@intentius/chant 0.122.0 → 0.124.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 (203) hide show
  1. package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
  2. package/dist/cli/commands/lint.d.ts.map +1 -1
  3. package/dist/cli/handlers/gate-resume.d.ts +1 -1
  4. package/dist/cli/handlers/gate-resume.d.ts.map +1 -1
  5. package/dist/cli/handlers/operator.d.ts +1 -1
  6. package/dist/cli/handlers/operator.d.ts.map +1 -1
  7. package/dist/cli/handlers/override-approve.d.ts +17 -0
  8. package/dist/cli/handlers/override-approve.d.ts.map +1 -0
  9. package/dist/cli/handlers/run-wave.d.ts.map +1 -1
  10. package/dist/cli/main.d.ts.map +1 -1
  11. package/dist/cli/registry.d.ts +4 -0
  12. package/dist/cli/registry.d.ts.map +1 -1
  13. package/dist/config-import.d.ts +1 -1
  14. package/dist/gate-decision.d.ts +157 -0
  15. package/dist/gate-decision.d.ts.map +1 -0
  16. package/dist/lifecycle/gate-applied.d.ts +75 -0
  17. package/dist/lifecycle/gate-applied.d.ts.map +1 -0
  18. package/dist/lifecycle/gate-audit.d.ts +145 -0
  19. package/dist/lifecycle/gate-audit.d.ts.map +1 -0
  20. package/dist/lifecycle/gate-ledger-select.d.ts +54 -0
  21. package/dist/lifecycle/gate-ledger-select.d.ts.map +1 -0
  22. package/dist/lifecycle/gate-ledger.d.ts +1 -45
  23. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  24. package/dist/lifecycle/override-ledger.d.ts +34 -0
  25. package/dist/lifecycle/override-ledger.d.ts.map +1 -0
  26. package/dist/lifecycle/rollback.d.ts.map +1 -1
  27. package/dist/lifecycle/work-item-id.d.ts +22 -0
  28. package/dist/lifecycle/work-item-id.d.ts.map +1 -0
  29. package/dist/lifecycle/work-lease.d.ts +2 -2
  30. package/dist/lifecycle/work-lease.d.ts.map +1 -1
  31. package/dist/lint/policy-import.d.ts +1 -1
  32. package/dist/lint/post-synth.d.ts +20 -1
  33. package/dist/lint/post-synth.d.ts.map +1 -1
  34. package/dist/notify-verify.d.ts +62 -0
  35. package/dist/notify-verify.d.ts.map +1 -0
  36. package/dist/notify-verify.js +76 -0
  37. package/dist/notify.d.ts +202 -0
  38. package/dist/notify.d.ts.map +1 -0
  39. package/dist/notify.js +307 -0
  40. package/dist/op/__fixtures__/decide-stub-backend.d.ts +1 -1
  41. package/dist/op/activities/activity-contracts.d.ts +4 -4
  42. package/dist/op/activities/gh-host.d.ts +37 -0
  43. package/dist/op/activities/gh-host.d.ts.map +1 -0
  44. package/dist/op/activities/lexicon-upgrade.d.ts +11 -0
  45. package/dist/op/activities/lexicon-upgrade.d.ts.map +1 -1
  46. package/dist/op/activities/propose-upgrade.d.ts.map +1 -1
  47. package/dist/op/activities/reconcile.d.ts +74 -76
  48. package/dist/op/activities/reconcile.d.ts.map +1 -1
  49. package/dist/op/gate-approval.d.ts +17 -1
  50. package/dist/op/gate-approval.d.ts.map +1 -1
  51. package/dist/op/gate-name.d.ts +8 -1
  52. package/dist/op/gate-name.d.ts.map +1 -1
  53. package/dist/op/gate-resume.d.ts +4 -2
  54. package/dist/op/gate-resume.d.ts.map +1 -1
  55. package/dist/op/gate-review.d.ts +2 -1
  56. package/dist/op/gate-review.d.ts.map +1 -1
  57. package/dist/op/gate.d.ts +43 -6
  58. package/dist/op/gate.d.ts.map +1 -1
  59. package/dist/op/index.d.ts +8 -6
  60. package/dist/op/index.d.ts.map +1 -1
  61. package/dist/op/op-waves-notify.d.ts +31 -0
  62. package/dist/op/op-waves-notify.d.ts.map +1 -0
  63. package/dist/op/op-waves-run.d.ts +218 -16
  64. package/dist/op/op-waves-run.d.ts.map +1 -1
  65. package/dist/op/op-waves.d.ts +113 -2
  66. package/dist/op/op-waves.d.ts.map +1 -1
  67. package/dist/op/operator.d.ts +15 -0
  68. package/dist/op/operator.d.ts.map +1 -1
  69. package/dist/op/work-lease-decl.d.ts.map +1 -1
  70. package/dist/op/work-lease-run.d.ts.map +1 -1
  71. package/dist/override-gate.d.ts +224 -0
  72. package/dist/override-gate.d.ts.map +1 -0
  73. package/dist/pr-forge.d.ts +225 -19
  74. package/dist/pr-forge.d.ts.map +1 -1
  75. package/dist/workspace/conformance/__fixtures__/reference-broker.d.ts +1 -1
  76. package/dist/workspace/conformance/broker.d.ts +1 -1
  77. package/dist/workspace/status-gates.d.ts +1 -0
  78. package/dist/workspace/status-gates.d.ts.map +1 -1
  79. package/dist/workspace/trust/gate-seal.d.ts +96 -0
  80. package/dist/workspace/trust/gate-seal.d.ts.map +1 -0
  81. package/dist/workspace/trust/policy.d.ts +2 -41
  82. package/dist/workspace/trust/policy.d.ts.map +1 -1
  83. package/dist/workspace/trust/seal.d.ts +2 -27
  84. package/dist/workspace/trust/seal.d.ts.map +1 -1
  85. package/dist/workspace/trust/trust-files.d.ts +57 -0
  86. package/dist/workspace/trust/trust-files.d.ts.map +1 -0
  87. package/dist/workspace/work-cli.d.ts.map +1 -1
  88. package/package.json +15 -2
  89. package/src/cli/commands/build.ts +1 -1
  90. package/src/cli/commands/check-lexicon.ts +3 -1
  91. package/src/cli/commands/lint-post-synth.e2e.test.ts +117 -0
  92. package/src/cli/commands/lint.test.ts +4 -109
  93. package/src/cli/commands/lint.ts +5 -2
  94. package/src/cli/discovery-skip.e2e.test.ts +18 -3
  95. package/src/cli/handlers/__snapshots__/build-member.test.ts.snap +2 -2
  96. package/src/cli/handlers/gate-resume.ts +3 -2
  97. package/src/cli/handlers/operator.test.ts +12 -1
  98. package/src/cli/handlers/operator.ts +25 -13
  99. package/src/cli/handlers/override-approve.test.ts +87 -0
  100. package/src/cli/handlers/override-approve.ts +103 -0
  101. package/src/cli/handlers/run-wave.ts +10 -2
  102. package/src/cli/main.test.ts +8 -0
  103. package/src/cli/main.ts +15 -0
  104. package/src/cli/registry.ts +4 -0
  105. package/src/config-import.ts +1 -1
  106. package/src/discovery/sandbox/run.test.ts +1 -1
  107. package/src/gate-decision-bundle.test.ts +56 -0
  108. package/src/gate-decision.test.ts +237 -0
  109. package/src/gate-decision.ts +321 -0
  110. package/src/lifecycle/gate-applied.ts +115 -0
  111. package/src/lifecycle/gate-audit-bundle.test.ts +47 -0
  112. package/src/lifecycle/gate-audit.test.ts +134 -0
  113. package/src/lifecycle/gate-audit.ts +438 -0
  114. package/src/lifecycle/gate-ledger-select.ts +113 -0
  115. package/src/lifecycle/gate-ledger.test.ts +22 -0
  116. package/src/lifecycle/gate-ledger.ts +9 -104
  117. package/src/lifecycle/override-ledger.test.ts +63 -0
  118. package/src/lifecycle/override-ledger.ts +69 -0
  119. package/src/lifecycle/rollback.ts +5 -1
  120. package/src/lifecycle/work-item-id.test.ts +71 -0
  121. package/src/lifecycle/work-item-id.ts +35 -0
  122. package/src/lifecycle/work-lease.ts +4 -5
  123. package/src/lint/policy-import.ts +1 -1
  124. package/src/lint/post-synth.test.ts +29 -0
  125. package/src/lint/post-synth.ts +21 -2
  126. package/src/notify-bundle.test.ts +50 -0
  127. package/src/notify-plain-node.e2e.test.ts +68 -0
  128. package/src/notify-verify.test.ts +59 -0
  129. package/src/notify-verify.ts +93 -0
  130. package/src/notify.schema.json +72 -0
  131. package/src/notify.test.ts +279 -0
  132. package/src/notify.ts +398 -0
  133. package/src/op/__fixtures__/decide-stub-backend.ts +1 -1
  134. package/src/op/activities/gh-host.test.ts +130 -0
  135. package/src/op/activities/gh-host.ts +79 -0
  136. package/src/op/activities/lexicon-upgrade.ts +22 -1
  137. package/src/op/activities/propose-upgrade.ts +5 -1
  138. package/src/op/activities/reconcile.test.ts +284 -280
  139. package/src/op/activities/reconcile.ts +128 -131
  140. package/src/op/gate-approval.test.ts +175 -7
  141. package/src/op/gate-approval.ts +27 -1
  142. package/src/op/gate-name.ts +10 -2
  143. package/src/op/gate-resume.ts +6 -3
  144. package/src/op/gate-review.ts +4 -7
  145. package/src/op/gate.ts +117 -16
  146. package/src/op/index.ts +36 -5
  147. package/src/op/op-waves-notify.test.ts +127 -0
  148. package/src/op/op-waves-notify.ts +101 -0
  149. package/src/op/op-waves-run.ts +908 -123
  150. package/src/op/op-waves.test.ts +543 -3
  151. package/src/op/op-waves.ts +215 -6
  152. package/src/op/operator.test.ts +29 -0
  153. package/src/op/operator.ts +27 -2
  154. package/src/op/run-context.test.ts +2 -2
  155. package/src/op/work-lease-decl.ts +4 -4
  156. package/src/op/work-lease-run.test.ts +5 -0
  157. package/src/op/work-lease-run.ts +4 -3
  158. package/src/override-gate-bundle.test.ts +47 -0
  159. package/src/override-gate.test.ts +239 -0
  160. package/src/override-gate.ts +395 -0
  161. package/src/pr-forge-bundle.test.ts +40 -0
  162. package/src/pr-forge.test.ts +378 -0
  163. package/src/pr-forge.ts +546 -47
  164. package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +1 -1
  165. package/src/workspace/conformance/__fixtures__/reference-broker.ts +1 -1
  166. package/src/workspace/conformance/broker.ts +1 -1
  167. package/src/workspace/records-write-contract.test.ts +3 -2
  168. package/src/workspace/records-write.e2e.test.ts +1 -1
  169. package/src/workspace/status-gates.ts +7 -4
  170. package/src/workspace/trust/gate-seal.test.ts +156 -0
  171. package/src/workspace/trust/gate-seal.ts +254 -0
  172. package/src/workspace/trust/policy.ts +10 -165
  173. package/src/workspace/trust/seal.ts +19 -34
  174. package/src/workspace/trust/trust-files.test.ts +50 -0
  175. package/src/workspace/trust/trust-files.ts +245 -0
  176. package/src/workspace/work-cli.ts +5 -3
  177. package/src/workspace/work-history.test.ts +7 -0
  178. /package/src/audit/{catalog.test.ts → catalog.e2e.test.ts} +0 -0
  179. /package/src/audit/{discover.test.ts → discover.e2e.test.ts} +0 -0
  180. /package/src/audit/{remote-discovery.test.ts → remote-discovery.e2e.test.ts} +0 -0
  181. /package/src/audit/{rules-doc.test.ts → rules-doc.e2e.test.ts} +0 -0
  182. /package/src/{bench.test.ts → bench.e2e.test.ts} +0 -0
  183. /package/src/cli/commands/{check-lexicon-doc-drift.test.ts → check-lexicon-doc-drift.e2e.test.ts} +0 -0
  184. /package/src/cli/commands/{import.test.ts → import.e2e.test.ts} +0 -0
  185. /package/src/cli/{component-security-boundary.test.ts → component-security-boundary.e2e.test.ts} +0 -0
  186. /package/src/cli/mcp/{workspace-tools.test.ts → workspace-tools.e2e.test.ts} +0 -0
  187. /package/src/cli/{security-boundary.test.ts → security-boundary.e2e.test.ts} +0 -0
  188. /package/src/cli/{serve-mcp-workspace.test.ts → serve-mcp-workspace.e2e.test.ts} +0 -0
  189. /package/src/cli/{static-config-read.test.ts → static-config-read.e2e.test.ts} +0 -0
  190. /package/src/codegen/{json-schema-to-ts.test.ts → json-schema-to-ts.e2e.test.ts} +0 -0
  191. /package/src/{lexicon-seams.test.ts → lexicon-seams.e2e.test.ts} +0 -0
  192. /package/src/meta/{declared-imports.test.ts → declared-imports.e2e.test.ts} +0 -0
  193. /package/src/op/{command-gate.test.ts → command-gate.e2e.test.ts} +0 -0
  194. /package/src/op/{steward-beside.test.ts → steward-beside.e2e.test.ts} +0 -0
  195. /package/src/op/{steward-points.test.ts → steward-points.e2e.test.ts} +0 -0
  196. /package/src/workspace/{checks.test.ts → checks.e2e.test.ts} +0 -0
  197. /package/src/workspace/{intent-reviews.test.ts → intent-reviews.e2e.test.ts} +0 -0
  198. /package/src/workspace/{profiles.test.ts → profiles.e2e.test.ts} +0 -0
  199. /package/src/workspace/{read-contract.test.ts → read-contract.e2e.test.ts} +0 -0
  200. /package/src/workspace/{records-contract.test.ts → records-contract.e2e.test.ts} +0 -0
  201. /package/src/workspace/{records-since.test.ts → records-since.e2e.test.ts} +0 -0
  202. /package/src/workspace/{records-write.test.ts → records-write-commands.e2e.test.ts} +0 -0
  203. /package/src/workspace/{work-size.test.ts → work-size.e2e.test.ts} +0 -0
@@ -0,0 +1,239 @@
1
+ /**
2
+ * #3760: the override gate decides a denied plan's override: bound to the
3
+ * subject, plan digest and rules, by someone the overriders at base list,
4
+ * with a reason, sealed under `sealed`, and spent once a run applies it.
5
+ */
6
+
7
+ import { createHash } from "node:crypto";
8
+ import { afterEach, describe, expect, test } from "vitest";
9
+ import {
10
+ OVERRIDE_APPLIED,
11
+ OVERRIDE_OP,
12
+ appliedOverride,
13
+ decideOverride,
14
+ overrideAuditApplies,
15
+ overrideCommand,
16
+ overrideDigest,
17
+ overridePending,
18
+ overridersAt,
19
+ parseAppliedOverrides,
20
+ parseOverriders,
21
+ recordedDenials,
22
+ sortedRules,
23
+ type AppliedOverrideRecord,
24
+ type GateResolutionRecord,
25
+ type OverrideInput,
26
+ type PendingGateRecord,
27
+ } from "./override-gate";
28
+ import { baseCommit, gateSealPayload, gateSignersAt, GATE_SEAL_NAMESPACE } from "./gate-decision";
29
+ import { deriveGateAudit, gateLedgerLines, isOverrideGate, POLICY_OVERRIDE_OP } from "./lifecycle/gate-audit";
30
+ import { isNeverOverMcpGateOp } from "./op/gate-name";
31
+ import { isPlanDigest } from "./lifecycle/plan-digest";
32
+ import { hasSshKeygen, TestRepo } from "./workspace/trust/test-repo";
33
+
34
+ const ROOT = "envs/prod/vpc";
35
+ const PLAN = `sha256:${"a".repeat(64)}`;
36
+ const PLAN2 = `sha256:${"b".repeat(64)}`;
37
+ const RULES = ["no-public-s3", "cost-cap"];
38
+ const NOW = "2026-10-11T12:00:00.000Z";
39
+ const DIGEST = overrideDigest(ROOT, PLAN, RULES);
40
+
41
+ const denial = (timestamp: string, plan = PLAN, rules = RULES, expiresAt = "2026-10-13T00:00:00.000Z"): PendingGateRecord =>
42
+ overridePending({ subject: ROOT, planDigest: plan, rules, timestamp, expiresAt });
43
+ const override = (timestamp: string, extra: Partial<GateResolutionRecord> = {}): GateResolutionRecord => ({
44
+ version: 1, op: OVERRIDE_OP, gate: ROOT, resolvedBy: "github:alice", timestamp, planDigest: DIGEST, note: "the bucket is the public docs site", ...extra,
45
+ });
46
+ const input = (resolutions: GateResolutionRecord[], over: Partial<OverrideInput> = {}): OverrideInput => ({
47
+ ledger: { pending: [denial("2026-10-11T10:00:00.000Z")], resolutions },
48
+ subject: ROOT, planDigest: PLAN, rules: RULES, overriders: ["github:alice", "github:bob"], now: NOW, ...over,
49
+ });
50
+
51
+ describe("the digest", () => {
52
+ test("binds the subject, the plan and the sorted rules, as terragucci's override.ts does", () => {
53
+ const expected = `sha256:${createHash("sha256").update([OVERRIDE_OP, ROOT, PLAN, "cost-cap", "no-public-s3"].join("\n")).digest("hex")}`;
54
+ expect(DIGEST).toBe(expected);
55
+ expect(isPlanDigest(DIGEST)).toBe(true);
56
+ expect(overrideDigest(ROOT, PLAN, [" no-public-s3", "cost-cap", "cost-cap"])).toBe(DIGEST);
57
+ expect(overrideDigest(ROOT, PLAN2, RULES)).not.toBe(DIGEST);
58
+ expect(overrideDigest(ROOT, PLAN, ["cost-cap"])).not.toBe(DIGEST);
59
+ expect(overrideDigest("envs/dev/vpc", PLAN, RULES)).not.toBe(DIGEST);
60
+ });
61
+
62
+ test("the command names each rule and asks for a reason, and a sign under sealed", () => {
63
+ expect(sortedRules(["b", " a", "b", ""])).toEqual(["a", "b"]);
64
+ expect(overrideCommand(ROOT, RULES)).toBe(`chant approve policy-override ${ROOT} --rule cost-cap --rule no-public-s3 --reason "<why>"`);
65
+ expect(overrideCommand(ROOT, RULES, true)).toMatch(/ --sign$/);
66
+ });
67
+ });
68
+
69
+ describe("the denial", () => {
70
+ test("is a pending fact of the override op whose planDigest is the override digest, with the plan and rules", () => {
71
+ const p = denial("2026-10-11T10:00:00.000Z") as PendingGateRecord & { plan: string; rules: string[] };
72
+ expect(p).toMatchObject({ version: 1, kind: "pending", op: "policy-override", gate: ROOT, planDigest: DIGEST, plan: PLAN, rules: ["cost-cap", "no-public-s3"] });
73
+ });
74
+
75
+ test("recordedDenials keeps each subject's newest", () => {
76
+ const other = overridePending({ subject: "envs/dev/vpc", planDigest: PLAN, rules: ["x"], timestamp: "2026-10-11T09:00:00.000Z", expiresAt: NOW });
77
+ const ds = recordedDenials([denial("2026-10-11T08:00:00.000Z", PLAN2), denial("2026-10-11T10:00:00.000Z"), other, { ...other, op: "tf-apply" }]);
78
+ expect(ds.map((d) => [d.gate, d.plan])).toEqual([["envs/dev/vpc", PLAN], [ROOT, PLAN]]);
79
+ });
80
+
81
+ test("the override op is never resolved over MCP, and the audit reader reads it as overrides", () => {
82
+ expect(isNeverOverMcpGateOp(OVERRIDE_OP)).toBe(true);
83
+ expect(OVERRIDE_OP).toBe(POLICY_OVERRIDE_OP);
84
+ expect(isOverrideGate(OVERRIDE_OP, ROOT)).toBe(true);
85
+ });
86
+ });
87
+
88
+ describe("decideOverride", () => {
89
+ test("an override of this digest, after the denial, by a listed person with a reason, counts", () => {
90
+ const d = decideOverride(input([override("2026-10-11T11:00:00.000Z")]));
91
+ expect(d).toMatchObject({ status: "overridden", digest: DIGEST, override: { subject: ROOT, by: "github:alice", reason: "the bucket is the public docs site", rules: ["cost-cap", "no-public-s3"], planDigest: PLAN, sealed: false } });
92
+ });
93
+
94
+ test("overriders are matched as signers are: case and width folded", () => {
95
+ expect(decideOverride(input([override("2026-10-11T11:00:00.000Z", { resolvedBy: "GitHub:Alice" })])).status).toBe("overridden");
96
+ });
97
+
98
+ test.each<[string, Partial<GateResolutionRecord> & { via?: string }, RegExp]>([
99
+ ["someone not listed", { resolvedBy: "github:mallory" }, /github:mallory is not listed in \.chant\/overriders at base \(github:alice, github:bob\)/],
100
+ ["no reason", { note: " " }, /gives no reason/],
101
+ ["a job's line", { via: "slack" }, /written by a job \(via slack\)/],
102
+ ["a review's line", { review: { pr: 7, head: "c".repeat(40), approvers: ["github:alice"] } }, /review of #7/],
103
+ ["an agent", { approver: { kind: "agent" } }, /recorded by an agent/],
104
+ ])("%s does not count, and says why", (_name, extra, why) => {
105
+ const d = decideOverride(input([override("2026-10-11T11:00:00.000Z", extra)]));
106
+ expect(d.status).toBe("none");
107
+ expect(d.status === "none" && d.refusals).toEqual([expect.stringMatching(why)]);
108
+ });
109
+
110
+ test("nobody is listed when the overriders file is missing", () => {
111
+ const d = decideOverride(input([override("2026-10-11T11:00:00.000Z")], { overriders: [], overridersPath: "yodel.config.ts" }));
112
+ expect(d.status === "none" && d.refusals[0]).toMatch(/not listed in yodel\.config\.ts at base, which lists nobody/);
113
+ });
114
+
115
+ test("an override from before the newest denial answers nothing", () => {
116
+ const d = decideOverride(input([override("2026-10-11T09:00:00.000Z")]));
117
+ expect(d).toMatchObject({ status: "none", refusals: [] });
118
+ });
119
+
120
+ test("an override of another plan or other rules is the moved refusal", () => {
121
+ const was = overrideDigest(ROOT, PLAN2, RULES);
122
+ const d = decideOverride(input([override("2026-10-11T11:00:00.000Z", { planDigest: was })]));
123
+ expect(d).toMatchObject({ status: "moved", by: "github:alice", was, digest: DIGEST });
124
+ });
125
+
126
+ test("the standing denial is reported, so a run records no second one; an expired one is not", () => {
127
+ const standing = decideOverride(input([]));
128
+ expect(standing.status === "none" && standing.standing?.planDigest).toBe(DIGEST);
129
+ const expired = decideOverride(input([], { ledger: { pending: [denial("2026-10-08T10:00:00.000Z", PLAN, RULES, "2026-10-09T00:00:00.000Z")], resolutions: [] } }));
130
+ expect(expired.status === "none" && expired.standing).toBeUndefined();
131
+ const otherPlan = decideOverride(input([], { planDigest: PLAN2 }));
132
+ expect(otherPlan.status === "none" && otherPlan.standing).toBeUndefined();
133
+ });
134
+
135
+ test("an override written after an expired denial still counts for the same plan", () => {
136
+ const ledger = { pending: [denial("2026-10-08T10:00:00.000Z", PLAN, RULES, "2026-10-09T00:00:00.000Z")], resolutions: [override("2026-10-10T11:00:00.000Z")] };
137
+ expect(decideOverride(input([], { ledger })).status).toBe("overridden");
138
+ });
139
+
140
+ describe("spent once applied", () => {
141
+ const first = override("2026-10-11T11:00:00.000Z");
142
+ const decided = decideOverride(input([first]));
143
+ if (decided.status !== "overridden") throw new Error("setup");
144
+ const applied = appliedOverride(decided.override, { timestamp: "2026-10-11T11:05:00.000Z", runId: "42", commit: "c".repeat(40) });
145
+
146
+ test("the applied record names the subject, the override digest and the override's time", () => {
147
+ expect(applied).toEqual({ version: 1, kind: "applied", op: OVERRIDE_OP, gate: ROOT, planDigest: DIGEST, approvedAt: first.timestamp, approvedBy: "github:alice", timestamp: "2026-10-11T11:05:00.000Z", runId: "42", commit: "c".repeat(40) });
148
+ expect(parseAppliedOverrides(`${JSON.stringify(applied)}\nnot json\n{"version":1,"kind":"finished"}\n`)).toEqual([applied]);
149
+ });
150
+
151
+ test("a spent override does not count for the same plan again", () => {
152
+ const d = decideOverride(input([first], { applied: [applied] }));
153
+ expect(d.status === "none" && d.refusals).toEqual([expect.stringMatching(/is spent: a run applied under it at 2026-10-11T11:05:00/)]);
154
+ });
155
+
156
+ test("an override written after the applied record counts", () => {
157
+ const again = override("2026-10-11T11:10:00.000Z", { resolvedBy: "github:bob" });
158
+ expect(decideOverride(input([first, again], { applied: [applied] }))).toMatchObject({ status: "overridden", override: { by: "github:bob" } });
159
+ });
160
+
161
+ test("a re-planned denial waits for its own override, and the spent one is not a moved plan", () => {
162
+ const ledger = { pending: [denial("2026-10-11T10:00:00.000Z"), denial("2026-10-11T10:30:00.000Z", PLAN2)], resolutions: [first] };
163
+ // Not newer than the new denial: nothing.
164
+ expect(decideOverride(input([], { ledger, planDigest: PLAN2, applied: [applied] }))).toMatchObject({ status: "none", refusals: [] });
165
+ // Written after the new denial, but for the old plan and already spent.
166
+ const late = override("2026-10-11T10:40:00.000Z");
167
+ const spentLater: AppliedOverrideRecord = { ...applied, approvedAt: late.timestamp };
168
+ const d = decideOverride(input([], { ledger: { ...ledger, resolutions: [late] }, planDigest: PLAN2, applied: [spentLater] }));
169
+ expect(d).toMatchObject({ status: "none", spent: { digest: DIGEST, by: "github:alice", at: late.timestamp } });
170
+ // The same line, never applied, is the moved refusal.
171
+ expect(decideOverride(input([], { ledger: { ...ledger, resolutions: [late] }, planDigest: PLAN2 })).status).toBe("moved");
172
+ });
173
+
174
+ test("the audit reader records the override and its spending", () => {
175
+ const ledgerText = [denial("2026-10-11T10:00:00.000Z"), first].map((l) => JSON.stringify(l)).join("\n");
176
+ const { entries, gaps } = deriveGateAudit({ ledgers: [{ op: OVERRIDE_OP, lines: gateLedgerLines(ledgerText) }], applies: overrideAuditApplies([applied]) });
177
+ expect(entries.map((e) => e.kind)).toEqual(["override-requested", "override-recorded", "applied", "override-spent"]);
178
+ expect(entries.find((e) => e.kind === "override-recorded")).toMatchObject({ rules: ["cost-cap", "no-public-s3"], note: "the bucket is the public docs site", digest: DIGEST });
179
+ expect(entries.find((e) => e.kind === "applied")?.source.path).toBe(`${OVERRIDE_APPLIED}:1`);
180
+ expect(gaps).toEqual([]);
181
+ });
182
+ });
183
+ });
184
+
185
+ describe("the overriders at base", () => {
186
+ const repos: TestRepo[] = [];
187
+ afterEach(() => {
188
+ while (repos.length) repos.pop()!.cleanup();
189
+ });
190
+
191
+ test("one principal per line, comments and blanks skipped", () => {
192
+ expect(parseOverriders("# who may override\ngithub:alice\n\n github:bob # on call\ngithub:alice\n")).toEqual(["github:alice", "github:bob"]);
193
+ });
194
+
195
+ test("are read at base, so the change being applied cannot add its own overrider", () => {
196
+ const r = new TestRepo("3760-overriders");
197
+ repos.push(r);
198
+ r.write(".chant/overriders", "github:alice\n");
199
+ r.commit("base");
200
+ r.write(".chant/overriders", "github:alice\ngithub:mallory\n");
201
+ r.commit("adds mallory");
202
+ const base = baseCommit(r.dir);
203
+ expect(overridersAt(r.dir, base)).toEqual({ base, path: ".chant/overriders", active: true, principals: ["github:alice"] });
204
+ expect(overridersAt(r.dir, base, "ops/overriders").active).toBe(false);
205
+ });
206
+ });
207
+
208
+ describe.skipIf(!hasSshKeygen)("sealed", () => {
209
+ const repos: TestRepo[] = [];
210
+ afterEach(() => {
211
+ while (repos.length) repos.pop()!.cleanup();
212
+ });
213
+
214
+ test("only an override sealed by its overrider's key at base counts", () => {
215
+ const r = new TestRepo("3760-sealed");
216
+ repos.push(r);
217
+ const alice = r.key("alice");
218
+ const bob = r.key("bob");
219
+ r.write(".chant/allowed_signers", `github:alice ${alice.pub}\n`);
220
+ r.commit("base");
221
+ r.write(".chant/allowed_signers", `github:alice ${alice.pub}\ngithub:bob ${bob.pub}\n`);
222
+ r.commit("adds bob");
223
+ const signers = gateSignersAt(r.dir, baseCommit(r.dir));
224
+ const seal = (o: GateResolutionRecord, key = alice) => ({ ...o, seal: { signer: o.resolvedBy, key: "k", signature: r.sshSign(key, gateSealPayload(o), GATE_SEAL_NAMESPACE) } });
225
+
226
+ const unsealed = override("2026-10-11T11:00:00.000Z");
227
+ expect(decideOverride(input([unsealed], { mode: "sealed", signers })).status === "none").toBe(true);
228
+ expect(decideOverride(input([seal(unsealed)], { mode: "sealed", signers }))).toMatchObject({ status: "overridden", override: { sealed: true } });
229
+ // Bob is listed as an overrider, but his key is added by the change itself.
230
+ const byBob = seal(override("2026-10-11T11:00:00.000Z", { resolvedBy: "github:bob" }), bob);
231
+ const d = decideOverride(input([byBob], { mode: "sealed", signers }));
232
+ expect(d.status === "none" && d.refusals[0]).toMatch(/github:bob has no key in \.chant\/allowed_signers at base/);
233
+ // A seal binds the digest, so a sealed line edited to another digest does not verify.
234
+ const moved = { ...seal(unsealed), planDigest: overrideDigest(ROOT, PLAN, ["cost-cap"]) };
235
+ expect(decideOverride(input([moved], { mode: "sealed", signers, rules: ["cost-cap"], ledger: { pending: [], resolutions: [moved] } })).status).toBe("none");
236
+ // Under sealed with no signers read, nothing counts.
237
+ expect(decideOverride(input([seal(unsealed)], { mode: "sealed" })).status).toBe("none");
238
+ });
239
+ });
@@ -0,0 +1,395 @@
1
+ /**
2
+ * The override gate (#3760, decision #3756): a person's recorded, reasoned
3
+ * decision to apply one plan a policy denied, spent once a run applies it.
4
+ *
5
+ * The policy engines stay in the products (OPA and conftest in terragucci,
6
+ * config rules in SQL Yodeler). They say a plan is denied and by which rules.
7
+ * This module records and decides the overrides of those denials, so a
8
+ * product keeps a thin layer over it:
9
+ *
10
+ * - A denial is a pending fact on `_gates/policy-override.jsonl` on
11
+ * `chant/lifecycle`, gate the subject (a root, an environment, whatever the
12
+ * product applies one plan to), with the rules that denied it
13
+ * ({@link overridePending}). Its `planDigest` is the override digest
14
+ * ({@link overrideDigest}), which binds the subject, the plan digest and
15
+ * the rule ids, so `chant approve` copies it by default.
16
+ * - An override is an approval of that gate and digest, written with
17
+ * `chant approve policy-override <subject> --rule <id> --reason <text>`
18
+ * (`--sign` under `sealed`). The reason is its `note`.
19
+ * - It counts ({@link decideOverride}) when it is newer than the subject's
20
+ * newest pending fact, names this digest, was written by a person the
21
+ * overriders at base list ({@link overridersAt}), gives a reason, and under
22
+ * `sealed` carries a seal that verifies for its approver against the
23
+ * signers file at base. A line a job wrote (one that carries `via` or a
24
+ * forge `review`) or an agent wrote never counts.
25
+ * - Before a run applies under an override it appends an applied record
26
+ * ({@link appliedOverride}) to `_gates/policy-override/applied.jsonl`. An
27
+ * override with an applied record is spent: it counts for nothing again, so
28
+ * a plan that is denied again waits for its own override, and a spent
29
+ * override of an older plan is not reported as a moved plan.
30
+ *
31
+ * Like `./gate-decision.ts` it reads the lines it is handed and writes
32
+ * nothing; `override-gate-bundle.test.ts` holds its import graph to the
33
+ * same bundle. chant's own writers are in `./lifecycle/override-ledger.ts`.
34
+ */
35
+
36
+ import { createHash } from "node:crypto";
37
+ import { spawnSync } from "node:child_process";
38
+ import { OVERRIDE_GATE_OP } from "./op/gate-name";
39
+ import { latestPendingGate } from "./lifecycle/gate-ledger-select";
40
+ import { samePlanDigest } from "./lifecycle/plan-digest";
41
+ import { gateSealRefusal, type GateSigners } from "./workspace/trust/gate-seal";
42
+ import { DEFAULT_SIGNERS_PATH } from "./workspace/trust/trust-files";
43
+ import type { GateResolutionRecord, PendingGateRecord } from "./lifecycle/gate-ledger";
44
+ import type { GateApprovalSource } from "./op/op-waves";
45
+ import type { GateAuditApply } from "./lifecycle/gate-audit";
46
+
47
+ export type { GateResolutionRecord, PendingGateRecord } from "./lifecycle/gate-ledger";
48
+ export type { GateSigners } from "./workspace/trust/gate-seal";
49
+
50
+ /** The op every override and denial is recorded under. `./lifecycle/gate-audit.ts` reads this op as overrides by default. */
51
+ export const OVERRIDE_OP = OVERRIDE_GATE_OP;
52
+ /** The ledger file on `chant/lifecycle` that holds denials and overrides. */
53
+ export const OVERRIDE_LEDGER = `_gates/${OVERRIDE_OP}.jsonl`;
54
+ /** The file beside it that holds the overrides a run applied under. chant reads only the `.jsonl` files directly under `_gates/` as gate ledgers, so it never reads these lines as gate records. */
55
+ export const OVERRIDE_APPLIED = `_gates/${OVERRIDE_OP}/applied.jsonl`;
56
+ /** Where the overriders are read at base when the caller names no other file: one principal per line, `#` starts a comment. */
57
+ export const OVERRIDERS_PATH = ".chant/overriders";
58
+
59
+ // ── the digest ───────────────────────────────────────────────────────────
60
+
61
+ /** The rules as an override names them: trimmed, each once, sorted. */
62
+ export function sortedRules(rules: readonly string[]): string[] {
63
+ return [...new Set(rules.map((r) => r.trim()).filter(Boolean))].sort();
64
+ }
65
+
66
+ /**
67
+ * The digest an override binds: the subject, its plan digest and the rules
68
+ * that denied it, as `sha256:<hex>` over their LF-joined lines after the op
69
+ * name. A plan that moved, or a different set of rules, gives another digest,
70
+ * so an override of one denial never lets another through.
71
+ */
72
+ export function overrideDigest(subject: string, planDigest: string, rules: readonly string[]): string {
73
+ const text = [OVERRIDE_OP, subject, planDigest, ...sortedRules(rules)].join("\n");
74
+ return `sha256:${createHash("sha256").update(text).digest("hex")}`;
75
+ }
76
+
77
+ /** The command that overrides a denial. */
78
+ export function overrideCommand(subject: string, rules: readonly string[], sealed = false): string {
79
+ return `chant approve ${OVERRIDE_OP} ${subject} ${sortedRules(rules).map((r) => `--rule ${r}`).join(" ")} --reason "<why>"${sealed ? " --sign" : ""}`;
80
+ }
81
+
82
+ // ── the denial ───────────────────────────────────────────────────────────
83
+
84
+ /** A denial's pending fact: the gate's record, with the plan it denied and the rules that denied it. `planDigest` is the override digest. */
85
+ export interface OverridePendingRecord extends PendingGateRecord {
86
+ /** The plan digest the policy denied. */
87
+ plan: string;
88
+ /** The ids of the rules that denied it, sorted. */
89
+ rules: string[];
90
+ }
91
+
92
+ export interface OverridePendingInput {
93
+ subject: string;
94
+ planDigest: string;
95
+ rules: readonly string[];
96
+ /** ISO-8601. */
97
+ timestamp: string;
98
+ /** ISO-8601; past it a run that is still denied records a fresh fact. */
99
+ expiresAt: string;
100
+ description?: string;
101
+ runId?: string;
102
+ url?: string;
103
+ }
104
+
105
+ /** The pending fact a run records when the policy denies a plan and no override counts for it ({@link decideOverride} returns no `standing` fact). Appending it is the caller's. */
106
+ export function overridePending(input: OverridePendingInput): OverridePendingRecord {
107
+ const rules = sortedRules(input.rules);
108
+ return {
109
+ version: 1,
110
+ kind: "pending",
111
+ op: OVERRIDE_OP,
112
+ gate: input.subject,
113
+ timestamp: input.timestamp,
114
+ expiresAt: input.expiresAt,
115
+ planDigest: overrideDigest(input.subject, input.planDigest, rules),
116
+ plan: input.planDigest,
117
+ rules,
118
+ description: input.description ?? `the policy denied this plan (${rules.join(", ")})`,
119
+ ...(input.runId ? { runId: input.runId } : {}),
120
+ ...(input.url ? { url: input.url } : {}),
121
+ };
122
+ }
123
+
124
+ const time = (iso: string): number => new Date(iso).getTime();
125
+
126
+ /** The denials on the ledger: each subject's newest pending fact of the override op that names a plan. Sorted by subject. */
127
+ export function recordedDenials(pending: readonly PendingGateRecord[]): OverridePendingRecord[] {
128
+ const newest = new Map<string, PendingGateRecord>();
129
+ for (const p of pending) {
130
+ if (p.op !== OVERRIDE_OP || !p.planDigest) continue;
131
+ const before = newest.get(p.gate);
132
+ if (!before || time(p.timestamp) >= time(before.timestamp)) newest.set(p.gate, p);
133
+ }
134
+ return [...newest.values()]
135
+ .map((p) => {
136
+ const r = p as Partial<OverridePendingRecord>;
137
+ return { ...p, plan: typeof r.plan === "string" ? r.plan : "", rules: Array.isArray(r.rules) ? sortedRules(r.rules.filter((x): x is string => typeof x === "string")) : [] };
138
+ })
139
+ .sort((a, b) => (a.gate < b.gate ? -1 : a.gate > b.gate ? 1 : 0));
140
+ }
141
+
142
+ // ── who may override, at base ────────────────────────────────────────────
143
+
144
+ /** The overriders file at base. */
145
+ export interface Overriders {
146
+ /** The commit it was read at. */
147
+ base: string | null;
148
+ path: string;
149
+ /** Whether the file exists at base. Without it nobody may override. */
150
+ active: boolean;
151
+ principals: string[];
152
+ }
153
+
154
+ /** The principals in an overriders file: one per line, blank lines and `#` comments skipped. */
155
+ export function parseOverriders(text: string): string[] {
156
+ const out: string[] = [];
157
+ for (const raw of text.split("\n")) {
158
+ const line = raw.replace(/#.*$/, "").trim();
159
+ if (line && !out.includes(line)) out.push(line);
160
+ }
161
+ return out;
162
+ }
163
+
164
+ /**
165
+ * The overriders at `base` (a commit the repository holds, such as
166
+ * `baseCommit(repo)` from `@intentius/chant/gate-decision`), from `path`
167
+ * (default {@link OVERRIDERS_PATH}). Read at base, so the change being
168
+ * applied cannot add the person who overrides its own denial. A product that
169
+ * keeps its list in its own config at base reads it there and passes the
170
+ * principals to {@link decideOverride} instead.
171
+ */
172
+ export function overridersAt(repo: string, base: string, path = OVERRIDERS_PATH): Overriders {
173
+ const r = spawnSync("git", ["cat-file", "blob", `${base}:${path}`], { cwd: repo, encoding: "utf-8" });
174
+ if (r.status !== 0) return { base, path, active: false, principals: [] };
175
+ return { base, path, active: true, principals: parseOverriders(r.stdout) };
176
+ }
177
+
178
+ // ── the decision ─────────────────────────────────────────────────────────
179
+
180
+ /** A line of `_gates/policy-override/applied.jsonl`: a run applied under the override of `planDigest` made at `approvedAt`. */
181
+ export interface AppliedOverrideRecord {
182
+ version: 1;
183
+ kind: "applied";
184
+ op: string;
185
+ /** The subject. */
186
+ gate: string;
187
+ /** The override digest. */
188
+ planDigest: string;
189
+ /** The override's own timestamp: every override of this digest at or before it is spent. */
190
+ approvedAt: string;
191
+ approvedBy: string;
192
+ /** When the run recorded it. */
193
+ timestamp: string;
194
+ runId?: string;
195
+ commit?: string;
196
+ }
197
+
198
+ /** The applied records in the text of an applied file. Malformed lines, and lines of another kind, are skipped. */
199
+ export function parseAppliedOverrides(text: string): AppliedOverrideRecord[] {
200
+ const out: AppliedOverrideRecord[] = [];
201
+ for (const line of text.split("\n").map((l) => l.trim()).filter(Boolean)) {
202
+ try {
203
+ const r = JSON.parse(line) as Record<string, unknown>;
204
+ if (r.version === 1 && r.kind === "applied" && typeof r.op === "string" && typeof r.gate === "string" && typeof r.planDigest === "string" && typeof r.approvedAt === "string") {
205
+ out.push(r as unknown as AppliedOverrideRecord);
206
+ }
207
+ } catch {
208
+ continue;
209
+ }
210
+ }
211
+ return out;
212
+ }
213
+
214
+ /** What judges an override line: who may write one, the approval mode, and under `sealed` the signers at base. */
215
+ export interface OverrideRule {
216
+ /** The principals who may override (an overriders file's, or a product's own list at base). */
217
+ overriders: readonly string[];
218
+ /** Where they were read, for the refusals. Default {@link OVERRIDERS_PATH}. */
219
+ overridersPath?: string;
220
+ /** `sealed` counts only a seal that verifies; `ledger` and `pr-review` count an unsealed override (an override is a person's line on the ledger, never a review). Default `ledger`. */
221
+ mode?: GateApprovalSource;
222
+ /** The signers at base (`gateSignersAt`). Under `sealed`, absent means nothing counts. */
223
+ signers?: GateSigners;
224
+ }
225
+
226
+ const norm = (s: string): string => s.normalize("NFKC").trim().toLowerCase();
227
+
228
+ /** Why this ledger line does not count as an override, or null when it does. */
229
+ export function overrideRefusal(rule: OverrideRule, r: GateResolutionRecord): string | null {
230
+ const line = r as GateResolutionRecord & { via?: unknown };
231
+ if (line.via !== undefined) return `the override line was written by a job (via ${String(line.via)}), and an override is a person's`;
232
+ if (r.review !== undefined) return `the override line came from the review of #${r.review.pr}, and an override is a person's own line`;
233
+ if (r.approver?.kind === "agent") return `the override by ${r.resolvedBy} was recorded by an agent, and an override is a person's`;
234
+ const where = `${rule.overridersPath ?? OVERRIDERS_PATH} at base`;
235
+ if (!rule.overriders.map(norm).includes(norm(r.resolvedBy))) {
236
+ return `${r.resolvedBy} is not listed in ${where}${rule.overriders.length ? ` (${rule.overriders.join(", ")})` : ", which lists nobody"}`;
237
+ }
238
+ if (!r.note?.trim()) return `the override by ${r.resolvedBy} gives no reason`;
239
+ if (rule.mode === "sealed") {
240
+ const signers = rule.signers ?? { base: null, signersPath: DEFAULT_SIGNERS_PATH, active: false, signers: [], problems: ["no signers were read for this sealed override"] };
241
+ return gateSealRefusal(signers, r);
242
+ }
243
+ return null;
244
+ }
245
+
246
+ export interface OverrideInput extends OverrideRule {
247
+ /** The lines of `_gates/policy-override.jsonl`. Lines of other ops are ignored. */
248
+ ledger: { resolutions: readonly GateResolutionRecord[]; pending: readonly PendingGateRecord[] };
249
+ /** The lines of `_gates/policy-override/applied.jsonl`. Absent reads as none. */
250
+ applied?: readonly AppliedOverrideRecord[];
251
+ subject: string;
252
+ /** The plan digest the policy denied. */
253
+ planDigest: string;
254
+ /** The ids of the rules that denied it. */
255
+ rules: readonly string[];
256
+ /** ISO-8601 now, for whether the standing pending fact has expired. */
257
+ now?: string;
258
+ }
259
+
260
+ /** An override that counts. */
261
+ export interface StandingOverride {
262
+ subject: string;
263
+ by: string;
264
+ at: string;
265
+ reason: string;
266
+ rules: string[];
267
+ /** The plan digest it lets through. */
268
+ planDigest: string;
269
+ /** The override digest. */
270
+ digest: string;
271
+ sealed: boolean;
272
+ resolution: GateResolutionRecord;
273
+ }
274
+
275
+ export type OverrideDecision =
276
+ /** An override of this digest counts: the run may apply this plan, after recording {@link appliedOverride}. */
277
+ | { status: "overridden"; digest: string; override: StandingOverride }
278
+ /**
279
+ * No override counts. `refusals` says why each override of this digest
280
+ * does not. `standing` is the unexpired pending fact for this digest, so a
281
+ * run that finds one records no other. `spent` is the newest override of
282
+ * another digest a run already applied under.
283
+ */
284
+ | { status: "none"; digest: string; refusals: string[]; standing?: OverridePendingRecord; spent?: { digest: string; by: string; at: string } }
285
+ /** An override counts for another digest and was never applied: the plan or its rules changed after it was written. */
286
+ | { status: "moved"; digest: string; by: string; was: string | undefined; refusals: string[]; standing?: OverridePendingRecord };
287
+
288
+ /**
289
+ * Decide one denied plan against the override ledger, as `decideGateApproval`
290
+ * decides a gate: a line counts only when it is an override of this subject
291
+ * newer than the subject's newest pending fact, names this digest, passes
292
+ * {@link overrideRefusal}, and is not spent (an applied record of its digest
293
+ * made at or after it). The newest counting line wins.
294
+ *
295
+ * Decide before recording a denial: an override written after an expired
296
+ * pending fact still counts, and a run that records a fresh fact first would
297
+ * make it older than the anchor.
298
+ */
299
+ export function decideOverride(input: OverrideInput): OverrideDecision {
300
+ const rules = sortedRules(input.rules);
301
+ const digest = overrideDigest(input.subject, input.planDigest, rules);
302
+ const pending = input.ledger.pending.filter((p) => p.op === OVERRIDE_OP);
303
+ const latest = latestPendingGate(pending as PendingGateRecord[], input.subject);
304
+ const since = latest ? time(latest.timestamp) : 0;
305
+ const applied = (input.applied ?? []).filter((a) => a.op === OVERRIDE_OP && a.gate === input.subject);
306
+ const spentBy = (r: GateResolutionRecord): AppliedOverrideRecord | undefined =>
307
+ r.planDigest === undefined ? undefined : applied.find((a) => samePlanDigest(a.planDigest, r.planDigest) && time(a.approvedAt) >= time(r.timestamp));
308
+ const newer = (r: GateResolutionRecord, best: GateResolutionRecord | undefined) => !best || time(r.timestamp) >= time(best.timestamp);
309
+
310
+ let matched: GateResolutionRecord | undefined;
311
+ let mismatched: GateResolutionRecord | undefined;
312
+ let used: GateResolutionRecord | undefined;
313
+ const refusals: string[] = [];
314
+ for (const r of input.ledger.resolutions) {
315
+ if (r.op !== OVERRIDE_OP || r.gate !== input.subject || time(r.timestamp) < since) continue;
316
+ const why = overrideRefusal(input, r);
317
+ const spent = spentBy(r);
318
+ if (samePlanDigest(r.planDigest, digest)) {
319
+ if (why !== null) refusals.push(why);
320
+ else if (spent) refusals.push(`the override by ${r.resolvedBy} at ${r.timestamp} is spent: a run applied under it at ${spent.timestamp}`);
321
+ else if (newer(r, matched)) matched = r;
322
+ } else if (why !== null) {
323
+ continue;
324
+ } else if (spent) {
325
+ if (newer(r, used)) used = r;
326
+ } else if (newer(r, mismatched)) {
327
+ mismatched = r;
328
+ }
329
+ }
330
+
331
+ if (matched) {
332
+ return {
333
+ status: "overridden",
334
+ digest,
335
+ override: {
336
+ subject: input.subject,
337
+ by: matched.resolvedBy,
338
+ at: matched.timestamp,
339
+ reason: matched.note!.trim(),
340
+ rules,
341
+ planDigest: input.planDigest,
342
+ digest,
343
+ sealed: input.mode === "sealed",
344
+ resolution: matched,
345
+ },
346
+ };
347
+ }
348
+ const now = input.now ?? new Date().toISOString();
349
+ const standing = latest && time(latest.expiresAt) > time(now) && samePlanDigest(latest.planDigest, digest)
350
+ ? recordedDenials([latest])[0]
351
+ : undefined;
352
+ if (mismatched) return { status: "moved", digest, by: mismatched.resolvedBy, was: mismatched.planDigest, refusals, ...(standing ? { standing } : {}) };
353
+ return {
354
+ status: "none",
355
+ digest,
356
+ refusals,
357
+ ...(standing ? { standing } : {}),
358
+ ...(used ? { spent: { digest: used.planDigest!, by: used.resolvedBy, at: used.timestamp } } : {}),
359
+ };
360
+ }
361
+
362
+ /** The applied record a run appends before it applies under `override`, which spends it. Appending it is the caller's. */
363
+ export function appliedOverride(override: StandingOverride, at: { timestamp: string; runId?: string; commit?: string }): AppliedOverrideRecord {
364
+ return {
365
+ version: 1,
366
+ kind: "applied",
367
+ op: OVERRIDE_OP,
368
+ gate: override.subject,
369
+ planDigest: override.digest,
370
+ approvedAt: override.at,
371
+ approvedBy: override.by,
372
+ timestamp: at.timestamp,
373
+ ...(at.runId ? { runId: at.runId } : {}),
374
+ ...(at.commit ? { commit: at.commit } : {}),
375
+ };
376
+ }
377
+
378
+ /**
379
+ * The applied records as applies for the audit reader
380
+ * (`@intentius/chant/lifecycle/gate-audit`), each citing the override it
381
+ * spent, so the trail shows an `override-spent` entry for it and a gap when
382
+ * the ledger holds no such override. A product that records its applies
383
+ * elsewhere cites the override on its own apply instead.
384
+ */
385
+ export function overrideAuditApplies(applied: readonly AppliedOverrideRecord[], path = OVERRIDE_APPLIED): GateAuditApply[] {
386
+ return applied.map((a, i) => ({
387
+ ref: `${path}:${i + 1}`,
388
+ at: a.timestamp,
389
+ gate: a.gate,
390
+ op: a.op,
391
+ approval: "not-required",
392
+ overrides: [{ gate: a.gate, digest: a.planDigest }],
393
+ detail: { approvedBy: a.approvedBy, approvedAt: a.approvedAt, ...(a.runId ? { runId: a.runId } : {}), ...(a.commit ? { commit: a.commit } : {}) },
394
+ }));
395
+ }