pi-daddy 0.17.1 → 0.18.1

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 (126) hide show
  1. package/CHANGELOG.md +72 -0
  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/capabilities.d.ts +15 -0
  15. package/dist/capabilities.d.ts.map +1 -1
  16. package/dist/capabilities.js +47 -0
  17. package/dist/capabilities.js.map +1 -1
  18. package/dist/chain.d.ts +6 -1
  19. package/dist/chain.d.ts.map +1 -1
  20. package/dist/chain.js +1 -1
  21. package/dist/chain.js.map +1 -1
  22. package/dist/check-runner.d.ts +61 -0
  23. package/dist/check-runner.d.ts.map +1 -0
  24. package/dist/check-runner.js +237 -0
  25. package/dist/check-runner.js.map +1 -0
  26. package/dist/correlation.d.ts +90 -0
  27. package/dist/correlation.d.ts.map +1 -0
  28. package/dist/correlation.js +183 -0
  29. package/dist/correlation.js.map +1 -0
  30. package/dist/delegate-types.d.ts +140 -0
  31. package/dist/delegate-types.d.ts.map +1 -0
  32. package/dist/delegate-types.js +8 -0
  33. package/dist/delegate-types.js.map +1 -0
  34. package/dist/delegate.d.ts +4 -128
  35. package/dist/delegate.d.ts.map +1 -1
  36. package/dist/delegate.js +70 -73
  37. package/dist/delegate.js.map +1 -1
  38. package/dist/delegation-approval.d.ts +36 -0
  39. package/dist/delegation-approval.d.ts.map +1 -0
  40. package/dist/delegation-approval.js +67 -0
  41. package/dist/delegation-approval.js.map +1 -0
  42. package/dist/git-identity.d.ts +13 -0
  43. package/dist/git-identity.d.ts.map +1 -0
  44. package/dist/git-identity.js +44 -0
  45. package/dist/git-identity.js.map +1 -0
  46. package/dist/index.d.ts +5 -1
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/index.js +5 -1
  49. package/dist/index.js.map +1 -1
  50. package/dist/lease-record.d.ts +78 -0
  51. package/dist/lease-record.d.ts.map +1 -0
  52. package/dist/lease-record.js +52 -0
  53. package/dist/lease-record.js.map +1 -0
  54. package/dist/ledger-events.d.ts +87 -0
  55. package/dist/ledger-events.d.ts.map +1 -0
  56. package/dist/ledger-events.js +45 -0
  57. package/dist/ledger-events.js.map +1 -0
  58. package/dist/ledger-report.d.ts +19 -12
  59. package/dist/ledger-report.d.ts.map +1 -1
  60. package/dist/ledger-report.js +81 -1
  61. package/dist/ledger-report.js.map +1 -1
  62. package/dist/ledger.d.ts +44 -1
  63. package/dist/ledger.d.ts.map +1 -1
  64. package/dist/ledger.js +20 -2
  65. package/dist/ledger.js.map +1 -1
  66. package/dist/propagation.d.ts.map +1 -1
  67. package/dist/propagation.js +2 -1
  68. package/dist/propagation.js.map +1 -1
  69. package/dist/refusals.d.ts +16 -0
  70. package/dist/refusals.d.ts.map +1 -0
  71. package/dist/refusals.js +52 -0
  72. package/dist/refusals.js.map +1 -0
  73. package/dist/resolve.d.ts.map +1 -1
  74. package/dist/resolve.js +7 -1
  75. package/dist/resolve.js.map +1 -1
  76. package/dist/run-child.d.ts +4 -0
  77. package/dist/run-child.d.ts.map +1 -1
  78. package/dist/run-child.js +21 -1
  79. package/dist/run-child.js.map +1 -1
  80. package/dist/run-herdr.d.ts +7 -0
  81. package/dist/run-herdr.d.ts.map +1 -1
  82. package/dist/run-herdr.js +41 -12
  83. package/dist/run-herdr.js.map +1 -1
  84. package/dist/workspace-lease.d.ts +28 -0
  85. package/dist/workspace-lease.d.ts.map +1 -0
  86. package/dist/workspace-lease.js +276 -0
  87. package/dist/workspace-lease.js.map +1 -0
  88. package/dist/workspace.d.ts +32 -0
  89. package/dist/workspace.d.ts.map +1 -0
  90. package/dist/workspace.js +79 -0
  91. package/dist/workspace.js.map +1 -0
  92. package/extensions/approval-banking.ts +66 -0
  93. package/extensions/approvals.ts +95 -7
  94. package/extensions/chain-approval-facts.ts +51 -0
  95. package/extensions/chain-ledger.ts +48 -0
  96. package/extensions/delegate-chain.ts +115 -81
  97. package/extensions/delegation.ts +81 -23
  98. package/extensions/execute-child.ts +289 -0
  99. package/extensions/fanout-outcome.ts +97 -0
  100. package/extensions/run-delegation.ts +130 -120
  101. package/extensions/session.ts +4 -0
  102. package/extensions/workspace-runtime.ts +165 -0
  103. package/package.json +17 -1
  104. package/src/approval-prompt.ts +4 -2
  105. package/src/approval-store.ts +4 -1
  106. package/src/approval.ts +47 -14
  107. package/src/capabilities.ts +53 -0
  108. package/src/chain.ts +3 -1
  109. package/src/check-runner.ts +341 -0
  110. package/src/correlation.ts +260 -0
  111. package/src/delegate-types.ts +144 -0
  112. package/src/delegate.ts +82 -180
  113. package/src/delegation-approval.ts +99 -0
  114. package/src/git-identity.ts +52 -0
  115. package/src/index.ts +50 -0
  116. package/src/lease-record.ts +119 -0
  117. package/src/ledger-events.ts +138 -0
  118. package/src/ledger-report.ts +90 -2
  119. package/src/ledger.ts +68 -3
  120. package/src/propagation.ts +2 -1
  121. package/src/refusals.ts +67 -0
  122. package/src/resolve.ts +7 -1
  123. package/src/run-child.ts +23 -1
  124. package/src/run-herdr.ts +41 -11
  125. package/src/workspace-lease.ts +303 -0
  126. package/src/workspace.ts +135 -0
@@ -28,7 +28,12 @@ import type { createApprovalGate } from "../src/approval-prompt.ts";
28
28
  import { timeoutMsFromEnv, type PromptOutcomeKind } from "../src/approval-prompt.ts";
29
29
  import { ceilingForDefinition, digestDefinition } from "../src/definitions.ts";
30
30
  import type { Capability } from "../src/resolve.ts";
31
+ import { approvalBindingDigest, type ApprovalBinding } from "../src/correlation.ts";
32
+ import type { RefusalCode } from "../src/refusals.ts";
31
33
  import type { GrantsSession } from "./session.ts";
34
+ import type { BankedApproval } from "./approval-banking.ts";
35
+ export { unbankApprovals } from "./approval-banking.ts";
36
+ export type { BankedApproval } from "./approval-banking.ts";
32
37
 
33
38
  /**
34
39
  * What a subject looks like right now, for the confused-deputy check in the approval store.
@@ -102,6 +107,8 @@ export interface ApprovalOutcome {
102
107
  * and the behaviour that was already in place.
103
108
  */
104
109
  scopes: Record<Capability, ApprovalScope>;
110
+ /** Complete scope map for the ledger, including inherited, session, and persisted answers. */
111
+ recordedScopes: Record<Capability, ApprovalScope>;
105
112
  /**
106
113
  * Where EACH approved capability's yes came from (R-46).
107
114
  *
@@ -110,6 +117,10 @@ export interface ApprovalOutcome {
110
117
  * always computed this map; the bug was that it was thrown away.
111
118
  */
112
119
  sources: Record<Capability, ApprovalSource>;
120
+ /** Exact binding per approved capability when this is a correlated controller call. */
121
+ bindings: Record<Capability, ApprovalBinding>;
122
+ expiresAt: Record<Capability, string>;
123
+ uses: Record<Capability, { max: number; remaining: number }>;
113
124
  humanDenied: boolean;
114
125
  /**
115
126
  * Which of the five prompt outcomes ended the loop, when one did (ADR-0026's F5).
@@ -119,6 +130,18 @@ export interface ApprovalOutcome {
119
130
  * dismissal is a queue or a longer timeout, `no-ui` is an operator pre-approving, `error` is a defect.
120
131
  */
121
132
  gateOutcome?: PromptOutcomeKind;
133
+ /**
134
+ * Authority this gate LEFT BEHIND: session keys added, and 30-day entries written to disk.
135
+ *
136
+ * Recorded so a caller that refuses after the gate can take it back. `run-delegation.ts` already
137
+ * documents the rule at length — "a refused operation must not leave authority behind" — and moved the
138
+ * executor check before the gate to honour it. The adjacent path did not: an operator clicks *Always*,
139
+ * the load-bearing ledger append then fails (a lock timeout under fan-out is a documented condition),
140
+ * the delegation is refused, and a project-wide 30-day approval survives for a spawn that never
141
+ * happened (R-113).
142
+ */
143
+ banked?: BankedApproval[];
144
+ refusalCode?: RefusalCode;
122
145
  reason?: string;
123
146
  }
124
147
 
@@ -143,8 +166,9 @@ export async function storedApprovals(
143
166
  session: GrantsSession,
144
167
  gated: Capability[],
145
168
  subject: string,
169
+ expectedBinding?: ApprovalBinding,
146
170
  ): Promise<ResolveApprovalsResult> {
147
- const { valid } = await loadApprovals({
171
+ const { valid, dropped } = await loadApprovals({
148
172
  cwd: session.cwd,
149
173
  now: new Date(),
150
174
  snapshotOf: (name) => snapshotOf(session, name),
@@ -153,7 +177,10 @@ export async function storedApprovals(
153
177
  gated,
154
178
  subject,
155
179
  sessionApprovals: session.sessionApprovals,
180
+ sessionApprovalBindings: session.sessionApprovalBindings,
156
181
  persisted: valid,
182
+ expectedBinding,
183
+ expiredKeys: new Set(dropped.filter((item) => item.verdict === "expired").map((item) => item.key)),
157
184
  // ADR-0022. Verified HERE rather than at parse time because it needs `session.definitions`, which
158
185
  // arrives at `session_start` — after the session object is built.
159
186
  inherited: verifyInherited(session.inheritedApprovals, (name) => snapshotOf(session, name)),
@@ -180,11 +207,23 @@ export async function obtainApprovals(
180
207
  ctx: ApprovalUIContext | null,
181
208
  task?: string,
182
209
  signal?: AbortSignal,
210
+ expectedBinding?: ApprovalBinding,
183
211
  ): Promise<ApprovalOutcome> {
184
212
  const snapshot = (name: string) => snapshotOf(session, name);
185
- const pre = await storedApprovals(session, gatedBlocked, subject);
213
+ const pre = await storedApprovals(session, gatedBlocked, subject, expectedBinding);
214
+ const preBindings: Record<Capability, ApprovalBinding> = {};
215
+ if (expectedBinding) for (const capability of pre.approved) preBindings[capability] = expectedBinding;
186
216
  if (ctx === null || pre.needsPrompt.length === 0) {
187
- return { approved: pre.approved, sources: pre.sources, scopes: {}, humanDenied: false };
217
+ return {
218
+ approved: pre.approved,
219
+ sources: pre.sources,
220
+ scopes: {},
221
+ recordedScopes: pre.scopes,
222
+ bindings: preBindings,
223
+ expiresAt: pre.expiresAt,
224
+ uses: {},
225
+ humanDenied: false,
226
+ };
188
227
  }
189
228
 
190
229
  // The gate PROVIDER lives on the session, not here: `obtainApprovals` runs once per `tool_call` and once
@@ -203,12 +242,21 @@ export async function obtainApprovals(
203
242
  // about — so a mixed set reports exactly which yes came from where.
204
243
  const sources: Record<Capability, ApprovalSource> = { ...pre.sources };
205
244
  const scopes: Record<Capability, ApprovalScope> = {};
245
+ const recordedScopes: Record<Capability, ApprovalScope> = { ...pre.scopes };
246
+ const bindings: Record<Capability, ApprovalBinding> = { ...preBindings };
247
+ const expiresAt: Record<Capability, string> = { ...pre.expiresAt };
248
+ const uses: Record<Capability, { max: number; remaining: number }> = {};
206
249
  let humanDenied = false;
250
+ const banked: BankedApproval[] = [];
207
251
  let gateOutcome: PromptOutcomeKind | undefined;
252
+ let refusalCode: RefusalCode | undefined;
208
253
  let reason: string | undefined;
209
254
 
210
255
  for (const capability of pre.needsPrompt) {
211
- const outcome = await gate.request({ capability, subject, path, task, signal });
256
+ const outcome = await gate.request({
257
+ capability, subject, path, task, signal,
258
+ ...(expectedBinding ? { bindingKey: approvalBindingDigest(expectedBinding) } : {}),
259
+ });
212
260
  if (outcome.scope === null) {
213
261
  // Forward the gate's own discriminant rather than re-deriving it from `hasUI`: `hasUI` is true in
214
262
  // RPC mode too, so an automated client's timeout or dismissal there would misreport as "a human
@@ -219,6 +267,11 @@ export async function obtainApprovals(
219
267
  // so "was there an operator who timed out, or was there nobody?" had no answer, and the two want
220
268
  // different fixes. It was computed here all along and thrown away.
221
269
  gateOutcome = outcome.kind;
270
+ refusalCode = pre.expired.includes(capability)
271
+ ? "APPROVAL_EXPIRED"
272
+ : pre.scopeMismatched.includes(capability)
273
+ ? "APPROVAL_SCOPE_MISMATCH"
274
+ : "GATED_UNAPPROVED";
222
275
  reason = outcome.reason;
223
276
  break;
224
277
  }
@@ -230,9 +283,20 @@ export async function obtainApprovals(
230
283
  // were not.
231
284
  sources[capability] = outcome.joined ? "session" : "prompt";
232
285
  scopes[capability] = outcome.scope;
286
+ recordedScopes[capability] = outcome.scope;
287
+ if (expectedBinding) bindings[capability] = expectedBinding;
288
+ if (outcome.scope === "once") uses[capability] = { max: 1, remaining: 0 };
233
289
 
234
290
  if (outcome.scope === "session" || outcome.scope === "always") {
235
- session.sessionApprovals.add(approvalKey(capability, subject));
291
+ const key = approvalKey(capability, subject);
292
+ // Whether THIS call created the authority, decided before we add it. `joined` means a human answer
293
+ // opened by another delegation was shared with us; an existing key means it was already standing.
294
+ // Either way it is not ours to take back if we are refused — see `BankedApproval.owned`.
295
+ const owned = !outcome.joined
296
+ && !(expectedBinding ? session.sessionApprovalBindings.has(key) : session.sessionApprovals.has(key));
297
+ if (expectedBinding) session.sessionApprovalBindings.set(key, expectedBinding);
298
+ else session.sessionApprovals.add(key);
299
+ banked.push({ key, capability, subject, persisted: false, owned });
236
300
  }
237
301
  if (outcome.scope === "always") {
238
302
  const now = new Date();
@@ -242,6 +306,8 @@ export async function obtainApprovals(
242
306
  // "type-missing" or "type-changed". Writing it is not unsafe (it fails closed), it is simply a
243
307
  // dead entry that silently accumulates in the file. Skip it and say so, taking the same
244
308
  // downgrade-to-session path as a failed write below: the human's yes still stands.
309
+ const expires = expiryFor(now);
310
+ expiresAt[capability] = expires;
245
311
  const written =
246
312
  current === null
247
313
  ? false
@@ -250,12 +316,13 @@ export async function obtainApprovals(
250
316
  approvalKey(capability, subject),
251
317
  {
252
318
  approvedAt: now.toISOString(),
253
- expiresAt: expiryFor(now),
319
+ expiresAt: expires,
254
320
  cwd: session.cwd,
255
321
  grantAtApproval: current.ceiling,
256
322
  // ADR-0019: the tools AND the instructions the human actually saw. Pinning only the
257
323
  // former would let a rewritten body inherit a yes that was given about different text.
258
324
  bodyAtApproval: current.bodySha256,
325
+ ...(expectedBinding ? { binding: expectedBinding } : {}),
259
326
  // The TASK is deliberately absent (ADR-0021). It is shown in the dialog, where a human
260
327
  // needs it, and never written down, because the model assembles it from the parent's
261
328
  // context and it can carry anything the parent could see.
@@ -263,6 +330,12 @@ export async function obtainApprovals(
263
330
  snapshot,
264
331
  now,
265
332
  );
333
+ if (written) {
334
+ // Mark the on-disk half so a later refusal knows there is a file entry to take back, not just a
335
+ // session key that dies with the process.
336
+ const persistedKey = approvalKey(capability, subject);
337
+ for (const entry of banked) if (entry.key === persistedKey) entry.persisted = true;
338
+ }
266
339
  if (!written) {
267
340
  // The human already said yes; the security decision stands. Only the convenience cache
268
341
  // failed, so this downgrades scope rather than refusing the delegation (see approval-store.ts).
@@ -276,10 +349,25 @@ export async function obtainApprovals(
276
349
  // The human's yes stands; only the cache failed. Downgrade THIS capability, not the set — under
277
350
  // the old scalar this also rewrote the scope of every other capability answered in the same call.
278
351
  scopes[capability] = "session";
352
+ recordedScopes[capability] = "session";
353
+ delete expiresAt[capability];
279
354
  }
280
355
  }
281
356
  session.publishChildEnv(); // a new session approval widens what children may inherit — republish now
282
357
  }
283
358
 
284
- return { approved, sources, scopes, humanDenied, gateOutcome, reason };
359
+ return {
360
+ approved,
361
+ sources,
362
+ scopes,
363
+ recordedScopes,
364
+ bindings,
365
+ expiresAt,
366
+ uses,
367
+ humanDenied,
368
+ gateOutcome,
369
+ refusalCode,
370
+ reason,
371
+ banked,
372
+ };
285
373
  }
@@ -0,0 +1,51 @@
1
+ import type { InheritableApproval } from "../src/approval.ts";
2
+ import type { Capability } from "../src/resolve.ts";
3
+ import type { ApprovalOutcome } from "./approvals.ts";
4
+
5
+ export interface ChainApprovalAudit {
6
+ sources: Map<string, ApprovalOutcome["sources"][Capability]>;
7
+ scopes: Map<string, ApprovalOutcome["recordedScopes"][Capability]>;
8
+ expiresAt: Map<string, string>;
9
+ uses: Map<string, { max: number; remaining: number }>;
10
+ }
11
+
12
+ export function newChainApprovalAudit(): ChainApprovalAudit {
13
+ return { sources: new Map(), scopes: new Map(), expiresAt: new Map(), uses: new Map() };
14
+ }
15
+
16
+ export function rememberChainApproval(
17
+ audit: ChainApprovalAudit,
18
+ capability: Capability,
19
+ subject: string,
20
+ outcome: ApprovalOutcome,
21
+ ): void {
22
+ const key = `${capability}@${subject}`;
23
+ const source = outcome.sources[capability];
24
+ const scope = outcome.recordedScopes[capability];
25
+ if (source) audit.sources.set(key, source);
26
+ if (scope) audit.scopes.set(key, scope);
27
+ const expiresAt = outcome.expiresAt[capability];
28
+ if (expiresAt) audit.expiresAt.set(key, expiresAt);
29
+ const uses = outcome.uses[capability];
30
+ if (uses) audit.uses.set(key, uses);
31
+ }
32
+
33
+ export function chainApprovalFacts(
34
+ audit: ChainApprovalAudit,
35
+ available: InheritableApproval[],
36
+ subject: string,
37
+ ): Pick<ApprovalOutcome, "approved" | "sources" | "scopes" | "expiresAt" | "uses" | "humanDenied"> {
38
+ const entries = available.filter((approval) => approval.subject === subject);
39
+ const values = <T>(map: Map<string, T>) => Object.fromEntries(entries.flatMap((approval) => {
40
+ const value = map.get(`${approval.capability}@${approval.subject}`);
41
+ return value === undefined ? [] : [[approval.capability, value]];
42
+ }));
43
+ return {
44
+ approved: entries.map((approval) => approval.capability),
45
+ sources: values(audit.sources),
46
+ scopes: values(audit.scopes),
47
+ expiresAt: values(audit.expiresAt),
48
+ uses: values(audit.uses),
49
+ humanDenied: false,
50
+ };
51
+ }
@@ -0,0 +1,48 @@
1
+ import type { ApprovalOutcome } from "./approvals.ts";
2
+ import type { Delegation } from "../src/delegate.ts";
3
+ import { appendRecord, buildRecord } from "../src/ledger.ts";
4
+ import type { StructuredRefusal } from "../src/refusals.ts";
5
+ import { childSpawnId } from "../src/fanout.ts";
6
+ import type { GrantsSession } from "./session.ts";
7
+
8
+ /** Audit a chain preflight/gate refusal with the same trusted planner facts as a normal delegation. */
9
+ export async function recordChainRefusal(input: {
10
+ session: GrantsSession;
11
+ plan: Delegation;
12
+ stepIndex: number;
13
+ agent?: string;
14
+ reason: string;
15
+ refusal?: StructuredRefusal;
16
+ approval?: ApprovalOutcome;
17
+ }): Promise<void> {
18
+ if (!input.session.ledgerPath) return;
19
+ await appendRecord(
20
+ { path: input.session.ledgerPath, strict: true },
21
+ buildRecord({
22
+ parentId: input.session.ownSpawnId,
23
+ childId: childSpawnId(input.session.ownSpawnId, input.stepIndex),
24
+ depth: input.plan.childDepth,
25
+ agentType: input.agent ?? "delegate",
26
+ requested: input.plan.requested,
27
+ parentGrant: input.session.ownGrant,
28
+ result: input.plan.result,
29
+ blocked: true,
30
+ reason: input.reason,
31
+ approved: input.approval?.approved,
32
+ approvalSources: input.approval?.sources,
33
+ approvalScopes: input.approval?.recordedScopes,
34
+ approvalExpiresAt: input.approval?.expiresAt,
35
+ approvalUses: input.approval?.uses,
36
+ humanDenied: input.approval?.humanDenied,
37
+ gateOutcome: input.approval?.gateOutcome,
38
+ definitionDigest: input.plan.definitionDigest,
39
+ taskDigest: input.plan.taskDigest,
40
+ correlation: input.plan.correlation,
41
+ // An approval banked before a later chain decline is a yes that led to no spawn, not the stale
42
+ // GATED_UNAPPROVED refusal from the pre-approval plan.
43
+ refusal: input.refusal ?? (input.approval?.approved.length ? undefined : input.plan.refusal),
44
+ executor: input.session.executor.kind,
45
+ now: new Date(),
46
+ }),
47
+ );
48
+ }
@@ -20,23 +20,21 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
20
20
  import { Type } from "typebox";
21
21
  import type { InheritableApproval } from "../src/approval.ts";
22
22
  import { DELEGATE_SUBJECT, shouldSeekApproval } from "../src/approval.ts";
23
- import { chainStepSpec, PLACEHOLDER } from "../src/chain.ts";
23
+ import { chainStepSpec, PLACEHOLDER, type ChainStep } from "../src/chain.ts";
24
24
  import { planDelegation } from "../src/delegate.ts";
25
25
  import { MAX_CHAIN_STEPS, childSpawnId, splitBudget } from "../src/fanout.ts";
26
26
  import { PAINT_INTERVAL_MS, appendTail, emptyTail, renderProgress, replaceTail, throttle, type ChildProgress } from "../src/progress.ts";
27
27
  import type { Capability } from "../src/resolve.ts";
28
- import { appendRecord, buildRecord } from "../src/ledger.ts";
28
+ import { recordChainRefusal } from "./chain-ledger.ts";
29
29
  import { obtainApprovals, snapshotOf } from "./approvals.ts";
30
30
  import { runOneDelegation } from "./run-delegation.ts";
31
+ import { isCriticalAssuranceBlock } from "./execute-child.ts";
31
32
  import type { GrantsSession } from "./session.ts";
33
+ import { GovernanceRefusal, refusal, type StructuredRefusal } from "../src/refusals.ts";
34
+ import { chainApprovalFacts, newChainApprovalAudit, rememberChainApproval } from "./chain-approval-facts.ts";
32
35
 
33
36
  /** One step of a chain, as the model describes it. */
34
- interface StepSpec {
35
- task: string;
36
- agent?: string;
37
- tools?: string[];
38
- model?: string;
39
- }
37
+ type StepSpec = ChainStep;
40
38
 
41
39
  /**
42
40
  * One dialog's worth of gate: a single capability, for a single subject, described by the step that needs it.
@@ -53,13 +51,17 @@ interface GateRequest {
53
51
  capability: Capability;
54
52
  /** The task of the step that needs this capability. */
55
53
  task: string;
54
+ stepIndex: number;
55
+ plan: ReturnType<typeof planDelegation>;
56
56
  }
57
57
 
58
58
  /** Either every gate the chain will hit, or the first step that can never run and why. */
59
59
  interface ChainPlan {
60
60
  requests: GateRequest[];
61
+ /** Exact approval keys each step would spend, including duplicate subjects across steps. */
62
+ uses: Map<number, Set<string>>;
61
63
  /** Set when a step is refused for a reason no approval can fix. The chain must refuse before asking anyone. */
62
- doomed?: { step: number; reason: string };
64
+ doomed?: { step: number; reason: string; plan: ReturnType<typeof planDelegation>; agent?: string; refusal?: StructuredRefusal };
63
65
  }
64
66
 
65
67
  /**
@@ -85,6 +87,7 @@ interface ChainPlan {
85
87
  */
86
88
  async function planChain(session: GrantsSession, steps: StepSpec[], perStepBudget: number): Promise<ChainPlan> {
87
89
  const requests: GateRequest[] = [];
90
+ const uses = new Map<number, Set<string>>();
88
91
  const seen = new Set<string>();
89
92
  const context = await session.delegationContext();
90
93
 
@@ -93,25 +96,49 @@ async function planChain(session: GrantsSession, steps: StepSpec[], perStepBudge
93
96
  // or a step with a whitespace task raises a dialog and is refused afterwards. The handoff is absent at planning
94
97
  // time and cannot change which capabilities are gated.
95
98
  const plan = planDelegation(
96
- { task: step.task, agent: step.agent, tools: step.tools, model: step.model },
99
+ {
100
+ task: step.task,
101
+ agent: step.agent,
102
+ tools: step.tools,
103
+ model: step.model,
104
+ correlation: step.workspace
105
+ ? { ...(step.correlation ?? {}), workspace_id: step.workspace.workspace_id }
106
+ : step.correlation,
107
+ // Routing spec, not the model's correlation claim — see R-110 in `src/correlation.ts`.
108
+ boundWorkspaceId: step.workspace?.workspace_id,
109
+ boundContextId: step.correlation?.context_id,
110
+ },
97
111
  { ...context, spawnId: session.ownSpawnId, childSpawnId: childSpawnId(session.ownSpawnId, index) },
98
112
  );
99
113
 
100
114
  // A gate is the ONLY refusal an approval can lift. Anything else is doomed, and asking about it banks authority
101
115
  // for a spawn that will never happen.
102
116
  if (!plan.ok && !shouldSeekApproval(plan.result)) {
103
- return { requests: [], doomed: { step: index + 1, reason: plan.reason ?? "this step cannot run" } };
117
+ return {
118
+ requests: [], uses, doomed: {
119
+ step: index + 1, reason: plan.reason ?? "this step cannot run", plan, agent: step.agent,
120
+ ...(plan.refusal ? { refusal: plan.refusal } : {}),
121
+ },
122
+ };
104
123
  }
105
124
 
125
+ // A correlated approval is bound to the exact task. Step N's final task does not exist until step N-1
126
+ // finishes, so asking upfront would bind the template and then spend it on different instructions.
127
+ // Legacy chains keep their upfront dialogs; correlated steps gate when their composed task exists.
128
+ if (plan.approvalBinding) continue;
129
+
106
130
  const subject = step.agent ?? DELEGATE_SUBJECT;
131
+ uses.set(index, new Set(plan.result.gatedBlocked.map((capability) => `${capability}@${subject}`)));
107
132
  for (const capability of plan.result.gatedBlocked) {
108
133
  const key = `${capability}@${subject}`;
109
134
  if (seen.has(key)) continue;
110
135
  seen.add(key);
111
- requests.push({ subject, path: step.agent ? "definition" : "delegate", capability, task: step.task });
136
+ requests.push({
137
+ subject, path: step.agent ? "definition" : "delegate", capability, task: step.task, stepIndex: index, plan,
138
+ });
112
139
  }
113
140
  }
114
- return { requests };
141
+ return { requests, uses };
115
142
  }
116
143
 
117
144
  export function registerChainTool(pi: ExtensionAPI, session: GrantsSession): void {
@@ -127,6 +154,23 @@ export function registerChainTool(pi: ExtensionAPI, session: GrantsSession): voi
127
154
  agent: Type.Optional(Type.String({ description: "Definition to spawn for this step." })),
128
155
  tools: Type.Optional(Type.Array(Type.String(), { description: "Capabilities, when no 'agent' fits." })),
129
156
  model: Type.Optional(Type.String({ description: "Model as provider/id. Defaults to this session's." })),
157
+ correlation: Type.Optional(Type.Object({
158
+ schema_version: Type.Optional(Type.String()), run_id: Type.Optional(Type.String()),
159
+ task_id: Type.Optional(Type.String()), workspace_id: Type.Optional(Type.String()),
160
+ context_id: Type.Optional(Type.String()), phase: Type.Optional(Type.String()),
161
+ assurance: Type.Optional(Type.String()), assurance_effective: Type.Optional(Type.String()),
162
+ policy_label: Type.Optional(Type.String()), assurance_source: Type.Optional(Type.String()),
163
+ assurance_scope: Type.Optional(Type.Any()), activated_at: Type.Optional(Type.String()),
164
+ plan_digest: Type.Optional(Type.String()), definition_digest: Type.Optional(Type.String()),
165
+ task_digest: Type.Optional(Type.String()), base_sha: Type.Optional(Type.String()),
166
+ head_sha: Type.Optional(Type.String()), tree_sha: Type.Optional(Type.String()),
167
+ event_seq: Type.Optional(Type.Number()), last_change_seq: Type.Optional(Type.Number()),
168
+ last_authority_seq: Type.Optional(Type.Number()), check_receipt_id: Type.Optional(Type.String()),
169
+ })),
170
+ workspace: Type.Optional(Type.Object({
171
+ workspace_id: Type.String(),
172
+ access: Type.Union([Type.Literal("read"), Type.Literal("write")]),
173
+ })),
130
174
  });
131
175
 
132
176
  const params = Type.Object({
@@ -156,37 +200,41 @@ export function registerChainTool(pi: ExtensionAPI, session: GrantsSession): voi
156
200
  // written, and the delegation was then refused anyway. `runOneDelegation` checks the executor before its own
157
201
  // gate; a chain hoists its gate above `runOneDelegation`, so the check has to be repeated here or that
158
202
  // ordering is simply bypassed.
159
- if (session.executor.refusal) throw new Error(`chain refused: ${session.executor.refusal}`);
203
+ if (session.executor.refusal) throw new GovernanceRefusal(refusal("EXECUTOR_UNAVAILABLE", `chain refused: ${session.executor.refusal}`));
160
204
 
161
205
  // Cardinality next, still before the gate. `splitBudget` is reused rather than re-derived, so a chain and a
162
206
  // fan-out cannot disagree about what the budget means.
163
207
  const split = splitBudget(session.fanoutBudget, steps.length);
164
- if (!split.ok) throw new Error(`chain refused: ${split.reason}`);
208
+ if (!split.ok) throw new GovernanceRefusal(refusal("FANOUT_EXCEEDED", `chain refused: ${split.reason}`));
165
209
 
166
210
  // Plan every step first. A step that can never run refuses the chain HERE, before anyone is asked — see
167
211
  // `planChain`.
168
212
  const chainPlan = await planChain(session, steps, split.perChild);
169
213
  if (chainPlan.doomed) {
170
- throw new Error(
171
- `chain refused at step ${chainPlan.doomed.step}: ${chainPlan.doomed.reason} No step ran, and nobody was ` +
172
- `asked to approve anything — a step that cannot run must not bank authority for a spawn that will ` +
173
- `never happen.`,
174
- );
214
+ const message = `chain refused at step ${chainPlan.doomed.step}: ${chainPlan.doomed.reason} No step ran, and nobody was ` +
215
+ `asked to approve anything — a step that cannot run must not bank authority for a spawn that will never happen.`;
216
+ await recordChainRefusal({
217
+ session, plan: chainPlan.doomed.plan, stepIndex: chainPlan.doomed.step - 1,
218
+ agent: chainPlan.doomed.agent, reason: message, refusal: chainPlan.doomed.refusal,
219
+ });
220
+ if (chainPlan.doomed.refusal) throw new GovernanceRefusal({ ...chainPlan.doomed.refusal, message });
221
+ throw new Error(message);
175
222
  }
176
223
 
177
224
  // One dialog per `capability@subject`, each naming the step that needs it, all before the first step runs.
178
225
  const preApproved: InheritableApproval[] = [];
179
- let declined: { capability: Capability; subject: string } | undefined;
180
- let humanDenied = false;
226
+ const approvalAudit = newChainApprovalAudit();
227
+ const approvalStep = new Map<string, number>();
228
+ const approvedDecisions: Array<{ request: GateRequest; outcome: Awaited<ReturnType<typeof obtainApprovals>> }> = [];
229
+ let declined: { request: GateRequest; outcome: Awaited<ReturnType<typeof obtainApprovals>> } | undefined;
181
230
 
182
231
  for (const request of chainPlan.requests) {
183
232
  const outcome = await obtainApprovals(session, [request.capability], request.subject, request.path, ctx, request.task, signal);
184
- humanDenied = humanDenied || outcome.humanDenied;
185
233
  if (!outcome.approved.includes(request.capability)) {
186
234
  // **Stop asking.** The chain's outcome is already fixed, and every further dialog banks authority — a
187
235
  // `session` yes into `sessionApprovals` and an `always` yes onto disk for 30 days — for a chain that will
188
236
  // not run. The single-delegate path breaks on the first no for the same reason.
189
- declined = { capability: request.capability, subject: request.subject };
237
+ declined = { request, outcome };
190
238
  break;
191
239
  }
192
240
  preApproved.push({
@@ -197,47 +245,28 @@ export function registerChainTool(pi: ExtensionAPI, session: GrantsSession): voi
197
245
  // against one definition's instructions while the capability was spent on another's.
198
246
  bodySha256: snapshotOf(session, request.subject)?.bodySha256,
199
247
  });
248
+ rememberChainApproval(approvalAudit, request.capability, request.subject, outcome);
249
+ approvalStep.set(`${request.capability}@${request.subject}`, request.stepIndex);
250
+ approvedDecisions.push({ request, outcome });
200
251
  }
201
252
 
202
253
  if (declined) {
203
- // Recorded before it is thrown, and recorded against the subject that was actually refused. The first version
204
- // hardcoded step 1's identity, so the trail asserted a human had denied a capability for `digger` when they
205
- // had *approved* it for `digger` and denied it for `shaper` — the ledger and the approval store asserting
206
- // opposite facts about the same key, which is R-28's shape.
207
- if (session.ledgerPath) {
208
- const at = steps.findIndex((step) => (step.agent ?? DELEGATE_SUBJECT) === declined.subject);
209
- await appendRecord(
210
- { path: session.ledgerPath, strict: true },
211
- buildRecord({
212
- parentId: session.ownSpawnId,
213
- childId: childSpawnId(session.ownSpawnId, Math.max(0, at)),
214
- depth: session.depth + 1,
215
- agentType: declined.subject === DELEGATE_SUBJECT ? "delegate" : declined.subject,
216
- requested: [declined.capability],
217
- parentGrant: session.ownGrant,
218
- result: { effective: [], denied: [], clipped: [], gatedBlocked: [declined.capability], universal: [], subsumedBy: [] },
219
- blocked: true,
220
- humanDenied,
221
- reason: `chain refused: ${declined.capability} not approved for ${declined.subject}; no step ran`,
222
- executor: session.executor.kind,
223
- now: new Date(),
224
- }),
225
- ).catch((error) => {
226
- // Not swallowed. Everywhere else a `strict` ledger failure fails closed, and losing the one line that
227
- // records a human's refusal is the direction rule 8 forbids — the chain refuses either way, so saying so
228
- // costs nothing.
229
- ctx.ui?.notify?.(
230
- `grants: the chain was refused AND its ledger line could not be written (${String(error)}) — the ` +
231
- `refusal happened, but this audit trail does not show it.`,
232
- "error",
233
- );
254
+ const { request, outcome } = declined;
255
+ const message = `chain refused: ${request.capability} was not approved for ${request.subject}, so no step ran. A chain ` +
256
+ `is gated as a unit — running only its approved steps would return a partial result that reads like a complete one.`;
257
+ const structured = outcome.refusalCode ? refusal(outcome.refusalCode, message) : undefined;
258
+ for (const approved of approvedDecisions) {
259
+ await recordChainRefusal({
260
+ session, plan: approved.request.plan, stepIndex: approved.request.stepIndex,
261
+ agent: steps[approved.request.stepIndex]?.agent, reason: message, approval: approved.outcome,
234
262
  });
235
263
  }
236
- throw new Error(
237
- `chain refused: ${declined.capability} was not approved for ${declined.subject}, so no step ran. A chain ` +
238
- `is gated as a unit — running only its approved steps would return a partial result that reads like a ` +
239
- `complete one.`,
240
- );
264
+ await recordChainRefusal({
265
+ session, plan: request.plan, stepIndex: request.stepIndex, agent: steps[request.stepIndex]?.agent,
266
+ reason: message, refusal: structured, approval: outcome,
267
+ });
268
+ if (structured) throw new GovernanceRefusal(structured);
269
+ throw new Error(message);
241
270
  }
242
271
 
243
272
  const children: ChildProgress[] = steps.map((step) => ({
@@ -252,7 +281,9 @@ export function registerChainTool(pi: ExtensionAPI, session: GrantsSession): voi
252
281
  });
253
282
  }, PAINT_INTERVAL_MS);
254
283
 
255
- const outcomes: Array<{ ok: boolean; text: string; reason?: string; step: number; agent?: string }> = [];
284
+ const outcomes: Array<{
285
+ ok: boolean; text: string; reason?: string; refusal?: StructuredRefusal; step: number; agent?: string;
286
+ }> = [];
256
287
  let previous: string | undefined;
257
288
  let aborted = false;
258
289
  /**
@@ -271,6 +302,11 @@ export function registerChainTool(pi: ExtensionAPI, session: GrantsSession): voi
271
302
 
272
303
  for (const [index, step] of steps.entries()) {
273
304
  const childId = childSpawnId(session.ownSpawnId, index);
305
+ const availableForStep = available.filter((approval) => {
306
+ const key = `${approval.capability}@${approval.subject}`;
307
+ return chainPlan.uses.get(index)?.has(key) &&
308
+ (approval.scope !== "once" || approvalStep.get(key) === index);
309
+ });
274
310
  const outcome = await runOneDelegation(
275
311
  session,
276
312
  chainStepSpec(step, previous),
@@ -279,21 +315,9 @@ export function registerChainTool(pi: ExtensionAPI, session: GrantsSession): voi
279
315
  ctx,
280
316
  signal,
281
317
  {
282
- preApproved: available,
283
- approvalFacts: {
284
- // Only this step's subject, so the record says what was authorised for THIS child rather than for the
285
- // chain as a whole.
286
- approved: available.filter((a) => a.subject === (step.agent ?? DELEGATE_SUBJECT)).map((a) => a.capability),
287
- sources: Object.fromEntries(
288
- available
289
- .filter((a) => a.subject === (step.agent ?? DELEGATE_SUBJECT))
290
- .map((a) => [a.capability, "prompt" as const]),
291
- ),
292
- scopes: Object.fromEntries(
293
- available.filter((a) => a.subject === (step.agent ?? DELEGATE_SUBJECT)).map((a) => [a.capability, a.scope]),
294
- ),
295
- humanDenied: false,
296
- },
318
+ preApproved: availableForStep,
319
+ // Only approvals actually offered to this step are attributed or consumed here.
320
+ approvalFacts: chainApprovalFacts(approvalAudit, availableForStep, step.agent ?? DELEGATE_SUBJECT),
297
321
  onProgress: (update) => {
298
322
  const child = children[index];
299
323
  if (!child) return;
@@ -312,11 +336,16 @@ export function registerChainTool(pi: ExtensionAPI, session: GrantsSession): voi
312
336
 
313
337
  children[index].state = outcome.ok ? "completed" : "failed";
314
338
  children[index].settledAt = Date.now();
315
- outcomes.push({ ok: outcome.ok, text: outcome.text, reason: outcome.reason, step: index + 1, agent: step.agent });
339
+ outcomes.push({
340
+ ok: outcome.ok, text: outcome.text, reason: outcome.reason, refusal: outcome.refusal,
341
+ step: index + 1, agent: step.agent,
342
+ });
343
+ if (isCriticalAssuranceBlock(outcome)) throw new Error(outcome.text);
316
344
 
317
345
  // Spend any `once` this step was handed, before the next step sees the list.
318
- const spentSubject = step.agent ?? DELEGATE_SUBJECT;
319
- available = available.filter((a) => !(a.scope === "once" && a.subject === spentSubject));
346
+ available = available.filter((approval) =>
347
+ approval.scope !== "once" || approvalStep.get(`${approval.capability}@${approval.subject}`) !== index,
348
+ );
320
349
 
321
350
  if (!outcome.ok) {
322
351
  // **Abort, and mark the rest.** Continuing would make the next step's task an error message, which is
@@ -345,12 +374,17 @@ export function registerChainTool(pi: ExtensionAPI, session: GrantsSession): voi
345
374
  if (outcomes.length === 1 && !outcomes[0].ok) {
346
375
  // Nothing completed at all, so there is no partial result to hand back — and a tool that returns text when
347
376
  // nothing ran is how a wrong summary gets written.
348
- throw new Error(`chain failed at its first step.\n\n${report}`);
377
+ const message = `chain failed at its first step.\n\n${report}`;
378
+ if (outcomes[0].refusal) throw new GovernanceRefusal({ ...outcomes[0].refusal, message });
379
+ throw new Error(message);
349
380
  }
350
381
 
351
382
  return {
352
383
  content: [{ type: "text", text: `${report}${tail}` }],
353
- details: { steps: steps.length, completed: outcomes.filter((o) => o.ok).length, aborted, budgetPerStep: split.perChild },
384
+ details: {
385
+ steps: steps.length, completed: outcomes.filter((o) => o.ok).length, aborted,
386
+ budgetPerStep: split.perChild, refusals: outcomes.map((outcome) => outcome.refusal ?? null),
387
+ },
354
388
  };
355
389
  },
356
390
  });