pi-daddy 0.18.0 → 0.19.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 (130) hide show
  1. package/CHANGELOG.md +154 -0
  2. package/README.md +46 -2
  3. package/contracts/ledger/v2/README.md +59 -0
  4. package/contracts/ledger/v2/fixtures/capability-decision.json +95 -0
  5. package/contracts/ledger/v2/fixtures/check-receipt.json +40 -0
  6. package/contracts/ledger/v2/fixtures/child-lifecycle.json +42 -0
  7. package/contracts/ledger/v2/fixtures/workspace-lease.json +41 -0
  8. package/contracts/ledger/v2/ledger-event.schema.json +633 -0
  9. package/dist/approval-prompt.d.ts +2 -1
  10. package/dist/approval-prompt.d.ts.map +1 -1
  11. package/dist/approval-prompt.js +9 -0
  12. package/dist/approval-prompt.js.map +1 -1
  13. package/dist/approval.d.ts +4 -2
  14. package/dist/approval.d.ts.map +1 -1
  15. package/dist/approval.js +4 -0
  16. package/dist/approval.js.map +1 -1
  17. package/dist/capabilities.d.ts +105 -0
  18. package/dist/capabilities.d.ts.map +1 -1
  19. package/dist/capabilities.js +161 -3
  20. package/dist/capabilities.js.map +1 -1
  21. package/dist/catalog.d.ts +15 -1
  22. package/dist/catalog.d.ts.map +1 -1
  23. package/dist/catalog.js +54 -3
  24. package/dist/catalog.js.map +1 -1
  25. package/dist/check-runner.d.ts.map +1 -1
  26. package/dist/check-runner.js +5 -7
  27. package/dist/check-runner.js.map +1 -1
  28. package/dist/cli.d.ts.map +1 -1
  29. package/dist/cli.js +21 -1
  30. package/dist/cli.js.map +1 -1
  31. package/dist/definitions.d.ts.map +1 -1
  32. package/dist/definitions.js +7 -1
  33. package/dist/definitions.js.map +1 -1
  34. package/dist/delegate.d.ts.map +1 -1
  35. package/dist/delegate.js +32 -4
  36. package/dist/delegate.js.map +1 -1
  37. package/dist/delegation-approval.d.ts.map +1 -1
  38. package/dist/delegation-approval.js +37 -12
  39. package/dist/delegation-approval.js.map +1 -1
  40. package/dist/executor.d.ts +2 -1
  41. package/dist/executor.d.ts.map +1 -1
  42. package/dist/executor.js +1 -0
  43. package/dist/executor.js.map +1 -1
  44. package/dist/grant-env.d.ts +2 -0
  45. package/dist/grant-env.d.ts.map +1 -1
  46. package/dist/grant-env.js +26 -3
  47. package/dist/grant-env.js.map +1 -1
  48. package/dist/index.d.ts +1 -1
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +1 -1
  51. package/dist/index.js.map +1 -1
  52. package/dist/init.d.ts +13 -1
  53. package/dist/init.d.ts.map +1 -1
  54. package/dist/init.js +35 -2
  55. package/dist/init.js.map +1 -1
  56. package/dist/lease-helper.d.ts +58 -0
  57. package/dist/lease-helper.d.ts.map +1 -0
  58. package/dist/lease-helper.js +94 -0
  59. package/dist/lease-helper.js.map +1 -0
  60. package/dist/lease-record.d.ts +15 -3
  61. package/dist/lease-record.d.ts.map +1 -1
  62. package/dist/lease-record.js.map +1 -1
  63. package/dist/ledger-events.d.ts +35 -14
  64. package/dist/ledger-events.d.ts.map +1 -1
  65. package/dist/ledger-events.js +41 -0
  66. package/dist/ledger-events.js.map +1 -1
  67. package/dist/ledger.d.ts +10 -5
  68. package/dist/ledger.d.ts.map +1 -1
  69. package/dist/ledger.js +13 -3
  70. package/dist/ledger.js.map +1 -1
  71. package/dist/propagation.d.ts +16 -0
  72. package/dist/propagation.d.ts.map +1 -1
  73. package/dist/propagation.js +22 -2
  74. package/dist/propagation.js.map +1 -1
  75. package/dist/refusals.d.ts +1 -1
  76. package/dist/refusals.d.ts.map +1 -1
  77. package/dist/refusals.js +2 -0
  78. package/dist/refusals.js.map +1 -1
  79. package/dist/resolve.d.ts +10 -0
  80. package/dist/resolve.d.ts.map +1 -1
  81. package/dist/resolve.js +33 -3
  82. package/dist/resolve.js.map +1 -1
  83. package/dist/routing-authority.d.ts +71 -0
  84. package/dist/routing-authority.d.ts.map +1 -0
  85. package/dist/routing-authority.js +100 -0
  86. package/dist/routing-authority.js.map +1 -0
  87. package/dist/skill-packages.d.ts +11 -5
  88. package/dist/skill-packages.d.ts.map +1 -1
  89. package/dist/skill-packages.js +20 -11
  90. package/dist/skill-packages.js.map +1 -1
  91. package/dist/workspace-lease.d.ts +15 -3
  92. package/dist/workspace-lease.d.ts.map +1 -1
  93. package/dist/workspace-lease.js +81 -24
  94. package/dist/workspace-lease.js.map +1 -1
  95. package/dist/workspace.d.ts +25 -0
  96. package/dist/workspace.d.ts.map +1 -1
  97. package/dist/workspace.js +142 -5
  98. package/dist/workspace.js.map +1 -1
  99. package/extensions/delegation.ts +7 -1
  100. package/extensions/grants-command.ts +11 -1
  101. package/extensions/grants.ts +6 -1
  102. package/extensions/init-command.ts +33 -2
  103. package/extensions/session-report.ts +13 -20
  104. package/extensions/session.ts +9 -1
  105. package/extensions/workspace-runtime.ts +34 -4
  106. package/package.json +8 -3
  107. package/src/approval-prompt.ts +2 -1
  108. package/src/approval.ts +4 -2
  109. package/src/capabilities.ts +173 -4
  110. package/src/catalog.ts +62 -4
  111. package/src/check-runner.ts +8 -8
  112. package/src/cli.ts +24 -1
  113. package/src/definitions.ts +7 -1
  114. package/src/delegate.ts +42 -4
  115. package/src/delegation-approval.ts +37 -12
  116. package/src/executor.ts +2 -1
  117. package/src/grant-env.ts +39 -7
  118. package/src/index.ts +1 -0
  119. package/src/init.ts +40 -2
  120. package/src/lease-helper.ts +97 -0
  121. package/src/lease-record.ts +15 -3
  122. package/src/ledger-events.ts +66 -22
  123. package/src/ledger.ts +30 -6
  124. package/src/propagation.ts +23 -2
  125. package/src/refusals.ts +2 -0
  126. package/src/resolve.ts +35 -3
  127. package/src/routing-authority.ts +121 -0
  128. package/src/skill-packages.ts +20 -13
  129. package/src/workspace-lease.ts +84 -24
  130. package/src/workspace.ts +179 -6
@@ -0,0 +1,97 @@
1
+ /**
2
+ * **The lock helper: a separate program, and the parent's side of its pipes.**
3
+ *
4
+ * Split out of `workspace-lease.ts` because `test/file-size.test.ts` refused that file — at 405 lines on the
5
+ * ADR-0035 branch when PR #14 merged into it, and at 435 here once R-152's guards landed. Both branches take
6
+ * the same seam so they converge rather than diverge, and the cap has never been raised: `delegate.ts` was
7
+ * split at 413 the same way (rule: when a guard fails, obey it).
8
+ *
9
+ * The seam is not arbitrary. Everything here concerns the process that HOLDS the kernel lock — the source it
10
+ * runs, the readiness token it prints, and the parent-side handles that keep it referenced.
11
+ * `workspace-lease.ts` keeps the lease lifecycle that talks to it.
12
+ */
13
+
14
+ export const LEASE_READY = "PI_DADDY_LEASE_READY";
15
+ // The lock holder also owns crash cleanup for the governed child. If the parent dies, stdin closes;
16
+ // the helper signals the attached process, or closes the herdr tab, before releasing flock. A raw
17
+ // descendant deliberately detached by bash remains ADR-0012's OS-containment boundary, not a lease
18
+ // guarantee. The process branch SIGTERMs, escalates to SIGKILL at +500ms, and releases at +750ms
19
+ // WITHOUT confirming death (R-101). The herdr branch retries `tab close` a BOUNDED number of times
20
+ // and then releases anyway, leaving a marker file: an unreleasable lock strands a worktree forever
21
+ // with no in-product recovery, which is strictly worse than a recorded failure to close (R-102).
22
+ //
23
+ // **Each attempt is bounded in WALL CLOCK, not just in count (R-146).** `execFile` with no `timeout`
24
+ // never calls back if `herdr` accepts the close and does not answer, so the retry counter never
25
+ // decrements, `giveUp` never runs and no marker is written — the lock is held forever. That was masked
26
+ // while the parent could not exit, because the operator saw a hung `pi` instead; measured with a `herdr`
27
+ // that sleeps, the parent then exited in 82ms and left a silent strand, which is R-102's rejected outcome
28
+ // reached quietly. A bound on retries is not a bound on time.
29
+ export const HELPER_SOURCE = `
30
+ import { execFile } from "node:child_process";
31
+ import { writeFileSync } from "node:fs";
32
+ let clean=false, target=null, buffered="";
33
+ process.stdout.write(${JSON.stringify(`${LEASE_READY}:`)}+process.pid+"\\n");
34
+ process.stdin.setEncoding("utf8");
35
+ process.stdin.on("data",chunk=>{buffered+=chunk;for(;;){const i=buffered.indexOf("\\n");if(i<0)break;const line=buffered.slice(0,i);buffered=buffered.slice(i+1);try{const value=JSON.parse(line);if(value.release)clean=true;else if(value.process_pid)target={process_pid:value.process_pid};else if(value.herdr_tab)target={herdr_tab:value.herdr_tab};}catch{}}});
36
+ process.stdin.on("end",()=>{if(clean||!target)return process.exit(0);if(target.process_pid){try{process.kill(target.process_pid,"SIGTERM")}catch{return process.exit(0)}setTimeout(()=>{try{process.kill(target.process_pid,"SIGKILL")}catch{}},500);return setTimeout(()=>process.exit(0),750);}let left=Number(process.env.PI_DADDY_LEASE_CLOSE_ATTEMPTS||10);const giveUp=last=>{try{if(process.env.PI_DADDY_LEASE_MARKER)writeFileSync(process.env.PI_DADDY_LEASE_MARKER,JSON.stringify({reason:last&&(last.killed||last.signal==="SIGKILL")?"herdr-close-timeout":"herdr-close-failed",herdr_tab:target.herdr_tab})+"\\n")}catch{}process.exit(0)};const close=()=>execFile("herdr",["tab","close",target.herdr_tab],{timeout:Number(process.env.PI_DADDY_LEASE_CLOSE_TIMEOUT_MS||15000),killSignal:"SIGKILL"},error=>{if(!error)return process.exit(0);if(--left<=0)return giveUp(error);setTimeout(close,1000)});close();});
37
+ process.stdin.resume();`;
38
+
39
+ /**
40
+ * `unref` the parent's end of one of the helper's pipes.
41
+ *
42
+ * A spawned pipe is a `net.Socket`, which has `unref`; `ChildProcess.stdin` is typed `Writable`, which does
43
+ * not. Hence the narrow cast. The optional call is **defensive, not load-bearing** — the holder is spawned at
44
+ * exactly one site with three pipes, so none of these is ever `null`, and an earlier version of this comment
45
+ * claimed otherwise by citing a `stdio: "ignore"` caller that does not exist.
46
+ *
47
+ * Only `stdout` and `stderr` are unref'ed. `stdin` was too, until a line-by-line reversion showed the suite
48
+ * stayed green without it — an unforced line inside the fix that exists to be forced, which is exactly the
49
+ * shape R-122 was about.
50
+ */
51
+ export const unrefStream = (stream: unknown): void => {
52
+ (stream as { unref?: () => void } | null)?.unref?.();
53
+ };
54
+
55
+ /**
56
+ * The bounds on the helper's `herdr tab close` attempts live here, with the attempts they bound — moved out of
57
+ * `workspace-lease.ts` when the line ceiling refused it at 402 lines, once both this branch's ADR-0035 work and
58
+ * `main`'s R-152 guards had landed in it. Third time that guard has fired on this file and third time the
59
+ * answer was a seam rather than a bigger cap.
60
+ */
61
+ /**
62
+ * **A bound that is not a bound throws, and it throws a `RangeError` (R-146, R-152).**
63
+ *
64
+ * Both ends fail, in opposite directions and both silently:
65
+ *
66
+ * - `0` reads as "no limit" and Node agrees — `execFile` treats `timeout: 0` as *no* timeout (measured: the
67
+ * callback for a 3s sleep arrives at 3004ms with no error), reinstating the unbounded hang the bound exists
68
+ * to prevent. Negatives behave identically.
69
+ * - anything above `2^31 - 1` truncates: `setTimeout` warns `TimeoutOverflowWarning … set to 1`, so
70
+ * `Number.MAX_SAFE_INTEGER` — the plausible "effectively no limit" sentinel, given the argument about `0`
71
+ * — SIGKILLs every `herdr tab close` after 1ms, before herdr can act. Measured: callback at 3ms.
72
+ *
73
+ * **Not a `GovernanceRefusal`.** It was one, carrying `WORKSPACE_LEASE_STALE`, which everywhere else in this
74
+ * package means *the lease went stale or was lost* — so an ADR-0034 controller switching on codes (the reason
75
+ * codes exist, R-103) would classify a permanent caller bug as transient and retry a call that can never
76
+ * succeed. A refusal is a governance outcome that gets ledgered; a bad argument is neither.
77
+ *
78
+ * Checked for read leases too, which the first version did not: it sat below the read-lease early return, so a
79
+ * controller smoke-testing its configuration against a read lease got a false all-clear.
80
+ */
81
+ export const assertCloseBounds = (input: { herdrCloseTimeoutMs?: number; herdrCloseAttempts?: number }): void => {
82
+ const MAX_TIMER = 2_147_483_647;
83
+ for (const [name, value, ceiling] of [
84
+ ["herdrCloseTimeoutMs", input.herdrCloseTimeoutMs, MAX_TIMER],
85
+ ["herdrCloseAttempts", input.herdrCloseAttempts, Number.MAX_SAFE_INTEGER],
86
+ ] as const) {
87
+ if (value === undefined) continue;
88
+ if (!Number.isInteger(value) || value < 1 || value > ceiling) {
89
+ throw new RangeError(
90
+ `${name} must be a whole number between 1 and ${ceiling}, not ${String(value)}. Zero and negatives ` +
91
+ `are not "no limit" but no bound at all, a value past ${MAX_TIMER} truncates to 1ms, and a ` +
92
+ `fractional count is not a count — this bound exists so a hung herdr cannot hold the writer lock ` +
93
+ `forever (R-146).`,
94
+ );
95
+ }
96
+ }
97
+ };
@@ -53,13 +53,18 @@ export interface WorkspaceLease {
53
53
  * Needed because a retained lease never calls `release()`, so the metadata stays `state: "active"` and
54
54
  * whoever acquires next reports `recovered: true` — blaming a crash on a known-good path.
55
55
  */
56
- markRetained(reason?: string): Promise<void>;
56
+ /**
57
+ * Record that the lease is being KEPT rather than handed back, and answer what actually happened — the
58
+ * caller ledgers this word (R-152). It is not always `retained`: a helper that has already died makes the
59
+ * fact `lost`, and a lease already settled by `release()` keeps the outcome it had.
60
+ */
61
+ markRetained(reason?: string): Promise<LeaseReleaseOutcome>;
57
62
  /** Reads the give-up marker the helper leaves when it could not close a herdr writer tab. */
58
63
  readCloseFailure(): Promise<{ reason: string; herdr_tab?: string } | null>;
59
64
  }
60
65
 
61
66
  /**
62
- * What a release actually did. Five members rather than three, because the first version conflated facts
67
+ * What a release actually did. Six members rather than three, because the first version conflated facts
63
68
  * that call for different responses — and one of them is an alarm:
64
69
  * `released` the lock went back and THIS owner wrote its own handover;
65
70
  * `released-unrecorded` the lock went back and the record does not say so, so the next owner will report
@@ -77,7 +82,14 @@ export type LeaseReleaseOutcome =
77
82
  | "released-unrecorded"
78
83
  | "released-superseded"
79
84
  | "not-held"
80
- | "lost";
85
+ | "lost"
86
+ /**
87
+ * The lease was RETAINED and is therefore already settled — `release()` after `markRetained()` answers
88
+ * this instead of running the clean handshake (R-146). It was previously expressible only as the
89
+ * `| "retained"` bolted onto two signatures, which is why `release()` could not say it and claimed
90
+ * `released` instead: a clean handover for a lease kept precisely because a pane would not close.
91
+ */
92
+ | "retained";
81
93
 
82
94
  export function leasePaths(leaseDir: string, root: string) {
83
95
  // Canonical root, never caller-chosen workspace ID: aliases for one worktree must contend.
@@ -9,6 +9,24 @@ import type { ExecutorKind } from "./executor.ts";
9
9
  import type { CorrelationMetadata } from "./correlation.ts";
10
10
  import type { StructuredRefusal } from "./refusals.ts";
11
11
 
12
+ export const WORKSPACE_ACCESSES = ["read", "write"] as const;
13
+ export type WorkspaceAccess = typeof WORKSPACE_ACCESSES[number];
14
+
15
+ export const WORKSPACE_RECOVERY_VALUES = [false, true, "unknown"] as const;
16
+ export type WorkspaceRecovery = typeof WORKSPACE_RECOVERY_VALUES[number];
17
+
18
+ export const CHILD_LIFECYCLE_STATES = ["starting", "completed", "failed"] as const;
19
+ export type ChildLifecycleState = typeof CHILD_LIFECYCLE_STATES[number];
20
+
21
+ /** The complete Node signal vocabulary accepted by a v2 child-lifecycle event. */
22
+ export const CHILD_PROCESS_SIGNALS = [
23
+ "SIGABRT", "SIGALRM", "SIGBUS", "SIGCHLD", "SIGCONT", "SIGFPE", "SIGHUP", "SIGILL", "SIGINT", "SIGIO",
24
+ "SIGIOT", "SIGKILL", "SIGPIPE", "SIGPOLL", "SIGPROF", "SIGPWR", "SIGQUIT", "SIGSEGV", "SIGSTKFLT",
25
+ "SIGSTOP", "SIGSYS", "SIGTERM", "SIGTRAP", "SIGTSTP", "SIGTTIN", "SIGTTOU", "SIGUNUSED", "SIGURG",
26
+ "SIGUSR1", "SIGUSR2", "SIGVTALRM", "SIGWINCH", "SIGXCPU", "SIGXFSZ", "SIGBREAK", "SIGLOST", "SIGINFO",
27
+ ] as const satisfies readonly NodeJS.Signals[];
28
+ export type ChildProcessSignal = typeof CHILD_PROCESS_SIGNALS[number];
29
+
12
30
  /**
13
31
  * `released` is a handover this owner performed. FOUR members were added by the 0.18.0 review pass, and
14
32
  * they were not all previously recorded the same way — `uncontended` was recorded as an *acquisition*, and
@@ -20,16 +38,11 @@ import type { StructuredRefusal } from "./refusals.ts";
20
38
  * `uncontended` a read lease took no kernel lock at all, so counting it as an acquisition
21
39
  * overstated how many exclusions the kernel actually performed (R-105).
22
40
  */
23
- export type WorkspaceLeaseOutcome =
24
- | "acquired"
25
- | "uncontended"
26
- | "refused"
27
- | "released"
28
- | "released-unrecorded"
29
- | "lost"
30
- | "retained"
31
- | "timeout"
32
- | "recovered";
41
+ export const WORKSPACE_LEASE_OUTCOMES = [
42
+ "acquired", "uncontended", "refused", "released", "released-unrecorded", "lost", "retained", "timeout",
43
+ "recovered",
44
+ ] as const;
45
+ export type WorkspaceLeaseOutcome = typeof WORKSPACE_LEASE_OUTCOMES[number];
33
46
 
34
47
  export interface WorkspaceLeaseEvent extends LedgerEventBase {
35
48
  ledgerVersion: typeof LEDGER_VERSION;
@@ -37,10 +50,10 @@ export interface WorkspaceLeaseEvent extends LedgerEventBase {
37
50
  childId: string;
38
51
  workspaceId: string;
39
52
  root: string;
40
- access: "read" | "write";
53
+ access: WorkspaceAccess;
41
54
  outcome: WorkspaceLeaseOutcome;
42
55
  /** `"unknown"` when the prior owner's record was unreadable — not evidence of a clean handover. */
43
- recovered?: boolean | "unknown";
56
+ recovered?: WorkspaceRecovery;
44
57
  releaseReason?: string;
45
58
  refusal?: StructuredRefusal;
46
59
  }
@@ -49,13 +62,13 @@ export interface ChildLifecycleEvent extends LedgerEventBase {
49
62
  ledgerVersion: typeof LEDGER_VERSION;
50
63
  event: "child_lifecycle";
51
64
  childId: string;
52
- state: "starting" | "completed" | "failed";
65
+ state: ChildLifecycleState;
53
66
  executor: ExecutorKind;
54
67
  exitCode?: number | null;
55
- signal?: NodeJS.Signals | null;
56
- timedOut?: boolean;
57
- aborted?: boolean;
58
- truncated?: boolean;
68
+ signal?: ChildProcessSignal | null;
69
+ timedOut?: true;
70
+ aborted?: true;
71
+ truncated?: true;
59
72
  reason?: string;
60
73
  }
61
74
 
@@ -69,8 +82,14 @@ export interface CheckReceiptLedgerEvent extends LedgerEventBase {
69
82
  treeSha: string;
70
83
  }
71
84
 
85
+ export type CapabilityDecisionEvent = GrantRecord & {
86
+ ledgerVersion: typeof LEDGER_VERSION;
87
+ event: "capability_decision";
88
+ taskDigest: string;
89
+ };
90
+
72
91
  export type RuntimeLedgerEvent =
73
- | (GrantRecord & { ledgerVersion: typeof LEDGER_VERSION; event: "capability_decision" })
92
+ | CapabilityDecisionEvent
74
93
  | WorkspaceLeaseEvent
75
94
  | ChildLifecycleEvent
76
95
  | CheckReceiptLedgerEvent;
@@ -79,9 +98,9 @@ export function buildWorkspaceLeaseEvent(args: {
79
98
  childId: string;
80
99
  workspaceId: string;
81
100
  root: string;
82
- access: "read" | "write";
101
+ access: WorkspaceAccess;
83
102
  outcome: WorkspaceLeaseOutcome;
84
- recovered?: boolean | "unknown";
103
+ recovered?: WorkspaceRecovery;
85
104
  releaseReason?: string;
86
105
  refusal?: StructuredRefusal;
87
106
  correlation?: CorrelationMetadata;
@@ -107,12 +126,37 @@ export function buildWorkspaceLeaseEvent(args: {
107
126
  };
108
127
  }
109
128
 
129
+ export function buildCheckReceiptLedgerEvent(args: {
130
+ childId: string;
131
+ receiptId: string;
132
+ workspaceId: string;
133
+ checkId: string;
134
+ treeSha: string;
135
+ correlation?: CorrelationMetadata;
136
+ now: Date;
137
+ }): CheckReceiptLedgerEvent {
138
+ if (!/^[a-f0-9]{64}$/i.test(args.receiptId)) {
139
+ throw new TypeError("receiptId must be a SHA-256 hex digest");
140
+ }
141
+ return {
142
+ ledgerVersion: LEDGER_VERSION,
143
+ event: "check_receipt",
144
+ ts: args.now.toISOString(),
145
+ childId: args.childId,
146
+ receiptId: args.receiptId,
147
+ workspaceId: args.workspaceId,
148
+ checkId: args.checkId,
149
+ treeSha: args.treeSha,
150
+ ...(args.correlation ? { correlation: structuredClone(args.correlation) } : {}),
151
+ };
152
+ }
153
+
110
154
  export function buildChildLifecycleEvent(args: {
111
155
  childId: string;
112
- state: "starting" | "completed" | "failed";
156
+ state: ChildLifecycleState;
113
157
  executor: ExecutorKind;
114
158
  exitCode?: number | null;
115
- signal?: NodeJS.Signals | null;
159
+ signal?: ChildProcessSignal | null;
116
160
  timedOut?: boolean;
117
161
  aborted?: boolean;
118
162
  truncated?: boolean;
package/src/ledger.ts CHANGED
@@ -34,11 +34,17 @@ import type { StructuredRefusal } from "./refusals.ts";
34
34
  import type { RuntimeLedgerEvent } from "./ledger-events.ts";
35
35
 
36
36
  export const LEDGER_VERSION = 2 as const;
37
+ export const LEDGER_EVENT_KINDS = [
38
+ "capability_decision", "workspace_lease", "child_lifecycle", "check_receipt",
39
+ ] as const;
40
+ export type LedgerEventKind = typeof LEDGER_EVENT_KINDS[number];
41
+ export const LEDGER_GATE_OUTCOMES = ["declined", "dismissed", "no-ui", "error"] as const;
42
+ export type LedgerGateOutcome = typeof LEDGER_GATE_OUTCOMES[number];
37
43
 
38
44
  export interface LedgerEventBase {
39
45
  /** Optional only on the legacy-compatible `GrantRecord` public type; every v2 event builder writes it. */
40
46
  ledgerVersion?: typeof LEDGER_VERSION;
41
- event?: "capability_decision" | "workspace_lease" | "child_lifecycle" | "check_receipt";
47
+ event?: LedgerEventKind;
42
48
  ts: string;
43
49
  childId?: string;
44
50
  correlation?: CorrelationMetadata;
@@ -104,7 +110,7 @@ export interface GrantRecord extends LedgerEventBase {
104
110
  /** Present only when the source was a live prompt, and only when one scope covers the whole set. */
105
111
  approvalScope?: ApprovalScope;
106
112
  /** A human was asked and declined. Distinct from `denied`, which is an escalation attempt. */
107
- humanDenied?: boolean;
113
+ humanDenied?: true;
108
114
  /**
109
115
  * WHY a gate went unsatisfied, when the answer was not a yes.
110
116
  *
@@ -120,9 +126,10 @@ export interface GrantRecord extends LedgerEventBase {
120
126
  * say *"nobody was there to ask"* and be believed, so it is recorded rather than inferred.
121
127
  *
122
128
  * **Privacy is unchanged**: this is a fixed five-member enum, not text — nothing model-authored, nothing
123
- * a task could carry.
129
+ * a task could carry. The prompt has five outcomes, but `granted` is deliberately omitted from the
130
+ * four-member ledger enum because the approval source/scope fields already record a yes.
124
131
  */
125
- gateOutcome?: PromptOutcomeKind;
132
+ gateOutcome?: LedgerGateOutcome;
126
133
  /**
127
134
  * WHICH operator-authored instructions this child was given (ADR-0018).
128
135
  *
@@ -208,6 +215,12 @@ export function buildRecord(args: {
208
215
  refusal?: StructuredRefusal;
209
216
  now: Date;
210
217
  }): GrantRecord {
218
+ if (args.taskDigest !== undefined && !/^[a-f0-9]{64}$/i.test(args.taskDigest)) {
219
+ throw new TypeError("taskDigest must be a SHA-256 hex digest");
220
+ }
221
+ if (args.definitionDigest && !/^[a-f0-9]{64}$/i.test(args.definitionDigest.sha256)) {
222
+ throw new TypeError("definitionDigest.sha256 must be a SHA-256 hex digest");
223
+ }
211
224
  // R-46: the scalar is a SUMMARY, emitted only when it cannot mislead. `buildRecord` derives it rather
212
225
  // than accepting it, so a call site cannot supply one that disagrees with the map beside it.
213
226
  const sources = args.approvalSources ?? {};
@@ -217,7 +230,7 @@ export function buildRecord(args: {
217
230
  return {
218
231
  // A trusted task digest is what distinguishes a v2 capability event from the legacy construction API.
219
232
  // Production delegation always supplies it; old TypeScript callers remain able to append legacy lines.
220
- ...(args.taskDigest ? { ledgerVersion: LEDGER_VERSION, event: "capability_decision" as const } : {}),
233
+ ...(args.taskDigest !== undefined ? { ledgerVersion: LEDGER_VERSION, event: "capability_decision" as const } : {}),
221
234
  ts: args.now.toISOString(),
222
235
  parentId: args.parentId,
223
236
  childId: args.childId,
@@ -225,7 +238,7 @@ export function buildRecord(args: {
225
238
  agentType: args.agentType,
226
239
  executor: args.executor,
227
240
  ...(args.taskFrom ? { taskFrom: args.taskFrom } : {}),
228
- ...(args.taskDigest ? { taskDigest: args.taskDigest } : {}),
241
+ ...(args.taskDigest !== undefined ? { taskDigest: args.taskDigest } : {}),
229
242
  ...(args.correlation ? { correlation: structuredClone(args.correlation) } : {}),
230
243
  ...(args.refusal ? { refusal: structuredClone(args.refusal) } : {}),
231
244
  requested: args.requested,
@@ -302,11 +315,22 @@ export function isEscalationAttempt(record: GrantRecord): boolean {
302
315
  }
303
316
 
304
317
  export {
318
+ CHILD_LIFECYCLE_STATES,
319
+ CHILD_PROCESS_SIGNALS,
320
+ WORKSPACE_ACCESSES,
321
+ WORKSPACE_LEASE_OUTCOMES,
322
+ WORKSPACE_RECOVERY_VALUES,
323
+ buildCheckReceiptLedgerEvent,
305
324
  buildChildLifecycleEvent,
306
325
  buildWorkspaceLeaseEvent,
326
+ type CapabilityDecisionEvent,
307
327
  type ChildLifecycleEvent,
328
+ type ChildLifecycleState,
329
+ type ChildProcessSignal,
308
330
  type CheckReceiptLedgerEvent,
309
331
  type RuntimeLedgerEvent,
332
+ type WorkspaceAccess,
310
333
  type WorkspaceLeaseEvent,
311
334
  type WorkspaceLeaseOutcome,
335
+ type WorkspaceRecovery,
312
336
  } from "./ledger-events.ts";
@@ -27,7 +27,9 @@
27
27
 
28
28
  import type { Capability } from "./resolve.ts";
29
29
  import { WILDCARD } from "./pi-tools.ts";
30
+ import { WORKSPACE_WILDCARD } from "./resolve.ts";
30
31
  import { inheritApprovals, type InheritableApproval } from "./approval.ts";
32
+ import { assertCapabilitiesArePropagatable } from "./capabilities.ts";
31
33
 
32
34
  export const ENV_GRANT = "PI_GRANTS_GRANT";
33
35
  /**
@@ -246,11 +248,30 @@ export interface ChildEnvInput {
246
248
  * empty grant, so they can spawn nothing. That fails closed. It is also unreachable in normal flow,
247
249
  * because a session's first provider request always precedes its first tool call.
248
250
  */
251
+ /**
252
+ * The part of a grant a child may inherit: everything but the wildcards that are HELD, never handed down.
253
+ *
254
+ * `tool:*` for R-26's reason — a root that handed it down let every descendant reacquire the full catalog.
255
+ * `workspace:*` for the same reason one namespace over (R-131): a descendant holding it could route anywhere
256
+ * the registry lists, which is the attenuation failure ADR-0035 exists to close. `agent:*` is deliberately
257
+ * NOT stripped — ADR-0023 decided that a session authorised for any definition passes that on, because
258
+ * definitions are ceilings rather than roots.
259
+ *
260
+ * **Exported, and there is exactly one spelling of this rule on purpose.** It shipped as a filter inline in
261
+ * `childEnv` and nowhere else, so a grant travelling the OTHER path — `delegate.ts` building a child's
262
+ * `PI_GRANTS_GRANT` from `result.effective` — carried `workspace:*` straight down. The test written beside
263
+ * that fix exercised `childEnv`, which is not the path a delegated child's grant travels, so it could not
264
+ * catch it. R-28's shape: two routes for one rule, with the guard on the quieter one. Both call this now.
265
+ */
266
+ export function inheritableGrant(grant: readonly Capability[]): Capability[] {
267
+ return grant.filter((c) => c !== WILDCARD && c !== WORKSPACE_WILDCARD);
268
+ }
269
+
249
270
  export function childEnv(input: ChildEnvInput): Record<string, string> {
250
271
  if (input.governed === false) return {};
251
- const inheritable = input.ownGrant.filter((c) => c !== WILDCARD);
272
+ const inheritable = inheritableGrant(input.ownGrant);
252
273
  const env: Record<string, string> = {
253
- [ENV_GRANT]: inheritable.join(","),
274
+ [ENV_GRANT]: (assertCapabilitiesArePropagatable(inheritable), inheritable.join(",")),
254
275
  [ENV_DEPTH]: String(input.depth + 1),
255
276
  [ENV_MAX_DEPTH]: String(input.maxDepth),
256
277
  };
package/src/refusals.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export const REFUSAL_CODES = [
2
2
  "CAPABILITY_ESCALATION",
3
+ "GRANT_ID_MALFORMED",
3
4
  "DEFINITION_NOT_AUTHORIZED",
4
5
  "UNDECLARED_TOOLS",
5
6
  "UNKNOWN_TOOL",
@@ -27,6 +28,7 @@ export const REFUSAL_CODES = [
27
28
  "LEDGER_WRITE_FAILED",
28
29
  "FANOUT_FAILED",
29
30
  "WORKSPACE_NOT_REGISTERED",
31
+ "WORKSPACE_NOT_AUTHORIZED",
30
32
  "WORKSPACE_WRITE_CONFLICT",
31
33
  "WORKSPACE_LEASE_STALE",
32
34
  "CHECK_NOT_CONFIGURED",
package/src/resolve.ts CHANGED
@@ -62,6 +62,7 @@ export function expandSubsumed(grant: Capability[]): Capability[] {
62
62
  }
63
63
 
64
64
  import { WILDCARD } from "./pi-tools.ts";
65
+ import { isWellFormedCapability } from "./capabilities.ts";
65
66
 
66
67
  /**
67
68
  * "Any definition" — ADR-0023, and one of two wildcards this module understands.
@@ -75,6 +76,16 @@ import { WILDCARD } from "./pi-tools.ts";
75
76
  * `tool:*` — authority to grant every tool — which made the safe configuration the laborious one.
76
77
  */
77
78
  export const AGENT_WILDCARD: Capability = "agent:*";
79
+ /**
80
+ * `workspace:*` covers any `workspace:<id>` — ADR-0035, and the second namespace wildcard.
81
+ *
82
+ * Added as a deliberate edit, which is what the comment in `resolve()` below asks for: there is no
83
+ * generalised `<ns>:*` rule, so a namespace does not acquire a wildcard by existing. Unlike `agent:*` this
84
+ * one is **not inheritable** (`childEnv` strips it) — R-26's rule, because a root that handed
85
+ * `workspace:*` down would make routing attenuation meaningless below the root, which is the exact defect
86
+ * R-131 records.
87
+ */
88
+ export const WORKSPACE_WILDCARD: Capability = "workspace:*";
78
89
 
79
90
  export interface ResolveInput {
80
91
  /** What the delegating agent asked to give the child. */
@@ -124,7 +135,10 @@ export function resolve(input: ResolveInput): ResolveResult {
124
135
  const parent =
125
136
  input.subsumption === false ? held : new Set(expandSubsumed(input.parentGrant));
126
137
  /**
127
- * `agent:*` covers any `agent:<name>` — ADR-0023, and the ONLY wildcard rule in this function.
138
+ * `agent:*` covers any `agent:<name>` — ADR-0023. It was the only wildcard rule in this function when
139
+ * that was written; ADR-0035 added a third, so `covered()` now has `tool:*`, `agent:*` and `workspace:*`.
140
+ * There is still no GENERALISED `<ns>:*` rule: each is a deliberate edit, which is the property worth
141
+ * keeping. Corrected because the sentence sat eighty lines above "all THREE wildcards are excluded".
128
142
  *
129
143
  * `resolve` is otherwise exact-match plus subsumption, deliberately: `tool:*` works not because anything
130
144
  * here understands it, but because `deriveOwnGrant` *enumerates* a session's observed tool names beside
@@ -149,8 +163,17 @@ export function resolve(input: ResolveInput): ResolveResult {
149
163
  * spellings of one rule, and the enforcing one was wrong.
150
164
  */
151
165
  const anyCapability = held.has(WILDCARD);
166
+ const anyWorkspace = held.has(WORKSPACE_WILDCARD);
167
+ // A malformed id is never covered — not by an exact hold, and above all not by a wildcard's PREFIX rule,
168
+ // which is how `agent:x,tool:bash` got admitted and then split into two capabilities in the child.
169
+ // Landing in `denied` is the right outcome rather than a throw: it fails closed AND records an
170
+ // escalation attempt, so the ledger shows the attempt instead of a clean line.
152
171
  const covered = (c: Capability): boolean =>
153
- parent.has(c) || anyCapability || (anyDefinition && c.startsWith("agent:"));
172
+ isWellFormedCapability(c)
173
+ && (parent.has(c) || anyCapability
174
+ || (anyDefinition && c.startsWith("agent:"))
175
+ || (anyWorkspace && c.startsWith("workspace:")));
176
+
154
177
  const ceiling = input.ceiling === undefined ? null : new Set(input.ceiling);
155
178
  const gated = new Set(input.gated ?? []);
156
179
  const approved = new Set(input.approved ?? []);
@@ -193,7 +216,16 @@ export function resolve(input: ResolveInput): ResolveResult {
193
216
  // F9: capabilities covered by a WILDCARD are not "subsumed" — this field means "the grant is broader
194
217
  // than its list suggests", which is the `bash`-covers-`grep` warning. A wildcard holder already knows
195
218
  // its grant is broad; listing every id under it would bury the signal the field exists to carry.
196
- subsumedBy: effective.filter((c) => !held.has(c) && !anyCapability && !(anyDefinition && c.startsWith("agent:"))),
219
+ //
220
+ // All THREE wildcards are excluded. `anyWorkspace` was missing, so a caller holding `workspace:*` saw
221
+ // its own `workspace:prod` reported as subsumed while the `agent:*` holder correctly saw nothing — the
222
+ // field contradicting its own rule, and a false "broader than it looks" flag in the ledger and in
223
+ // `/grants`. Every wildcard added to `covered()` above needs a line here; that is now three for three.
224
+ subsumedBy: effective.filter((c) =>
225
+ !held.has(c)
226
+ && !anyCapability
227
+ && !(anyDefinition && c.startsWith("agent:"))
228
+ && !(anyWorkspace && c.startsWith("workspace:"))),
197
229
  };
198
230
  }
199
231
 
@@ -0,0 +1,121 @@
1
+ /**
2
+ * ADR-0035's three routing guards, and why each one is shaped the way it is.
3
+ *
4
+ * Lifted out of `delegate.ts` when it crossed the 400-line ceiling `test/file-size.test.ts` enforces — the
5
+ * same move `grants.ts` made at 398 and `delegate.ts` itself made at 413. It is a real seam rather than a
6
+ * line-count dodge: all three answer one question, *may this caller route this child to this workspace, and
7
+ * is the id even usable as a capability?*, and none of them looks at anything else in a delegation. The
8
+ * alternative was trimming the rationale below to fit, which is how a codebase loses the reasons for its
9
+ * guards.
10
+ *
11
+ * Each returns a refusal DESCRIPTOR rather than a `Delegation`, so the planner keeps sole ownership of
12
+ * assembling records. `denied` is present exactly when the attempt should count as an escalation.
13
+ */
14
+
15
+ import { mayRouteToWorkspace, isSafeWorkspaceId, workspaceCapability } from "./capabilities.ts";
16
+ import { WORKSPACE_WILDCARD, type Capability } from "./resolve.ts";
17
+ import { WILDCARD } from "./pi-tools.ts";
18
+ import type { RefusalCode } from "./refusals.ts";
19
+
20
+ export interface RoutingRefusal {
21
+ code: RefusalCode;
22
+ reason: string;
23
+ /**
24
+ * The capability to record in `denied`, when this refusal IS an escalation attempt.
25
+ *
26
+ * Absent for a malformed id, which is a bad request rather than a bid for authority — and seeding `denied`
27
+ * with an id that re-splits into several capabilities would make the one signal `isEscalationAttempt` reads
28
+ * unparseable. Present for an unauthorised route, which is exactly a bid for authority.
29
+ */
30
+ denied?: Capability[];
31
+ }
32
+
33
+ /**
34
+ * May this caller route a child to `boundWorkspaceId`, and is that id usable as a capability id at all?
35
+ *
36
+ * Checked before anything is said about the target, for the reason `maySpawnDefinition` is: it is a
37
+ * governance question about the SESSION. Before ADR-0035 nothing checked it — the registry inherited into
38
+ * every governed child and a child routed to `staging` could route its grandchild to `prod` (R-131, measured
39
+ * in `docs/probes/g36-workspace-attenuation`).
40
+ *
41
+ * **Well-formedness first, because `workspace_id` is a model-facing tool parameter** and the next step turns
42
+ * it into a capability id. `workspace_id: "prod,tool:bash"` produced a `WORKSPACE_NOT_AUTHORIZED` whose
43
+ * `denied` array held `workspace:prod,tool:bash`: no authority minted, since the refusal is terminal, but
44
+ * `denied` is the channel `isEscalationAttempt` and `/grants ledger` count and an id that re-splits makes it
45
+ * unreadable. 0.18.1 is what happens when a comma goes unremarked.
46
+ *
47
+ * **`!== undefined`, not truthiness.** `workspace_id: ""` is falsy, so it skipped BOTH checks and failed
48
+ * closed much later at `resolveWorkspace` with `denied: []` — an unauthorised routing attempt invisible to
49
+ * every audit query. An empty id is a malformed id, not an absent one.
50
+ */
51
+ export function checkRoutingAuthority(
52
+ boundWorkspaceId: string | undefined,
53
+ ownGrant: readonly Capability[],
54
+ ): RoutingRefusal | null {
55
+ if (boundWorkspaceId === undefined) return null;
56
+ const authorising = workspaceCapability(boundWorkspaceId);
57
+ if (!isSafeWorkspaceId(boundWorkspaceId)) {
58
+ return {
59
+ code: "GRANT_ID_MALFORMED",
60
+ reason:
61
+ `workspace id ${JSON.stringify(boundWorkspaceId)} is not usable as a capability id — it would ` +
62
+ `become ${JSON.stringify(authorising)}, and an id must match [A-Za-z0-9][A-Za-z0-9._/-]*. Slashes ` +
63
+ `and dots are fine; spaces, quotes, commas, wildcards, shell metacharacters and non-ASCII are not.`,
64
+ };
65
+ }
66
+ if (mayRouteToWorkspace(ownGrant, boundWorkspaceId)) return null;
67
+ // Filtered by the grammar too: an id that cannot pass `isSafeWorkspaceId` is refused on every
68
+ // attempt, so listing it here points the model at a destination it can never reach.
69
+ const held = ownGrant
70
+ .filter((c) => c.startsWith("workspace:") && isSafeWorkspaceId(c.slice("workspace:".length)))
71
+ .sort();
72
+ return {
73
+ code: "WORKSPACE_NOT_AUTHORIZED",
74
+ // Recorded as a denial rather than a bare refusal, exactly as DEFINITION_NOT_AUTHORIZED is: asking to
75
+ // route somewhere this session was not granted IS an attempt to exceed the grant.
76
+ denied: [authorising],
77
+ reason:
78
+ `cannot route a child to workspace "${boundWorkspaceId}" — this session does not hold ${authorising}. ` +
79
+ (held.length > 0
80
+ ? `It may route to: ${held.join(", ")}.`
81
+ : `It may route to no workspace at all; add ${authorising} to its grant to allow this one.`),
82
+ };
83
+ }
84
+
85
+ /**
86
+ * May `workspace:*` be handed to a child? No — and only its HOLDER gets told so in those words.
87
+ *
88
+ * `workspace:*` is held and never inherited, so a child that "was granted" it would receive an env without
89
+ * it, and the ledger would record an authority the child does not have — the mirror image of R-131 and just
90
+ * as unreadable. `childEnv` strips it from a session's OWN grant because a root legitimately holds it; asking
91
+ * to hand it to a child is a different act and the honest answer is no. `NARROWING_VIOLATED` is the existing
92
+ * code for "this grant would not actually narrow", which is precisely what routing authority over every
93
+ * registered root does.
94
+ *
95
+ * **`tool:*` does NOT reach the same outcome, and an earlier comment claimed it did.** It said
96
+ * `assertNarrowing` produces the equivalent refusal; it does not. `UNIVERSAL_CAPABILITIES` is
97
+ * `["ext:pi-fabric/fabric_exec", "tool:fabric_exec"]`, so `result.universal` is empty for `tool:*` and
98
+ * nothing throws: requesting it for a child is allowed, recorded as granted, and silently stripped from the
99
+ * child env by `inheritableGrant` (R-135). The asymmetry is deliberate — an ungoverned session's own grant IS
100
+ * `tool:*`, so refusing to spawn from one would break governance-is-opt-in, while `workspace:*` only ever
101
+ * appears in `requested` because somebody asked — but it is an asymmetry, not one rule twice.
102
+ *
103
+ * **Only for a holder.** A caller without it is attempting an escalation, and this refusal would tell it the
104
+ * wildcard "is held, never inherited" — false about a session holding nothing of the sort — while leaving
105
+ * `denied` empty, so nothing counted a model probing `tools: ["workspace:*"]`. Returning `null` lets
106
+ * `resolve()` deny it as uncovered, which is what `agent:*` and `tool:*` already do.
107
+ */
108
+ export function checkWorkspaceWildcardRequest(
109
+ requested: readonly Capability[],
110
+ ownGrant: readonly Capability[],
111
+ ): RoutingRefusal | null {
112
+ if (!requested.includes(WORKSPACE_WILDCARD)) return null;
113
+ if (!ownGrant.includes(WORKSPACE_WILDCARD) && !ownGrant.includes(WILDCARD)) return null;
114
+ return {
115
+ code: "NARROWING_VIOLATED",
116
+ reason:
117
+ `cannot grant ${WORKSPACE_WILDCARD} to a child — it is held, never inherited, because a descendant ` +
118
+ `holding it could route anywhere the registry lists and routing would stop attenuating below here. ` +
119
+ `Name the workspaces this child may route to instead (workspace:<id>, one per id).`,
120
+ };
121
+ }