pi-daddy 0.21.0 → 0.22.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 (73) hide show
  1. package/CHANGELOG.md +25 -1
  2. package/README.md +14 -9
  3. package/contracts/ledger/v3/fixtures/capability-decision.json +3 -4
  4. package/contracts/ledger/v3/fixtures/check-receipt.json +3 -4
  5. package/contracts/ledger/v3/fixtures/child-lifecycle.json +3 -4
  6. package/contracts/ledger/v3/fixtures/workflow-fact.json +3 -4
  7. package/contracts/ledger/v3/fixtures/workspace-lease.json +3 -4
  8. package/contracts/ledger/v3/ledger-event.schema.json +38 -13
  9. package/dist/check-runner.d.ts.map +1 -1
  10. package/dist/check-runner.js +161 -149
  11. package/dist/check-runner.js.map +1 -1
  12. package/dist/correlation.d.ts +15 -4
  13. package/dist/correlation.d.ts.map +1 -1
  14. package/dist/correlation.js +40 -2
  15. package/dist/correlation.js.map +1 -1
  16. package/dist/delegation-approval.d.ts +2 -2
  17. package/dist/delegation-approval.d.ts.map +1 -1
  18. package/dist/delegation-approval.js +2 -0
  19. package/dist/delegation-approval.js.map +1 -1
  20. package/dist/finalization.d.ts +15 -0
  21. package/dist/finalization.d.ts.map +1 -0
  22. package/dist/finalization.js +65 -0
  23. package/dist/finalization.js.map +1 -0
  24. package/dist/git-identity.d.ts.map +1 -1
  25. package/dist/git-identity.js +18 -15
  26. package/dist/git-identity.js.map +1 -1
  27. package/dist/grant-store.d.ts +13 -0
  28. package/dist/grant-store.d.ts.map +1 -1
  29. package/dist/grant-store.js +48 -25
  30. package/dist/grant-store.js.map +1 -1
  31. package/dist/herdr-poll.js +1 -1
  32. package/dist/herdr-poll.js.map +1 -1
  33. package/dist/init.d.ts.map +1 -1
  34. package/dist/init.js +2 -6
  35. package/dist/init.js.map +1 -1
  36. package/dist/model-preflight.d.ts +8 -0
  37. package/dist/model-preflight.d.ts.map +1 -0
  38. package/dist/model-preflight.js +20 -0
  39. package/dist/model-preflight.js.map +1 -0
  40. package/dist/refusals.d.ts +1 -1
  41. package/dist/refusals.d.ts.map +1 -1
  42. package/dist/refusals.js +2 -0
  43. package/dist/refusals.js.map +1 -1
  44. package/dist/run-child.d.ts +1 -1
  45. package/dist/run-child.d.ts.map +1 -1
  46. package/dist/run-child.js +2 -2
  47. package/dist/run-child.js.map +1 -1
  48. package/dist/run-herdr.d.ts.map +1 -1
  49. package/dist/run-herdr.js +6 -5
  50. package/dist/run-herdr.js.map +1 -1
  51. package/extensions/chain-plan.ts +16 -0
  52. package/extensions/correlation-shape.ts +39 -0
  53. package/extensions/delegate-chain.ts +9 -14
  54. package/extensions/delegation.ts +3 -25
  55. package/extensions/execute-child.ts +8 -1
  56. package/extensions/grant-store-refusal.ts +36 -0
  57. package/extensions/grants.ts +6 -0
  58. package/extensions/run-delegation.ts +21 -2
  59. package/extensions/session.ts +15 -5
  60. package/extensions/stored-grant-session.ts +33 -0
  61. package/package.json +3 -1
  62. package/src/check-runner.ts +56 -44
  63. package/src/correlation.ts +55 -6
  64. package/src/delegation-approval.ts +4 -2
  65. package/src/finalization.ts +79 -0
  66. package/src/git-identity.ts +20 -16
  67. package/src/grant-store.ts +55 -25
  68. package/src/herdr-poll.ts +1 -1
  69. package/src/init.ts +5 -5
  70. package/src/model-preflight.ts +32 -0
  71. package/src/refusals.ts +2 -0
  72. package/src/run-child.ts +2 -2
  73. package/src/run-herdr.ts +6 -4
@@ -40,6 +40,7 @@ import { planWithApprovals } from "./run-delegation.ts";
40
40
  import { createGrantsSession, loadProjectDefinitions, resolveExecutor, type GrantsSession } from "./session.ts";
41
41
  import { reportSessionStart } from "./session-report.ts";
42
42
  import { SPAWN_TOOLS, tripwireReason } from "./tripwire.ts";
43
+ import { reportGrantStoreRefusal } from "./grant-store-refusal.ts";
43
44
  import {
44
45
  defaultDashboardPaths,
45
46
  offerDashboardHandshake,
@@ -93,6 +94,11 @@ export default function (pi: ExtensionAPI) {
93
94
  );
94
95
  }
95
96
  try {
97
+ try {
98
+ await reportGrantStoreRefusal(session, ctx.ui);
99
+ } catch (error) {
100
+ ctx.ui.notify(`grants: stored-grant refusal reporting failed (${String(error)}); the session remains refused.`, "error");
101
+ }
96
102
  // Guarded together, and guarded at all because of R-60 rather than because either one throws today:
97
103
  // both loaders swallow their own filesystem errors, so this catch is currently unreachable. The point
98
104
  // is that "currently" is not a property anyone can rely on — `verifyLedger` was also harmless until
@@ -24,6 +24,7 @@ import {
24
24
  import type { InheritableApproval } from "../src/approval.ts";
25
25
  import type { GrantsSession } from "./session.ts";
26
26
  import type { CorrelationMetadata } from "../src/correlation.ts";
27
+ import { preflightModel, type ModelCatalogue } from "../src/model-preflight.ts";
27
28
  import { GovernanceRefusal, refusal as structuredRefusal } from "../src/refusals.ts";
28
29
  import { executePlannedChild, type DelegationOutcome } from "./execute-child.ts";
29
30
  import { recordDelegationDecision, type ApprovalLedgerFacts } from "./delegation-ledger.ts";
@@ -57,6 +58,7 @@ interface ChildSpec {
57
58
  interface DelegationToolContext extends ApprovalUIContext {
58
59
  cwd: string;
59
60
  model?: { provider: string; id: string };
61
+ modelRegistry: ModelCatalogue;
60
62
  }
61
63
 
62
64
  /** A planned delegation, plus whatever approvals contributed to it. */
@@ -274,11 +276,17 @@ export async function runOneDelegation(
274
276
  // requested and refused, and stored approvals still count toward it — nothing is *hidden*, only nobody is
275
277
  // *asked*. It is the same argument `/grants` uses for its preview.
276
278
  const executorRefusal = session.executor.refusal;
279
+ const modelRefusal = preflightModel(
280
+ request.model,
281
+ ctx.modelRegistry,
282
+ session.modelResolutionCache,
283
+ session.allowUnresolvedModels,
284
+ );
277
285
  let preparedWorkspace: PreparedWorkspace | undefined;
278
286
  let approvalOutcome: ApprovalOutcome | undefined;
279
287
  let plan: ReturnType<typeof planDelegation>;
280
288
 
281
- if (spec.workspace && !executorRefusal) {
289
+ if (spec.workspace && !executorRefusal && !modelRefusal) {
282
290
  // Check non-liftable refusals before taking a lease, and take the lease before asking a human. This
283
291
  // preserves both anti-race rules: a doomed spawn cannot bank approval, and a conflicting writer starts
284
292
  // no child process.
@@ -307,7 +315,14 @@ export async function runOneDelegation(
307
315
  }
308
316
  }
309
317
  } else {
310
- const gated = await planWithApprovals(session, request, extra, executorRefusal ? null : ctx, signal, preApproved);
318
+ const gated = await planWithApprovals(
319
+ session,
320
+ request,
321
+ extra,
322
+ executorRefusal || modelRefusal ? null : ctx,
323
+ signal,
324
+ preApproved,
325
+ );
311
326
  plan = gated.plan;
312
327
  approvalOutcome = gated.approval;
313
328
  }
@@ -315,6 +330,10 @@ export async function runOneDelegation(
315
330
  if (executorRefusal) {
316
331
  const message = `grants: ${executorRefusal}`;
317
332
  plan = { ...plan, ok: false, reason: message, refusal: structuredRefusal("EXECUTOR_UNAVAILABLE", message) };
333
+ } else if (modelRefusal) {
334
+ // This is a routing preflight, not a grant decision: preserve the resolved capability facts and replace
335
+ // only the outcome. It is still recorded by the common refusal path, and no lease, dialog or child starts.
336
+ plan = { ...plan, ok: false, reason: modelRefusal.message, refusal: modelRefusal };
318
337
  }
319
338
 
320
339
  // The capability decision provisions the child, so this append remains load-bearing and fails closed.
@@ -44,8 +44,10 @@ import type { Capability } from "../src/resolve.ts";
44
44
  import { loadDefinitions } from "../src/definitions.ts";
45
45
  import { buildCatalog } from "../src/catalog.ts";
46
46
  import { ENV_WORKSPACE_REGISTRY } from "../src/workspace.ts";
47
- import { grantStorePath, loadStoredGrantSync, projectLedgerPath } from "../src/grant-store.ts";
47
+ import type { GrantStoreRefusalReason } from "../src/grant-store.ts";
48
48
  import { republishable } from "./approvals.ts";
49
+ import { storedGrantSessionState } from "./stored-grant-session.ts";
50
+ import { ENV_ALLOW_UNRESOLVED_MODELS } from "../src/model-preflight.ts";
49
51
 
50
52
  /**
51
53
  * Run governed children in herdr panes instead of captured child processes.
@@ -118,6 +120,10 @@ export interface GrantsSession {
118
120
  readonly fanoutBudget: number;
119
121
  /** Whether `delegate` / `delegate_all` are registered at all (S-5). Decided on the INHERITED grant. */
120
122
  readonly mayDelegate: boolean;
123
+ /** Operator escape hatch for custom model resolution. Exact `1`, read once for the session. */
124
+ readonly allowUnresolvedModels: boolean;
125
+ /** Results from pi's synchronous model catalogue, shared by every delegation in this session. */
126
+ readonly modelResolutionCache: Map<string, boolean>;
121
127
  /** Path to this extension, so a child granted `tool:delegate` can delegate in turn. */
122
128
  readonly extensionPath?: string;
123
129
 
@@ -183,6 +189,8 @@ export interface GrantsSession {
183
189
  * `createGrantsSession` for why the factory cannot use `ctx.cwd`.
184
190
  */
185
191
  readonly storeCwd: string;
192
+ /** Invalid stored state fails closed and is reported/ledgered during session_start. */
193
+ readonly grantStoreRefusal?: { reason: GrantStoreRefusalReason; path: string };
186
194
  /**
187
195
  * Adopt the project choice made DURING the session — grant plus optional default ledger — without restart.
188
196
  *
@@ -260,14 +268,13 @@ export function createGrantsSession(extensionPath: string | undefined): GrantsSe
260
268
  const storeCwd = process.cwd();
261
269
  // One root-only store read supplies both decisions made by `/grants init`. A child always has ENV_GRANT,
262
270
  // so it cannot activate a ledger merely because its routed cwd happens to have a v2 store (ADR-0037).
263
- const stored = grantRaw === undefined ? loadStoredGrantSync(storeCwd) : null;
264
- const governed = grantRaw !== undefined || stored !== null;
265
- const inherited: Capability[] = grantRaw !== undefined ? parseList(grantRaw) : (stored?.grant ?? [WILDCARD]);
271
+ const storedState = storedGrantSessionState(grantRaw, storeCwd);
272
+ const { governed, inherited, refusal: grantStoreRefusal } = storedState;
266
273
  const ledgerRaw = process.env[ENV_LEDGER];
267
274
  // Capture provenance before publishChildEnv writes this session's derived default into process.env. A later
268
275
  // `/grants init` for ctx.cwd must not mistake our own publication for an operator override.
269
276
  const ledgerFromEnvironment = ledgerRaw !== undefined;
270
- const storedLedger = stored?.projectLedger ? projectLedgerPath(storeCwd) : undefined;
277
+ const storedLedger = storedState.defaultLedger;
271
278
  // G7 / A-S4 + B-I4: strict, three-way parsing that fails CLOSED. A malformed bound used to yield
272
279
  // `NaN`, and every comparison against `NaN` is false, so depth limiting switched itself off.
273
280
  const bounds = depthConfig(process.env[ENV_DEPTH], process.env[ENV_MAX_DEPTH]);
@@ -310,6 +317,8 @@ export function createGrantsSession(extensionPath: string | undefined): GrantsSe
310
317
  * before any tools are observed. An ungoverned session registers it as before.
311
318
  */
312
319
  mayDelegate: !governed || inherited.includes(DELEGATE_CAPABILITY) || inherited.includes(WILDCARD),
320
+ allowUnresolvedModels: process.env[ENV_ALLOW_UNRESOLVED_MODELS] === "1",
321
+ modelResolutionCache: new Map<string, boolean>(),
313
322
  extensionPath,
314
323
 
315
324
  sessionApprovals: new Set<string>(),
@@ -350,6 +359,7 @@ export function createGrantsSession(extensionPath: string | undefined): GrantsSe
350
359
  }),
351
360
 
352
361
  storeCwd,
362
+ ...(grantStoreRefusal ? { grantStoreRefusal } : {}),
353
363
 
354
364
  adoptGrant: (grant: Capability[], projectLedger?: string) => {
355
365
  // Governed too, not just bounded. A session that starts with no grant and then runs `/grants init` is
@@ -0,0 +1,33 @@
1
+ import {
2
+ grantStorePath,
3
+ loadStoredGrantStateSync,
4
+ projectLedgerPath,
5
+ type GrantStoreRefusalReason,
6
+ type StoredGrant,
7
+ } from "../src/grant-store.ts";
8
+ import { WILDCARD } from "../src/pi-tools.ts";
9
+ import { parseList } from "../src/propagation.ts";
10
+ import type { Capability } from "../src/resolve.ts";
11
+
12
+ export interface StoredGrantSessionState {
13
+ stored?: StoredGrant;
14
+ refusal?: { reason: GrantStoreRefusalReason; path: string };
15
+ governed: boolean;
16
+ inherited: Capability[];
17
+ defaultLedger?: string;
18
+ }
19
+
20
+ /** Resolve the root-only project store while preserving the environment's absolute precedence. */
21
+ export function storedGrantSessionState(grantRaw: string | undefined, cwd: string): StoredGrantSessionState {
22
+ const state = grantRaw === undefined ? loadStoredGrantStateSync(cwd) : { state: "absent" as const };
23
+ const stored = state.state === "valid" ? state.stored : undefined;
24
+ const refusal = state.state === "refuse" ? { reason: state.reason, path: grantStorePath(cwd) } : undefined;
25
+ return {
26
+ ...(stored ? { stored } : {}),
27
+ ...(refusal ? { refusal } : {}),
28
+ governed: grantRaw !== undefined || stored !== undefined || refusal !== undefined,
29
+ inherited: grantRaw !== undefined ? parseList(grantRaw) : stored?.grant ?? (refusal ? [] : [WILDCARD]),
30
+ // Invalid state authorises only a refusal line at this conventional path; no child can start from it.
31
+ defaultLedger: stored?.projectLedger || refusal ? projectLedgerPath(cwd) : undefined,
32
+ };
33
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-daddy",
3
- "version": "0.21.0",
3
+ "version": "0.22.0",
4
4
  "description": "Capability governance for pi sub-agents: spawn Agent Skills (SKILL.md) definitions whose allowed-tools becomes a grant that can only narrow going down a delegation tree, enforced by pi's own --tools allowlist, with an append-only ledger.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -161,6 +161,7 @@
161
161
  "typecheck": "tsc -p tsconfig.check.json",
162
162
  "test": "node --test test/*.test.ts",
163
163
  "test:integration": "node --test test-integration/*.it.ts",
164
+ "test:integration:ci": "node --test test-integration/approval.it.ts test-integration/governance.it.ts",
164
165
  "contracts:generate": "node scripts/generate-ledger-v3-contract.ts",
165
166
  "prepack": "npm run build",
166
167
  "test:smoke": "node scripts/smoke-installed.mjs",
@@ -175,6 +176,7 @@
175
176
  },
176
177
  "peerDependencies": {
177
178
  "@earendil-works/pi-coding-agent": ">=0.83.0",
179
+ "@types/node": ">=22",
178
180
  "typebox": ">=1.0.0"
179
181
  },
180
182
  "devDependencies": {
@@ -3,6 +3,7 @@ import { chmod, mkdtemp, readFile, realpath, rm, stat, writeFile } from "node:fs
3
3
  import { tmpdir } from "node:os";
4
4
  import { basename, isAbsolute, join } from "node:path";
5
5
  import { normaliseCorrelation, type CorrelationMetadata } from "./correlation.ts";
6
+ import { runWithFinalizers } from "./finalization.ts";
6
7
  import {
7
8
  appendLedgerEvent,
8
9
  buildCheckReceiptLedgerEvent,
@@ -163,27 +164,28 @@ export async function runNamedCheck(input: {
163
164
  /** Failures of best-effort RECORDS, reported alongside whatever actually happened — never instead of it. */
164
165
  const notes: string[] = [];
165
166
  let releaseReason = "failed";
166
- try {
167
+ return runWithFinalizers(async () => {
167
168
  try {
168
- lease = await acquireWorkspaceLease({
169
- workspace: input.workspace, access: leaseAccess, leaseDir: input.leaseDir ?? defaultWorkspaceLeaseDir(),
170
- ownerId, signal: input.signal,
171
- });
172
- } catch (error) {
173
- if (input.ledgerPath) await appendLedgerEvent(
174
- { path: input.ledgerPath, strict: true },
175
- buildWorkspaceLeaseEvent({
176
- executionId, parentExecutionId,
177
- childId: ownerId, workspaceId: input.workspace.workspaceId, root: input.workspace.root, access: leaseAccess,
178
- outcome: "refused",
179
- correlation, now: new Date(),
180
- ...(error instanceof GovernanceRefusal
181
- ? { refusal: { code: error.code, message: error.message, ...(error.details ? { details: error.details } : {}) } }
182
- : {}),
183
- }),
184
- );
185
- throw error;
186
- }
169
+ try {
170
+ lease = await acquireWorkspaceLease({
171
+ workspace: input.workspace, access: leaseAccess, leaseDir: input.leaseDir ?? defaultWorkspaceLeaseDir(),
172
+ ownerId, signal: input.signal,
173
+ });
174
+ } catch (error) {
175
+ if (input.ledgerPath) await appendLedgerEvent(
176
+ { path: input.ledgerPath, strict: true },
177
+ buildWorkspaceLeaseEvent({
178
+ executionId, parentExecutionId,
179
+ childId: ownerId, workspaceId: input.workspace.workspaceId, root: input.workspace.root, access: leaseAccess,
180
+ outcome: "refused",
181
+ correlation, now: new Date(),
182
+ ...(error instanceof GovernanceRefusal
183
+ ? { refusal: { code: error.code, message: error.message, ...(error.details ? { details: error.details } : {}) } }
184
+ : {}),
185
+ }),
186
+ );
187
+ throw error;
188
+ }
187
189
  if (input.ledgerPath) {
188
190
  try {
189
191
  await appendLedgerEvent(
@@ -329,29 +331,39 @@ export async function runNamedCheck(input: {
329
331
  );
330
332
  }
331
333
  throw error;
332
- } finally {
333
- if (stagedDir) await rm(stagedDir, { recursive: true, force: true });
334
- if (lease) {
335
- // `release()` cannot throw (R-99), and the record of it is best-effort for the same reason as
336
- // above: a `finally` that throws destroys whatever the caller was already returning or raising.
337
- const outcome = await lease.release(releaseReason);
338
- if (input.ledgerPath) await appendLedgerEvent(
339
- {
340
- path: input.ledgerPath,
341
- strict: false,
342
- onFailure: (cause) => { notes.push(`check lease release record failed: ${String(cause)}`); },
343
- },
344
- buildWorkspaceLeaseEvent({
345
- executionId, parentExecutionId,
346
- childId: ownerId, workspaceId: input.workspace.workspaceId, root: input.workspace.root, access: leaseAccess,
347
- outcome: outcome === "lost"
348
- ? "lost"
349
- : outcome === "released-unrecorded"
350
- ? "released-unrecorded"
351
- : releaseReason === "timeout" ? "timeout" : "released",
352
- releaseReason, correlation, now: new Date(),
353
- }),
354
- );
355
334
  }
356
- }
335
+ }, [
336
+ {
337
+ label: "staged check cleanup failed",
338
+ run: async () => {
339
+ if (stagedDir) await rm(stagedDir, { recursive: true, force: true });
340
+ },
341
+ },
342
+ {
343
+ label: "workspace lease finalizer failed",
344
+ run: async () => {
345
+ if (!lease) return;
346
+ // `release()` cannot throw (R-99), and the record of it is best-effort for the same reason as
347
+ // above: a finalizer must not destroy whatever the caller was already returning or raising.
348
+ const outcome = await lease.release(releaseReason);
349
+ if (input.ledgerPath) await appendLedgerEvent(
350
+ {
351
+ path: input.ledgerPath,
352
+ strict: false,
353
+ onFailure: (cause) => { notes.push(`check lease release record failed: ${String(cause)}`); },
354
+ },
355
+ buildWorkspaceLeaseEvent({
356
+ executionId, parentExecutionId,
357
+ childId: ownerId, workspaceId: input.workspace.workspaceId, root: input.workspace.root, access: leaseAccess,
358
+ outcome: outcome === "lost"
359
+ ? "lost"
360
+ : outcome === "released-unrecorded"
361
+ ? "released-unrecorded"
362
+ : releaseReason === "timeout" ? "timeout" : "released",
363
+ releaseReason, correlation, now: new Date(),
364
+ }),
365
+ );
366
+ },
367
+ },
368
+ ]);
357
369
  }
@@ -7,12 +7,18 @@ import { isLedgerCorrelationIdentifier } from "./ledger-identifiers.ts";
7
7
  export type JsonPrimitive = string | number | boolean | null;
8
8
  export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
9
9
 
10
+ export interface AssuranceScope {
11
+ type: "entire-run" | "selectors";
12
+ selectors: string[];
13
+ }
14
+
10
15
  /**
11
16
  * Non-authoritative workflow metadata copied onto runtime and ledger events.
12
17
  *
13
- * pi-daddy deliberately does not interpret assurance labels, sources, scope selectors, timestamps or
14
- * sequence floors. In particular, a digest-looking value here never substitutes for a digest computed by
15
- * the planner. The snake_case names are the wire vocabulary used by external controllers.
18
+ * pi-daddy interprets only the pinned correlation wire contract: schema version 1.0 and the closed
19
+ * assurance-scope shape. Labels, selector meanings, timestamps and sequence floors remain non-authoritative.
20
+ * In particular, a digest-looking value here never substitutes for a digest computed by the planner. The
21
+ * snake_case names are the wire vocabulary used by external controllers.
16
22
  */
17
23
  export interface CorrelationMetadata {
18
24
  schema_version?: string;
@@ -25,7 +31,7 @@ export interface CorrelationMetadata {
25
31
  assurance_effective?: string;
26
32
  policy_label?: string;
27
33
  assurance_source?: string;
28
- assurance_scope?: JsonValue;
34
+ assurance_scope?: AssuranceScope;
29
35
  activated_at?: string;
30
36
  plan_digest?: string;
31
37
  definition_digest?: string;
@@ -93,6 +99,29 @@ function correlationTooLarge(message: string, details?: Record<string, string |
93
99
  return new GovernanceRefusal(refusal("CORRELATION_TOO_LARGE", `correlation metadata: ${message}`, details));
94
100
  }
95
101
 
102
+ function normaliseAssuranceScope(value: unknown): AssuranceScope {
103
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
104
+ throw correlationRefusal("assurance_scope must be an object with type and selectors");
105
+ }
106
+ const scope = value as Record<string, unknown>;
107
+ const keys = Object.keys(scope).sort();
108
+ if (keys.length !== 2 || keys[0] !== "selectors" || keys[1] !== "type") {
109
+ throw correlationRefusal("assurance_scope must contain only type and selectors");
110
+ }
111
+ if (!Array.isArray(scope.selectors) || !scope.selectors.every((selector) => typeof selector === "string" && selector.length > 0)) {
112
+ throw correlationRefusal("assurance_scope selectors must be non-empty strings");
113
+ }
114
+ if (scope.type === "entire-run") {
115
+ if (scope.selectors.length !== 0) throw correlationRefusal("entire-run assurance_scope requires empty selectors");
116
+ return { type: "entire-run", selectors: [] };
117
+ }
118
+ if (scope.type === "selectors") {
119
+ if (scope.selectors.length === 0) throw correlationRefusal("selectors scope requires at least one selector");
120
+ return { type: "selectors", selectors: [...scope.selectors] };
121
+ }
122
+ throw correlationRefusal("assurance_scope type must be entire-run or selectors");
123
+ }
124
+
96
125
  /**
97
126
  * Snapshot bounded JSON metadata so a caller cannot mutate a record after planning, and so nothing
98
127
  * unbounded or undeclared can reach the ledger through it.
@@ -138,7 +167,7 @@ export function normaliseCorrelation(input: CorrelationMetadata | undefined): Co
138
167
  { limit: MAX_CORRELATION_SCOPE_BYTES, actual: size },
139
168
  );
140
169
  }
141
- output[key] = value;
170
+ output[key] = normaliseAssuranceScope(value);
142
171
  continue;
143
172
  }
144
173
  if (CORRELATION_NUMERIC.has(key as keyof CorrelationMetadata)) {
@@ -149,6 +178,12 @@ export function normaliseCorrelation(input: CorrelationMetadata | undefined): Co
149
178
  continue;
150
179
  }
151
180
  if (typeof value !== "string") throw correlationRefusal(`${key} must be a string`, { field: key });
181
+ if (key === "schema_version" && value !== "1.0") {
182
+ throw correlationRefusal(`unsupported schema_version ${value}; supported version is 1.0`, {
183
+ field: key,
184
+ supported: "1.0",
185
+ });
186
+ }
152
187
  if (value.length > MAX_CORRELATION_FIELD_CHARS) {
153
188
  throw correlationTooLarge(
154
189
  `${key} exceeds ${MAX_CORRELATION_FIELD_CHARS} characters`,
@@ -183,6 +218,8 @@ export interface ApprovalBinding {
183
218
  definition_sha256?: string;
184
219
  workspace_id?: string;
185
220
  context_id?: string;
221
+ tree_sha?: string;
222
+ last_change_seq?: number;
186
223
  parent_id: string;
187
224
  }
188
225
 
@@ -210,6 +247,10 @@ export function buildApprovalBinding(input: {
210
247
  workspaceId?: string;
211
248
  /** Caller-declared label. Narrows only; never a claim that anything was enforced. */
212
249
  contextId?: string;
250
+ /** Optional upstream tree identity. When supplied, later approval use must match it exactly. */
251
+ treeSha?: string;
252
+ /** Optional upstream change floor. When supplied, later approval use must match it exactly. */
253
+ lastChangeSeq?: number;
213
254
  }): ApprovalBinding {
214
255
  const requested = [...new Set(input.requested)].sort();
215
256
  const effective = [...new Set(input.effective)].sort();
@@ -223,6 +264,8 @@ export function buildApprovalBinding(input: {
223
264
  ...(input.definitionSha256 ? { definition_sha256: input.definitionSha256 } : {}),
224
265
  ...(input.workspaceId ? { workspace_id: input.workspaceId } : {}),
225
266
  ...(input.contextId ? { context_id: input.contextId } : {}),
267
+ ...(input.treeSha !== undefined ? { tree_sha: input.treeSha } : {}),
268
+ ...(input.lastChangeSeq !== undefined ? { last_change_seq: input.lastChangeSeq } : {}),
226
269
  parent_id: input.parentId,
227
270
  };
228
271
  }
@@ -238,6 +281,10 @@ export function approvalBindingDigest(binding: ApprovalBinding): string {
238
281
  definition_sha256: binding.definition_sha256 ?? null,
239
282
  workspace_id: binding.workspace_id ?? null,
240
283
  context_id: binding.context_id ?? null,
284
+ // Keep the exact pre-ADR-0039 serialization when both additions are absent: persisted approvals store
285
+ // this digest, so unconditional null placeholders would invalidate every existing bound approval.
286
+ ...(binding.tree_sha !== undefined ? { tree_sha: binding.tree_sha } : {}),
287
+ ...(binding.last_change_seq !== undefined ? { last_change_seq: binding.last_change_seq } : {}),
241
288
  parent_id: binding.parent_id,
242
289
  };
243
290
  return createHash("sha256").update(JSON.stringify(ordered), "utf8").digest("hex");
@@ -253,10 +300,12 @@ export function isApprovalBinding(value: unknown): value is ApprovalBinding {
253
300
  Array.isArray(binding.requested) && binding.requested.every((item) => typeof item === "string") &&
254
301
  Array.isArray(binding.effective) && binding.effective.every((item) => typeof item === "string") &&
255
302
  typeof binding.parent_id === "string" &&
256
- ["definition_sha256", "workspace_id", "context_id"].every((key) => {
303
+ ["definition_sha256", "workspace_id", "context_id", "tree_sha"].every((key) => {
257
304
  const value = (binding as Record<string, unknown>)[key];
258
305
  return value === undefined || typeof value === "string";
259
306
  }) &&
307
+ (binding.last_change_seq === undefined ||
308
+ (typeof binding.last_change_seq === "number" && Number.isFinite(binding.last_change_seq))) &&
260
309
  // Self-consistency. This guard's only trust boundary is a binding parsed off DISK
261
310
  // (`approval-store.ts`), so an internally contradictory record — digests that do not match the
262
311
  // capability arrays sitting beside them — must be unrepresentable rather than merely unlikely.
@@ -26,8 +26,8 @@ export function resolveDelegationApproval(input: {
26
26
  spawned?: SkillDefinition;
27
27
  definitionDigest?: DefinitionDigest;
28
28
  /**
29
- * Present iff this call is task-bound. Its VALUES never enter the binding — only its presence selects
30
- * the exact-bound regime over the legacy subject-scoped one.
29
+ * Present iff this call is task-bound. Most values remain labels; optional tree_sha/last_change_seq enter
30
+ * the binding only to narrow a supplied approval to the upstream tree state that was reviewed.
31
31
  */
32
32
  correlation?: CorrelationMetadata;
33
33
  /** Trusted: an id that was resolved against the operator registry and leased. Never a caller claim. */
@@ -89,6 +89,8 @@ export function resolveDelegationApproval(input: {
89
89
  parentId: input.parentId,
90
90
  workspaceId: input.boundWorkspaceId,
91
91
  contextId: input.boundContextId,
92
+ treeSha: input.correlation?.tree_sha,
93
+ lastChangeSeq: input.correlation?.last_change_seq,
92
94
  })
93
95
  : undefined;
94
96
 
@@ -0,0 +1,79 @@
1
+ interface Finalizer {
2
+ label: string;
3
+ run: () => void | Promise<void>;
4
+ }
5
+
6
+ const FINALIZER_ERRORS = Symbol("pi-daddy.finalizer-errors");
7
+
8
+ type ErrorWithFinalizers = Error & { [FINALIZER_ERRORS]?: unknown[] };
9
+
10
+ /**
11
+ * Run every finalizer without allowing one of their failures to replace the operation's primary error.
12
+ *
13
+ * Error identity is preserved when possible: refusal codes and `instanceof` checks are part of callers'
14
+ * control flow. Finalizer failures are attached and added to the primary message instead of wrapping it.
15
+ */
16
+ export async function runWithFinalizers<T>(operation: () => Promise<T>, finalizers: readonly Finalizer[]): Promise<T> {
17
+ let primaryPresent = false;
18
+ let primary: unknown;
19
+ let result: T | undefined;
20
+
21
+ try {
22
+ result = await operation();
23
+ } catch (error) {
24
+ primaryPresent = true;
25
+ primary = error;
26
+ }
27
+
28
+ const failures: Array<{ label: string; error: unknown }> = [];
29
+ for (const finalizer of finalizers) {
30
+ try {
31
+ await finalizer.run();
32
+ } catch (error) {
33
+ failures.push({ label: finalizer.label, error });
34
+ }
35
+ }
36
+
37
+ if (primaryPresent) {
38
+ if (failures.length > 0) throw appendFinalizerFailures(primary, failures);
39
+ throw primary;
40
+ }
41
+ if (failures.length === 1) throw failures[0].error;
42
+ if (failures.length > 1) {
43
+ throw new AggregateError(
44
+ failures.map((failure) => failure.error),
45
+ failures.map((failure) => `${failure.label}: ${String(failure.error)}`).join("; "),
46
+ );
47
+ }
48
+ return result as T;
49
+ }
50
+
51
+ /** True when a primary error retained a finalizer failure of the requested kind. */
52
+ export function hasFinalizerError(error: unknown, predicate: (value: unknown) => boolean): boolean {
53
+ return error instanceof Error && (error as ErrorWithFinalizers)[FINALIZER_ERRORS]?.some(predicate) === true;
54
+ }
55
+
56
+ function appendFinalizerFailures(primary: unknown, failures: ReadonlyArray<{ label: string; error: unknown }>): unknown {
57
+ if (primary instanceof Error) {
58
+ const target = primary as ErrorWithFinalizers;
59
+ try {
60
+ const prior = target[FINALIZER_ERRORS] ?? [];
61
+ Object.defineProperty(target, FINALIZER_ERRORS, {
62
+ configurable: true,
63
+ value: [...prior, ...failures.map((failure) => failure.error)],
64
+ });
65
+ target.message = [
66
+ target.message,
67
+ ...failures.map((failure) => `${failure.label}: ${String(failure.error)}`),
68
+ ].join("; ");
69
+ return target;
70
+ } catch {
71
+ // A frozen foreign error cannot be annotated. Keep it as the first AggregateError member and cause.
72
+ }
73
+ }
74
+ return new AggregateError(
75
+ [primary, ...failures.map((failure) => failure.error)],
76
+ [String(primary), ...failures.map((failure) => `${failure.label}: ${String(failure.error)}`)].join("; "),
77
+ { cause: primary },
78
+ );
79
+ }
@@ -3,6 +3,7 @@ import { mkdtemp, rm } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { promisify } from "node:util";
6
+ import { runWithFinalizers } from "./finalization.ts";
6
7
  import { GovernanceRefusal, refusal } from "./refusals.ts";
7
8
  import type { ValidatedWorkspace } from "./workspace.ts";
8
9
 
@@ -33,20 +34,23 @@ export async function computeGitCandidateIdentity(workspace: ValidatedWorkspace)
33
34
  const git = async (args: string[]) => (await execFileAsync("git", ["-C", workspace.root, ...args], {
34
35
  env, encoding: "utf8", maxBuffer: 16 * 1024 * 1024,
35
36
  })).stdout.trim();
36
- try {
37
- const headSha = await git(["rev-parse", "HEAD"]);
38
- await git(["read-tree", "HEAD"]);
39
- await git(["add", "-A"]);
40
- const treeSha = await git(["write-tree"]);
41
- if (!/^[a-f0-9]{40,64}$/i.test(headSha) || !/^[a-f0-9]{40,64}$/i.test(treeSha)) throw new Error("Git returned an invalid object id");
42
- return { headSha, treeSha };
43
- } catch (error) {
44
- throw new GovernanceRefusal(refusal(
45
- "CHECK_IDENTITY_UNAVAILABLE",
46
- `could not compute exact Git head/candidate-tree identity for workspace ${workspace.workspaceId} (${String(error)})`,
47
- { workspace_id: workspace.workspaceId },
48
- ));
49
- } finally {
50
- await rm(dir, { recursive: true, force: true });
51
- }
37
+ return runWithFinalizers(async () => {
38
+ try {
39
+ const headSha = await git(["rev-parse", "HEAD"]);
40
+ await git(["read-tree", "HEAD"]);
41
+ await git(["add", "-A"]);
42
+ const treeSha = await git(["write-tree"]);
43
+ if (!/^[a-f0-9]{40,64}$/i.test(headSha) || !/^[a-f0-9]{40,64}$/i.test(treeSha)) throw new Error("Git returned an invalid object id");
44
+ return { headSha, treeSha };
45
+ } catch (error) {
46
+ throw new GovernanceRefusal(refusal(
47
+ "CHECK_IDENTITY_UNAVAILABLE",
48
+ `could not compute exact Git head/candidate-tree identity for workspace ${workspace.workspaceId} (${String(error)})`,
49
+ { workspace_id: workspace.workspaceId },
50
+ ));
51
+ }
52
+ }, [{
53
+ label: "temporary Git index cleanup failed",
54
+ run: () => rm(dir, { recursive: true, force: true }),
55
+ }]);
52
56
  }