@intentius/chant 0.52.2 → 0.53.1

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 (125) hide show
  1. package/dist/cdk/advise.d.ts +29 -0
  2. package/dist/cdk/advise.d.ts.map +1 -0
  3. package/dist/cdk/assembly.d.ts +38 -0
  4. package/dist/cdk/assembly.d.ts.map +1 -0
  5. package/dist/cdk/graph.d.ts +68 -0
  6. package/dist/cdk/graph.d.ts.map +1 -0
  7. package/dist/cdk/tier-map.d.ts +44 -0
  8. package/dist/cdk/tier-map.d.ts.map +1 -0
  9. package/dist/cdk/types.d.ts +114 -0
  10. package/dist/cdk/types.d.ts.map +1 -0
  11. package/dist/cli/commands/carve-apply.d.ts.map +1 -1
  12. package/dist/cli/commands/carve-bridge.d.ts.map +1 -1
  13. package/dist/cli/commands/carve-emit.d.ts.map +1 -1
  14. package/dist/cli/commands/carve.d.ts +48 -6
  15. package/dist/cli/commands/carve.d.ts.map +1 -1
  16. package/dist/cli/handlers/carve-emit.d.ts.map +1 -1
  17. package/dist/cli/handlers/carve.d.ts +5 -4
  18. package/dist/cli/handlers/carve.d.ts.map +1 -1
  19. package/dist/cli/handlers/lifecycle.d.ts +10 -0
  20. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  21. package/dist/cli/handlers/op-progress.d.ts +37 -4
  22. package/dist/cli/handlers/op-progress.d.ts.map +1 -1
  23. package/dist/cli/handlers/operator.d.ts +61 -1
  24. package/dist/cli/handlers/operator.d.ts.map +1 -1
  25. package/dist/cli/handlers/run.d.ts.map +1 -1
  26. package/dist/cli/main.d.ts.map +1 -1
  27. package/dist/cli/registry.d.ts +9 -1
  28. package/dist/cli/registry.d.ts.map +1 -1
  29. package/dist/identity.d.ts +196 -0
  30. package/dist/identity.d.ts.map +1 -0
  31. package/dist/index.d.ts +1 -0
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/lexicon.d.ts +41 -0
  34. package/dist/lexicon.d.ts.map +1 -1
  35. package/dist/lifecycle/converge-ledger.d.ts +81 -1
  36. package/dist/lifecycle/converge-ledger.d.ts.map +1 -1
  37. package/dist/lifecycle/gate-ledger.d.ts +43 -1
  38. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  39. package/dist/terraform/adopt-state.d.ts +17 -66
  40. package/dist/terraform/adopt-state.d.ts.map +1 -1
  41. package/dist/terraform/aws-resources.d.ts +1 -1
  42. package/dist/terraform/carve-provider.d.ts +142 -0
  43. package/dist/terraform/carve-provider.d.ts.map +1 -0
  44. package/dist/terraform/carve.d.ts +36 -3
  45. package/dist/terraform/carve.d.ts.map +1 -1
  46. package/dist/terraform/emit-source.d.ts +25 -0
  47. package/dist/terraform/emit-source.d.ts.map +1 -0
  48. package/dist/terraform/graduate.d.ts +11 -1
  49. package/dist/terraform/graduate.d.ts.map +1 -1
  50. package/dist/terraform/providers/aws.d.ts +19 -0
  51. package/dist/terraform/providers/aws.d.ts.map +1 -0
  52. package/dist/terraform/providers/gcp.d.ts +41 -0
  53. package/dist/terraform/providers/gcp.d.ts.map +1 -0
  54. package/dist/terraform/providers/index.d.ts +15 -0
  55. package/dist/terraform/providers/index.d.ts.map +1 -0
  56. package/dist/terraform/providers/kubernetes.d.ts +29 -0
  57. package/dist/terraform/providers/kubernetes.d.ts.map +1 -0
  58. package/dist/terraform/score.d.ts +70 -4
  59. package/dist/terraform/score.d.ts.map +1 -1
  60. package/dist/terraform/tier-map.d.ts +30 -35
  61. package/dist/terraform/tier-map.d.ts.map +1 -1
  62. package/dist/terraform/types.d.ts +6 -0
  63. package/dist/terraform/types.d.ts.map +1 -1
  64. package/package.json +1 -1
  65. package/src/cdk/__fixtures__/cdk.out/AppStack.template.json +171 -0
  66. package/src/cdk/__fixtures__/cdk.out/DataStack.template.json +90 -0
  67. package/src/cdk/__fixtures__/cdk.out/cdk.out +1 -0
  68. package/src/cdk/__fixtures__/cdk.out/manifest.json +30 -0
  69. package/src/cdk/__fixtures__/cdk.out/tree.json +201 -0
  70. package/src/cdk/__fixtures__/cdk.out-dummy/LookupStack.template.json +29 -0
  71. package/src/cdk/__fixtures__/cdk.out-dummy/manifest.json +26 -0
  72. package/src/cdk/advise.test.ts +208 -0
  73. package/src/cdk/advise.ts +44 -0
  74. package/src/cdk/assembly.ts +133 -0
  75. package/src/cdk/graph.test.ts +206 -0
  76. package/src/cdk/graph.ts +525 -0
  77. package/src/cdk/tier-map.ts +71 -0
  78. package/src/cdk/types.ts +115 -0
  79. package/src/cli/commands/carve-apply.ts +20 -4
  80. package/src/cli/commands/carve-bridge.ts +9 -5
  81. package/src/cli/commands/carve-emit-k8s.test.ts +262 -0
  82. package/src/cli/commands/carve-emit-provider.test.ts +207 -0
  83. package/src/cli/commands/carve-emit.ts +28 -24
  84. package/src/cli/commands/carve.ts +139 -36
  85. package/src/cli/handlers/carve-emit.ts +8 -3
  86. package/src/cli/handlers/carve.ts +8 -7
  87. package/src/cli/handlers/lifecycle.test.ts +187 -1
  88. package/src/cli/handlers/lifecycle.ts +125 -1
  89. package/src/cli/handlers/op-progress.test.ts +67 -1
  90. package/src/cli/handlers/op-progress.ts +70 -14
  91. package/src/cli/handlers/operator.test.ts +302 -1
  92. package/src/cli/handlers/operator.ts +193 -5
  93. package/src/cli/handlers/run.ts +11 -13
  94. package/src/cli/main.test.ts +19 -0
  95. package/src/cli/main.ts +38 -10
  96. package/src/cli/registry.ts +9 -1
  97. package/src/identity.test.ts +199 -0
  98. package/src/identity.ts +346 -0
  99. package/src/index.ts +1 -0
  100. package/src/lexicon.ts +53 -0
  101. package/src/lifecycle/converge-ledger.test.ts +110 -0
  102. package/src/lifecycle/converge-ledger.ts +110 -2
  103. package/src/lifecycle/gate-ledger.test.ts +81 -1
  104. package/src/lifecycle/gate-ledger.ts +64 -1
  105. package/src/terraform/__fixtures__/gcp-estate/main.tf +60 -0
  106. package/src/terraform/adopt-state.test.ts +131 -0
  107. package/src/terraform/adopt-state.ts +19 -167
  108. package/src/terraform/aws-resources.test.ts +38 -22
  109. package/src/terraform/aws-resources.ts +1 -1
  110. package/src/terraform/carve-provider.test.ts +155 -0
  111. package/src/terraform/carve-provider.ts +237 -0
  112. package/src/terraform/carve.test.ts +55 -1
  113. package/src/terraform/carve.ts +0 -0
  114. package/src/terraform/emit-source.ts +39 -0
  115. package/src/terraform/graduate.test.ts +37 -0
  116. package/src/terraform/graduate.ts +55 -7
  117. package/src/terraform/graph.ts +3 -3
  118. package/src/terraform/providers/aws.ts +169 -0
  119. package/src/terraform/providers/gcp.test.ts +228 -0
  120. package/src/terraform/providers/gcp.ts +329 -0
  121. package/src/terraform/providers/index.ts +21 -0
  122. package/src/terraform/providers/kubernetes.ts +224 -0
  123. package/src/terraform/score.ts +111 -25
  124. package/src/terraform/tier-map.ts +45 -107
  125. package/src/terraform/types.ts +6 -0
package/src/index.ts CHANGED
@@ -53,6 +53,7 @@ export * from "./import/parser";
53
53
  export * from "./import/generator";
54
54
  export * from "./lexicon";
55
55
  export * from "./observation";
56
+ export * from "./identity";
56
57
  export * from "./apply";
57
58
  export * from "./deep-observation";
58
59
  export * from "./owner-chain";
package/src/lexicon.ts CHANGED
@@ -18,6 +18,7 @@ import type { RuleMeta } from "./audit/catalog";
18
18
  import type { ReferenceCatalog } from "./graph-refs";
19
19
  import type { IREdge } from "./graph-ir";
20
20
  import type { DescribeResourcesResult, UnobservedReason } from "./observation";
21
+ import type { DescribeIdentityOptions, DescribeIdentityResult } from "./identity";
21
22
  import type { DeepNormalizationHooks, DeepObservationResult } from "./deep-observation";
22
23
  import type { DisruptionQuery, DisruptionVerdict } from "./lifecycle/disruption";
23
24
  import type { OwnerChainVerdict } from "./owner-chain";
@@ -46,6 +47,18 @@ export type {
46
47
  UnobservedReason,
47
48
  } from "./observation";
48
49
 
50
+ // The identity contract (#1982), re-exported from the same entry so a
51
+ // lexicon's `describeIdentity` types itself without a second import path.
52
+ // Runtime helpers live in `@intentius/chant/identity`.
53
+ export type {
54
+ DescribeIdentityOptions,
55
+ DescribeIdentityResult,
56
+ ResolvedIdentity,
57
+ UnresolvedIdentity,
58
+ IdentityRow,
59
+ IdentityStatus,
60
+ } from "./identity";
61
+
49
62
  // Disruption classification (#1665), re-exported from the same entry so a
50
63
  // lexicon's `classifyDisruption` types itself without a second import path.
51
64
  // Runtime helpers live in `@intentius/chant/lifecycle/disruption`.
@@ -1083,6 +1096,46 @@ export interface LexiconPlugin {
1083
1096
  */
1084
1097
  describeStackStatus?(options: { environment: string; stack: string }): Promise<StackStatusObservation | null>;
1085
1098
 
1099
+ /**
1100
+ * Report the principal chant would act as in this substrate, and the scope
1101
+ * that principal resolves to (#1982) — what `chant lifecycle whoami <env>`
1102
+ * prints, one row per lexicon, before anything acts.
1103
+ *
1104
+ * Every implementation of {@link describeResources} already resolves this
1105
+ * and throws it away: the bind reaches the provider on the applier's own
1106
+ * transport, the k8s connector resolves a context and knows which binding
1107
+ * produced it, the aws read client resolves a region and an endpoint. The
1108
+ * answer has only ever surfaced as a `no-credentials` hole or, when the
1109
+ * binding was wrong rather than missing, as a clean read of the wrong
1110
+ * account.
1111
+ *
1112
+ * Implement it with the substrate's own cheap self-query —
1113
+ * `sts:GetCallerIdentity`, `SelfSubjectReview`, the token introspection the
1114
+ * forge already exposes. Two rules bind the implementation:
1115
+ *
1116
+ * - **Read-only.** `whoami` is pre-flight and never a gate. The self-query
1117
+ * must be one the substrate treats as a read of the caller's own identity;
1118
+ * it creates nothing and changes nothing.
1119
+ * - **No credential in the answer.** An identity is a principal and a scope,
1120
+ * never a token, key or password. Where a substrate's only identity signal
1121
+ * IS a secret, report that fact in `source` without the value. Core
1122
+ * redacts credential material from every field as a backstop, which is not
1123
+ * a reason to put one there.
1124
+ *
1125
+ * `endpoint` must be the address this lexicon's live read resolves for the
1126
+ * same environment. A whoami that names a binding the read does not use is
1127
+ * worse than no whoami, so a test pins the two together.
1128
+ *
1129
+ * Returning `{ unresolved: { reason, detail } }` is a real answer and the
1130
+ * required one when the identity cannot be resolved — `no-credentials` for
1131
+ * "nothing is configured", `no-binding` for "this environment resolves to no
1132
+ * target", `read-failed` for a self-query that errored. A throw degrades to
1133
+ * `read-failed` for this lexicon alone and never fails the command. Omitting
1134
+ * the method entirely reports `not reported`, which is honest; it is never
1135
+ * rendered as an empty identity.
1136
+ */
1137
+ describeIdentity?(options: DescribeIdentityOptions): Promise<DescribeIdentityResult>;
1138
+
1086
1139
  /**
1087
1140
  * Enumerate the resources this lexicon would delete for one marker identity
1088
1141
  * (#1222). Opt-in, and read-only here: this method names the would-delete
@@ -7,9 +7,11 @@ import {
7
7
  appendConvergeRecord,
8
8
  readConvergeLedger,
9
9
  consecutiveRuleFires,
10
+ componentVerdicts,
10
11
  type ConvergeTickRecordInput,
11
12
  type ConvergeTickRecord,
12
13
  } from "./converge-ledger";
14
+ import type { ComponentStatusRow } from "./status";
13
15
 
14
16
  function git(args: string[], cwd: string): { stdout: string; exitCode: number } {
15
17
  const r = spawnSync("git", args, { cwd, encoding: "utf-8" });
@@ -171,6 +173,114 @@ describe("converge-ledger", () => {
171
173
  });
172
174
  });
173
175
 
176
+ // ── Per-component verdicts and tick id (#2027) ─────────────────────────────
177
+
178
+ describe("tick id", () => {
179
+ test("mints one per record, and two ticks in the same ISO second are still distinguishable", async () => {
180
+ await withTestDir(async (dir) => {
181
+ await initRepo(dir);
182
+ const a = await appendConvergeRecord(makeInput(), { cwd: dir });
183
+ const b = await appendConvergeRecord(makeInput(), { cwd: dir });
184
+
185
+ expect(a.record.id).toMatch(/^[0-9a-f-]{36}$/);
186
+ expect(a.record.timestamp).toBe(b.record.timestamp);
187
+ expect(a.record.id).not.toBe(b.record.id);
188
+
189
+ const { records } = await readConvergeLedger("staging", { cwd: dir });
190
+ expect(records.map((r) => r.id)).toEqual([a.record.id, b.record.id]);
191
+ });
192
+ });
193
+
194
+ test("an explicitly supplied id wins over the mint", async () => {
195
+ await withTestDir(async (dir) => {
196
+ await initRepo(dir);
197
+ const { record } = await appendConvergeRecord(makeInput({ id: "tick-fixed" }), { cwd: dir });
198
+ expect(record.id).toBe("tick-fixed");
199
+ });
200
+ });
201
+
202
+ test("a pre-#2027 record with no id still reads, rather than being counted malformed", async () => {
203
+ await withTestDir(async (dir) => {
204
+ await initRepo(dir);
205
+ await appendConvergeRecord(makeInput(), { cwd: dir });
206
+ // Hand-write the pre-#2027 shape (no `id`) onto the same ledger,
207
+ // through the same git plumbing a real writer used before the field
208
+ // existed.
209
+ const { readBlobFromPath, writeBlobToPath } = await import("./git");
210
+ const legacy = JSON.stringify({ version: 1, ...makeInput({ timestamp: "2025-12-31T00:00:00.000Z" }) });
211
+ const existing = await readBlobFromPath("staging", "converge.jsonl", { cwd: dir });
212
+ await writeBlobToPath("staging", "converge.jsonl", `${existing}\n${legacy}`, "legacy", { cwd: dir });
213
+
214
+ const { records, malformed } = await readConvergeLedger("staging", { cwd: dir });
215
+ expect(malformed).toBe(0);
216
+ expect(records).toHaveLength(2);
217
+ expect(records[1].id).toBeUndefined();
218
+ });
219
+ });
220
+ });
221
+
222
+ describe("componentVerdicts", () => {
223
+ const row = (over: Partial<ComponentStatusRow>): ComponentStatusRow => ({
224
+ component: "svc",
225
+ env: "staging",
226
+ reconciliation: "reconciled",
227
+ detail: "digest matches live",
228
+ ...over,
229
+ } as ComponentStatusRow);
230
+
231
+ test("keeps the verdict-bearing subset and drops the heavy release/build fields", () => {
232
+ const rows = [
233
+ row({ component: "api", reconciliation: "drifted", detail: "live digest differs", live: true,
234
+ recorded: { version: 1 } as unknown as ComponentStatusRow["recorded"] }),
235
+ row({ component: "worker", reconciliation: "unknown", detail: "could not read live state",
236
+ unobserved: { reason: "no-credentials", detail: "no role assumed" } }),
237
+ ];
238
+ expect(componentVerdicts(rows)).toEqual([
239
+ { component: "api", reconciliation: "drifted", detail: "live digest differs", live: true },
240
+ {
241
+ component: "worker",
242
+ reconciliation: "unknown",
243
+ detail: "could not read live state",
244
+ unobserved: { reason: "no-credentials", detail: "no role assumed" },
245
+ },
246
+ ]);
247
+ });
248
+
249
+ test("caps a multi-line or oversized detail to one line, so the record stays one line of JSON", () => {
250
+ const verdicts = componentVerdicts([
251
+ row({ detail: "first line\nsecond line" }),
252
+ row({ component: "big", detail: "x".repeat(500) }),
253
+ ]);
254
+ expect(verdicts[0].detail).toBe("first line");
255
+ expect(verdicts[1].detail).toHaveLength(301); // 300 + the ellipsis
256
+ expect(JSON.stringify(verdicts)).not.toContain("\\n");
257
+ });
258
+
259
+ test("round-trips on a record, naming the component that tripped the tick's aggregate unknown", async () => {
260
+ await withTestDir(async (dir) => {
261
+ await initRepo(dir);
262
+ const { record } = await appendConvergeRecord(
263
+ makeInput({
264
+ components: componentVerdicts([
265
+ row({ component: "api", reconciliation: "drifted", detail: "live digest differs", live: true }),
266
+ row({ component: "worker", reconciliation: "unknown", detail: "unreadable",
267
+ unobserved: { reason: "no-credentials" } }),
268
+ ]),
269
+ summary: { drifted: 1, remediated: 0, reported: 1, skippedBudget: 0, skippedFlap: 0, unobserved: 1, adopted: 0 },
270
+ }),
271
+ { cwd: dir },
272
+ );
273
+
274
+ const { records } = await readConvergeLedger("staging", { cwd: dir });
275
+ expect(records).toEqual([record]);
276
+ // The count says "1 unobserved"; the verdicts say which one.
277
+ expect(records[0].summary.unobserved).toBe(1);
278
+ expect(records[0].components?.filter((c) => c.unobserved).map((c) => c.component)).toEqual(["worker"]);
279
+ expect(records[0].components?.find((c) => c.reconciliation === "drifted")?.component).toBe("api");
280
+ });
281
+ });
282
+ });
283
+
174
284
  // ── Concurrent local writers (#1485) ────────────────────────────────────────
175
285
 
176
286
  describe("appendConvergeRecord retries on RefCASConflictError", () => {
@@ -16,11 +16,29 @@
16
16
  * row include a given rule id, stopping at the first tick where it didn't
17
17
  * fire (the symptom cleared).
18
18
  */
19
+ import { randomUUID } from "node:crypto";
19
20
  import { sortedJsonReplacer } from "../utils";
21
+ import type { ComponentStatusRow } from "./status";
20
22
  import { readBlobFromPath, readPathSha, readBlobBySha, writeBlobToPath, RefCASConflictError } from "./git";
21
23
 
22
24
  const FILENAME = "converge.jsonl";
23
25
 
26
+ /**
27
+ * Cap a piece of free text to one sanitized line before it goes into a
28
+ * record. A record here is one line of JSON, so a multi-line or unbounded
29
+ * string folded into any field would break the line the ledger is built out
30
+ * of. Every free-text field a tick writes goes through this: a dispatch
31
+ * failure's `stderr` (lexicons/temporal's `sanitizeOneLine`, which delegates
32
+ * here) and a component verdict's lexicon-authored `detail`.
33
+ */
34
+ const MAX_LEDGER_TEXT_LEN = 300;
35
+ export function sanitizeLedgerText(raw: string, maxLen = MAX_LEDGER_TEXT_LEN): string {
36
+ const firstLine = raw.split(/\r?\n/, 1)[0] ?? "";
37
+ // Strips literal control bytes; it is not matching on a range boundary.
38
+ const stripped = firstLine.replace(/[\x00-\x1f\x7f]/g, " ").trim();
39
+ return stripped.length > maxLen ? `${stripped.slice(0, maxLen)}…` : stripped;
40
+ }
41
+
24
42
  /**
25
43
  * Read-modify-append retry budget for {@link appendConvergeRecord} (#1485).
26
44
  * `writeBlobToPath`'s ref write is now CAS-guarded (./git.ts) — a conflict
@@ -50,14 +68,85 @@ export interface ConvergeRuleOutcome {
50
68
  op?: string;
51
69
  /** The gate's signal name, for `action: "gated"`. */
52
70
  gateName?: string;
71
+ /**
72
+ * Where this gate's approval happens (#2028), for `action: "gated"` — the
73
+ * PR carrying the change in a gate-as-PR flow, or whatever review surface
74
+ * the dispatching environment knows about.
75
+ *
76
+ * The pending fact is the one a human has to act on, and it used to carry
77
+ * no link at all: `chant operator status`'s pending row was
78
+ * `{rule, op, gate}` and the only affordance it could print was a shell
79
+ * command. Absent when there genuinely is no address — a local tick with no
80
+ * PR behind it — never a synthesized one. See
81
+ * `./gate-ledger.ts`'s `resolveApprovalUrl` for where it comes from, and
82
+ * `GateResolutionRecord.url` for the resolved counterpart.
83
+ */
84
+ url?: string;
53
85
  /** The report reason, for `action: "reported"` (including a flap-damped rule's forced report) — and the human-readable explanation for `action: "gated"`. */
54
86
  reason?: string;
55
87
  }
56
88
 
89
+ /**
90
+ * One component's verdict as the tick observed it (#2027) — the per-entity
91
+ * layer behind the tick's single aggregate `status`.
92
+ *
93
+ * A deliberate subset of `ComponentStatusRow`: the five verdict-bearing
94
+ * fields, joined on the same `component` key `chant components status --live
95
+ * --json` emits, and none of the heavy ones. `recorded`, `build` and
96
+ * `componentBom` are dropped on purpose — they are release/build-ledger
97
+ * content, already readable by that same key, and a tick record is one line
98
+ * of JSON appended every tick forever. `detail` goes through
99
+ * {@link sanitizeLedgerText} for the same reason.
100
+ */
101
+ export interface ConvergeComponentVerdict {
102
+ component: string;
103
+ reconciliation: ComponentStatusRow["reconciliation"];
104
+ /** Human-readable detail backing the verdict, capped to one line. */
105
+ detail: string;
106
+ /** "Observed live", when live evidence was gathered. Absent means "did not look" or "could not look" — see `unobserved`. */
107
+ live?: boolean;
108
+ /** Why live state could not be read for this component (#1089). Mutually exclusive with `live`; this is the row that tripped an aggregate `status: "unknown"`. */
109
+ unobserved?: { reason: string; detail?: string };
110
+ }
111
+
112
+ /**
113
+ * Project the tick's `ComponentStatusRow[]` down to what the ledger keeps.
114
+ * Pure; row order is preserved, so a consumer sees components in the same
115
+ * order the status join produced them.
116
+ */
117
+ export function componentVerdicts(rows: readonly ComponentStatusRow[]): ConvergeComponentVerdict[] {
118
+ return rows.map((row) => ({
119
+ component: row.component,
120
+ reconciliation: row.reconciliation,
121
+ detail: sanitizeLedgerText(row.detail ?? ""),
122
+ ...(row.live !== undefined ? { live: row.live } : {}),
123
+ ...(row.unobserved
124
+ ? {
125
+ unobserved: {
126
+ reason: row.unobserved.reason,
127
+ ...(row.unobserved.detail !== undefined ? { detail: sanitizeLedgerText(row.unobserved.detail) } : {}),
128
+ },
129
+ }
130
+ : {}),
131
+ }));
132
+ }
133
+
57
134
  /** One immutable converge-tick record. */
58
135
  export interface ConvergeTickRecord {
59
136
  /** Schema version, so an incompatible future shape is detected before being misread. */
60
137
  version: 1;
138
+ /**
139
+ * Stable tick id (#2027) — the one thing an outcome, a gate fact or a
140
+ * remediation can point at. Before this, a tick's identity was the
141
+ * `(op, env, timestamp)` triple, so two ticks landing in the same ISO
142
+ * second were indistinguishable and nothing could reference one.
143
+ *
144
+ * Minted by {@link appendConvergeRecord} with `randomUUID()` (the same
145
+ * mint `./lease.ts` uses for a lease token) when the caller doesn't supply
146
+ * one. Optional on the type because `version: 1` records written before
147
+ * #2027 have none — a reader must handle its absence, not assume it.
148
+ */
149
+ id?: string;
61
150
  /** The ConvergeOp's name (`OpConfig.name`). */
62
151
  op: string;
63
152
  env: string;
@@ -67,6 +156,18 @@ export interface ConvergeTickRecord {
67
156
  firedRuleIds: string[];
68
157
  /** Per-rule outcome, for every fired rule. */
69
158
  outcomes: ConvergeRuleOutcome[];
159
+ /**
160
+ * The per-component verdicts this tick observed (#2027), behind the
161
+ * aggregate counts in `summary`.
162
+ *
163
+ * The tick derives these to compute `summary.drifted` and the whole-tick
164
+ * `status`, and used to throw them away — so a reader could say "this
165
+ * environment drifted twice this hour" but could not colour the node that
166
+ * drifted, or name the single unobserved component whose `unknown` verdict
167
+ * refused remediation for everything else. Optional because `version: 1`
168
+ * records written before #2027 have none; absent is not "no components".
169
+ */
170
+ components?: ConvergeComponentVerdict[];
70
171
  /** Aggregate counts backing the tick's one log line. */
71
172
  summary: {
72
173
  drifted: number;
@@ -90,6 +191,13 @@ export type ConvergeTickRecordInput = Omit<ConvergeTickRecord, "version">;
90
191
  * `pushLifecycle` (./git.ts) afterward, same two-step shape every other
91
192
  * ledger write here uses.
92
193
  *
194
+ * Mints `record.id` (#2027) when the input doesn't carry one, so every tick
195
+ * written from here on is referenceable. Unlike `timestamp`, which stays
196
+ * caller-supplied because library code never calls `Date.now()` internally,
197
+ * an id has nothing for a caller to decide — the mint lives here so no
198
+ * writer can forget it. An explicit `input.id` still wins, which is what a
199
+ * test asserting an exact record uses.
200
+ *
93
201
  * Retries the whole read-modify-write cycle (#1485) on `RefCASConflictError`
94
202
  * — `writeBlobToPath`'s ref write is CAS-guarded, so a concurrent writer to
95
203
  * a *different* env's file on the same orphan branch (two operators ticking
@@ -108,8 +216,8 @@ export type ConvergeTickRecordInput = Omit<ConvergeTickRecord, "version">;
108
216
  export async function appendConvergeRecord(
109
217
  input: ConvergeTickRecordInput,
110
218
  opts?: { cwd?: string },
111
- ): Promise<{ commit: string; record: ConvergeTickRecord }> {
112
- const record: ConvergeTickRecord = { version: 1, ...input };
219
+ ): Promise<{ commit: string; record: ConvergeTickRecord & { id: string } }> {
220
+ const record: ConvergeTickRecord & { id: string } = { version: 1, ...input, id: input.id ?? randomUUID() };
113
221
  const json = JSON.stringify(record, sortedJsonReplacer);
114
222
 
115
223
  let lastErr: unknown;
@@ -3,7 +3,7 @@ import { withTestDir } from "@intentius/chant-test-utils";
3
3
  import { spawnSync } from "node:child_process";
4
4
  import { writeFileSync } from "node:fs";
5
5
  import { join } from "node:path";
6
- import { appendGateResolution, readGateResolutions, latestResolutionSince } from "./gate-ledger";
6
+ import { appendGateResolution, readGateResolutions, latestResolutionSince, resolveApprovalUrl, isApprovalUrl } from "./gate-ledger";
7
7
  import { readBlobFromPath } from "./git";
8
8
 
9
9
  function git(args: string[], cwd: string): { stdout: string; exitCode: number } {
@@ -100,4 +100,84 @@ describe("lifecycle/gate-ledger", () => {
100
100
  expect(latestResolutionSince([], "g1", "2026-01-01T00:00:00.000Z")).toBeUndefined();
101
101
  });
102
102
  });
103
+ // ── Gate-as-fact carries an address (#2028) ──────────────────────────────
104
+
105
+ describe("resolveApprovalUrl", () => {
106
+ test("a GitHub Actions pull_request run resolves to that PR", () => {
107
+ expect(resolveApprovalUrl({
108
+ GITHUB_SERVER_URL: "https://github.com",
109
+ GITHUB_REPOSITORY: "INTENTIUS/chant",
110
+ GITHUB_REF_NAME: "2028/merge",
111
+ })).toBe("https://github.com/INTENTIUS/chant/pull/2028");
112
+ });
113
+
114
+ test("honours a GitHub Enterprise server url, trailing slash and all", () => {
115
+ expect(resolveApprovalUrl({
116
+ GITHUB_SERVER_URL: "https://ghe.example.com/",
117
+ GITHUB_REPOSITORY: "org/repo",
118
+ GITHUB_REF_NAME: "7/head",
119
+ })).toBe("https://ghe.example.com/org/repo/pull/7");
120
+ });
121
+
122
+ test("a GitLab merge-request pipeline resolves to that MR", () => {
123
+ expect(resolveApprovalUrl({
124
+ CI_MERGE_REQUEST_PROJECT_URL: "https://gitlab.com/org/repo",
125
+ CI_MERGE_REQUEST_IID: "42",
126
+ })).toBe("https://gitlab.com/org/repo/-/merge_requests/42");
127
+ });
128
+
129
+ test("a push-event CI run, or no CI at all, has no address — undefined, never a guess", () => {
130
+ expect(resolveApprovalUrl({ GITHUB_REPOSITORY: "org/repo", GITHUB_REF_NAME: "main" })).toBeUndefined();
131
+ expect(resolveApprovalUrl({ GITHUB_REF_NAME: "3/merge" })).toBeUndefined();
132
+ expect(resolveApprovalUrl({ CI_MERGE_REQUEST_PROJECT_URL: "https://gitlab.com/org/repo" })).toBeUndefined();
133
+ expect(resolveApprovalUrl({})).toBeUndefined();
134
+ });
135
+ });
136
+
137
+ describe("isApprovalUrl", () => {
138
+ test("accepts absolute http/https", () => {
139
+ expect(isApprovalUrl("https://github.com/org/repo/pull/1")).toBe(true);
140
+ expect(isApprovalUrl("http://localhost:3000/pr/1")).toBe(true);
141
+ });
142
+
143
+ test("refuses anything a reader could not follow as a link", () => {
144
+ for (const bad of ["", "org/repo/pull/1", "/pull/1", "file:///etc/passwd", "javascript:alert(1)", "not a url"]) {
145
+ expect(isApprovalUrl(bad)).toBe(false);
146
+ }
147
+ });
148
+ });
149
+
150
+ test("a resolution round-trips its typed url alongside free-text note", async () => {
151
+ await withTestDir(async (dir) => {
152
+ await initRepo(dir);
153
+ const { record } = await appendGateResolution(
154
+ {
155
+ op: "fountain-apply",
156
+ gate: "rollout-gate",
157
+ resolvedBy: "alex",
158
+ timestamp: "2026-01-01T00:00:00.000Z",
159
+ note: "rolled staging first",
160
+ url: "https://github.com/INTENTIUS/chant/pull/2028",
161
+ },
162
+ { cwd: dir },
163
+ );
164
+ const { records } = await readGateResolutions("fountain-apply", { cwd: dir });
165
+ expect(records).toEqual([record]);
166
+ expect(records[0].url).toBe("https://github.com/INTENTIUS/chant/pull/2028");
167
+ expect(records[0].note).toBe("rolled staging first");
168
+ });
169
+ });
170
+
171
+ test("a pre-#2028 resolution with no url still reads", async () => {
172
+ await withTestDir(async (dir) => {
173
+ await initRepo(dir);
174
+ await appendGateResolution(
175
+ { op: "fountain-apply", gate: "g", resolvedBy: "alex", timestamp: "2026-01-01T00:00:00.000Z" },
176
+ { cwd: dir },
177
+ );
178
+ const { records, malformed } = await readGateResolutions("fountain-apply", { cwd: dir });
179
+ expect(malformed).toBe(0);
180
+ expect(records[0].url).toBeUndefined();
181
+ });
182
+ });
103
183
  });
@@ -42,6 +42,58 @@ import { readBlobFromPath, readPathSha, readBlobBySha, writeBlobToPath, RefCASCo
42
42
  const DIR = "_gates";
43
43
  const APPEND_RETRY_ATTEMPTS = 5;
44
44
 
45
+ /**
46
+ * The address of the approval surface for a gate, resolved from the CI
47
+ * environment (#2028).
48
+ *
49
+ * #1485's argument for gate-as-fact was that approval gets an address:
50
+ * "gate-as-PR gives approval a URL, a review surface, and CODEOWNERS as the
51
+ * authorization model." What shipped recorded the gate and not the address,
52
+ * so a pending-approval card had nothing to link to and gate-as-PR stayed a
53
+ * convention. This is the narrow, honest half of that: when a tick (or a
54
+ * `chant approve`) runs inside the PR/MR job that carries the change, the
55
+ * loop genuinely knows where approval happens, and says so. Anywhere else it
56
+ * returns `undefined` and the field is simply absent — never a guess, never a
57
+ * synthesized link.
58
+ *
59
+ * The env-var fallback chain is the same one `--actor` and `--run-id` already
60
+ * use (`../cli/handlers/components.ts`): GitHub Actions first, then GitLab CI.
61
+ *
62
+ * - GitHub Actions on a `pull_request` event: `GITHUB_SERVER_URL` +
63
+ * `GITHUB_REPOSITORY` + the PR number, which `GITHUB_REF_NAME` carries as
64
+ * `<n>/merge`. A push-event run has no PR, so it resolves to nothing.
65
+ * - GitLab CI on a merge-request pipeline: `CI_MERGE_REQUEST_PROJECT_URL` +
66
+ * `CI_MERGE_REQUEST_IID`.
67
+ */
68
+ export function resolveApprovalUrl(env: NodeJS.ProcessEnv = process.env): string | undefined {
69
+ const prNumber = /^(\d+)\/(merge|head)$/.exec(env.GITHUB_REF_NAME ?? "")?.[1];
70
+ if (prNumber && env.GITHUB_REPOSITORY) {
71
+ const server = (env.GITHUB_SERVER_URL ?? "https://github.com").replace(/\/$/, "");
72
+ return `${server}/${env.GITHUB_REPOSITORY}/pull/${prNumber}`;
73
+ }
74
+
75
+ if (env.CI_MERGE_REQUEST_PROJECT_URL && env.CI_MERGE_REQUEST_IID) {
76
+ return `${env.CI_MERGE_REQUEST_PROJECT_URL.replace(/\/$/, "")}/-/merge_requests/${env.CI_MERGE_REQUEST_IID}`;
77
+ }
78
+
79
+ return undefined;
80
+ }
81
+
82
+ /**
83
+ * Whether `raw` is an address worth recording as one: an absolute `http`/
84
+ * `https` URL. A gate's address is a link a reader is expected to follow, so
85
+ * a relative path or a `file:`/`javascript:` scheme is refused at the CLI
86
+ * boundary rather than written into an immutable record.
87
+ */
88
+ export function isApprovalUrl(raw: string): boolean {
89
+ try {
90
+ const parsed = new URL(raw);
91
+ return parsed.protocol === "http:" || parsed.protocol === "https:";
92
+ } catch {
93
+ return false;
94
+ }
95
+ }
96
+
45
97
  /** One immutable gate-resolution record. */
46
98
  export interface GateResolutionRecord {
47
99
  /** Schema version, so an incompatible future shape is detected before being misread. */
@@ -54,8 +106,19 @@ export interface GateResolutionRecord {
54
106
  resolvedBy: string;
55
107
  /** ISO-8601 timestamp, caller-supplied (library code never calls `Date.now()` internally). */
56
108
  timestamp: string;
57
- /** Optional free-text context (e.g. a PR URL — "or a merged PR" is the issue's other resolution path; recording its link here keeps both paths visible from one ledger). */
109
+ /** Optional free-text context. Before #2028 this was also where a PR link went by convention; put the link in {@link GateResolutionRecord.url} instead and leave this for prose. */
58
110
  note?: string;
111
+ /**
112
+ * The address this resolution happened at (#2028) — the PR that carried the
113
+ * change, the review thread, whatever the approval surface was. Typed, so
114
+ * "resolved by this PR" is machine-readable instead of a reader sniffing
115
+ * `note` for something that looks like a link.
116
+ *
117
+ * `chant approve --url` sets it; absent when the resolver genuinely had no
118
+ * address (a human at a terminal, no PR). Always an absolute `http`/`https`
119
+ * URL — see {@link isApprovalUrl}.
120
+ */
121
+ url?: string;
59
122
  }
60
123
 
61
124
  export type GateResolutionInput = Omit<GateResolutionRecord, "version">;
@@ -0,0 +1,60 @@
1
+ # A small google-provider estate for the carve-out advisor (#2017), mixed the
2
+ # same way the AWS sample estate is: clean leaves, a hub with boundary work, a
3
+ # tier-3 map, and one resource with no native mapping at all.
4
+
5
+ # Clean leaf: a bucket nothing else reads.
6
+ resource "google_storage_bucket" "assets" {
7
+ name = "myapp-assets-prod"
8
+ location = "US"
9
+ }
10
+
11
+ # Clean leaf with one inbound edge: the subscription reads the topic.
12
+ resource "google_pubsub_topic" "events" {
13
+ name = "myapp-events"
14
+ }
15
+
16
+ resource "google_pubsub_subscription" "worker" {
17
+ name = "myapp-worker"
18
+ topic = google_pubsub_topic.events.name
19
+ }
20
+
21
+ # The hub: two subnets, a router and the cluster all reference the network, so
22
+ # carving it costs four data-source patches to the surviving Terraform.
23
+ resource "google_compute_network" "main" {
24
+ name = "myapp-vpc"
25
+ auto_create_subnetworks = false
26
+ }
27
+
28
+ resource "google_compute_subnetwork" "a" {
29
+ name = "myapp-subnet-a"
30
+ ip_cidr_range = "10.0.1.0/24"
31
+ network = google_compute_network.main.id
32
+ }
33
+
34
+ resource "google_compute_subnetwork" "b" {
35
+ name = "myapp-subnet-b"
36
+ ip_cidr_range = "10.0.2.0/24"
37
+ network = google_compute_network.main.id
38
+ }
39
+
40
+ resource "google_compute_router" "nat" {
41
+ name = "myapp-router"
42
+ network = google_compute_network.main.id
43
+ }
44
+
45
+ # Tier 3: Config Connector inlines node pools the cluster also declares apart.
46
+ resource "google_container_cluster" "primary" {
47
+ name = "myapp-gke"
48
+ network = google_compute_network.main.id
49
+ subnetwork = google_compute_subnetwork.a.id
50
+ }
51
+
52
+ # Identity lives in account_id here, not name.
53
+ resource "google_service_account" "runner" {
54
+ account_id = "myapp-runner"
55
+ }
56
+
57
+ # Leave in Terraform: no native mapping, scored 0.
58
+ resource "random_pet" "suffix" {
59
+ length = 2
60
+ }