pi-daddy 0.17.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/CHANGELOG.md +154 -80
  2. package/README.md +54 -25
  3. package/dist/approval-prompt.d.ts +3 -1
  4. package/dist/approval-prompt.d.ts.map +1 -1
  5. package/dist/approval-prompt.js +1 -1
  6. package/dist/approval-prompt.js.map +1 -1
  7. package/dist/approval-store.d.ts.map +1 -1
  8. package/dist/approval-store.js +4 -1
  9. package/dist/approval-store.js.map +1 -1
  10. package/dist/approval.d.ts +20 -2
  11. package/dist/approval.d.ts.map +1 -1
  12. package/dist/approval.js +26 -6
  13. package/dist/approval.js.map +1 -1
  14. package/dist/chain.d.ts +6 -1
  15. package/dist/chain.d.ts.map +1 -1
  16. package/dist/chain.js +1 -1
  17. package/dist/chain.js.map +1 -1
  18. package/dist/check-runner.d.ts +61 -0
  19. package/dist/check-runner.d.ts.map +1 -0
  20. package/dist/check-runner.js +237 -0
  21. package/dist/check-runner.js.map +1 -0
  22. package/dist/correlation.d.ts +90 -0
  23. package/dist/correlation.d.ts.map +1 -0
  24. package/dist/correlation.js +183 -0
  25. package/dist/correlation.js.map +1 -0
  26. package/dist/delegate-types.d.ts +140 -0
  27. package/dist/delegate-types.d.ts.map +1 -0
  28. package/dist/delegate-types.js +8 -0
  29. package/dist/delegate-types.js.map +1 -0
  30. package/dist/delegate.d.ts +4 -128
  31. package/dist/delegate.d.ts.map +1 -1
  32. package/dist/delegate.js +68 -72
  33. package/dist/delegate.js.map +1 -1
  34. package/dist/delegation-approval.d.ts +36 -0
  35. package/dist/delegation-approval.d.ts.map +1 -0
  36. package/dist/delegation-approval.js +67 -0
  37. package/dist/delegation-approval.js.map +1 -0
  38. package/dist/git-identity.d.ts +13 -0
  39. package/dist/git-identity.d.ts.map +1 -0
  40. package/dist/git-identity.js +44 -0
  41. package/dist/git-identity.js.map +1 -0
  42. package/dist/index.d.ts +5 -1
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +5 -1
  45. package/dist/index.js.map +1 -1
  46. package/dist/lease-record.d.ts +78 -0
  47. package/dist/lease-record.d.ts.map +1 -0
  48. package/dist/lease-record.js +52 -0
  49. package/dist/lease-record.js.map +1 -0
  50. package/dist/ledger-events.d.ts +87 -0
  51. package/dist/ledger-events.d.ts.map +1 -0
  52. package/dist/ledger-events.js +45 -0
  53. package/dist/ledger-events.js.map +1 -0
  54. package/dist/ledger-report.d.ts +19 -12
  55. package/dist/ledger-report.d.ts.map +1 -1
  56. package/dist/ledger-report.js +81 -1
  57. package/dist/ledger-report.js.map +1 -1
  58. package/dist/ledger.d.ts +44 -1
  59. package/dist/ledger.d.ts.map +1 -1
  60. package/dist/ledger.js +20 -2
  61. package/dist/ledger.js.map +1 -1
  62. package/dist/refusals.d.ts +16 -0
  63. package/dist/refusals.d.ts.map +1 -0
  64. package/dist/refusals.js +51 -0
  65. package/dist/refusals.js.map +1 -0
  66. package/dist/run-child.d.ts +4 -0
  67. package/dist/run-child.d.ts.map +1 -1
  68. package/dist/run-child.js +21 -1
  69. package/dist/run-child.js.map +1 -1
  70. package/dist/run-herdr.d.ts +7 -0
  71. package/dist/run-herdr.d.ts.map +1 -1
  72. package/dist/run-herdr.js +41 -12
  73. package/dist/run-herdr.js.map +1 -1
  74. package/dist/workspace-lease.d.ts +28 -0
  75. package/dist/workspace-lease.d.ts.map +1 -0
  76. package/dist/workspace-lease.js +276 -0
  77. package/dist/workspace-lease.js.map +1 -0
  78. package/dist/workspace.d.ts +32 -0
  79. package/dist/workspace.d.ts.map +1 -0
  80. package/dist/workspace.js +79 -0
  81. package/dist/workspace.js.map +1 -0
  82. package/extensions/approval-banking.ts +66 -0
  83. package/extensions/approvals.ts +95 -7
  84. package/extensions/chain-approval-facts.ts +51 -0
  85. package/extensions/chain-ledger.ts +48 -0
  86. package/extensions/delegate-chain.ts +115 -81
  87. package/extensions/delegation.ts +81 -23
  88. package/extensions/execute-child.ts +289 -0
  89. package/extensions/fanout-outcome.ts +97 -0
  90. package/extensions/run-delegation.ts +130 -120
  91. package/extensions/session.ts +4 -0
  92. package/extensions/workspace-runtime.ts +165 -0
  93. package/package.json +17 -1
  94. package/src/approval-prompt.ts +4 -2
  95. package/src/approval-store.ts +4 -1
  96. package/src/approval.ts +47 -14
  97. package/src/chain.ts +3 -1
  98. package/src/check-runner.ts +341 -0
  99. package/src/correlation.ts +260 -0
  100. package/src/delegate-types.ts +144 -0
  101. package/src/delegate.ts +80 -179
  102. package/src/delegation-approval.ts +99 -0
  103. package/src/git-identity.ts +52 -0
  104. package/src/index.ts +50 -0
  105. package/src/lease-record.ts +119 -0
  106. package/src/ledger-events.ts +138 -0
  107. package/src/ledger-report.ts +90 -2
  108. package/src/ledger.ts +68 -3
  109. package/src/refusals.ts +66 -0
  110. package/src/run-child.ts +23 -1
  111. package/src/run-herdr.ts +41 -11
  112. package/src/workspace-lease.ts +303 -0
  113. package/src/workspace.ts +135 -0
package/src/approval.ts CHANGED
@@ -11,6 +11,7 @@
11
11
 
12
12
  import { WILDCARD } from "./pi-tools.ts";
13
13
  import type { Capability, ResolveResult } from "./resolve.ts";
14
+ import { approvalBindingsEqual, type ApprovalBinding } from "./correlation.ts";
14
15
 
15
16
  /** How far a single yes reaches in time. */
16
17
  export type ApprovalScope = "once" | "session" | "always";
@@ -110,6 +111,11 @@ export interface InheritableApproval {
110
111
  * no file to hash — and, per ADR-0019, is never persisted or offered `always` for the same reason.
111
112
  */
112
113
  bodySha256?: string;
114
+ /**
115
+ * Exact delegation binding for controllers that supply correlation/workspace context (ADR-0034).
116
+ * Bound approvals are deliberately not inherited: authority for one task must not become subtree-wide.
117
+ */
118
+ binding?: ApprovalBinding;
113
119
  }
114
120
 
115
121
  /**
@@ -133,7 +139,7 @@ export function inheritApprovals(approved: InheritableApproval[], grant: Capabil
133
139
  return [
134
140
  ...new Set(
135
141
  approved
136
- .filter((a) => a.scope !== "once" && a.capability !== WILDCARD && held.has(a.capability))
142
+ .filter((a) => a.scope !== "once" && a.binding === undefined && a.capability !== WILDCARD && held.has(a.capability))
137
143
  // A definition subject MUST carry a pin to cross a boundary (ADR-0022, hardened after F1).
138
144
  //
139
145
  // `verifyInherited` honours an unpinned entry by decision — `<delegate>` names no file and a
@@ -234,6 +240,8 @@ export interface ApprovalEntry {
234
240
  * predates 0.10.0 and cannot be verified, which `entryVerdict` treats as changed: fail closed.
235
241
  */
236
242
  bodyAtApproval?: string;
243
+ /** Exact task/grant/workspace/context/parent binding for correlated controller calls (ADR-0034). */
244
+ binding?: ApprovalBinding;
237
245
  }
238
246
 
239
247
  export type EntryVerdict =
@@ -310,9 +318,14 @@ export interface ResolveApprovalsInput {
310
318
  subject: string;
311
319
  /** Approval KEYS approved for this session, in memory only. */
312
320
  sessionApprovals: ReadonlySet<string>;
313
- /** Persisted entries by key, ALREADY validity-filtered by the store. */
321
+ /** Exact bindings for correlated session approvals. Kept separate so legacy keys remain compatible. */
322
+ sessionApprovalBindings?: ReadonlyMap<string, ApprovalBinding>;
323
+ /** Persisted entries by key, ALREADY definition/expiry-filtered by the store. */
314
324
  persisted: ReadonlyMap<string, ApprovalEntry>;
315
- /** Capabilities approved further up the tree and inherited with the grant. */
325
+ /** When present, only an exact binding may satisfy this gate. */
326
+ expectedBinding?: ApprovalBinding;
327
+ /** Same-key persisted entries dropped specifically because their expiry passed. */
328
+ expiredKeys?: ReadonlySet<string>;
316
329
  /**
317
330
  * `capability@subject` keys inherited from the delegator (ADR-0014).
318
331
  *
@@ -326,7 +339,12 @@ export interface ResolveApprovalsResult {
326
339
  approved: Capability[];
327
340
  /** Gated capabilities still requiring a live human. */
328
341
  needsPrompt: Capability[];
342
+ /** A same-key approval existed but named another task/workspace/context/parent. */
343
+ scopeMismatched: Capability[];
344
+ expired: Capability[];
329
345
  sources: Record<Capability, ApprovalSource>;
346
+ scopes: Record<Capability, ApprovalScope>;
347
+ expiresAt: Record<Capability, string>;
330
348
  }
331
349
 
332
350
  /**
@@ -342,23 +360,38 @@ export function resolveApprovals(input: ResolveApprovalsInput): ResolveApprovals
342
360
  const inherited = input.inherited ?? new Set<string>();
343
361
  const approved: Capability[] = [];
344
362
  const needsPrompt: Capability[] = [];
345
- const sources: Record<Capability, ApprovalSource> = {};
363
+ const scopeMismatched: Capability[] = [];
364
+ const expired: Capability[] = [];
365
+ const sources: Record<Capability, ApprovalSource> = {}, scopes: Record<Capability, ApprovalScope> = {};
366
+ const expiresAt: Record<Capability, string> = {};
346
367
 
347
368
  for (const capability of [...new Set(input.gated)].sort()) {
348
369
  const key = approvalKey(capability, input.subject);
349
- if (inherited.has(key)) {
350
- approved.push(capability);
351
- sources[capability] = "inherited";
352
- } else if (input.sessionApprovals.has(key)) {
353
- approved.push(capability);
354
- sources[capability] = "session";
355
- } else if (input.persisted.has(key)) {
356
- approved.push(capability);
357
- sources[capability] = "persisted";
370
+ const expected = input.expectedBinding;
371
+ const sessionBinding = input.sessionApprovalBindings?.get(key);
372
+ const persisted = input.persisted.get(key);
373
+ const inheritedApplies = expected === undefined && inherited.has(key);
374
+ const legacySessionApplies = expected === undefined && input.sessionApprovals.has(key);
375
+ const boundSessionApplies = expected !== undefined && approvalBindingsEqual(sessionBinding, expected);
376
+ const persistedApplies = persisted !== undefined && (
377
+ expected === undefined ? persisted.binding === undefined : approvalBindingsEqual(persisted.binding, expected)
378
+ );
379
+
380
+ if (inheritedApplies) {
381
+ approved.push(capability); sources[capability] = "inherited"; scopes[capability] = "session";
382
+ } else if (legacySessionApplies || boundSessionApplies) {
383
+ approved.push(capability); sources[capability] = "session"; scopes[capability] = "session";
384
+ } else if (persistedApplies) {
385
+ approved.push(capability); sources[capability] = "persisted"; scopes[capability] = "always";
386
+ expiresAt[capability] = persisted!.expiresAt;
358
387
  } else {
359
388
  needsPrompt.push(capability);
389
+ if (input.expiredKeys?.has(key)) expired.push(capability);
390
+ else if (expected !== undefined && (sessionBinding !== undefined || persisted !== undefined || input.sessionApprovals.has(key))) {
391
+ scopeMismatched.push(capability);
392
+ }
360
393
  }
361
394
  }
362
395
 
363
- return { approved, needsPrompt, sources };
396
+ return { approved, needsPrompt, scopeMismatched, expired, sources, scopes, expiresAt };
364
397
  }
package/src/chain.ts CHANGED
@@ -29,7 +29,7 @@ import { randomBytes } from "node:crypto";
29
29
  * How much of a step's output crosses to the next step.
30
30
  *
31
31
  * A child may return up to `DEFAULT_MAX_OUTPUT_BYTES` (1 MiB); pasting that into a task would spend most of the
32
- * next child's context on its predecessor's transcript. 64 KiB is a generous summary and a poor transcript, which
32
+ * next child's context on its predecessor's transcript. 32 KiB is a generous summary and a poor transcript, which
33
33
  * is the right side of that line for a handoff.
34
34
  */
35
35
  export const HANDOFF_MAX_BYTES = 32 * 1024;
@@ -156,6 +156,8 @@ export interface ChainStep {
156
156
  agent?: string;
157
157
  tools?: string[];
158
158
  model?: string;
159
+ correlation?: import("./correlation.ts").CorrelationMetadata;
160
+ workspace?: { workspace_id: string; access: import("./workspace.ts").WorkspaceAccess };
159
161
  }
160
162
 
161
163
  /**
@@ -0,0 +1,341 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { chmod, mkdtemp, readFile, realpath, rm, stat, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { basename, isAbsolute, join } from "node:path";
5
+ import { normaliseCorrelation, type CorrelationMetadata } from "./correlation.ts";
6
+ import {
7
+ appendLedgerEvent,
8
+ buildWorkspaceLeaseEvent,
9
+ type CheckReceiptLedgerEvent,
10
+ LEDGER_VERSION,
11
+ } from "./ledger.ts";
12
+ import { computeGitCandidateIdentity } from "./git-identity.ts";
13
+ import { runChild } from "./run-child.ts";
14
+ import { GovernanceRefusal, refusal } from "./refusals.ts";
15
+ import {
16
+ acquireWorkspaceLease,
17
+ defaultWorkspaceLeaseDir,
18
+ type ValidatedWorkspace,
19
+ type WorkspaceAccess,
20
+ leaseAcquisitionOutcome,
21
+ leaseReleaseLedgerOutcome,
22
+ } from "./workspace.ts";
23
+
24
+ export interface CheckDefinition {
25
+ /** Absolute operator-owned executable. PATH lookup is deliberately not part of the contract. */
26
+ executable: string;
27
+ /** Exact argv elements. Never a shell command string. */
28
+ argv: string[];
29
+ /** Fixed non-sensitive environment values. */
30
+ env?: Record<string, string>;
31
+ /** Explicit inherited names. Sensitive names are still removed. */
32
+ inherit_env?: string[];
33
+ timeout_ms?: number;
34
+ max_output_bytes?: number;
35
+ /** Checks are assumed capable of writing unless the operator declares read. */
36
+ workspace_access?: WorkspaceAccess;
37
+ }
38
+
39
+ export interface CheckRegistry {
40
+ version: 1;
41
+ checks: Record<string, CheckDefinition>;
42
+ }
43
+
44
+ export interface CheckReceipt {
45
+ schema_version: "1.0";
46
+ receipt_id: string;
47
+ check_id: string;
48
+ executable: string;
49
+ executable_sha256: string;
50
+ argv: string[];
51
+ argv_sha256: string;
52
+ cwd: string;
53
+ cwd_sha256: string;
54
+ started_at: string;
55
+ ended_at: string;
56
+ exit_code: number | null;
57
+ signal: NodeJS.Signals | null;
58
+ timed_out: boolean;
59
+ aborted: boolean;
60
+ truncated: boolean;
61
+ output_sha256: string;
62
+ workspace_id: string;
63
+ workspace_access: WorkspaceAccess;
64
+ head_sha: string;
65
+ tree_sha: string;
66
+ correlation?: CorrelationMetadata;
67
+ }
68
+
69
+ const SAFE_ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
70
+ const SENSITIVE_ENV_NAME = /(?:^|_)(?:TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|AUTH|COOKIE|PRIVATE_KEY|API_KEY)(?:_|$)|^PI_GRANTS_|^(?:AWS|GITHUB|GITLAB|AZURE|GOOGLE)_/i;
71
+
72
+ export function buildCheckEnvironment(
73
+ definition: CheckDefinition,
74
+ inherited: NodeJS.ProcessEnv = process.env,
75
+ ): NodeJS.ProcessEnv {
76
+ const env: NodeJS.ProcessEnv = {};
77
+ for (const name of definition.inherit_env ?? []) {
78
+ if (!SAFE_ENV_NAME.test(name) || SENSITIVE_ENV_NAME.test(name)) continue;
79
+ if (inherited[name] !== undefined) env[name] = inherited[name];
80
+ }
81
+ for (const [name, value] of Object.entries(definition.env ?? {})) {
82
+ if (!SAFE_ENV_NAME.test(name) || SENSITIVE_ENV_NAME.test(name)) continue;
83
+ env[name] = value;
84
+ }
85
+ return env;
86
+ }
87
+
88
+ function validateDefinition(checkId: string, value: CheckDefinition | undefined): CheckDefinition {
89
+ if (!value) {
90
+ throw new GovernanceRefusal(refusal(
91
+ "CHECK_NOT_CONFIGURED", `check ${JSON.stringify(checkId)} is not present in operator-owned configuration`, { check_id: checkId },
92
+ ));
93
+ }
94
+ if (!isAbsolute(value.executable) || !Array.isArray(value.argv) || value.argv.some((item) => typeof item !== "string")) {
95
+ throw new GovernanceRefusal(refusal(
96
+ "CHECK_CONFIGURATION_INVALID", `check ${JSON.stringify(checkId)} requires an absolute executable and argv string array`,
97
+ { check_id: checkId },
98
+ ));
99
+ }
100
+ for (const bound of [value.timeout_ms, value.max_output_bytes]) {
101
+ if (bound !== undefined && (!Number.isInteger(bound) || bound <= 0)) {
102
+ throw new GovernanceRefusal(refusal(
103
+ "CHECK_CONFIGURATION_INVALID", `check ${JSON.stringify(checkId)} has a non-positive runtime bound`, { check_id: checkId },
104
+ ));
105
+ }
106
+ }
107
+ return value;
108
+ }
109
+
110
+ function receiptId(receipt: Omit<CheckReceipt, "receipt_id">): string {
111
+ return createHash("sha256").update(JSON.stringify(receipt), "utf8").digest("hex");
112
+ }
113
+
114
+ export async function runNamedCheck(input: {
115
+ checkId: string;
116
+ registry: CheckRegistry;
117
+ workspace: ValidatedWorkspace;
118
+ correlation?: CorrelationMetadata;
119
+ inheritedEnv?: NodeJS.ProcessEnv;
120
+ signal?: AbortSignal;
121
+ leaseDir?: string;
122
+ ledgerPath?: string;
123
+ }): Promise<{
124
+ output: string;
125
+ exitCode: number | null;
126
+ signal: NodeJS.Signals | null;
127
+ receipt: CheckReceipt;
128
+ }> {
129
+ if (input.registry?.version !== 1 || !input.registry.checks || typeof input.registry.checks !== "object") {
130
+ throw new GovernanceRefusal(refusal("CHECK_CONFIGURATION_INVALID", "check registry must contain {version:1, checks:{...}}"));
131
+ }
132
+ const definition = validateDefinition(input.checkId, input.registry.checks[input.checkId]);
133
+ let executable: string;
134
+ try {
135
+ executable = await realpath(definition.executable);
136
+ if (!(await stat(executable)).isFile()) throw new Error("not a file");
137
+ } catch (error) {
138
+ throw new GovernanceRefusal(refusal(
139
+ "CHECK_CONFIGURATION_INVALID", `check ${JSON.stringify(input.checkId)} executable is not a real file (${String(error)})`,
140
+ { check_id: input.checkId },
141
+ ));
142
+ }
143
+
144
+ const argvSha256 = createHash("sha256").update(JSON.stringify(definition.argv), "utf8").digest("hex");
145
+ const cwdSha256 = createHash("sha256").update(input.workspace.root, "utf8").digest("hex");
146
+ const access = definition.workspace_access ?? "write";
147
+ // Evidence checks coordinate exclusively so their pre/post candidate identities cannot overlap another
148
+ // governed writer. `access` still records what the configured executable is intended to do.
149
+ const leaseAccess: WorkspaceAccess = "write";
150
+ const ownerId = `check:${input.checkId}:${randomUUID()}`;
151
+ const correlation = normaliseCorrelation(input.correlation);
152
+ let lease: Awaited<ReturnType<typeof acquireWorkspaceLease>> | undefined;
153
+ let stagedDir: string | undefined;
154
+ /** Failures of best-effort RECORDS, reported alongside whatever actually happened — never instead of it. */
155
+ const notes: string[] = [];
156
+ let releaseReason = "failed";
157
+ try {
158
+ try {
159
+ lease = await acquireWorkspaceLease({
160
+ workspace: input.workspace, access: leaseAccess, leaseDir: input.leaseDir ?? defaultWorkspaceLeaseDir(),
161
+ ownerId, signal: input.signal,
162
+ });
163
+ } catch (error) {
164
+ if (input.ledgerPath) await appendLedgerEvent(
165
+ { path: input.ledgerPath, strict: true },
166
+ buildWorkspaceLeaseEvent({
167
+ childId: ownerId, workspaceId: input.workspace.workspaceId, root: input.workspace.root, access: leaseAccess,
168
+ outcome: "refused",
169
+ correlation, now: new Date(),
170
+ ...(error instanceof GovernanceRefusal
171
+ ? { refusal: { code: error.code, message: error.message, ...(error.details ? { details: error.details } : {}) } }
172
+ : {}),
173
+ }),
174
+ );
175
+ throw error;
176
+ }
177
+ if (input.ledgerPath) {
178
+ try {
179
+ await appendLedgerEvent(
180
+ { path: input.ledgerPath, strict: true },
181
+ buildWorkspaceLeaseEvent({
182
+ childId: ownerId, workspaceId: input.workspace.workspaceId, root: input.workspace.root, access: leaseAccess,
183
+ outcome: leaseAcquisitionOutcome(leaseAccess, lease.recovered), recovered: lease.recovered,
184
+ correlation, now: new Date(),
185
+ }),
186
+ );
187
+ } catch (error) {
188
+ await lease.release("ledger-failed");
189
+ lease = undefined;
190
+ throw error;
191
+ }
192
+ }
193
+
194
+ const before = await computeGitCandidateIdentity(input.workspace);
195
+ for (const [name, supplied, actual] of [
196
+ ["head_sha", correlation?.head_sha, before.headSha], ["tree_sha", correlation?.tree_sha, before.treeSha],
197
+ ] as const) {
198
+ if (supplied && supplied !== actual) throw new GovernanceRefusal(refusal(
199
+ "CHECK_IDENTITY_MISMATCH", `check ${JSON.stringify(input.checkId)} ${name} does not match the validated workspace`,
200
+ { check_id: input.checkId, supplied, actual },
201
+ ));
202
+ }
203
+ const executableBytes = await readFile(executable);
204
+ const executableSha256 = createHash("sha256").update(executableBytes).digest("hex");
205
+ stagedDir = await mkdtemp(join(tmpdir(), "pi-daddy-check-exec-"));
206
+ const stagedExecutable = join(stagedDir, basename(executable));
207
+ await writeFile(stagedExecutable, executableBytes, { mode: 0o700 });
208
+ await chmod(stagedExecutable, 0o700);
209
+ const leaseAbort = new AbortController();
210
+ let leaseLost = false;
211
+ lease.lost.then(() => { leaseLost = true; leaseAbort.abort(); });
212
+ const checkSignal = input.signal ? AbortSignal.any([input.signal, leaseAbort.signal]) : leaseAbort.signal;
213
+ const started = new Date();
214
+ const result = await runChild({
215
+ command: "setpriv", args: ["--pdeathsig", "KILL", "--", stagedExecutable, ...definition.argv],
216
+ env: buildCheckEnvironment(definition, input.inheritedEnv), cwd: input.workspace.root,
217
+ signal: checkSignal, timeoutMs: definition.timeout_ms, maxOutputBytes: definition.max_output_bytes,
218
+ onSpawn: (pid) => lease!.attachProcess(pid),
219
+ });
220
+ const ended = new Date();
221
+ releaseReason = input.signal?.aborted ? "cancelled" : result.timedOut ? "timeout" : result.aborted ? "failed" : "completed";
222
+ // Every throw below re-stamps this. Without that, a check refused for CHECK_IDENTITY_MISMATCH still
223
+ // wrote `released / completed` from the `finally` — the LAST lease event for that owner — so the trail
224
+ // still read like a clean run and `verifyLedger` still counted a clean release. The R-114 fix added a
225
+ // refusal line beside the misleading one instead of correcting it.
226
+ if (result.spawnError) throw new GovernanceRefusal(refusal(
227
+ "EXECUTOR_UNAVAILABLE", `check ${JSON.stringify(input.checkId)} could not start its constrained executor`,
228
+ { check_id: input.checkId },
229
+ ));
230
+ if (leaseLost) throw new GovernanceRefusal(refusal(
231
+ "WORKSPACE_LEASE_STALE", `check ${JSON.stringify(input.checkId)} lost its workspace lease while running`,
232
+ { check_id: input.checkId },
233
+ ));
234
+ const after = await computeGitCandidateIdentity(input.workspace);
235
+ if (leaseLost) throw new GovernanceRefusal(refusal(
236
+ "WORKSPACE_LEASE_STALE", `check ${JSON.stringify(input.checkId)} lost its workspace lease before receipt`,
237
+ { check_id: input.checkId },
238
+ ));
239
+ if (before.headSha !== after.headSha || before.treeSha !== after.treeSha) {
240
+ throw new GovernanceRefusal(refusal(
241
+ "CHECK_IDENTITY_MISMATCH", `check ${JSON.stringify(input.checkId)} workspace changed while it ran`,
242
+ { check_id: input.checkId },
243
+ ));
244
+ }
245
+ // The candidate is now measured and the process is dead. End the lease before writing the receipt so
246
+ // lease loss cannot race a blocked ledger append and leave behind evidence from an unprotected run.
247
+ // Record what release actually DID. This asserted `released` unconditionally and threw the outcome
248
+ // away, so the evidence path reproduced the very defect the outcome union was added to expose: a lost
249
+ // or unrecorded handover ledgered as a clean one, with `lease = undefined` stopping the `finally` from
250
+ // ever noticing (R-100/R-103, found while reviewing the fix for R-99).
251
+ const releaseOutcome = await lease.release(releaseReason);
252
+ lease = undefined;
253
+ if (leaseLost) throw new GovernanceRefusal(refusal(
254
+ "WORKSPACE_LEASE_STALE", `check ${JSON.stringify(input.checkId)} lost its workspace lease before receipt`,
255
+ { check_id: input.checkId },
256
+ ));
257
+ if (input.ledgerPath) await appendLedgerEvent(
258
+ { path: input.ledgerPath, strict: true },
259
+ buildWorkspaceLeaseEvent({
260
+ childId: ownerId, workspaceId: input.workspace.workspaceId, root: input.workspace.root, access: leaseAccess,
261
+ outcome: leaseReleaseLedgerOutcome(releaseOutcome, releaseReason), releaseReason, correlation, now: new Date(),
262
+ }),
263
+ );
264
+ const body: Omit<CheckReceipt, "receipt_id"> = {
265
+ schema_version: "1.0", check_id: input.checkId, executable, executable_sha256: executableSha256,
266
+ argv: [...definition.argv], argv_sha256: argvSha256, cwd: input.workspace.root, cwd_sha256: cwdSha256,
267
+ started_at: started.toISOString(), ended_at: ended.toISOString(), exit_code: result.code,
268
+ signal: result.signal ?? null, timed_out: result.timedOut, aborted: result.aborted, truncated: result.truncated,
269
+ output_sha256: createHash("sha256").update(result.text, "utf8").digest("hex"),
270
+ workspace_id: input.workspace.workspaceId, workspace_access: access,
271
+ head_sha: before.headSha, tree_sha: before.treeSha, ...(correlation ? { correlation } : {}),
272
+ };
273
+ const receipt: CheckReceipt = { receipt_id: receiptId(body), ...body };
274
+ if (input.ledgerPath) {
275
+ const event: CheckReceiptLedgerEvent = {
276
+ ledgerVersion: LEDGER_VERSION, event: "check_receipt", ts: ended.toISOString(), childId: ownerId,
277
+ receiptId: receipt.receipt_id, workspaceId: input.workspace.workspaceId, checkId: input.checkId,
278
+ treeSha: receipt.tree_sha, ...(correlation ? { correlation } : {}),
279
+ };
280
+ await appendLedgerEvent({ path: input.ledgerPath, strict: true }, event);
281
+ }
282
+ return { output: result.text, exitCode: result.code, signal: result.signal ?? null, receipt };
283
+ } catch (error) {
284
+ // The run did not complete, so the release must not be recorded as if it had. Without this the
285
+ // `finally` wrote `released / completed` as the LAST lease event for a refused check, so the trail
286
+ // still read like a clean run and `verifyLedger` still counted a clean release — the R-114 fix put a
287
+ // refusal line beside the misleading one instead of correcting it.
288
+ if (releaseReason === "completed") releaseReason = "refused";
289
+ // The single most important negative result this feature can produce — CHECK_IDENTITY_MISMATCH, "the
290
+ // workspace changed while the check ran" — used to leave NO ledger evidence at all, while the
291
+ // `finally` recorded `released, reason: completed`, so the trail read like a clean run (R-114).
292
+ if (input.ledgerPath) {
293
+ await appendLedgerEvent(
294
+ {
295
+ path: input.ledgerPath,
296
+ // Best effort, and loud: this records a failure, so it must not replace the failure. A strict
297
+ // append that threw here would substitute "ledger write failed" for the evidence-integrity
298
+ // refusal that actually happened (R-115).
299
+ strict: false,
300
+ // NOT empty. `onFailure` exists because a non-strict append is a choice not to fail closed and
301
+ // never a choice to be silent — the comment above says "loud", and an empty callback is the
302
+ // definition of swallowing. This guards the evidence record itself, so losing it silently is
303
+ // how a mutated workspace came to leave a trail that read like a clean run.
304
+ onFailure: (cause) => { notes.push(`check refusal record failed: ${String(cause)}`); },
305
+ },
306
+ buildWorkspaceLeaseEvent({
307
+ childId: ownerId, workspaceId: input.workspace.workspaceId, root: input.workspace.root, access: leaseAccess,
308
+ outcome: "refused",
309
+ refusal: error instanceof GovernanceRefusal
310
+ ? { code: error.code, message: error.message, ...(error.details ? { details: error.details } : {}) }
311
+ : refusal("CHECK_IDENTITY_UNAVAILABLE", `check ${JSON.stringify(input.checkId)} failed (${String(error)})`),
312
+ correlation, now: new Date(),
313
+ }),
314
+ );
315
+ }
316
+ throw error;
317
+ } finally {
318
+ if (stagedDir) await rm(stagedDir, { recursive: true, force: true });
319
+ if (lease) {
320
+ // `release()` cannot throw (R-99), and the record of it is best-effort for the same reason as
321
+ // above: a `finally` that throws destroys whatever the caller was already returning or raising.
322
+ const outcome = await lease.release(releaseReason);
323
+ if (input.ledgerPath) await appendLedgerEvent(
324
+ {
325
+ path: input.ledgerPath,
326
+ strict: false,
327
+ onFailure: (cause) => { notes.push(`check lease release record failed: ${String(cause)}`); },
328
+ },
329
+ buildWorkspaceLeaseEvent({
330
+ childId: ownerId, workspaceId: input.workspace.workspaceId, root: input.workspace.root, access: leaseAccess,
331
+ outcome: outcome === "lost"
332
+ ? "lost"
333
+ : outcome === "released-unrecorded"
334
+ ? "released-unrecorded"
335
+ : releaseReason === "timeout" ? "timeout" : "released",
336
+ releaseReason, correlation, now: new Date(),
337
+ }),
338
+ );
339
+ }
340
+ }
341
+ }