@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.
@@ -53,6 +53,24 @@
53
53
  * passing is ok (exit 0). The workspace-law axis rides the envelope as
54
54
  * evidence only.
55
55
  *
56
+ * ## Wave 3: classification and events (additive)
57
+ *
58
+ * Every result carries the evolution classification (`classifications`,
59
+ * `affected`, `debt` — added fields; nothing existing moved) computed by the
60
+ * one home the predicates live in, `../governance/evolution-event.mjs`'s
61
+ * `classifyEvolution`, from the reconciliation output this command already
62
+ * produced. With `--event-out <dir>` (owned by `../../cli.mjs`), the same
63
+ * facts are persisted as the canonical reconcile EvolutionEvent
64
+ * (`kind: "reconcile"`, `source: "change"`, declaration digest over the
65
+ * intent's declarative rows, disposition by §5's mapping: matched with every
66
+ * constraint passing ⇒ accepted, undeclared/unfulfilled or a failed
67
+ * constraint ⇒ rejected, unproven or an undeterminable constraint ⇒
68
+ * no-verdict). A write failure throws → exit 3; the flag absent means no
69
+ * file is written and the run is byte-identical to a pre-wave-3 one. The
70
+ * breadth guard that refuses an empty-rows intent whose summary asserts a
71
+ * change lives in `./change-intent.mjs`'s validation (`parseChangeIntent`),
72
+ * the same loud lane as every other malformed declaration.
73
+ *
56
74
  * Refusals (each a throw → exit 3 upstream): a manifest that fails shape or
57
75
  * reference validation, an unreadable/malformed/foreign-schema baseline,
58
76
  * incomplete baseline coverage, a provider mismatch, incomplete head
@@ -85,6 +103,18 @@ import { formatChangeReport } from "../report/change-text.mjs";
85
103
  import { evaluateRun } from "../rules/index.mjs";
86
104
  import { compareSnapshotMetadata } from "./snapshot-meta.mjs";
87
105
  import { resolveProvenance } from "./provenance.mjs";
106
+ import { referenceTime } from "../governance/clock.mjs";
107
+ import {
108
+ classifyEvolution,
109
+ declarationDigest,
110
+ eventDedupeKey,
111
+ eventId,
112
+ EVOLUTION_EVENT_SCHEMA_VERSION,
113
+ } from "../governance/evolution-event.mjs";
114
+ import { writeEvent } from "../governance/evolution-store.mjs";
115
+ import { judgeIntent } from "../architecture-intent/judge.mjs";
116
+ import { INTENT_FILE, loadIntent } from "../architecture-intent/model.mjs";
117
+ import { debtChangeDiff } from "../governance/debt-ledger.mjs";
88
118
 
89
119
  /**
90
120
  * One expected-fact row as the report and JSON carry it. Kept in one builder
@@ -111,6 +141,81 @@ function cmpFacts(a, b) {
111
141
  );
112
142
  }
113
143
 
144
+ /**
145
+ * The identity string a delta-classified violation entry carries into the
146
+ * event's `findings` — the same identity fields the delta classification
147
+ * emits (`messageId`, `sourceProject`, `target`), serialized deterministically
148
+ * so the ref is stable across runs over the same transition.
149
+ *
150
+ * @param {{messageId: string, sourceProject: string|null, target: string}} entry
151
+ * @returns {string}
152
+ */
153
+ function violationFindingId(entry) {
154
+ return `${entry.messageId}:${entry.sourceProject ?? "-"}:${entry.target}`;
155
+ }
156
+
157
+ /**
158
+ * The identity string an observed edge carries into the event's `observed`
159
+ * and `affected` — `(source, target, type)`, the triple `./diff.mjs`'s
160
+ * `edgeIdentityKey` owns, spelled for a human reader (`>` separator, optional
161
+ * type suffix). Two spellings of one triple never diverge because the triple
162
+ * itself is the input.
163
+ *
164
+ * @param {{source: string, target: string, type?: string}} edge
165
+ * @returns {string}
166
+ */
167
+ function edgeIdentityString(edge) {
168
+ return `${edge.source}>${edge.target}${edge.type === undefined || edge.type === "" ? "" : `:${edge.type}`}`;
169
+ }
170
+
171
+ /**
172
+ * The structural-diff facts in the event's `observed` shape (design §1),
173
+ * mapped from `computeDiff`'s output and the metadata comparison — the same
174
+ * lists the reconciliation already consumed, never recomputed.
175
+ *
176
+ * @param {{addedProjects: object[], removedProjects: object[],
177
+ * changedProjects: object[], addedEdges: object[], removedEdges: object[]}} structural
178
+ * @param {{policyChanged: boolean|null, policyOneSided: boolean,
179
+ * provenanceChanged: boolean|null}} meta From `compareSnapshotMetadata`.
180
+ * @returns {{architectureChanged: boolean, projects: {added: string[],
181
+ * removed: string[], changed: string[]}, edges: {added: string[],
182
+ * removed: string[]}, policyChanged: boolean|null, policyOneSided: boolean,
183
+ * provenanceChanged: boolean|null, providerChanged: boolean}}
184
+ */
185
+ function observedFrom(structural, meta) {
186
+ const projects = {
187
+ added: structural.addedProjects.map((project) => project.name),
188
+ removed: structural.removedProjects.map((project) => project.name),
189
+ changed: structural.changedProjects.map((project) => project.name),
190
+ };
191
+ const edges = {
192
+ added: structural.addedEdges.map(edgeIdentityString),
193
+ removed: structural.removedEdges.map(edgeIdentityString),
194
+ };
195
+ return {
196
+ architectureChanged:
197
+ projects.added.length +
198
+ projects.removed.length +
199
+ projects.changed.length +
200
+ edges.added.length +
201
+ edges.removed.length >
202
+ 0,
203
+ projects,
204
+ edges,
205
+ // `null` is "could not be compared": exactly one side records the law
206
+ // (`policyOneSided`) or neither does — passed through so classifyEvolution
207
+ // discloses rather than reading it as "the same", and the advanced
208
+ // both-absent pair never classifies as unchanged
209
+ // (`../governance/evolution-event.mjs`).
210
+ policyChanged: meta.policyChanged,
211
+ policyOneSided: meta.policyOneSided,
212
+ provenanceChanged: meta.provenanceChanged,
213
+ // The provider mismatch refuses before reconciliation ever runs, so a
214
+ // reconciliable pair is by construction same-provider.
215
+ providerChanged: false,
216
+ };
217
+ }
218
+
114
219
  /**
115
220
  * Reconciles a normalized change-intent contract against the structural diff
116
221
  * of the two graphs. Pure — the heart of the command, and the function a
@@ -244,6 +349,37 @@ export function reconciliationVerdict(lists, unprovenReasons) {
244
349
  return "matched";
245
350
  }
246
351
 
352
+ /**
353
+ * The reconcile DISPOSITION mapping (wave 3, design §5) — the verdict axis
354
+ * first, then the declared constraints' verdicts. Pure and exported so the
355
+ * mapping is a fact a test can pin:
356
+ *
357
+ * | verdict | disposition |
358
+ * |---|---|
359
+ * | `matched`, every declared constraint passing | `accepted` |
360
+ * | `matched`, a declared constraint failing | `rejected` — the change's own
361
+ * promise was broken, the same lane design §2 gives a constraint `fail` |
362
+ * | `undeclared` / `unfulfilled` | `rejected` |
363
+ * | `unproven`, or any declared constraint `unknown` | `no-verdict` — an
364
+ * undetermined axis never reads as an acceptance |
365
+ *
366
+ * `no-verdict` is never a degraded `accepted`: the mapping refuses to
367
+ * fabricate either direction when the run could not determine it.
368
+ *
369
+ * @param {"matched"|"undeclared"|"unfulfilled"|"unproven"} verdict
370
+ * @param {{verdict: string}[]} [constraints] The judged constraint rows from
371
+ * this run — empty when none were declared or when the base identity was
372
+ * unproven (constraints are left unevaluated then).
373
+ * @returns {"accepted"|"rejected"|"no-verdict"}
374
+ */
375
+ export function reconcileDisposition(verdict, constraints = []) {
376
+ if (verdict === "unproven") return "no-verdict";
377
+ if (verdict === "undeclared" || verdict === "unfulfilled") return "rejected";
378
+ if (constraints.some((row) => row.verdict === "unknown")) return "no-verdict";
379
+ if (constraints.some((row) => row.verdict === "fail")) return "rejected";
380
+ return "accepted";
381
+ }
382
+
247
383
  /**
248
384
  * Judges the constraints the contract declares, through the shared engine —
249
385
  * both sides re-judged under the CURRENT law and one shared instant, exactly
@@ -335,14 +471,24 @@ function judgeDeclaredConstraints(intent, io) {
335
471
  * @param {string} intentPath Absolute path to the change-intent manifest.
336
472
  * @param {object} commandContext From `resolveCommandContext`.
337
473
  * @param {{config?: object|null, readBaseline?: (path: string) => object,
338
- * readIntent?: (path: string) => Promise<object>, now?: string}} [io]
474
+ * readIntent?: (path: string) => Promise<object>, now?: string,
475
+ * loadIntentOverride?: (root: string, opts?: object) => Promise<object|undefined>,
476
+ * eventOut?: string, writeEvent?: (dir: string, event: object,
477
+ * io?: object) => {id: string, duplicate: boolean}}} [io]
339
478
  * The resolved boundary config (required — constraints are judged under it
340
479
  * and the fingerprint identifies the law), injectable readers so tests drive
341
- * the command without disk, and the shared reference instant. Both readers
342
- * return VALIDATED domain objects `readEvidenceSnapshot`'s parse and
343
- * `parseChangeIntent`'s normalized contract respectively the same seam
344
- * contract `deltaCommand`'s `readBaseline` states; an injector handing back
345
- * raw file contents skips the grammar this command trusts.
480
+ * the command without disk, the shared reference instant, and the wave-3
481
+ * event surface: `eventOut` names the directory the reconcile EvolutionEvent
482
+ * is written to (absent no event file, byte-identical run), and
483
+ * `writeEvent` is the store seam defaulting to the canonical append-only
484
+ * store. Both readers return VALIDATED domain objects
485
+ * `readEvidenceSnapshot`'s parse and `parseChangeIntent`'s normalized
486
+ * contract respectively — the same seam contract `deltaCommand`'s
487
+ * `readBaseline` states; an injector handing back raw file contents skips
488
+ * the grammar this command trusts. `loadIntentOverride` is the same
489
+ * architecture-intent seam `drift` uses (defaults to `loadIntent`); the
490
+ * change event's `debt` diff judges the intent over this run's base and
491
+ * head graphs and would be untestable without it.
346
492
  * @returns {Promise<{status: "ok"|"findings"|"no-verdict",
347
493
  * changeIntent: object, coverage: object, report: {text: string, json: string}}>}
348
494
  * @throws {Error} on every refusal the module header lists.
@@ -351,9 +497,27 @@ export async function changeCommand(
351
497
  baselinePath,
352
498
  intentPath,
353
499
  commandContext,
354
- { config, readBaseline = readEvidenceSnapshot, readIntent, now } = {},
500
+ {
501
+ config,
502
+ readBaseline = readEvidenceSnapshot,
503
+ readIntent,
504
+ now,
505
+ // Wave 3 (design §4): the directory the reconcile EvolutionEvent is
506
+ // written to. Absent ⇒ no event file is written and the run is
507
+ // byte-identical to a pre-wave-3 run; the classification still rides the
508
+ // envelope result either way.
509
+ eventOut,
510
+ // The architecture-intent reader seam (design §8): the change event's
511
+ // `debt` diff judges the SAME intent file `drift`/`debt` judge over this
512
+ // run's base and head graphs. Injectable so tests drive it without disk;
513
+ // defaults to the canonical loader. Absent intent ⇒ no ids are emitted.
514
+ loadIntentOverride,
515
+ // The store seam, injectable so tests drive the write without disk and
516
+ // embedders can route it; defaults to the canonical append-only store.
517
+ writeEvent: writeEventSeam = writeEvent,
518
+ } = {},
355
519
  ) {
356
- const { root, provider, marker, graph, analysis } = commandContext;
520
+ const { root, provider, marker, graph, analysis, tracked } = commandContext;
357
521
 
358
522
  // The one required member of `io`: constraints are judged under the current
359
523
  // law and the envelope records which law that was, so an undefined config
@@ -452,12 +616,23 @@ export async function changeCommand(
452
616
  /** @type {object[]} */
453
617
  let constraints = [];
454
618
  let liveViolations = null;
619
+ // The classification is a wave-3 event signal too, so it is held at
620
+ // function scope: `null` when the base identity is unproven (nothing was
621
+ // evaluated, and the event must not fabricate violation facts).
622
+ /** @type {ReturnType<typeof classifyDelta>|null} */
623
+ let classification = null;
624
+ // The base engine graph is hoisted (not block-scoped) because the change
625
+ // event's `debt` diff below re-judges the architecture intent over BOTH
626
+ // sides. It exists only when the base identity is provable; `null` here is
627
+ // the F-CHG-1 signal that no debt diff is trustworthy and none will be
628
+ // emitted (an unproven base must never fabricate ledger ids).
629
+ const baseEngineGraph =
630
+ unprovenReasons.length === 0 ? evidenceGraphToProjectGraph(baseline.graph) : null;
455
631
  if (unprovenReasons.length === 0) {
456
632
  const configWithNow = now === undefined ? config : { ...config, now };
457
- const baseEngineGraph = evidenceGraphToProjectGraph(baseline.graph);
458
633
  const baseRaw = evaluateRun(baseline.records, baseEngineGraph, configWithNow).rawViolations;
459
634
  const headEval = evaluateRun(analysis.imports, graph, configWithNow);
460
- const classification = classifyDelta({
635
+ classification = classifyDelta({
461
636
  baseViolations: baseRaw,
462
637
  headViolations: headEval.rawViolations,
463
638
  baseRecords: baseline.records,
@@ -547,6 +722,140 @@ export async function changeCommand(
547
722
  notes,
548
723
  };
549
724
 
725
+ // Wave 3 (design §1, §2, §5): the evolution classification and the reconcile
726
+ // event. The classification is ALWAYS computed — it rides the envelope
727
+ // result in memory even when no event is written (design §4) — and the
728
+ // event is persisted only when `eventOut` names a directory. The signals
729
+ // are mapped from the reconciliation output already computed above; nothing
730
+ // here re-derives what `diff`, `classifyDelta` or `classifyEvolution`
731
+ // answered (`../governance/evolution-event.mjs` is the predicates' one
732
+ // home). When the base identity is unproven the constraints were left
733
+ // unevaluated, so no violation signal exists — the classification then
734
+ // answers `no-verdict` from the declared-intent row alone, never a
735
+ // fabricated acceptance.
736
+ const observed = observedFrom(structural, meta);
737
+ /** @type {{id: string, waived: boolean}[]} */
738
+ const introducedViolations = (classification?.violations.introduced ?? []).map((entry) => ({
739
+ id: violationFindingId(entry),
740
+ waived: entry.waived === true,
741
+ }));
742
+ const resolvedViolations = (classification?.violations.resolved ?? []).map(violationFindingId);
743
+ const unknownViolations = (classification?.violations.unknown ?? []).map((entry) => ({
744
+ id: `unidentified:${entry.reason}`,
745
+ reason: entry.reason,
746
+ }));
747
+ const evolution = classifyEvolution({
748
+ observed,
749
+ ...(classification === null
750
+ ? {}
751
+ : {
752
+ violations: {
753
+ introduced: introducedViolations,
754
+ resolved: resolvedViolations,
755
+ unknown: unknownViolations,
756
+ },
757
+ }),
758
+ declaredConstraints: constraints.map((row) => ({ id: row.name, verdict: row.verdict })),
759
+ declaredIntentRows: [{ id: "intent", verdict }],
760
+ });
761
+
762
+ // Debt impact: the architecture-lifecycle debt THIS change opened or closed,
763
+ // as stable ids the W5 ledger derives for the same intent facts (design §8)
764
+ // — the SAME fact → the SAME id, so an event's `debt.introduced` links the
765
+ // ledger's `introducedBy`. The ids come from a `judgeIntent` diff over the
766
+ // run's base and head graphs under ONE current intent: a drift finding (or
767
+ // unbuilt aspirational gap) present at head but not base is introduced; one
768
+ // gone at head is resolved. Constraint-fail rows carry no source/target/rule
769
+ // — because they are not drift, they stay in `affected.constraints` and
770
+ // `findings`, never in `debt`. An unproven base (F-CHG-1) or an unjudgeable
771
+ // intent is a no-verdict: no ids are emitted, an in-band note says so, and a
772
+ // change run never fabricates ledger ids over evidence it cannot vouch for.
773
+ /** @type {{introduced: string[], resolved: string[], note?: string}} */
774
+ let debt;
775
+ if (baseEngineGraph === null) {
776
+ debt = {
777
+ introduced: [],
778
+ resolved: [],
779
+ note: "base identity unproven — no architecture debt diff can be trusted",
780
+ };
781
+ } else {
782
+ try {
783
+ const archIntent = await (loadIntentOverride ?? loadIntent)(root, { tracked });
784
+ if (archIntent === undefined || archIntent === null) {
785
+ debt = {
786
+ introduced: [],
787
+ resolved: [],
788
+ note: `no '${INTENT_FILE}' tracked — the change event carries no architecture debt ids`,
789
+ };
790
+ } else {
791
+ const baseVerdict = judgeIntent(archIntent, baseEngineGraph);
792
+ const headVerdict = judgeIntent(archIntent, graph);
793
+ debt = debtChangeDiff(baseVerdict, headVerdict);
794
+ }
795
+ } catch (error) {
796
+ debt = {
797
+ introduced: [],
798
+ resolved: [],
799
+ note: `architecture intent could not be judged — no debt ids emitted (${error.message})`,
800
+ };
801
+ }
802
+ }
803
+
804
+ const baseCommit = baseline.provenance?.commit;
805
+ const headCommit = headProvenance?.commit;
806
+ /** @type {object} */
807
+ const event = {
808
+ schemaVersion: EVOLUTION_EVENT_SCHEMA_VERSION,
809
+ kind: "reconcile",
810
+ source: "change",
811
+ base: {
812
+ ...(typeof baseCommit === "string" ? { revision: baseCommit } : {}),
813
+ // The caller's own evidence ref — the baseline file this run consumed,
814
+ // spelled as the run received it (the same convention `declaration.file`
815
+ // uses for the intent path).
816
+ evidence: baselinePath,
817
+ },
818
+ head: {
819
+ ...(typeof headCommit === "string" ? { revision: headCommit } : {}),
820
+ },
821
+ declaration: { file: intentPath, digest: declarationDigest(intent) },
822
+ observed,
823
+ affected: evolution.affected,
824
+ findings: {
825
+ introduced: introducedViolations.map((entry) => entry.id),
826
+ resolved: resolvedViolations,
827
+ unknown: unknownViolations.map((entry) => entry.id),
828
+ },
829
+ // The verdictDeltas are the DECLARED constraints judged over the delta —
830
+ // a change run has no separate base-verdict pass, so each row states the
831
+ // delta verdict itself rather than a pair that does not exist.
832
+ fitness: {
833
+ verdictDeltas: constraints.map((row) => ({ id: row.name, verdict: row.verdict })),
834
+ },
835
+ debt,
836
+ classifications: evolution.classifications,
837
+ disposition: evolution.disposition,
838
+ notes: evolution.notes,
839
+ provenance: typeof headCommit === "string" ? [{ kind: "git-commit", ref: headCommit }] : [],
840
+ // The governance clock — the same injected `now` seam the rest of the run
841
+ // uses, the wall clock only when nothing was injected. It is EXCLUDED
842
+ // from the identity by `evolution-event.mjs` by construction.
843
+ recordedAt: { by: "change", tool: "archkeep:v1", on: now ?? referenceTime() },
844
+ };
845
+ event.dedupeKey = eventDedupeKey(event);
846
+ event.id = eventId(event);
847
+
848
+ /** @type {{dir: string, id: string, duplicate: boolean}|null} */
849
+ let eventWritten = null;
850
+ if (eventOut !== undefined && eventOut !== null && eventOut !== "") {
851
+ // The store's io.root is the workspace root: the write must be provable
852
+ // to stay inside the workspace (the same containment `--output` obeys).
853
+ // A write failure throws — exit 3 upstream, the could-not-look lane —
854
+ // never a silent "event recorded" that wrote nothing.
855
+ const write = writeEventSeam(eventOut, event, { root });
856
+ eventWritten = { dir: eventOut, ...write };
857
+ }
858
+
550
859
  const result = {
551
860
  intent: {
552
861
  file: intentPath,
@@ -588,6 +897,12 @@ export async function changeCommand(
588
897
  changedSinceBase: meta.policyChanged === true,
589
898
  liveViolations,
590
899
  },
900
+ // Wave 3 additive result fields (design §5): the evolution classification
901
+ // and the debt impact. Present on every run, `--event-out` or not — the
902
+ // event's classification rides the envelope in memory (design §4).
903
+ classifications: evolution.classifications,
904
+ affected: evolution.affected,
905
+ debt,
591
906
  };
592
907
 
593
908
  const envelope = jsonEnvelope({
@@ -605,7 +920,13 @@ export async function changeCommand(
605
920
  changeIntent: result,
606
921
  coverage,
607
922
  report: {
608
- text: formatChangeReport({ change: result, coverage }),
923
+ text: formatChangeReport({
924
+ change: result,
925
+ coverage,
926
+ // Present only when an event was written — the text report stays
927
+ // byte-identical when `--event-out` is absent.
928
+ ...(eventWritten === null ? {} : { eventWritten }),
929
+ }),
609
930
  json: renderJson(envelope),
610
931
  },
611
932
  };
@@ -49,6 +49,7 @@ import { isWholeFileFailure } from "../analysis/source-util.mjs";
49
49
  import { judgeIntent } from "../architecture-intent/judge.mjs";
50
50
  import { INTENT_FILE, loadIntent } from "../architecture-intent/model.mjs";
51
51
  import { computeDebtLedger } from "../governance/debt-ledger.mjs";
52
+ import { readEvents } from "../governance/evolution-store.mjs";
52
53
  import { formatDebtReport } from "../report/debt-text.mjs";
53
54
  import { jsonEnvelope, renderJson } from "../report/json.mjs";
54
55
  import { resolveProvenance } from "./provenance.mjs";
@@ -68,9 +69,12 @@ import { readSnapshots } from "./history.mjs";
68
69
  * @param {object} commandContext From `resolveCommandContext`.
69
70
  * @param {{config?: {depConstraints: object[], options: object,
70
71
  * suppressions: object[]}|null, referenceTime?: number|string,
71
- * io?: {readSnapshots?: Function, loadIntentOverride?: Function,
72
- * resolveProvenance?: Function}}} [options] Injectable seams for tests,
73
- * mirroring the `history`/`drift` pattern.
72
+ * events?: string|null, io?: {readSnapshots?: Function,
73
+ * loadIntentOverride?: Function, resolveProvenance?: Function,
74
+ * readEvents?: Function}}} [options] Injectable seams for tests, mirroring
75
+ * the `history`/`drift` pattern. `events` is the event-store directory the
76
+ * `--events <dir>` CLI flag resolves; absent ⇒ no lifecycle refs (the
77
+ * ledger says so in `lifecycle.note`), never guessed.
74
78
  * @returns {Promise<{status: "ok", ledger: object, coverage: object,
75
79
  * report: {text: string, json: string}}>}
76
80
  * @throws {Error} on every condition the header lists, all exit-3 class.
@@ -140,14 +144,26 @@ export async function debtCommand(dir, commandContext, options = {}) {
140
144
  );
141
145
  }
142
146
 
147
+ // The optional event linkage (design §4): `--events <dir>` reads the
148
+ // evolution store so active debt can carry `introducedBy` and closed debt
149
+ // can appear on the `resolved` list. Absent ⇒ nothing is loaded and the
150
+ // ledger records no refs ("no event store linked" note); refs are never
151
+ // guessed.
152
+ const loadStoreEvents = io.readEvents ?? readEvents;
153
+ const events = options.events ? loadStoreEvents(options.events) : null;
154
+
143
155
  const ledger = computeDebtLedger(
144
156
  {
145
157
  suppressions: config.suppressions,
146
158
  intentNotes: verdict.notes,
159
+ gaps: verdict.gaps,
147
160
  findings: verdict.findings,
148
161
  },
149
162
  read,
150
- { referenceTime: options.referenceTime },
163
+ {
164
+ referenceTime: options.referenceTime,
165
+ events,
166
+ },
151
167
  );
152
168
 
153
169
  const observed = buildObserved(commandContext);
@@ -168,7 +184,9 @@ export async function debtCommand(dir, commandContext, options = {}) {
168
184
  // so a consumer diffing or hashing two envelopes to detect real drift
169
185
  // knows to exclude it rather than read clock drift as architectural
170
186
  // change; every other field is deterministic given the same law, history
171
- // directory and tree.
187
+ // directory and tree. The lifecycle note (when the store is not linked)
188
+ // states the same posture for refs: they are absent, disclosed, never
189
+ // fabricated.
172
190
  notes: [
173
191
  `ledger ages are snapshot-relative; ${ledger.entries.length} entr` +
174
192
  `${ledger.entries.length === 1 ? "y" : "ies"} derived across ${read.files.length} snapshot` +
@@ -177,6 +195,7 @@ export async function debtCommand(dir, commandContext, options = {}) {
177
195
  "workspace — it is expected to differ between two runs of an unchanged tree and " +
178
196
  "should be excluded from any diff or hash meant to detect real change. Every other " +
179
197
  "field here is deterministic given the same law, history directory and tree.",
198
+ ...(ledger.lifecycle.note ? [ledger.lifecycle.note] : []),
180
199
  ],
181
200
  };
182
201
 
@@ -192,9 +211,11 @@ export async function debtCommand(dir, commandContext, options = {}) {
192
211
  agings: ledger.agings,
193
212
  sampleTime: ledger.sampleTime,
194
213
  entries: ledger.entries,
214
+ resolved: ledger.resolved,
195
215
  total: ledger.total,
196
216
  byKind: ledger.byKind,
197
217
  bySeverity: ledger.bySeverity,
218
+ lifecycle: ledger.lifecycle,
198
219
  };
199
220
 
200
221
  const envelope = jsonEnvelope({