@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.
- package/dist/cli/commands/lint.d.ts.map +1 -1
- package/dist/cli/handlers/gate-resume.d.ts +1 -1
- package/dist/cli/handlers/gate-resume.d.ts.map +1 -1
- package/dist/cli/handlers/operator.d.ts +1 -1
- package/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/cli/handlers/override-approve.d.ts +17 -0
- package/dist/cli/handlers/override-approve.d.ts.map +1 -0
- package/dist/cli/handlers/run-wave.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +4 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/gate-decision.d.ts +157 -0
- package/dist/gate-decision.d.ts.map +1 -0
- package/dist/lifecycle/gate-applied.d.ts +75 -0
- package/dist/lifecycle/gate-applied.d.ts.map +1 -0
- package/dist/lifecycle/gate-audit.d.ts +145 -0
- package/dist/lifecycle/gate-audit.d.ts.map +1 -0
- package/dist/lifecycle/gate-ledger-select.d.ts +54 -0
- package/dist/lifecycle/gate-ledger-select.d.ts.map +1 -0
- package/dist/lifecycle/gate-ledger.d.ts +1 -45
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/lifecycle/override-ledger.d.ts +34 -0
- package/dist/lifecycle/override-ledger.d.ts.map +1 -0
- package/dist/lifecycle/rollback.d.ts.map +1 -1
- package/dist/lifecycle/work-item-id.d.ts +22 -0
- package/dist/lifecycle/work-item-id.d.ts.map +1 -0
- package/dist/lifecycle/work-lease.d.ts +2 -2
- package/dist/lifecycle/work-lease.d.ts.map +1 -1
- package/dist/lint/post-synth.d.ts +20 -1
- package/dist/lint/post-synth.d.ts.map +1 -1
- package/dist/notify-verify.d.ts +62 -0
- package/dist/notify-verify.d.ts.map +1 -0
- package/dist/notify.d.ts +202 -0
- package/dist/notify.d.ts.map +1 -0
- package/dist/op/activities/activity-contracts.d.ts +4 -4
- package/dist/op/activities/gh-host.d.ts +37 -0
- package/dist/op/activities/gh-host.d.ts.map +1 -0
- package/dist/op/activities/lexicon-upgrade.d.ts +11 -0
- package/dist/op/activities/lexicon-upgrade.d.ts.map +1 -1
- package/dist/op/activities/propose-upgrade.d.ts.map +1 -1
- package/dist/op/activities/reconcile.d.ts +74 -76
- package/dist/op/activities/reconcile.d.ts.map +1 -1
- package/dist/op/gate-approval.d.ts +17 -1
- package/dist/op/gate-approval.d.ts.map +1 -1
- package/dist/op/gate-name.d.ts +8 -1
- package/dist/op/gate-name.d.ts.map +1 -1
- package/dist/op/gate-resume.d.ts +4 -2
- package/dist/op/gate-resume.d.ts.map +1 -1
- package/dist/op/gate-review.d.ts +2 -1
- package/dist/op/gate-review.d.ts.map +1 -1
- package/dist/op/gate.d.ts +43 -6
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/index.d.ts +8 -6
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/op-waves-notify.d.ts +31 -0
- package/dist/op/op-waves-notify.d.ts.map +1 -0
- package/dist/op/op-waves-run.d.ts +91 -16
- package/dist/op/op-waves-run.d.ts.map +1 -1
- package/dist/op/op-waves.d.ts +99 -2
- package/dist/op/op-waves.d.ts.map +1 -1
- package/dist/op/operator.d.ts +15 -0
- package/dist/op/operator.d.ts.map +1 -1
- package/dist/op/work-lease-decl.d.ts.map +1 -1
- package/dist/op/work-lease-run.d.ts.map +1 -1
- package/dist/override-gate.d.ts +224 -0
- package/dist/override-gate.d.ts.map +1 -0
- package/dist/pr-forge.d.ts +198 -19
- package/dist/pr-forge.d.ts.map +1 -1
- package/dist/workspace/status-gates.d.ts +1 -0
- package/dist/workspace/status-gates.d.ts.map +1 -1
- package/dist/workspace/trust/gate-seal.d.ts +96 -0
- package/dist/workspace/trust/gate-seal.d.ts.map +1 -0
- package/dist/workspace/trust/policy.d.ts +2 -41
- package/dist/workspace/trust/policy.d.ts.map +1 -1
- package/dist/workspace/trust/seal.d.ts +2 -27
- package/dist/workspace/trust/seal.d.ts.map +1 -1
- package/dist/workspace/trust/trust-files.d.ts +57 -0
- package/dist/workspace/trust/trust-files.d.ts.map +1 -0
- package/dist/workspace/work-cli.d.ts.map +1 -1
- package/package.json +2 -1
- package/src/cli/commands/build.ts +1 -1
- package/src/cli/commands/lint.ts +5 -2
- package/src/cli/handlers/__snapshots__/build-member.test.ts.snap +2 -2
- package/src/cli/handlers/gate-resume.ts +3 -2
- package/src/cli/handlers/operator.test.ts +12 -1
- package/src/cli/handlers/operator.ts +25 -13
- package/src/cli/handlers/override-approve.test.ts +87 -0
- package/src/cli/handlers/override-approve.ts +103 -0
- package/src/cli/handlers/run-wave.ts +10 -2
- package/src/cli/main.test.ts +8 -0
- package/src/cli/main.ts +15 -0
- package/src/cli/registry.ts +4 -0
- package/src/gate-decision-bundle.test.ts +56 -0
- package/src/gate-decision.test.ts +237 -0
- package/src/gate-decision.ts +321 -0
- package/src/lifecycle/gate-applied.ts +115 -0
- package/src/lifecycle/gate-audit-bundle.test.ts +47 -0
- package/src/lifecycle/gate-audit.test.ts +134 -0
- package/src/lifecycle/gate-audit.ts +438 -0
- package/src/lifecycle/gate-ledger-select.ts +113 -0
- package/src/lifecycle/gate-ledger.test.ts +22 -0
- package/src/lifecycle/gate-ledger.ts +9 -104
- package/src/lifecycle/override-ledger.test.ts +63 -0
- package/src/lifecycle/override-ledger.ts +69 -0
- package/src/lifecycle/rollback.ts +5 -1
- package/src/lifecycle/work-item-id.test.ts +71 -0
- package/src/lifecycle/work-item-id.ts +35 -0
- package/src/lifecycle/work-lease.ts +4 -5
- package/src/lint/post-synth.test.ts +29 -0
- package/src/lint/post-synth.ts +21 -2
- package/src/notify-bundle.test.ts +50 -0
- package/src/notify-verify.test.ts +59 -0
- package/src/notify-verify.ts +93 -0
- package/src/notify.schema.json +72 -0
- package/src/notify.test.ts +279 -0
- package/src/notify.ts +398 -0
- package/src/op/activities/gh-host.test.ts +130 -0
- package/src/op/activities/gh-host.ts +79 -0
- package/src/op/activities/lexicon-upgrade.ts +22 -1
- package/src/op/activities/propose-upgrade.ts +5 -1
- package/src/op/activities/reconcile.test.ts +284 -280
- package/src/op/activities/reconcile.ts +128 -131
- package/src/op/gate-approval.test.ts +175 -7
- package/src/op/gate-approval.ts +27 -1
- package/src/op/gate-name.ts +10 -2
- package/src/op/gate-resume.ts +6 -3
- package/src/op/gate-review.ts +4 -7
- package/src/op/gate.ts +117 -16
- package/src/op/index.ts +20 -5
- package/src/op/op-waves-notify.test.ts +127 -0
- package/src/op/op-waves-notify.ts +101 -0
- package/src/op/op-waves-run.ts +527 -104
- package/src/op/op-waves.test.ts +383 -3
- package/src/op/op-waves.ts +187 -6
- package/src/op/operator.test.ts +29 -0
- package/src/op/operator.ts +27 -2
- package/src/op/run-context.test.ts +2 -2
- package/src/op/work-lease-decl.ts +4 -4
- package/src/op/work-lease-run.test.ts +5 -0
- package/src/op/work-lease-run.ts +4 -3
- package/src/override-gate-bundle.test.ts +47 -0
- package/src/override-gate.test.ts +239 -0
- package/src/override-gate.ts +395 -0
- package/src/pr-forge-bundle.test.ts +40 -0
- package/src/pr-forge.test.ts +303 -0
- package/src/pr-forge.ts +502 -47
- package/src/workspace/records-write-contract.test.ts +3 -2
- package/src/workspace/status-gates.ts +7 -4
- package/src/workspace/trust/gate-seal.test.ts +156 -0
- package/src/workspace/trust/gate-seal.ts +254 -0
- package/src/workspace/trust/policy.ts +10 -165
- package/src/workspace/trust/seal.ts +19 -34
- package/src/workspace/trust/trust-files.test.ts +50 -0
- package/src/workspace/trust/trust-files.ts +245 -0
- package/src/workspace/work-cli.ts +5 -3
- package/src/workspace/work-history.test.ts +7 -0
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
|
|
6
6
|
import { readFileSync } from "node:fs";
|
|
7
7
|
import { relative, resolve } from "node:path";
|
|
8
|
+
import { notifyOpWave } from "../../op/op-waves-notify";
|
|
8
9
|
import { parseOpWavesSpec, recordOpWaveHeadPlans, runOpWave } from "../../op/op-waves-run";
|
|
10
|
+
import type { OpWavesSpec } from "../../op/op-waves";
|
|
9
11
|
import { formatError } from "../format";
|
|
10
12
|
import type { CommandContext } from "../registry";
|
|
11
13
|
|
|
@@ -35,9 +37,11 @@ export async function runOpWaveCommand(ctx: CommandContext): Promise<number> {
|
|
|
35
37
|
console.error(formatError({ message: "--decide and --share are two different jobs; pass one" }));
|
|
36
38
|
return 1;
|
|
37
39
|
}
|
|
40
|
+
let spec: OpWavesSpec | undefined;
|
|
41
|
+
const job = args.share !== undefined ? { share: args.share } : {};
|
|
38
42
|
try {
|
|
39
43
|
const path = resolve(specFile);
|
|
40
|
-
|
|
44
|
+
spec = parseOpWavesSpec(readFileSync(path, "utf-8"), `--spec ${specFile}`);
|
|
41
45
|
const result = await runOpWave({
|
|
42
46
|
spec,
|
|
43
47
|
specFile: relative(process.cwd(), path),
|
|
@@ -47,9 +51,13 @@ export async function runOpWaveCommand(ctx: CommandContext): Promise<number> {
|
|
|
47
51
|
...(args.base ? { base: args.base } : {}),
|
|
48
52
|
});
|
|
49
53
|
if (args.json) console.log(JSON.stringify(result, null, 2));
|
|
54
|
+
// #3761: tell people, when the spec says where. Delivery never changes the exit code.
|
|
55
|
+
await notifyOpWave(spec, args.wave, { result }, job);
|
|
50
56
|
return result.exitCode;
|
|
51
57
|
} catch (err) {
|
|
52
|
-
|
|
58
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
59
|
+
console.error(formatError({ message }));
|
|
60
|
+
if (spec) await notifyOpWave(spec, args.wave, { error: message }, job);
|
|
53
61
|
return 1;
|
|
54
62
|
}
|
|
55
63
|
}
|
package/src/cli/main.test.ts
CHANGED
|
@@ -346,6 +346,14 @@ describe("parseArgs", () => {
|
|
|
346
346
|
expect(result.url).toBe("https://github.com/org/repo/pull/1");
|
|
347
347
|
});
|
|
348
348
|
|
|
349
|
+
test("parses --rule (repeated) and --reason for an override (#3760)", () => {
|
|
350
|
+
const result = parseArgs(["approve", "policy-override", "prod", "--rule", "no-drop", "--rule", "no-lock", "--reason", "the table is empty"]);
|
|
351
|
+
expect(result.rules).toEqual(["no-drop", "no-lock"]);
|
|
352
|
+
expect(result.reason).toBe("the table is empty");
|
|
353
|
+
expect(() => parseArgs(["approve", "policy-override", "prod", "--rule"])).toThrow(/--rule needs a rule id/);
|
|
354
|
+
expect(() => parseArgs(["approve", "policy-override", "prod", "--reason"])).toThrow(/--reason needs the text/);
|
|
355
|
+
});
|
|
356
|
+
|
|
349
357
|
// ── --no-release-record (#597) ───────────────────────────────────────────
|
|
350
358
|
|
|
351
359
|
test("--promote-to <env> sets promoteTo, and a missing value is refused (#2575)", () => {
|
package/src/cli/main.ts
CHANGED
|
@@ -696,6 +696,15 @@ export function parseArgs(args: string[]): ParsedArgs {
|
|
|
696
696
|
result[arg.slice(2) as "unit" | "decision"] = value;
|
|
697
697
|
} else if (arg === "--note") {
|
|
698
698
|
result.note = args[++i];
|
|
699
|
+
} else if (arg === "--reason") {
|
|
700
|
+
// `chant approve policy-override <subject> --reason <text>` (#3760): why the denied plan goes out anyway.
|
|
701
|
+
result.reason = args[++i];
|
|
702
|
+
if (result.reason === undefined || result.reason.startsWith("-")) throw new Error("--reason needs the text of the reason: --reason <text>");
|
|
703
|
+
} else if (arg === "--rule") {
|
|
704
|
+
// `chant approve policy-override <subject> --rule <id>` (#3760), once per rule that denied the plan.
|
|
705
|
+
const value = args[++i];
|
|
706
|
+
if (!value || value.startsWith("-")) throw new Error("--rule needs a rule id: --rule <id>");
|
|
707
|
+
(result.rules ??= []).push(value);
|
|
699
708
|
} else if (arg === "--expire") {
|
|
700
709
|
result.expire = true;
|
|
701
710
|
} else if (arg === "--role") {
|
|
@@ -999,6 +1008,12 @@ Ops:
|
|
|
999
1008
|
--resume then starts the CI job that waits at the
|
|
1000
1009
|
gate again (GitHub re-run, GitLab retry, Forgejo
|
|
1001
1010
|
dispatch) with CHANT_FORGE_TOKEN
|
|
1011
|
+
On op policy-override the gate is the subject a
|
|
1012
|
+
policy denied: --rule <id> names each rule that
|
|
1013
|
+
denied it and --reason <text> says why the plan goes
|
|
1014
|
+
out anyway; the next run that plans the same digest
|
|
1015
|
+
applies it once, if .chant/overriders at base lists
|
|
1016
|
+
--actor
|
|
1002
1017
|
|
|
1003
1018
|
graph Show Op dependency graph (--stacks for cross-stack order,
|
|
1004
1019
|
--format ir|mermaid|dot|layout for the lint-gated graph IR,
|
package/src/cli/registry.ts
CHANGED
|
@@ -598,6 +598,10 @@ export interface ParsedArgs {
|
|
|
598
598
|
once?: boolean;
|
|
599
599
|
/** `chant approve <op> <gate> --note <text>` (#1485) — optional free-text prose recorded on the gate-resolution fact. The PR link belongs in `--url` since #2028; this is for everything that isn't the link. */
|
|
600
600
|
note?: string;
|
|
601
|
+
/** `chant approve policy-override <subject> --reason <text>` (#3760): why a denied plan goes out anyway, recorded as the override's note. */
|
|
602
|
+
reason?: string;
|
|
603
|
+
/** `chant approve policy-override <subject> --rule <id>` (#3760), repeated: the rules that denied the plan, which the override names. */
|
|
604
|
+
rules?: string[];
|
|
601
605
|
/** `chant approve <op> <gate> --expire` (#2119) — clear the gate's standing pending fact instead of approving it, so the next run decides the gate from scratch and records a fresh one. Writes no resolution: nothing is approved, the wait is only restarted. */
|
|
602
606
|
expire?: boolean;
|
|
603
607
|
/**
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The gate decision bundles small (#3763, ruling #3421).
|
|
3
|
+
*
|
|
4
|
+
* terragucci decides its wave gates from one bundled file with no TypeScript
|
|
5
|
+
* toolchain, and checked sealed approvals with its own copy of chant's seal
|
|
6
|
+
* rule because `@intentius/chant/op/gate` pulls in zod and the git ledger
|
|
7
|
+
* code. `@intentius/chant/gate-decision` holds the decision for a plan digest
|
|
8
|
+
* under `ledger`, `pr-review` and `sealed`, and the seal check over
|
|
9
|
+
* node:crypto. This bundles it the way terragucci would and reads esbuild's
|
|
10
|
+
* own list of what went in.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { build } from "esbuild";
|
|
15
|
+
import { describe, expect, test } from "vitest";
|
|
16
|
+
import { REPO } from "./workspace/__fixtures__/contract-repo";
|
|
17
|
+
|
|
18
|
+
const FORBIDDEN = [/node_modules\//, /\/src\/fold\//, /\/src\/lint\//, /\/src\/codegen\//, /\/src\/lifecycle\/git\.ts$/, /\/src\/lifecycle\/gate-ledger\.ts$/];
|
|
19
|
+
|
|
20
|
+
describe("the gate-decision bundle", () => {
|
|
21
|
+
test("holds the decision, the seal check and the plan digest, and no package or git code", async () => {
|
|
22
|
+
const result = await build({
|
|
23
|
+
entryPoints: [join(REPO, "packages", "core", "src", "gate-decision.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/gate-decision.ts",
|
|
41
|
+
"packages/core/src/intrinsic.ts",
|
|
42
|
+
"packages/core/src/lifecycle/gate-applied.ts",
|
|
43
|
+
"packages/core/src/lifecycle/gate-ledger-select.ts",
|
|
44
|
+
"packages/core/src/lifecycle/plan-digest.ts",
|
|
45
|
+
"packages/core/src/notify-verify.ts",
|
|
46
|
+
"packages/core/src/notify.ts",
|
|
47
|
+
"packages/core/src/op/op-waves.ts",
|
|
48
|
+
"packages/core/src/workspace/jsonc.ts",
|
|
49
|
+
"packages/core/src/workspace/trust/gate-seal.ts",
|
|
50
|
+
"packages/core/src/workspace/trust/trust-files.ts",
|
|
51
|
+
]);
|
|
52
|
+
// About 27 KB today.
|
|
53
|
+
const bytes = result.outputFiles.reduce((n, f) => n + f.contents.byteLength, 0);
|
|
54
|
+
expect(bytes).toBeLessThan(40 * 1024);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #3763: `@intentius/chant/gate-decision` decides a plan-bound gate as chant's
|
|
3
|
+
* own wave gate does. Each ledger is decided both ways, by
|
|
4
|
+
* {@link decideGateApproval} and by `evaluateGate` with the port `chant run
|
|
5
|
+
* wave` builds, and the seal rule is read at a real base commit.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, expect, test, afterEach } from "vitest";
|
|
9
|
+
import {
|
|
10
|
+
baseCommit,
|
|
11
|
+
decideGateApproval,
|
|
12
|
+
gateSealPayload,
|
|
13
|
+
gateSignersAt,
|
|
14
|
+
identityGatesAt,
|
|
15
|
+
reviewApproval,
|
|
16
|
+
appliedGateApplies,
|
|
17
|
+
parseAppliedGateLines,
|
|
18
|
+
GATE_SEAL_NAMESPACE,
|
|
19
|
+
type AppliedGateRecord,
|
|
20
|
+
type GateApprovalInput,
|
|
21
|
+
type GateResolutionRecord,
|
|
22
|
+
type MergedReview,
|
|
23
|
+
type PendingGateRecord,
|
|
24
|
+
} from "./gate-decision";
|
|
25
|
+
import { evaluateGate, memoryGateLedgerPort, type GateLedgerPort } from "./op/gate";
|
|
26
|
+
import { sealedApprovalRule, trustPolicyAt } from "./op/gate-review";
|
|
27
|
+
import { hasSshKeygen, TestRepo } from "./workspace/trust/test-repo";
|
|
28
|
+
import { deriveGateAudit } from "./lifecycle/gate-audit";
|
|
29
|
+
|
|
30
|
+
const GATE = "migrations-wave-1";
|
|
31
|
+
const D1 = "jcs1-sha256:1111";
|
|
32
|
+
const D2 = "jcs1-sha256:2222";
|
|
33
|
+
const NOW = "2026-10-11T12:00:00.000Z";
|
|
34
|
+
|
|
35
|
+
const pending = (timestamp: string, planDigest = D1, extra: Partial<PendingGateRecord> = {}): PendingGateRecord => ({
|
|
36
|
+
version: 1, kind: "pending", op: "migrations", gate: GATE, timestamp, expiresAt: "2026-10-13T00:00:00.000Z", planDigest, ...extra,
|
|
37
|
+
});
|
|
38
|
+
const approved = (timestamp: string, planDigest: string | undefined = D1, extra: Partial<GateResolutionRecord> = {}): GateResolutionRecord => ({
|
|
39
|
+
version: 1, op: "migrations", gate: GATE, resolvedBy: "alice", timestamp, ...(planDigest ? { planDigest } : {}), ...extra,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
/** evaluateGate's answer, reduced to what decideGateApproval also says. */
|
|
43
|
+
async function evaluated(port: GateLedgerPort, input: { planDigest: string; environment?: string }) {
|
|
44
|
+
const check = await evaluateGate(port, { op: "migrations", gate: GATE, now: NOW, ...input });
|
|
45
|
+
return check.satisfied
|
|
46
|
+
? { status: "approved", by: check.resolution.resolvedBy, at: check.resolution.timestamp }
|
|
47
|
+
: { status: "waiting", mismatched: check.mismatch?.resolvedBy ?? null, recorded: check.recorded };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function decided(input: GateApprovalInput) {
|
|
51
|
+
const d = decideGateApproval({ now: NOW, ...input });
|
|
52
|
+
return d.status === "approved"
|
|
53
|
+
? { status: "approved", by: d.via === "ledger" ? d.resolution.resolvedBy : d.review.approvers?.[0], at: d.via === "ledger" ? d.resolution.timestamp : undefined }
|
|
54
|
+
: { status: "waiting", mismatched: d.mismatched?.resolvedBy ?? null, recorded: d.standing === undefined };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe("under ledger, the decision is evaluateGate's", () => {
|
|
58
|
+
const ledgers: Array<[string, { resolutions: GateResolutionRecord[]; pending: PendingGateRecord[] }, { planDigest: string; environment?: string }]> = [
|
|
59
|
+
["an approval of this digest after the pending fact", { pending: [pending("2026-10-11T10:00:00.000Z")], resolutions: [approved("2026-10-11T11:00:00.000Z")] }, { planDigest: D1 }],
|
|
60
|
+
["an approval from before the gate last asked", { pending: [pending("2026-10-11T10:00:00.000Z")], resolutions: [approved("2026-10-11T09:00:00.000Z")] }, { planDigest: D1 }],
|
|
61
|
+
["an approval of another digest", { pending: [pending("2026-10-11T10:00:00.000Z", D2)], resolutions: [approved("2026-10-11T11:00:00.000Z")] }, { planDigest: D2 }],
|
|
62
|
+
["an approval that names no plan", { pending: [], resolutions: [approved("2026-10-11T11:00:00.000Z", undefined)] }, { planDigest: D1 }],
|
|
63
|
+
["the newest of several", { pending: [], resolutions: [approved("2026-10-11T08:00:00.000Z"), approved("2026-10-11T11:00:00.000Z", D1, { resolvedBy: "bob" }), approved("2026-10-11T11:30:00.000Z", D2)] }, { planDigest: D1 }],
|
|
64
|
+
["a standing pending fact for this digest", { pending: [pending("2026-10-11T10:00:00.000Z")], resolutions: [] }, { planDigest: D1 }],
|
|
65
|
+
["an expired pending fact", { pending: [{ ...pending("2026-10-08T10:00:00.000Z"), expiresAt: "2026-10-09T00:00:00.000Z" }], resolutions: [] }, { planDigest: D1 }],
|
|
66
|
+
["another environment's approval", { pending: [], resolutions: [approved("2026-10-11T11:00:00.000Z", D1, { environment: "staging" })] }, { planDigest: D1, environment: "prod" }],
|
|
67
|
+
["this environment's approval", { pending: [pending("2026-10-11T10:00:00.000Z", D1, { environment: "staging" })], resolutions: [approved("2026-10-11T09:00:00.000Z", D1, { environment: "prod" })] }, { planDigest: D1, environment: "prod" }],
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
test.each(ledgers)("%s", async (_name, ledger, input) => {
|
|
71
|
+
expect(decided({ ledger, gate: GATE, ...input })).toEqual(await evaluated(memoryGateLedgerPort(ledger), input));
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("pr-review", () => {
|
|
76
|
+
const merged = (over: Partial<MergedReview> = {}): MergedReview => ({ pr: 7, head: "c".repeat(40), author: "github:carol", approvers: ["github:bob"], refused: [], ...over });
|
|
77
|
+
|
|
78
|
+
test("a writer's review of a head that planned this digest approves it", () => {
|
|
79
|
+
expect(reviewApproval(merged(), D1, D1)).toEqual({ approved: true, review: { pr: 7, head: "c".repeat(40), approvers: ["github:bob"] } });
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("a review approves nothing when nobody counted, no plans were recorded, or the plan moved", () => {
|
|
83
|
+
expect(reviewApproval(merged({ approvers: [], refused: ["github:carol is the author, whose own review never counts"] }), D1, D1).review.reason).toBe(
|
|
84
|
+
"no writer other than its author approved the head of #7: github:carol is the author, whose own review never counts",
|
|
85
|
+
);
|
|
86
|
+
expect(reviewApproval(merged(), undefined, D1).review.reason).toMatch(/no plans are recorded for the head cccccccccccc of #7/);
|
|
87
|
+
expect(reviewApproval(merged(), D2, D1).review.reason).toBe(`the plan moved after the review: the head planned ${D2}, and the merge plans ${D1}`);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("the ledger is read first, and the review only when no approval counts", () => {
|
|
91
|
+
const ledger = { pending: [], resolutions: [approved("2026-10-11T11:00:00.000Z")] };
|
|
92
|
+
const review = reviewApproval(merged(), D1, D1);
|
|
93
|
+
expect(decideGateApproval({ ledger, gate: GATE, planDigest: D1, mode: "pr-review", review, now: NOW })).toMatchObject({ status: "approved", via: "ledger" });
|
|
94
|
+
expect(decideGateApproval({ ledger: { pending: [], resolutions: [] }, gate: GATE, planDigest: D1, mode: "pr-review", review, now: NOW })).toMatchObject({ status: "approved", via: "pr-review" });
|
|
95
|
+
const moved = reviewApproval(merged(), D2, D1);
|
|
96
|
+
expect(decideGateApproval({ ledger: { pending: [], resolutions: [] }, gate: GATE, planDigest: D1, mode: "pr-review", review: moved, now: NOW })).toMatchObject({ status: "waiting", review: { reason: expect.stringMatching(/plan moved/) } });
|
|
97
|
+
// Under ledger, a review is not consulted.
|
|
98
|
+
expect(decideGateApproval({ ledger: { pending: [], resolutions: [] }, gate: GATE, planDigest: D1, mode: "ledger", review, now: NOW }).status).toBe("waiting");
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe("spent approvals (#3784)", () => {
|
|
103
|
+
const appliedUnder = (r: GateResolutionRecord, timestamp = "2026-10-11T11:30:00.000Z", extra: Partial<AppliedGateRecord> = {}): AppliedGateRecord => ({
|
|
104
|
+
version: 1, kind: "applied", op: "migrations", gate: GATE, planDigest: r.planDigest!, approvedAt: r.timestamp, approvedBy: r.resolvedBy, timestamp, ...extra,
|
|
105
|
+
});
|
|
106
|
+
const a1 = approved("2026-10-11T11:00:00.000Z");
|
|
107
|
+
|
|
108
|
+
test("with spend, an approval a wave applied under does not pass the same digest twice", () => {
|
|
109
|
+
const ledger = { pending: [pending("2026-10-11T10:00:00.000Z")], resolutions: [a1], applied: [appliedUnder(a1)] };
|
|
110
|
+
expect(decideGateApproval({ ledger, gate: GATE, planDigest: D1, now: NOW })).toMatchObject({ status: "approved" });
|
|
111
|
+
expect(decideGateApproval({ ledger, gate: GATE, planDigest: D1, spend: true, now: NOW })).toMatchObject({ status: "waiting", spent: a1, standing: { planDigest: D1 } });
|
|
112
|
+
// A newer approval of the digest is not spent by the older apply.
|
|
113
|
+
const again = approved("2026-10-11T11:45:00.000Z", D1, { resolvedBy: "bob" });
|
|
114
|
+
expect(decideGateApproval({ ledger: { ...ledger, resolutions: [a1, again] }, gate: GATE, planDigest: D1, spend: true, now: NOW })).toMatchObject({ status: "approved", resolution: { resolvedBy: "bob" } });
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("a spent approval of another digest stands as no mismatch, as in terragucci's decideGate", () => {
|
|
118
|
+
const a2 = approved("2026-10-11T11:00:00.000Z", D2);
|
|
119
|
+
const ledger = { pending: [], resolutions: [a2], applied: [appliedUnder(a2)] };
|
|
120
|
+
expect(decideGateApproval({ ledger, gate: GATE, planDigest: D1, now: NOW })).toMatchObject({ status: "waiting", mismatched: a2 });
|
|
121
|
+
const d = decideGateApproval({ ledger, gate: GATE, planDigest: D1, spend: true, now: NOW });
|
|
122
|
+
expect(d).toMatchObject({ status: "waiting", spent: a2 });
|
|
123
|
+
expect(d).not.toHaveProperty("mismatched");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("decideGateApproval and evaluateGate spend the same approvals", async () => {
|
|
127
|
+
const ledger = { pending: [], resolutions: [a1] };
|
|
128
|
+
const spent = [appliedUnder(a1)];
|
|
129
|
+
expect(decided({ ledger: { ...ledger, applied: spent }, gate: GATE, planDigest: D1, spend: true })).toEqual(
|
|
130
|
+
await evaluatedSpending(memoryGateLedgerPort(ledger), { planDigest: D1, spent }),
|
|
131
|
+
);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("a pull request review a wave applied this digest under is spent", () => {
|
|
135
|
+
const head = "c".repeat(40);
|
|
136
|
+
const review = reviewApproval({ pr: 7, head, author: "github:carol", approvers: ["github:bob"], refused: [] }, D1, D1);
|
|
137
|
+
const recorded = approved("2026-10-11T11:00:00.000Z", D1, { resolvedBy: "github:bob", review: { pr: 7, head, approvers: ["github:bob"] } });
|
|
138
|
+
const ledger = { pending: [], resolutions: [recorded], applied: [appliedUnder(recorded, undefined, { review: { pr: 7, head } })] };
|
|
139
|
+
const d = decideGateApproval({ ledger, gate: GATE, planDigest: D1, mode: "pr-review", review, spend: true, now: NOW });
|
|
140
|
+
expect(d).toMatchObject({ status: "waiting", review: { reason: expect.stringMatching(/review is spent/) } });
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("the applied lines parse, and gate-audit reads them as applies under their approval", () => {
|
|
144
|
+
const line = appliedUnder(a1);
|
|
145
|
+
const text = `${JSON.stringify(line)}\nnot json\n${JSON.stringify({ ...line, kind: "finished" })}\n`;
|
|
146
|
+
expect(parseAppliedGateLines(text)).toEqual([line]);
|
|
147
|
+
const audit = deriveGateAudit({
|
|
148
|
+
ledgers: [{ op: "migrations", lines: [{ record: pending("2026-10-11T10:00:00.000Z") }, { record: a1 }] }],
|
|
149
|
+
applies: appliedGateApplies(parseAppliedGateLines(text)),
|
|
150
|
+
});
|
|
151
|
+
const approvedEntry = audit.entries.find((e) => e.kind === "approved")!;
|
|
152
|
+
expect(audit.entries.find((e) => e.kind === "applied")).toMatchObject({ gate: GATE, digest: D1, under: approvedEntry.id, detail: { approvedAt: a1.timestamp, approvedBy: "alice" } });
|
|
153
|
+
expect(audit.gaps).toEqual([]);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
async function evaluatedSpending(port: GateLedgerPort, input: { planDigest: string; spent: AppliedGateRecord[] }) {
|
|
158
|
+
const check = await evaluateGate(port, { op: "migrations", gate: GATE, now: NOW, ...input });
|
|
159
|
+
return check.satisfied
|
|
160
|
+
? { status: "approved", by: check.resolution.resolvedBy, at: check.resolution.timestamp }
|
|
161
|
+
: { status: "waiting", mismatched: check.mismatch?.resolvedBy ?? null, recorded: check.recorded };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
describe.skipIf(!hasSshKeygen)("sealed, read at base", () => {
|
|
165
|
+
const repos: TestRepo[] = [];
|
|
166
|
+
afterEach(() => {
|
|
167
|
+
while (repos.length) repos.pop()!.cleanup();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
function setup(files: Record<string, string>) {
|
|
171
|
+
const r = new TestRepo("3763-sealed");
|
|
172
|
+
repos.push(r);
|
|
173
|
+
const alice = r.key("alice");
|
|
174
|
+
const mallory = r.key("mallory");
|
|
175
|
+
for (const [path, text] of Object.entries(files)) r.write(path, text.replace("<alice>", alice.pub));
|
|
176
|
+
const base = r.commit("base");
|
|
177
|
+
// The change being applied adds mallory, who must not count.
|
|
178
|
+
r.write(".chant/allowed_signers", `alice ${alice.pub}\nmallory ${mallory.pub}\n`);
|
|
179
|
+
r.commit("adds mallory");
|
|
180
|
+
const seal = (a: GateResolutionRecord, key = alice, signer = a.resolvedBy) => ({ ...a, seal: { signer, key: "k", signature: r.sshSign(key, gateSealPayload(a), GATE_SEAL_NAMESPACE) } });
|
|
181
|
+
return { r, base, alice, mallory, seal };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
test("only a seal by a signer at base counts, as it does for chant run wave", async () => {
|
|
185
|
+
const { r, base, mallory, seal } = setup({ ".chant/allowed_signers": "alice <alice>\n" });
|
|
186
|
+
expect(baseCommit(r.dir)).toBe(base);
|
|
187
|
+
const signers = gateSignersAt(r.dir, base);
|
|
188
|
+
expect(signers).toMatchObject({ base, active: true, signersPath: ".chant/allowed_signers" });
|
|
189
|
+
expect(signers.signers.map((s) => s.principal)).toEqual(["alice"]);
|
|
190
|
+
|
|
191
|
+
const rule = sealedApprovalRule(GATE, trustPolicyAt(r.dir, base));
|
|
192
|
+
const chantWave = (ledger: { resolutions: GateResolutionRecord[]; pending: PendingGateRecord[] }): GateLedgerPort => ({
|
|
193
|
+
...memoryGateLedgerPort(ledger),
|
|
194
|
+
approvalRule: async (g) => (g === GATE ? rule : null),
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
const unsigned = approved("2026-10-11T11:00:00.000Z");
|
|
198
|
+
const byMallory = seal(approved("2026-10-11T11:10:00.000Z", D1, { resolvedBy: "mallory" }), mallory);
|
|
199
|
+
const byAlice = seal(approved("2026-10-11T11:20:00.000Z"));
|
|
200
|
+
const ledgers = [[unsigned], [unsigned, byMallory], [unsigned, byMallory, byAlice], [{ ...byAlice, planDigest: D2 }]];
|
|
201
|
+
for (const resolutions of ledgers) {
|
|
202
|
+
const ledger = { pending: [], resolutions };
|
|
203
|
+
expect(decided({ ledger, gate: GATE, planDigest: D1, mode: "sealed", signers })).toEqual(await evaluated(chantWave(ledger), { planDigest: D1 }));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const waiting = decideGateApproval({ ledger: { pending: [], resolutions: [unsigned, byMallory] }, gate: GATE, planDigest: D1, mode: "sealed", signers, now: NOW });
|
|
207
|
+
expect(waiting.status === "waiting" && waiting.uncounted.map((u) => u.reason)).toEqual([
|
|
208
|
+
`the approval of gate ${GATE} by alice carries no seal`,
|
|
209
|
+
"mallory has no key in .chant/allowed_signers at base, so the seal can't count",
|
|
210
|
+
]);
|
|
211
|
+
// With no signers handed in, a sealed gate passes nothing.
|
|
212
|
+
expect(decideGateApproval({ ledger: { pending: [], resolutions: [byAlice] }, gate: GATE, planDigest: D1, mode: "sealed", now: NOW }).status).toBe("waiting");
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("the signers path trust.json names, and a trust.json chant would refuse", () => {
|
|
216
|
+
const named = setup({ ".chant/trust.json": JSON.stringify({ schema: 1, signers: "ops/signers" }), "ops/signers": "alice <alice>\n" });
|
|
217
|
+
expect(gateSignersAt(named.r.dir, named.base)).toMatchObject({ active: true, signersPath: "ops/signers" });
|
|
218
|
+
expect(trustPolicyAt(named.r.dir, named.base)).toMatchObject({ active: true, signersPath: "ops/signers" });
|
|
219
|
+
|
|
220
|
+
const invalid = setup({ ".chant/trust.json": JSON.stringify({ schema: 1, roles: { Admin: ["alice"] } }), ".chant/allowed_signers": "alice <alice>\n" });
|
|
221
|
+
expect(gateSignersAt(invalid.r.dir, invalid.base)).toMatchObject({ active: false, problems: [expect.stringMatching(/trust\.json is invalid: roles/)] });
|
|
222
|
+
expect(trustPolicyAt(invalid.r.dir, invalid.base).active).toBe(false);
|
|
223
|
+
|
|
224
|
+
const none = setup({ "README.md": "x\n" });
|
|
225
|
+
expect(gateSignersAt(none.r.dir, none.base)).toMatchObject({ active: false, signers: [] });
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("identity.gates at base", () => {
|
|
229
|
+
const { r, base } = setup({
|
|
230
|
+
"chant.workspace.json": JSON.stringify({ identity: { gates: { [GATE]: {}, deploy: { class: "human" } } } }),
|
|
231
|
+
".chant/allowed_signers": "alice <alice>\n",
|
|
232
|
+
});
|
|
233
|
+
expect(identityGatesAt(r.dir, base)).toEqual([{ gate: GATE, class: null }, { gate: "deploy", class: "human" }]);
|
|
234
|
+
const empty = setup({ "README.md": "x\n" });
|
|
235
|
+
expect(identityGatesAt(empty.r.dir, empty.base)).toEqual([]);
|
|
236
|
+
});
|
|
237
|
+
});
|