pi-daddy 0.18.1 → 0.20.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 (218) hide show
  1. package/CHANGELOG.md +201 -0
  2. package/README.md +136 -23
  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/contracts/ledger/v3/README.md +36 -0
  10. package/contracts/ledger/v3/fixtures/capability-decision.json +98 -0
  11. package/contracts/ledger/v3/fixtures/check-receipt.json +42 -0
  12. package/contracts/ledger/v3/fixtures/child-lifecycle.json +45 -0
  13. package/contracts/ledger/v3/fixtures/workflow-fact.json +41 -0
  14. package/contracts/ledger/v3/fixtures/workspace-lease.json +43 -0
  15. package/contracts/ledger/v3/ledger-event.schema.json +930 -0
  16. package/dist/approval-prompt.d.ts +2 -1
  17. package/dist/approval-prompt.d.ts.map +1 -1
  18. package/dist/approval-prompt.js +9 -0
  19. package/dist/approval-prompt.js.map +1 -1
  20. package/dist/approval.d.ts +4 -2
  21. package/dist/approval.d.ts.map +1 -1
  22. package/dist/approval.js +4 -0
  23. package/dist/approval.js.map +1 -1
  24. package/dist/capabilities.d.ts +90 -0
  25. package/dist/capabilities.d.ts.map +1 -1
  26. package/dist/capabilities.js +114 -3
  27. package/dist/capabilities.js.map +1 -1
  28. package/dist/catalog.d.ts +15 -1
  29. package/dist/catalog.d.ts.map +1 -1
  30. package/dist/catalog.js +54 -3
  31. package/dist/catalog.js.map +1 -1
  32. package/dist/check-runner.d.ts.map +1 -1
  33. package/dist/check-runner.js +19 -7
  34. package/dist/check-runner.js.map +1 -1
  35. package/dist/cli.d.ts.map +1 -1
  36. package/dist/cli.js +21 -1
  37. package/dist/cli.js.map +1 -1
  38. package/dist/correlation.d.ts.map +1 -1
  39. package/dist/correlation.js +10 -0
  40. package/dist/correlation.js.map +1 -1
  41. package/dist/dashboard-cli.d.ts +18 -0
  42. package/dist/dashboard-cli.d.ts.map +1 -0
  43. package/dist/dashboard-cli.js +154 -0
  44. package/dist/dashboard-cli.js.map +1 -0
  45. package/dist/dashboard-handshake.d.ts +37 -0
  46. package/dist/dashboard-handshake.d.ts.map +1 -0
  47. package/dist/dashboard-handshake.js +127 -0
  48. package/dist/dashboard-handshake.js.map +1 -0
  49. package/dist/dashboard-herdr.d.ts +54 -0
  50. package/dist/dashboard-herdr.d.ts.map +1 -0
  51. package/dist/dashboard-herdr.js +286 -0
  52. package/dist/dashboard-herdr.js.map +1 -0
  53. package/dist/dashboard-projection.d.ts +73 -0
  54. package/dist/dashboard-projection.d.ts.map +1 -0
  55. package/dist/dashboard-projection.js +294 -0
  56. package/dist/dashboard-projection.js.map +1 -0
  57. package/dist/dashboard-render.d.ts +10 -0
  58. package/dist/dashboard-render.d.ts.map +1 -0
  59. package/dist/dashboard-render.js +208 -0
  60. package/dist/dashboard-render.js.map +1 -0
  61. package/dist/definitions.d.ts.map +1 -1
  62. package/dist/definitions.js +7 -1
  63. package/dist/definitions.js.map +1 -1
  64. package/dist/delegate-types.d.ts +6 -2
  65. package/dist/delegate-types.d.ts.map +1 -1
  66. package/dist/delegate-types.js.map +1 -1
  67. package/dist/delegate.d.ts.map +1 -1
  68. package/dist/delegate.js +34 -4
  69. package/dist/delegate.js.map +1 -1
  70. package/dist/delegation-approval.d.ts.map +1 -1
  71. package/dist/delegation-approval.js +37 -12
  72. package/dist/delegation-approval.js.map +1 -1
  73. package/dist/execution-id.d.ts +6 -0
  74. package/dist/execution-id.d.ts.map +1 -0
  75. package/dist/execution-id.js +13 -0
  76. package/dist/execution-id.js.map +1 -0
  77. package/dist/executor.d.ts +2 -1
  78. package/dist/executor.d.ts.map +1 -1
  79. package/dist/executor.js +1 -0
  80. package/dist/executor.js.map +1 -1
  81. package/dist/grant-env.d.ts +2 -0
  82. package/dist/grant-env.d.ts.map +1 -1
  83. package/dist/grant-env.js +26 -3
  84. package/dist/grant-env.js.map +1 -1
  85. package/dist/index.d.ts +4 -1
  86. package/dist/index.d.ts.map +1 -1
  87. package/dist/index.js +4 -1
  88. package/dist/index.js.map +1 -1
  89. package/dist/init.d.ts +13 -1
  90. package/dist/init.d.ts.map +1 -1
  91. package/dist/init.js +35 -2
  92. package/dist/init.js.map +1 -1
  93. package/dist/lease-helper.d.ts +58 -0
  94. package/dist/lease-helper.d.ts.map +1 -0
  95. package/dist/lease-helper.js +94 -0
  96. package/dist/lease-helper.js.map +1 -0
  97. package/dist/lease-record.d.ts +15 -3
  98. package/dist/lease-record.d.ts.map +1 -1
  99. package/dist/lease-record.js.map +1 -1
  100. package/dist/ledger-events.d.ts +59 -15
  101. package/dist/ledger-events.d.ts.map +1 -1
  102. package/dist/ledger-events.js +67 -5
  103. package/dist/ledger-events.js.map +1 -1
  104. package/dist/ledger-identifiers.d.ts +7 -0
  105. package/dist/ledger-identifiers.d.ts.map +1 -0
  106. package/dist/ledger-identifiers.js +20 -0
  107. package/dist/ledger-identifiers.js.map +1 -0
  108. package/dist/ledger-report.d.ts +5 -2
  109. package/dist/ledger-report.d.ts.map +1 -1
  110. package/dist/ledger-report.js +38 -14
  111. package/dist/ledger-report.js.map +1 -1
  112. package/dist/ledger-v3-validation.d.ts +11 -0
  113. package/dist/ledger-v3-validation.d.ts.map +1 -0
  114. package/dist/ledger-v3-validation.js +265 -0
  115. package/dist/ledger-v3-validation.js.map +1 -0
  116. package/dist/ledger.d.ts +27 -9
  117. package/dist/ledger.d.ts.map +1 -1
  118. package/dist/ledger.js +38 -6
  119. package/dist/ledger.js.map +1 -1
  120. package/dist/propagation.d.ts +19 -1
  121. package/dist/propagation.d.ts.map +1 -1
  122. package/dist/propagation.js +23 -1
  123. package/dist/propagation.js.map +1 -1
  124. package/dist/refusals.d.ts +1 -1
  125. package/dist/refusals.d.ts.map +1 -1
  126. package/dist/refusals.js +1 -0
  127. package/dist/refusals.js.map +1 -1
  128. package/dist/resolve.d.ts +10 -0
  129. package/dist/resolve.d.ts.map +1 -1
  130. package/dist/resolve.js +27 -3
  131. package/dist/resolve.js.map +1 -1
  132. package/dist/routing-authority.d.ts +71 -0
  133. package/dist/routing-authority.d.ts.map +1 -0
  134. package/dist/routing-authority.js +100 -0
  135. package/dist/routing-authority.js.map +1 -0
  136. package/dist/run-child.d.ts +3 -1
  137. package/dist/run-child.d.ts.map +1 -1
  138. package/dist/run-child.js +30 -2
  139. package/dist/run-child.js.map +1 -1
  140. package/dist/run-herdr.d.ts +2 -0
  141. package/dist/run-herdr.d.ts.map +1 -1
  142. package/dist/run-herdr.js +8 -0
  143. package/dist/run-herdr.js.map +1 -1
  144. package/dist/skill-packages.d.ts +11 -5
  145. package/dist/skill-packages.d.ts.map +1 -1
  146. package/dist/skill-packages.js +20 -11
  147. package/dist/skill-packages.js.map +1 -1
  148. package/dist/workflow-fact-id.d.ts +4 -0
  149. package/dist/workflow-fact-id.d.ts.map +1 -0
  150. package/dist/workflow-fact-id.js +14 -0
  151. package/dist/workflow-fact-id.js.map +1 -0
  152. package/dist/workflow-facts.d.ts +34 -0
  153. package/dist/workflow-facts.d.ts.map +1 -0
  154. package/dist/workflow-facts.js +43 -0
  155. package/dist/workflow-facts.js.map +1 -0
  156. package/dist/workspace-lease.d.ts +15 -3
  157. package/dist/workspace-lease.d.ts.map +1 -1
  158. package/dist/workspace-lease.js +81 -24
  159. package/dist/workspace-lease.js.map +1 -1
  160. package/dist/workspace.d.ts +25 -0
  161. package/dist/workspace.d.ts.map +1 -1
  162. package/dist/workspace.js +142 -5
  163. package/dist/workspace.js.map +1 -1
  164. package/extensions/chain-ledger.ts +4 -0
  165. package/extensions/chain-plan.ts +98 -0
  166. package/extensions/delegate-chain.ts +63 -122
  167. package/extensions/delegation-ledger.ts +60 -0
  168. package/extensions/delegation.ts +20 -14
  169. package/extensions/execute-child.ts +85 -21
  170. package/extensions/execution-occurrence.ts +20 -0
  171. package/extensions/grants-command.ts +33 -4
  172. package/extensions/grants.ts +50 -1
  173. package/extensions/init-command.ts +33 -2
  174. package/extensions/run-delegation.ts +36 -56
  175. package/extensions/session-report.ts +13 -20
  176. package/extensions/session.ts +16 -3
  177. package/extensions/workspace-runtime.ts +44 -4
  178. package/herdr-plugin/herdr-plugin.toml +18 -0
  179. package/package.json +22 -5
  180. package/src/approval-prompt.ts +2 -1
  181. package/src/approval.ts +4 -2
  182. package/src/capabilities.ts +120 -4
  183. package/src/catalog.ts +62 -4
  184. package/src/check-runner.ts +24 -8
  185. package/src/cli.ts +24 -1
  186. package/src/correlation.ts +11 -0
  187. package/src/dashboard-cli.ts +171 -0
  188. package/src/dashboard-handshake.ts +185 -0
  189. package/src/dashboard-herdr.ts +355 -0
  190. package/src/dashboard-projection.ts +390 -0
  191. package/src/dashboard-render.ts +254 -0
  192. package/src/definitions.ts +7 -1
  193. package/src/delegate-types.ts +6 -2
  194. package/src/delegate.ts +43 -4
  195. package/src/delegation-approval.ts +37 -12
  196. package/src/execution-id.ts +18 -0
  197. package/src/executor.ts +2 -1
  198. package/src/grant-env.ts +39 -7
  199. package/src/index.ts +26 -0
  200. package/src/init.ts +40 -2
  201. package/src/lease-helper.ts +97 -0
  202. package/src/lease-record.ts +15 -3
  203. package/src/ledger-events.ts +116 -28
  204. package/src/ledger-identifiers.ts +21 -0
  205. package/src/ledger-report.ts +40 -19
  206. package/src/ledger-v3-validation.ts +266 -0
  207. package/src/ledger.ts +75 -12
  208. package/src/propagation.ts +24 -1
  209. package/src/refusals.ts +1 -0
  210. package/src/resolve.ts +29 -3
  211. package/src/routing-authority.ts +121 -0
  212. package/src/run-child.ts +35 -3
  213. package/src/run-herdr.ts +9 -0
  214. package/src/skill-packages.ts +20 -13
  215. package/src/workflow-fact-id.ts +16 -0
  216. package/src/workflow-facts.ts +65 -0
  217. package/src/workspace-lease.ts +84 -24
  218. package/src/workspace.ts +179 -6
@@ -0,0 +1,266 @@
1
+ import { normaliseCorrelation, type CorrelationMetadata } from "./correlation.ts";
2
+ import { isExecutionId } from "./execution-id.ts";
3
+ import { REFUSAL_CODES } from "./refusals.ts";
4
+ import { isWorkflowFactId, isWorkflowIdentifier, workflowFactStateMatches } from "./workflow-fact-id.ts";
5
+ import { isLedgerCapabilityIdentifier, isLedgerDisplayIdentifier } from "./ledger-identifiers.ts";
6
+
7
+ export type LedgerV3Object = Record<string, unknown>;
8
+
9
+ const SHA256_RE = /^[a-f0-9]{64}$/i;
10
+ const EXECUTORS = new Set(["process", "herdr"]);
11
+ const APPROVAL_SOURCES = new Set(["prompt", "session", "persisted", "inherited"]);
12
+ const APPROVAL_SCOPES = new Set(["once", "session", "always"]);
13
+ const GATE_OUTCOMES = new Set(["declined", "dismissed", "no-ui", "error"]);
14
+ const LEASE_OUTCOMES = new Set([
15
+ "acquired", "uncontended", "refused", "released", "released-unrecorded", "lost", "retained", "timeout", "recovered",
16
+ ]);
17
+ const PROCESS_SIGNALS = new Set([
18
+ "SIGABRT", "SIGALRM", "SIGBUS", "SIGCHLD", "SIGCONT", "SIGFPE", "SIGHUP", "SIGILL", "SIGINT", "SIGIO",
19
+ "SIGIOT", "SIGKILL", "SIGPIPE", "SIGPOLL", "SIGPROF", "SIGPWR", "SIGQUIT", "SIGSEGV", "SIGSTKFLT",
20
+ "SIGSTOP", "SIGSYS", "SIGTERM", "SIGTRAP", "SIGTSTP", "SIGTTIN", "SIGTTOU", "SIGUNUSED", "SIGURG",
21
+ "SIGUSR1", "SIGUSR2", "SIGVTALRM", "SIGWINCH", "SIGXCPU", "SIGXFSZ", "SIGBREAK", "SIGLOST", "SIGINFO",
22
+ ]);
23
+
24
+ const FIELDS = {
25
+ capability_decision: new Set([
26
+ "ledgerVersion", "event", "ts", "executionId", "parentExecutionId", "parentId", "childId", "depth",
27
+ "agentType", "requested", "parentGrant", "effective", "denied", "clipped", "gatedBlocked", "blocked",
28
+ "reason", "approved", "approvalSource", "approvalSources", "approvalScopes", "approvalExpiresAt",
29
+ "approvalUses", "approvalScope", "humanDenied", "gateOutcome", "definitionDigest", "executor", "taskFrom",
30
+ "taskFromExecutionId", "taskDigest", "correlation", "refusal",
31
+ ]),
32
+ workspace_lease: new Set([
33
+ "ledgerVersion", "event", "ts", "executionId", "parentExecutionId", "childId", "workspaceId", "root",
34
+ "access", "outcome", "recovered", "releaseReason", "refusal", "correlation",
35
+ ]),
36
+ child_lifecycle: new Set([
37
+ "ledgerVersion", "event", "ts", "executionId", "parentExecutionId", "childId", "state", "executor",
38
+ "exitCode", "signal", "timedOut", "aborted", "truncated", "reason", "deadlineAt", "herdrPaneId",
39
+ "herdrAgentName", "correlation",
40
+ ]),
41
+ check_receipt: new Set([
42
+ "ledgerVersion", "event", "ts", "executionId", "parentExecutionId", "childId", "receiptId", "workspaceId",
43
+ "checkId", "treeSha", "correlation",
44
+ ]),
45
+ workflow_fact: new Set([
46
+ "ledgerVersion", "event", "ts", "factId", "source", "provenance", "kind", "subject", "state", "correlation",
47
+ ]),
48
+ } as const;
49
+ type EventKind = keyof typeof FIELDS;
50
+
51
+ export function isLedgerObject(value: unknown): value is LedgerV3Object {
52
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
53
+ const prototype = Object.getPrototypeOf(value);
54
+ return prototype === Object.prototype || prototype === null;
55
+ }
56
+
57
+ export function isNonEmptyString(value: unknown): value is string {
58
+ return typeof value === "string" && value.length > 0;
59
+ }
60
+
61
+ /** Ledger timestamp profile: JSON Schema `date-time`, narrowed to seconds 00-59 for JS date arithmetic. */
62
+ export function isTimestamp(value: unknown): value is string {
63
+ if (typeof value !== "string") return false;
64
+ const match = /^(\d{4})-(\d{2})-(\d{2})[Tt](\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:[Zz]|([+-])(\d{2}):(\d{2}))$/.exec(value);
65
+ if (!match) return false;
66
+ const [, yearText, monthText, dayText, hourText, minuteText, secondText, , offsetHourText, offsetMinuteText] = match;
67
+ const year = Number(yearText);
68
+ const month = Number(monthText);
69
+ const day = Number(dayText);
70
+ const hour = Number(hourText);
71
+ const minute = Number(minuteText);
72
+ const second = Number(secondText);
73
+ if (month < 1 || month > 12 || hour > 23 || minute > 59 || second > 59) return false;
74
+ const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
75
+ const days = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
76
+ if (day < 1 || day > days[month - 1]) return false;
77
+ return offsetHourText === undefined || (Number(offsetHourText) <= 23 && Number(offsetMinuteText) <= 59);
78
+ }
79
+
80
+ export function isStringArray(value: unknown): value is string[] {
81
+ return Array.isArray(value) && value.every((item) => typeof item === "string");
82
+ }
83
+
84
+ function optional(event: LedgerV3Object, field: string, valid: (value: unknown) => boolean): boolean {
85
+ return !Object.hasOwn(event, field) || valid(event[field]);
86
+ }
87
+
88
+ function stringMap(value: unknown, valid: (entry: unknown) => boolean): boolean {
89
+ return isLedgerObject(value) && Object.values(value).every(valid);
90
+ }
91
+
92
+ function capabilityMap(value: unknown, valid: (entry: unknown) => boolean): boolean {
93
+ return isLedgerObject(value) && Object.keys(value).every(isLedgerCapabilityIdentifier) && Object.values(value).every(valid);
94
+ }
95
+
96
+ function validCorrelation(value: unknown): boolean {
97
+ if (!isLedgerObject(value)) return false;
98
+ try {
99
+ const normalised = normaliseCorrelation(value as CorrelationMetadata);
100
+ // `normaliseCorrelation` drops nulls. The closed schema does not, so equality is part of validation.
101
+ return JSON.stringify(normalised) === JSON.stringify(value);
102
+ } catch {
103
+ return false;
104
+ }
105
+ }
106
+
107
+ function validRefusal(value: unknown): boolean {
108
+ if (!isLedgerObject(value) || Object.keys(value).some((key) => !["code", "message", "details"].includes(key))) return false;
109
+ if (!REFUSAL_CODES.includes(value.code as never) || typeof value.message !== "string") return false;
110
+ return optional(value, "details", (details) => isLedgerObject(details) && Object.values(details).every(
111
+ (entry) => entry === null || ["string", "number", "boolean"].includes(typeof entry),
112
+ ));
113
+ }
114
+
115
+ function validDefinitionDigest(value: unknown): boolean {
116
+ return isLedgerObject(value) &&
117
+ Object.keys(value).every((key) => ["name", "source", "sha256"].includes(key)) &&
118
+ typeof value.name === "string" && typeof value.source === "string" && SHA256_RE.test(String(value.sha256));
119
+ }
120
+
121
+ function validApprovalUse(value: unknown): boolean {
122
+ return isLedgerObject(value) && Object.keys(value).every((key) => ["max", "remaining"].includes(key)) &&
123
+ Number.isInteger(value.max) && (value.max as number) >= 0 &&
124
+ Number.isInteger(value.remaining) && (value.remaining as number) >= 0;
125
+ }
126
+
127
+ function validateBase(event: LedgerV3Object): string | null {
128
+ if (!isTimestamp(event.ts)) return "ts must be an RFC 3339 timestamp";
129
+ if (!isExecutionId(event.executionId)) return "executionId is missing or invalid";
130
+ if (event.parentExecutionId !== null && !isExecutionId(event.parentExecutionId)) {
131
+ return "parentExecutionId must be an execution id or null";
132
+ }
133
+ if (event.parentExecutionId === event.executionId) return "an execution cannot be its own parent";
134
+ if (!isLedgerDisplayIdentifier(event.childId)) return "childId is missing or invalid";
135
+ return null;
136
+ }
137
+
138
+ function validateCapabilityDecision(event: LedgerV3Object): string | null {
139
+ if (!isLedgerDisplayIdentifier(event.parentId) || !Number.isInteger(event.depth) || (event.depth as number) < 0) {
140
+ return "capability decision identity is invalid";
141
+ }
142
+ for (const field of ["requested", "parentGrant", "effective", "denied", "clipped", "gatedBlocked"]) {
143
+ if (!isStringArray(event[field]) || !event[field].every(isLedgerCapabilityIdentifier)) {
144
+ return `capability decision ${field} must contain capability identifiers`;
145
+ }
146
+ }
147
+ if (typeof event.blocked !== "boolean" || !EXECUTORS.has(String(event.executor)) || !SHA256_RE.test(String(event.taskDigest))) {
148
+ return "capability decision required fields are invalid";
149
+ }
150
+ if (!optional(event, "agentType", isLedgerDisplayIdentifier) ||
151
+ !optional(event, "reason", (value) => typeof value === "string") ||
152
+ !optional(event, "approved", (value) => isStringArray(value) && value.every(isLedgerCapabilityIdentifier)) ||
153
+ !optional(event, "approvalSource", (value) => APPROVAL_SOURCES.has(String(value))) ||
154
+ !optional(event, "approvalSources", (value) => capabilityMap(value, (entry) => APPROVAL_SOURCES.has(String(entry)))) ||
155
+ !optional(event, "approvalScopes", (value) => capabilityMap(value, (entry) => APPROVAL_SCOPES.has(String(entry)))) ||
156
+ !optional(event, "approvalExpiresAt", (value) => capabilityMap(value, isTimestamp)) ||
157
+ !optional(event, "approvalUses", (value) => capabilityMap(value, validApprovalUse)) ||
158
+ !optional(event, "approvalScope", (value) => APPROVAL_SCOPES.has(String(value))) ||
159
+ !optional(event, "humanDenied", (value) => value === true) ||
160
+ !optional(event, "gateOutcome", (value) => GATE_OUTCOMES.has(String(value))) ||
161
+ !optional(event, "definitionDigest", validDefinitionDigest) ||
162
+ !optional(event, "taskFrom", isLedgerDisplayIdentifier) ||
163
+ !optional(event, "taskFromExecutionId", isExecutionId) ||
164
+ !optional(event, "correlation", validCorrelation) ||
165
+ !optional(event, "refusal", validRefusal)) {
166
+ return "capability decision optional fields are invalid";
167
+ }
168
+ return null;
169
+ }
170
+
171
+ function validateWorkspaceLease(event: LedgerV3Object): string | null {
172
+ if (!isLedgerDisplayIdentifier(event.workspaceId) || !isNonEmptyString(event.root) ||
173
+ !["read", "write"].includes(String(event.access)) || !LEASE_OUTCOMES.has(String(event.outcome))) {
174
+ return "workspace lease required fields are invalid";
175
+ }
176
+ if (!optional(event, "recovered", (value) => value === true || value === false || value === "unknown") ||
177
+ !optional(event, "releaseReason", (value) => typeof value === "string") ||
178
+ !optional(event, "refusal", validRefusal) || !optional(event, "correlation", validCorrelation)) {
179
+ return "workspace lease optional fields are invalid";
180
+ }
181
+ return null;
182
+ }
183
+
184
+ function validateChildLifecycle(event: LedgerV3Object): string | null {
185
+ if (!["starting", "running", "completed", "failed"].includes(String(event.state)) || !EXECUTORS.has(String(event.executor))) {
186
+ return "child lifecycle required fields are invalid";
187
+ }
188
+ if (["starting", "running"].includes(String(event.state)) && !isTimestamp(event.deadlineAt)) {
189
+ return "deadlineAt is required and must be an RFC 3339 timestamp for a non-terminal lifecycle";
190
+ }
191
+ if (!optional(event, "deadlineAt", isTimestamp) ||
192
+ !optional(event, "exitCode", (value) => value === null || Number.isInteger(value)) ||
193
+ !optional(event, "signal", (value) => value === null || PROCESS_SIGNALS.has(String(value))) ||
194
+ !optional(event, "timedOut", (value) => value === true) ||
195
+ !optional(event, "aborted", (value) => value === true) ||
196
+ !optional(event, "truncated", (value) => value === true) ||
197
+ !optional(event, "reason", (value) => typeof value === "string") ||
198
+ !optional(event, "correlation", validCorrelation)) {
199
+ return "child lifecycle optional fields are invalid";
200
+ }
201
+ if (!optional(event, "herdrPaneId", isLedgerDisplayIdentifier) ||
202
+ !optional(event, "herdrAgentName", isLedgerDisplayIdentifier)) {
203
+ return "Herdr runtime identity is invalid";
204
+ }
205
+ if (Object.hasOwn(event, "herdrPaneId") !== Object.hasOwn(event, "herdrAgentName")) {
206
+ return "Herdr runtime identity must be paired";
207
+ }
208
+ if (Object.hasOwn(event, "herdrPaneId") && event.executor !== "herdr") {
209
+ return "Herdr runtime identity requires the Herdr executor";
210
+ }
211
+ return null;
212
+ }
213
+
214
+ function validateCheckReceipt(event: LedgerV3Object): string | null {
215
+ if (!SHA256_RE.test(String(event.receiptId)) || !isLedgerDisplayIdentifier(event.workspaceId) ||
216
+ !isLedgerDisplayIdentifier(event.checkId) || !isNonEmptyString(event.treeSha) ||
217
+ !optional(event, "correlation", validCorrelation)) {
218
+ return "check receipt required fields are invalid";
219
+ }
220
+ return null;
221
+ }
222
+
223
+ function validateWorkflowFact(event: LedgerV3Object): string | null {
224
+ if (!isTimestamp(event.ts) || !isWorkflowFactId(event.factId) ||
225
+ !isWorkflowIdentifier(event.source) || !isWorkflowIdentifier(event.subject) ||
226
+ !["planned", "observed", "controller_validated"].includes(String(event.provenance)) ||
227
+ !["workflow_phase", "inline_skill", "transition"].includes(String(event.kind)) ||
228
+ !["pending", "observed", "started", "completed", "blocked"].includes(String(event.state)) ||
229
+ !validCorrelation(event.correlation) ||
230
+ !isNonEmptyString((event.correlation as LedgerV3Object).run_id)) {
231
+ return "workflow fact required fields are invalid";
232
+ }
233
+ return workflowFactStateMatches(event.provenance, event.state)
234
+ ? null
235
+ : "workflow fact provenance contradicts its state";
236
+ }
237
+
238
+ /** Exact, content-free runtime validation for one closed ledger v3 event. */
239
+ export function validateLedgerV3Event(event: LedgerV3Object): string | null {
240
+ if (event.ledgerVersion !== 3) return "unsupported ledger version";
241
+ const kind = event.event;
242
+ if (typeof kind !== "string" || !Object.hasOwn(FIELDS, kind)) return "unknown ledger event discriminator";
243
+ const allowed = FIELDS[kind as EventKind];
244
+ if (Object.keys(event).some((field) => !allowed.has(field))) return "ledger v3 contains an unsupported field";
245
+ if (kind === "workflow_fact") return validateWorkflowFact(event);
246
+ const base = validateBase(event);
247
+ if (base) return base;
248
+ if (kind === "capability_decision") return validateCapabilityDecision(event);
249
+ if (kind === "workspace_lease") return validateWorkspaceLease(event);
250
+ if (kind === "child_lifecycle") return validateChildLifecycle(event);
251
+ return validateCheckReceipt(event);
252
+ }
253
+
254
+ /** Ensure a public builder cannot emit bytes the closed v3 reader would reject. */
255
+ export function assertLedgerV3Wire<T extends object>(event: T): T {
256
+ let wire: unknown;
257
+ try {
258
+ wire = JSON.parse(JSON.stringify(event));
259
+ } catch (error) {
260
+ throw new TypeError(`invalid ledger v3 event: ${String(error)}`);
261
+ }
262
+ if (!isLedgerObject(wire)) throw new TypeError("invalid ledger v3 event: event is not a JSON object");
263
+ const invalid = validateLedgerV3Event(wire);
264
+ if (invalid) throw new TypeError(`invalid ledger v3 event: ${invalid}`);
265
+ return event;
266
+ }
package/src/ledger.ts CHANGED
@@ -32,21 +32,33 @@ import type { CorrelationMetadata } from "./correlation.ts";
32
32
  import type { StructuredRefusal } from "./refusals.ts";
33
33
  // Type-only, so the cycle with ./ledger-events.ts is erased at runtime.
34
34
  import type { RuntimeLedgerEvent } from "./ledger-events.ts";
35
+ import { assertExecutionId } from "./execution-id.ts";
36
+ import { assertLedgerV3Wire } from "./ledger-v3-validation.ts";
35
37
 
36
- export const LEDGER_VERSION = 2 as const;
38
+ export const LEDGER_VERSION = 3 as const;
39
+ export const LEDGER_EVENT_KINDS = [
40
+ "capability_decision", "workspace_lease", "child_lifecycle", "check_receipt", "workflow_fact",
41
+ ] as const;
42
+ export type LedgerEventKind = typeof LEDGER_EVENT_KINDS[number];
43
+ export const LEDGER_GATE_OUTCOMES = ["declined", "dismissed", "no-ui", "error"] as const;
44
+ export type LedgerGateOutcome = typeof LEDGER_GATE_OUTCOMES[number];
37
45
 
38
46
  export interface LedgerEventBase {
39
- /** Optional only on the legacy-compatible `GrantRecord` public type; every v2 event builder writes it. */
47
+ /** Optional only on the legacy-compatible `GrantRecord` public type; every v3 event builder writes it. */
40
48
  ledgerVersion?: typeof LEDGER_VERSION;
41
- event?: "capability_decision" | "workspace_lease" | "child_lifecycle" | "check_receipt";
49
+ event?: LedgerEventKind;
42
50
  ts: string;
51
+ /** Unique execution occurrence. Optional only for legacy, unversioned GrantRecord values. */
52
+ executionId?: string;
53
+ /** Explicit execution parent; null means the delegating session is not itself a governed child. */
54
+ parentExecutionId?: string | null;
43
55
  childId?: string;
44
56
  correlation?: CorrelationMetadata;
45
57
  }
46
58
 
47
59
  export interface GrantRecord extends LedgerEventBase {
48
60
  /**
49
- * Present only when a trusted `taskDigest` was supplied — that is what makes a line v2. `buildRecord`
61
+ * Present only when a trusted `taskDigest` was supplied — that is what makes a line v3. `buildRecord`
50
62
  * does NOT always emit it, and the comment here used to say it did while the emission site 200 lines
51
63
  * down said the opposite. Two comments asserting contradictory facts about one field is R-28's shape.
52
64
  */
@@ -104,7 +116,7 @@ export interface GrantRecord extends LedgerEventBase {
104
116
  /** Present only when the source was a live prompt, and only when one scope covers the whole set. */
105
117
  approvalScope?: ApprovalScope;
106
118
  /** A human was asked and declined. Distinct from `denied`, which is an escalation attempt. */
107
- humanDenied?: boolean;
119
+ humanDenied?: true;
108
120
  /**
109
121
  * WHY a gate went unsatisfied, when the answer was not a yes.
110
122
  *
@@ -120,9 +132,10 @@ export interface GrantRecord extends LedgerEventBase {
120
132
  * say *"nobody was there to ask"* and be believed, so it is recorded rather than inferred.
121
133
  *
122
134
  * **Privacy is unchanged**: this is a fixed five-member enum, not text — nothing model-authored, nothing
123
- * a task could carry.
135
+ * a task could carry. The prompt has five outcomes, but `granted` is deliberately omitted from the
136
+ * four-member ledger enum because the approval source/scope fields already record a yes.
124
137
  */
125
- gateOutcome?: PromptOutcomeKind;
138
+ gateOutcome?: LedgerGateOutcome;
126
139
  /**
127
140
  * WHICH operator-authored instructions this child was given (ADR-0018).
128
141
  *
@@ -158,6 +171,8 @@ export interface GrantRecord extends LedgerEventBase {
158
171
  * `definitionDigest` names its instructions, and neither says where the TASK came from.
159
172
  */
160
173
  taskFrom?: string;
174
+ /** Unique occurrence whose output composed this task; taskFrom remains the readable logical position. */
175
+ taskFromExecutionId?: string;
161
176
  /** Trusted SHA-256 of the exact task; task text remains forbidden (ADR-0034). */
162
177
  taskDigest?: string;
163
178
  /** Stable machine-readable refusal accompanying `reason`. */
@@ -182,6 +197,10 @@ export interface LedgerOptions {
182
197
  }
183
198
 
184
199
  export function buildRecord(args: {
200
+ /** Required whenever taskDigest makes this an explicit v3 event. */
201
+ executionId?: string;
202
+ /** Required (including explicit null) whenever taskDigest makes this an explicit v3 event. */
203
+ parentExecutionId?: string | null;
185
204
  parentId: string;
186
205
  childId: string;
187
206
  depth: number;
@@ -201,23 +220,42 @@ export function buildRecord(args: {
201
220
  definitionDigest?: DefinitionDigest;
202
221
  /** Where the child ran (ADR-0031). Required: the probe's answer survives nowhere else. */
203
222
  executor: ExecutorKind;
204
- /** The child whose output composed this task (ADR-0033). Absent for anything but a chain step. */
223
+ /** The logical child whose output composed this task (ADR-0033). */
205
224
  taskFrom?: string;
225
+ /** The unique occurrence whose output composed this task. */
226
+ taskFromExecutionId?: string;
206
227
  taskDigest?: string;
207
228
  correlation?: CorrelationMetadata;
208
229
  refusal?: StructuredRefusal;
209
230
  now: Date;
210
231
  }): GrantRecord {
232
+ if (args.taskDigest !== undefined) {
233
+ if (!/^[a-f0-9]{64}$/i.test(args.taskDigest)) throw new TypeError("taskDigest must be a SHA-256 hex digest");
234
+ assertExecutionId(args.executionId);
235
+ if (args.parentExecutionId !== null) assertExecutionId(args.parentExecutionId, "parentExecutionId");
236
+ if (args.parentExecutionId === args.executionId) throw new TypeError("an execution cannot be its own parent");
237
+ if (args.taskFromExecutionId !== undefined) assertExecutionId(args.taskFromExecutionId, "taskFromExecutionId");
238
+ }
239
+ if (args.definitionDigest && !/^[a-f0-9]{64}$/i.test(args.definitionDigest.sha256)) {
240
+ throw new TypeError("definitionDigest.sha256 must be a SHA-256 hex digest");
241
+ }
211
242
  // R-46: the scalar is a SUMMARY, emitted only when it cannot mislead. `buildRecord` derives it rather
212
243
  // than accepting it, so a call site cannot supply one that disagrees with the map beside it.
213
244
  const sources = args.approvalSources ?? {};
214
245
  const distinct = [...new Set(Object.values(sources))];
215
246
  const scopes = args.approvalScopes ?? {};
216
247
  const distinctScopes = [...new Set(Object.values(scopes))];
217
- return {
218
- // A trusted task digest is what distinguishes a v2 capability event from the legacy construction API.
248
+ const record: GrantRecord = {
249
+ // A trusted task digest is what distinguishes a v3 capability event from the legacy construction API.
219
250
  // 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 } : {}),
251
+ ...(args.taskDigest !== undefined
252
+ ? {
253
+ ledgerVersion: LEDGER_VERSION,
254
+ event: "capability_decision" as const,
255
+ executionId: args.executionId,
256
+ parentExecutionId: args.parentExecutionId,
257
+ }
258
+ : {}),
221
259
  ts: args.now.toISOString(),
222
260
  parentId: args.parentId,
223
261
  childId: args.childId,
@@ -225,7 +263,8 @@ export function buildRecord(args: {
225
263
  agentType: args.agentType,
226
264
  executor: args.executor,
227
265
  ...(args.taskFrom ? { taskFrom: args.taskFrom } : {}),
228
- ...(args.taskDigest ? { taskDigest: args.taskDigest } : {}),
266
+ ...(args.taskFromExecutionId ? { taskFromExecutionId: args.taskFromExecutionId } : {}),
267
+ ...(args.taskDigest !== undefined ? { taskDigest: args.taskDigest } : {}),
229
268
  ...(args.correlation ? { correlation: structuredClone(args.correlation) } : {}),
230
269
  ...(args.refusal ? { refusal: structuredClone(args.refusal) } : {}),
231
270
  requested: args.requested,
@@ -252,6 +291,8 @@ export function buildRecord(args: {
252
291
  ...(args.gateOutcome && args.gateOutcome !== "granted" ? { gateOutcome: args.gateOutcome } : {}),
253
292
  ...(args.definitionDigest ? { definitionDigest: args.definitionDigest } : {}),
254
293
  };
294
+ if (args.taskDigest !== undefined) assertLedgerV3Wire(record);
295
+ return record;
255
296
  }
256
297
 
257
298
  // R-49: the lock moved to `src/file-lock.ts` so the approvals store could use the SAME one rather than
@@ -302,11 +343,33 @@ export function isEscalationAttempt(record: GrantRecord): boolean {
302
343
  }
303
344
 
304
345
  export {
346
+ CHILD_LIFECYCLE_STATES,
347
+ CHILD_PROCESS_SIGNALS,
348
+ WORKSPACE_ACCESSES,
349
+ WORKSPACE_LEASE_OUTCOMES,
350
+ WORKSPACE_RECOVERY_VALUES,
351
+ buildCheckReceiptLedgerEvent,
305
352
  buildChildLifecycleEvent,
306
353
  buildWorkspaceLeaseEvent,
354
+ type CapabilityDecisionEvent,
307
355
  type ChildLifecycleEvent,
356
+ type ChildLifecycleState,
357
+ type ChildProcessSignal,
308
358
  type CheckReceiptLedgerEvent,
309
359
  type RuntimeLedgerEvent,
360
+ type WorkspaceAccess,
310
361
  type WorkspaceLeaseEvent,
311
362
  type WorkspaceLeaseOutcome,
363
+ type WorkspaceRecovery,
312
364
  } from "./ledger-events.ts";
365
+
366
+ export {
367
+ WORKFLOW_FACT_KINDS,
368
+ WORKFLOW_FACT_PROVENANCE,
369
+ WORKFLOW_FACT_STATES,
370
+ buildWorkflowFactEvent,
371
+ type WorkflowFactEvent,
372
+ type WorkflowFactKind,
373
+ type WorkflowFactProvenance,
374
+ type WorkflowFactState,
375
+ } from "./workflow-facts.ts";
@@ -27,6 +27,7 @@
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";
31
32
  import { assertCapabilitiesArePropagatable } from "./capabilities.ts";
32
33
 
@@ -45,6 +46,8 @@ export const ENV_FANOUT = "PI_GRANTS_FANOUT";
45
46
  * Without it every level restarts at `d0` and the ledger cannot be joined into a tree across processes.
46
47
  */
47
48
  export const ENV_PARENT_ID = "PI_GRANTS_PARENT_ID";
49
+ /** Unique identity of this governed execution occurrence; unlike ENV_PARENT_ID it is never reused. */
50
+ export const ENV_EXECUTION_ID = "PI_GRANTS_EXECUTION_ID";
48
51
  export const ENV_DEPTH = "PI_GRANTS_DEPTH";
49
52
  export const ENV_MAX_DEPTH = "PI_GRANTS_MAX_DEPTH";
50
53
  export const ENV_GATED = "PI_GRANTS_GATED";
@@ -67,6 +70,7 @@ export const GRANT_ENV_KEYS = [
67
70
  ENV_APPROVED,
68
71
  ENV_FANOUT,
69
72
  ENV_PARENT_ID,
73
+ ENV_EXECUTION_ID,
70
74
  ] as const;
71
75
 
72
76
  export const parseList = (raw: string | undefined): Capability[] =>
@@ -247,9 +251,28 @@ export interface ChildEnvInput {
247
251
  * empty grant, so they can spawn nothing. That fails closed. It is also unreachable in normal flow,
248
252
  * because a session's first provider request always precedes its first tool call.
249
253
  */
254
+ /**
255
+ * The part of a grant a child may inherit: everything but the wildcards that are HELD, never handed down.
256
+ *
257
+ * `tool:*` for R-26's reason — a root that handed it down let every descendant reacquire the full catalog.
258
+ * `workspace:*` for the same reason one namespace over (R-131): a descendant holding it could route anywhere
259
+ * the registry lists, which is the attenuation failure ADR-0035 exists to close. `agent:*` is deliberately
260
+ * NOT stripped — ADR-0023 decided that a session authorised for any definition passes that on, because
261
+ * definitions are ceilings rather than roots.
262
+ *
263
+ * **Exported, and there is exactly one spelling of this rule on purpose.** It shipped as a filter inline in
264
+ * `childEnv` and nowhere else, so a grant travelling the OTHER path — `delegate.ts` building a child's
265
+ * `PI_GRANTS_GRANT` from `result.effective` — carried `workspace:*` straight down. The test written beside
266
+ * that fix exercised `childEnv`, which is not the path a delegated child's grant travels, so it could not
267
+ * catch it. R-28's shape: two routes for one rule, with the guard on the quieter one. Both call this now.
268
+ */
269
+ export function inheritableGrant(grant: readonly Capability[]): Capability[] {
270
+ return grant.filter((c) => c !== WILDCARD && c !== WORKSPACE_WILDCARD);
271
+ }
272
+
250
273
  export function childEnv(input: ChildEnvInput): Record<string, string> {
251
274
  if (input.governed === false) return {};
252
- const inheritable = input.ownGrant.filter((c) => c !== WILDCARD);
275
+ const inheritable = inheritableGrant(input.ownGrant);
253
276
  const env: Record<string, string> = {
254
277
  [ENV_GRANT]: (assertCapabilitiesArePropagatable(inheritable), inheritable.join(",")),
255
278
  [ENV_DEPTH]: String(input.depth + 1),
package/src/refusals.ts CHANGED
@@ -28,6 +28,7 @@ export const REFUSAL_CODES = [
28
28
  "LEDGER_WRITE_FAILED",
29
29
  "FANOUT_FAILED",
30
30
  "WORKSPACE_NOT_REGISTERED",
31
+ "WORKSPACE_NOT_AUTHORIZED",
31
32
  "WORKSPACE_WRITE_CONFLICT",
32
33
  "WORKSPACE_LEASE_STALE",
33
34
  "CHECK_NOT_CONFIGURED",
package/src/resolve.ts CHANGED
@@ -76,6 +76,16 @@ import { isWellFormedCapability } from "./capabilities.ts";
76
76
  * `tool:*` — authority to grant every tool — which made the safe configuration the laborious one.
77
77
  */
78
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:*";
79
89
 
80
90
  export interface ResolveInput {
81
91
  /** What the delegating agent asked to give the child. */
@@ -125,7 +135,10 @@ export function resolve(input: ResolveInput): ResolveResult {
125
135
  const parent =
126
136
  input.subsumption === false ? held : new Set(expandSubsumed(input.parentGrant));
127
137
  /**
128
- * `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".
129
142
  *
130
143
  * `resolve` is otherwise exact-match plus subsumption, deliberately: `tool:*` works not because anything
131
144
  * here understands it, but because `deriveOwnGrant` *enumerates* a session's observed tool names beside
@@ -150,13 +163,17 @@ export function resolve(input: ResolveInput): ResolveResult {
150
163
  * spellings of one rule, and the enforcing one was wrong.
151
164
  */
152
165
  const anyCapability = held.has(WILDCARD);
166
+ const anyWorkspace = held.has(WORKSPACE_WILDCARD);
153
167
  // A malformed id is never covered — not by an exact hold, and above all not by a wildcard's PREFIX rule,
154
168
  // which is how `agent:x,tool:bash` got admitted and then split into two capabilities in the child.
155
169
  // Landing in `denied` is the right outcome rather than a throw: it fails closed AND records an
156
170
  // escalation attempt, so the ledger shows the attempt instead of a clean line.
157
171
  const covered = (c: Capability): boolean =>
158
172
  isWellFormedCapability(c)
159
- && (parent.has(c) || anyCapability || (anyDefinition && c.startsWith("agent:")));
173
+ && (parent.has(c) || anyCapability
174
+ || (anyDefinition && c.startsWith("agent:"))
175
+ || (anyWorkspace && c.startsWith("workspace:")));
176
+
160
177
  const ceiling = input.ceiling === undefined ? null : new Set(input.ceiling);
161
178
  const gated = new Set(input.gated ?? []);
162
179
  const approved = new Set(input.approved ?? []);
@@ -199,7 +216,16 @@ export function resolve(input: ResolveInput): ResolveResult {
199
216
  // F9: capabilities covered by a WILDCARD are not "subsumed" — this field means "the grant is broader
200
217
  // than its list suggests", which is the `bash`-covers-`grep` warning. A wildcard holder already knows
201
218
  // its grant is broad; listing every id under it would bury the signal the field exists to carry.
202
- 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:"))),
203
229
  };
204
230
  }
205
231