@codyswann/lisa 4.71.1 → 4.71.3

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 (76) hide show
  1. package/all/copy-overwrite/scripts/lib/github-attestation-provider.mjs +22 -0
  2. package/all/copy-overwrite/scripts/lib/history-secret-scanner.mjs +46 -37
  3. package/all/copy-overwrite/scripts/lib/npm-update-allocate.mjs +13 -3
  4. package/all/copy-overwrite/scripts/lib/npm-update-cancel-origin.mjs +291 -0
  5. package/all/copy-overwrite/scripts/lib/npm-update-cancellation-proof.mjs +327 -0
  6. package/all/copy-overwrite/scripts/lib/npm-update-cancellation.mjs +248 -0
  7. package/all/copy-overwrite/scripts/lib/npm-update-gate.mjs +52 -35
  8. package/all/copy-overwrite/scripts/lib/npm-update-helper-inventory.mjs +4 -0
  9. package/all/copy-overwrite/scripts/lib/npm-update-hosted-gate.mjs +48 -26
  10. package/all/copy-overwrite/scripts/lib/npm-update-invariants.mjs +101 -0
  11. package/all/copy-overwrite/scripts/lib/npm-update-leaf-contract.mjs +1 -1
  12. package/all/copy-overwrite/scripts/lib/npm-update-leaf.mjs +29 -3
  13. package/all/copy-overwrite/scripts/lib/npm-update-native-process.mjs +24 -6
  14. package/all/copy-overwrite/scripts/lib/npm-update-supersession.mjs +274 -0
  15. package/all/copy-overwrite/scripts/lisa-npm-updater.mjs +98 -21
  16. package/all/copy-overwrite/scripts/npm-updater-helper-graph.json +82 -12
  17. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  18. package/dist/core/lisa-owned-hash-ledger.js +86 -0
  19. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  20. package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
  21. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  22. package/dist/core/upstream-evidence-manifest.js +28 -14
  23. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  24. package/package.json +4 -4
  25. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  26. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  27. package/plugins/lisa-agy/plugin.json +1 -1
  28. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  29. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  30. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  31. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  32. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  33. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  34. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  35. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  36. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  37. package/plugins/lisa-expo-agy/plugin.json +1 -1
  38. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  39. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  40. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  41. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  42. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  43. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  44. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  46. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  47. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  48. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  49. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  52. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  53. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  54. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  55. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  57. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  58. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  59. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  60. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  61. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  62. package/plugins/lisa-rails-agy/plugin.json +1 -1
  63. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  64. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  65. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  66. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  67. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  68. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  69. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  70. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  71. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  72. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  73. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  74. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  75. package/rails/copy-overwrite/ast-grep/rules/ruby/no-unsafe-send.yml +17 -0
  76. package/scripts/two-channel-couplings.json +2 -2
@@ -0,0 +1,327 @@
1
+ // This file is managed by Lisa and IS replaced on each `lisa` run.
2
+ // Do not edit directly — durable changes belong upstream in Lisa.
3
+
4
+ /** A manual cancellation is a third fixed signing purpose, never publication or review authority. */
5
+ import { canonicalJson } from "../lisa-automation-provenance.mjs";
6
+ import { required, keys, FILES, OBJECT } from "./npm-update-contract.mjs";
7
+ import {
8
+ verifiedRole,
9
+ matchSigningTime,
10
+ officialResult,
11
+ sha256,
12
+ } from "./github-attestation-verifier.mjs";
13
+
14
+ export const CANCELLATION_PREDICATE =
15
+ "https://lisa.dev/attestations/npm-cancellation/v1";
16
+ const DIGEST = /^[a-f0-9]{64}$/;
17
+ const DECIMAL = /^[1-9]\d*$/;
18
+ const RECORD_KEYS = [
19
+ "version",
20
+ "purpose",
21
+ "repository",
22
+ "repositoryId",
23
+ "ownerId",
24
+ "selectionKey",
25
+ "workItem",
26
+ "oldProposalKey",
27
+ "oldParent",
28
+ "parent",
29
+ "proposalKey",
30
+ "proposalHashes",
31
+ "originDescriptorSha256",
32
+ "originCheckpointSha256",
33
+ "claimCommentId",
34
+ "claimSha256",
35
+ "leafBodySha256",
36
+ "policySha256",
37
+ "npmPolicySha256",
38
+ "maintainer",
39
+ "operatorId",
40
+ "runId",
41
+ "runAttempt",
42
+ "destination",
43
+ ];
44
+
45
+ /** Manual intent comes from the provider's actual run and collaborator, not a comment or environment assertion. */
46
+ export function assertManualCancellation(
47
+ run,
48
+ permission,
49
+ policy,
50
+ maintainer,
51
+ proposal,
52
+ request,
53
+ invocation
54
+ ) {
55
+ keys(request, ["number", "proposalKey", "parent"]);
56
+ keys(invocation, ["runId", "runAttempt"]);
57
+ required(
58
+ Number.isSafeInteger(request.number) &&
59
+ request.number > 0 &&
60
+ typeof request.proposalKey === "string" &&
61
+ DIGEST.test(request.proposalKey) &&
62
+ request.parent === proposal.parent &&
63
+ OBJECT.test(request.parent),
64
+ "invalid exact cancellation intent"
65
+ );
66
+ required(
67
+ [invocation.runId, invocation.runAttempt].every(
68
+ value => typeof value === "string" && DECIMAL.test(value)
69
+ ) &&
70
+ String(run.id) === invocation.runId &&
71
+ String(run.run_attempt) === invocation.runAttempt &&
72
+ run.event === "workflow_dispatch" &&
73
+ policy.allowedTriggers?.includes("workflow_dispatch") &&
74
+ run.status === "in_progress" &&
75
+ run.head_sha === proposal.parent &&
76
+ run.head_branch === "main" &&
77
+ run.path === policy.callerWorkflow.slice(policy.repository.length + 1) &&
78
+ String(run.head_repository?.id) === policy.repositoryId &&
79
+ Array.isArray(run.referenced_workflows) &&
80
+ run.referenced_workflows.some(
81
+ workflow =>
82
+ workflow.path?.split("@")[0] === policy.signerWorkflow &&
83
+ workflow.sha === policy.signerDigest
84
+ ),
85
+ "cancellation requires the actual manual main invocation"
86
+ );
87
+ const actor = run.triggering_actor;
88
+ required(
89
+ actor?.type === "User" &&
90
+ actor.login === maintainer &&
91
+ [actor.id, permission.user?.id].every(id =>
92
+ typeof id === "string"
93
+ ? DECIMAL.test(id)
94
+ : Number.isSafeInteger(id) && id > 0
95
+ ) &&
96
+ permission.user?.type === "User" &&
97
+ permission.user.login === maintainer &&
98
+ String(permission.user.id) === String(actor.id) &&
99
+ ["write", "admin"].includes(permission.permission),
100
+ "cancellation requires the configured operator's current repository authority"
101
+ );
102
+ return String(actor.id);
103
+ }
104
+
105
+ /** The closed subject preserves one stale origin and one exact replacement, with no published destination. */
106
+ export function validateCancellation(value, expected) {
107
+ keys(value, RECORD_KEYS);
108
+ keys(value.proposalHashes, FILES);
109
+ keys(value.destination, ["branch", "expectedBranchHead", "prNumber"]);
110
+ required(
111
+ value.version === 1 &&
112
+ value.purpose === "cancel-stale-prepublication-npm-proposal" &&
113
+ typeof value.repository === "string" &&
114
+ /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(value.repository) &&
115
+ typeof value.maintainer === "string" &&
116
+ /^[A-Za-z0-9][A-Za-z0-9-]{0,38}$/.test(value.maintainer) &&
117
+ typeof value.oldParent === "string" &&
118
+ typeof value.parent === "string" &&
119
+ OBJECT.test(value.oldParent) &&
120
+ OBJECT.test(value.parent) &&
121
+ value.oldParent !== value.parent &&
122
+ value.oldProposalKey !== value.proposalKey &&
123
+ typeof value.workItem === "string" &&
124
+ value.workItem.startsWith(`${value.repository}#`) &&
125
+ DECIMAL.test(value.workItem.slice(value.repository.length + 1)),
126
+ "invalid stale cancellation scope"
127
+ );
128
+ required(
129
+ [
130
+ "repositoryId",
131
+ "ownerId",
132
+ "claimCommentId",
133
+ "operatorId",
134
+ "runId",
135
+ "runAttempt",
136
+ ].every(
137
+ key => typeof value[key] === "string" && DECIMAL.test(value[key])
138
+ ) &&
139
+ [
140
+ "selectionKey",
141
+ "oldProposalKey",
142
+ "proposalKey",
143
+ "originDescriptorSha256",
144
+ "originCheckpointSha256",
145
+ "claimSha256",
146
+ "leafBodySha256",
147
+ "policySha256",
148
+ "npmPolicySha256",
149
+ ].every(
150
+ key => typeof value[key] === "string" && DIGEST.test(value[key])
151
+ ) &&
152
+ Object.values(value.proposalHashes).every(
153
+ hash => typeof hash === "string" && DIGEST.test(hash)
154
+ ),
155
+ "invalid cancellation identity or digest"
156
+ );
157
+ required(
158
+ value.destination.branch === `lisa/npm-${value.oldProposalKey}` &&
159
+ value.destination.expectedBranchHead === null &&
160
+ value.destination.prNumber === null &&
161
+ canonicalJson(value) === canonicalJson(expected),
162
+ "cancellation subject or prepublication destination differs"
163
+ );
164
+ return value;
165
+ }
166
+
167
+ /** A durable cancellation still proves its own actual manual operator; completion is not fresh publication authority. */
168
+ export function assertRecordedOperator(run, permission, policy, record) {
169
+ required(
170
+ ["in_progress", "completed"].includes(run.status) &&
171
+ String(run.id) === record.runId &&
172
+ String(run.run_attempt) === record.runAttempt &&
173
+ run.event === "workflow_dispatch" &&
174
+ policy.allowedTriggers?.includes("workflow_dispatch") &&
175
+ run.head_sha === record.parent &&
176
+ run.head_branch === "main" &&
177
+ run.path === policy.callerWorkflow.slice(policy.repository.length + 1) &&
178
+ String(run.head_repository?.id) === policy.repositoryId &&
179
+ run.triggering_actor?.type === "User" &&
180
+ run.triggering_actor.login === record.maintainer &&
181
+ String(run.triggering_actor.id) === record.operatorId &&
182
+ permission.user?.type === "User" &&
183
+ permission.user.login === record.maintainer &&
184
+ String(permission.user.id) === record.operatorId &&
185
+ ["write", "admin"].includes(permission.permission) &&
186
+ Array.isArray(run.referenced_workflows) &&
187
+ run.referenced_workflows.some(
188
+ workflow =>
189
+ workflow.path?.split("@")[0] === policy.signerWorkflow &&
190
+ workflow.sha === policy.signerDigest
191
+ ),
192
+ "recorded cancellation operator or invocation differs"
193
+ );
194
+ }
195
+
196
+ /** The same fixed constructor is used before issuance and after independent provider verification. */
197
+ export function cancellationRecord(
198
+ origin,
199
+ proposal,
200
+ config,
201
+ invocation,
202
+ operatorId
203
+ ) {
204
+ const authority = config.automationProvenance;
205
+ return {
206
+ version: 1,
207
+ purpose: "cancel-stale-prepublication-npm-proposal",
208
+ repository: proposal.repository,
209
+ repositoryId: authority.repositoryId,
210
+ ownerId: authority.ownerId,
211
+ selectionKey: proposal.selectionKey,
212
+ workItem: origin.allocation.workItem,
213
+ oldProposalKey: origin.proposal.key,
214
+ oldParent: origin.proposal.parent,
215
+ parent: proposal.parent,
216
+ proposalKey: proposal.key,
217
+ proposalHashes: proposal.hashes,
218
+ originDescriptorSha256: origin.checkpoint.digest,
219
+ originCheckpointSha256: sha256(canonicalJson(origin.checkpoint.payload)),
220
+ claimCommentId: origin.allocation.claimCommentId,
221
+ claimSha256: origin.allocation.claimSha256,
222
+ leafBodySha256: sha256(origin.allocation.draft.body),
223
+ policySha256: sha256(canonicalJson(authority)),
224
+ npmPolicySha256: proposal.policySha256,
225
+ maintainer: config.npmUpdater.maintainer,
226
+ operatorId,
227
+ runId: invocation.runId,
228
+ runAttempt: invocation.runAttempt,
229
+ destination: {
230
+ branch: `lisa/npm-${origin.proposal.key}`,
231
+ expectedBranchHead: null,
232
+ prNumber: null,
233
+ },
234
+ };
235
+ }
236
+
237
+ /** Fresh cancellation always uses its fixed subject, predicate and ordinary expiration. */
238
+ export function assertCancellationAttestation(
239
+ results,
240
+ record,
241
+ digest,
242
+ policy,
243
+ now = Date.now()
244
+ ) {
245
+ const result = verifiedRole(
246
+ results,
247
+ record,
248
+ digest,
249
+ policy,
250
+ CANCELLATION_PREDICATE,
251
+ "cancellation.json"
252
+ );
253
+ matchSigningTime(result.verifiedTimestamps, policy, now);
254
+ return result.signature.certificate;
255
+ }
256
+
257
+ /** A recorded cancellation authenticates historical bytes within the actual provider run chronology. */
258
+ export function assertRecordedCancellation(
259
+ results,
260
+ record,
261
+ digest,
262
+ policy,
263
+ run,
264
+ now = Date.now()
265
+ ) {
266
+ const result = verifiedRole(
267
+ results,
268
+ record,
269
+ digest,
270
+ policy,
271
+ CANCELLATION_PREDICATE,
272
+ "cancellation.json"
273
+ );
274
+ const started = Date.parse(run.run_started_at);
275
+ const updated = Date.parse(run.updated_at);
276
+ required(
277
+ [started, updated].every(Number.isFinite) &&
278
+ started <= updated &&
279
+ updated <= now + 60_000,
280
+ "invalid cancellation provider chronology"
281
+ );
282
+ matchSigningTime(
283
+ result.verifiedTimestamps,
284
+ { ...policy, maxAgeSeconds: Number.MAX_SAFE_INTEGER },
285
+ now
286
+ );
287
+ for (const time of result.verifiedTimestamps) {
288
+ const signed = Date.parse(time.timestamp);
289
+ required(
290
+ signed >= started - 60_000 && signed <= updated + 60_000,
291
+ "cancellation signed outside provider chronology"
292
+ );
293
+ }
294
+ return result.signature.certificate;
295
+ }
296
+
297
+ /** Both fixed roles run the same official binary and cancellation-only subject/predicate. */
298
+ function cancellationResult(policy, record, paths) {
299
+ return officialResult(
300
+ policy,
301
+ record,
302
+ paths.cancellation,
303
+ paths.cancellationBundle,
304
+ CANCELLATION_PREDICATE
305
+ );
306
+ }
307
+
308
+ /** Verification never substitutes an operator's ambient GH binary for the immutable Linux policy. */
309
+ export function verifyCancellationAttestation(policy, record, paths, digest) {
310
+ return assertCancellationAttestation(
311
+ cancellationResult(policy, record, paths),
312
+ record,
313
+ digest,
314
+ policy
315
+ );
316
+ }
317
+
318
+ /** This fixed historical entry point grants no current publication authority. */
319
+ export function verifyRecordedCancellation(policy, record, paths, digest, run) {
320
+ return assertRecordedCancellation(
321
+ cancellationResult(policy, record, paths),
322
+ record,
323
+ digest,
324
+ policy,
325
+ run
326
+ );
327
+ }
@@ -0,0 +1,248 @@
1
+ // This file is managed by Lisa and IS replaced on each `lisa` run.
2
+ // Do not edit directly — durable changes belong upstream in Lisa.
3
+
4
+ /** Explicit hosted manual cancellation preserves the old leaf and issues no publication authority. */
5
+ import { join } from "node:path";
6
+ import { writeFileSync } from "node:fs";
7
+ import { canonicalJson } from "../lisa-automation-provenance.mjs";
8
+ import { required, keys } from "./npm-update-contract.mjs";
9
+ import { readBytes, readJson, writeJson } from "./npm-update-process.mjs";
10
+ import { assertHostedGate } from "./npm-update-hosted-gate.mjs";
11
+ import { sha256 } from "./github-attestation-verifier.mjs";
12
+ import { discoverPrior } from "./npm-update-allocate.mjs";
13
+ import { authenticateStaleOrigin } from "./npm-update-cancel-origin.mjs";
14
+ import {
15
+ recordedCancellation,
16
+ cancellationDestinationAbsent,
17
+ } from "./npm-update-supersession.mjs";
18
+ import {
19
+ assertManualCancellation,
20
+ cancellationRecord,
21
+ validateCancellation,
22
+ verifyCancellationAttestation,
23
+ } from "./npm-update-cancellation-proof.mjs";
24
+ import {
25
+ persistCheckpoint,
26
+ decodeCheckpoint,
27
+ } from "./npm-update-checkpoint.mjs";
28
+ import { withStage } from "./npm-update-invariants.mjs";
29
+
30
+ /** Native dispatch bytes are checked independently of reusable workflow input forwarding. */
31
+ export function assertDispatchCancellation(event, run, request, authority) {
32
+ required(
33
+ event &&
34
+ ["main", "refs/heads/main"].includes(event.ref) &&
35
+ event.repository?.full_name === authority.repository &&
36
+ String(event.repository.id) === authority.repositoryId &&
37
+ String(event.repository.owner?.id) === authority.ownerId &&
38
+ event.sender?.type === "User" &&
39
+ event.sender.id === run.triggering_actor?.id &&
40
+ event.sender.login === run.triggering_actor.login &&
41
+ event.inputs?.cancel_issue === String(request.number) &&
42
+ event.inputs.cancel_proposal_key === request.proposalKey &&
43
+ event.inputs.cancel_expected_main === request.parent,
44
+ "native manual dispatch intent differs"
45
+ );
46
+ }
47
+
48
+ /** The actual run and repository permission are read through the existing pinned default-token transport. */
49
+ async function manualAuthority(api, proposal, config, request) {
50
+ assertHostedGate({ policy: config.npmUpdater, proposal, config });
51
+ const invocation = {
52
+ runId: process.env.GITHUB_RUN_ID,
53
+ runAttempt: process.env.GITHUB_RUN_ATTEMPT,
54
+ };
55
+ required(
56
+ Object.values(invocation).every(
57
+ value => typeof value === "string" && /^[1-9]\d*$/.test(value)
58
+ ),
59
+ "native cancellation invocation is absent"
60
+ );
61
+ await api.main(proposal.parent);
62
+ const run = await api.request(
63
+ `repos/${proposal.repository}/actions/runs/${invocation.runId}/attempts/${invocation.runAttempt}`
64
+ );
65
+ const permission = await api.request(
66
+ `repos/${proposal.repository}/collaborators/${config.npmUpdater.maintainer}/permission`
67
+ );
68
+ const operatorId = assertManualCancellation(
69
+ run,
70
+ permission,
71
+ api.policy,
72
+ config.npmUpdater.maintainer,
73
+ proposal,
74
+ request,
75
+ invocation
76
+ );
77
+ const event = JSON.parse(
78
+ new TextDecoder("utf8", { fatal: true }).decode(
79
+ readBytes(process.env.GITHUB_EVENT_PATH, 1_048_576, false)
80
+ )
81
+ );
82
+ assertDispatchCancellation(event, run, request, api.policy);
83
+ return { invocation, operatorId };
84
+ }
85
+
86
+ /** Named stale origins are never substituted by a closed implementation ticket or another active leaf. */
87
+ async function cancellationState(context) {
88
+ const { api, cwd, proposal, config, request } = context;
89
+ const manual = await withStage("cancel-intent", () =>
90
+ manualAuthority(api, proposal, config, request)
91
+ );
92
+ const prior = await discoverPrior(api, proposal, { cwd, config });
93
+ const issue = await api.issue(request.number);
94
+ const origin = await withStage("cancel-origin", () =>
95
+ authenticateStaleOrigin({ api, cwd, issue, proposal, config })
96
+ );
97
+ required(
98
+ origin.proposal.key === request.proposalKey,
99
+ "named original proposal differs"
100
+ );
101
+ await cancellationDestinationAbsent(api, request.proposalKey);
102
+ const recorded = await recordedCancellation({
103
+ api,
104
+ cwd,
105
+ issue,
106
+ proposal,
107
+ config,
108
+ origin,
109
+ });
110
+ if (recorded) {
111
+ const record = recorded.payload.preview.cancellation;
112
+ required(
113
+ record.parent === proposal.parent &&
114
+ record.proposalKey === proposal.key &&
115
+ canonicalJson(record.proposalHashes) === canonicalJson(proposal.hashes),
116
+ "recorded replacement no longer matches"
117
+ );
118
+ } else
119
+ required(
120
+ issue.state === "open" &&
121
+ prior.status === "stale-outstanding" &&
122
+ prior.issue.number === request.number,
123
+ "only the active stale origin can be cancelled"
124
+ );
125
+ return { ...manual, issue, origin, recorded };
126
+ }
127
+
128
+ /** Preparation writes only a private prospective subject, never a provider lifecycle mutation. */
129
+ export async function prepareCancellation(context) {
130
+ const { output, proposal, config, request } = context;
131
+ const state = await cancellationState(context);
132
+ const record =
133
+ state.recorded?.payload.preview.cancellation ??
134
+ cancellationRecord(
135
+ state.origin,
136
+ proposal,
137
+ config,
138
+ state.invocation,
139
+ state.operatorId
140
+ );
141
+ validateCancellation(record, record);
142
+ writeJson(join(output, "cancellation.json"), record);
143
+ const mode = state.recorded ? "recorded" : "fresh";
144
+ if (state.recorded) {
145
+ const bytes = Buffer.from(state.recorded.payload.bundle);
146
+ required(bytes.length <= 1_048_576, "cancellation bundle exceeds bound");
147
+ writeFileSync(join(output, "cancellation-bundle.json"), bytes, {
148
+ mode: 0o600,
149
+ flag: "wx",
150
+ });
151
+ }
152
+ writeJson(join(output, "cancellation-state.json"), { mode, request });
153
+ return { mode };
154
+ }
155
+
156
+ /** Each chunk and closure rechecks current operator, main, holds, destination and immutable subject. */
157
+ async function authorizeCancellation(context, record, paths, state) {
158
+ const current = await cancellationState(context);
159
+ if (state.mode === "fresh") {
160
+ validateCancellation(
161
+ record,
162
+ cancellationRecord(
163
+ current.origin,
164
+ context.proposal,
165
+ context.config,
166
+ current.invocation,
167
+ current.operatorId
168
+ )
169
+ );
170
+ verifyCancellationAttestation(
171
+ context.api.policy,
172
+ record,
173
+ paths,
174
+ sha256(readBytes(paths.cancellation, 65_536))
175
+ );
176
+ } else
177
+ required(
178
+ state.mode === "recorded" &&
179
+ current.recorded &&
180
+ canonicalJson(current.recorded.payload.preview.cancellation) ===
181
+ canonicalJson(record),
182
+ "recorded cancellation authority differs"
183
+ );
184
+ return current;
185
+ }
186
+
187
+ /** Durable proof precedes a not_planned close; interruptions retain all evidence for the next manual run. */
188
+ export async function finalizeCancellation(context) {
189
+ const { api, output, proposal, request } = context;
190
+ const state = readJson(join(output, "cancellation-state.json"), 65_536);
191
+ keys(state, ["mode", "request"]);
192
+ required(
193
+ canonicalJson(state.request) === canonicalJson(request),
194
+ "cancellation intent changed"
195
+ );
196
+ const paths = {
197
+ cancellation: join(output, "cancellation.json"),
198
+ cancellationBundle: join(output, "cancellation-bundle.json"),
199
+ };
200
+ const record = readJson(paths.cancellation, 65_536);
201
+ const digest = sha256(readBytes(paths.cancellation, 65_536));
202
+ const current = await authorizeCancellation(context, record, paths, state);
203
+ const payload = current.recorded?.payload ?? {
204
+ version: 1,
205
+ proposal,
206
+ allocation: current.origin.allocation,
207
+ preview: { cancellation: record },
208
+ bundle: readBytes(paths.cancellationBundle, 1_048_576).toString("utf8"),
209
+ };
210
+ if (!current.recorded)
211
+ await withStage("cancel-checkpoint", () =>
212
+ persistCheckpoint(api, request.number, payload, digest, () =>
213
+ authorizeCancellation(context, record, paths, state)
214
+ )
215
+ );
216
+ const issue = (await authorizeCancellation(context, record, paths, state))
217
+ .issue;
218
+ required(
219
+ canonicalJson(decodeCheckpoint(issue.comments, digest)) ===
220
+ canonicalJson(payload),
221
+ "durable cancellation checkpoint differs"
222
+ );
223
+ if (issue.state === "open")
224
+ await withStage("cancel-close", () =>
225
+ api.request(
226
+ `repos/${proposal.repository}/issues/${request.number}`,
227
+ "PATCH",
228
+ { state: "closed", state_reason: "not_planned" }
229
+ )
230
+ );
231
+ const closed = await api.issue(request.number);
232
+ required(
233
+ closed.state === "closed" &&
234
+ closed.state_reason === "not_planned" &&
235
+ closed.closed_by?.type === "Bot" &&
236
+ String(closed.closed_by.id) === api.policy.claimActorId &&
237
+ closed.body === issue.body &&
238
+ closed.title === issue.title,
239
+ "cancellation closure readback differs"
240
+ );
241
+ await api.main(proposal.parent);
242
+ await cancellationDestinationAbsent(api, request.proposalKey);
243
+ return {
244
+ status: "cancelled",
245
+ number: request.number,
246
+ cancellationSha256: digest,
247
+ };
248
+ }
@@ -42,6 +42,7 @@ import {
42
42
  ordinaryGateReceipt,
43
43
  } from "./npm-update-publication.mjs";
44
44
  import { executeCanonicalHelper } from "./npm-update-controller-factory.mjs";
45
+ import { withStage } from "./npm-update-invariants.mjs";
45
46
 
46
47
  const BOT = "github-actions[bot]";
47
48
  const MAIL = "41898282+github-actions[bot]@users.noreply.github.com";
@@ -143,7 +144,7 @@ export async function descriptorFor({
143
144
  async function stageProposal(context, env) {
144
145
  const { cwd, proposal } = context;
145
146
  const command = args => git(cwd, env, args, undefined, context.deadline);
146
- await baseline(cwd, env, proposal);
147
+ await withStage("gate-baseline", () => baseline(cwd, env, proposal));
147
148
  const parent = (await command(["rev-parse", "HEAD"])).stdout
148
149
  .toString()
149
150
  .trim();
@@ -154,8 +155,12 @@ async function stageProposal(context, env) {
154
155
  );
155
156
  const branch = `lisa/npm-${proposal.key}`;
156
157
  await command(["checkout", "-b", branch]);
157
- await executeCanonicalHelper(context, "stage-read", "link");
158
- await executeCanonicalHelper(context, "stage-read", "attach-branch");
158
+ await withStage("gate-link", () =>
159
+ executeCanonicalHelper(context, "stage-read", "link")
160
+ );
161
+ await withStage("gate-attach", () =>
162
+ executeCanonicalHelper(context, "stage-read", "attach-branch")
163
+ );
159
164
  for (const file of FILES)
160
165
  writeFileSync(join(cwd, file), proposal.files[file]);
161
166
  await command(["add", "--", ...FILES]);
@@ -263,38 +268,50 @@ async function pushProposal(context, env, branch, object) {
263
268
  export async function gateProposal(context) {
264
269
  const scoped = { ...context, deadline: Date.now() + 1_800_000 };
265
270
  const { proposal, policy, token, preview } = context;
266
- validateProposal(proposal, policy);
267
- assertHostedGate(context);
268
- validateGateProof(context);
269
- required(
270
- typeof token === "string" && token.length > 0,
271
- "readonly gate token is absent"
272
- );
273
- return withPrivateRoot(async root => {
274
- const env = {
275
- ...candidateEnvironment(root),
276
- GIT_AUTHOR_NAME: BOT,
277
- GIT_AUTHOR_EMAIL: MAIL,
278
- GIT_COMMITTER_NAME: BOT,
279
- GIT_COMMITTER_EMAIL: MAIL,
280
- GIT_AUTHOR_DATE: `${preview.epoch} +0000`,
281
- GIT_COMMITTER_DATE: `${preview.epoch} +0000`,
282
- };
283
- const branch = await stageProposal(scoped, env);
284
- const message = await installGateProof(context, root, args =>
285
- git(context.cwd, env, args, undefined, scoped.deadline)
271
+ await withStage("gate-validate", () => {
272
+ validateProposal(proposal, policy);
273
+ assertHostedGate(context);
274
+ validateGateProof(context);
275
+ required(
276
+ typeof token === "string" && token.length > 0,
277
+ "readonly gate token is absent"
286
278
  );
287
- const hosted = await createHostedGate(scoped, root, env);
288
- try {
289
- const gated = { ...scoped, hookInstallation: hosted.installation };
290
- const object = await committedProposal(
291
- gated,
292
- { ...hosted.env, LISA_NPM_HOOK_ROLE: "commit" },
293
- message
294
- );
295
- return await pushProposal(gated, hosted.env, branch, object);
296
- } finally {
297
- await hosted.close();
298
- }
299
279
  });
280
+ return withStage("gate-scratch", () =>
281
+ withPrivateRoot(async root => {
282
+ const env = {
283
+ ...candidateEnvironment(root),
284
+ GIT_AUTHOR_NAME: BOT,
285
+ GIT_AUTHOR_EMAIL: MAIL,
286
+ GIT_COMMITTER_NAME: BOT,
287
+ GIT_COMMITTER_EMAIL: MAIL,
288
+ GIT_AUTHOR_DATE: `${preview.epoch} +0000`,
289
+ GIT_COMMITTER_DATE: `${preview.epoch} +0000`,
290
+ };
291
+ const branch = await withStage("gate-stage", () =>
292
+ stageProposal(scoped, env)
293
+ );
294
+ const proofGit = args =>
295
+ git(context.cwd, env, args, undefined, scoped.deadline);
296
+ const message = await withStage("gate-proof", () =>
297
+ installGateProof(context, root, proofGit)
298
+ );
299
+ const hosted = await createHostedGate(scoped, root, env);
300
+ try {
301
+ const gated = { ...scoped, hookInstallation: hosted.installation };
302
+ const object = await withStage("gate-commit", () =>
303
+ committedProposal(
304
+ gated,
305
+ { ...hosted.env, LISA_NPM_HOOK_ROLE: "commit" },
306
+ message
307
+ )
308
+ );
309
+ return await withStage("gate-push", () =>
310
+ pushProposal(gated, hosted.env, branch, object)
311
+ );
312
+ } finally {
313
+ await withStage("gate-close", () => hosted.close());
314
+ }
315
+ })
316
+ );
300
317
  }