@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.
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/commands/build.js +4 -2
- package/dist/cli/commands/lint.d.ts.map +1 -1
- package/dist/cli/commands/lint.js +4 -2
- package/dist/cli/handlers/approve.d.ts.map +1 -1
- package/dist/cli/handlers/approve.js +39 -4
- package/dist/gate-decision.d.ts +1 -1
- package/dist/gate-decision.d.ts.map +1 -1
- package/dist/gate-decision.js +1 -1
- package/dist/lifecycle/gate-audit.d.ts +7 -1
- package/dist/lifecycle/gate-audit.d.ts.map +1 -1
- package/dist/lifecycle/gate-audit.js +40 -3
- package/dist/lifecycle/gate-checkpoint.d.ts +78 -0
- package/dist/lifecycle/gate-checkpoint.d.ts.map +1 -0
- package/dist/lifecycle/gate-checkpoint.js +103 -0
- package/dist/lifecycle/gate-ledger.d.ts +41 -0
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.js +18 -0
- package/dist/lifecycle/run-ledger.d.ts +2 -0
- package/dist/lifecycle/run-ledger.d.ts.map +1 -1
- package/dist/lifecycle/run-ledger.js +2 -0
- package/dist/lint/rules/op/index.d.ts +6 -2
- package/dist/lint/rules/op/index.d.ts.map +1 -1
- package/dist/lint/rules/op/index.js +7 -3
- package/dist/lint/rules/op/ops016-gate-resume.d.ts +14 -0
- package/dist/lint/rules/op/ops016-gate-resume.d.ts.map +1 -0
- package/dist/lint/rules/op/ops016-gate-resume.js +31 -0
- package/dist/lint/rules/op/ops017-gate-alias.d.ts +30 -0
- package/dist/lint/rules/op/ops017-gate-alias.d.ts.map +1 -0
- package/dist/lint/rules/op/ops017-gate-alias.js +67 -0
- package/dist/op/activity-contract.d.ts +14 -0
- package/dist/op/activity-contract.d.ts.map +1 -1
- package/dist/op/activity-contract.js +16 -0
- package/dist/op/builders.d.ts +7 -0
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/builders.js +13 -0
- package/dist/op/gate-alias.d.ts +57 -0
- package/dist/op/gate-alias.d.ts.map +1 -0
- package/dist/op/gate-alias.js +127 -0
- package/dist/op/gate-checkpoint.d.ts +123 -0
- package/dist/op/gate-checkpoint.d.ts.map +1 -0
- package/dist/op/gate-checkpoint.js +280 -0
- package/dist/op/gate.d.ts +54 -2
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/gate.js +142 -1
- package/dist/op/index.d.ts +4 -1
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/index.js +2 -1
- package/dist/op/local-executor.d.ts +57 -2
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/local-executor.js +136 -6
- package/dist/op/local-output.d.ts.map +1 -1
- package/dist/op/local-output.js +20 -2
- package/dist/op/op-ir.d.ts +4 -0
- package/dist/op/op-ir.d.ts.map +1 -1
- package/dist/op/op-ir.js +4 -0
- package/dist/op/runtime.d.ts +15 -0
- package/dist/op/runtime.d.ts.map +1 -1
- package/dist/op/runtimes/local.d.ts.map +1 -1
- package/dist/op/runtimes/local.js +5 -0
- package/dist/op/types.d.ts +31 -0
- package/dist/op/types.d.ts.map +1 -1
- package/dist/wave-runtime.mjs +214 -212
- package/dist/workspace/trust/gate-seal.d.ts +32 -1
- package/dist/workspace/trust/gate-seal.d.ts.map +1 -1
- package/dist/workspace/trust/gate-seal.js +41 -2
- package/dist/workspace/trust/seal.d.ts +1 -1
- package/dist/workspace/trust/seal.d.ts.map +1 -1
- package/dist/workspace/trust/seal.js +1 -1
- package/package.json +1 -1
- package/src/cli/commands/build.ts +9 -2
- package/src/cli/commands/lint.ts +4 -2
- package/src/cli/handlers/approve.ts +46 -4
- package/src/cli/handlers/operator.test.ts +91 -0
- package/src/gate-decision.ts +2 -0
- package/src/lifecycle/gate-audit.ts +41 -4
- package/src/lifecycle/gate-checkpoint.test.ts +128 -0
- package/src/lifecycle/gate-checkpoint.ts +133 -0
- package/src/lifecycle/gate-ledger.ts +61 -0
- package/src/lifecycle/run-ledger.ts +4 -0
- package/src/lint/rules/op/index.ts +9 -3
- package/src/lint/rules/op/ops016-gate-resume.test.ts +53 -0
- package/src/lint/rules/op/ops016-gate-resume.ts +48 -0
- package/src/lint/rules/op/ops017-gate-alias.test.ts +85 -0
- package/src/lint/rules/op/ops017-gate-alias.ts +89 -0
- package/src/op/activity-contract.ts +22 -1
- package/src/op/builders.ts +21 -1
- package/src/op/gate-alias.e2e.test.ts +78 -0
- package/src/op/gate-alias.test.ts +350 -0
- package/src/op/gate-alias.ts +137 -0
- package/src/op/gate-checkpoint.test.ts +284 -0
- package/src/op/gate-checkpoint.ts +322 -0
- package/src/op/gate.ts +198 -4
- package/src/op/index.ts +4 -1
- package/src/op/local-executor.ts +180 -7
- package/src/op/local-output.ts +18 -2
- package/src/op/op-ir.test.ts +25 -0
- package/src/op/op-ir.ts +8 -0
- package/src/op/runtime.ts +8 -0
- package/src/op/runtimes/local.ts +5 -0
- package/src/op/types.ts +32 -0
- package/src/workspace/trust/gate-seal.test.ts +29 -2
- package/src/workspace/trust/gate-seal.ts +46 -2
- package/src/workspace/trust/seal.ts +1 -1
|
@@ -33,8 +33,30 @@ export interface SealedGateApproval {
|
|
|
33
33
|
/** Who relayed the approval for `resolvedBy` (#3402), when someone did. */
|
|
34
34
|
relayedBy?: string;
|
|
35
35
|
timestamp: string;
|
|
36
|
+
/** The checkpoint the approval lets a run resume from (#3947), when it carries one. */
|
|
37
|
+
checkpointDigest?: string;
|
|
38
|
+
/** The line a carried approval came from (#3949): the names and time its seal was made under. */
|
|
39
|
+
carriedFrom?: {
|
|
40
|
+
op: string;
|
|
41
|
+
gate: string;
|
|
42
|
+
timestamp: string;
|
|
43
|
+
checkpointDigest?: string;
|
|
44
|
+
};
|
|
36
45
|
seal?: unknown;
|
|
37
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* The approval as it was sealed (#3949). A carried approval is recorded
|
|
49
|
+
* under a gate's new name and the time it was carried, and its seal was
|
|
50
|
+
* made over the line it came from, so its op, gate, time and checkpoint
|
|
51
|
+
* digest are read off `carriedFrom`. Any other approval is returned as it is.
|
|
52
|
+
*/
|
|
53
|
+
export declare function sealedFieldsOf(a: SealedGateApproval): SealedGateApproval;
|
|
54
|
+
/**
|
|
55
|
+
* The version of a seal over `a` (#3947): 2 when the approval carries a
|
|
56
|
+
* checkpoint digest, which the payload signs, and 1 otherwise. `chant approve
|
|
57
|
+
* --sign` records 2 on the seal; a seal records no version for 1.
|
|
58
|
+
*/
|
|
59
|
+
export declare function gateSealVersion(a: Pick<SealedGateApproval, "checkpointDigest" | "carriedFrom">): 1 | 2;
|
|
38
60
|
/**
|
|
39
61
|
* The bytes a gate approval's seal signs (#3163): the op (or component), the
|
|
40
62
|
* gate, the environment, the plan digest, the approver and the time, joined
|
|
@@ -43,8 +65,17 @@ export interface SealedGateApproval {
|
|
|
43
65
|
* `relayed-by <principal>`, so the relay can't be added, changed or removed
|
|
44
66
|
* after sealing; an approval nobody relayed signs the six lines it always did,
|
|
45
67
|
* so seals made before #3402 still verify.
|
|
68
|
+
*
|
|
69
|
+
* Seal version 2 (#3947) adds the checkpoint a resumed run carries its step
|
|
70
|
+
* outputs from: an approval with a `checkpointDigest` signs one more line,
|
|
71
|
+
* `checkpoint <digest>`, last. The values the steps after the gate read are
|
|
72
|
+
* then covered by the seal too, and removing or swapping the digest stops it
|
|
73
|
+
* verifying. An approval with none signs what a version 1 seal signed.
|
|
74
|
+
*
|
|
75
|
+
* A carried approval (#3949) signs what the line it came from signed: see
|
|
76
|
+
* {@link sealedFieldsOf}.
|
|
46
77
|
*/
|
|
47
|
-
export declare function gateSealPayload(
|
|
78
|
+
export declare function gateSealPayload(approval: SealedGateApproval): Buffer;
|
|
48
79
|
/** `SHA256:<base64>`, the fingerprint ssh-keygen prints for a public key blob. */
|
|
49
80
|
export declare function sshKeyFingerprint(blob: Buffer): string;
|
|
50
81
|
/** What {@link verifySshSignatureWith} found. */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"gate-seal.d.ts","sourceRoot":"","sources":["../../../src/workspace/trust/gate-seal.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAGH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,eAAe,CAAC;AAE5C;;;GAGG;AACH,eAAO,MAAM,mBAAmB,eAAe,CAAC;AAEhD,kIAAkI;AAClI,MAAM,WAAW,kBAAkB;IACjC,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC;IACnB,2EAA2E;IAC3E,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,CAAC,EAAE,OAAO,CAAC;CAChB;AAED
|
|
1
|
+
{"version":3,"file":"gate-seal.d.ts","sourceRoot":"","sources":["../../../src/workspace/trust/gate-seal.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAGH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,eAAe,CAAC;AAE5C;;;GAGG;AACH,eAAO,MAAM,mBAAmB,eAAe,CAAC;AAEhD,kIAAkI;AAClI,MAAM,WAAW,kBAAkB;IACjC,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC;IACnB,2EAA2E;IAC3E,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,uFAAuF;IACvF,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,iGAAiG;IACjG,WAAW,CAAC,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAC;QAAC,gBAAgB,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IACzF,IAAI,CAAC,EAAE,OAAO,CAAC;CAChB;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,CAAC,EAAE,kBAAkB,GAAG,kBAAkB,CAWxE;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,CAAC,EAAE,IAAI,CAAC,kBAAkB,EAAE,kBAAkB,GAAG,aAAa,CAAC,GAAG,CAAC,GAAG,CAAC,CAGtG;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,eAAe,CAAC,QAAQ,EAAE,kBAAkB,GAAG,MAAM,CAKpE;AA8BD,kFAAkF;AAClF,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAEtD;AAmCD,iDAAiD;AACjD,MAAM,MAAM,iBAAiB,GAAG;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAE1F;;;;GAIG;AACH,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,MAAM,EAAE,GAAG,iBAAiB,CA0CtI;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAU9D;AAID;;;;GAIG;AACH,MAAM,WAAW,WAAW;IAC1B,0EAA0E;IAC1E,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,6BAA6B;IAC7B,WAAW,EAAE,MAAM,CAAC;IACpB,uGAAuG;IACvG,MAAM,EAAE,OAAO,CAAC;IAChB,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,yFAAyF;IACzF,QAAQ,EAAE,MAAM,EAAE,CAAC;CACpB;AAID,kJAAkJ;AAClJ,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAYrF;AAED;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,OAAO,EAAE,WAAW,EAAE,CAAC,EAAE,kBAAkB,GAAG,MAAM,GAAG,IAAI,CAmB1F"}
|
|
@@ -23,6 +23,34 @@ import { createHash, createPublicKey, verify } from "node:crypto";
|
|
|
23
23
|
* verdict, record or commit signature can't stand in for one.
|
|
24
24
|
*/
|
|
25
25
|
export const GATE_SEAL_NAMESPACE = "chant-gate";
|
|
26
|
+
/**
|
|
27
|
+
* The approval as it was sealed (#3949). A carried approval is recorded
|
|
28
|
+
* under a gate's new name and the time it was carried, and its seal was
|
|
29
|
+
* made over the line it came from, so its op, gate, time and checkpoint
|
|
30
|
+
* digest are read off `carriedFrom`. Any other approval is returned as it is.
|
|
31
|
+
*/
|
|
32
|
+
export function sealedFieldsOf(a) {
|
|
33
|
+
const from = a.carriedFrom;
|
|
34
|
+
if (from === undefined)
|
|
35
|
+
return a;
|
|
36
|
+
const { carriedFrom: _from, checkpointDigest: _checkpoint, ...rest } = a;
|
|
37
|
+
return {
|
|
38
|
+
...rest,
|
|
39
|
+
op: from.op,
|
|
40
|
+
gate: from.gate,
|
|
41
|
+
timestamp: from.timestamp,
|
|
42
|
+
...(from.checkpointDigest !== undefined ? { checkpointDigest: from.checkpointDigest } : {}),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The version of a seal over `a` (#3947): 2 when the approval carries a
|
|
47
|
+
* checkpoint digest, which the payload signs, and 1 otherwise. `chant approve
|
|
48
|
+
* --sign` records 2 on the seal; a seal records no version for 1.
|
|
49
|
+
*/
|
|
50
|
+
export function gateSealVersion(a) {
|
|
51
|
+
const checkpoint = a.carriedFrom !== undefined ? a.carriedFrom.checkpointDigest : a.checkpointDigest;
|
|
52
|
+
return checkpoint !== undefined ? 2 : 1;
|
|
53
|
+
}
|
|
26
54
|
/**
|
|
27
55
|
* The bytes a gate approval's seal signs (#3163): the op (or component), the
|
|
28
56
|
* gate, the environment, the plan digest, the approver and the time, joined
|
|
@@ -31,10 +59,21 @@ export const GATE_SEAL_NAMESPACE = "chant-gate";
|
|
|
31
59
|
* `relayed-by <principal>`, so the relay can't be added, changed or removed
|
|
32
60
|
* after sealing; an approval nobody relayed signs the six lines it always did,
|
|
33
61
|
* so seals made before #3402 still verify.
|
|
62
|
+
*
|
|
63
|
+
* Seal version 2 (#3947) adds the checkpoint a resumed run carries its step
|
|
64
|
+
* outputs from: an approval with a `checkpointDigest` signs one more line,
|
|
65
|
+
* `checkpoint <digest>`, last. The values the steps after the gate read are
|
|
66
|
+
* then covered by the seal too, and removing or swapping the digest stops it
|
|
67
|
+
* verifying. An approval with none signs what a version 1 seal signed.
|
|
68
|
+
*
|
|
69
|
+
* A carried approval (#3949) signs what the line it came from signed: see
|
|
70
|
+
* {@link sealedFieldsOf}.
|
|
34
71
|
*/
|
|
35
|
-
export function gateSealPayload(
|
|
72
|
+
export function gateSealPayload(approval) {
|
|
73
|
+
const a = sealedFieldsOf(approval);
|
|
36
74
|
const relay = a.relayedBy !== undefined ? `\nrelayed-by ${a.relayedBy}` : "";
|
|
37
|
-
|
|
75
|
+
const checkpoint = gateSealVersion(a) === 2 ? `\ncheckpoint ${a.checkpointDigest}` : "";
|
|
76
|
+
return Buffer.from(`${a.op}\n${a.gate}\n${a.environment ?? ""}\n${a.planDigest ?? ""}\n${a.resolvedBy}\n${a.timestamp}${relay}${checkpoint}`, "utf-8");
|
|
38
77
|
}
|
|
39
78
|
// ── SSHSIG ───────────────────────────────────────────────────────────────
|
|
40
79
|
/** A reader over ssh wire format: `take(n)` bytes, or `take()` one length-prefixed string. */
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
import type { SealCode } from "../records.js";
|
|
25
25
|
import { type TrustPolicy } from "./policy.js";
|
|
26
26
|
import { type SealedGateApproval } from "./gate-seal.js";
|
|
27
|
-
export { GATE_SEAL_NAMESPACE, gateSealPayload, type SealedGateApproval } from "./gate-seal.js";
|
|
27
|
+
export { GATE_SEAL_NAMESPACE, gateSealPayload, gateSealVersion, sealedFieldsOf, type SealedGateApproval } from "./gate-seal.js";
|
|
28
28
|
/** The ssh signature namespace of a verdict seal. */
|
|
29
29
|
export declare const REVIEW_SEAL_NAMESPACE = "chant-review";
|
|
30
30
|
/** The ssh signature namespace of a record's author seal (#2688). */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"seal.d.ts","sourceRoot":"","sources":["../../../src/workspace/trust/seal.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAMH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAC3C,OAAO,EAAE,KAAK,WAAW,EAAE,MAAM,UAAU,CAAC;AAG5C,OAAO,EAAmG,KAAK,kBAAkB,EAAE,MAAM,aAAa,CAAC;AAEvJ,OAAO,EAAE,mBAAmB,EAAE,eAAe,EAAE,KAAK,kBAAkB,EAAE,MAAM,aAAa,CAAC;
|
|
1
|
+
{"version":3,"file":"seal.d.ts","sourceRoot":"","sources":["../../../src/workspace/trust/seal.ts"],"names":[],"mappings":"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"}
|
|
@@ -27,7 +27,7 @@ import { homedir, tmpdir } from "node:os";
|
|
|
27
27
|
import { join, resolve } from "node:path";
|
|
28
28
|
import { sshKeygen, verifySshSignature } from "./ssh-commit.js";
|
|
29
29
|
import { GATE_SEAL_NAMESPACE, gateSealPayload, namespaceAllowed, sshSignatureKey, verifySshSignatureWith } from "./gate-seal.js";
|
|
30
|
-
export { GATE_SEAL_NAMESPACE, gateSealPayload } from "./gate-seal.js";
|
|
30
|
+
export { GATE_SEAL_NAMESPACE, gateSealPayload, gateSealVersion, sealedFieldsOf } from "./gate-seal.js";
|
|
31
31
|
/** The ssh signature namespace of a verdict seal. */
|
|
32
32
|
export const REVIEW_SEAL_NAMESPACE = "chant-review";
|
|
33
33
|
/** The ssh signature namespace of a record's author seal (#2688). */
|
package/package.json
CHANGED
|
@@ -27,7 +27,7 @@ import { coreKnowledgeChecks } from "../../lint/knowledge-checks";
|
|
|
27
27
|
import { applyConfiguredSeverity, applyConfiguredPreset, resolvePresetIds } from "../../lint/config";
|
|
28
28
|
import { applyInlineSuppressions, type SuppressionMetaFinding } from "../../lint/suppressions";
|
|
29
29
|
import type { Declarable } from "../../declarable";
|
|
30
|
-
import { coreOpChecks } from "../../lint/rules/op";
|
|
30
|
+
import { coreOpChecks, workspaceIdentityGates } from "../../lint/rules/op";
|
|
31
31
|
import { loadPolicyChecks } from "../../lint/policy";
|
|
32
32
|
import { armSandboxPolicyExecution, runProjectPolicies } from "../../lint/policy-sandbox";
|
|
33
33
|
import { sortedJsonReplacer } from "../../utils";
|
|
@@ -588,7 +588,14 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
|
|
|
588
588
|
// FULL build result regardless of which plugins loaded — same as the
|
|
589
589
|
// receipt/output/knowledge checks above.
|
|
590
590
|
if (result.errors.length === 0) {
|
|
591
|
-
|
|
591
|
+
// OPS017 (#3949) reads the workspace's identity.gates, when an Op has gate aliases.
|
|
592
|
+
const identityGates = await workspaceIdentityGates(configDir, result.entities);
|
|
593
|
+
const opDiags = runPostSynthChecks(
|
|
594
|
+
coreOpChecks({ decideBackends: config.decide?.backends, ...(identityGates ? { identityGates } : {}) }),
|
|
595
|
+
result,
|
|
596
|
+
env,
|
|
597
|
+
{ activityContracts },
|
|
598
|
+
);
|
|
592
599
|
const { diagnostics: activeDiags, suppressed } = applyConfiguredSeverity(opDiags, config.lint?.rules);
|
|
593
600
|
suppressedPostSynthCount += suppressed.length;
|
|
594
601
|
for (const diag of activeDiags) {
|
package/src/cli/commands/lint.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { loadComponentChecks } from "../../lint/rules/comp/index";
|
|
|
13
13
|
import { runComponentChecks, type ComponentCheckDiagnostic } from "../../lint/component-checks";
|
|
14
14
|
import { buildCapabilityRegistry } from "../../components/capability-plugin-loader";
|
|
15
15
|
import type { RollbackPolicy } from "../../components/capability";
|
|
16
|
-
import { coreOpChecks } from "../../lint/rules/op";
|
|
16
|
+
import { coreOpChecks, workspaceIdentityGates } from "../../lint/rules/op";
|
|
17
17
|
import { loadActivityContracts } from "../../op/activity-contract-registry";
|
|
18
18
|
import { runPostSynthChecks } from "../../lint/post-synth";
|
|
19
19
|
import { rule } from "../../lint/declarative";
|
|
@@ -627,8 +627,10 @@ async function runOpCheckDiagnostics(
|
|
|
627
627
|
.then((loaded) => loaded.config.decide?.backends)
|
|
628
628
|
.catch(() => undefined);
|
|
629
629
|
|
|
630
|
+
// OPS017 (#3949) reads the workspace's identity.gates, when an Op has gate aliases.
|
|
631
|
+
const identityGates = await workspaceIdentityGates(findProjectRoot(infraPath), entities);
|
|
630
632
|
const raw = runPostSynthChecks(
|
|
631
|
-
coreOpChecks({ decideBackends }),
|
|
633
|
+
coreOpChecks({ decideBackends, ...(identityGates ? { identityGates } : {}) }),
|
|
632
634
|
{
|
|
633
635
|
outputs: new Map(),
|
|
634
636
|
entities: entities as Map<string, never>,
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* re-exports everything here.
|
|
6
6
|
*/
|
|
7
7
|
import { discoverOps } from "../../op/discover";
|
|
8
|
+
import { aliasingGate } from "../../op/gate-alias";
|
|
8
9
|
import type { GateResolutionRecord, PendingGateRecord } from "../../lifecycle/gate-ledger";
|
|
9
10
|
import {
|
|
10
11
|
currentGateOrigin, isModelAuthored, sameOriginRefusal, UNATTESTED_APPROVER, type GateOrigin,
|
|
@@ -280,7 +281,9 @@ export async function recordGateApproval(
|
|
|
280
281
|
// `fan-out` is the op name every `chant components fan-out` gate is recorded
|
|
281
282
|
// under (../handlers/fan-out.ts). It is a command rather than a declaration,
|
|
282
283
|
// so there is no `*.op.ts` to find and nothing is wrong when none is there.
|
|
283
|
-
|
|
284
|
+
// #3949: a gate that lists this one as an alias, after an Op was restructured.
|
|
285
|
+
const successor = aliasingGate([...ops.values()].map((o) => o.config), opName, gate);
|
|
286
|
+
if (!successor && !ops.has(opName) && opName !== FAN_OUT_GATE_OP && opName !== WORKSPACE_UPGRADE_GATE_OP && opName !== OVERRIDE_GATE_OP) {
|
|
284
287
|
console.error(formatWarning({
|
|
285
288
|
message: `Op "${opName}" was not found among discovered *.op.ts declarations — recording the resolution anyway`,
|
|
286
289
|
}));
|
|
@@ -301,6 +304,26 @@ export async function recordGateApproval(
|
|
|
301
304
|
return { ok: false };
|
|
302
305
|
}
|
|
303
306
|
|
|
307
|
+
// #3949: an approval under an alias counts toward the new gate only while
|
|
308
|
+
// the new gate has no standing fact of its own. Once it has one, approve
|
|
309
|
+
// the gate the runs now stop at.
|
|
310
|
+
if (successor) {
|
|
311
|
+
const theirs = latestPendingGate((await readGateLedger(successor.op)).pending, successor.gate);
|
|
312
|
+
if (theirs && !isPendingGateExpired(theirs, new Date().toISOString())) {
|
|
313
|
+
console.error(formatError({
|
|
314
|
+
message:
|
|
315
|
+
`Gate "${gate}" on "${opName}" is now gate "${successor.gate}" on "${successor.op}", which lists it as an alias, ` +
|
|
316
|
+
`and "${successor.gate}" has a standing pending fact`,
|
|
317
|
+
hint: `Approve that gate instead: chant approve ${successor.op} ${successor.gate}`,
|
|
318
|
+
}));
|
|
319
|
+
return { ok: false };
|
|
320
|
+
}
|
|
321
|
+
console.error(formatInfo(
|
|
322
|
+
`Gate "${gate}" on "${opName}" is an alias of gate "${successor.gate}" on "${successor.op}": ` +
|
|
323
|
+
`the next run of ${successor.op} counts this approval there while the pending fact it answers stands and the plan is the same.`,
|
|
324
|
+
));
|
|
325
|
+
}
|
|
326
|
+
|
|
304
327
|
// #2028: the resolution's link is typed. `--url` wins; otherwise, running
|
|
305
328
|
// inside the PR/MR job that carries the change is itself the address, the
|
|
306
329
|
// same env fallback `--actor` uses. `--note` stays free-text prose.
|
|
@@ -358,6 +381,14 @@ export async function recordGateApproval(
|
|
|
358
381
|
}
|
|
359
382
|
planDigest = standing.planDigest;
|
|
360
383
|
}
|
|
384
|
+
// #3947: a gate that resumes recorded a checkpoint with its pending fact.
|
|
385
|
+
// An approval of that fact's plan copies the checkpoint's digest, the way it
|
|
386
|
+
// copies the plan's, so the run that resumes checks the file against what
|
|
387
|
+
// was approved here. An approval of another plan copies none.
|
|
388
|
+
const checkpointDigest =
|
|
389
|
+
standing?.checkpointDigest !== undefined && samePlanDigest(planDigest, standing.planDigest)
|
|
390
|
+
? standing.checkpointDigest
|
|
391
|
+
: undefined;
|
|
361
392
|
|
|
362
393
|
// chant#2384 — the gate's whole value is that its two halves have different
|
|
363
394
|
// authors. At a shell they do: a person runs, reads the plan, and approves.
|
|
@@ -457,11 +488,11 @@ export async function recordGateApproval(
|
|
|
457
488
|
const timestamp = new Date().toISOString();
|
|
458
489
|
let seal: GateResolutionRecord["seal"];
|
|
459
490
|
if (opts.sign !== undefined) {
|
|
460
|
-
const { resolveSigningKey, sealGateApproval, SealError } = await import("../../workspace/trust/seal");
|
|
491
|
+
const { resolveSigningKey, sealGateApproval, gateSealVersion, SealError } = await import("../../workspace/trust/seal");
|
|
461
492
|
try {
|
|
462
493
|
const key = resolveSigningKey(opts.sign, cwd);
|
|
463
494
|
try {
|
|
464
|
-
|
|
495
|
+
const sealed = {
|
|
465
496
|
op: opName,
|
|
466
497
|
gate,
|
|
467
498
|
...(environment !== undefined ? { environment } : {}),
|
|
@@ -469,7 +500,10 @@ export async function recordGateApproval(
|
|
|
469
500
|
resolvedBy,
|
|
470
501
|
...(relayedBy ? { relayedBy } : {}),
|
|
471
502
|
timestamp,
|
|
472
|
-
|
|
503
|
+
...(checkpointDigest !== undefined ? { checkpointDigest } : {}),
|
|
504
|
+
};
|
|
505
|
+
// #3947: a seal over a checkpoint digest is seal version 2.
|
|
506
|
+
seal = { ...sealGateApproval(key.file, sealed), ...(gateSealVersion(sealed) === 2 ? { version: 2 as const } : {}) };
|
|
473
507
|
} finally {
|
|
474
508
|
key.cleanup();
|
|
475
509
|
}
|
|
@@ -488,6 +522,7 @@ export async function recordGateApproval(
|
|
|
488
522
|
resolvedBy,
|
|
489
523
|
...(relayedBy ? { relayedBy } : {}),
|
|
490
524
|
timestamp,
|
|
525
|
+
...(checkpointDigest !== undefined ? { checkpointDigest } : {}),
|
|
491
526
|
seal,
|
|
492
527
|
});
|
|
493
528
|
if (why !== null) {
|
|
@@ -550,6 +585,7 @@ export async function recordGateApproval(
|
|
|
550
585
|
...(opts.note ? { note: opts.note } : {}),
|
|
551
586
|
...(url ? { url } : {}),
|
|
552
587
|
...(planDigest !== undefined ? { planDigest } : {}),
|
|
588
|
+
...(checkpointDigest !== undefined ? { checkpointDigest } : {}),
|
|
553
589
|
...(environment !== undefined ? { environment } : {}),
|
|
554
590
|
origin,
|
|
555
591
|
...(refusal && opts.allowSameOrigin ? { sameOriginOverride: true } : {}),
|
|
@@ -574,6 +610,12 @@ export async function recordGateApproval(
|
|
|
574
610
|
", and only that plan. A run whose fresh plan differs refuses rather than applying it.",
|
|
575
611
|
));
|
|
576
612
|
}
|
|
613
|
+
if (record.checkpointDigest) {
|
|
614
|
+
console.error(formatInfo(
|
|
615
|
+
`The next run resumes at this gate from run ${standing?.runId ?? "(unknown)"}'s checkpoint ${record.checkpointDigest}, ` +
|
|
616
|
+
"after it plans again. A moved plan or another commit starts it at the first phase.",
|
|
617
|
+
));
|
|
618
|
+
}
|
|
577
619
|
if (approval) {
|
|
578
620
|
for (const line of describeApprovalProgress(approval, record, ledger.resolutions, standingForOrigin, ledger.pending)) {
|
|
579
621
|
console.error(formatInfo(line));
|
|
@@ -929,6 +929,97 @@ describe("runApprove — the resolution names the plan it approves (#2300)", ()
|
|
|
929
929
|
});
|
|
930
930
|
});
|
|
931
931
|
|
|
932
|
+
/** #3947 — a gate that resumes recorded a checkpoint, and its approval copies the digest. */
|
|
933
|
+
describe("runApprove — the approval copies the checkpoint a resumed run carries on from (#3947)", () => {
|
|
934
|
+
const CHECKPOINT = `jcs1-sha256:${"c".repeat(64)}`;
|
|
935
|
+
const seedCheckpoint = () =>
|
|
936
|
+
readGateLedgerMock.mockResolvedValue({
|
|
937
|
+
resolutions: [],
|
|
938
|
+
pending: [{
|
|
939
|
+
version: 1, kind: "pending", op: "fountain-apply", gate: "rollout-gate", runId: "local-1",
|
|
940
|
+
timestamp: "2026-01-01T00:00:00.000Z", expiresAt: "2099-01-01T00:00:00.000Z",
|
|
941
|
+
planDigest: PLAN_A, checkpointDigest: CHECKPOINT,
|
|
942
|
+
}],
|
|
943
|
+
malformed: 0,
|
|
944
|
+
});
|
|
945
|
+
|
|
946
|
+
test("approving the standing fact copies its checkpoint digest and says the next run resumes", async () => {
|
|
947
|
+
seedCheckpoint();
|
|
948
|
+
appendGateResolutionMock.mockResolvedValue({
|
|
949
|
+
commit: "sha",
|
|
950
|
+
record: {
|
|
951
|
+
version: 1, op: "fountain-apply", gate: "rollout-gate", resolvedBy: "alex",
|
|
952
|
+
timestamp: "2026-01-01T00:00:00.000Z", planDigest: PLAN_A, checkpointDigest: CHECKPOINT,
|
|
953
|
+
},
|
|
954
|
+
});
|
|
955
|
+
const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
956
|
+
|
|
957
|
+
expect(await runApprove(ctx({ path: "fountain-apply", extraPositional: "rollout-gate", actor: "alex" }))).toBe(0);
|
|
958
|
+
expect(appendGateResolutionMock).toHaveBeenCalledWith(expect.objectContaining({ planDigest: PLAN_A, checkpointDigest: CHECKPOINT }));
|
|
959
|
+
expect(errSpy.mock.calls.map((c) => String(c[0])).join("\n")).toContain(`resumes at this gate from run local-1's checkpoint ${CHECKPOINT}`);
|
|
960
|
+
errSpy.mockRestore();
|
|
961
|
+
});
|
|
962
|
+
|
|
963
|
+
test("an approval of another plan copies no checkpoint", async () => {
|
|
964
|
+
seedCheckpoint();
|
|
965
|
+
const other = `sha256:${"b".repeat(64)}`;
|
|
966
|
+
appendGateResolutionMock.mockResolvedValue({
|
|
967
|
+
commit: "sha",
|
|
968
|
+
record: { version: 1, op: "fountain-apply", gate: "rollout-gate", resolvedBy: "alex", timestamp: "2026-01-01T00:00:00.000Z", planDigest: other },
|
|
969
|
+
});
|
|
970
|
+
const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
971
|
+
|
|
972
|
+
expect(await runApprove(ctx({ path: "fountain-apply", extraPositional: "rollout-gate", actor: "alex", plan: other }))).toBe(0);
|
|
973
|
+
expect(appendGateResolutionMock.mock.calls[0][0]).not.toHaveProperty("checkpointDigest");
|
|
974
|
+
errSpy.mockRestore();
|
|
975
|
+
});
|
|
976
|
+
});
|
|
977
|
+
|
|
978
|
+
/** #3949 — once a gate that aliases this one has a standing fact, approve that gate. */
|
|
979
|
+
describe("runApprove — an approval under a gate's alias (#3949)", () => {
|
|
980
|
+
const merged = {
|
|
981
|
+
config: {
|
|
982
|
+
name: "upgrade",
|
|
983
|
+
overview: "upgrade every tier",
|
|
984
|
+
phases: [{ name: "Wave 1", steps: [{ kind: "gate", gate: "approve-tier-1", plan: PLAN_A, aliases: [{ op: "upgrade-tier-1", gate: "approve" }] }] }],
|
|
985
|
+
},
|
|
986
|
+
};
|
|
987
|
+
const fact = (op: string, gate: string) => ({
|
|
988
|
+
version: 1, kind: "pending", op, gate, timestamp: "2026-01-01T00:00:00.000Z", expiresAt: "2099-01-01T00:00:00.000Z", planDigest: PLAN_A,
|
|
989
|
+
});
|
|
990
|
+
const ledgers = (byOp: Record<string, unknown[]>) =>
|
|
991
|
+
readGateLedgerMock.mockImplementation(async (op: string) => ({ resolutions: [], pending: byOp[op] ?? [], malformed: 0 }));
|
|
992
|
+
|
|
993
|
+
test("refuses and names the new gate once it has a standing pending fact", async () => {
|
|
994
|
+
discoverOpsMock.mockResolvedValue({ ops: new Map([["upgrade", merged]]), errors: [] });
|
|
995
|
+
ledgers({ "upgrade-tier-1": [fact("upgrade-tier-1", "approve")], upgrade: [fact("upgrade", "approve-tier-1")] });
|
|
996
|
+
const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
997
|
+
|
|
998
|
+
expect(await runApprove(ctx({ path: "upgrade-tier-1", extraPositional: "approve", actor: "alex" }))).toBe(1);
|
|
999
|
+
const out = errSpy.mock.calls.map((c) => String(c[0])).join("\n");
|
|
1000
|
+
expect(out).toContain('is now gate "approve-tier-1" on "upgrade"');
|
|
1001
|
+
expect(out).toContain("chant approve upgrade approve-tier-1");
|
|
1002
|
+
expect(out).not.toContain("was not found among discovered");
|
|
1003
|
+
expect(appendGateResolutionMock).not.toHaveBeenCalled();
|
|
1004
|
+
errSpy.mockRestore();
|
|
1005
|
+
});
|
|
1006
|
+
|
|
1007
|
+
test("records the approval while the new gate has none, and says where it counts", async () => {
|
|
1008
|
+
discoverOpsMock.mockResolvedValue({ ops: new Map([["upgrade", merged]]), errors: [] });
|
|
1009
|
+
ledgers({ "upgrade-tier-1": [fact("upgrade-tier-1", "approve")] });
|
|
1010
|
+
appendGateResolutionMock.mockResolvedValue({
|
|
1011
|
+
commit: "sha",
|
|
1012
|
+
record: { version: 1, op: "upgrade-tier-1", gate: "approve", resolvedBy: "alex", timestamp: "2026-01-01T00:00:00.000Z", planDigest: PLAN_A },
|
|
1013
|
+
});
|
|
1014
|
+
const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
1015
|
+
|
|
1016
|
+
expect(await runApprove(ctx({ path: "upgrade-tier-1", extraPositional: "approve", actor: "alex" }))).toBe(0);
|
|
1017
|
+
expect(appendGateResolutionMock).toHaveBeenCalledWith(expect.objectContaining({ op: "upgrade-tier-1", gate: "approve", planDigest: PLAN_A }));
|
|
1018
|
+
expect(errSpy.mock.calls.map((c) => String(c[0])).join("\n")).toContain('is an alias of gate "approve-tier-1" on "upgrade"');
|
|
1019
|
+
errSpy.mockRestore();
|
|
1020
|
+
});
|
|
1021
|
+
});
|
|
1022
|
+
|
|
932
1023
|
/**
|
|
933
1024
|
* #2574 — a component's gate ledger holds every environment's pending facts.
|
|
934
1025
|
* The approval records the environment it answers, and `--env` picks it.
|
package/src/gate-decision.ts
CHANGED
|
@@ -48,6 +48,13 @@
|
|
|
48
48
|
* - `override`: an apply cites an override that no line holds.
|
|
49
49
|
* - `malformed-line`: a line that is not a gate record. Its content is lost
|
|
50
50
|
* to the trail, so it is said.
|
|
51
|
+
* - `carried-without-source`: an approval carried from a gate's alias
|
|
52
|
+
* (#3949, `carriedFrom`) whose cited line is not in the cited op's
|
|
53
|
+
* ledger. Checked only when that ledger is passed in.
|
|
54
|
+
*
|
|
55
|
+
* A carried approval's request is the pending fact of the name it was
|
|
56
|
+
* carried from, in that op's ledger. When that ledger is not passed in,
|
|
57
|
+
* neither gap is looked for, and the entry's `carriedFrom` says where to look.
|
|
51
58
|
*
|
|
52
59
|
* ## Sources and ids
|
|
53
60
|
*
|
|
@@ -211,13 +218,15 @@ export interface GateAuditEntry {
|
|
|
211
218
|
subject?: "approval" | "override";
|
|
212
219
|
/** An applied entry: the approval entry it ran under, if one is held. An override-spent entry: the override entry. */
|
|
213
220
|
under?: string;
|
|
221
|
+
/** An approval a run carried from one of the gate's aliases (#3949): the op, gate and time of the line it came from. */
|
|
222
|
+
carriedFrom?: { op: string; gate: string; at: string };
|
|
214
223
|
/** Everything else the line carries, such as `members`, `via` or a product's own fields. */
|
|
215
224
|
detail: Record<string, unknown>;
|
|
216
225
|
source: GateAuditSource;
|
|
217
226
|
}
|
|
218
227
|
|
|
219
228
|
export interface GateAuditGap {
|
|
220
|
-
kind: "approval-without-request" | "unsealed" | "approval" | "override" | "malformed-line";
|
|
229
|
+
kind: "approval-without-request" | "unsealed" | "approval" | "override" | "malformed-line" | "carried-without-source";
|
|
221
230
|
op?: string;
|
|
222
231
|
gate?: string;
|
|
223
232
|
digest?: string;
|
|
@@ -284,7 +293,7 @@ type Row = Record<string, unknown>;
|
|
|
284
293
|
|
|
285
294
|
const KNOWN = new Set([
|
|
286
295
|
"version", "kind", "op", "gate", "resolvedBy", "relayedBy", "timestamp", "note", "url", "planDigest", "environment",
|
|
287
|
-
"approver", "seal", "review", "runId", "expiresAt", "description", "wave", "heldBy", "approvalMode", "rules", "pr", "head", "reviewers",
|
|
296
|
+
"approver", "seal", "review", "runId", "expiresAt", "description", "wave", "heldBy", "approvalMode", "rules", "pr", "head", "reviewers", "carriedFrom",
|
|
288
297
|
]);
|
|
289
298
|
|
|
290
299
|
const waveOf = (v: unknown): { number: number; name: string; op?: string } | undefined => {
|
|
@@ -335,6 +344,11 @@ function approverOf(r: Row): GateAuditEntry["approver"] {
|
|
|
335
344
|
return { kind: a.kind, ...(roles ? { roles } : {}) };
|
|
336
345
|
}
|
|
337
346
|
|
|
347
|
+
const carriedOf = (v: unknown): GateAuditEntry["carriedFrom"] => {
|
|
348
|
+
const c = v as { op?: unknown; gate?: unknown; timestamp?: unknown } | null;
|
|
349
|
+
return c && typeof c === "object" && typeof c.op === "string" && typeof c.gate === "string" && typeof c.timestamp === "string" ? { op: c.op, gate: c.gate, at: c.timestamp } : undefined;
|
|
350
|
+
};
|
|
351
|
+
|
|
338
352
|
const drop = <T extends object>(o: T): T => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined)) as T;
|
|
339
353
|
|
|
340
354
|
const idOf = (...parts: unknown[]): string => contentDigest(canonicalJson(parts));
|
|
@@ -375,6 +389,19 @@ export function deriveGateAudit(input: GateAuditInput): { entries: GateAuditEntr
|
|
|
375
389
|
const entries: GateAuditEntry[] = [];
|
|
376
390
|
const gaps: GateAuditGap[] = [];
|
|
377
391
|
|
|
392
|
+
// #3949: every ledger's requests and approvals by op, for an approval carried from another op's gate.
|
|
393
|
+
const byOp = new Map<string, { requests: Seen[]; approvals: Row[] }>();
|
|
394
|
+
for (const ledger of input.ledgers) {
|
|
395
|
+
const held = byOp.get(ledger.op) ?? { requests: [], approvals: [] };
|
|
396
|
+
byOp.set(ledger.op, held);
|
|
397
|
+
for (const line of ledger.lines) {
|
|
398
|
+
const r = rowOf(line);
|
|
399
|
+
if (!r || line.removed || typeof r.gate !== "string" || typeof r.timestamp !== "string") continue;
|
|
400
|
+
if (r.kind === "pending") held.requests.push({ op: ledger.op, gate: r.gate, at: time(r.timestamp), ...(str(r.planDigest) ? { digest: str(r.planDigest) } : {}) });
|
|
401
|
+
else held.approvals.push(r);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
378
405
|
for (const ledger of input.ledgers) {
|
|
379
406
|
const path = ledger.path ?? `_gates/${ledger.op}.jsonl`;
|
|
380
407
|
const parsed: { line: GateLedgerLine; n: number; r: Row; pending: boolean }[] = [];
|
|
@@ -453,12 +480,22 @@ export function deriveGateAudit(input: GateAuditInput): { entries: GateAuditEntr
|
|
|
453
480
|
at: String(r.timestamp),
|
|
454
481
|
by: String(r.resolvedBy),
|
|
455
482
|
seal,
|
|
456
|
-
...drop({ sealed, signer, committedBy: line.added?.by, relayedBy: str(r.relayedBy), approver: approverOf(r), note: str(r.note), url: str(r.url), review: reviewOf(r), rules }),
|
|
483
|
+
...drop({ sealed, signer, committedBy: line.added?.by, relayedBy: str(r.relayedBy), approver: approverOf(r), note: str(r.note), url: str(r.url), review: reviewOf(r), rules, carriedFrom: carriedOf(r.carriedFrom) }),
|
|
457
484
|
detail: extra,
|
|
458
485
|
});
|
|
459
486
|
entries.push(entry);
|
|
460
487
|
|
|
461
|
-
|
|
488
|
+
const carried = entry.carriedFrom;
|
|
489
|
+
if (!override && carried) {
|
|
490
|
+
const cited = byOp.get(carried.op);
|
|
491
|
+
const from = `${carried.op}/${carried.gate}`;
|
|
492
|
+
if (cited && !cited.approvals.some((a) => a.gate === carried.gate && time(String(a.timestamp)) === time(carried.at) && a.resolvedBy === r.resolvedBy)) {
|
|
493
|
+
gaps.push({ kind: "carried-without-source", op: ledger.op, gate, ...(digest ? { digest } : {}), detail: `${path} holds an approval of ${gate} by ${entry.by} carried from ${from} at ${carried.at}, and _gates/${carried.op}.jsonl holds no such approval` });
|
|
494
|
+
}
|
|
495
|
+
if (cited && !cited.requests.some((q) => q.gate === carried.gate && q.at <= time(carried.at) && compatible(q.digest, digest))) {
|
|
496
|
+
gaps.push({ kind: "approval-without-request", op: ledger.op, gate, ...(digest ? { digest } : {}), detail: `${path} holds an approval of ${gate} by ${entry.by} carried from ${from} at ${carried.at} with no pending fact of ${from}${digest ? " and digest" : ""} before it` });
|
|
497
|
+
}
|
|
498
|
+
} else if (!override) {
|
|
462
499
|
const at = time(entry.at);
|
|
463
500
|
const env = str(r.environment);
|
|
464
501
|
const asked = requests.some((q) => q.gate === gate && q.at <= at && compatible(q.digest, digest) && (q.environment === undefined || env === undefined || q.environment === env));
|