@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,321 @@
1
+ /**
2
+ * A gate's decision for a plan digest, and the seal check it needs, for a
3
+ * tool that runs from one bundled file (#3763, decision #3756).
4
+ *
5
+ * `@intentius/chant/gate-decision` holds what a tool needs to decide a
6
+ * plan-bound gate the way chant's own wave gate does (`chant run wave`,
7
+ * `./op/op-waves-run.ts`) under each approval mode:
8
+ *
9
+ * - `ledger`: an approval of this digest on `chant/lifecycle`, newer than the
10
+ * gate's newest pending fact ({@link decideGateApproval}).
11
+ * - `sealed`: the same, counting only an approval whose seal verifies for its
12
+ * approver against the signers file at base ({@link gateSignersAt},
13
+ * {@link gateSealRefusal}). The seal is checked with node:crypto, so the job
14
+ * needs no ssh-keygen.
15
+ * - `pr-review`: the same as `ledger`, or else the merged pull request's
16
+ * approving review, when its head planned this digest
17
+ * ({@link reviewApproval}).
18
+ *
19
+ * With `spend` (#3784), an approval a wave already applied under is spent and
20
+ * counts for nothing, as terragucci's wave gate has it: the caller hands in
21
+ * the lines of `_gates/<op>/applied.jsonl` ({@link parseAppliedGateLines}).
22
+ *
23
+ * The module reads the ledger lines and the review it is handed; reading
24
+ * them is the caller's. It imports no TypeScript, esbuild, zod, lint, fold or
25
+ * codegen module, and none of chant's git ledger code, which
26
+ * `gate-decision-bundle.test.ts` holds it to. Its rules are chant's own:
27
+ * `./lifecycle/gate-ledger-select.ts` is what `evaluateGate` decides by, and
28
+ * `gate-decision.test.ts` decides the same ledgers both ways.
29
+ */
30
+
31
+ import { spawnSync } from "node:child_process";
32
+ import {
33
+ isPendingGateExpired,
34
+ latestPendingGate,
35
+ latestResolutionForPlan,
36
+ } from "./lifecycle/gate-ledger-select";
37
+ import { samePlanDigest } from "./lifecycle/plan-digest";
38
+ import { isApprovalSpent, type AppliedGateRecord } from "./lifecycle/gate-applied";
39
+ import { gateSealRefusal, type GateSigners } from "./workspace/trust/gate-seal";
40
+ import {
41
+ DEFAULT_SIGNERS_PATH,
42
+ TRUST_CONFIG_PATH,
43
+ parseAllowedSigners,
44
+ trustConfigProblems,
45
+ } from "./workspace/trust/trust-files";
46
+ import { parseJsonText } from "./workspace/jsonc";
47
+ import type { GateResolutionRecord, PendingGateRecord } from "./lifecycle/gate-ledger";
48
+ import type { MergedReview } from "./op/gate-review";
49
+ import type { GateApprovalSource } from "./op/op-waves";
50
+
51
+ export { GATE_APPROVAL_SOURCES, type GateApprovalSource } from "./op/op-waves";
52
+ export {
53
+ isPendingGateExpired,
54
+ latestPendingGate,
55
+ latestResolutionForPlan,
56
+ latestResolutionSince,
57
+ type PlanBoundResolution,
58
+ } from "./lifecycle/gate-ledger-select";
59
+ export {
60
+ GATE_SEAL_NAMESPACE,
61
+ gateSealPayload,
62
+ gateSealRefusal,
63
+ sshKeyFingerprint,
64
+ verifySshSignatureWith,
65
+ type GateSigners,
66
+ type SealedGateApproval,
67
+ type SshSignatureCheck,
68
+ } from "./workspace/trust/gate-seal";
69
+ export {
70
+ DEFAULT_SIGNERS_PATH,
71
+ TRUST_CONFIG_PATH,
72
+ parseAllowedSigners,
73
+ trustConfigProblems,
74
+ type Signer,
75
+ type SignerSet,
76
+ } from "./workspace/trust/trust-files";
77
+ export type { GateResolutionRecord, PendingGateRecord } from "./lifecycle/gate-ledger";
78
+ export {
79
+ appliedGateApplies,
80
+ appliedGatePath,
81
+ isApprovalSpent,
82
+ parseAppliedGateLines,
83
+ type AppliedGateRecord,
84
+ } from "./lifecycle/gate-applied";
85
+ export type { MergedReview } from "./op/gate-review";
86
+
87
+ // ── reading at base ──────────────────────────────────────────────────────
88
+
89
+ function git(repo: string, args: string[]) {
90
+ return spawnSync("git", args, { cwd: repo, encoding: "utf-8" });
91
+ }
92
+
93
+ /** A file's text at `base`, or undefined when `base` holds no file at `path`. */
94
+ function showAt(repo: string, base: string, path: string): string | undefined {
95
+ const r = git(repo, ["cat-file", "blob", `${base}:${path}`]);
96
+ return r.status === 0 ? r.stdout : undefined;
97
+ }
98
+
99
+ /**
100
+ * The commit a gate's rule is read at when the default branch applies its
101
+ * own commit: the first parent of HEAD, the branch as it stood before the
102
+ * merge, so a merge that adds a signer or drops a gate does not judge its own
103
+ * apply. A shallow checkout fetches that one commit from `origin`. A root
104
+ * commit has no parent and is its own base. Throws when it cannot be read.
105
+ */
106
+ export function baseCommit(repo: string): string {
107
+ const head = git(repo, ["rev-parse", "--verify", "-q", "HEAD^{commit}"]);
108
+ const sha = head.stdout.trim();
109
+ if (head.status !== 0 || !sha) throw new Error(`${repo} has no commit checked out, so the gate cannot be decided`);
110
+ // The commit object's first `parent` line: a shallow clone keeps it even when it lacks the parent itself.
111
+ const header = git(repo, ["cat-file", "commit", sha]).stdout.split("\n\n")[0] ?? "";
112
+ const parent = /^parent ([0-9a-f]{40,64})$/m.exec(header)?.[1];
113
+ if (!parent) return sha;
114
+ const has = () => git(repo, ["cat-file", "-e", `${parent}^{commit}`]).status === 0;
115
+ if (!has()) git(repo, ["fetch", "-q", "--depth=1", "origin", parent]);
116
+ if (!has()) throw new Error(`cannot fetch ${parent.slice(0, 12)}, the commit before this one, so the gate cannot be decided`);
117
+ return parent;
118
+ }
119
+
120
+ /**
121
+ * The signers at `base`: the file `.chant/trust.json` names there, else
122
+ * `.chant/allowed_signers`, read as chant's trust policy reads it. A
123
+ * trust.json that is not valid (`trustConfigProblems`) turns attestation
124
+ * off, as it does in chant, so a sealed gate passes nothing. `base` is a
125
+ * commit the repository holds, such as {@link baseCommit}'s answer.
126
+ */
127
+ export function gateSignersAt(repo: string, base: string): GateSigners {
128
+ const off = (problems: string[]): GateSigners => ({ base, signersPath: DEFAULT_SIGNERS_PATH, active: false, signers: [], problems });
129
+ const configText = showAt(repo, base, TRUST_CONFIG_PATH);
130
+ let signersPath = DEFAULT_SIGNERS_PATH;
131
+ if (configText !== undefined) {
132
+ let raw: unknown;
133
+ try {
134
+ raw = JSON.parse(configText);
135
+ } catch (err) {
136
+ return off([`${TRUST_CONFIG_PATH} is not JSON: ${err instanceof Error ? err.message : String(err)}`]);
137
+ }
138
+ const problems = trustConfigProblems(raw);
139
+ if (problems.length > 0) return off([`${TRUST_CONFIG_PATH} is invalid: ${problems.join("; ")}`]);
140
+ signersPath = (raw as { signers?: string }).signers ?? DEFAULT_SIGNERS_PATH;
141
+ }
142
+ const text = showAt(repo, base, signersPath);
143
+ return { base, signersPath, active: text !== undefined, signers: text === undefined ? [] : parseAllowedSigners(text).signers, problems: [] };
144
+ }
145
+
146
+ /** A gate the declaration at base names under `identity.gates`, and the principal class its signer must be in, if any. */
147
+ export interface IdentityGate {
148
+ gate: string;
149
+ class: string | null;
150
+ }
151
+
152
+ /**
153
+ * The gates `chant.workspace.json` (or `.jsonc`) at `base` names under
154
+ * `identity.gates`: each counts only a sealed approval, from its class when
155
+ * it names one. chant checks the class against the role grants at base; this
156
+ * reads the names. Empty when there is no declaration at base. Throws when
157
+ * the declaration there cannot be parsed, since the gate cannot be decided.
158
+ */
159
+ export function identityGatesAt(repo: string, base: string): IdentityGate[] {
160
+ for (const file of ["chant.workspace.json", "chant.workspace.jsonc"]) {
161
+ const text = showAt(repo, base, file);
162
+ if (text === undefined) continue;
163
+ const parsed = parseJsonText(text, { jsonc: file.endsWith(".jsonc") });
164
+ if (!parsed.ok) throw new Error(`${file} at base is not valid JSON (${parsed.message}), so the gate cannot be decided`);
165
+ const gates = (parsed.value as { identity?: { gates?: Record<string, { class?: unknown } | null> } } | null)?.identity?.gates ?? {};
166
+ return Object.entries(gates).map(([gate, entry]) => ({ gate, class: typeof entry?.class === "string" ? entry.class : null }));
167
+ }
168
+ return [];
169
+ }
170
+
171
+ // ── pr-review ────────────────────────────────────────────────────────────
172
+
173
+ /** What a pull request's review says about a wave, under `pr-review`. */
174
+ export interface ReviewApproval {
175
+ approved: boolean;
176
+ review: { pr?: number; head?: string; approvers?: string[]; url?: string; reason?: string };
177
+ }
178
+
179
+ /**
180
+ * Whether the merged pull request's review approves `digest` (#3684): a
181
+ * writer other than its author approved its head, and the head planned this
182
+ * digest (`headDigest`, from the plans `chant run wave --record-plans`
183
+ * recorded for the head; undefined when none were). Reading the review and
184
+ * the plans is the caller's; under `pr-review` the caller records the
185
+ * approval on the ledger when this approves.
186
+ */
187
+ export function reviewApproval(merged: MergedReview, headDigest: string | undefined, digest: string): ReviewApproval {
188
+ const seen = { pr: merged.pr, head: merged.head, ...(merged.url ? { url: merged.url } : {}) };
189
+ if (merged.approvers.length === 0) {
190
+ const why = merged.refused.length > 0 ? merged.refused.join("; ") : "nobody approved it";
191
+ return { approved: false, review: { ...seen, reason: `no writer other than its author approved the head of #${merged.pr}: ${why}` } };
192
+ }
193
+ if (headDigest === undefined) {
194
+ return {
195
+ approved: false,
196
+ review: { ...seen, approvers: merged.approvers, reason: `no plans are recorded for the head ${merged.head.slice(0, 12)} of #${merged.pr} (its pipeline runs \`chant run wave --record-plans\`)` },
197
+ };
198
+ }
199
+ if (headDigest !== digest) {
200
+ return {
201
+ approved: false,
202
+ review: { ...seen, approvers: merged.approvers, reason: `the plan moved after the review: the head planned ${headDigest}, and the merge plans ${digest}` },
203
+ };
204
+ }
205
+ return { approved: true, review: { ...seen, approvers: merged.approvers } };
206
+ }
207
+
208
+ // ── the decision ─────────────────────────────────────────────────────────
209
+
210
+ /** The gate ledger lines of one op, as `chant/lifecycle` holds them in `_gates/<op>.jsonl`. */
211
+ export interface GateLedgerLines {
212
+ resolutions: GateResolutionRecord[];
213
+ pending: PendingGateRecord[];
214
+ /** The approvals a wave applied under, from `_gates/<op>/applied.jsonl`. Read only with `spend`. */
215
+ applied?: AppliedGateRecord[];
216
+ }
217
+
218
+ export interface GateApprovalInput {
219
+ ledger: GateLedgerLines;
220
+ gate: string;
221
+ /** The digest the gate binds: the plan, or a wave's set digest. */
222
+ planDigest: string;
223
+ /** On a component gate, the environment: only its pending facts and approvals count. */
224
+ environment?: string;
225
+ /** Which approvals count. Default `ledger`. */
226
+ mode?: GateApprovalSource;
227
+ /** The signers at base, which a `sealed` gate checks seals against ({@link gateSignersAt}). Absent under `sealed`, nothing counts. */
228
+ signers?: GateSigners;
229
+ /** Another rule an approval must pass, such as chant's `identity.gates` rule: why it does not count, or null. */
230
+ refuses?: (approval: GateResolutionRecord) => string | null;
231
+ /** Under `pr-review`, what the merged pull request's review says ({@link reviewApproval}); consulted only when no approval on the ledger counts. */
232
+ review?: ReviewApproval;
233
+ /** ISO-8601 now, for whether the standing pending fact has expired. */
234
+ now?: string;
235
+ /**
236
+ * An approval a wave applied under is spent (#3784): it neither passes the
237
+ * digest again nor stands as the approval of another. Default false.
238
+ */
239
+ spend?: boolean;
240
+ }
241
+
242
+ /** An approval of this gate and digest that a rule did not count, and why. */
243
+ export interface UncountedApproval {
244
+ resolution: GateResolutionRecord;
245
+ reason: string;
246
+ }
247
+
248
+ export type GateApprovalDecision =
249
+ | { status: "approved"; via: "ledger"; resolution: GateResolutionRecord }
250
+ | { status: "approved"; via: "pr-review"; review: ReviewApproval["review"] }
251
+ | {
252
+ status: "waiting";
253
+ /** The pending fact that still stands for this digest, unexpired: a caller that records one need not record another. */
254
+ standing?: PendingGateRecord;
255
+ /** The newest counted approval of this gate for another digest, or for none: the plan moved since it. */
256
+ mismatched?: GateResolutionRecord;
257
+ /** Approvals of this digest the mode or `refuses` did not count, newest last. */
258
+ uncounted: UncountedApproval[];
259
+ /** Under `pr-review`, why the review did not approve. */
260
+ review?: ReviewApproval["review"];
261
+ /** With `spend`, the newest approval of this gate a wave already applied under, which therefore counted for nothing. */
262
+ spent?: GateResolutionRecord;
263
+ };
264
+
265
+ const EPOCH = new Date(0).toISOString();
266
+
267
+ /**
268
+ * Decide a plan-bound gate against its ledger lines, as chant's wave gate
269
+ * does: an approval counts when it names this gate and digest (and
270
+ * environment, when set), is newer than the gate's newest pending fact, and
271
+ * passes the mode's rule: any under `ledger` and `pr-review`, a verified seal
272
+ * under `sealed`. With none, `pr-review` takes the review's answer. Writes
273
+ * nothing: recording a pending fact, or the review's approval, is the
274
+ * caller's.
275
+ */
276
+ export function decideGateApproval(input: GateApprovalInput): GateApprovalDecision {
277
+ const { gate, planDigest, environment: env } = input;
278
+ const mode = input.mode ?? "ledger";
279
+ const pending = env === undefined ? input.ledger.pending : input.ledger.pending.filter((p) => p.environment === env);
280
+ const mine = (env === undefined ? input.ledger.resolutions : input.ledger.resolutions.filter((r) => r.environment === undefined || r.environment === env)).filter(
281
+ (r) => r.gate === gate,
282
+ );
283
+ const sealed = mode === "sealed";
284
+ const noSigners: GateSigners = { base: null, signersPath: DEFAULT_SIGNERS_PATH, active: false, signers: [], problems: ["no signers were read for this sealed gate"] };
285
+ const why = (r: GateResolutionRecord): string | null =>
286
+ input.refuses?.(r) ?? (sealed ? gateSealRefusal(input.signers ?? noSigners, r) : null);
287
+
288
+ const standingFact = latestPendingGate(pending, gate);
289
+ const since = standingFact?.timestamp ?? EPOCH;
290
+ const counted: GateResolutionRecord[] = [];
291
+ const uncounted: UncountedApproval[] = [];
292
+ let spent: GateResolutionRecord | undefined;
293
+ for (const r of mine) {
294
+ if (input.spend && isApprovalSpent(input.ledger.applied ?? [], r)) {
295
+ if (new Date(r.timestamp).getTime() >= new Date(since).getTime() && (!spent || new Date(r.timestamp).getTime() >= new Date(spent.timestamp).getTime())) spent = r;
296
+ continue;
297
+ }
298
+ const reason = why(r);
299
+ if (reason === null) counted.push(r);
300
+ else if (samePlanDigest(r.planDigest, planDigest) && new Date(r.timestamp).getTime() >= new Date(since).getTime()) uncounted.push({ resolution: r, reason });
301
+ }
302
+ const found = latestResolutionForPlan(counted, gate, since, planDigest);
303
+ if (found.resolution) return { status: "approved", via: "ledger", resolution: found.resolution };
304
+ const reviewSpent =
305
+ input.spend === true && input.review?.approved === true &&
306
+ isApprovalSpent(input.ledger.applied ?? [], { gate, planDigest, review: { ...(input.review.review.head ? { head: input.review.review.head } : {}) } });
307
+ if (mode === "pr-review" && input.review?.approved && !reviewSpent) return { status: "approved", via: "pr-review", review: input.review.review };
308
+
309
+ const now = input.now ?? new Date().toISOString();
310
+ const standing = standingFact && !isPendingGateExpired(standingFact, now) && samePlanDigest(standingFact.planDigest, planDigest) ? standingFact : undefined;
311
+ return {
312
+ status: "waiting",
313
+ ...(standing ? { standing } : {}),
314
+ ...(found.mismatched ? { mismatched: found.mismatched } : {}),
315
+ uncounted: uncounted.sort((a, b) => new Date(a.resolution.timestamp).getTime() - new Date(b.resolution.timestamp).getTime()),
316
+ ...(mode === "pr-review" && input.review
317
+ ? { review: reviewSpent ? { ...input.review.review, reason: `a wave already applied this digest under the review of ${input.review.review.head?.slice(0, 12) ?? "its head"}, so the review is spent` } : input.review.review }
318
+ : {}),
319
+ ...(spent ? { spent } : {}),
320
+ };
321
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * The approvals a wave applied under, and the rule that spends them (#3784).
3
+ *
4
+ * terragucci's wave gate writes one line to `_gates/<op>/applied.jsonl` on
5
+ * `chant/lifecycle` before a wave applies under an approval: the gate, the
6
+ * digest, and the approval's own timestamp. From then on that approval is
7
+ * spent. It does not let the same digest through again, and it no longer
8
+ * stands as the approval of another digest either. Op waves write the same
9
+ * line, in the same file, when their spec sets `approvals.spend`, and
10
+ * `decideGateApproval` (`../gate-decision.ts`) and `evaluateGate`
11
+ * (`../op/gate.ts`) take the lines to leave spent approvals out.
12
+ *
13
+ * The file sits in a directory under `_gates/`, so the readers of
14
+ * `_gates/<op>.jsonl` never see its lines. {@link appliedGateApplies} hands
15
+ * them to `gate-audit` as applies, each tied to the approval it ran under.
16
+ *
17
+ * Pure: no git, no clock, no file. It bundles into `@intentius/chant/gate-decision`.
18
+ */
19
+
20
+ import { samePlanDigest } from "./plan-digest";
21
+ import type { GateAuditApply } from "./gate-audit";
22
+
23
+ /** One approval a wave applied under: the approval of `planDigest` made at `approvedAt` was used. */
24
+ export interface AppliedGateRecord {
25
+ version: 1;
26
+ kind: "applied";
27
+ op: string;
28
+ gate: string;
29
+ planDigest: string;
30
+ /** The approval's own timestamp, as the ledger holds it. Every approval of this gate and digest at or before it is spent. */
31
+ approvedAt: string;
32
+ approvedBy: string;
33
+ /** When the wave recorded it, just before it applied. */
34
+ timestamp: string;
35
+ /** The wave, by number and name, for an Op waves record. */
36
+ wave?: { number: number; name: string };
37
+ /** The pull request review the approval came from, under `pr-review`. */
38
+ review?: { pr?: number; head?: string };
39
+ runId?: string;
40
+ commit?: string;
41
+ }
42
+
43
+ /** Where the applied lines of `op` live on `chant/lifecycle`: the directory and the file, as `writeBlobToPath` takes them. */
44
+ export function appliedGatePath(op: string): { dir: string; file: string } {
45
+ return { dir: `_gates/${op}`, file: "applied.jsonl" };
46
+ }
47
+
48
+ /** The applied lines of an `applied.jsonl` file, oldest first. A line that is not one is skipped. */
49
+ export function parseAppliedGateLines(text: string): AppliedGateRecord[] {
50
+ const out: AppliedGateRecord[] = [];
51
+ for (const line of text.split("\n").map((l) => l.trim()).filter(Boolean)) {
52
+ let r: Record<string, unknown>;
53
+ try {
54
+ r = JSON.parse(line) as Record<string, unknown>;
55
+ } catch {
56
+ continue;
57
+ }
58
+ if (
59
+ r?.version === 1 && r.kind === "applied" && typeof r.op === "string" && typeof r.gate === "string" &&
60
+ typeof r.planDigest === "string" && typeof r.approvedAt === "string" && typeof r.timestamp === "string"
61
+ ) {
62
+ out.push({ approvedBy: "", ...r } as unknown as AppliedGateRecord);
63
+ }
64
+ }
65
+ return out;
66
+ }
67
+
68
+ const time = (iso: string): number => new Date(iso).getTime();
69
+
70
+ /**
71
+ * Whether an approval is spent: a wave applied under an approval of the same
72
+ * gate and digest made at or after it. One that names no digest is never
73
+ * spent. With `review`, an approval that came from a pull request's review
74
+ * is spent too when a wave applied this digest under a review of the same
75
+ * head, so the review cannot be recorded again to pass the digest twice. A
76
+ * review not yet recorded has no `timestamp`.
77
+ */
78
+ export function isApprovalSpent(
79
+ applied: readonly AppliedGateRecord[],
80
+ approval: { gate: string; planDigest?: string; timestamp?: string; review?: { pr?: number; head?: string } },
81
+ ): boolean {
82
+ const digest = approval.planDigest;
83
+ if (digest === undefined) return false;
84
+ return applied.some(
85
+ (a) =>
86
+ a.gate === approval.gate &&
87
+ samePlanDigest(a.planDigest, digest) &&
88
+ ((approval.timestamp !== undefined && time(a.approvedAt) >= time(approval.timestamp)) ||
89
+ (approval.review?.head !== undefined && a.review?.head === approval.review.head)),
90
+ );
91
+ }
92
+
93
+ /**
94
+ * The applied lines as `gate-audit` applies: each an `applied` entry tied to
95
+ * the approval it ran under. `ref` names the file, `_gates/<op>/applied.jsonl`
96
+ * by default; each line's index is appended so ids stay stable.
97
+ */
98
+ export function appliedGateApplies(records: readonly AppliedGateRecord[], ref?: string): GateAuditApply[] {
99
+ return records.map((r, i) => ({
100
+ ref: `${ref ?? `_gates/${r.op}/applied.jsonl`}#${i + 1}`,
101
+ at: r.timestamp,
102
+ gate: r.gate,
103
+ op: r.op,
104
+ digest: r.planDigest,
105
+ approval: "required" as const,
106
+ detail: {
107
+ approvedAt: r.approvedAt,
108
+ approvedBy: r.approvedBy,
109
+ ...(r.wave ? { wave: r.wave } : {}),
110
+ ...(r.review ? { review: r.review } : {}),
111
+ ...(r.runId ? { runId: r.runId } : {}),
112
+ ...(r.commit ? { commit: r.commit } : {}),
113
+ },
114
+ }));
115
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The gate audit reader bundles small (#3764, ruling #3421).
3
+ *
4
+ * terragucci and SQL Yodeler derive their audit trail from the gate ledger in
5
+ * one bundled file, with no TypeScript toolchain. So `@intentius/chant/lifecycle/gate-audit`
6
+ * must not reach `typescript`, esbuild, zod, git, or chant's fold, lint and
7
+ * codegen modules. This bundles it the way a product would and reads
8
+ * esbuild's own list of what went in.
9
+ */
10
+
11
+ import { join } from "node:path";
12
+ import { build } from "esbuild";
13
+ import { describe, expect, test } from "vitest";
14
+ import { REPO } from "../workspace/__fixtures__/contract-repo";
15
+
16
+ const BUDGET_BYTES = 32 * 1024;
17
+
18
+ const FORBIDDEN = [/node_modules\//, /\/src\/fold\//, /\/src\/lint\//, /\/src\/codegen\//, /\/src\/workspace\//, /\/lifecycle\/git\.ts$/, /\/lifecycle\/gate-ledger\.ts$/, /\/op\//];
19
+
20
+ describe("the gate audit bundle", () => {
21
+ test("holds the reader, the digest comparison and nothing heavier", async () => {
22
+ const result = await build({
23
+ entryPoints: [join(REPO, "packages", "core", "src", "lifecycle", "gate-audit.ts")],
24
+ bundle: true,
25
+ platform: "node",
26
+ format: "esm",
27
+ write: false,
28
+ metafile: true,
29
+ outdir: "out",
30
+ logLevel: "silent",
31
+ });
32
+ const inputs = Object.keys(result.metafile.inputs);
33
+ for (const pattern of FORBIDDEN) {
34
+ expect(inputs.filter((i) => pattern.test(i)), String(pattern)).toEqual([]);
35
+ }
36
+ expect(inputs.map((i) => i.replace(/^.*?packages\//, "packages/")).sort()).toEqual([
37
+ "packages/core/src/content-digest.ts",
38
+ "packages/core/src/declarable.ts",
39
+ "packages/core/src/effect-receipt.ts",
40
+ "packages/core/src/intrinsic.ts",
41
+ "packages/core/src/lifecycle/gate-audit.ts",
42
+ "packages/core/src/lifecycle/plan-digest.ts",
43
+ ]);
44
+ const bytes = result.outputFiles.reduce((n, f) => n + f.contents.byteLength, 0);
45
+ expect(bytes).toBeLessThan(BUDGET_BYTES);
46
+ });
47
+ });
@@ -0,0 +1,134 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { deriveGateAudit, gateLedgerLines, gateLedgerLinesFromLog, readGateAuditLedgers, isOverrideGate } from "./gate-audit";
3
+ import { memoryGateLedgerPort } from "../op/gate";
4
+
5
+ const D1 = "jcs1-sha256:" + "a".repeat(64);
6
+ const D2 = "jcs1-sha256:" + "b".repeat(64);
7
+
8
+ const pending = (o: Record<string, unknown> = {}) =>
9
+ JSON.stringify({ version: 1, kind: "pending", op: "tf-apply", gate: "wave-1", timestamp: "2026-10-01T10:00:00.000Z", expiresAt: "2026-10-03T10:00:00.000Z", planDigest: D1, runId: "r1", ...o });
10
+ const resolution = (o: Record<string, unknown> = {}) =>
11
+ JSON.stringify({ version: 1, op: "tf-apply", gate: "wave-1", resolvedBy: "alex", timestamp: "2026-10-01T11:00:00.000Z", planDigest: D1, ...o });
12
+ const ledger = (op: string, ...texts: string[]) => ({ op, lines: texts.map((text) => ({ text })) });
13
+
14
+ describe("lifecycle/gate-audit", () => {
15
+ test("a request, then an approval with its seal and approver", () => {
16
+ const seal = { signer: "alex", key: "SHA256:k", signature: "-----BEGIN" };
17
+ const { entries, gaps } = deriveGateAudit({
18
+ ledgers: [ledger("tf-apply", pending(), resolution({ seal, relayedBy: "slack", note: "ok", approver: { kind: "human", roles: ["ops"] }, via: "relay" }))],
19
+ });
20
+ expect(gaps).toEqual([]);
21
+ expect(entries.map((e) => e.kind)).toEqual(["approval-requested", "approved"]);
22
+ expect(entries[0]).toMatchObject({ gate: "wave-1", digest: D1, runId: "r1", expiresAt: "2026-10-03T10:00:00.000Z" });
23
+ expect(entries[1]).toMatchObject({ by: "alex", seal: "sealed", signer: "alex", relayedBy: "slack", note: "ok", approver: { kind: "human", roles: ["ops"] }, detail: { via: "relay" } });
24
+ });
25
+
26
+ test("ids are stable and differ per line", () => {
27
+ const input = { ledgers: [ledger("tf-apply", pending(), resolution())] };
28
+ const a = deriveGateAudit(input).entries;
29
+ const b = deriveGateAudit(input).entries;
30
+ expect(a.map((e) => e.id)).toEqual(b.map((e) => e.id));
31
+ expect(new Set(a.map((e) => e.id)).size).toBe(2);
32
+ });
33
+
34
+ test("a pull request review counts as signed; a bare approval is unsigned", () => {
35
+ const review = { pr: 7, head: "abc", approvers: ["bo"] };
36
+ const { entries } = deriveGateAudit({ ledgers: [ledger("tf-apply", pending(), resolution({ review }), resolution({ timestamp: "2026-10-01T12:00:00.000Z" }))] });
37
+ expect(entries.filter((e) => e.kind === "approved").map((e) => e.seal)).toEqual(["review", "unsigned"]);
38
+ expect(entries.find((e) => e.seal === "review")?.review).toEqual(review);
39
+ });
40
+
41
+ test("an approval with no pending fact before it is a gap, also when the digest differs or the fact is later", () => {
42
+ const none = deriveGateAudit({ ledgers: [ledger("tf-apply", resolution())] });
43
+ expect(none.gaps.map((g) => g.kind)).toEqual(["approval-without-request"]);
44
+ const other = deriveGateAudit({ ledgers: [ledger("tf-apply", pending({ planDigest: D2 }), resolution())] });
45
+ expect(other.gaps.map((g) => g.kind)).toEqual(["approval-without-request"]);
46
+ const later = deriveGateAudit({ ledgers: [ledger("tf-apply", resolution({ timestamp: "2026-10-01T09:00:00.000Z" }), pending())] });
47
+ expect(later.gaps.map((g) => g.kind)).toEqual(["approval-without-request"]);
48
+ const old = deriveGateAudit({ ledgers: [ledger("tf-apply", pending({ planDigest: undefined }), resolution())] });
49
+ expect(old.gaps).toEqual([]);
50
+ });
51
+
52
+ test("the sealed option flags an unsigned approval and not a sealed or reviewed one", () => {
53
+ const { gaps } = deriveGateAudit({
54
+ ledgers: [ledger("tf-apply", pending(), resolution(), resolution({ review: { pr: 1 } }))],
55
+ sealed: (op) => op === "tf-apply",
56
+ });
57
+ expect(gaps.map((g) => g.kind)).toEqual(["unsealed"]);
58
+ });
59
+
60
+ test("overrides: terragucci's denial and SQL Yodeler's override gate", () => {
61
+ const denial = pending({ op: "policy-override", gate: "root/a", planDigest: "sha256:ov", rules: ["r1", "r2"], members: [{ member: "root/a", planDigest: D1 }] });
62
+ const rec = resolution({ op: "policy-override", gate: "root/a", planDigest: "sha256:ov", note: "freeze exception" });
63
+ const yodel = resolution({ op: "migrate", gate: "override-no-drop", note: "legacy table" });
64
+ const { entries, gaps } = deriveGateAudit({ ledgers: [ledger("policy-override", denial, rec), ledger("migrate", yodel)] });
65
+ expect(gaps).toEqual([]);
66
+ expect(entries.map((e) => e.kind)).toEqual(["override-requested", "override-recorded", "override-recorded"]);
67
+ const recorded = entries.filter((e) => e.kind === "override-recorded");
68
+ expect(recorded[0]).toMatchObject({ note: "freeze exception", rules: ["r1", "r2"] });
69
+ expect(recorded[1]).toMatchObject({ note: "legacy table", rules: ["no-drop"] });
70
+ expect(entries.find((e) => e.kind === "override-requested")?.detail).toMatchObject({ members: [{ member: "root/a", planDigest: D1 }] });
71
+ });
72
+
73
+ test("a removed line is a revocation by whoever removed it", () => {
74
+ const log = [
75
+ `\x1ec1\x1falex\x1f2026-10-01T11:00:00+00:00\n\ndiff --git a/x b/x\n+${resolution()}\n`,
76
+ `\x1ec2\x1fbo\x1f2026-10-02T08:00:00+00:00\n\ndiff --git a/x b/x\n-${resolution()}\n`,
77
+ `\x1ec3\x1fcy\x1f2026-10-02T09:00:00+00:00\n\ndiff --git a/x b/x\n-${pending({ gate: "gone" })}\n+${pending({ gate: "gone" })}\n`,
78
+ ].join("");
79
+ const lines = gateLedgerLinesFromLog(log);
80
+ expect(lines).toHaveLength(2);
81
+ const { entries } = deriveGateAudit({ ledgers: [{ op: "tf-apply", lines: [{ text: pending() }, ...lines] }] });
82
+ const revoked = entries.find((e) => e.kind === "revoked")!;
83
+ expect(revoked).toMatchObject({ by: "bo", at: "2026-10-02T08:00:00.000Z", subject: "approval", detail: { approvedBy: "alex" } });
84
+ expect(entries.filter((e) => e.kind === "approved")).toHaveLength(1);
85
+ });
86
+
87
+ test("applies: under an approval, without one, waived, and spending an override", () => {
88
+ const ledgers = [ledger("tf-apply", pending(), resolution()), ledger("policy-override", resolution({ op: "policy-override", gate: "root/a", planDigest: "sha256:ov" }))];
89
+ const ok = { ref: "reports/1", at: "2026-10-01T12:00:00.000Z", gate: "wave-1", digest: D1 };
90
+ const { entries, gaps } = deriveGateAudit({
91
+ ledgers,
92
+ applies: [
93
+ ok,
94
+ { ...ok, ref: "reports/2", digest: D2 },
95
+ { ...ok, ref: "reports/3", digest: D2, approval: "not-required" },
96
+ { ...ok, ref: "reports/4", digest: undefined },
97
+ { ...ok, ref: "reports/5", at: "2026-10-01T10:30:00.000Z" },
98
+ { ...ok, ref: "reports/6", overrides: [{ gate: "root/a", digest: "sha256:ov" }, { gate: "root/b" }] },
99
+ ],
100
+ });
101
+ expect(gaps.map((g) => [g.kind, g.detail.split(" ")[0]])).toEqual([
102
+ ["approval", "reports/2"],
103
+ ["approval", "reports/5"],
104
+ ["override", "reports/6"],
105
+ ]);
106
+ const approved = entries.find((e) => e.kind === "approved")!;
107
+ expect(entries.filter((e) => e.kind === "applied" && e.under === approved.id)).toHaveLength(2);
108
+ const spent = entries.filter((e) => e.kind === "override-spent");
109
+ expect(spent).toHaveLength(1);
110
+ expect(spent[0]).toMatchObject({ gate: "root/a", by: "alex", at: "2026-10-01T12:00:00.000Z" });
111
+ });
112
+
113
+ test("malformed lines are gaps and never entries", () => {
114
+ const { entries, gaps } = deriveGateAudit({ ledgers: [ledger("tf-apply", "{nope", JSON.stringify({ version: 2, gate: "g", timestamp: "t" }), pending())] });
115
+ expect(entries).toHaveLength(1);
116
+ expect(gaps.map((g) => g.kind)).toEqual(["malformed-line", "malformed-line"]);
117
+ });
118
+
119
+ test("the override rule is replaceable", () => {
120
+ const { entries } = deriveGateAudit({ ledgers: [ledger("x", resolution({ op: "x", gate: "ovr/a" }))], isOverride: (_op, gate) => gate.startsWith("ovr/") });
121
+ expect(entries[0]?.kind).toBe("override-recorded");
122
+ expect(isOverrideGate("policy-override", "r")).toBe(true);
123
+ expect(isOverrideGate("a", "b")).toBe(false);
124
+ });
125
+
126
+ test("gateLedgerLines and the port reader feed the same derivation", async () => {
127
+ const text = `${pending()}\n\n${resolution()}\n`;
128
+ const port = memoryGateLedgerPort({ pending: [JSON.parse(pending())], resolutions: [JSON.parse(resolution())] });
129
+ const fromText = deriveGateAudit({ ledgers: [{ op: "tf-apply", lines: gateLedgerLines(text) }] });
130
+ const fromPort = deriveGateAudit({ ledgers: await readGateAuditLedgers(port, ["tf-apply"]) });
131
+ expect(fromPort.entries.map((e) => e.kind)).toEqual(["approval-requested", "approved"]);
132
+ expect(fromPort.entries.map((e) => e.at)).toEqual(fromText.entries.map((e) => e.at));
133
+ });
134
+ });