pi-daddy 0.21.1 → 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 (65) hide show
  1. package/CHANGELOG.md +20 -1
  2. package/README.md +12 -7
  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/init.d.ts.map +1 -1
  32. package/dist/init.js +2 -6
  33. package/dist/init.js.map +1 -1
  34. package/dist/model-preflight.d.ts +8 -0
  35. package/dist/model-preflight.d.ts.map +1 -0
  36. package/dist/model-preflight.js +20 -0
  37. package/dist/model-preflight.js.map +1 -0
  38. package/dist/refusals.d.ts +1 -1
  39. package/dist/refusals.d.ts.map +1 -1
  40. package/dist/refusals.js +2 -0
  41. package/dist/refusals.js.map +1 -1
  42. package/dist/run-herdr.d.ts.map +1 -1
  43. package/dist/run-herdr.js +6 -5
  44. package/dist/run-herdr.js.map +1 -1
  45. package/extensions/chain-plan.ts +16 -0
  46. package/extensions/correlation-shape.ts +39 -0
  47. package/extensions/delegate-chain.ts +9 -14
  48. package/extensions/delegation.ts +2 -24
  49. package/extensions/execute-child.ts +8 -1
  50. package/extensions/grant-store-refusal.ts +36 -0
  51. package/extensions/grants.ts +6 -0
  52. package/extensions/run-delegation.ts +21 -2
  53. package/extensions/session.ts +15 -5
  54. package/extensions/stored-grant-session.ts +33 -0
  55. package/package.json +3 -1
  56. package/src/check-runner.ts +56 -44
  57. package/src/correlation.ts +55 -6
  58. package/src/delegation-approval.ts +4 -2
  59. package/src/finalization.ts +79 -0
  60. package/src/git-identity.ts +20 -16
  61. package/src/grant-store.ts +55 -25
  62. package/src/init.ts +5 -5
  63. package/src/model-preflight.ts +32 -0
  64. package/src/refusals.ts +2 -0
  65. package/src/run-herdr.ts +6 -4
@@ -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.1",
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
  }
@@ -52,6 +52,12 @@ export interface StoredGrant {
52
52
  projectLedger: boolean;
53
53
  }
54
54
 
55
+ export type GrantStoreRefusalReason = "malformed" | "unsupported-version" | "unreadable" | "wrong-cwd";
56
+ export type StoredGrantState =
57
+ | { state: "absent" }
58
+ | { state: "valid"; stored: StoredGrant }
59
+ | { state: "refuse"; reason: GrantStoreRefusalReason };
60
+
55
61
  export interface SaveGrantOptions {
56
62
  projectLedger?: boolean;
57
63
  }
@@ -84,26 +90,36 @@ export function grantStorePath(cwd: string): string {
84
90
  * treats absent and invalid stores alike (R-175), so do not call that wider behavior fail-closed. The `cwd`
85
91
  * check is R-27's: a copied file describes a directory nobody authorised it for.
86
92
  */
87
- export function parseStoredGrant(text: string, cwd: string): StoredGrant | null {
93
+ export function parseStoredGrantState(text: string, cwd: string): StoredGrantState {
94
+ let parsed: { version?: unknown; cwd?: unknown; grant?: unknown; projectLedger?: unknown };
88
95
  try {
89
- const parsed = JSON.parse(text) as {
90
- version?: unknown;
91
- cwd?: unknown;
92
- grant?: unknown;
93
- projectLedger?: unknown;
94
- };
95
- if (parsed.version !== 1 && parsed.version !== 2) return null;
96
- if (parsed.cwd !== cwd) return null;
97
- if (!Array.isArray(parsed.grant)) return null;
98
- if (!parsed.grant.every((c) => typeof c === "string" && c.length > 0)) return null;
99
- if (parsed.version === 2 && parsed.projectLedger !== true) return null;
100
- return {
101
- grant: parsed.grant as Capability[],
102
- projectLedger: parsed.version === 2,
103
- };
96
+ parsed = JSON.parse(text) as typeof parsed;
104
97
  } catch {
105
- return null;
98
+ return { state: "refuse", reason: "malformed" };
99
+ }
100
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
101
+ return { state: "refuse", reason: "malformed" };
102
+ }
103
+ if (parsed.version !== 1 && parsed.version !== 2) {
104
+ return { state: "refuse", reason: "unsupported-version" };
105
+ }
106
+ if (parsed.cwd !== cwd) return { state: "refuse", reason: "wrong-cwd" };
107
+ if (!Array.isArray(parsed.grant)) return { state: "refuse", reason: "malformed" };
108
+ if (!parsed.grant.every((c) => typeof c === "string" && c.length > 0)) {
109
+ return { state: "refuse", reason: "malformed" };
110
+ }
111
+ if (parsed.version === 2 && parsed.projectLedger !== true) {
112
+ return { state: "refuse", reason: "malformed" };
106
113
  }
114
+ return {
115
+ state: "valid",
116
+ stored: { grant: parsed.grant as Capability[], projectLedger: parsed.version === 2 },
117
+ };
118
+ }
119
+
120
+ export function parseStoredGrant(text: string, cwd: string): StoredGrant | null {
121
+ const result = parseStoredGrantState(text, cwd);
122
+ return result.state === "valid" ? result.stored : null;
107
123
  }
108
124
 
109
125
  /** Compatibility view for callers that need only the capability ceiling. */
@@ -120,27 +136,41 @@ export function parseGrantFile(text: string, cwd: string): Capability[] | null {
120
136
  * decision it exists to inform, so the store would silently fail to grant delegation — the exact class of
121
137
  * defect R-38 and R-39 were.
122
138
  */
123
- export function loadStoredGrantSync(cwd: string): StoredGrant | null {
139
+ export function loadStoredGrantStateSync(cwd: string): StoredGrantState {
124
140
  try {
125
- return parseStoredGrant(readFileSync(grantStorePath(cwd), "utf8"), cwd);
126
- } catch {
127
- return null;
141
+ return parseStoredGrantState(readFileSync(grantStorePath(cwd), "utf8"), cwd);
142
+ } catch (error) {
143
+ return (error as NodeJS.ErrnoException).code === "ENOENT"
144
+ ? { state: "absent" }
145
+ : { state: "refuse", reason: "unreadable" };
128
146
  }
129
147
  }
130
148
 
149
+ export function loadStoredGrantSync(cwd: string): StoredGrant | null {
150
+ const result = loadStoredGrantStateSync(cwd);
151
+ return result.state === "valid" ? result.stored : null;
152
+ }
153
+
131
154
  export function loadGrantSync(cwd: string): Capability[] | null {
132
155
  return loadStoredGrantSync(cwd)?.grant ?? null;
133
156
  }
134
157
 
135
158
  /** Async twin, for callers that already have one. Same parser, so they cannot disagree. */
136
- export async function loadStoredGrant(cwd: string): Promise<StoredGrant | null> {
159
+ export async function loadStoredGrantState(cwd: string): Promise<StoredGrantState> {
137
160
  try {
138
- return parseStoredGrant(await readFile(grantStorePath(cwd), "utf8"), cwd);
139
- } catch {
140
- return null;
161
+ return parseStoredGrantState(await readFile(grantStorePath(cwd), "utf8"), cwd);
162
+ } catch (error) {
163
+ return (error as NodeJS.ErrnoException).code === "ENOENT"
164
+ ? { state: "absent" }
165
+ : { state: "refuse", reason: "unreadable" };
141
166
  }
142
167
  }
143
168
 
169
+ export async function loadStoredGrant(cwd: string): Promise<StoredGrant | null> {
170
+ const result = await loadStoredGrantState(cwd);
171
+ return result.state === "valid" ? result.stored : null;
172
+ }
173
+
144
174
  export async function loadGrant(cwd: string): Promise<Capability[] | null> {
145
175
  return (await loadStoredGrant(cwd))?.grant ?? null;
146
176
  }