@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.
- package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
- 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/config-import.d.ts +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/policy-import.d.ts +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-verify.js +76 -0
- package/dist/notify.d.ts +202 -0
- package/dist/notify.d.ts.map +1 -0
- package/dist/notify.js +307 -0
- package/dist/op/__fixtures__/decide-stub-backend.d.ts +1 -1
- 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 +218 -16
- package/dist/op/op-waves-run.d.ts.map +1 -1
- package/dist/op/op-waves.d.ts +113 -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 +225 -19
- package/dist/pr-forge.d.ts.map +1 -1
- package/dist/workspace/conformance/__fixtures__/reference-broker.d.ts +1 -1
- package/dist/workspace/conformance/broker.d.ts +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 +15 -2
- package/src/cli/commands/build.ts +1 -1
- package/src/cli/commands/check-lexicon.ts +3 -1
- package/src/cli/commands/lint-post-synth.e2e.test.ts +117 -0
- package/src/cli/commands/lint.test.ts +4 -109
- package/src/cli/commands/lint.ts +5 -2
- package/src/cli/discovery-skip.e2e.test.ts +18 -3
- 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/config-import.ts +1 -1
- package/src/discovery/sandbox/run.test.ts +1 -1
- 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/policy-import.ts +1 -1
- 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-plain-node.e2e.test.ts +68 -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/__fixtures__/decide-stub-backend.ts +1 -1
- 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 +36 -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 +908 -123
- package/src/op/op-waves.test.ts +543 -3
- package/src/op/op-waves.ts +215 -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 +378 -0
- package/src/pr-forge.ts +546 -47
- package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +1 -1
- package/src/workspace/conformance/__fixtures__/reference-broker.ts +1 -1
- package/src/workspace/conformance/broker.ts +1 -1
- package/src/workspace/records-write-contract.test.ts +3 -2
- package/src/workspace/records-write.e2e.test.ts +1 -1
- 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
- /package/src/audit/{catalog.test.ts → catalog.e2e.test.ts} +0 -0
- /package/src/audit/{discover.test.ts → discover.e2e.test.ts} +0 -0
- /package/src/audit/{remote-discovery.test.ts → remote-discovery.e2e.test.ts} +0 -0
- /package/src/audit/{rules-doc.test.ts → rules-doc.e2e.test.ts} +0 -0
- /package/src/{bench.test.ts → bench.e2e.test.ts} +0 -0
- /package/src/cli/commands/{check-lexicon-doc-drift.test.ts → check-lexicon-doc-drift.e2e.test.ts} +0 -0
- /package/src/cli/commands/{import.test.ts → import.e2e.test.ts} +0 -0
- /package/src/cli/{component-security-boundary.test.ts → component-security-boundary.e2e.test.ts} +0 -0
- /package/src/cli/mcp/{workspace-tools.test.ts → workspace-tools.e2e.test.ts} +0 -0
- /package/src/cli/{security-boundary.test.ts → security-boundary.e2e.test.ts} +0 -0
- /package/src/cli/{serve-mcp-workspace.test.ts → serve-mcp-workspace.e2e.test.ts} +0 -0
- /package/src/cli/{static-config-read.test.ts → static-config-read.e2e.test.ts} +0 -0
- /package/src/codegen/{json-schema-to-ts.test.ts → json-schema-to-ts.e2e.test.ts} +0 -0
- /package/src/{lexicon-seams.test.ts → lexicon-seams.e2e.test.ts} +0 -0
- /package/src/meta/{declared-imports.test.ts → declared-imports.e2e.test.ts} +0 -0
- /package/src/op/{command-gate.test.ts → command-gate.e2e.test.ts} +0 -0
- /package/src/op/{steward-beside.test.ts → steward-beside.e2e.test.ts} +0 -0
- /package/src/op/{steward-points.test.ts → steward-points.e2e.test.ts} +0 -0
- /package/src/workspace/{checks.test.ts → checks.e2e.test.ts} +0 -0
- /package/src/workspace/{intent-reviews.test.ts → intent-reviews.e2e.test.ts} +0 -0
- /package/src/workspace/{profiles.test.ts → profiles.e2e.test.ts} +0 -0
- /package/src/workspace/{read-contract.test.ts → read-contract.e2e.test.ts} +0 -0
- /package/src/workspace/{records-contract.test.ts → records-contract.e2e.test.ts} +0 -0
- /package/src/workspace/{records-since.test.ts → records-since.e2e.test.ts} +0 -0
- /package/src/workspace/{records-write.test.ts → records-write-commands.e2e.test.ts} +0 -0
- /package/src/workspace/{work-size.test.ts → work-size.e2e.test.ts} +0 -0
|
@@ -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
|
+
});
|
|
@@ -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
|
+
}
|