@ecoma-io/archkeep 0.16.1 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +1 -1
  2. package/cli.mjs +258 -20
  3. package/package.json +2 -2
  4. package/src/architecture-intent/judge.mjs +19 -6
  5. package/src/commands/adr.mjs +45 -4
  6. package/src/commands/change-intent.mjs +55 -8
  7. package/src/commands/change.mjs +332 -11
  8. package/src/commands/debt.mjs +26 -5
  9. package/src/commands/decisions.mjs +291 -0
  10. package/src/commands/delta-classify.mjs +257 -0
  11. package/src/commands/delta.mjs +269 -8
  12. package/src/commands/evolution.mjs +758 -5
  13. package/src/commands/explain.mjs +207 -1
  14. package/src/commands/history.mjs +81 -5
  15. package/src/commands/plan-context-command.mjs +163 -2
  16. package/src/commands/provenance-command.mjs +86 -17
  17. package/src/commands/provenance.mjs +60 -0
  18. package/src/commands/report.mjs +48 -1
  19. package/src/commands/trajectory.mjs +89 -3
  20. package/src/fixtures/evolution-lifecycle/workspace.mjs +242 -0
  21. package/src/governance/adr-registry.mjs +252 -15
  22. package/src/governance/debt-ledger.mjs +261 -19
  23. package/src/governance/decision-fitness.mjs +213 -0
  24. package/src/governance/decision-graph.mjs +483 -0
  25. package/src/governance/decision-lineage.mjs +250 -0
  26. package/src/governance/evolution-event.mjs +470 -0
  27. package/src/governance/evolution-store.mjs +362 -0
  28. package/src/governance/provenance-record.mjs +150 -0
  29. package/src/providers/native/model.mjs +18 -4
  30. package/src/report/adr-text.mjs +109 -4
  31. package/src/report/change-text.mjs +21 -3
  32. package/src/report/debt-text.mjs +42 -6
  33. package/src/report/decisions-text.mjs +164 -0
  34. package/src/report/delta-text.mjs +36 -1
  35. package/src/report/evolution-text.mjs +231 -2
  36. package/src/report/explain-text.mjs +122 -1
  37. package/src/report/history-text.mjs +9 -3
  38. package/src/report/plan-context-text.mjs +94 -0
  39. package/src/report/provenance-text.mjs +67 -1
  40. package/src/report/report-text.mjs +53 -18
  41. package/src/report/snapshot-text.mjs +35 -1
  42. package/src/report/trajectory-text.mjs +30 -1
@@ -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
@@ -140,6 +140,35 @@ export const INSUFFICIENT_HISTORY = "insufficient_history";
140
140
  * @property {number|null} persistent
141
141
  */
142
142
 
143
+ /**
144
+ * The trend-facts block: per-class counts and boundary-movement totals over
145
+ * the SAME comparable transitions the axes count — a pair whose fingerprint
146
+ * or provenance could not be compared is excluded exactly as it is excluded
147
+ * from `transitions.unchanged`, so the basis is one consistent subset. Each
148
+ * class counts once per transition carrying it (a transition may carry
149
+ * several). `violationsIntroduced`/`violationsResolved` count classes, never
150
+ * violation rows — and for snapshot-sourced transitions no finding evidence
151
+ * exists at all, so those totals are `0` with the `note` saying why: the
152
+ * absence of evidence, never a claim that none occurred (the silent
153
+ * direction).
154
+ *
155
+ * `null` when no trend can be derived: fewer than two observations, or a
156
+ * history where every transition was incomparable. Never a zero-filled block.
157
+ *
158
+ * @typedef {object} TrajectoryTrends
159
+ * @property {{CHANGE: number, DRIFT: number, VIOLATION: number, REPAIR: number,
160
+ * DECISION_CHANGE: number}} byClass One count per evolution class, over
161
+ * comparable transitions whose `classifications` carry it.
162
+ * @property {number} violationsIntroduced
163
+ * @property {number} violationsResolved
164
+ * @property {number} comparableTransitions The number of transitions the
165
+ * counts were derived over — `transitions.count − transitions.incomparable`.
166
+ * @property {"comparable transition classifications"} basis What the counts
167
+ * are a claim about, stated as a value.
168
+ * @property {string} [note] A disclosure when the counts cannot speak about
169
+ * evidence the input never carried (violation/repair rows).
170
+ */
171
+
143
172
  /**
144
173
  * Aggregates the deterministic trajectory over an ordered snapshot set.
145
174
  * Pure: same bytes in, same object out. All keys are always present — shape
@@ -154,7 +183,8 @@ export const INSUFFICIENT_HISTORY = "insufficient_history";
154
183
  * transitions: {count: number, architecture: number, policy: number,
155
184
  * provider: number, codeDrift: number, incomparable: number, unchanged: number},
156
185
  * disclosures: {policyOneSided: number, provenanceOneSided: number, crossRepo: number},
157
- * projects: TrajectoryAxis, edges: TrajectoryAxis}}
186
+ * projects: TrajectoryAxis, edges: TrajectoryAxis,
187
+ * trends: TrajectoryTrends|null}}
158
188
  */
159
189
  export function computeTrajectory(files) {
160
190
  const n = files.length;
@@ -182,6 +212,10 @@ export function computeTrajectory(files) {
182
212
  };
183
213
  const disclosures = { policyOneSided: 0, provenanceOneSided: 0, crossRepo: 0 };
184
214
 
215
+ /** @type {{CHANGE: number, DRIFT: number, VIOLATION: number, REPAIR: number,
216
+ DECISION_CHANGE: number}} */
217
+ const classCounts = { CHANGE: 0, DRIFT: 0, VIOLATION: 0, REPAIR: 0, DECISION_CHANGE: 0 };
218
+
185
219
  // Cumulative transition events, accumulated while classifying. Kept as
186
220
  // scalars rather than deferred to a second pass — one walk over the pairs.
187
221
  let addedProjectEvents = 0;
@@ -231,15 +265,34 @@ export function computeTrajectory(files) {
231
265
  if (record.policyChanged === true) transitions.policy += 1;
232
266
  if (record.providerChanged) transitions.provider += 1;
233
267
  if (record.codeDrift) transitions.codeDrift += 1;
234
-
235
268
  // The asymmetric-evidence cases, counted from `meta` itself — never
236
269
  // parsed back out of the record's prose notes.
237
270
  if (meta.policyOneSided) disclosures.policyOneSided += 1;
238
271
  if (meta.provenanceOneSided) disclosures.provenanceOneSided += 1;
239
272
  if (meta.crossRepo) disclosures.crossRepo += 1;
240
- const incomparable = meta.policyOneSided || meta.provenanceOneSided;
273
+ // The advanced-both-absent pair is the same incomparable case as
274
+ // one-sided: neither side records the boundary law while the commit
275
+ // advanced, so the transition carried real code motion the tool cannot
276
+ // classify (F-HIST-1). `provenanceChanged === true` requires both sides
277
+ // to record provenance AND the commits to differ, so neither-side-
278
+ // absent histories (both commits `null`) stay comparable-unchanged.
279
+ const incomparable =
280
+ meta.policyOneSided ||
281
+ meta.provenanceOneSided ||
282
+ (meta.policyChanged === null && meta.provenanceChanged === true);
241
283
  if (incomparable) transitions.incomparable += 1;
242
284
 
285
+ // The trend classes are counted over the SAME comparable subset the
286
+ // axes already disclose: a pair excluded from `unchanged` because its
287
+ // fingerprint or provenance could not be compared is excluded from the
288
+ // trend facts the same way — never silently folded in. Each class is a
289
+ // fact per transition; a transition carrying several counts in each.
290
+ if (!incomparable) {
291
+ for (const cls of record.classifications) {
292
+ classCounts[cls] += 1;
293
+ }
294
+ }
295
+
243
296
  // `unchanged` is deliberately STRICTER than the label `history`'s text
244
297
  // renderer prints for the same transition: an aggregate has no
245
298
  // per-transition note to disclose "one side carried no fingerprint",
@@ -328,6 +381,38 @@ export function computeTrajectory(files) {
328
381
  resolved: available ? edgeMovement.resolved : null,
329
382
  persistent: available ? persistentCount(edgePresence) : null,
330
383
  };
384
+ // The trend-facts block. It exists only when comparable evidence exists:
385
+ // fewer than two observations, or a history whose every transition was
386
+ // incomparable, yields `null` — never a zero-filled block (a zero would
387
+ // claim "no change" over evidence this run could not compare). The counts
388
+ // ride the same classification the axes classify with; both come from
389
+ // `classifyTransition`'s one walk, so the trends are idempotent: same
390
+ // snapshots, same object, every run.
391
+ //
392
+ // The violation/repair totals are computed from the classification inputs,
393
+ // and snapshot-sourced transitions carry none: stored snapshots hold the
394
+ // graph and the policy fingerprint, not findings. Zero there is the absence
395
+ // of evidence, never a claim that none occurred — the note says so, on
396
+ // every non-null trends block.
397
+ /** @type {TrajectoryTrends|null} */
398
+ let trends = null;
399
+ if (available) {
400
+ const comparableTransitions = transitions.count - transitions.incomparable;
401
+ if (comparableTransitions > 0) {
402
+ trends = {
403
+ byClass: classCounts,
404
+ violationsIntroduced: 0,
405
+ violationsResolved: 0,
406
+ comparableTransitions,
407
+ basis: "comparable transition classifications",
408
+ note:
409
+ "transition classifications carry no violation or repair evidence — stored " +
410
+ "snapshots hold the graph and the policy fingerprint, not findings, so " +
411
+ "VIOLATION/REPAIR and the violations-* totals are 0 because no finding could " +
412
+ "be classified, never because none occurred",
413
+ };
414
+ }
415
+ }
331
416
 
332
417
  return {
333
418
  observations: {
@@ -344,6 +429,7 @@ export function computeTrajectory(files) {
344
429
  disclosures,
345
430
  projects,
346
431
  edges,
432
+ trends,
347
433
  };
348
434
  }
349
435