@intentius/chant 0.124.25 → 0.124.26

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 (104) hide show
  1. package/dist/cli/commands/build.d.ts.map +1 -1
  2. package/dist/cli/commands/build.js +4 -2
  3. package/dist/cli/commands/lint.d.ts.map +1 -1
  4. package/dist/cli/commands/lint.js +4 -2
  5. package/dist/cli/handlers/approve.d.ts.map +1 -1
  6. package/dist/cli/handlers/approve.js +39 -4
  7. package/dist/gate-decision.d.ts +1 -1
  8. package/dist/gate-decision.d.ts.map +1 -1
  9. package/dist/gate-decision.js +1 -1
  10. package/dist/lifecycle/gate-audit.d.ts +7 -1
  11. package/dist/lifecycle/gate-audit.d.ts.map +1 -1
  12. package/dist/lifecycle/gate-audit.js +40 -3
  13. package/dist/lifecycle/gate-checkpoint.d.ts +78 -0
  14. package/dist/lifecycle/gate-checkpoint.d.ts.map +1 -0
  15. package/dist/lifecycle/gate-checkpoint.js +103 -0
  16. package/dist/lifecycle/gate-ledger.d.ts +41 -0
  17. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  18. package/dist/lifecycle/gate-ledger.js +18 -0
  19. package/dist/lifecycle/run-ledger.d.ts +2 -0
  20. package/dist/lifecycle/run-ledger.d.ts.map +1 -1
  21. package/dist/lifecycle/run-ledger.js +2 -0
  22. package/dist/lint/rules/op/index.d.ts +6 -2
  23. package/dist/lint/rules/op/index.d.ts.map +1 -1
  24. package/dist/lint/rules/op/index.js +7 -3
  25. package/dist/lint/rules/op/ops016-gate-resume.d.ts +14 -0
  26. package/dist/lint/rules/op/ops016-gate-resume.d.ts.map +1 -0
  27. package/dist/lint/rules/op/ops016-gate-resume.js +31 -0
  28. package/dist/lint/rules/op/ops017-gate-alias.d.ts +30 -0
  29. package/dist/lint/rules/op/ops017-gate-alias.d.ts.map +1 -0
  30. package/dist/lint/rules/op/ops017-gate-alias.js +67 -0
  31. package/dist/op/activity-contract.d.ts +14 -0
  32. package/dist/op/activity-contract.d.ts.map +1 -1
  33. package/dist/op/activity-contract.js +16 -0
  34. package/dist/op/builders.d.ts +7 -0
  35. package/dist/op/builders.d.ts.map +1 -1
  36. package/dist/op/builders.js +13 -0
  37. package/dist/op/gate-alias.d.ts +57 -0
  38. package/dist/op/gate-alias.d.ts.map +1 -0
  39. package/dist/op/gate-alias.js +127 -0
  40. package/dist/op/gate-checkpoint.d.ts +123 -0
  41. package/dist/op/gate-checkpoint.d.ts.map +1 -0
  42. package/dist/op/gate-checkpoint.js +280 -0
  43. package/dist/op/gate.d.ts +54 -2
  44. package/dist/op/gate.d.ts.map +1 -1
  45. package/dist/op/gate.js +142 -1
  46. package/dist/op/index.d.ts +4 -1
  47. package/dist/op/index.d.ts.map +1 -1
  48. package/dist/op/index.js +2 -1
  49. package/dist/op/local-executor.d.ts +57 -2
  50. package/dist/op/local-executor.d.ts.map +1 -1
  51. package/dist/op/local-executor.js +136 -6
  52. package/dist/op/local-output.d.ts.map +1 -1
  53. package/dist/op/local-output.js +20 -2
  54. package/dist/op/op-ir.d.ts +4 -0
  55. package/dist/op/op-ir.d.ts.map +1 -1
  56. package/dist/op/op-ir.js +4 -0
  57. package/dist/op/runtime.d.ts +15 -0
  58. package/dist/op/runtime.d.ts.map +1 -1
  59. package/dist/op/runtimes/local.d.ts.map +1 -1
  60. package/dist/op/runtimes/local.js +5 -0
  61. package/dist/op/types.d.ts +31 -0
  62. package/dist/op/types.d.ts.map +1 -1
  63. package/dist/wave-runtime.mjs +214 -212
  64. package/dist/workspace/trust/gate-seal.d.ts +32 -1
  65. package/dist/workspace/trust/gate-seal.d.ts.map +1 -1
  66. package/dist/workspace/trust/gate-seal.js +41 -2
  67. package/dist/workspace/trust/seal.d.ts +1 -1
  68. package/dist/workspace/trust/seal.d.ts.map +1 -1
  69. package/dist/workspace/trust/seal.js +1 -1
  70. package/package.json +1 -1
  71. package/src/cli/commands/build.ts +9 -2
  72. package/src/cli/commands/lint.ts +4 -2
  73. package/src/cli/handlers/approve.ts +46 -4
  74. package/src/cli/handlers/operator.test.ts +91 -0
  75. package/src/gate-decision.ts +2 -0
  76. package/src/lifecycle/gate-audit.ts +41 -4
  77. package/src/lifecycle/gate-checkpoint.test.ts +128 -0
  78. package/src/lifecycle/gate-checkpoint.ts +133 -0
  79. package/src/lifecycle/gate-ledger.ts +61 -0
  80. package/src/lifecycle/run-ledger.ts +4 -0
  81. package/src/lint/rules/op/index.ts +9 -3
  82. package/src/lint/rules/op/ops016-gate-resume.test.ts +53 -0
  83. package/src/lint/rules/op/ops016-gate-resume.ts +48 -0
  84. package/src/lint/rules/op/ops017-gate-alias.test.ts +85 -0
  85. package/src/lint/rules/op/ops017-gate-alias.ts +89 -0
  86. package/src/op/activity-contract.ts +22 -1
  87. package/src/op/builders.ts +21 -1
  88. package/src/op/gate-alias.e2e.test.ts +78 -0
  89. package/src/op/gate-alias.test.ts +350 -0
  90. package/src/op/gate-alias.ts +137 -0
  91. package/src/op/gate-checkpoint.test.ts +284 -0
  92. package/src/op/gate-checkpoint.ts +322 -0
  93. package/src/op/gate.ts +198 -4
  94. package/src/op/index.ts +4 -1
  95. package/src/op/local-executor.ts +180 -7
  96. package/src/op/local-output.ts +18 -2
  97. package/src/op/op-ir.test.ts +25 -0
  98. package/src/op/op-ir.ts +8 -0
  99. package/src/op/runtime.ts +8 -0
  100. package/src/op/runtimes/local.ts +5 -0
  101. package/src/op/types.ts +32 -0
  102. package/src/workspace/trust/gate-seal.test.ts +29 -2
  103. package/src/workspace/trust/gate-seal.ts +46 -2
  104. package/src/workspace/trust/seal.ts +1 -1
@@ -0,0 +1,128 @@
1
+ /**
2
+ * #3947 on a real `chant/lifecycle` branch: the checkpoint lands at
3
+ * `_gates/<op>/checkpoints/<runId>.json` beside the gate ledger, the ledger's
4
+ * readers don't see it, and a run resumes from it at the checkout's own HEAD.
5
+ */
6
+
7
+ import { describe, expect, test } from "vitest";
8
+ import { withTestDir } from "@intentius/chant-test-utils";
9
+ import { spawnSync } from "node:child_process";
10
+ import { writeFileSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import {
13
+ checkpointOutputs,
14
+ gateCheckpointDigest,
15
+ isCheckpointRunId,
16
+ parseGateCheckpoint,
17
+ readGateCheckpointText,
18
+ writeGateCheckpoint,
19
+ type GateCheckpoint,
20
+ } from "./gate-checkpoint";
21
+ import { appendGateResolution, readGateLedger } from "./gate-ledger";
22
+ import { runOpLocally } from "../op/local-executor";
23
+ import { gitGateLedgerPort } from "../op/gate";
24
+ import { stepOutput } from "../op/step-output-ref";
25
+ import type { ActivityFn } from "../op/activity-registry";
26
+ import type { OpConfig } from "../op/types";
27
+
28
+ function git(args: string[], cwd: string): string {
29
+ return spawnSync("git", args, { cwd, encoding: "utf-8" }).stdout ?? "";
30
+ }
31
+
32
+ function initRepo(dir: string): void {
33
+ git(["init", "-q", "-b", "main"], dir);
34
+ git(["config", "user.email", "test@chant.dev"], dir);
35
+ git(["config", "user.name", "Test"], dir);
36
+ writeFileSync(join(dir, "README.md"), "fixture\n");
37
+ git(["add", "README.md"], dir);
38
+ git(["commit", "-q", "-m", "init"], dir);
39
+ }
40
+
41
+ const checkpoint = (over: Partial<GateCheckpoint> = {}): GateCheckpoint => ({
42
+ version: 1, op: "upgrade", gate: "approve", runId: "run-1", commit: "c".repeat(40), outputs: { snap: { id: "s-1" } }, ...over,
43
+ });
44
+
45
+ describe("gate checkpoint files", () => {
46
+ test("the digest is over the JCS form, so key order and the file's layout don't move it", () => {
47
+ const a = checkpoint({ outputs: { snap: { id: "s-1", b: 1 } } });
48
+ const b = checkpoint({ outputs: { snap: { b: 1, id: "s-1" } } });
49
+ expect(gateCheckpointDigest(a)).toBe(gateCheckpointDigest(b));
50
+ expect(gateCheckpointDigest(a)).toMatch(/^jcs1-sha256:[0-9a-f]{64}$/);
51
+ expect(gateCheckpointDigest(checkpoint({ outputs: { snap: { id: "s-2" } } }))).not.toBe(gateCheckpointDigest(checkpoint()));
52
+ });
53
+
54
+ test("outputs are stored as JSON carries them, and one JSON can't hold is named", () => {
55
+ const outputs = new Map<string, unknown>([["a", { at: new Date(0), skip: undefined }], ["b", 1n]]);
56
+ expect(checkpointOutputs(outputs, ["a"])).toEqual({ outputs: { a: { at: "1970-01-01T00:00:00.000Z" } } });
57
+ expect(checkpointOutputs(outputs, ["a", "b"])).toEqual({ unstorable: "b" });
58
+ });
59
+
60
+ test("a run id that can't be a file name gets no checkpoint", () => {
61
+ expect(isCheckpointRunId("local-1760000000000")).toBe(true);
62
+ expect(isCheckpointRunId("../escape")).toBe(false);
63
+ expect(isCheckpointRunId(".hidden")).toBe(false);
64
+ });
65
+
66
+ test("a file that isn't a checkpoint reads as null", () => {
67
+ expect(parseGateCheckpoint("{")).toBeNull();
68
+ expect(parseGateCheckpoint(JSON.stringify({ ...checkpoint(), outputs: [] }))).toBeNull();
69
+ });
70
+
71
+ test("writes beside the gate ledger, which its readers don't see", async () => {
72
+ await withTestDir(async (dir) => {
73
+ initRepo(dir);
74
+ await writeGateCheckpoint(checkpoint(), { cwd: dir });
75
+ const text = await readGateCheckpointText("upgrade", "run-1", { cwd: dir });
76
+ expect(parseGateCheckpoint(text!)).toEqual(checkpoint());
77
+ expect(git(["ls-tree", "-r", "--name-only", "chant/lifecycle"], dir).trim()).toBe("_gates/upgrade/checkpoints/run-1.json");
78
+ expect(await readGateLedger("upgrade", { cwd: dir })).toEqual({ resolutions: [], pending: [], malformed: 0 });
79
+ expect(await readGateCheckpointText("upgrade", "run-2", { cwd: dir })).toBeNull();
80
+ });
81
+ });
82
+ });
83
+
84
+ describe("a resumed run on a real ledger", () => {
85
+ test("stops, is approved with the checkpoint's digest, and resumes at the gate at the same HEAD", async () => {
86
+ await withTestDir(async (dir) => {
87
+ initRepo(dir);
88
+ let snapshots = 0;
89
+ const applied: unknown[] = [];
90
+ const activities = new Map<string, ActivityFn>([
91
+ ["snapshot", async () => ({ id: `snap-${++snapshots}` })],
92
+ ["plan", async () => ({ planDigest: `jcs1-sha256:${"a".repeat(64)}` })],
93
+ ["apply", async (args) => {
94
+ applied.push(args.snapshot);
95
+ return {};
96
+ }],
97
+ ]);
98
+ const config: OpConfig = {
99
+ name: "upgrade",
100
+ overview: "upgrade",
101
+ phases: [
102
+ { name: "Prepare", steps: [{ kind: "activity", id: "snapshot", fn: "snapshot" }, { kind: "activity", id: "plan", fn: "plan" }] },
103
+ { name: "Gate", steps: [{ kind: "gate", gate: "approve", plan: stepOutput("plan", "planDigest"), resume: true }] },
104
+ { name: "Apply", steps: [{ kind: "activity", fn: "apply", args: { snapshot: stepOutput("snapshot", "id") } }] },
105
+ ],
106
+ };
107
+ const options = (runId: string, now: string) => ({ gates: gitGateLedgerPort({ cwd: dir }), cwd: dir, runId, now });
108
+
109
+ const first = await runOpLocally(config, activities, {}, undefined, options("run-1", "2026-10-12T12:00:00.000Z"));
110
+ expect(first.status).toBe("gated");
111
+ const digest = first.gate!.checkpointDigest!;
112
+ const stored = parseGateCheckpoint((await readGateCheckpointText("upgrade", "run-1", { cwd: dir }))!)!;
113
+ expect(stored.commit).toBe(git(["rev-parse", "HEAD"], dir).trim());
114
+ expect(gateCheckpointDigest(stored)).toBe(digest);
115
+
116
+ await appendGateResolution({
117
+ op: "upgrade", gate: "approve", resolvedBy: "alex", timestamp: "2026-10-12T12:05:00.000Z",
118
+ planDigest: first.gate!.planDigest!, checkpointDigest: digest,
119
+ }, { cwd: dir });
120
+
121
+ const second = await runOpLocally(config, activities, {}, undefined, options("run-2", "2026-10-12T12:10:00.000Z"));
122
+ expect(second.status).toBe("ok");
123
+ expect(second.resumed).toEqual({ gate: "approve", run: "run-1", checkpoint: digest });
124
+ expect(snapshots).toBe(1);
125
+ expect(applied).toEqual(["snap-1"]);
126
+ });
127
+ });
128
+ });
@@ -0,0 +1,133 @@
1
+ /**
2
+ * A gate's checkpoint (#3947): the step outputs a run held when it stopped at
3
+ * a gate that resumes, kept on `chant/lifecycle` at
4
+ * `_gates/<op>/checkpoints/<runId>.json`.
5
+ *
6
+ * The run writes the file just before it appends the pending fact, and puts
7
+ * the file's digest on that fact as `checkpointDigest`. `chant approve`
8
+ * copies the digest onto the approval, the way it copies the plan digest, and
9
+ * a seal covers it (`../workspace/trust/gate-seal.ts`). A later run that
10
+ * resumes at the gate reads the file back and checks it against the digest
11
+ * the approver saw, so a value pushed onto the branch after the approval is
12
+ * refused rather than carried into the steps after the gate.
13
+ *
14
+ * Only the outputs some step after the gate references are stored, worked
15
+ * out from the Op's references (`../op/gate-checkpoint.ts`). An output that
16
+ * an activity contract marks secret is never stored: the gate doesn't
17
+ * resume, and the next run starts at the first phase.
18
+ *
19
+ * Pure apart from the two git helpers at the bottom.
20
+ */
21
+
22
+ import { canonicalJson } from "../effect-receipt";
23
+ import { contentDigest } from "../content-digest";
24
+ import { sortedJsonReplacer } from "../utils";
25
+ import { readBlobFromPath, writeBlobToPath } from "./git";
26
+
27
+ /** One gate checkpoint file. */
28
+ export interface GateCheckpoint {
29
+ version: 1;
30
+ /** The op the gate belongs to. */
31
+ op: string;
32
+ /** The gate's name. */
33
+ gate: string;
34
+ /** The run that stopped at the gate, and the file's name. The pending fact names the same run. */
35
+ runId: string;
36
+ /** The commit the run ran at. A resumed run must be at the same one. */
37
+ commit: string;
38
+ /** The environment the run was started for (`--env`), when it named one. */
39
+ environment?: string;
40
+ /** The plan the run reached the gate with, when the gate binds one. */
41
+ planDigest?: string;
42
+ /** Step id to the output that step returned, for each step a later step references. */
43
+ outputs: Record<string, unknown>;
44
+ }
45
+
46
+ /** The prefix a checkpoint digest is written with: the JCS form of the file, hashed with SHA-256. */
47
+ export const CHECKPOINT_DIGEST_PREFIX = "jcs1-sha256:";
48
+
49
+ const CHECKPOINT_DIGEST_PATTERN = /^jcs1-sha256:[0-9a-f]{64}$/;
50
+
51
+ /** Whether `raw` is a checkpoint digest this code wrote. */
52
+ export function isCheckpointDigest(raw: unknown): raw is string {
53
+ return typeof raw === "string" && CHECKPOINT_DIGEST_PATTERN.test(raw);
54
+ }
55
+
56
+ /** The digest of a checkpoint: SHA-256 over its RFC 8785 (JCS) form. Throws when an output can't be put in JSON. */
57
+ export function gateCheckpointDigest(checkpoint: GateCheckpoint): string {
58
+ return CHECKPOINT_DIGEST_PREFIX + contentDigest(canonicalJson(checkpoint)).slice("sha256:".length);
59
+ }
60
+
61
+ /**
62
+ * A run id is the checkpoint's file name, so it may hold only letters,
63
+ * digits, `.`, `_` and `-`, and may not start with a dot. A run named
64
+ * otherwise (a caller-chosen id with a slash in it) gets no checkpoint.
65
+ */
66
+ export function isCheckpointRunId(runId: string): boolean {
67
+ return /^[A-Za-z0-9_-][A-Za-z0-9._-]*$/.test(runId) && runId.length <= 200;
68
+ }
69
+
70
+ /** Where the checkpoint of `runId` lives on `chant/lifecycle`: the directory and the file, as `writeBlobToPath` takes them. */
71
+ export function gateCheckpointPath(op: string, runId: string): { dir: string; file: string } {
72
+ return { dir: `_gates/${op}/checkpoints`, file: `${runId}.json` };
73
+ }
74
+
75
+ /** The file's text: the checkpoint as JSON, keys sorted, one value per line. */
76
+ export function gateCheckpointText(checkpoint: GateCheckpoint): string {
77
+ return `${JSON.stringify(checkpoint, sortedJsonReplacer, 2)}\n`;
78
+ }
79
+
80
+ /**
81
+ * Put `outputs` in the form a checkpoint stores: each value as JSON would
82
+ * carry it. Returns the step whose output JSON can't hold (a cycle, a bigint)
83
+ * instead, so the caller can say which one blocks the gate's resume.
84
+ */
85
+ export function checkpointOutputs(outputs: ReadonlyMap<string, unknown>, ids: readonly string[]): { outputs: Record<string, unknown> } | { unstorable: string } {
86
+ const out: Record<string, unknown> = {};
87
+ for (const id of ids) {
88
+ if (!outputs.has(id)) continue;
89
+ const value = outputs.get(id);
90
+ try {
91
+ const text = JSON.stringify(value === undefined ? null : value);
92
+ const parsed: unknown = JSON.parse(text);
93
+ canonicalJson(parsed);
94
+ out[id] = parsed;
95
+ } catch {
96
+ return { unstorable: id };
97
+ }
98
+ }
99
+ return { outputs: out };
100
+ }
101
+
102
+ /** A checkpoint file's text read back, or null when it isn't one. */
103
+ export function parseGateCheckpoint(text: string): GateCheckpoint | null {
104
+ let raw: unknown;
105
+ try {
106
+ raw = JSON.parse(text);
107
+ } catch {
108
+ return null;
109
+ }
110
+ const r = raw as Partial<GateCheckpoint> | null;
111
+ if (
112
+ r === null || typeof r !== "object" || r.version !== 1 || typeof r.op !== "string" || typeof r.gate !== "string" ||
113
+ typeof r.runId !== "string" || typeof r.commit !== "string" ||
114
+ r.outputs === null || typeof r.outputs !== "object" || Array.isArray(r.outputs) ||
115
+ (r.environment !== undefined && typeof r.environment !== "string") ||
116
+ (r.planDigest !== undefined && typeof r.planDigest !== "string")
117
+ ) {
118
+ return null;
119
+ }
120
+ return r as GateCheckpoint;
121
+ }
122
+
123
+ /** Write a checkpoint to the local `chant/lifecycle` branch. Does not push: the pending fact appended after it does. */
124
+ export async function writeGateCheckpoint(checkpoint: GateCheckpoint, opts?: { cwd?: string }): Promise<void> {
125
+ const { dir, file } = gateCheckpointPath(checkpoint.op, checkpoint.runId);
126
+ await writeBlobToPath(dir, file, gateCheckpointText(checkpoint), `Gate checkpoint for ${checkpoint.gate}`, opts);
127
+ }
128
+
129
+ /** The text of `runId`'s checkpoint for `op`, or null when there is none. */
130
+ export async function readGateCheckpointText(op: string, runId: string, opts?: { cwd?: string }): Promise<string | null> {
131
+ const { dir, file } = gateCheckpointPath(op, runId);
132
+ return readBlobFromPath(dir, file, opts);
133
+ }
@@ -138,6 +138,12 @@ export interface GateResolutionRecord {
138
138
  * such a record proves is that somebody approved *something*.
139
139
  */
140
140
  planDigest?: string;
141
+ /**
142
+ * The checkpoint this approval lets a run resume from (#3947), copied off
143
+ * the pending fact it answers. Set only on an approval of a gate that
144
+ * resumes (`resume: true`) whose run stored a checkpoint. A seal covers it.
145
+ */
146
+ checkpointDigest?: string;
141
147
  /**
142
148
  * The environment this approval is for (#2574), copied off the pending fact
143
149
  * it answers. Set for a component gate, whose ledger is keyed by component
@@ -204,6 +210,33 @@ export interface GateResolutionRecord {
204
210
  * `constrains` names the gate back (`gate:<op>/<gate>`).
205
211
  */
206
212
  answer?: GateAnswerRef;
213
+ /**
214
+ * The line this approval was carried from (#3949): an approval recorded
215
+ * under one of the gate's aliases, which a run counted toward the gate and
216
+ * wrote again under its new name. `op`, `gate` and `timestamp` name that
217
+ * line; `checkpointDigest` is the one it carried, if any, since a seal
218
+ * signs it. `resolvedBy`, the plan, the environment and the seal are the
219
+ * original's, and the seal verifies against the names in here. This
220
+ * record's own `timestamp` is when it was carried.
221
+ */
222
+ carriedFrom?: GateCarriedFrom;
223
+ }
224
+
225
+ /** Where a carried approval came from (#3949): the original line's op, gate and time, and its checkpoint digest when it had one. */
226
+ export interface GateCarriedFrom {
227
+ op: string;
228
+ gate: string;
229
+ timestamp: string;
230
+ checkpointDigest?: string;
231
+ }
232
+
233
+ /** Whether `raw` is a {@link GateCarriedFrom}. */
234
+ export function isGateCarriedFrom(raw: unknown): raw is GateCarriedFrom {
235
+ const c = raw as Partial<GateCarriedFrom> | null;
236
+ return (
237
+ !!c && typeof c === "object" && !Array.isArray(c) && typeof c.op === "string" && typeof c.gate === "string" &&
238
+ typeof c.timestamp === "string" && (c.checkpointDigest === undefined || typeof c.checkpointDigest === "string")
239
+ );
207
240
  }
208
241
 
209
242
  /** A gate approval's seal (#3163): who signed, the key's fingerprint (reported, never trusted), and the armored signature. */
@@ -211,6 +244,12 @@ export interface GateApprovalSeal {
211
244
  signer: string;
212
245
  key: string;
213
246
  signature: string;
247
+ /**
248
+ * The seal's version (#3947). `2` on a seal over an approval that carries a
249
+ * `checkpointDigest`, whose payload signs the digest as one more line.
250
+ * Absent on every other seal, which is version 1.
251
+ */
252
+ version?: 2;
214
253
  }
215
254
 
216
255
  export type GateResolutionInput = Omit<GateResolutionRecord, "version" | "kind">;
@@ -278,6 +317,16 @@ export interface PendingGateRecord {
278
317
  * binds one since #2574.
279
318
  */
280
319
  planDigest?: string;
320
+ /**
321
+ * The digest of the checkpoint the run stored when it stopped here (#3947),
322
+ * on a gate that resumes (`resume: true`). The file is at
323
+ * `_gates/<op>/checkpoints/<runId>.json` (`./gate-checkpoint.ts`). `chant
324
+ * approve` copies it onto the approval, and a run resumes at this gate only
325
+ * from a checkpoint that matches it. Absent when the gate doesn't resume,
326
+ * or when its run could not store one, such as when an output it needs is
327
+ * secret.
328
+ */
329
+ checkpointDigest?: string;
281
330
  /**
282
331
  * The environment the run reached this gate in (#2574). Set by the
283
332
  * component driver, whose gate ledger is keyed by component, so pending
@@ -470,6 +519,12 @@ export function parseGateLedger(
470
519
  malformed++;
471
520
  continue;
472
521
  }
522
+ // #3947: and for the checkpoint an approval lets a run resume from.
523
+ const rawCheckpoint: unknown = (parsed as { checkpointDigest?: unknown }).checkpointDigest;
524
+ if (rawCheckpoint !== undefined && typeof rawCheckpoint !== "string") {
525
+ malformed++;
526
+ continue;
527
+ }
473
528
  if (parsed.kind === "pending") {
474
529
  if (typeof parsed.expiresAt !== "string") {
475
530
  malformed++;
@@ -488,6 +543,12 @@ export function parseGateLedger(
488
543
  malformed++;
489
544
  continue;
490
545
  }
546
+ // #3949: a carried approval's seal is checked against the line it cites, so the citation must be whole.
547
+ const rawCarried: unknown = (parsed as { carriedFrom?: unknown }).carriedFrom;
548
+ if (rawCarried !== undefined && !isGateCarriedFrom(rawCarried)) {
549
+ malformed++;
550
+ continue;
551
+ }
491
552
  resolutions.push(parsed as GateResolutionRecord);
492
553
  } catch {
493
554
  malformed++;
@@ -87,6 +87,8 @@ export function buildRunRecord(
87
87
  gate?: OpRunRecord["gate"];
88
88
  point?: OpRunRecord["point"];
89
89
  steward?: string;
90
+ /** The gate the run resumed at, for a run that resumed (#3947). */
91
+ resumed?: OpRunRecord["resumed"];
90
92
  /** Each phase's wall-clock time, by name, as the executor measured it. */
91
93
  phaseDurations?: Record<string, number>;
92
94
  },
@@ -112,6 +114,7 @@ export function buildRunRecord(
112
114
  ...(record.refusal !== undefined ? { refusal: record.refusal } : {}),
113
115
  ...(record.point ? { point: record.point } : {}),
114
116
  ...(record.gate ? { gate: record.gate } : {}),
117
+ ...(record.resumed ? { resumed: record.resumed } : {}),
115
118
  });
116
119
  if (record.outcome) outcomes[record.outcome.name] = record.outcome.value;
117
120
  }
@@ -134,6 +137,7 @@ export function buildRunRecord(
134
137
  ...(times.gate ? { gate: times.gate } : {}),
135
138
  ...(times.point ? { point: times.point } : {}),
136
139
  ...(times.steward ? { steward: times.steward } : {}),
140
+ ...(times.resumed ? { resumed: times.resumed } : {}),
137
141
  };
138
142
  }
139
143
 
@@ -14,6 +14,8 @@ import { ops012 } from "./ops012-activity-contract";
14
14
  import { ops013 } from "./ops013-step-output-ref";
15
15
  import { ops014 } from "./ops014-converge-rule-refusals";
16
16
  import { ops015 } from "./ops015-gate-approval";
17
+ import { ops016 } from "./ops016-gate-resume";
18
+ import { ops017Check } from "./ops017-gate-alias";
17
19
  import { sys010Check } from "./sys010-key-over-plain-http";
18
20
  import type { DecideBackend } from "../../../op/decide-config";
19
21
 
@@ -21,18 +23,22 @@ export { ops012 } from "./ops012-activity-contract";
21
23
  export { ops013 } from "./ops013-step-output-ref";
22
24
  export { ops014 } from "./ops014-converge-rule-refusals";
23
25
  export { ops015 } from "./ops015-gate-approval";
26
+ export { ops016 } from "./ops016-gate-resume";
27
+ export { ops017, ops017Check, workspaceIdentityGates } from "./ops017-gate-alias";
24
28
  export { sys010, sys010Check } from "./sys010-key-over-plain-http";
25
29
 
26
30
  export interface CoreOpCheckOptions {
27
31
  /** `decide.backends` from the project's chant.config, for SYS010 (#2828). */
28
32
  decideBackends?: Record<string, Pick<DecideBackend, "url" | "key">>;
33
+ /** The workspace's `identity.gates`, gate to class, for OPS017 (#3949): `workspaceIdentityGates`' answer. */
34
+ identityGates?: ReadonlyMap<string, string | null>;
29
35
  }
30
36
 
31
37
  /**
32
38
  * Core's own post-synth checks over the Op model: OPS012, OPS013, OPS014,
33
- * OPS015, and SYS010 over the `decide` steps and the configured decide
34
- * backends.
39
+ * OPS015, OPS016, OPS017 over the workspace's `identity.gates`, and SYS010
40
+ * over the `decide` steps and the configured decide backends.
35
41
  */
36
42
  export function coreOpChecks(opts: CoreOpCheckOptions = {}): PostSynthCheck[] {
37
- return [ops012, ops013, ops014, ops015, sys010Check(opts.decideBackends)];
43
+ return [ops012, ops013, ops014, ops015, ops016, ops017Check(opts.identityGates), sys010Check(opts.decideBackends)];
38
44
  }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * OPS016 (#3947) — a gate with `resume: true`, checked over the build output.
3
+ */
4
+
5
+ import { describe, test, expect } from "vitest";
6
+ import { z } from "zod";
7
+ import type { PostSynthContext } from "../../post-synth";
8
+ import { DECLARABLE_MARKER } from "../../../declarable";
9
+ import { activityContract } from "../../../op/activity-contract";
10
+ import { stepOutput } from "../../../op/step-output-ref";
11
+ import { ops016 } from "./ops016-gate-resume";
12
+
13
+ function ctxWith(phases: unknown[], activityContracts?: PostSynthContext["activityContracts"]): PostSynthContext {
14
+ const entities = new Map<string, unknown>([["upgrade", {
15
+ [DECLARABLE_MARKER]: true,
16
+ entityType: "Chant::Op",
17
+ lexicon: "chant",
18
+ kind: "resource",
19
+ props: { name: "upgrade", overview: "test", phases },
20
+ attributes: {},
21
+ }]]);
22
+ return {
23
+ outputs: new Map([["chant", ""]]),
24
+ entities: entities as Map<string, never>,
25
+ buildResult: { outputs: new Map([["chant", ""]]), entities: entities as Map<string, never>, warnings: [], errors: [], sourceFileCount: 1 },
26
+ ...(activityContracts ? { activityContracts } : {}),
27
+ };
28
+ }
29
+
30
+ const phases = (gate: Record<string, unknown>) => [
31
+ { name: "Prepare", steps: [{ kind: "activity", id: "token", fn: "mintToken" }, { kind: "activity", id: "plan", fn: "planIt" }] },
32
+ { name: "Gate", steps: [{ kind: "gate", gate: "approve", plan: stepOutput("plan", "planDigest"), resume: true, ...gate }] },
33
+ { name: "Apply", steps: [{ kind: "activity", fn: "applyIt", args: { token: stepOutput("token", "value") } }] },
34
+ ];
35
+
36
+ describe("OPS016", () => {
37
+ test("a gate that can resume passes", () => {
38
+ expect(ops016.check(ctxWith(phases({})))).toEqual([]);
39
+ });
40
+
41
+ test("a gate that asks a decision point is refused, naming the op, phase and gate", () => {
42
+ const [diag] = ops016.check(ctxWith(phases({ point: "prod-apply" })));
43
+ expect(diag?.checkId).toBe("OPS016");
44
+ expect(diag?.severity).toBe("error");
45
+ expect(diag?.message).toBe('Op "upgrade", phase "Gate", gate:approve: a gate that asks a decision point can\'t resume');
46
+ });
47
+
48
+ test("a secret output read after the gate is refused, from the contracts the build loaded", () => {
49
+ const contracts = new Map([["mintToken", activityContract("mintToken", z.object({}), z.object({ value: z.string() }), { secret: ["value"] })]]);
50
+ const [diag] = ops016.check(ctxWith(phases({}), contracts));
51
+ expect(diag?.message).toContain(`can't resume: step "token"'s output "value" is secret`);
52
+ });
53
+ });
@@ -0,0 +1,48 @@
1
+ /**
2
+ * OPS016: a gate that resumes (`resume: true`, #3947) can. It sits at the top
3
+ * of a main phase, asks no decision point, is not in an Op with a
4
+ * `workLease`, names a plan step that runs before it when its plan is a
5
+ * reference, and no step after it reads an output an activity contract marks
6
+ * secret, since a checkpoint never stores one.
7
+ *
8
+ * The run itself treats a gate that fails one of these as one that doesn't
9
+ * resume. This check makes that a build error instead of a quiet re-run from
10
+ * the first phase. Contracts come from the same merged map OPS013 uses.
11
+ */
12
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "../../post-synth";
13
+ import { mergeActivityContracts, type ActivityContract } from "../../../op";
14
+ import type { OpConfig } from "../../../op";
15
+ import { validateGateResume } from "../../../op/gate-checkpoint";
16
+ import * as contracts from "../../../op/activities/activity-contracts";
17
+ import { isOpEntity } from "./support";
18
+
19
+ const CONTRACTS: Map<string, ActivityContract> = new Map(
20
+ Object.values(contracts).map((c) => [c.name, c]),
21
+ );
22
+
23
+ export const ops016: PostSynthCheck = {
24
+ id: "OPS016",
25
+ description:
26
+ "A gate with resume: true can resume: at the top of a main phase, no decision point or workLease, a plan step before it, and no secret output read after it",
27
+
28
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
29
+ const diagnostics: PostSynthDiagnostic[] = [];
30
+ const activeContracts = mergeActivityContracts(CONTRACTS, ctx.activityContracts);
31
+ for (const [entityKey, entity] of ctx.entities) {
32
+ if (!isOpEntity(entity)) continue;
33
+ const rec = entity as unknown as Record<string, unknown>;
34
+ const props = ((entity as { props?: Record<string, unknown> }).props ?? {}) as unknown as OpConfig;
35
+ if (typeof props.name !== "string" || !Array.isArray(props.phases)) continue;
36
+ for (const issue of validateGateResume(props, activeContracts)) {
37
+ diagnostics.push({
38
+ checkId: "OPS016",
39
+ severity: "error",
40
+ message: `Op "${issue.opName}", phase "${issue.phase}", ${issue.fn}: ${issue.message}`,
41
+ entity: entityKey,
42
+ lexicon: typeof rec.lexicon === "string" ? rec.lexicon : undefined,
43
+ });
44
+ }
45
+ }
46
+ return diagnostics;
47
+ },
48
+ };
@@ -0,0 +1,85 @@
1
+ /**
2
+ * OPS017 (#3949) — a gate's aliases, checked over every Op in the build and
3
+ * the workspace's `identity.gates`.
4
+ */
5
+
6
+ import { afterEach, describe, expect, test } from "vitest";
7
+ import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
8
+ import { rm } from "node:fs/promises";
9
+ import { join } from "node:path";
10
+ import { tmpdir } from "node:os";
11
+ import type { PostSynthContext } from "../../post-synth";
12
+ import { DECLARABLE_MARKER } from "../../../declarable";
13
+ import { ops017, ops017Check, workspaceIdentityGates } from "./ops017-gate-alias";
14
+
15
+ const PLAN = `jcs1-sha256:${"a".repeat(64)}`;
16
+
17
+ function ctxWith(ops: Array<{ name: string; phases: unknown[] }>): PostSynthContext {
18
+ const entities = new Map<string, unknown>(ops.map((op) => [op.name, {
19
+ [DECLARABLE_MARKER]: true,
20
+ entityType: "Chant::Op",
21
+ lexicon: "chant",
22
+ kind: "resource",
23
+ props: { name: op.name, overview: "test", phases: op.phases },
24
+ attributes: {},
25
+ }]));
26
+ return {
27
+ outputs: new Map([["chant", ""]]),
28
+ entities: entities as Map<string, never>,
29
+ buildResult: { outputs: new Map([["chant", ""]]), entities: entities as Map<string, never>, warnings: [], errors: [], sourceFileCount: 1 },
30
+ };
31
+ }
32
+
33
+ const merged = (gate: Record<string, unknown> = {}) => ({
34
+ name: "upgrade",
35
+ phases: [{ name: "Wave 1", steps: [{ kind: "gate", gate: "approve-tier-1", plan: PLAN, aliases: [{ op: "upgrade-tier-1", gate: "approve" }], ...gate }] }],
36
+ });
37
+ const tier1 = { name: "upgrade-tier-1", phases: [{ name: "Gate", steps: [{ kind: "gate", gate: "approve" }] }] };
38
+
39
+ const dirs: string[] = [];
40
+ afterEach(async () => {
41
+ for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true });
42
+ });
43
+
44
+ function workspace(gates: Record<string, { class?: string }>): string {
45
+ const dir = mkdtempSync(join(tmpdir(), "chant-ops017-"));
46
+ dirs.push(dir);
47
+ mkdirSync(join(dir, ".git"));
48
+ writeFileSync(join(dir, "chant.workspace.json"), JSON.stringify({ name: "acme", schema: 1, members: [], identity: { gates } }));
49
+ return dir;
50
+ }
51
+
52
+ describe("OPS017", () => {
53
+ test("an alias of a gate that is gone passes", () => {
54
+ expect(ops017.check(ctxWith([merged()]))).toEqual([]);
55
+ });
56
+
57
+ test("an alias of a gate still in the build is an error", () => {
58
+ const [d] = ops017.check(ctxWith([merged(), tier1]));
59
+ expect(d).toMatchObject({ checkId: "OPS017", severity: "error", entity: "upgrade" });
60
+ expect(d!.message).toBe(
61
+ 'Op "upgrade", phase "Wave 1", gate:approve-tier-1: alias upgrade-tier-1/approve is still a gate; remove that gate, or this alias, so one approval doesn\'t answer two gates',
62
+ );
63
+ });
64
+
65
+ test("aliases on a gate with no plan are an error", () => {
66
+ expect(ops017.check(ctxWith([merged({ plan: undefined })]))[0]!.message).toContain("aliases need a plan");
67
+ });
68
+
69
+ test("a gate that drops its alias's identity.gates rule is an error", async () => {
70
+ const check = async (gates: Record<string, { class?: string }>) => {
71
+ const ctx = ctxWith([merged()]);
72
+ return ops017Check(await workspaceIdentityGates(workspace(gates), ctx.entities)).check(ctx);
73
+ };
74
+ expect((await check({ approve: {} }))[0]!.message).toContain("identity.gates names its alias approve");
75
+ expect((await check({ approve: { class: "human" }, "approve-tier-1": {} }))[0]!.message).toContain("must keep its alias's class");
76
+ expect(await check({ approve: {}, "approve-tier-1": {} })).toEqual([]);
77
+ });
78
+
79
+ test("the identity rules are read only when an Op has aliases, and only in a workspace", async () => {
80
+ const dir = workspace({ approve: {} });
81
+ expect(await workspaceIdentityGates(dir, ctxWith([tier1]).entities)).toBeUndefined();
82
+ expect(await workspaceIdentityGates(tmpdir(), ctxWith([merged()]).entities)).toBeUndefined();
83
+ expect(await workspaceIdentityGates(dir, ctxWith([merged()]).entities)).toEqual(new Map([["approve", null]]));
84
+ });
85
+ });