@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,89 @@
1
+ /**
2
+ * OPS017: a gate's aliases (#3949) can stand. An alias admits approvals of
3
+ * the plan a run planned, so the gate must bind a `plan`, and a gate that
4
+ * asks a decision point takes none. An alias must not name a gate that still
5
+ * exists in the build, or one approval would answer two gates, and two gates
6
+ * must not claim one alias. When the workspace's `identity.gates` names an
7
+ * alias, the new gate must be named too, with the alias's class, or
8
+ * approvals that needed a seal would count without one.
9
+ *
10
+ * The identity rules come from the caller, as SYS010's decide backends do:
11
+ * `chant build` and `chant lint` read them with
12
+ * {@link workspaceIdentityGates}, which loads workspace code only when an Op
13
+ * has aliases and the project sits in a workspace. Without them that part
14
+ * is skipped.
15
+ */
16
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "../../post-synth";
17
+ import type { OpConfig } from "../../../op";
18
+ import { gateStepsOf, validateGateAliases } from "../../../op/gate-alias";
19
+ import { findWorkspaceRoot } from "../../../project-root";
20
+ import { isOpEntity } from "./support";
21
+
22
+ /** The Op configs among a build's entities, each with its entity key and lexicon. */
23
+ function opsOf(entities: Iterable<[string, unknown]>): Array<{ entityKey: string; lexicon?: string; config: OpConfig }> {
24
+ const ops: Array<{ entityKey: string; lexicon?: string; config: OpConfig }> = [];
25
+ for (const [entityKey, entity] of entities) {
26
+ if (!isOpEntity(entity as never)) continue;
27
+ const rec = entity as Record<string, unknown>;
28
+ const props = ((entity as { props?: Record<string, unknown> }).props ?? {}) as unknown as OpConfig;
29
+ if (typeof props.name !== "string" || !Array.isArray(props.phases)) continue;
30
+ ops.push({ entityKey, ...(typeof rec.lexicon === "string" ? { lexicon: rec.lexicon } : {}), config: props });
31
+ }
32
+ return ops;
33
+ }
34
+
35
+ const hasAliases = (ops: ReadonlyArray<{ config: OpConfig }>): boolean =>
36
+ ops.some(({ config }) => gateStepsOf(config).some(({ step }) => step.aliases !== undefined));
37
+
38
+ /**
39
+ * The gates the workspace around `projectDir` names under `identity.gates`,
40
+ * each with its class (null for any signer), for OPS017. Undefined when no
41
+ * Op among `entities` has aliases, outside a workspace, or when the
42
+ * declaration can't be read, which `chant workspace check` reports. The
43
+ * workspace modules load only past the first two checks, so a level-0
44
+ * build loads none.
45
+ */
46
+ export async function workspaceIdentityGates(
47
+ projectDir: string,
48
+ entities: Iterable<[string, unknown]>,
49
+ ): Promise<Map<string, string | null> | undefined> {
50
+ if (!hasAliases(opsOf(entities))) return undefined;
51
+ const found = findWorkspaceRoot(projectDir);
52
+ if (!found) return undefined;
53
+ try {
54
+ const { readDeclaration } = await import("../../../workspace/declaration");
55
+ const { workingTree } = await import("../../../workspace/tree");
56
+ const gates = readDeclaration(workingTree(found.dir)).identity?.gates ?? {};
57
+ return new Map(Object.values(gates).map((g) => [g.gate, g.class]));
58
+ } catch {
59
+ return undefined;
60
+ }
61
+ }
62
+
63
+ /** OPS017, judging `identity.gates` by `identityGates` when given. */
64
+ export function ops017Check(identityGates?: ReadonlyMap<string, string | null>): PostSynthCheck {
65
+ return {
66
+ id: "OPS017",
67
+ description:
68
+ "A gate's aliases need a plan and no decision point, must not name a gate that still exists or another gate's alias, and keep any identity.gates rule an alias had",
69
+
70
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
71
+ const ops = opsOf(ctx.entities);
72
+ if (!hasAliases(ops)) return [];
73
+ const byName = new Map(ops.map((o) => [o.config.name, o]));
74
+ return validateGateAliases(ops.map((o) => o.config), identityGates).map((issue) => {
75
+ const op = byName.get(issue.opName);
76
+ return {
77
+ checkId: "OPS017",
78
+ severity: "error",
79
+ message: `Op "${issue.opName}", phase "${issue.phase}", gate:${issue.gate}: ${issue.message}`,
80
+ ...(op ? { entity: op.entityKey } : {}),
81
+ ...(op?.lexicon ? { lexicon: op.lexicon } : {}),
82
+ };
83
+ });
84
+ },
85
+ };
86
+ }
87
+
88
+ /** OPS017 without the workspace's identity rules. */
89
+ export const ops017: PostSynthCheck = ops017Check();
@@ -89,6 +89,26 @@ export interface ActivityContract<Args = unknown, Return = unknown> {
89
89
  * stack members internally) simply declares none.
90
90
  */
91
91
  entities?: string[];
92
+ /**
93
+ * What the activity returns that is secret (#3947): `true` for the whole
94
+ * return value, or the names of its top-level fields that are. A gate that
95
+ * resumes never stores a secret output in its checkpoint, so a gate whose
96
+ * later steps reference one doesn't resume, and OPS016 says so at build.
97
+ */
98
+ secret?: true | string[];
99
+ }
100
+
101
+ /**
102
+ * Whether a reference into `contract`'s return value at `path` reads a value
103
+ * the contract marks secret (#3947). A whole-value reference reads every
104
+ * field, so it is secret when any field is.
105
+ */
106
+ export function readsSecretOutput(contract: Pick<ActivityContract, "secret"> | undefined, path?: string): boolean {
107
+ const secret = contract?.secret;
108
+ if (secret === undefined) return false;
109
+ if (secret === true) return true;
110
+ if (!path) return secret.length > 0;
111
+ return secret.includes(path.split(".")[0]!);
92
112
  }
93
113
 
94
114
  /** Declare an activity contract. */
@@ -96,7 +116,7 @@ export function activityContract<ArgsSchema extends z.ZodTypeAny, ReturnSchema e
96
116
  name: string,
97
117
  args: ArgsSchema,
98
118
  returns?: ReturnSchema,
99
- opts?: { entities?: string[] },
119
+ opts?: { entities?: string[]; secret?: true | string[] },
100
120
  ): ActivityContract<z.infer<ArgsSchema>, ReturnSchema extends z.ZodTypeAny ? z.infer<ReturnSchema> : unknown> {
101
121
  return {
102
122
  [CONTRACT_BRAND]: true,
@@ -104,6 +124,7 @@ export function activityContract<ArgsSchema extends z.ZodTypeAny, ReturnSchema e
104
124
  args,
105
125
  ...(returns ? { returns } : {}),
106
126
  ...(opts?.entities && opts.entities.length > 0 ? { entities: opts.entities } : {}),
127
+ ...(opts?.secret === true || (Array.isArray(opts?.secret) && opts.secret.length > 0) ? { secret: opts!.secret } : {}),
107
128
  } as ActivityContract<z.infer<ArgsSchema>, ReturnSchema extends z.ZodTypeAny ? z.infer<ReturnSchema> : unknown>;
108
129
  }
109
130
 
@@ -5,6 +5,7 @@ import { isEffectReceipt, type EffectReceiptDeclaration } from "../effect-receip
5
5
  import { receiptCheckInput } from "./receipt-store";
6
6
  import { gateApprovalProblems } from "./gate-approval";
7
7
  import { gatePointProblems } from "./gate-point";
8
+ import { gateAliasProblems } from "./gate-alias";
8
9
  import { makeOutProxy, stepOutput, type StepOutputRef, type WithStepRefs } from "./step-output-ref";
9
10
  import type { ChantBuildArgs } from "./activities/build";
10
11
  import type { ShellCmdArgs } from "./activities/shell";
@@ -135,10 +136,23 @@ export function activity(
135
136
  * The point's deciders and quorum decide, `chant workspace points answer`
136
137
  * answers an open question, and the run passes on the answer. See
137
138
  * {@link GateStep.point}.
139
+ *
140
+ * Pass `aliases` to keep the approvals recorded under the gate's old names
141
+ * after an Op is restructured (#3949): `gate("approve-tier-1", { plan:
142
+ * plan.out.planDigest, aliases: [{ op: "upgrade-tier-1", gate: "approve" }] })`.
143
+ * A gate with aliases must bind a plan. See {@link GateStep.aliases}.
138
144
  */
139
145
  export function gate(
140
146
  name: string,
141
- opts?: { timeout?: string; description?: string; plan?: GateStep["plan"]; approval?: GateStep["approval"]; point?: GateStep["point"] },
147
+ opts?: {
148
+ timeout?: string;
149
+ description?: string;
150
+ plan?: GateStep["plan"];
151
+ approval?: GateStep["approval"];
152
+ point?: GateStep["point"];
153
+ resume?: boolean;
154
+ aliases?: GateStep["aliases"];
155
+ },
142
156
  ): GateStep {
143
157
  if (opts?.approval !== undefined) {
144
158
  const problems = gateApprovalProblems(opts.approval);
@@ -148,6 +162,10 @@ export function gate(
148
162
  const problems = gatePointProblems(opts.point, opts.approval !== undefined);
149
163
  if (problems.length > 0) throw new Error(`gate(${JSON.stringify(name)}): ${problems[0]}`);
150
164
  }
165
+ if (opts?.aliases !== undefined) {
166
+ const problems = gateAliasProblems(opts.aliases, { plan: opts.plan, point: opts.point });
167
+ if (problems.length > 0) throw new Error(`gate(${JSON.stringify(name)}): ${problems[0]}`);
168
+ }
151
169
  return {
152
170
  kind: "gate",
153
171
  gate: name,
@@ -156,6 +174,8 @@ export function gate(
156
174
  ...(opts?.plan !== undefined ? { plan: opts.plan } : {}),
157
175
  ...(opts?.approval !== undefined ? { approval: opts.approval } : {}),
158
176
  ...(opts?.point !== undefined ? { point: opts.point } : {}),
177
+ ...(opts?.resume ? { resume: true } : {}),
178
+ ...(opts?.aliases !== undefined && opts.aliases.length > 0 ? { aliases: opts.aliases.map((a) => ({ ...(a.op !== undefined ? { op: a.op } : {}), gate: a.gate })) } : {}),
159
179
  };
160
180
  }
161
181
 
@@ -0,0 +1,78 @@
1
+ /**
2
+ * #3949 against a real `chant/lifecycle` branch: an approval recorded under
3
+ * a per-tier Op's gate passes the merged Op's gate that lists it as an
4
+ * alias. The run reads the old Op's ledger file, writes the carried line to
5
+ * its own, and the next run passes on that line alone.
6
+ */
7
+
8
+ import { afterAll, beforeAll, describe, expect, test } from "vitest";
9
+ import { cleanScratch, commitAll, git, repo } from "../workspace/__fixtures__/contract-repo";
10
+ import { appendGateResolution, appendPendingGate, readGateLedger } from "../lifecycle/gate-ledger";
11
+ import { runOpLocally } from "./local-executor";
12
+ import { gitGateLedgerPort } from "./gate";
13
+ import { stepOutput } from "./step-output-ref";
14
+ import type { ActivityFn } from "./activity-registry";
15
+ import type { OpConfig } from "./types";
16
+
17
+ const PLAN = `jcs1-sha256:${"a".repeat(64)}`;
18
+
19
+ let root: string;
20
+ beforeAll(() => {
21
+ root = repo({ "chant.config.json": "{}\n" }, false);
22
+ git(root, "config", "user.name", "Gate Test");
23
+ git(root, "config", "user.email", "gate@example.com");
24
+ commitAll(root, "c0");
25
+ });
26
+ afterAll(cleanScratch);
27
+
28
+ const applied: string[] = [];
29
+ const ACTIVITIES = new Map<string, ActivityFn>([
30
+ ["plan", async () => ({ planDigest: PLAN })],
31
+ ["apply", async (args) => (applied.push(String((args as { tier: string }).tier)), { ok: true })],
32
+ ]);
33
+
34
+ const merged: OpConfig = {
35
+ name: "upgrade",
36
+ overview: "upgrade every tier, one wave each",
37
+ phases: [
38
+ { name: "Plan", steps: [{ kind: "activity", id: "plan", fn: "plan" }] },
39
+ {
40
+ name: "Wave 1",
41
+ steps: [{ kind: "gate", gate: "approve-tier-1", plan: stepOutput("plan", "planDigest"), aliases: [{ op: "upgrade-tier-1", gate: "approve" }] }],
42
+ },
43
+ { name: "Apply 1", steps: [{ kind: "activity", fn: "apply", args: { tier: "1" } }] },
44
+ ],
45
+ };
46
+
47
+ describe("a gate alias on a real ledger", () => {
48
+ test("the merged Op passes on the old Op's approval, and keeps passing on the carried line", async () => {
49
+ const now = Date.now();
50
+ const iso = (s: number) => new Date(now + s * 1000).toISOString();
51
+ await appendPendingGate(
52
+ { op: "upgrade-tier-1", gate: "approve", timestamp: iso(-60), expiresAt: iso(3600), planDigest: PLAN, runId: "old-run" },
53
+ { cwd: root },
54
+ );
55
+ await appendGateResolution({ op: "upgrade-tier-1", gate: "approve", resolvedBy: "alice", timestamp: iso(-30), planDigest: PLAN }, { cwd: root });
56
+
57
+ const port = gitGateLedgerPort({ cwd: root });
58
+ const first = await runOpLocally(merged, ACTIVITIES, {}, undefined, { cwd: root, gates: port, runId: "run-1" });
59
+ expect(first.status).toBe("ok");
60
+ expect(first.records.find((r) => r.fn === "gate:approve-tier-1")?.approval).toMatchObject({ resolvedBy: "alice", via: "alias" });
61
+
62
+ const ledger = await readGateLedger("upgrade", { cwd: root });
63
+ expect(ledger.resolutions).toHaveLength(1);
64
+ expect(ledger.resolutions[0]).toMatchObject({
65
+ op: "upgrade",
66
+ gate: "approve-tier-1",
67
+ resolvedBy: "alice",
68
+ planDigest: PLAN,
69
+ carriedFrom: { op: "upgrade-tier-1", gate: "approve", timestamp: iso(-30) },
70
+ });
71
+
72
+ const second = await runOpLocally(merged, ACTIVITIES, {}, undefined, { cwd: root, gates: port, runId: "run-2" });
73
+ expect(second.status).toBe("ok");
74
+ expect(second.records.find((r) => r.fn === "gate:approve-tier-1")?.approval?.via).toBeUndefined();
75
+ expect((await readGateLedger("upgrade", { cwd: root })).resolutions).toHaveLength(1);
76
+ expect(applied).toEqual(["1", "1"]);
77
+ });
78
+ });
@@ -0,0 +1,350 @@
1
+ /**
2
+ * #3949: a gate can carry aliases, so an Op can be restructured without
3
+ * orphaning the approvals on the ledger. loomster's case: three per-tier
4
+ * upgrade Ops, each with an `approve` gate, merged into one Op whose gates
5
+ * are its waves.
6
+ */
7
+
8
+ import { describe, expect, test } from "vitest";
9
+ import { approvalsAnswering, evaluateGate, memoryGateLedgerPort, type GateApprovalRule, type GateCheckInput } from "./gate";
10
+ import { aliasingGate, gateAliasProblems, resolveGateAliases, validateGateAliases } from "./gate-alias";
11
+ import { gate } from "./builders";
12
+ import { decideGateResume } from "./gate-checkpoint";
13
+ import { runOpLocally } from "./local-executor";
14
+ import { renderHuman } from "./local-output";
15
+ import { stepOutput } from "./step-output-ref";
16
+ import { parseGateLedger, type GateResolutionRecord, type PendingGateRecord } from "../lifecycle/gate-ledger";
17
+ import { gateSealPayload, gateSealVersion } from "../workspace/trust/gate-seal";
18
+ import { deriveGateAudit } from "../lifecycle/gate-audit";
19
+ import type { AppliedGateRecord } from "../lifecycle/gate-applied";
20
+ import type { ActivityFn } from "./activity-registry";
21
+ import type { OpConfig } from "./types";
22
+
23
+ const PLAN = `jcs1-sha256:${"a".repeat(64)}`;
24
+ const MOVED = `jcs1-sha256:${"b".repeat(64)}`;
25
+ const CHECKPOINT = `jcs1-sha256:${"c".repeat(64)}`;
26
+ const COMMIT = "1".repeat(40);
27
+ const at = (minute: number) => `2026-10-12T12:${String(minute).padStart(2, "0")}:00.000Z`;
28
+ const NOW = at(30);
29
+
30
+ const pending = (op: string, gateName: string, over: Partial<PendingGateRecord> = {}): PendingGateRecord => ({
31
+ version: 1,
32
+ kind: "pending",
33
+ op,
34
+ gate: gateName,
35
+ timestamp: at(0),
36
+ expiresAt: "2099-01-01T00:00:00.000Z",
37
+ planDigest: PLAN,
38
+ runId: "old-run",
39
+ ...over,
40
+ });
41
+
42
+ const approval = (op: string, gateName: string, resolvedBy: string, over: Partial<GateResolutionRecord> = {}): GateResolutionRecord => ({
43
+ version: 1,
44
+ op,
45
+ gate: gateName,
46
+ resolvedBy,
47
+ timestamp: at(5),
48
+ planDigest: PLAN,
49
+ ...over,
50
+ });
51
+
52
+ /** The in-memory port, with each Op's ledger read on its own as the git port reads `_gates/<op>.jsonl`. */
53
+ function ports(seed: { resolutions?: GateResolutionRecord[]; pending?: PendingGateRecord[]; applied?: AppliedGateRecord[] } = {}) {
54
+ const base = memoryGateLedgerPort(seed);
55
+ return {
56
+ ...base,
57
+ async read(op: string) {
58
+ const all = await base.read(op);
59
+ return { resolutions: all.resolutions.filter((r) => r.op === op), pending: all.pending.filter((p) => p.op === op) };
60
+ },
61
+ };
62
+ }
63
+
64
+ const TIER_1 = { op: "upgrade-tier-1", gate: "approve" };
65
+ const input = (over: Partial<GateCheckInput> = {}): GateCheckInput => ({
66
+ op: "upgrade",
67
+ gate: "approve-tier-1",
68
+ planDigest: PLAN,
69
+ now: NOW,
70
+ aliases: [TIER_1],
71
+ ...over,
72
+ });
73
+
74
+ describe("declaring aliases", () => {
75
+ test("the builder keeps them and fills nothing in", () => {
76
+ const step = gate("approve-tier-1", { plan: "p", aliases: [{ op: "upgrade-tier-1", gate: "approve" }, { gate: "old" }] });
77
+ expect(step.aliases).toEqual([{ op: "upgrade-tier-1", gate: "approve" }, { gate: "old" }]);
78
+ expect(resolveGateAliases(step, "upgrade")).toEqual([TIER_1, { op: "upgrade", gate: "old" }]);
79
+ });
80
+
81
+ test("a gate with no plan, or one that asks a decision point, takes none", () => {
82
+ expect(() => gate("approve", { aliases: [{ gate: "old" }] })).toThrow(/aliases need a plan/);
83
+ expect(gateAliasProblems([{ gate: "old" }], { plan: "p", point: "prod" })).toContain("a gate that asks a decision point can't take aliases");
84
+ expect(gateAliasProblems([{ gate: "old" }, { gate: "old" }], { plan: "p" })).toContain("alias old is listed twice");
85
+ });
86
+ });
87
+
88
+ describe("OPS017's check", () => {
89
+ const merged: OpConfig = {
90
+ name: "upgrade",
91
+ overview: "upgrade every tier",
92
+ phases: [{ name: "Wave 1", steps: [{ kind: "gate", gate: "approve-tier-1", plan: PLAN, aliases: [TIER_1] }] }],
93
+ };
94
+ const tier1: OpConfig = { name: "upgrade-tier-1", overview: "tier 1", phases: [{ name: "Gate", steps: [{ kind: "gate", gate: "approve" }] }] };
95
+
96
+ test("refuses an alias that is still a gate", () => {
97
+ expect(validateGateAliases([merged])).toEqual([]);
98
+ const [issue] = validateGateAliases([merged, tier1]);
99
+ expect(issue).toMatchObject({ opName: "upgrade", gate: "approve-tier-1" });
100
+ expect(issue!.message).toContain("alias upgrade-tier-1/approve is still a gate");
101
+ });
102
+
103
+ test("refuses one alias on two gates", () => {
104
+ const twice: OpConfig = {
105
+ ...merged,
106
+ phases: [...merged.phases, { name: "Wave 2", steps: [{ kind: "gate", gate: "approve-tier-2", plan: PLAN, aliases: [TIER_1] }] }],
107
+ };
108
+ expect(validateGateAliases([twice]).map((i) => i.message)).toEqual([expect.stringContaining("is also an alias of upgrade/approve-tier-1")]);
109
+ });
110
+
111
+ test("refuses a gate that drops an identity.gates rule its alias had", () => {
112
+ expect(validateGateAliases([merged], new Map([["approve", null]]))[0]!.message).toContain("identity.gates names its alias approve");
113
+ expect(validateGateAliases([merged], new Map([["approve", "human"], ["approve-tier-1", null]]))[0]!.message).toContain("must keep its alias's class");
114
+ expect(validateGateAliases([merged], new Map([["approve", "human"], ["approve-tier-1", "human"]]))).toEqual([]);
115
+ expect(validateGateAliases([merged], new Map([["approve", null], ["approve-tier-1", "human"]]))).toEqual([]);
116
+ });
117
+
118
+ test("names the gate that aliases an old name, for chant approve", () => {
119
+ expect(aliasingGate([merged, undefined], "upgrade-tier-1", "approve")).toEqual({ op: "upgrade", gate: "approve-tier-1" });
120
+ expect(aliasingGate([merged], "upgrade-tier-2", "approve")).toBeUndefined();
121
+ });
122
+ });
123
+
124
+ describe("a gate passes on an approval recorded under its alias", () => {
125
+ test("the approval is written again under the new name, citing the line it came from", async () => {
126
+ const port = ports({ pending: [pending("upgrade-tier-1", "approve")], resolutions: [approval("upgrade-tier-1", "approve", "alice", { url: "https://example.test/pr/1" })] });
127
+ const check = await evaluateGate(port, input());
128
+ expect(check).toMatchObject({ satisfied: true, via: "alias" });
129
+ expect(port.resolved).toHaveLength(1);
130
+ expect(port.resolved[0]).toMatchObject({
131
+ op: "upgrade",
132
+ gate: "approve-tier-1",
133
+ resolvedBy: "alice",
134
+ timestamp: NOW,
135
+ planDigest: PLAN,
136
+ url: "https://example.test/pr/1",
137
+ approver: { kind: "human" },
138
+ carriedFrom: { op: "upgrade-tier-1", gate: "approve", timestamp: at(5) },
139
+ });
140
+ expect(port.appended).toHaveLength(0);
141
+
142
+ // The next run counts the carried line as its own and carries nothing more.
143
+ const again = await evaluateGate(port, input({ now: at(40) }));
144
+ expect(again).toMatchObject({ satisfied: true, resolution: { carriedFrom: { op: "upgrade-tier-1" } } });
145
+ expect(again.satisfied && again.via).toBeUndefined();
146
+ expect(port.resolved).toHaveLength(1);
147
+ });
148
+
149
+ test("the alias's op defaults to the gate's own, for a gate renamed in place", async () => {
150
+ const port = ports({ pending: [pending("upgrade", "approve")], resolutions: [approval("upgrade", "approve", "alice")] });
151
+ expect(await evaluateGate(port, input({ aliases: resolveGateAliases({ aliases: [{ gate: "approve" }] }, "upgrade") }))).toMatchObject({ satisfied: true, via: "alias" });
152
+ });
153
+
154
+ const refusedBecause = async (name: string, seed: Parameters<typeof ports>[0], over: Partial<GateCheckInput> = {}) => {
155
+ const port = ports(seed);
156
+ const check = await evaluateGate(port, input(over));
157
+ expect(check.satisfied, name).toBe(false);
158
+ expect(port.resolved, name).toHaveLength(0);
159
+ };
160
+
161
+ test("but not once the old pending fact has expired", () =>
162
+ refusedBecause("expired", { pending: [pending("upgrade-tier-1", "approve", { expiresAt: at(20) })], resolutions: [approval("upgrade-tier-1", "approve", "alice")] }));
163
+
164
+ test("nor for another plan", async () => {
165
+ await refusedBecause("moved", { pending: [pending("upgrade-tier-1", "approve")], resolutions: [approval("upgrade-tier-1", "approve", "alice")] }, { planDigest: MOVED });
166
+ await refusedBecause("no plan", { pending: [pending("upgrade-tier-1", "approve")], resolutions: [approval("upgrade-tier-1", "approve", "alice")] }, { planDigest: undefined });
167
+ });
168
+
169
+ test("nor from an approval older than the old fact, or for another environment", async () => {
170
+ await refusedBecause("stale", { pending: [pending("upgrade-tier-1", "approve", { timestamp: at(10) })], resolutions: [approval("upgrade-tier-1", "approve", "alice")] });
171
+ await refusedBecause("env", { pending: [pending("upgrade-tier-1", "approve", { environment: "prod" })], resolutions: [approval("upgrade-tier-1", "approve", "alice", { environment: "prod" })] }, { environment: "staging" });
172
+ });
173
+
174
+ test("nor from an agent, or a policy permit", async () => {
175
+ await refusedBecause("agent", { pending: [pending("upgrade-tier-1", "approve")], resolutions: [approval("upgrade-tier-1", "approve", "bot", { approver: { kind: "agent" } })] });
176
+ await refusedBecause("mcp", { pending: [pending("upgrade-tier-1", "approve")], resolutions: [approval("upgrade-tier-1", "approve", "bot", { origin: "mcp" })] });
177
+ });
178
+
179
+ test("nor from an approval spent under the old name", () =>
180
+ refusedBecause("spent", {
181
+ pending: [pending("upgrade-tier-1", "approve")],
182
+ resolutions: [approval("upgrade-tier-1", "approve", "alice")],
183
+ applied: [{ version: 1, kind: "applied", op: "upgrade-tier-1", gate: "approve", planDigest: PLAN, approvedAt: at(5), approvedBy: "alice", timestamp: at(6) }],
184
+ }));
185
+
186
+ test("nor again, once the carried line was spent under the new name", async () => {
187
+ const carried = approval("upgrade", "approve-tier-1", "alice", { timestamp: at(20), carriedFrom: { op: "upgrade-tier-1", gate: "approve", timestamp: at(5) } });
188
+ await refusedBecause(
189
+ "spent carried",
190
+ { pending: [pending("upgrade-tier-1", "approve")], resolutions: [approval("upgrade-tier-1", "approve", "alice"), carried] },
191
+ { spent: [{ version: 1, kind: "applied", op: "upgrade", gate: "approve-tier-1", planDigest: PLAN, approvedAt: at(20), approvedBy: "alice", timestamp: at(21) }] },
192
+ );
193
+ });
194
+
195
+ test("a carried line stops counting once the gate no longer names its alias", async () => {
196
+ const carried = approval("upgrade", "approve-tier-1", "alice", { timestamp: at(20), carriedFrom: { op: "upgrade-tier-1", gate: "approve", timestamp: at(5) } });
197
+ const port = ports({ resolutions: [carried] });
198
+ expect((await evaluateGate(port, input())).satisfied).toBe(true);
199
+ expect((await evaluateGate(port, input({ aliases: [] }))).satisfied).toBe(false);
200
+ });
201
+
202
+ test("the new gate's identity rule judges the approval, and names the one it refuses", async () => {
203
+ const rule: GateApprovalRule = {
204
+ requirement: { gate: "approve-tier-1", class: null },
205
+ refuses: (r) => (r.seal ? null : `the approval by ${r.resolvedBy} is not signed`),
206
+ };
207
+ const port = { ...ports({ pending: [pending("upgrade-tier-1", "approve")], resolutions: [approval("upgrade-tier-1", "approve", "alice")] }), approvalRule: async () => rule };
208
+ const check = await evaluateGate(port, input());
209
+ expect(check.satisfied).toBe(false);
210
+ expect(!check.satisfied && check.refused?.[0]?.reason).toBe("carried from upgrade-tier-1/approve: the approval by alice is not signed");
211
+ });
212
+ });
213
+
214
+ describe("old and new approvals add up toward one quorum", () => {
215
+ const quorum = { approval: { quorum: { count: 2 }, mode: "log-only" as const } };
216
+
217
+ test("an approver under each name passes it, and only the old one is carried", async () => {
218
+ const port = ports({
219
+ pending: [pending("upgrade-tier-1", "approve"), pending("upgrade", "approve-tier-1", { timestamp: at(10), approval: quorum.approval })],
220
+ resolutions: [approval("upgrade-tier-1", "approve", "alice"), approval("upgrade", "approve-tier-1", "bob", { timestamp: at(12) })],
221
+ });
222
+ const check = await evaluateGate(port, input(quorum));
223
+ expect(check).toMatchObject({ satisfied: true, via: "alias" });
224
+ expect(check.satisfied && check.approvals?.map((r) => r.resolvedBy).sort()).toEqual(["alice", "bob"]);
225
+ expect(port.resolved.map((r) => r.resolvedBy)).toEqual(["alice"]);
226
+ });
227
+
228
+ test("one person under both names counts once, and the progress shows them", async () => {
229
+ const port = ports({
230
+ pending: [pending("upgrade-tier-1", "approve"), pending("upgrade", "approve-tier-1", { timestamp: at(10), approval: quorum.approval })],
231
+ resolutions: [approval("upgrade-tier-1", "approve", "alice"), approval("upgrade", "approve-tier-1", "alice", { timestamp: at(12) })],
232
+ });
233
+ const check = await evaluateGate(port, input(quorum));
234
+ expect(check.satisfied).toBe(false);
235
+ expect(!check.satisfied && check.quorum).toEqual({ approvers: ["alice"], need: 2 });
236
+ expect(port.resolved).toHaveLength(0);
237
+ });
238
+
239
+ test("an old approval waiting on its own quorum still counts toward the new one", async () => {
240
+ const port = ports({
241
+ pending: [pending("upgrade-tier-1", "approve", { approval: quorum.approval }), pending("upgrade-tier-2", "approve")],
242
+ resolutions: [approval("upgrade-tier-1", "approve", "alice"), approval("upgrade-tier-2", "approve", "carol")],
243
+ });
244
+ const check = await evaluateGate(port, input({ ...quorum, aliases: [TIER_1, { op: "upgrade-tier-2", gate: "approve" }] }));
245
+ expect(check).toMatchObject({ satisfied: true, via: "alias" });
246
+ expect(port.resolved.map((r) => `${r.resolvedBy}<-${r.carriedFrom?.op}`)).toEqual(["alice<-upgrade-tier-1", "carol<-upgrade-tier-2"]);
247
+ });
248
+ });
249
+
250
+ describe("a carried approval and checkpoints", () => {
251
+ const sealed = approval("upgrade-tier-1", "approve", "alice", {
252
+ relayedBy: "follower",
253
+ checkpointDigest: CHECKPOINT,
254
+ seal: { signer: "alice", key: "SHA256:x", signature: "sig", version: 2 },
255
+ });
256
+
257
+ test("its seal is checked against the line it came from, checkpoint digest included", async () => {
258
+ const port = ports({ pending: [pending("upgrade-tier-1", "approve", { checkpointDigest: CHECKPOINT })], resolutions: [sealed] });
259
+ await evaluateGate(port, input());
260
+ const carried = port.resolved[0]!;
261
+ expect(carried).not.toHaveProperty("checkpointDigest");
262
+ expect(carried.carriedFrom).toEqual({ op: "upgrade-tier-1", gate: "approve", timestamp: at(5), checkpointDigest: CHECKPOINT });
263
+ expect(carried.seal).toEqual(sealed.seal);
264
+ expect(gateSealPayload(carried).toString()).toBe(gateSealPayload(sealed).toString());
265
+ expect(gateSealVersion(carried)).toBe(2);
266
+
267
+ // The line reads back whole, and a broken citation is a malformed line.
268
+ const text = [JSON.stringify(carried), JSON.stringify({ ...carried, carriedFrom: { op: "x" } })].join("\n");
269
+ expect(parseGateLedger(text)).toMatchObject({ resolutions: [{ carriedFrom: carried.carriedFrom }], malformed: 1 });
270
+ });
271
+
272
+ test("it never lets a run resume from the new gate's checkpoint", async () => {
273
+ const config: OpConfig = {
274
+ name: "upgrade",
275
+ overview: "upgrade every tier",
276
+ phases: [{ name: "Gate", steps: [{ kind: "gate", gate: "approve-tier-1", plan: PLAN, resume: true, aliases: [TIER_1] }] }],
277
+ };
278
+ const carried = approval("upgrade", "approve-tier-1", "alice", { timestamp: at(20), checkpointDigest: CHECKPOINT, carriedFrom: { op: "upgrade-tier-1", gate: "approve", timestamp: at(5) } });
279
+ const port = ports({ pending: [pending("upgrade", "approve-tier-1", { timestamp: at(10), checkpointDigest: CHECKPOINT, runId: "run-1" })], resolutions: [carried] });
280
+ expect(await decideGateResume(port, config, { now: NOW, commit: COMMIT })).toEqual({ kind: "none" });
281
+ });
282
+
283
+ test("a merged Op passes its resume gate on the old approval, starting at the first phase", async () => {
284
+ const calls: string[] = [];
285
+ const activities = new Map<string, ActivityFn>([
286
+ ["snapshot", async () => (calls.push("snapshot"), { id: "snap-1" })],
287
+ ["plan", async () => (calls.push("plan"), { planDigest: PLAN })],
288
+ ["apply", async () => (calls.push("apply"), { ok: true })],
289
+ ]);
290
+ const config: OpConfig = {
291
+ name: "upgrade",
292
+ overview: "upgrade every tier",
293
+ phases: [
294
+ { name: "Prepare", steps: [{ kind: "activity", id: "snapshot", fn: "snapshot" }, { kind: "activity", id: "plan", fn: "plan" }] },
295
+ { name: "Gate", steps: [{ kind: "gate", gate: "approve-tier-1", plan: stepOutput("plan", "planDigest"), resume: true, aliases: [TIER_1] }] },
296
+ { name: "Apply", steps: [{ kind: "activity", fn: "apply", args: { snapshot: stepOutput("snapshot", "id") } }] },
297
+ ],
298
+ };
299
+ const port = ports({ pending: [pending("upgrade-tier-1", "approve", { checkpointDigest: CHECKPOINT })], resolutions: [sealed] });
300
+ const result = await runOpLocally(config, activities, {}, undefined, { gates: port, now: NOW, runId: "run-1", commit: COMMIT });
301
+ expect(result.status).toBe("ok");
302
+ expect(result.resumed).toBeUndefined();
303
+ expect(calls).toEqual(["snapshot", "plan", "apply"]);
304
+ const gateRecord = result.records.find((r) => r.fn === "gate:approve-tier-1")!;
305
+ expect(gateRecord.approval).toMatchObject({
306
+ resolvedBy: "alice",
307
+ via: "alias",
308
+ carriedFrom: [{ op: "upgrade-tier-1", gate: "approve", resolvedBy: "alice", timestamp: at(5) }],
309
+ });
310
+ expect(port.checkpoints.size).toBe(0);
311
+ const lines: string[] = [];
312
+ renderHuman(result, (l) => lines.push(l));
313
+ expect(lines).toContain(` [carried] alice's approval of upgrade-tier-1/approve at ${at(5)}`);
314
+ });
315
+ });
316
+
317
+ describe("the rule an alias reads approvals by", () => {
318
+ test("approvalsAnswering takes the standing fact's gate, plan and environment, since the fact", () => {
319
+ const standing = pending("upgrade-tier-1", "approve", { timestamp: at(3) });
320
+ const rs = [
321
+ approval("upgrade-tier-1", "approve", "a"),
322
+ approval("upgrade-tier-1", "approve", "b", { timestamp: at(1) }),
323
+ approval("upgrade-tier-1", "approve", "c", { planDigest: MOVED }),
324
+ approval("upgrade-tier-1", "approve", "d", { environment: "prod" }),
325
+ approval("upgrade-tier-1", "other", "e"),
326
+ ];
327
+ expect(approvalsAnswering(rs, standing).map((r) => r.resolvedBy)).toEqual(["a"]);
328
+ });
329
+ });
330
+
331
+ describe("the audit of a carried approval", () => {
332
+ const carried = approval("upgrade", "approve-tier-1", "alice", { timestamp: at(20), carriedFrom: { op: "upgrade-tier-1", gate: "approve", timestamp: at(5) } });
333
+ const lines = (...records: unknown[]) => records.map((record) => ({ record }));
334
+
335
+ test("names where it came from, and finds its request under the old name", () => {
336
+ const { entries, gaps } = deriveGateAudit({
337
+ ledgers: [
338
+ { op: "upgrade-tier-1", lines: lines(pending("upgrade-tier-1", "approve"), approval("upgrade-tier-1", "approve", "alice")) },
339
+ { op: "upgrade", lines: lines(carried) },
340
+ ],
341
+ });
342
+ expect(entries.find((e) => e.op === "upgrade")).toMatchObject({ kind: "approved", carriedFrom: { op: "upgrade-tier-1", gate: "approve", at: at(5) } });
343
+ expect(gaps).toEqual([]);
344
+ });
345
+
346
+ test("says when the cited line is missing", () => {
347
+ const { gaps } = deriveGateAudit({ ledgers: [{ op: "upgrade-tier-1", lines: lines(pending("upgrade-tier-1", "approve")) }, { op: "upgrade", lines: lines(carried) }] });
348
+ expect(gaps.map((g) => g.kind)).toEqual(["carried-without-source"]);
349
+ });
350
+ });