@intentius/chant 0.50.0 → 0.51.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 (63) hide show
  1. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  2. package/dist/cli/handlers/op-progress.d.ts +57 -0
  3. package/dist/cli/handlers/op-progress.d.ts.map +1 -0
  4. package/dist/cli/handlers/run-client.d.ts +21 -1
  5. package/dist/cli/handlers/run-client.d.ts.map +1 -1
  6. package/dist/cli/handlers/run-report.d.ts.map +1 -1
  7. package/dist/cli/handlers/run.d.ts.map +1 -1
  8. package/dist/cli/handlers/search.d.ts +22 -0
  9. package/dist/cli/handlers/search.d.ts.map +1 -1
  10. package/dist/cli/main.d.ts.map +1 -1
  11. package/dist/cli/mcp/op-tools.d.ts.map +1 -1
  12. package/dist/cli/mcp/resource-handlers.d.ts.map +1 -1
  13. package/dist/cli/registry.d.ts +33 -1
  14. package/dist/cli/registry.d.ts.map +1 -1
  15. package/dist/components/run-progress.d.ts +7 -5
  16. package/dist/components/run-progress.d.ts.map +1 -1
  17. package/dist/lexicon.d.ts +41 -0
  18. package/dist/lexicon.d.ts.map +1 -1
  19. package/dist/lifecycle/assert-live.d.ts +77 -0
  20. package/dist/lifecycle/assert-live.d.ts.map +1 -0
  21. package/dist/lifecycle/change-set.d.ts +17 -0
  22. package/dist/lifecycle/change-set.d.ts.map +1 -1
  23. package/dist/lifecycle/disruption.d.ts +96 -0
  24. package/dist/lifecycle/disruption.d.ts.map +1 -0
  25. package/dist/lifecycle/index.d.ts +2 -0
  26. package/dist/lifecycle/index.d.ts.map +1 -1
  27. package/dist/lifecycle/replay.d.ts +2 -0
  28. package/dist/lifecycle/replay.d.ts.map +1 -1
  29. package/dist/op/local-executor.d.ts +7 -1
  30. package/dist/op/local-executor.d.ts.map +1 -1
  31. package/dist/testing.d.ts +23 -2
  32. package/dist/testing.d.ts.map +1 -1
  33. package/package.json +1 -1
  34. package/src/cli/handlers/lifecycle.test.ts +90 -0
  35. package/src/cli/handlers/lifecycle.ts +18 -1
  36. package/src/cli/handlers/op-progress.test.ts +202 -0
  37. package/src/cli/handlers/op-progress.ts +192 -0
  38. package/src/cli/handlers/run-client.test.ts +82 -0
  39. package/src/cli/handlers/run-client.ts +85 -2
  40. package/src/cli/handlers/run-report.test.ts +62 -0
  41. package/src/cli/handlers/run-report.ts +20 -58
  42. package/src/cli/handlers/run.test.ts +144 -0
  43. package/src/cli/handlers/run.ts +40 -18
  44. package/src/cli/handlers/search-drift.test.ts +263 -0
  45. package/src/cli/handlers/search.ts +150 -1
  46. package/src/cli/main.ts +9 -0
  47. package/src/cli/mcp/op-tools.ts +17 -6
  48. package/src/cli/mcp/resource-handlers.ts +13 -5
  49. package/src/cli/registry.ts +33 -1
  50. package/src/components/run-progress.ts +9 -5
  51. package/src/lexicon.ts +51 -0
  52. package/src/lifecycle/assert-live.test.ts +125 -0
  53. package/src/lifecycle/assert-live.ts +154 -0
  54. package/src/lifecycle/change-set.ts +35 -3
  55. package/src/lifecycle/disruption.test.ts +186 -0
  56. package/src/lifecycle/disruption.ts +224 -0
  57. package/src/lifecycle/index.ts +2 -0
  58. package/src/lifecycle/replay.test.ts +25 -0
  59. package/src/lifecycle/replay.ts +11 -3
  60. package/src/op/local-executor.ts +7 -1
  61. package/src/op/local-output.ts +1 -1
  62. package/src/testing.test.ts +89 -2
  63. package/src/testing.ts +63 -3
@@ -0,0 +1,154 @@
1
+ /**
2
+ * assertLive (#1857) — the read half of the test harness (#1224):
3
+ * observation-backed assertions against a live deploy, for exactly one
4
+ * declared entity at a time. Same primitive teardown.ts's fallback path
5
+ * uses — `describeResources` — turned into a pass/throw instead of a
6
+ * would-delete set.
7
+ *
8
+ * The observation contract (#1089) draws a hard line between OBSERVED-ABSENT
9
+ * and NOT-OBSERVED: a declared entity the read could not cover is never the
10
+ * same as one confirmed missing. `assertLiveEntity` preserves that line by
11
+ * construction — NOT-OBSERVED throws {@link UnobservedAssertionError}, a type
12
+ * distinct from the {@link LiveAssertionError} an observed-absent, foreign,
13
+ * or status-mismatched verdict throws, so a caller can tell "could not tell"
14
+ * from "confirmed wrong" without parsing a message.
15
+ *
16
+ * Marker verification is best-effort by the same logic {@link
17
+ * ResourceMetadata.marker}'s own contract states: a lexicon with no marker
18
+ * channel on this read path (aws's thin `describeResources`, `ownership:
19
+ * "unknown"`) reports no marker at all, which is not the same claim as
20
+ * "foreign". Enforcing a match whenever the channel exists — a present
21
+ * mismatch, or `ownership: "foreign"` with no marker to show — catches the
22
+ * case the harness cares about (a same-named leftover from another env);
23
+ * an absent channel is passed through unverified rather than making every
24
+ * lexicon without one unusable.
25
+ */
26
+
27
+ import { normalizeObservation, unobservedAll, unobservedReasonText, type UnobservedReason } from "../observation";
28
+ import type { ObservationLexicon, ResourceMetadata } from "../lexicon";
29
+ import type { OwnershipMarker } from "../ownership";
30
+
31
+ /**
32
+ * Thrown by {@link assertLiveEntity} for a confirmed failure: observed
33
+ * absent, a marker that names another stack/env, a resource confirmed
34
+ * foreign, or a status mismatch. Never thrown for NOT-OBSERVED — see {@link
35
+ * UnobservedAssertionError}.
36
+ */
37
+ export class LiveAssertionError extends Error {
38
+ constructor(message: string) {
39
+ super(message);
40
+ this.name = "LiveAssertionError";
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Thrown when the entity is NOT-OBSERVED (#1089) rather than confirmed
46
+ * present or absent. Kept as its own type, not a flag on {@link
47
+ * LiveAssertionError}: a suite (or a CI policy) that wants to fail loudly on
48
+ * "could not tell" but treat it differently from a confirmed miss can catch
49
+ * this one specifically.
50
+ */
51
+ export class UnobservedAssertionError extends Error {
52
+ constructor(
53
+ public readonly entity: string,
54
+ public readonly reason: UnobservedReason,
55
+ public readonly detail?: string,
56
+ ) {
57
+ super(
58
+ `assertLive("${entity}") is NOT-OBSERVED — ${unobservedReasonText(reason)}` +
59
+ `${detail ? `: ${detail}` : ""}. An entity chant could not read is never the same as one confirmed absent.`,
60
+ );
61
+ this.name = "UnobservedAssertionError";
62
+ }
63
+ }
64
+
65
+ export interface AssertLiveOptions {
66
+ /** Expected `ResourceMetadata.status`, where the lexicon reports one. Skipped when omitted. */
67
+ status?: string;
68
+ }
69
+
70
+ export interface AssertLiveEntityOptions extends AssertLiveOptions {
71
+ plugin: ObservationLexicon;
72
+ /** chant entity name — the key to assert on. */
73
+ name: string;
74
+ entityType: string;
75
+ props: Record<string, unknown>;
76
+ /** This lexicon's own built output for the deploy, or `""` when none was built. */
77
+ buildOutput: string;
78
+ environment: string;
79
+ /** This deploy's identity — the marker an observed resource is checked against. */
80
+ marker: OwnershipMarker;
81
+ }
82
+
83
+ /** True when `meta` names a different stack/env than `marker`, on whichever signal it carries. */
84
+ function isConfirmedForeign(meta: ResourceMetadata, marker: OwnershipMarker): boolean {
85
+ if (meta.marker) return meta.marker.stack !== marker.stack || meta.marker.env !== marker.env;
86
+ return meta.ownership === "foreign";
87
+ }
88
+
89
+ /**
90
+ * Assert one declared entity is live: observed present in `environment`, not
91
+ * a confirmed-foreign resource, and — when `status` is given — reporting
92
+ * that status. Resolves to the entity's {@link ResourceMetadata} on success.
93
+ *
94
+ * Throws {@link UnobservedAssertionError} for NOT-OBSERVED. Throws {@link
95
+ * LiveAssertionError} for observed-absent, a confirmed-foreign identity, or a
96
+ * status mismatch.
97
+ */
98
+ export async function assertLiveEntity(opts: AssertLiveEntityOptions): Promise<ResourceMetadata> {
99
+ const { plugin, name, entityType, props, buildOutput, environment, marker, status } = opts;
100
+
101
+ if (!plugin.describeResources) {
102
+ throw new UnobservedAssertionError(
103
+ name,
104
+ "unsupported-kind",
105
+ `the "${plugin.name}" lexicon implements no describeResources`,
106
+ );
107
+ }
108
+
109
+ let observed;
110
+ try {
111
+ observed = normalizeObservation(
112
+ await plugin.describeResources({
113
+ environment,
114
+ buildOutput,
115
+ entityNames: [name],
116
+ entities: new Map([[name, { entityType, props }]]),
117
+ }),
118
+ );
119
+ } catch (err) {
120
+ const detail = err instanceof Error ? err.message : String(err);
121
+ observed = {
122
+ resources: {},
123
+ unobserved: unobservedAll([name], "read-failed", detail, { [name]: entityType }),
124
+ queried: {},
125
+ notes: [],
126
+ };
127
+ }
128
+
129
+ const unobserved = observed.unobserved[name];
130
+ if (unobserved) throw new UnobservedAssertionError(name, unobserved.reason, unobserved.detail);
131
+
132
+ const meta = observed.resources[name];
133
+ if (!meta) {
134
+ throw new LiveAssertionError(
135
+ `assertLive("${name}"): observed absent — chant looked and "${environment}" reported no such resource.`,
136
+ );
137
+ }
138
+
139
+ if (isConfirmedForeign(meta, marker)) {
140
+ const found = meta.marker ? `{ stack: "${meta.marker.stack}", env: "${meta.marker.env ?? ""}" }` : "no chant marker";
141
+ throw new LiveAssertionError(
142
+ `assertLive("${name}"): observed, but it is not this deploy's — carries ${found}, not ` +
143
+ `{ stack: "${marker.stack}", env: "${marker.env ?? ""}" }. A same-named resource from another stack or env cannot satisfy this assertion.`,
144
+ );
145
+ }
146
+
147
+ if (status !== undefined && meta.status !== status) {
148
+ throw new LiveAssertionError(
149
+ `assertLive("${name}", { status: "${status}" }): observed with status "${meta.status}".`,
150
+ );
151
+ }
152
+
153
+ return meta;
154
+ }
@@ -14,6 +14,7 @@
14
14
  */
15
15
  import { diffLive, type AttributeChange, type DiffLiveInput } from "./live-diff";
16
16
  import { unobservedReasonText, type UnobservedReason } from "../observation";
17
+ import { renderDisruption, summarizeDisruption, type Disruption } from "./disruption";
17
18
 
18
19
  /**
19
20
  * What the projection proposes for a single resource.
@@ -129,6 +130,22 @@ export interface ChangeSetEntry {
129
130
  effectReason?: EffectFireReason;
130
131
  /** Human-readable backing for `effectReason` (the digests that differ, the unresolved path). */
131
132
  effectDetail?: string;
133
+ /**
134
+ * How much applying this change hurts (#1665) — in-place / rolling / replace
135
+ * / destroy / unknown. Set on `update` entries only: every other action
136
+ * carries its blast radius in the action itself.
137
+ *
138
+ * The verdict comes from the lexicon that owns the spec
139
+ * ({@link LexiconPlugin.classifyDisruption}), never from core, which has no
140
+ * per-provider replacement rules and must not grow any. `unknown` is the
141
+ * default and the only fallback — read it as "nobody could say", never as
142
+ * "probably in place".
143
+ */
144
+ disruption?: Disruption;
145
+ /** The attribute paths that forced `disruption` (#1665). */
146
+ disruptionBecause?: string[];
147
+ /** Human-readable backing for `disruption` — the spec knowledge behind the call, or why there is none. */
148
+ disruptionDetail?: string;
132
149
  }
133
150
 
134
151
  export interface ChangeSet {
@@ -314,6 +331,16 @@ export function renderChangeSet(cs: ChangeSet): string {
314
331
  const header = ACTION_ORDER.map((a) => `${counts[a]} ${a}`).join(", ");
315
332
  const lines: string[] = [`Plan for ${cs.env}: ${header}`];
316
333
 
334
+ // Disruption (#1665) rides the header too, so the one number that says how
335
+ // much this plan hurts is visible without reading every row.
336
+ const disruption = summarizeDisruption(cs);
337
+ const disruptionParts = (Object.entries(disruption) as Array<[Disruption, number]>)
338
+ .filter(([, n]) => n > 0)
339
+ .map(([level, n]) => `${n} ${level}`);
340
+ if (disruptionParts.length > 0) {
341
+ lines.push(`Disruption: ${disruptionParts.join(", ")}`);
342
+ }
343
+
317
344
  for (const action of ACTION_ORDER) {
318
345
  const group = cs.entries.filter((e) => e.action === action);
319
346
  if (group.length === 0) continue;
@@ -324,7 +351,9 @@ export function renderChangeSet(cs: ChangeSet): string {
324
351
  ? "\nRUNTIME (owned by a declared resource; not drift, never a delete/adopt candidate):"
325
352
  : action === "effect"
326
353
  ? "\nEFFECT (receipt absent or stale; the effect step fires — the generic apply never writes a receipt):"
327
- : `\n${action.toUpperCase()}:`,
354
+ : action === "update" && disruptionParts.length > 0
355
+ ? "\nUPDATE (disruption from the lexicon that owns the spec; unknown means nobody could say, not that it is safe):"
356
+ : `\n${action.toUpperCase()}:`,
328
357
  );
329
358
  for (const e of group) {
330
359
  if (e.action === "effect") {
@@ -339,9 +368,12 @@ export function renderChangeSet(cs: ChangeSet): string {
339
368
  ? ` — ${unobservedReasonText(e.unobservedReason)}${e.unobservedDetail ? `: ${e.unobservedDetail}` : ""}`
340
369
  : "";
341
370
  const owner = e.runtimeOwner ? ` — owned by ${e.runtimeOwner}` : "";
342
- lines.push(` ${e.name}${e.type ? ` (${e.type})` : ""}${own}${why}${owner}`);
371
+ lines.push(` ${e.name}${e.type ? ` (${e.type})` : ""}${own}${why}${owner}${renderDisruption(e)}`);
372
+ const forced = new Set(e.disruptionBecause ?? []);
343
373
  for (const d of e.deltas ?? []) {
344
- lines.push(` ${d.path}: ${fmt(d.oldValue)} → ${fmt(d.newValue)}`);
374
+ // A path the verdict rests on is marked, so a `replace` row says which
375
+ // of five changed properties caused it.
376
+ lines.push(` ${forced.has(d.path) ? "! " : ""}${d.path}: ${fmt(d.oldValue)} → ${fmt(d.newValue)}`);
345
377
  }
346
378
  }
347
379
  }
@@ -0,0 +1,186 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import {
3
+ annotateDisruption,
4
+ disruptionNotices,
5
+ summarizeDisruption,
6
+ worstDisruption,
7
+ type DisruptionClassifier,
8
+ } from "./disruption";
9
+ import { renderChangeSet, type ChangeSet, type ChangeSetEntry } from "./change-set";
10
+
11
+ function entry(overrides: Partial<ChangeSetEntry> = {}): ChangeSetEntry {
12
+ return {
13
+ name: "db",
14
+ type: "AWS::RDS::DBInstance",
15
+ lexicon: "aws",
16
+ action: "update",
17
+ evidence: { declared: true, inSnapshot: true, live: true, observed: true },
18
+ deltas: [{ path: "attributes.Engine", oldValue: "postgres", newValue: "mysql" }],
19
+ ownership: "unknown",
20
+ ...overrides,
21
+ };
22
+ }
23
+
24
+ function set(entries: ChangeSetEntry[]): ChangeSet {
25
+ return { env: "prod", entries };
26
+ }
27
+
28
+ describe("annotateDisruption (#1665)", () => {
29
+ test("no classifier degrades every update to unknown, never in-place", async () => {
30
+ const out = await annotateDisruption(set([entry()]), "prod", undefined);
31
+ expect(out.entries[0].disruption).toBe("unknown");
32
+ expect(out.entries[0].disruptionDetail).toContain("aws lexicon does not classify disruption");
33
+ });
34
+
35
+ test("a lexicon's verdict rides the entry", async () => {
36
+ const classify: DisruptionClassifier = () => ({
37
+ db: { disruption: "replace", because: ["attributes.Engine"], detail: "Engine is create-only" },
38
+ });
39
+ const out = await annotateDisruption(set([entry()]), "prod", classify);
40
+ expect(out.entries[0]).toMatchObject({
41
+ disruption: "replace",
42
+ disruptionBecause: ["attributes.Engine"],
43
+ disruptionDetail: "Engine is create-only",
44
+ });
45
+ });
46
+
47
+ test("a name the classifier said nothing about is unknown", async () => {
48
+ const classify: DisruptionClassifier = () => ({});
49
+ const out = await annotateDisruption(set([entry()]), "prod", classify);
50
+ expect(out.entries[0].disruption).toBe("unknown");
51
+ expect(out.entries[0].disruptionDetail).toContain("returned no verdict");
52
+ });
53
+
54
+ // The guard that makes `in-place` trustworthy: a lexicon cannot smuggle a
55
+ // level in that core does not recognise, and a bogus one is never treated as
56
+ // the safe end of the scale.
57
+ test("a level outside the vocabulary is rejected into unknown", async () => {
58
+ const classify = (() => ({
59
+ db: { disruption: "totally-fine", detail: "trust me" },
60
+ })) as unknown as DisruptionClassifier;
61
+ const out = await annotateDisruption(set([entry()]), "prod", classify);
62
+ expect(out.entries[0].disruption).toBe("unknown");
63
+ expect(out.entries[0].disruptionDetail).toContain("totally-fine");
64
+ });
65
+
66
+ test("a classifier that throws leaves unknown, and the plan survives", async () => {
67
+ const classify: DisruptionClassifier = () => {
68
+ throw new Error("registry missing");
69
+ };
70
+ const out = await annotateDisruption(set([entry()]), "prod", classify);
71
+ expect(out.entries[0].disruption).toBe("unknown");
72
+ expect(out.entries[0].disruptionDetail).toContain("registry missing");
73
+ });
74
+
75
+ test("an async classifier is awaited", async () => {
76
+ const classify: DisruptionClassifier = async () => ({
77
+ db: { disruption: "in-place", detail: "no create-only property changed" },
78
+ });
79
+ const out = await annotateDisruption(set([entry()]), "prod", classify);
80
+ expect(out.entries[0].disruption).toBe("in-place");
81
+ });
82
+
83
+ test("only update entries are classified", async () => {
84
+ const classify: DisruptionClassifier = ({ changes }) => {
85
+ expect(changes.map((c) => c.name)).toEqual(["db"]);
86
+ return { db: { disruption: "destroy" } };
87
+ };
88
+ const out = await annotateDisruption(
89
+ set([
90
+ entry(),
91
+ entry({ name: "bucket", action: "create", deltas: undefined }),
92
+ entry({ name: "queue", action: "delete", deltas: undefined }),
93
+ ]),
94
+ "prod",
95
+ classify,
96
+ );
97
+ const byName = Object.fromEntries(out.entries.map((e) => [e.name, e]));
98
+ expect(byName.db.disruption).toBe("destroy");
99
+ expect(byName.bucket.disruption).toBeUndefined();
100
+ expect(byName.queue.disruption).toBeUndefined();
101
+ });
102
+
103
+ test("a set with no updates is returned untouched, and no classifier is called", async () => {
104
+ let called = false;
105
+ const cs = set([entry({ action: "noop", deltas: undefined })]);
106
+ const out = await annotateDisruption(cs, "prod", () => {
107
+ called = true;
108
+ return {};
109
+ });
110
+ expect(out).toBe(cs);
111
+ expect(called).toBe(false);
112
+ });
113
+
114
+ test("the input change set is not mutated", async () => {
115
+ const cs = set([entry()]);
116
+ await annotateDisruption(cs, "prod", () => ({ db: { disruption: "replace" } }));
117
+ expect(cs.entries[0].disruption).toBeUndefined();
118
+ });
119
+ });
120
+
121
+ describe("summaries and notices", () => {
122
+ const classified = set([
123
+ entry({ name: "db", disruption: "destroy" }),
124
+ entry({ name: "sg", disruption: "replace" }),
125
+ entry({ name: "tags", disruption: "in-place" }),
126
+ entry({ name: "mystery", disruption: "unknown" }),
127
+ entry({ name: "bucket", action: "noop", disruption: undefined, deltas: undefined }),
128
+ ]);
129
+
130
+ test("summarizeDisruption counts update entries only", () => {
131
+ expect(summarizeDisruption(classified)).toEqual({
132
+ "in-place": 1,
133
+ rolling: 0,
134
+ replace: 1,
135
+ destroy: 1,
136
+ unknown: 1,
137
+ });
138
+ });
139
+
140
+ test("worstDisruption ranks unknown above every confident verdict", () => {
141
+ expect(worstDisruption(classified)).toBe("unknown");
142
+ expect(worstDisruption(set([entry({ disruption: "in-place" })]))).toBe("in-place");
143
+ expect(worstDisruption(set([entry({ action: "noop", disruption: undefined })]))).toBeUndefined();
144
+ });
145
+
146
+ test("notices name the replacing and the unclassified rows", () => {
147
+ const notices = disruptionNotices(classified);
148
+ expect(notices[0]).toContain("2 update(s) replace the resource");
149
+ expect(notices[0]).toContain("1 of them by deleting it first");
150
+ expect(notices[1]).toContain("1 update(s) could not be classified");
151
+ });
152
+
153
+ test("a clean plan produces no notices", () => {
154
+ expect(disruptionNotices(set([entry({ disruption: "in-place" })]))).toEqual([]);
155
+ });
156
+ });
157
+
158
+ describe("renderChangeSet with disruption", () => {
159
+ test("the header, the row, and the forcing delta all say it", () => {
160
+ const out = renderChangeSet(
161
+ set([
162
+ entry({
163
+ disruption: "replace",
164
+ disruptionBecause: ["attributes.Engine"],
165
+ disruptionDetail: "Engine is create-only",
166
+ deltas: [
167
+ { path: "attributes.Engine", oldValue: "postgres", newValue: "mysql" },
168
+ { path: "attributes.AllocatedStorage", oldValue: 20, newValue: 40 },
169
+ ],
170
+ }),
171
+ ]),
172
+ );
173
+ expect(out).toContain("Disruption: 1 replace");
174
+ expect(out).toContain("UPDATE (disruption from the lexicon that owns the spec");
175
+ expect(out).toContain("db (AWS::RDS::DBInstance) — replace: Engine is create-only");
176
+ expect(out).toContain("! attributes.Engine:");
177
+ expect(out).toContain(" attributes.AllocatedStorage:");
178
+ expect(out).not.toContain("! attributes.AllocatedStorage");
179
+ });
180
+
181
+ test("an unclassified plan renders the plain UPDATE header", () => {
182
+ const out = renderChangeSet(set([entry({ disruption: undefined })]));
183
+ expect(out).toContain("\nUPDATE:");
184
+ expect(out).not.toContain("Disruption:");
185
+ });
186
+ });
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Per-change disruption classification (#1665).
3
+ *
4
+ * The change set says WHAT a pending change is (`create`/`update`/`delete`/…).
5
+ * It says nothing about what applying it costs. An `update` that flips a tag
6
+ * and an `update` that rebuilds a database read identically, and the second one
7
+ * is the one that wakes somebody up.
8
+ *
9
+ * The knowledge that separates them is spec knowledge. CloudFormation's
10
+ * registry schema declares `createOnlyProperties` per type; Kubernetes' SSA
11
+ * schema knows which field changes roll a workload. Core owns neither, and
12
+ * hardcoding either here would put per-provider replacement rules in the tool
13
+ * — the same mistake `postSynthChecks` exists to avoid. So core defines the
14
+ * contract and the reporting, and the lexicon that compiled the spec supplies
15
+ * the answer, via {@link LexiconPlugin.classifyDisruption}.
16
+ *
17
+ * The invariant that makes the field trustworthy is that `unknown` is the
18
+ * default and the only fallback. No classifier, a classifier that says nothing
19
+ * about an entry, a classifier that throws, a classifier that returns a level
20
+ * outside the vocabulary — all of them land on `unknown`, never on `in-place`.
21
+ * A confident "this mutates in place" is only ever a lexicon's own claim.
22
+ */
23
+ import type { AttributeChange } from "./live-diff";
24
+ import type { ChangeSet, ChangeSetEntry } from "./change-set";
25
+
26
+ /**
27
+ * How much applying one pending change hurts.
28
+ *
29
+ * - `in-place` — the provider mutates the existing resource. No new identity,
30
+ * no window where it is absent.
31
+ * - `rolling` — the resource survives, but its workload is replaced
32
+ * incrementally (a Deployment's pod template changing). Disruptive to what
33
+ * is running, not to the resource.
34
+ * - `replace` — a new resource is created and the old one removed. The
35
+ * physical id changes; anything holding the old one has to be updated.
36
+ * - `destroy` — replacement that removes the old resource FIRST. There is a
37
+ * window with nothing there, and whatever the old one held is gone.
38
+ * - `unknown` — nobody could say. The honest value, and the default: it is
39
+ * what a change gets when no lexicon classifies it, and it must never be
40
+ * read as "probably fine".
41
+ */
42
+ export type Disruption = "in-place" | "rolling" | "replace" | "destroy" | "unknown";
43
+
44
+ /** Every level, most disruptive last — also the guard core validates a lexicon's answer against. */
45
+ export const DISRUPTION_LEVELS: readonly Disruption[] = [
46
+ "in-place",
47
+ "rolling",
48
+ "replace",
49
+ "destroy",
50
+ "unknown",
51
+ ];
52
+
53
+ /** Ordering for "the worst thing in this plan", with `unknown` above every confident verdict. */
54
+ const DISRUPTION_RANK: Record<Disruption, number> = {
55
+ "in-place": 0,
56
+ rolling: 1,
57
+ replace: 2,
58
+ destroy: 3,
59
+ unknown: 4,
60
+ };
61
+
62
+ /** One pending change put to a lexicon for classification. */
63
+ export interface DisruptionQuery {
64
+ /** The change set entry's `name` — the key a verdict comes back under. */
65
+ name: string;
66
+ /** Resource type, when the observation reported one. */
67
+ type?: string;
68
+ /** The attribute-level changes the entry carries. */
69
+ deltas: AttributeChange[];
70
+ }
71
+
72
+ /** A lexicon's answer for one query. */
73
+ export interface DisruptionVerdict {
74
+ disruption: Disruption;
75
+ /** The attribute paths that forced the verdict — empty or absent when none did. */
76
+ because?: string[];
77
+ /** One line of human-readable backing, naming the spec knowledge behind the call. */
78
+ detail?: string;
79
+ }
80
+
81
+ /**
82
+ * The shape of {@link LexiconPlugin.classifyDisruption}. Keyed by query `name`;
83
+ * a name the lexicon says nothing about degrades to `unknown`, so a partial
84
+ * answer is a valid answer.
85
+ */
86
+ export type DisruptionClassifier = (options: {
87
+ environment: string;
88
+ changes: DisruptionQuery[];
89
+ }) => Record<string, DisruptionVerdict> | Promise<Record<string, DisruptionVerdict>>;
90
+
91
+ /** The verdict every fallback path produces. */
92
+ export function unknownDisruption(detail: string): DisruptionVerdict {
93
+ return { disruption: "unknown", detail };
94
+ }
95
+
96
+ /**
97
+ * Annotate one lexicon's change set with a disruption verdict per `update`.
98
+ *
99
+ * Only `update` entries are asked about: every other action already carries its
100
+ * blast radius in the action itself. Called once per lexicon, before the plan
101
+ * merges the change sets, so `classify` is always the lexicon that produced the
102
+ * entries — the only party that can map its own observation's attribute paths
103
+ * back onto spec properties.
104
+ *
105
+ * Returns a new change set; the input is not mutated.
106
+ */
107
+ export async function annotateDisruption(
108
+ cs: ChangeSet,
109
+ environment: string,
110
+ classify?: DisruptionClassifier,
111
+ ): Promise<ChangeSet> {
112
+ const updates = cs.entries.filter((e) => e.action === "update");
113
+ if (updates.length === 0) return cs;
114
+
115
+ const who = updates[0].lexicon ? `the ${updates[0].lexicon} lexicon` : "this lexicon";
116
+
117
+ let verdicts: Record<string, DisruptionVerdict> = {};
118
+ let fallback: string | undefined;
119
+
120
+ if (!classify) {
121
+ fallback = `${who} does not classify disruption — replacement semantics are spec knowledge it has not published`;
122
+ } else {
123
+ try {
124
+ verdicts = (await classify({
125
+ environment,
126
+ changes: updates.map((e) => ({
127
+ name: e.name,
128
+ ...(e.type ? { type: e.type } : {}),
129
+ deltas: e.deltas ?? [],
130
+ })),
131
+ })) ?? {};
132
+ } catch (err) {
133
+ // A broken classifier is not evidence of anything. It must not be able to
134
+ // leave a confident verdict behind, and it must not fail the plan either.
135
+ verdicts = {};
136
+ fallback = `${who}'s disruption classifier failed: ${err instanceof Error ? err.message : String(err)}`;
137
+ }
138
+ }
139
+
140
+ const entries = cs.entries.map((e) => {
141
+ if (e.action !== "update") return e;
142
+ const verdict = resolveVerdict(verdicts[e.name], fallback, who);
143
+ const annotated: ChangeSetEntry = {
144
+ ...e,
145
+ disruption: verdict.disruption,
146
+ ...(verdict.because && verdict.because.length > 0 ? { disruptionBecause: verdict.because } : {}),
147
+ ...(verdict.detail ? { disruptionDetail: verdict.detail } : {}),
148
+ };
149
+ return annotated;
150
+ });
151
+
152
+ return { ...cs, entries };
153
+ }
154
+
155
+ function resolveVerdict(
156
+ verdict: DisruptionVerdict | undefined,
157
+ fallback: string | undefined,
158
+ who: string,
159
+ ): DisruptionVerdict {
160
+ if (fallback) return unknownDisruption(fallback);
161
+ if (!verdict) return unknownDisruption(`${who} returned no verdict for this change`);
162
+ if (!DISRUPTION_LEVELS.includes(verdict.disruption)) {
163
+ return unknownDisruption(
164
+ `${who} returned "${String(verdict.disruption)}", which is not a disruption level`,
165
+ );
166
+ }
167
+ return verdict;
168
+ }
169
+
170
+ /** Count `update` entries per level. Entries with no verdict at all are not counted. */
171
+ export function summarizeDisruption(cs: ChangeSet): Record<Disruption, number> {
172
+ const counts: Record<Disruption, number> = {
173
+ "in-place": 0,
174
+ rolling: 0,
175
+ replace: 0,
176
+ destroy: 0,
177
+ unknown: 0,
178
+ };
179
+ for (const e of cs.entries) {
180
+ if (e.action === "update" && e.disruption) counts[e.disruption]++;
181
+ }
182
+ return counts;
183
+ }
184
+
185
+ /** The most disruptive verdict in the set, or undefined when nothing was classified. */
186
+ export function worstDisruption(cs: ChangeSet): Disruption | undefined {
187
+ let worst: Disruption | undefined;
188
+ for (const e of cs.entries) {
189
+ if (e.action !== "update" || !e.disruption) continue;
190
+ if (!worst || DISRUPTION_RANK[e.disruption] > DISRUPTION_RANK[worst]) worst = e.disruption;
191
+ }
192
+ return worst;
193
+ }
194
+
195
+ /**
196
+ * Warnings a plan should print on stderr — so a `--json` or `--report gitlab-mr`
197
+ * consumer, whose shape has no column for disruption, still hears about the
198
+ * expensive rows. Same discipline as the unobserved warning (#1089).
199
+ */
200
+ export function disruptionNotices(cs: ChangeSet): string[] {
201
+ const counts = summarizeDisruption(cs);
202
+ const notices: string[] = [];
203
+ const replacing = counts.replace + counts.destroy;
204
+ if (replacing > 0) {
205
+ notices.push(
206
+ `${replacing} update(s) replace the resource rather than mutating it in place` +
207
+ (counts.destroy > 0
208
+ ? `, ${counts.destroy} of them by deleting it first — that window has nothing in it.`
209
+ : "."),
210
+ );
211
+ }
212
+ if (counts.unknown > 0) {
213
+ notices.push(
214
+ `${counts.unknown} update(s) could not be classified — no lexicon could say whether applying them replaces the resource. Unknown is not "in place".`,
215
+ );
216
+ }
217
+ return notices;
218
+ }
219
+
220
+ /** Render one entry's verdict for the human plan, or "" when there is none. */
221
+ export function renderDisruption(entry: ChangeSetEntry): string {
222
+ if (!entry.disruption) return "";
223
+ return ` — ${entry.disruption}${entry.disruptionDetail ? `: ${entry.disruptionDetail}` : ""}`;
224
+ }
@@ -7,6 +7,7 @@ export * from "./deep-diff";
7
7
  export * from "./deep-observe";
8
8
  export * from "./observation-baseline";
9
9
  export * from "./change-set";
10
+ export * from "./disruption";
10
11
  export * from "./unobserved-gate";
11
12
  export * from "./receipt-plan";
12
13
  export * from "./affected";
@@ -16,6 +17,7 @@ export * from "./build-ledger-store";
16
17
  export * from "./oras-referrer-lookup";
17
18
  export * from "./status";
18
19
  export * from "./teardown";
20
+ export * from "./assert-live";
19
21
  export * from "./symptoms";
20
22
  export * from "./converge-ledger";
21
23
  export * from "./scenario";
@@ -249,3 +249,28 @@ describe("a resource recorded twice is one node (#1432 follow-up)", () => {
249
249
  expect(keys(result.observations)).toEqual(["rtb-default"]);
250
250
  });
251
251
  });
252
+
253
+ describe("replaySnapshots — recorded depth (#1268)", () => {
254
+ it("reports identity when nothing recorded deeper", async () => {
255
+ stored.set("main__aws", snapshot("main", "us-east-1", { managed: { web: "i-1" } }));
256
+ const result = await replaySnapshots("prod", "latest", new Set());
257
+ if ("error" in result) throw new Error(result.error);
258
+ expect(result.depth).toBe("identity");
259
+ });
260
+
261
+ it("reports deep when any recorded lexicon read that deep", async () => {
262
+ stored.set("main__aws", JSON.stringify({
263
+ lexicon: "aws", environment: "prod", stack: "main",
264
+ commit: "abc", timestamp: "2026-08-03T00:00:00.000Z", depth: "deep",
265
+ resources: { web: { type: "AWS::EC2::Instance", status: "OBSERVED", physicalId: "i-1" } },
266
+ }));
267
+ stored.set("net__aws", JSON.stringify({
268
+ lexicon: "aws", environment: "prod", stack: "net",
269
+ commit: "abc", timestamp: "2026-08-03T00:00:00.000Z",
270
+ resources: { vpc: { type: "AWS::EC2::VPC", status: "OBSERVED", physicalId: "vpc-1" } },
271
+ }));
272
+ const result = await replaySnapshots("prod", "latest", new Set());
273
+ if ("error" in result) throw new Error(result.error);
274
+ expect(result.depth).toBe("deep");
275
+ });
276
+ });