@pikku/core 0.12.80 → 0.12.82

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 (231) hide show
  1. package/CHANGELOG.md +312 -0
  2. package/dist/errors/index.d.ts +1 -1
  3. package/dist/errors/index.js +1 -1
  4. package/dist/function/function-runner.js +2 -5
  5. package/dist/function/index.d.ts +1 -1
  6. package/dist/index.d.ts +11 -11
  7. package/dist/index.js +3 -3
  8. package/dist/pikku-state.js +4 -0
  9. package/dist/services/ai-agent-runner-service.d.ts +7 -0
  10. package/dist/services/ai-run-state-service.d.ts +10 -0
  11. package/dist/services/in-memory-ai-run-state-service.d.ts +5 -1
  12. package/dist/services/in-memory-ai-run-state-service.js +9 -0
  13. package/dist/services/index.d.ts +15 -15
  14. package/dist/services/index.js +5 -5
  15. package/dist/services/scoped-credential-service.d.ts +21 -0
  16. package/dist/services/scoped-credential-service.js +53 -0
  17. package/dist/testing/service-tests/ai-storage-service-tests.js +76 -0
  18. package/dist/types/core.types.d.ts +0 -2
  19. package/dist/types/state.types.d.ts +13 -0
  20. package/dist/wirings/actor-flow/index.d.ts +1 -1
  21. package/dist/wirings/ai-agent/ai-agent-finalize.d.ts +58 -0
  22. package/dist/wirings/ai-agent/ai-agent-finalize.js +138 -0
  23. package/dist/wirings/ai-agent/ai-agent-interrupt.js +1 -0
  24. package/dist/wirings/ai-agent/ai-agent-memory.d.ts +2 -8
  25. package/dist/wirings/ai-agent/ai-agent-memory.js +34 -17
  26. package/dist/wirings/ai-agent/ai-agent-model-config.d.ts +7 -0
  27. package/dist/wirings/ai-agent/ai-agent-model-config.js +44 -1
  28. package/dist/wirings/ai-agent/ai-agent-prepare.js +2 -0
  29. package/dist/wirings/ai-agent/ai-agent-runner.js +61 -40
  30. package/dist/wirings/ai-agent/ai-agent-stream.js +89 -36
  31. package/dist/wirings/ai-agent/ai-agent-turn.d.ts +1 -0
  32. package/dist/wirings/ai-agent/ai-agent-turn.js +1 -0
  33. package/dist/wirings/ai-agent/ai-agent.types.d.ts +46 -1
  34. package/dist/wirings/ai-agent/index.d.ts +8 -7
  35. package/dist/wirings/ai-agent/index.js +5 -4
  36. package/dist/wirings/ai-scorer/ai-scorer-grade.d.ts +26 -0
  37. package/dist/wirings/ai-scorer/ai-scorer-grade.js +33 -0
  38. package/dist/wirings/ai-scorer/ai-scorer-judge.d.ts +17 -0
  39. package/dist/wirings/ai-scorer/ai-scorer-judge.js +92 -0
  40. package/dist/wirings/ai-scorer/ai-scorer-live.d.ts +15 -0
  41. package/dist/wirings/ai-scorer/ai-scorer-live.js +38 -0
  42. package/dist/wirings/ai-scorer/ai-scorer-registry.d.ts +18 -0
  43. package/dist/wirings/ai-scorer/ai-scorer-registry.js +46 -0
  44. package/dist/wirings/ai-scorer/ai-scorer-sampling.d.ts +8 -0
  45. package/dist/wirings/ai-scorer/ai-scorer-sampling.js +31 -0
  46. package/dist/wirings/ai-scorer/ai-scorer-snapshots.d.ts +10 -0
  47. package/dist/wirings/ai-scorer/ai-scorer-snapshots.js +40 -0
  48. package/dist/wirings/ai-scorer/ai-scorer-worker.d.ts +15 -0
  49. package/dist/wirings/ai-scorer/ai-scorer-worker.js +58 -0
  50. package/dist/wirings/ai-scorer/ai-scorer.d.ts +39 -0
  51. package/dist/wirings/ai-scorer/ai-scorer.js +40 -0
  52. package/dist/wirings/ai-scorer/ai-scorer.types.d.ts +90 -0
  53. package/dist/wirings/ai-scorer/ai-scorer.types.js +4 -0
  54. package/dist/wirings/ai-scorer/index.d.ts +6 -0
  55. package/dist/wirings/ai-scorer/index.js +5 -0
  56. package/dist/wirings/channel/index.d.ts +5 -6
  57. package/dist/wirings/channel/index.js +3 -4
  58. package/dist/wirings/channel/local/local-channel-runner.js +8 -1
  59. package/dist/wirings/cli/channel/cli-raw-channel-runner.js +9 -1
  60. package/dist/wirings/cli/channel/index.d.ts +1 -2
  61. package/dist/wirings/cli/channel/index.js +0 -1
  62. package/dist/wirings/cli/cli-runner.js +13 -1
  63. package/dist/wirings/credential/index.d.ts +1 -1
  64. package/dist/wirings/gateway/index.d.ts +1 -1
  65. package/dist/wirings/http/http-runner.js +8 -2
  66. package/dist/wirings/http/index.d.ts +1 -2
  67. package/dist/wirings/mcp/index.d.ts +1 -1
  68. package/dist/wirings/mcp/mcp-runner.d.ts +15 -0
  69. package/dist/wirings/mcp/mcp-runner.js +18 -5
  70. package/dist/wirings/persona/index.d.ts +3 -4
  71. package/dist/wirings/persona/index.js +2 -3
  72. package/dist/wirings/queue/index.d.ts +1 -3
  73. package/dist/wirings/queue/index.js +1 -3
  74. package/dist/wirings/rpc/addon-runner.d.ts +4 -0
  75. package/dist/wirings/rpc/addon-runner.js +19 -3
  76. package/dist/wirings/rpc/rpc-runner.js +2 -0
  77. package/dist/wirings/rpc/rpc-types.d.ts +4 -0
  78. package/dist/wirings/rpc/wire-addon.d.ts +13 -0
  79. package/dist/wirings/rpc/wire-addon.js +4 -0
  80. package/dist/wirings/scheduler/index.d.ts +1 -1
  81. package/dist/wirings/trigger/index.d.ts +1 -1
  82. package/dist/wirings/virtual-user/index.d.ts +5 -6
  83. package/dist/wirings/virtual-user/index.js +2 -4
  84. package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +85 -15
  85. package/dist/wirings/workflow/index.d.ts +6 -6
  86. package/dist/wirings/workflow/index.js +2 -2
  87. package/dist/wirings/workflow/pikku-scenario-service.d.ts +7 -7
  88. package/dist/wirings/workflow/pikku-scenario-service.js +39 -13
  89. package/dist/wirings/workflow/pikku-workflow-service.js +17 -3
  90. package/dist/wirings/workflow/scenario-step.types.d.ts +8 -0
  91. package/dist/wirings/workflow/workflow-approval-audit.d.ts +16 -0
  92. package/dist/wirings/workflow/workflow-approval-audit.js +40 -0
  93. package/dist/wirings/workflow/workflow-approval-policy.d.ts +20 -0
  94. package/dist/wirings/workflow/workflow-approval-policy.js +48 -0
  95. package/dist/wirings/workflow/workflow-approval.d.ts +29 -1
  96. package/dist/wirings/workflow/workflow-approval.js +65 -2
  97. package/dist/wirings/workflow/workflow-run-ownership.d.ts +2 -1
  98. package/dist/wirings/workflow/workflow-run-ownership.js +2 -1
  99. package/dist/wirings/workflow/workflow.types.d.ts +1 -1
  100. package/knowledge/decisions/internals/addon-pikku-meta-ships-at-the-package-root-or-under-dist.md +32 -0
  101. package/knowledge/decisions/internals/an-addon-scope-root-loses-to-a-root-the-host-already-declares.md +39 -0
  102. package/knowledge/decisions/internals/index.md +30 -3
  103. package/knowledge/decisions/internals/validate-runs-checks-by-precondition.md +115 -0
  104. package/knowledge/decisions/security/a-function-never-receives-the-secret-service.md +37 -0
  105. package/knowledge/decisions/security/a-workflow-run-is-read-and-approved-by-its-owner.md +30 -14
  106. package/knowledge/decisions/security/an-approval-answer-outlives-the-run-it-answered.md +59 -0
  107. package/knowledge/decisions/security/index.md +3 -1
  108. package/knowledge/questions/index.md +1 -1
  109. package/package.json +3 -1
  110. package/scripts/generate-api-report.mts +143 -18
  111. package/src/api-report.test.ts +2 -2
  112. package/src/errors/index.ts +1 -1
  113. package/src/function/function-runner.test.ts +52 -0
  114. package/src/function/function-runner.ts +5 -9
  115. package/src/function/index.ts +0 -2
  116. package/src/index.ts +0 -35
  117. package/src/pikku-state.ts +5 -0
  118. package/src/public-surface.json +70 -94
  119. package/src/services/ai-agent-runner-service.ts +12 -1
  120. package/src/services/ai-run-state-service.ts +11 -0
  121. package/src/services/in-memory-ai-run-state-service.ts +13 -0
  122. package/src/services/index.ts +3 -43
  123. package/src/services/scoped-credential-service.test.ts +86 -0
  124. package/src/services/scoped-credential-service.ts +63 -0
  125. package/src/testing/service-tests/ai-storage-service-tests.ts +93 -0
  126. package/src/types/core.types.ts +3 -6
  127. package/src/types/state.types.ts +16 -0
  128. package/src/wirings/actor-flow/index.ts +0 -3
  129. package/src/wirings/ai-agent/ai-agent-finalize.test.ts +186 -0
  130. package/src/wirings/ai-agent/ai-agent-finalize.ts +197 -0
  131. package/src/wirings/ai-agent/ai-agent-interrupt.ts +1 -0
  132. package/src/wirings/ai-agent/ai-agent-memory.ts +54 -38
  133. package/src/wirings/ai-agent/ai-agent-model-config.test.ts +72 -3
  134. package/src/wirings/ai-agent/ai-agent-model-config.ts +49 -1
  135. package/src/wirings/ai-agent/ai-agent-prepare.ts +2 -0
  136. package/src/wirings/ai-agent/ai-agent-runner.ts +71 -40
  137. package/src/wirings/ai-agent/ai-agent-stream-output-hooks.test.ts +353 -0
  138. package/src/wirings/ai-agent/ai-agent-stream.ts +116 -54
  139. package/src/wirings/ai-agent/ai-agent-turn.test.ts +67 -0
  140. package/src/wirings/ai-agent/ai-agent-turn.ts +1 -0
  141. package/src/wirings/ai-agent/ai-agent.types.ts +64 -4
  142. package/src/wirings/ai-agent/index.ts +2 -16
  143. package/src/wirings/ai-scorer/ai-scorer-grade.test.ts +106 -0
  144. package/src/wirings/ai-scorer/ai-scorer-grade.ts +55 -0
  145. package/src/wirings/ai-scorer/ai-scorer-judge.test.ts +143 -0
  146. package/src/wirings/ai-scorer/ai-scorer-judge.ts +120 -0
  147. package/src/wirings/ai-scorer/ai-scorer-live.test.ts +174 -0
  148. package/src/wirings/ai-scorer/ai-scorer-live.ts +56 -0
  149. package/src/wirings/ai-scorer/ai-scorer-registry.ts +63 -0
  150. package/src/wirings/ai-scorer/ai-scorer-sampling.test.ts +34 -0
  151. package/src/wirings/ai-scorer/ai-scorer-sampling.ts +36 -0
  152. package/src/wirings/ai-scorer/ai-scorer-snapshots.test.ts +49 -0
  153. package/src/wirings/ai-scorer/ai-scorer-snapshots.ts +46 -0
  154. package/src/wirings/ai-scorer/ai-scorer-worker.test.ts +122 -0
  155. package/src/wirings/ai-scorer/ai-scorer-worker.ts +69 -0
  156. package/src/wirings/ai-scorer/ai-scorer.ts +76 -0
  157. package/src/wirings/ai-scorer/ai-scorer.types.ts +107 -0
  158. package/src/wirings/ai-scorer/index.ts +24 -0
  159. package/src/wirings/channel/index.ts +1 -20
  160. package/src/wirings/channel/local/local-channel-runner.test.ts +68 -0
  161. package/src/wirings/channel/local/local-channel-runner.ts +8 -1
  162. package/src/wirings/cli/channel/cli-raw-channel-runner.test.ts +23 -0
  163. package/src/wirings/cli/channel/cli-raw-channel-runner.ts +12 -1
  164. package/src/wirings/cli/channel/index.ts +0 -7
  165. package/src/wirings/cli/cli-runner.test.ts +68 -0
  166. package/src/wirings/cli/cli-runner.ts +18 -1
  167. package/src/wirings/credential/index.ts +0 -1
  168. package/src/wirings/gateway/index.ts +0 -3
  169. package/src/wirings/http/http-runner.test.ts +66 -0
  170. package/src/wirings/http/http-runner.ts +10 -2
  171. package/src/wirings/http/index.ts +1 -1
  172. package/src/wirings/mcp/index.ts +0 -1
  173. package/src/wirings/mcp/mcp-runner.test.ts +181 -0
  174. package/src/wirings/mcp/mcp-runner.ts +35 -5
  175. package/src/wirings/persona/index.ts +0 -8
  176. package/src/wirings/queue/index.ts +0 -14
  177. package/src/wirings/rpc/addon-runner.ts +34 -3
  178. package/src/wirings/rpc/addon-secrets.test.ts +261 -0
  179. package/src/wirings/rpc/rpc-runner.test.ts +2 -0
  180. package/src/wirings/rpc/rpc-runner.ts +2 -0
  181. package/src/wirings/rpc/rpc-types.ts +4 -0
  182. package/src/wirings/rpc/wire-addon.ts +17 -0
  183. package/src/wirings/scheduler/index.ts +0 -1
  184. package/src/wirings/trigger/index.ts +0 -1
  185. package/src/wirings/virtual-user/index.ts +0 -16
  186. package/src/wirings/workflow/dsl/workflow-dsl.types.ts +96 -16
  187. package/src/wirings/workflow/graph/graph-runner.test.ts +72 -0
  188. package/src/wirings/workflow/index.ts +2 -20
  189. package/src/wirings/workflow/pikku-scenario-service.ts +60 -15
  190. package/src/wirings/workflow/pikku-workflow-service.test.ts +13 -12
  191. package/src/wirings/workflow/pikku-workflow-service.ts +28 -4
  192. package/src/wirings/workflow/scenario-expectations.test.ts +75 -0
  193. package/src/wirings/workflow/scenario-hooks.test.ts +3 -2
  194. package/src/wirings/workflow/scenario-step.types.ts +8 -0
  195. package/src/wirings/workflow/workflow-approval-audit.ts +47 -0
  196. package/src/wirings/workflow/workflow-approval-policy.test.ts +524 -0
  197. package/src/wirings/workflow/workflow-approval-policy.ts +68 -0
  198. package/src/wirings/workflow/workflow-approval.ts +113 -9
  199. package/src/wirings/workflow/workflow-run-authority.test.ts +12 -15
  200. package/src/wirings/workflow/workflow-run-ownership.ts +2 -1
  201. package/src/wirings/workflow/workflow.types.ts +0 -9
  202. package/src/wirings-stay-decoupled.test.ts +6 -2
  203. package/tsconfig.tsbuildinfo +1 -1
  204. package/dist/internal.d.ts +0 -3
  205. package/dist/internal.js +0 -2
  206. package/dist/middleware/timeout.d.ts +0 -9
  207. package/dist/middleware/timeout.js +0 -15
  208. package/dist/pikku-response.d.ts +0 -6
  209. package/dist/pikku-response.js +0 -6
  210. package/dist/services/gopass-secrets.d.ts +0 -15
  211. package/dist/services/gopass-secrets.js +0 -76
  212. package/dist/services/http-scenario-actors.d.ts +0 -75
  213. package/dist/services/http-scenario-actors.js +0 -195
  214. package/dist/services/http-user-flow-actors.d.ts +0 -67
  215. package/dist/services/http-user-flow-actors.js +0 -193
  216. package/dist/services/scenario-actors-service.d.ts +0 -127
  217. package/dist/services/scenario-actors-service.js +0 -40
  218. package/dist/services/user-flow-actors-service.d.ts +0 -39
  219. package/dist/services/user-flow-actors-service.js +0 -1
  220. package/dist/wirings/credential/wire-credential.d.ts +0 -48
  221. package/dist/wirings/credential/wire-credential.js +0 -47
  222. package/dist/wirings/oauth2/oauth2-client.d.ts +0 -47
  223. package/dist/wirings/oauth2/oauth2-client.js +0 -263
  224. package/dist/wirings/oauth2/oauth2-routes.d.ts +0 -35
  225. package/dist/wirings/oauth2/oauth2-routes.js +0 -146
  226. package/dist/wirings/scope/wire-scope.d.ts +0 -33
  227. package/dist/wirings/scope/wire-scope.js +0 -32
  228. package/dist/wirings/workflow/dsl/index.d.ts +0 -5
  229. package/dist/wirings/workflow/dsl/index.js +0 -4
  230. package/dist/wirings/workflow/graph/index.d.ts +0 -5
  231. package/dist/wirings/workflow/graph/index.js +0 -4
@@ -156,16 +156,16 @@ export declare class PikkuScenarioService implements WorkflowRunExtension {
156
156
  }): Promise<void>;
157
157
  detachRunContext(runId: string): void;
158
158
  /**
159
- * What the run has accumulated, for a reporter that wants to say what a
160
- * failed scenario left behind. Undefined for a plain workflow.
159
+ * What the run has accumulated.
160
+ *
161
+ * Undefined only for a run whose wire was never decorated — decoration calls
162
+ * `contextForRun` unconditionally, so a plain workflow that has reached a
163
+ * step holds an empty context rather than none.
161
164
  */
162
165
  getRunContext(runId: string): Record<string, unknown> | undefined;
163
166
  /**
164
- * The run's context, created on demand.
165
- *
166
- * `attachRunContext` seeds it for an ordinary scenario run, but the wire is
167
- * also built on paths that skip attach; without this the body would be
168
- * writing to an `undefined` and teardown would read nothing.
167
+ * The run's context, created on demand because the wire is also built on
168
+ * paths that skip `attachRunContext`.
169
169
  */
170
170
  private contextForRun;
171
171
  decorateRunWire(wire: PikkuRawWire, context: {
@@ -176,9 +176,9 @@ export class PikkuScenarioService {
176
176
  // Scenario actors per run: live authenticated clients (cookie jars) are
177
177
  // process-local by nature, so they ride this map, never the persisted wire.
178
178
  runActors = new Map();
179
- // What each run has accumulated so far. Process-local like the actors above:
180
- // a scenario is a single in-process run, so the body and its hooks share one
181
- // object rather than reading it back off the persisted wire.
179
+ // What each run has accumulated so far — process-local like the actors above,
180
+ // so the body and its hooks share one object rather than reading it back off
181
+ // the persisted wire.
182
182
  runContexts = new Map();
183
183
  scenarioBrowserProvider;
184
184
  scenarioEnvironment;
@@ -235,18 +235,18 @@ export class PikkuScenarioService {
235
235
  this.runContexts.delete(runId);
236
236
  }
237
237
  /**
238
- * What the run has accumulated, for a reporter that wants to say what a
239
- * failed scenario left behind. Undefined for a plain workflow.
238
+ * What the run has accumulated.
239
+ *
240
+ * Undefined only for a run whose wire was never decorated — decoration calls
241
+ * `contextForRun` unconditionally, so a plain workflow that has reached a
242
+ * step holds an empty context rather than none.
240
243
  */
241
244
  getRunContext(runId) {
242
245
  return this.runContexts.get(runId);
243
246
  }
244
247
  /**
245
- * The run's context, created on demand.
246
- *
247
- * `attachRunContext` seeds it for an ordinary scenario run, but the wire is
248
- * also built on paths that skip attach; without this the body would be
249
- * writing to an `undefined` and teardown would read nothing.
248
+ * The run's context, created on demand because the wire is also built on
249
+ * paths that skip `attachRunContext`.
250
250
  */
251
251
  contextForRun(runId) {
252
252
  let runContext = this.runContexts.get(runId);
@@ -397,9 +397,6 @@ export class PikkuScenarioService {
397
397
  rpcService,
398
398
  });
399
399
  Object.assign(workflowWire, {
400
- // One object per run, resolved through the map rather than created here,
401
- // so the body and its before/after hooks share the same scratch even
402
- // though the hooks are separate functions that cannot see its locals.
403
400
  context: this.contextForRun(runId),
404
401
  // Durable polling step: invoke an RPC (as an actor when options.as is
405
402
  // set) until the predicate passes or `within` elapses. The whole poll is
@@ -485,6 +482,35 @@ export class PikkuScenarioService {
485
482
  }
486
483
  }, options);
487
484
  },
485
+ expectScore: async (stepName, agentRunId, scorerName, options) => {
486
+ this.engine.verifyStepName(stepName);
487
+ return await this.engine.inlineStep(runId, stepName, async () => {
488
+ const rpcName = 'pikkuScenarioGradeRun';
489
+ const data = {
490
+ runId: agentRunId,
491
+ scorer: scorerName,
492
+ ...(options?.reference !== undefined
493
+ ? { reference: options.reference }
494
+ : {}),
495
+ };
496
+ const grade = options?.actor
497
+ ? await options.actor.invoke(rpcName, data)
498
+ : await rpcService.rpcWithWire(rpcName, data, {});
499
+ // A scorer that graded is a scorer that answered, so an unstated
500
+ // bound still fails a zero rather than passing anything at all.
501
+ const atLeast = options?.atLeast ?? 0.5;
502
+ const failed = grade.score < atLeast ||
503
+ (options?.atMost !== undefined && grade.score > options.atMost);
504
+ if (failed) {
505
+ const bound = options?.atMost !== undefined
506
+ ? `between ${atLeast} and ${options.atMost}`
507
+ : `at least ${atLeast}`;
508
+ throw new Error(`[workflow] expectScore '${stepName}' expected '${scorerName}' to grade run ${agentRunId} ${bound}, got ${grade.score}` +
509
+ `${grade.reason ? `: ${grade.reason}` : ''}`);
510
+ }
511
+ return grade;
512
+ }, options);
513
+ },
488
514
  // knowledge: decisions/internals/scenario-given-and-when-are-sugar-but-then-is-not.md
489
515
  given: (stepName, stepFunc, data, options) => this.scenarioStep('given', scenarioStepContext(), stepName, stepFunc, data, options),
490
516
  when: (stepName, stepFunc, data, options) => this.scenarioStep('when', scenarioStepContext(), stepName, stepFunc, data, options),
@@ -6,7 +6,7 @@ import { isExpectedError } from '../../errors/error-handler.js';
6
6
  import { PikkuMissingMetaError } from '../../errors/errors.js';
7
7
  import { RPCNotFoundError } from '../rpc/rpc-runner.js';
8
8
  import { deriveInvocationId } from './workflow-invocation-id.js';
9
- import { assertWorkflowRunOwner } from './workflow-run-ownership.js';
9
+ import { approvalDeciderFrom } from './workflow-approval-policy.js';
10
10
  import { buildRunTimeline, reconstructStateAt, } from './run-timeline.js';
11
11
  import { DEFAULT_STEP_RETRIES, WORKFLOW_CHILD_POLL_MAX_MS, WORKFLOW_END_STATES, WORKFLOW_POLL_FACTOR, WORKFLOW_POLL_MIN_MS, WORKFLOW_TERMINAL_STATES, } from './workflow-constants.js';
12
12
  import { WorkflowAsyncException, WorkflowCancelledException, WorkflowDispatchException, WorkflowNotFoundError, WorkflowRunCancelledError, WorkflowRunFailedError, WorkflowRunNotFoundError, WorkflowStepFunctionMismatchError, WorkflowStepNameNotString, WorkflowSuspendedException, } from './workflow-errors.js';
@@ -14,6 +14,7 @@ import { resolveWorkflowMeta } from './workflow-meta-resolver.js';
14
14
  import { jobGroupFor, orchestratorQueueName, resolveWorkflowConfig, stepJobOptions, stepWorkerQueueName, } from './workflow-queue-routing.js';
15
15
  import { wireWorkflowQueueWorkers } from './workflow-queue-wiring.js';
16
16
  import { approvalStepNameFor, evaluateApprovalStep, recordApprovalDecision, } from './workflow-approval.js';
17
+ import { auditApprovalDecision } from './workflow-approval-audit.js';
17
18
  import { recordSuspension, suspendStepNameFor } from './workflow-suspend.js';
18
19
  import { RedispatchBackoff, sweepStalledRuns, sweepUndispatchedSteps, } from './workflow-recovery.js';
19
20
  export class PikkuWorkflowService {
@@ -371,6 +372,18 @@ export class PikkuWorkflowService {
371
372
  throw new WorkflowNotFoundError(name);
372
373
  }
373
374
  if (workflowMeta.source === 'graph') {
375
+ // A caller-supplied startNode must be one of the graph's declared entry
376
+ // nodes. startWorkflow is the boundary the public
377
+ // `/workflow/:name/graph/:nodeId` route and triggers enter through, so
378
+ // without this a request could name any dependency-free node — one whose
379
+ // input reads only `trigger` — and fire its RPC directly with
380
+ // attacker-chosen data, skipping every upstream eligibility, validation or
381
+ // approval node. (Internal resume/replay drives runWorkflowGraph directly
382
+ // and is unaffected.)
383
+ if (options?.startNode &&
384
+ !(workflowMeta.entryNodeIds ?? []).includes(options.startNode)) {
385
+ throw new Error(`Workflow graph '${name}': '${options.startNode}' is not a declared entry node`);
386
+ }
374
387
  const shouldInline = options?.inline || !getSingletonServices()?.queueService;
375
388
  return runWorkflowGraph(this, name, input, rpcService, shouldInline, options?.startNode, wire, workflowMeta);
376
389
  }
@@ -1014,11 +1027,12 @@ export class PikkuWorkflowService {
1014
1027
  updateRunState: (runId, key, value) => this.updateRunState(runId, key, value),
1015
1028
  resumeWorkflow: (runId) => this.resumeWorkflow(runId),
1016
1029
  scheduleRunWake: (runId, delay) => this.scheduleRunWake(runId, delay),
1030
+ getRunOwner: async (runId) => (await this.getRunIdentity(runId))?.wire?.pikkuUserId,
1031
+ auditApproval: (event) => auditApprovalDecision(event),
1017
1032
  };
1018
1033
  }
1019
1034
  async approveStep(runId, reason, decision, session) {
1020
- assertWorkflowRunOwner((await this.getRunIdentity(runId))?.wire, session);
1021
- return recordApprovalDecision(this.approvalStore, runId, reason, decision);
1035
+ return recordApprovalDecision(this.approvalStore, runId, reason, decision, approvalDeciderFrom(session));
1022
1036
  }
1023
1037
  async approvalStep(runId, reason, options) {
1024
1038
  const fromStepName = this.lastStepName(runId);
@@ -210,6 +210,14 @@ export interface ScenarioBrowserProvider {
210
210
  * scenario cannot leave the next signed in as somebody else.
211
211
  */
212
212
  reset?(): Promise<void>;
213
+ /**
214
+ * Name the scenario about to run, so anything it captures is filed under it.
215
+ *
216
+ * Called before each scenario. A provider that is never told has to fall
217
+ * back to one shared label, which puts every run's artifacts in a single
218
+ * folder — findable only by timestamp.
219
+ */
220
+ beginScenario?(scenario: string): void;
213
221
  /**
214
222
  * Snapshot every open window for a failed scenario. `label` identifies the
215
223
  * scenario in artifact filenames. Never throws: a failure to capture must
@@ -0,0 +1,16 @@
1
+ import type { ApprovalAuditEvent } from './workflow-approval.js';
2
+ /** The audit type an answer to an approval gate is recorded under. */
3
+ export declare const APPROVAL_AUDIT_TYPE = "workflow.approval.decided";
4
+ /**
5
+ * Record an answer to an approval gate where it outlives the run.
6
+ *
7
+ * The step result carries the settled decision, but it is deleted with the run
8
+ * — `deleteRun` cascades to steps and to history — and a refused attempt never
9
+ * reaches a step at all. An approval is asked for precisely so it can be
10
+ * answered for afterwards, so the answer also goes to the audit sink, which
11
+ * holds no foreign key to the run.
12
+ *
13
+ * A project with no sink wired records nothing, and a sink that fails is logged
14
+ * rather than thrown: the trail must not be the reason a decision is lost.
15
+ */
16
+ export declare const auditApprovalDecision: (event: ApprovalAuditEvent) => Promise<void>;
@@ -0,0 +1,40 @@
1
+ import { getSingletonServices } from '../../pikku-state.js';
2
+ /** The audit type an answer to an approval gate is recorded under. */
3
+ export const APPROVAL_AUDIT_TYPE = 'workflow.approval.decided';
4
+ /**
5
+ * Record an answer to an approval gate where it outlives the run.
6
+ *
7
+ * The step result carries the settled decision, but it is deleted with the run
8
+ * — `deleteRun` cascades to steps and to history — and a refused attempt never
9
+ * reaches a step at all. An approval is asked for precisely so it can be
10
+ * answered for afterwards, so the answer also goes to the audit sink, which
11
+ * holds no foreign key to the run.
12
+ *
13
+ * A project with no sink wired records nothing, and a sink that fails is logged
14
+ * rather than thrown: the trail must not be the reason a decision is lost.
15
+ */
16
+ export const auditApprovalDecision = async (event) => {
17
+ const services = getSingletonServices();
18
+ if (!services?.audit) {
19
+ return;
20
+ }
21
+ try {
22
+ await services.audit.audit({
23
+ type: APPROVAL_AUDIT_TYPE,
24
+ source: 'explicit',
25
+ outcome: event.outcome,
26
+ occurredAt: new Date().toISOString(),
27
+ wireType: 'workflow',
28
+ userIdentity: { pikkuUserId: event.decidedBy?.userId },
29
+ metadata: {
30
+ runId: event.runId,
31
+ reason: event.reason,
32
+ scopes: event.decidedBy?.scopes,
33
+ refusal: event.refusal,
34
+ },
35
+ });
36
+ }
37
+ catch (error) {
38
+ services.logger?.warn(`Failed to audit the decision on approval '${event.reason}' for run ${event.runId}`, error);
39
+ }
40
+ };
@@ -0,0 +1,20 @@
1
+ import { ForbiddenError } from '../../errors/errors.js';
2
+ import type { CoreUserSession } from '../../types/core.types.js';
3
+ import type { ApprovalDecider, WorkflowApprovalPolicy } from './dsl/workflow-dsl.types.js';
4
+ export declare class WorkflowApprovalForbiddenError extends ForbiddenError {
5
+ payload: {
6
+ reason: string;
7
+ detail: string;
8
+ };
9
+ constructor(reason: string, detail: string);
10
+ }
11
+ export declare const approvalDeciderFrom: (session: CoreUserSession | undefined) => ApprovalDecider | undefined;
12
+ /**
13
+ * Judge a decision against the gate's declared policy, returning the reason it
14
+ * is refused or `undefined` if it stands.
15
+ *
16
+ * Returns a message rather than throwing because the same judgement is needed
17
+ * in two places with different outcomes: refusing a live submission with a 403,
18
+ * and clearing an already-recorded decision on replay.
19
+ */
20
+ export declare const approvalPolicyRefusal: (policy: WorkflowApprovalPolicy, owner: string | undefined, decider: ApprovalDecider | undefined) => string | undefined;
@@ -0,0 +1,48 @@
1
+ import { ForbiddenError } from '../../errors/errors.js';
2
+ import { addError } from '../../errors/error-handler.js';
3
+ export class WorkflowApprovalForbiddenError extends ForbiddenError {
4
+ payload;
5
+ constructor(reason, detail) {
6
+ super(detail);
7
+ this.payload = { reason, detail };
8
+ }
9
+ }
10
+ addError(WorkflowApprovalForbiddenError, {
11
+ status: 403,
12
+ message: 'Not authorized to answer this approval.',
13
+ });
14
+ export const approvalDeciderFrom = (session) => session ? { userId: session.userId, scopes: session.scopes } : undefined;
15
+ /**
16
+ * Judge a decision against the gate's declared policy, returning the reason it
17
+ * is refused or `undefined` if it stands.
18
+ *
19
+ * Returns a message rather than throwing because the same judgement is needed
20
+ * in two places with different outcomes: refusing a live submission with a 403,
21
+ * and clearing an already-recorded decision on replay.
22
+ */
23
+ export const approvalPolicyRefusal = (policy, owner, decider) => {
24
+ if (policy.approverScope &&
25
+ !decider?.scopes?.includes(policy.approverScope)) {
26
+ return `Answering this approval requires the '${policy.approverScope}' scope`;
27
+ }
28
+ switch (policy.approvers ?? 'any') {
29
+ case 'owner':
30
+ if (!owner) {
31
+ return undefined;
32
+ }
33
+ if (!decider?.userId || decider.userId !== owner) {
34
+ return 'Only the user who started this run may answer this approval';
35
+ }
36
+ return undefined;
37
+ case 'not-initiator':
38
+ if (!decider?.userId) {
39
+ return 'Answering this approval requires a signed-in user';
40
+ }
41
+ if (owner && decider.userId === owner) {
42
+ return 'The user who started this run may not answer this approval';
43
+ }
44
+ return undefined;
45
+ case 'any':
46
+ return undefined;
47
+ }
48
+ };
@@ -1,4 +1,5 @@
1
1
  import type { ApprovalOutcome, StepState, WorkflowApprovalOptions } from './workflow.types.js';
2
+ import type { ApprovalDecider } from './dsl/workflow-dsl.types.js';
2
3
  /** The durable step name an approval point is recorded under. */
3
4
  export declare const approvalStepNameFor: (reason: string) => string;
4
5
  /**
@@ -9,6 +10,20 @@ export declare const approvalStepNameFor: (reason: string) => string;
9
10
  * shadow, another key.
10
11
  */
11
12
  export declare const approvalStateKey: (stepName: string) => string;
13
+ /**
14
+ * An answer to an approval gate, as the audit trail sees it.
15
+ *
16
+ * `denied` covers both a submission refused at the door and a decision cleared
17
+ * on replay — from the trail's point of view they are the same event, someone
18
+ * tried to answer a gate they could not.
19
+ */
20
+ export type ApprovalAuditEvent = {
21
+ runId: string;
22
+ reason: string;
23
+ outcome: 'success' | 'denied';
24
+ decidedBy?: ApprovalDecider;
25
+ refusal?: string;
26
+ };
12
27
  /** What the approval gate needs from the workflow service. */
13
28
  export type ApprovalStore = {
14
29
  getStepState: (runId: string, stepName: string) => Promise<StepState>;
@@ -19,6 +34,8 @@ export type ApprovalStore = {
19
34
  updateRunState: (runId: string, key: string, value: unknown) => Promise<void>;
20
35
  resumeWorkflow: (runId: string) => Promise<void>;
21
36
  scheduleRunWake: (runId: string, delay: number) => Promise<void>;
37
+ getRunOwner: (runId: string) => Promise<string | undefined>;
38
+ auditApproval: (event: ApprovalAuditEvent) => Promise<void>;
22
39
  };
23
40
  /**
24
41
  * Record a decision against an approval point and wake the run.
@@ -26,8 +43,14 @@ export type ApprovalStore = {
26
43
  * A decision arriving for an already-settled approval is refused rather than
27
44
  * overwriting it: the run has moved on, and a second answer would be recorded
28
45
  * against a gate nobody is waiting at.
46
+ *
47
+ * The decider is recorded rather than judged here, for the same reason the
48
+ * payload is: the gate's policy is a value on the workflow, and a decision can
49
+ * legitimately arrive before the run has ever reached the gate. Where the run
50
+ * has already published its policy, it is applied eagerly so a caller who
51
+ * cannot answer is told so rather than left waiting.
29
52
  */
30
- export declare const recordApprovalDecision: (store: ApprovalStore, runId: string, reason: string, decision: unknown) => Promise<void>;
53
+ export declare const recordApprovalDecision: (store: ApprovalStore, runId: string, reason: string, decision: unknown, decidedBy?: ApprovalDecider) => Promise<void>;
31
54
  /**
32
55
  * Evaluate an approval gate on replay.
33
56
  *
@@ -35,5 +58,10 @@ export declare const recordApprovalDecision: (store: ApprovalStore, runId: strin
35
58
  * schema is a value on the workflow and only the workflow has it. A payload
36
59
  * that fails validation is cleared and the run suspends again, so a bad
37
60
  * submission cannot settle the gate.
61
+ *
62
+ * The decider is judged here for the same reason, and for one more: a decision
63
+ * can be recorded before the run has ever reached the gate, so submission time
64
+ * is not a point at which the policy is reliably knowable. Judging on replay is
65
+ * what makes the policy hold in every ordering.
38
66
  */
39
67
  export declare const evaluateApprovalStep: (store: ApprovalStore, runId: string, reason: string, approvalStepName: string, fromStepName: string | undefined, options: WorkflowApprovalOptions) => Promise<ApprovalOutcome<unknown>>;
@@ -1,5 +1,6 @@
1
1
  import { getDurationInMilliseconds } from '../../time-utils.js';
2
2
  import { WorkflowApprovalResolvedError, WorkflowSuspendedException, } from './workflow-errors.js';
3
+ import { approvalPolicyRefusal, WorkflowApprovalForbiddenError, } from './workflow-approval-policy.js';
3
4
  /** The durable step name an approval point is recorded under. */
4
5
  export const approvalStepNameFor = (reason) => `__workflow_approval:${reason}`;
5
6
  /**
@@ -22,8 +23,14 @@ export const approvalStateKey = (stepName) => {
22
23
  * A decision arriving for an already-settled approval is refused rather than
23
24
  * overwriting it: the run has moved on, and a second answer would be recorded
24
25
  * against a gate nobody is waiting at.
26
+ *
27
+ * The decider is recorded rather than judged here, for the same reason the
28
+ * payload is: the gate's policy is a value on the workflow, and a decision can
29
+ * legitimately arrive before the run has ever reached the gate. Where the run
30
+ * has already published its policy, it is applied eagerly so a caller who
31
+ * cannot answer is told so rather than left waiting.
25
32
  */
26
- export const recordApprovalDecision = async (store, runId, reason, decision) => {
33
+ export const recordApprovalDecision = async (store, runId, reason, decision, decidedBy) => {
27
34
  const stepName = approvalStepNameFor(reason);
28
35
  const stateKey = approvalStateKey(stepName);
29
36
  let resolved;
@@ -39,12 +46,27 @@ export const recordApprovalDecision = async (store, runId, reason, decision) =>
39
46
  }
40
47
  const state = await store.getRunState(runId);
41
48
  const record = (state[stateKey] ?? {});
49
+ if (record.policy) {
50
+ const refusal = approvalPolicyRefusal(record.policy, await store.getRunOwner(runId), decidedBy);
51
+ if (refusal) {
52
+ await store.auditApproval({
53
+ runId,
54
+ reason,
55
+ outcome: 'denied',
56
+ decidedBy,
57
+ refusal,
58
+ });
59
+ throw new WorkflowApprovalForbiddenError(reason, refusal);
60
+ }
61
+ }
42
62
  await store.updateRunState(runId, stateKey, {
43
63
  ...record,
44
64
  decision,
45
65
  decidedAt: new Date().toISOString(),
66
+ decidedBy,
46
67
  error: undefined,
47
68
  });
69
+ await store.auditApproval({ runId, reason, outcome: 'success', decidedBy });
48
70
  await store.resumeWorkflow(runId);
49
71
  };
50
72
  /**
@@ -54,9 +76,19 @@ export const recordApprovalDecision = async (store, runId, reason, decision) =>
54
76
  * schema is a value on the workflow and only the workflow has it. A payload
55
77
  * that fails validation is cleared and the run suspends again, so a bad
56
78
  * submission cannot settle the gate.
79
+ *
80
+ * The decider is judged here for the same reason, and for one more: a decision
81
+ * can be recorded before the run has ever reached the gate, so submission time
82
+ * is not a point at which the policy is reliably knowable. Judging on replay is
83
+ * what makes the policy hold in every ordering.
57
84
  */
58
85
  export const evaluateApprovalStep = async (store, runId, reason, approvalStepName, fromStepName, options) => {
59
- const insert = () => store.insertStepState(runId, approvalStepName, 'pikkuWorkflowApproval', { reason, expiry: options.expiry }, undefined, fromStepName);
86
+ const insert = () => store.insertStepState(runId, approvalStepName, 'pikkuWorkflowApproval', {
87
+ reason,
88
+ expiry: options.expiry,
89
+ approvers: options.approvers,
90
+ approverScope: options.approverScope,
91
+ }, undefined, fromStepName);
60
92
  let stepState;
61
93
  try {
62
94
  stepState = await store.getStepState(runId, approvalStepName);
@@ -72,8 +104,13 @@ export const evaluateApprovalStep = async (store, runId, reason, approvalStepNam
72
104
  }
73
105
  const stateKey = approvalStateKey(approvalStepName);
74
106
  let record = ((await store.getRunState(runId))[stateKey] ?? {});
107
+ const policy = {
108
+ approvers: options.approvers,
109
+ approverScope: options.approverScope,
110
+ };
75
111
  if (stepState.status === 'pending') {
76
112
  await store.setStepRunning(stepState.stepId);
113
+ record = { ...record, policy };
77
114
  if (options.expiry !== undefined && !record.expiresAt) {
78
115
  const expiry = getDurationInMilliseconds(options.expiry);
79
116
  record = {
@@ -83,14 +120,36 @@ export const evaluateApprovalStep = async (store, runId, reason, approvalStepNam
83
120
  await store.updateRunState(runId, stateKey, record);
84
121
  await store.scheduleRunWake(runId, expiry);
85
122
  }
123
+ else {
124
+ await store.updateRunState(runId, stateKey, record);
125
+ }
86
126
  }
87
127
  if (record.decision !== undefined) {
128
+ const refusal = approvalPolicyRefusal(policy, await store.getRunOwner(runId), record.decidedBy);
129
+ if (refusal) {
130
+ await store.updateRunState(runId, stateKey, {
131
+ ...record,
132
+ decision: undefined,
133
+ decidedAt: undefined,
134
+ decidedBy: undefined,
135
+ error: [{ message: refusal }],
136
+ });
137
+ await store.auditApproval({
138
+ runId,
139
+ reason,
140
+ outcome: 'denied',
141
+ decidedBy: record.decidedBy,
142
+ refusal,
143
+ });
144
+ throw new WorkflowSuspendedException(runId, reason);
145
+ }
88
146
  const validation = await options.schema['~standard'].validate(record.decision);
89
147
  if (validation.issues) {
90
148
  await store.updateRunState(runId, stateKey, {
91
149
  ...record,
92
150
  decision: undefined,
93
151
  decidedAt: undefined,
152
+ decidedBy: undefined,
94
153
  error: validation.issues.map((issue) => ({
95
154
  message: issue.message,
96
155
  path: issue.path?.map((segment) => typeof segment === 'object' ? segment.key : segment),
@@ -98,9 +157,13 @@ export const evaluateApprovalStep = async (store, runId, reason, approvalStepNam
98
157
  });
99
158
  throw new WorkflowSuspendedException(runId, reason);
100
159
  }
160
+ // Spread rather than assigned, so a gate answered without a session keeps
161
+ // the shape it had before there was anything to record.
101
162
  const outcome = {
102
163
  status: 'decided',
103
164
  data: validation.value,
165
+ ...(record.decidedBy ? { decidedBy: record.decidedBy } : {}),
166
+ ...(record.decidedAt ? { decidedAt: record.decidedAt } : {}),
104
167
  };
105
168
  await store.setStepResult(stepState.stepId, outcome);
106
169
  return outcome;
@@ -6,7 +6,8 @@ export declare class WorkflowRunForbiddenError extends ForbiddenError {
6
6
  }
7
7
  /**
8
8
  * A run started through a session records that session's user as its owner, and
9
- * only that user may read it or answer its approval gates.
9
+ * only that user may read it. Who may *answer* a run's approval gates is the
10
+ * gate's own declaration — see `approvers` on `workflow.approval()`.
10
11
  *
11
12
  * A run with no recorded owner — started by a trigger, a scheduler, or a route
12
13
  * wired without auth — has nobody to compare a caller against, so ownership is
@@ -11,7 +11,8 @@ addError(WorkflowRunForbiddenError, {
11
11
  });
12
12
  /**
13
13
  * A run started through a session records that session's user as its owner, and
14
- * only that user may read it or answer its approval gates.
14
+ * only that user may read it. Who may *answer* a run's approval gates is the
15
+ * gate's own declaration — see `approvers` on `workflow.approval()`.
15
16
  *
16
17
  * A run with no recorded owner — started by a trigger, a scheduler, or a route
17
18
  * wired without auth — has nobody to compare a caller against, so ownership is
@@ -2,7 +2,7 @@ import type { SerializedError, CommonWireMeta } from '../../types/core.types.js'
2
2
  import type { CorePikkuFunctionConfig, CorePikkuFunctionHook } from '../../function/functions.types.js';
3
3
  import type { GroupConcurrencyConfig } from '../queue/queue.types.js';
4
4
  export type { WorkflowService } from '../../services/workflow-service.js';
5
- export type { WorkflowStepOptions, WorkflowExpectEventuallyOptions, WorkflowExpectErrorOptions, WorkflowExpectServiceOptions, WorkflowWireDoRPC, WorkflowWireDoInline, WorkflowWireSleep, WorkflowWireSuspend, WorkflowWireApproval, WorkflowApprovalOptions, ApprovalOutcome, InputSource, OutputBinding, RpcStepMeta, SimpleCondition, Condition, BranchCase, BranchStepMeta, ParallelGroupStepMeta, FanoutStepMeta, ReturnStepMeta, InlineStepMeta, SleepStepMeta, CancelStepMeta, SuspendStepMeta, ApprovalStepMeta, SetStepMeta, SwitchCaseMeta, SwitchStepMeta, FilterStepMeta, ArrayPredicateStepMeta, ScenarioStepInvocation, ScenarioStepMeta, WorkflowStepMeta, WorkflowStepWire, PikkuWorkflowWire, PikkuScenarioWire, } from './dsl/workflow-dsl.types.js';
5
+ export type { WorkflowStepOptions, WorkflowWireDoRPC, WorkflowApprovalOptions, ApprovalOutcome, InputSource, OutputBinding, RpcStepMeta, Condition, BranchStepMeta, ParallelGroupStepMeta, FanoutStepMeta, ReturnStepMeta, InlineStepMeta, SleepStepMeta, CancelStepMeta, SuspendStepMeta, ApprovalStepMeta, SetStepMeta, SwitchCaseMeta, SwitchStepMeta, FilterStepMeta, ArrayPredicateStepMeta, ScenarioStepInvocation, ScenarioStepMeta, WorkflowStepMeta, WorkflowStepWire, PikkuWorkflowWire, PikkuScenarioWire, } from './dsl/workflow-dsl.types.js';
6
6
  export type { ScenarioStepPhase, ScenarioStepOptions, PikkuScenarioStepWire, PikkuBrowserWire, ScenarioBrowserProvider, } from './scenario-step.types.js';
7
7
  import type { WorkflowStepMeta } from './dsl/workflow-dsl.types.js';
8
8
  export interface WorkflowRunWire {
@@ -0,0 +1,32 @@
1
+ ---
2
+ type: decision
3
+ title: An addon's .pikku meta ships at the package root or under dist, and readers probe both
4
+ description: The layout depends on the addon's build and files field, so a consumer resolving the directory by path must try `<pkg>/.pikku` and `<pkg>/dist/.pikku` before concluding an addon declares nothing
5
+ tags: services
6
+ ---
7
+
8
+ # An addon's .pikku meta ships at the package root or under dist, and readers probe both
9
+
10
+ An addon generates `.pikku/` at its project root, and whether that directory
11
+ reaches the published tarball at the root or inside `dist/` depends on the
12
+ addon's own `tsconfig` and `files` — both layouts are in the wild and both are
13
+ supported. The published `exports` map hides the difference for anyone
14
+ resolving by specifier: `"./.pikku/*"` points at wherever it landed, so
15
+ `require.resolve('<pkg>/.pikku/scopes/pikku-scopes-meta.gen.json')` works for
16
+ either shape, and that is the resolution every consumer should prefer.
17
+
18
+ Code that builds the path itself must probe both. `addonPikkuDir`
19
+ (`packages/addon/pikku-console/src/lib/derive-instance-overrides.ts`) tries
20
+ `<pkg>/.pikku` then `<pkg>/dist/.pikku` and returns `null` when neither exists.
21
+ The reason to say so out loud is the failure mode: a reader that assumes the
22
+ root finds nothing under a `dist`-shipping addon and cannot distinguish that
23
+ from an addon that declares no secrets, variables or scopes at all. Both answers
24
+ are "empty", and the wrong one is indistinguishable from a correct one until
25
+ something downstream reports an addon as ready when its secrets were never
26
+ checked.
27
+
28
+ **What this rules out:** hardcoding either path in a consumer, and treating a
29
+ missing directory as evidence an addon declares nothing without having tried
30
+ both. It also rules out normalising the layout by making the CLI move the
31
+ directory at publish time — the export map already makes the layout private to
32
+ the package, and the addons shipping each shape are already published.
@@ -0,0 +1,39 @@
1
+ ---
2
+ type: decision
3
+ title: An addon's scope root loses to a root the host app already declares
4
+ description: loadAddonFunctionsMeta merges addon scope trees by root name, first declaration wins, so an addon sharing a root with its host contributes nothing — silently
5
+ tags: services
6
+ ---
7
+
8
+ # An addon's scope root loses to a root the host app already declares
9
+
10
+ `loadAddonFunctionsMeta`
11
+ (`packages/inspector/src/utils/load-addon-functions-meta.ts`) reads each wired
12
+ addon's `pikku-scopes-meta.gen.json` and pushes its entries into
13
+ `state.scopes.definitions`, skipping any whose root name is already present. The
14
+ host app's own declarations were inspected first, so the host always wins.
15
+
16
+ The skip is per **root**, not per node. An addon declaring `admin.console.*` in
17
+ an app that declares `admin` contributes none of it — not the root it shares,
18
+ and not the branch the app never declared. Nothing errors: the scopes simply do
19
+ not exist, so `ScopeId` never gains them, no role can be granted one, and every
20
+ addon function requiring one denies everybody. The failure surfaces as a
21
+ `MissingScopeError` against a scope that cannot be granted, which reads as a
22
+ permissions bug rather than a merge that dropped.
23
+
24
+ This is a different rule from
25
+ [scope roots may be co-declared by an addon and its host
26
+ app](scope-roots-may-be-co-declared-by-an-addon-and-its-host-app.md): that one
27
+ is about `flattenScopeDefinitions` deduping ids *within* a build where both
28
+ declarations are present and identical. This one is about the addon's copy never
29
+ arriving. The consequence for addon authors is the same either way — own a root
30
+ outright, named for the package or vendor, and nest everything under it. It is
31
+ why `@pikku/addon-console` declares `pikku:console:*` rather than
32
+ `admin:console:*`.
33
+
34
+ **What this rules out:** shipping an addon whose scopes hang off a root a host
35
+ is likely to declare, and deep-merging the two trees here so both survive. A
36
+ merge would have to reconcile conflicting descriptions and display names for the
37
+ shared nodes, and would let an addon graft capabilities onto the host's `admin`
38
+ tree — where a role granting `admin` would pick them up without anyone having
39
+ asked for them.