@intentius/chant 0.122.0 → 0.123.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 (156) hide show
  1. package/dist/cli/commands/lint.d.ts.map +1 -1
  2. package/dist/cli/handlers/gate-resume.d.ts +1 -1
  3. package/dist/cli/handlers/gate-resume.d.ts.map +1 -1
  4. package/dist/cli/handlers/operator.d.ts +1 -1
  5. package/dist/cli/handlers/operator.d.ts.map +1 -1
  6. package/dist/cli/handlers/override-approve.d.ts +17 -0
  7. package/dist/cli/handlers/override-approve.d.ts.map +1 -0
  8. package/dist/cli/handlers/run-wave.d.ts.map +1 -1
  9. package/dist/cli/main.d.ts.map +1 -1
  10. package/dist/cli/registry.d.ts +4 -0
  11. package/dist/cli/registry.d.ts.map +1 -1
  12. package/dist/gate-decision.d.ts +157 -0
  13. package/dist/gate-decision.d.ts.map +1 -0
  14. package/dist/lifecycle/gate-applied.d.ts +75 -0
  15. package/dist/lifecycle/gate-applied.d.ts.map +1 -0
  16. package/dist/lifecycle/gate-audit.d.ts +145 -0
  17. package/dist/lifecycle/gate-audit.d.ts.map +1 -0
  18. package/dist/lifecycle/gate-ledger-select.d.ts +54 -0
  19. package/dist/lifecycle/gate-ledger-select.d.ts.map +1 -0
  20. package/dist/lifecycle/gate-ledger.d.ts +1 -45
  21. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  22. package/dist/lifecycle/override-ledger.d.ts +34 -0
  23. package/dist/lifecycle/override-ledger.d.ts.map +1 -0
  24. package/dist/lifecycle/rollback.d.ts.map +1 -1
  25. package/dist/lifecycle/work-item-id.d.ts +22 -0
  26. package/dist/lifecycle/work-item-id.d.ts.map +1 -0
  27. package/dist/lifecycle/work-lease.d.ts +2 -2
  28. package/dist/lifecycle/work-lease.d.ts.map +1 -1
  29. package/dist/lint/post-synth.d.ts +20 -1
  30. package/dist/lint/post-synth.d.ts.map +1 -1
  31. package/dist/notify-verify.d.ts +62 -0
  32. package/dist/notify-verify.d.ts.map +1 -0
  33. package/dist/notify.d.ts +202 -0
  34. package/dist/notify.d.ts.map +1 -0
  35. package/dist/op/activities/activity-contracts.d.ts +4 -4
  36. package/dist/op/activities/gh-host.d.ts +37 -0
  37. package/dist/op/activities/gh-host.d.ts.map +1 -0
  38. package/dist/op/activities/lexicon-upgrade.d.ts +11 -0
  39. package/dist/op/activities/lexicon-upgrade.d.ts.map +1 -1
  40. package/dist/op/activities/propose-upgrade.d.ts.map +1 -1
  41. package/dist/op/activities/reconcile.d.ts +74 -76
  42. package/dist/op/activities/reconcile.d.ts.map +1 -1
  43. package/dist/op/gate-approval.d.ts +17 -1
  44. package/dist/op/gate-approval.d.ts.map +1 -1
  45. package/dist/op/gate-name.d.ts +8 -1
  46. package/dist/op/gate-name.d.ts.map +1 -1
  47. package/dist/op/gate-resume.d.ts +4 -2
  48. package/dist/op/gate-resume.d.ts.map +1 -1
  49. package/dist/op/gate-review.d.ts +2 -1
  50. package/dist/op/gate-review.d.ts.map +1 -1
  51. package/dist/op/gate.d.ts +43 -6
  52. package/dist/op/gate.d.ts.map +1 -1
  53. package/dist/op/index.d.ts +8 -6
  54. package/dist/op/index.d.ts.map +1 -1
  55. package/dist/op/op-waves-notify.d.ts +31 -0
  56. package/dist/op/op-waves-notify.d.ts.map +1 -0
  57. package/dist/op/op-waves-run.d.ts +91 -16
  58. package/dist/op/op-waves-run.d.ts.map +1 -1
  59. package/dist/op/op-waves.d.ts +99 -2
  60. package/dist/op/op-waves.d.ts.map +1 -1
  61. package/dist/op/operator.d.ts +15 -0
  62. package/dist/op/operator.d.ts.map +1 -1
  63. package/dist/op/work-lease-decl.d.ts.map +1 -1
  64. package/dist/op/work-lease-run.d.ts.map +1 -1
  65. package/dist/override-gate.d.ts +224 -0
  66. package/dist/override-gate.d.ts.map +1 -0
  67. package/dist/pr-forge.d.ts +198 -19
  68. package/dist/pr-forge.d.ts.map +1 -1
  69. package/dist/workspace/status-gates.d.ts +1 -0
  70. package/dist/workspace/status-gates.d.ts.map +1 -1
  71. package/dist/workspace/trust/gate-seal.d.ts +96 -0
  72. package/dist/workspace/trust/gate-seal.d.ts.map +1 -0
  73. package/dist/workspace/trust/policy.d.ts +2 -41
  74. package/dist/workspace/trust/policy.d.ts.map +1 -1
  75. package/dist/workspace/trust/seal.d.ts +2 -27
  76. package/dist/workspace/trust/seal.d.ts.map +1 -1
  77. package/dist/workspace/trust/trust-files.d.ts +57 -0
  78. package/dist/workspace/trust/trust-files.d.ts.map +1 -0
  79. package/dist/workspace/work-cli.d.ts.map +1 -1
  80. package/package.json +2 -1
  81. package/src/cli/commands/build.ts +1 -1
  82. package/src/cli/commands/lint.ts +5 -2
  83. package/src/cli/handlers/__snapshots__/build-member.test.ts.snap +2 -2
  84. package/src/cli/handlers/gate-resume.ts +3 -2
  85. package/src/cli/handlers/operator.test.ts +12 -1
  86. package/src/cli/handlers/operator.ts +25 -13
  87. package/src/cli/handlers/override-approve.test.ts +87 -0
  88. package/src/cli/handlers/override-approve.ts +103 -0
  89. package/src/cli/handlers/run-wave.ts +10 -2
  90. package/src/cli/main.test.ts +8 -0
  91. package/src/cli/main.ts +15 -0
  92. package/src/cli/registry.ts +4 -0
  93. package/src/gate-decision-bundle.test.ts +56 -0
  94. package/src/gate-decision.test.ts +237 -0
  95. package/src/gate-decision.ts +321 -0
  96. package/src/lifecycle/gate-applied.ts +115 -0
  97. package/src/lifecycle/gate-audit-bundle.test.ts +47 -0
  98. package/src/lifecycle/gate-audit.test.ts +134 -0
  99. package/src/lifecycle/gate-audit.ts +438 -0
  100. package/src/lifecycle/gate-ledger-select.ts +113 -0
  101. package/src/lifecycle/gate-ledger.test.ts +22 -0
  102. package/src/lifecycle/gate-ledger.ts +9 -104
  103. package/src/lifecycle/override-ledger.test.ts +63 -0
  104. package/src/lifecycle/override-ledger.ts +69 -0
  105. package/src/lifecycle/rollback.ts +5 -1
  106. package/src/lifecycle/work-item-id.test.ts +71 -0
  107. package/src/lifecycle/work-item-id.ts +35 -0
  108. package/src/lifecycle/work-lease.ts +4 -5
  109. package/src/lint/post-synth.test.ts +29 -0
  110. package/src/lint/post-synth.ts +21 -2
  111. package/src/notify-bundle.test.ts +50 -0
  112. package/src/notify-verify.test.ts +59 -0
  113. package/src/notify-verify.ts +93 -0
  114. package/src/notify.schema.json +72 -0
  115. package/src/notify.test.ts +279 -0
  116. package/src/notify.ts +398 -0
  117. package/src/op/activities/gh-host.test.ts +130 -0
  118. package/src/op/activities/gh-host.ts +79 -0
  119. package/src/op/activities/lexicon-upgrade.ts +22 -1
  120. package/src/op/activities/propose-upgrade.ts +5 -1
  121. package/src/op/activities/reconcile.test.ts +284 -280
  122. package/src/op/activities/reconcile.ts +128 -131
  123. package/src/op/gate-approval.test.ts +175 -7
  124. package/src/op/gate-approval.ts +27 -1
  125. package/src/op/gate-name.ts +10 -2
  126. package/src/op/gate-resume.ts +6 -3
  127. package/src/op/gate-review.ts +4 -7
  128. package/src/op/gate.ts +117 -16
  129. package/src/op/index.ts +20 -5
  130. package/src/op/op-waves-notify.test.ts +127 -0
  131. package/src/op/op-waves-notify.ts +101 -0
  132. package/src/op/op-waves-run.ts +527 -104
  133. package/src/op/op-waves.test.ts +383 -3
  134. package/src/op/op-waves.ts +187 -6
  135. package/src/op/operator.test.ts +29 -0
  136. package/src/op/operator.ts +27 -2
  137. package/src/op/run-context.test.ts +2 -2
  138. package/src/op/work-lease-decl.ts +4 -4
  139. package/src/op/work-lease-run.test.ts +5 -0
  140. package/src/op/work-lease-run.ts +4 -3
  141. package/src/override-gate-bundle.test.ts +47 -0
  142. package/src/override-gate.test.ts +239 -0
  143. package/src/override-gate.ts +395 -0
  144. package/src/pr-forge-bundle.test.ts +40 -0
  145. package/src/pr-forge.test.ts +303 -0
  146. package/src/pr-forge.ts +502 -47
  147. package/src/workspace/records-write-contract.test.ts +3 -2
  148. package/src/workspace/status-gates.ts +7 -4
  149. package/src/workspace/trust/gate-seal.test.ts +156 -0
  150. package/src/workspace/trust/gate-seal.ts +254 -0
  151. package/src/workspace/trust/policy.ts +10 -165
  152. package/src/workspace/trust/seal.ts +19 -34
  153. package/src/workspace/trust/trust-files.test.ts +50 -0
  154. package/src/workspace/trust/trust-files.ts +245 -0
  155. package/src/workspace/work-cli.ts +5 -3
  156. package/src/workspace/work-history.test.ts +7 -0
@@ -0,0 +1,438 @@
1
+ /**
2
+ * An audit trail from the gate ledger (#3764, decision #3756). Pure: ledger
3
+ * lines (and, if the product has them, the applies that ran under a gate) go
4
+ * in; typed entries and the gaps they show come out. It reads no git, no
5
+ * clock and no file, so it bundles into a product's single CI file
6
+ * (`gate-audit-bundle.test.ts` holds the import graph).
7
+ *
8
+ * Both products derived this themselves: terragucci's `report/audit.ts` and
9
+ * SQL Yodeler's `report/audit.ts` each parse `_gates/<op>.jsonl`. Storage
10
+ * (an append-only record, a bucket, a history table) and rendering stay with
11
+ * them.
12
+ *
13
+ * ## Entries
14
+ *
15
+ * | kind | source line |
16
+ * |----------------------|------------------------------------------------------------------|
17
+ * | `approval-requested` | a pending fact of a gate that is not an override |
18
+ * | `approved` | a resolution of such a gate (seal, approver, review, relay) |
19
+ * | `override-requested` | a pending fact of an override gate: a denial that waits |
20
+ * | `override-recorded` | a resolution of an override gate (reason in `note`) |
21
+ * | `revoked` | a resolution a commit removed from the file (`subject` says which) |
22
+ * | `applied` | an apply the caller passes in, with the approval it ran under |
23
+ * | `override-spent` | an override an apply the caller passes in cites |
24
+ *
25
+ * An override gate is, by default, any gate of the op `policy-override`
26
+ * (terragucci writes one gate per root) or named `override-<rule>` (SQL
27
+ * Yodeler's `yodel override`). chant's override gate (#3760,
28
+ * `../override-gate.ts`) records under the op `policy-override`, so the
29
+ * default reads it, and its `overrideAuditApplies` turns applied records into
30
+ * applies that cite the override they spent. {@link GateAuditOptions.isOverride}
31
+ * replaces the rule. Anything a line carries beyond the typed fields is kept in
32
+ * `detail`, and `kind` is checked against {@link GATE_AUDIT_KINDS} only by
33
+ * the type, never by the reader.
34
+ *
35
+ * ## Gaps
36
+ *
37
+ * - `approval-without-request`: a resolution with no pending fact of its gate
38
+ * before it (or none for its digest). Overrides are exempt: both products
39
+ * may record one without a denial on file.
40
+ * - `unsealed`: an approval or override with neither a seal nor a pull
41
+ * request review, where {@link GateAuditOptions.sealed} says the gate needs
42
+ * one.
43
+ * - `approval`: an apply with a digest that no approval of its gate holds
44
+ * for that digest, recorded at or before the apply. Needs `approval:
45
+ * "not-required"` to be waived, as a baseline or a policy that asked for
46
+ * no approval does.
47
+ * - `override`: an apply cites an override that no line holds.
48
+ * - `malformed-line`: a line that is not a gate record. Its content is lost
49
+ * to the trail, so it is said.
50
+ */
51
+ import { canonicalJson } from "../effect-receipt";
52
+ import { contentDigest } from "../content-digest";
53
+ import { samePlanDigest } from "./plan-digest";
54
+ import type { GateLedgerPort } from "../op/gate";
55
+
56
+ export const GATE_AUDIT_KINDS = [
57
+ "approval-requested",
58
+ "approved",
59
+ "override-requested",
60
+ "override-recorded",
61
+ "revoked",
62
+ "applied",
63
+ "override-spent",
64
+ ] as const;
65
+ export type GateAuditKind = (typeof GATE_AUDIT_KINDS)[number];
66
+
67
+ /** How an approval was authenticated: an ssh seal, a forge review, or neither. */
68
+ export type GateAuditSeal = "sealed" | "review" | "unsigned";
69
+
70
+ /**
71
+ * One line of a ledger file. Give the `text` as it is on disk, or the parsed
72
+ * `record` a {@link GateLedgerPort} returns. A line a commit removed carries
73
+ * `removed`, with when and by whom (the commit's date and author).
74
+ */
75
+ export interface GateLedgerLine {
76
+ text?: string;
77
+ record?: unknown;
78
+ commit?: string;
79
+ removed?: { at: string; by?: string };
80
+ }
81
+
82
+ /** One op's ledger file. */
83
+ export interface GateAuditLedger {
84
+ op: string;
85
+ /** For the entries' source, such as `_gates/<op>.jsonl`. Default that. */
86
+ path?: string;
87
+ /** Oldest first. */
88
+ lines: readonly GateLedgerLine[];
89
+ }
90
+
91
+ /** An apply, from the product's own records (a wave report, a history row). */
92
+ export interface GateAuditApply {
93
+ /** Where it was recorded: a report path, a history row. Goes into the id, so the same source gives the same id. */
94
+ ref: string;
95
+ at: string;
96
+ /** The gate it ran under. */
97
+ gate: string;
98
+ /** Narrows the approval to one ledger. Default any. */
99
+ op?: string;
100
+ /** The plan digest it applied. Without one (a baseline) no approval is looked for. */
101
+ digest?: string;
102
+ by?: string;
103
+ /** `not-required` when a policy at base let it through with no approval. Default `required`. */
104
+ approval?: "required" | "not-required";
105
+ /** Overrides it was let through by. */
106
+ overrides?: ReadonlyArray<{ gate: string; digest?: string }>;
107
+ detail?: Record<string, unknown>;
108
+ }
109
+
110
+ export interface GateAuditSource {
111
+ /** The ledger file, or the apply's `ref` for an apply. */
112
+ path: string;
113
+ op?: string;
114
+ commit?: string;
115
+ change: "added" | "removed" | "apply";
116
+ }
117
+
118
+ export interface GateAuditEntry {
119
+ /** `sha256:` over where the entry came from: the same sources give the same id. */
120
+ id: string;
121
+ kind: GateAuditKind;
122
+ /** When it happened: the line's timestamp, the removing commit's date, or the apply's. */
123
+ at: string;
124
+ gate: string;
125
+ op?: string;
126
+ /** The plan digest the line binds. */
127
+ digest?: string;
128
+ /** Who: the approver or overrider, whoever removed the line, or for an apply, whoever ran it. */
129
+ by?: string;
130
+ /** Resolutions and revocations: sealed, reviewed, or unsigned. */
131
+ seal?: GateAuditSeal;
132
+ /** The ssh signer, when sealed. */
133
+ signer?: string;
134
+ /** Who carried the approval to chant (`chant approve --relayed-by`). */
135
+ relayedBy?: string;
136
+ /** A person or an agent, and the roles claimed. */
137
+ approver?: { kind: string; roles?: string[] };
138
+ /** The free text: an override's reason, an approval's note. */
139
+ note?: string;
140
+ url?: string;
141
+ /** The forge review an approval came from. */
142
+ review?: { pr?: number; head?: string; approvers?: string[] };
143
+ /** Pending facts: the run that reached the gate, when it expires, what it describes. */
144
+ runId?: string;
145
+ expiresAt?: string;
146
+ description?: string;
147
+ /** Overrides: the rules the denial named (from the denial line when there is one). */
148
+ rules?: string[];
149
+ /** A revoked entry: what was revoked. */
150
+ subject?: "approval" | "override";
151
+ /** An applied entry: the approval entry it ran under, if one is held. An override-spent entry: the override entry. */
152
+ under?: string;
153
+ /** Everything else the line carries, such as `members`, `via` or a product's own fields. */
154
+ detail: Record<string, unknown>;
155
+ source: GateAuditSource;
156
+ }
157
+
158
+ export interface GateAuditGap {
159
+ kind: "approval-without-request" | "unsealed" | "approval" | "override" | "malformed-line";
160
+ op?: string;
161
+ gate?: string;
162
+ digest?: string;
163
+ detail: string;
164
+ }
165
+
166
+ export interface GateAuditOptions {
167
+ /** Whether a gate is an override gate. Default: op `policy-override`, or a gate named `override-...`. */
168
+ isOverride?: (op: string, gate: string) => boolean;
169
+ /** Whether approvals of a gate need a seal or a review. Default none. */
170
+ sealed?: (op: string, gate: string) => boolean;
171
+ }
172
+
173
+ export interface GateAuditInput extends GateAuditOptions {
174
+ ledgers: readonly GateAuditLedger[];
175
+ applies?: readonly GateAuditApply[];
176
+ }
177
+
178
+ export const POLICY_OVERRIDE_OP = "policy-override";
179
+
180
+ /** The default {@link GateAuditOptions.isOverride}. */
181
+ export function isOverrideGate(op: string, gate: string): boolean {
182
+ return op === POLICY_OVERRIDE_OP || gate.startsWith("override-");
183
+ }
184
+
185
+ type Row = Record<string, unknown>;
186
+
187
+ const KNOWN = new Set([
188
+ "version", "kind", "op", "gate", "resolvedBy", "relayedBy", "timestamp", "note", "url", "planDigest", "environment",
189
+ "approver", "seal", "review", "runId", "expiresAt", "description", "rules", "pr", "head", "reviewers",
190
+ ]);
191
+
192
+ const str = (v: unknown): string | undefined => (typeof v === "string" && v !== "" ? v : undefined);
193
+ const strings = (v: unknown): string[] | undefined => (Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : undefined);
194
+ const time = (iso: string): number => Date.parse(iso) || 0;
195
+
196
+ function rowOf(line: GateLedgerLine): Row | undefined {
197
+ let v: unknown = line.record;
198
+ if (v === undefined && line.text !== undefined) {
199
+ try {
200
+ v = JSON.parse(line.text);
201
+ } catch {
202
+ return undefined;
203
+ }
204
+ }
205
+ return v && typeof v === "object" && !Array.isArray(v) ? (v as Row) : undefined;
206
+ }
207
+
208
+ function sealOf(r: Row): { seal: GateAuditSeal; signer?: string } {
209
+ const s = r.seal as { signature?: unknown; signer?: unknown } | null | undefined;
210
+ if (s && s.signature) return { seal: "sealed", ...(str(s.signer) ? { signer: str(s.signer) } : {}) };
211
+ if (r.review || r.via === "pr-review") return { seal: "review" };
212
+ return { seal: "unsigned" };
213
+ }
214
+
215
+ function reviewOf(r: Row): GateAuditEntry["review"] {
216
+ const rv = (r.review ?? undefined) as Row | undefined;
217
+ const pr = typeof rv?.pr === "number" ? rv.pr : typeof r.pr === "number" ? r.pr : undefined;
218
+ const head = str(rv?.head) ?? str(r.head);
219
+ const approvers = strings(rv?.approvers) ?? strings(r.reviewers);
220
+ if (pr === undefined && head === undefined && approvers === undefined) return undefined;
221
+ return { ...(pr !== undefined ? { pr } : {}), ...(head ? { head } : {}), ...(approvers ? { approvers } : {}) };
222
+ }
223
+
224
+ function approverOf(r: Row): GateAuditEntry["approver"] {
225
+ const a = r.approver as { kind?: unknown; roles?: unknown } | undefined;
226
+ if (!a || typeof a.kind !== "string") return undefined;
227
+ const roles = strings(a.roles);
228
+ return { kind: a.kind, ...(roles ? { roles } : {}) };
229
+ }
230
+
231
+ const drop = <T extends object>(o: T): T => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined)) as T;
232
+
233
+ const idOf = (...parts: unknown[]): string => contentDigest(canonicalJson(parts));
234
+
235
+ interface Seen {
236
+ op: string;
237
+ gate: string;
238
+ at: number;
239
+ digest?: string;
240
+ environment?: string;
241
+ }
242
+
243
+ /** Whether two digests, either absent, can be the same plan: absent matches anything, as an old line records none. */
244
+ const compatible = (a: string | undefined, b: string | undefined): boolean => a === undefined || b === undefined || samePlanDigest(a, b);
245
+
246
+ /** The entries `input` adds up to, oldest first, and the gaps they show. */
247
+ export function deriveGateAudit(input: GateAuditInput): { entries: GateAuditEntry[]; gaps: GateAuditGap[] } {
248
+ const isOverride = input.isOverride ?? isOverrideGate;
249
+ const entries: GateAuditEntry[] = [];
250
+ const gaps: GateAuditGap[] = [];
251
+
252
+ for (const ledger of input.ledgers) {
253
+ const path = ledger.path ?? `_gates/${ledger.op}.jsonl`;
254
+ const parsed: { line: GateLedgerLine; r: Row; pending: boolean }[] = [];
255
+ for (const line of ledger.lines) {
256
+ const r = rowOf(line);
257
+ const pending = r?.kind === "pending";
258
+ const ok = r && r.version === 1 && typeof r.gate === "string" && typeof r.timestamp === "string" && (pending || typeof r.resolvedBy === "string");
259
+ if (!r || !ok) {
260
+ gaps.push({ kind: "malformed-line", op: ledger.op, detail: `${path} holds a line that is not a gate record${line.commit ? ` (commit ${line.commit})` : ""}: ${(line.text ?? JSON.stringify(line.record) ?? "").slice(0, 80)}` });
261
+ continue;
262
+ }
263
+ parsed.push({ line, r, pending });
264
+ }
265
+
266
+ // Each denial an override answers: the newest pending fact of its gate and digest.
267
+ const denials = new Map<string, Row>();
268
+ const requests: Seen[] = [];
269
+ for (const p of parsed) {
270
+ if (!p.pending || p.line.removed) continue;
271
+ const digest = str(p.r.planDigest);
272
+ denials.set(`${String(p.r.gate)}\n${digest ?? ""}`, p.r);
273
+ requests.push({ op: ledger.op, gate: String(p.r.gate), at: time(String(p.r.timestamp)), ...(digest ? { digest } : {}), ...(str(p.r.environment) ? { environment: str(p.r.environment) } : {}) });
274
+ }
275
+
276
+ for (const { line, r, pending } of parsed) {
277
+ const gate = String(r.gate);
278
+ const override = isOverride(ledger.op, gate);
279
+ const removed = line.removed;
280
+ if (pending && removed) continue;
281
+ const digest = str(r.planDigest);
282
+ const source: GateAuditSource = { path, op: ledger.op, ...(line.commit ? { commit: line.commit } : {}), change: removed ? "removed" : "added" };
283
+ const id = idOf(removed ? "removed" : "ledger", path, line.commit ?? null, r);
284
+ const extra = Object.fromEntries(Object.entries(r).filter(([k]) => !KNOWN.has(k)));
285
+ const base = { id, gate, op: ledger.op, ...(digest ? { digest } : {}), source } as const;
286
+
287
+ if (pending) {
288
+ entries.push({
289
+ ...base,
290
+ kind: override ? "override-requested" : "approval-requested",
291
+ at: String(r.timestamp),
292
+ ...drop({ runId: str(r.runId), expiresAt: str(r.expiresAt), description: str(r.description), url: str(r.url), rules: strings(r.rules) }),
293
+ detail: extra,
294
+ });
295
+ continue;
296
+ }
297
+
298
+ const { seal, signer } = sealOf(r);
299
+ if (removed) {
300
+ entries.push({
301
+ ...base,
302
+ kind: "revoked",
303
+ at: removed.at,
304
+ ...(removed.by ? { by: removed.by } : {}),
305
+ subject: override ? "override" : "approval",
306
+ seal,
307
+ detail: { ...extra, approvedBy: r.resolvedBy, approvedAt: r.timestamp, ...(signer ? { signer } : {}) },
308
+ });
309
+ continue;
310
+ }
311
+
312
+ const denial = override ? denials.get(`${gate}\n${digest ?? ""}`) : undefined;
313
+ const rules = strings(denial?.rules) ?? (override && gate.startsWith("override-") ? [gate.slice("override-".length)] : undefined);
314
+ const entry: GateAuditEntry = {
315
+ ...base,
316
+ kind: override ? "override-recorded" : "approved",
317
+ at: String(r.timestamp),
318
+ by: String(r.resolvedBy),
319
+ seal,
320
+ ...drop({ signer, relayedBy: str(r.relayedBy), approver: approverOf(r), note: str(r.note), url: str(r.url), review: reviewOf(r), rules }),
321
+ detail: extra,
322
+ };
323
+ entries.push(entry);
324
+
325
+ if (!override) {
326
+ const at = time(entry.at);
327
+ const env = str(r.environment);
328
+ const asked = requests.some((q) => q.gate === gate && q.at <= at && compatible(q.digest, digest) && (q.environment === undefined || env === undefined || q.environment === env));
329
+ if (!asked) {
330
+ gaps.push({ kind: "approval-without-request", op: ledger.op, gate, ...(digest ? { digest } : {}), detail: `${path} holds an approval of ${gate} by ${entry.by} at ${entry.at} with no pending fact of that gate${digest ? " and digest" : ""} before it` });
331
+ }
332
+ }
333
+ if (seal === "unsigned" && input.sealed?.(ledger.op, gate)) {
334
+ gaps.push({ kind: "unsealed", op: ledger.op, gate, ...(digest ? { digest } : {}), detail: `${path} holds ${override ? "an override" : "an approval"} of ${gate} by ${entry.by} at ${entry.at} with no seal and no review, and the gate needs one` });
335
+ }
336
+ }
337
+ }
338
+
339
+ const held = entries.filter((e) => e.kind === "approved" || e.kind === "override-recorded");
340
+ for (const a of input.applies ?? []) {
341
+ const base = { gate: a.gate, ...(a.op ? { op: a.op } : {}), ...(a.digest ? { digest: a.digest } : {}), source: { path: a.ref, ...(a.op ? { op: a.op } : {}), change: "apply" as const } };
342
+ const at = time(a.at);
343
+ const find = (kind: GateAuditKind, gate: string, digest: string | undefined, before: boolean): GateAuditEntry | undefined =>
344
+ held
345
+ .filter((e) => e.kind === kind && e.gate === gate && (a.op === undefined || e.op === a.op) && (digest === undefined ? true : e.digest !== undefined && samePlanDigest(e.digest, digest)) && (!before || time(e.at) <= at))
346
+ .sort((x, y) => time(y.at) - time(x.at))[0];
347
+
348
+ const approval = a.digest !== undefined && a.approval !== "not-required" ? find("approved", a.gate, a.digest, true) : undefined;
349
+ if (a.digest !== undefined && a.approval !== "not-required" && !approval) {
350
+ gaps.push({ kind: "approval", ...(a.op ? { op: a.op } : {}), gate: a.gate, digest: a.digest, detail: `${a.ref} applied under ${a.digest}, but the ledger holds no approval of ${a.gate} for that digest before ${a.at}` });
351
+ }
352
+ entries.push({
353
+ ...base,
354
+ id: idOf("apply", a.ref, a.at, a.gate, a.digest ?? null),
355
+ kind: "applied",
356
+ at: a.at,
357
+ ...(a.by ? { by: a.by } : {}),
358
+ ...(approval ? { under: approval.id } : {}),
359
+ detail: { ...(a.detail ?? {}), approval: a.approval ?? "required" },
360
+ });
361
+
362
+ for (const o of a.overrides ?? []) {
363
+ const recorded = find("override-recorded", o.gate, o.digest, false);
364
+ if (!recorded) {
365
+ gaps.push({ kind: "override", ...(a.op ? { op: a.op } : {}), gate: o.gate, ...(o.digest ? { digest: o.digest } : {}), detail: `${a.ref} cites the override ${o.gate}${o.digest ? ` for ${o.digest}` : ""}, but the ledger holds none` });
366
+ continue;
367
+ }
368
+ entries.push({
369
+ ...base,
370
+ id: idOf("spent", a.ref, a.at, recorded.id),
371
+ kind: "override-spent",
372
+ gate: o.gate,
373
+ at: a.at,
374
+ ...(recorded.by ? { by: recorded.by } : {}),
375
+ ...(recorded.digest ? { digest: recorded.digest } : {}),
376
+ under: recorded.id,
377
+ detail: {},
378
+ });
379
+ }
380
+ }
381
+
382
+ entries.sort((x, y) => time(x.at) - time(y.at));
383
+ return { entries, gaps };
384
+ }
385
+
386
+ /** The lines of one ledger file's text, all added. Blank lines are skipped. */
387
+ export function gateLedgerLines(content: string): GateLedgerLine[] {
388
+ return content.split("\n").map((l) => l.trim()).filter(Boolean).map((text) => ({ text }));
389
+ }
390
+
391
+ /**
392
+ * The lines each commit added to and removed from a ledger file, oldest first,
393
+ * from `git log -p --unified=0 --no-renames` written with
394
+ * `--format=%x1e%H%x1f%an%x1f%aI` over the file. The caller runs git; this
395
+ * reads the text. A line removed and added again in one commit (a newline
396
+ * fixed at the end of the file) is neither. A removed line is what
397
+ * {@link deriveGateAudit} reports as a revocation.
398
+ */
399
+ export function gateLedgerLinesFromLog(log: string): GateLedgerLine[] {
400
+ const out: GateLedgerLine[] = [];
401
+ for (const block of log.split("\x1e").slice(1)) {
402
+ const [head = "", ...rest] = block.split("\n");
403
+ const [commit = "", author = "", date = ""] = head.split("\x1f");
404
+ const net = new Map<string, number>();
405
+ const order: string[] = [];
406
+ for (const l of rest) {
407
+ const sign = l[0];
408
+ if ((sign !== "+" && sign !== "-") || l[1] !== "{") continue;
409
+ const text = l.slice(1).trim();
410
+ if (!net.has(text)) order.push(text);
411
+ net.set(text, (net.get(text) ?? 0) + (sign === "+" ? 1 : -1));
412
+ }
413
+ const when = Date.parse(date.trim());
414
+ const at = Number.isNaN(when) ? date.trim() : new Date(when).toISOString();
415
+ for (const text of order) {
416
+ const n = net.get(text)!;
417
+ for (let i = 0; i < Math.abs(n); i++) {
418
+ out.push(n > 0 ? { text, commit: commit.trim() } : { text, commit: commit.trim(), removed: { at, ...(author ? { by: author } : {}) } });
419
+ }
420
+ }
421
+ }
422
+ return out;
423
+ }
424
+
425
+ /**
426
+ * The thin reader: each op's ledger through a {@link GateLedgerPort} (the git
427
+ * port, or the in-memory one in a test), as input for {@link deriveGateAudit}.
428
+ * The port holds the file as it stands, so nothing here is revoked; add
429
+ * history through {@link gateLedgerLinesFromLog} for that.
430
+ */
431
+ export async function readGateAuditLedgers(port: Pick<GateLedgerPort, "read">, ops: readonly string[]): Promise<GateAuditLedger[]> {
432
+ const out: GateAuditLedger[] = [];
433
+ for (const op of ops) {
434
+ const { resolutions, pending } = await port.read(op);
435
+ out.push({ op, lines: [...pending, ...resolutions].map((record) => ({ record })) });
436
+ }
437
+ return out;
438
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * The rules a gate is decided by, over gate ledger lines already read (#2119,
3
+ * #2300): the pending fact a resolution must be newer than, and the
4
+ * resolution that answers a gate for a plan. They read no git, so
5
+ * `@intentius/chant/gate-decision` bundles them without the ledger's git
6
+ * code (#3763). `./gate-ledger.ts` re-exports them.
7
+ */
8
+
9
+ import { samePlanDigest } from "./plan-digest";
10
+ import type { GateResolutionRecord, PendingGateRecord } from "./gate-ledger";
11
+
12
+ /** Whether a pending fact has aged out of relevance at `nowIso`. */
13
+ export function isPendingGateExpired(record: PendingGateRecord, nowIso: string): boolean {
14
+ return new Date(record.expiresAt).getTime() <= new Date(nowIso).getTime();
15
+ }
16
+
17
+ /** The most recent pending fact for `gate`, expired or not — the anchor {@link latestResolutionSince} measures a resolution against. `undefined` when the gate has never been recorded pending. */
18
+ export function latestPendingGate(
19
+ records: PendingGateRecord[],
20
+ gate: string,
21
+ ): PendingGateRecord | undefined {
22
+ let latest: PendingGateRecord | undefined;
23
+ for (const r of records) {
24
+ if (r.gate !== gate) continue;
25
+ if (!latest || new Date(r.timestamp).getTime() >= new Date(latest.timestamp).getTime()) latest = r;
26
+ }
27
+ return latest;
28
+ }
29
+
30
+ /** The most recent resolution for `gate` recorded after `sinceIso` (a gated tick's own timestamp) — what `chant operator status` uses to tell a resolved gate from a still-pending one. `undefined` when no such resolution exists. */
31
+ export function latestResolutionSince(
32
+ records: GateResolutionRecord[],
33
+ gate: string,
34
+ sinceIso: string,
35
+ ): GateResolutionRecord | undefined {
36
+ const since = new Date(sinceIso).getTime();
37
+ let latest: GateResolutionRecord | undefined;
38
+ for (const r of records) {
39
+ if (r.gate !== gate) continue;
40
+ if (new Date(r.timestamp).getTime() < since) continue;
41
+ if (!latest || new Date(r.timestamp).getTime() >= new Date(latest.timestamp).getTime()) latest = r;
42
+ }
43
+ return latest;
44
+ }
45
+
46
+ /** What {@link latestResolutionForPlan} found for the plan a run is holding. */
47
+ export interface PlanBoundResolution {
48
+ /** The resolution that answers this gate for this plan. Absent when nothing does. */
49
+ resolution?: GateResolutionRecord;
50
+ /**
51
+ * Present instead of {@link PlanBoundResolution.resolution} when a
52
+ * resolution stands for this gate but for a different plan — the newest
53
+ * such record, so a refusal can name who approved what and when. Its
54
+ * `planDigest` is `undefined` for a record written before #2300.
55
+ */
56
+ mismatched?: GateResolutionRecord;
57
+ }
58
+
59
+ /**
60
+ * The resolution that answers `gate` for the plan `planDigest` identifies
61
+ * (#2300).
62
+ *
63
+ * The criterion is the digest; recency is only the tiebreak between several
64
+ * resolutions that all match it. That inversion is the whole point of the
65
+ * issue: {@link latestResolutionSince} asks "is there a newer approval",
66
+ * which a run answers yes to no matter what has changed since, and this asks
67
+ * "is there an approval of *this*".
68
+ *
69
+ * - `planDigest` `undefined` — the gate binds no plan (an Op `gate` step
70
+ * authored with no `plan`). Falls straight through to
71
+ * {@link latestResolutionSince}: gates that never claimed to bind a plan
72
+ * behave exactly as they did before #2300.
73
+ * - A resolution whose `planDigest` equals `planDigest` answers the gate.
74
+ * - A resolution for a different plan does not, and comes back as
75
+ * `mismatched` so the caller can name both digests.
76
+ * - A resolution with no `planDigest` at all — every record written before
77
+ * #2300 — does not either, and comes back as `mismatched` with an absent
78
+ * `planDigest`. This is the safe reading of the migration: such a record
79
+ * proves someone approved something, and nothing about what. Accepting it
80
+ * once would silently apply the very change this check exists to catch, on
81
+ * exactly the estates that have been running longest. The cost is one
82
+ * further `chant approve` per gate after the upgrade, which the refusal
83
+ * says out loud.
84
+ */
85
+ export function latestResolutionForPlan(
86
+ records: GateResolutionRecord[],
87
+ gate: string,
88
+ sinceIso: string,
89
+ planDigest: string | undefined,
90
+ ): PlanBoundResolution {
91
+ if (planDigest === undefined) {
92
+ const resolution = latestResolutionSince(records, gate, sinceIso);
93
+ return resolution ? { resolution } : {};
94
+ }
95
+
96
+ const since = new Date(sinceIso).getTime();
97
+ let matched: GateResolutionRecord | undefined;
98
+ let mismatched: GateResolutionRecord | undefined;
99
+ for (const r of records) {
100
+ if (r.gate !== gate) continue;
101
+ if (new Date(r.timestamp).getTime() < since) continue;
102
+ const newest = (best: GateResolutionRecord | undefined) =>
103
+ !best || new Date(r.timestamp).getTime() >= new Date(best.timestamp).getTime();
104
+ if (samePlanDigest(r.planDigest, planDigest)) {
105
+ if (newest(matched)) matched = r;
106
+ } else if (newest(mismatched)) {
107
+ mismatched = r;
108
+ }
109
+ }
110
+
111
+ if (matched) return { resolution: matched };
112
+ return mismatched ? { mismatched } : {};
113
+ }
@@ -419,3 +419,25 @@ describe("readGateLedger — a corrupted planDigest is malformed (#2300)", () =>
419
419
  });
420
420
  });
421
421
  });
422
+
423
+ describe("applied approvals on chant/lifecycle (#3784)", () => {
424
+ test("the git port appends and reads _gates/<op>/applied.jsonl, which the gate ledger reader never sees", async () => {
425
+ const { gitGateLedgerPort } = await import("../op/gate");
426
+ await withTestDir(async (dir) => {
427
+ await initRepo(dir);
428
+ const port = gitGateLedgerPort({ cwd: dir });
429
+ await appendGateResolution({ op: "migrations", gate: "migrations-wave-1", resolvedBy: "alice", timestamp: "2026-10-10T00:00:00.000Z", planDigest: "sha256:aa" }, { cwd: dir });
430
+ const line = {
431
+ version: 1 as const, kind: "applied" as const, op: "migrations", gate: "migrations-wave-1", planDigest: "sha256:aa",
432
+ approvedAt: "2026-10-10T00:00:00.000Z", approvedBy: "alice", timestamp: "2026-10-11T00:00:00.000Z",
433
+ };
434
+ await port.appendApplied!(line);
435
+ await port.appendApplied!({ ...line, timestamp: "2026-10-11T01:00:00.000Z" });
436
+ expect(await port.readApplied!("migrations")).toEqual([line, { ...line, timestamp: "2026-10-11T01:00:00.000Z" }]);
437
+ expect(await readBlobFromPath("_gates/migrations", "applied.jsonl", { cwd: dir })).toMatch(/^\{.*\}\n\{.*\}\n$/);
438
+ const ledger = await port.read("migrations");
439
+ expect(ledger.resolutions).toHaveLength(1);
440
+ expect(ledger.pending).toHaveLength(0);
441
+ });
442
+ });
443
+ });