@pikku/core 0.12.80 → 0.12.83

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 (237) hide show
  1. package/CHANGELOG.md +345 -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 -16
  14. package/dist/services/index.js +5 -5
  15. package/dist/services/meta-service.d.ts +2 -1
  16. package/dist/services/scoped-credential-service.d.ts +21 -0
  17. package/dist/services/scoped-credential-service.js +53 -0
  18. package/dist/testing/service-tests/ai-storage-service-tests.js +76 -0
  19. package/dist/types/core.types.d.ts +2 -3
  20. package/dist/types/state.types.d.ts +19 -1
  21. package/dist/wirings/actor-flow/index.d.ts +1 -1
  22. package/dist/wirings/ai-agent/ai-agent-finalize.d.ts +58 -0
  23. package/dist/wirings/ai-agent/ai-agent-finalize.js +138 -0
  24. package/dist/wirings/ai-agent/ai-agent-interrupt.js +1 -0
  25. package/dist/wirings/ai-agent/ai-agent-memory.d.ts +2 -8
  26. package/dist/wirings/ai-agent/ai-agent-memory.js +34 -17
  27. package/dist/wirings/ai-agent/ai-agent-model-config.d.ts +7 -0
  28. package/dist/wirings/ai-agent/ai-agent-model-config.js +44 -1
  29. package/dist/wirings/ai-agent/ai-agent-prepare.js +4 -0
  30. package/dist/wirings/ai-agent/ai-agent-runner.js +61 -40
  31. package/dist/wirings/ai-agent/ai-agent-stream.js +89 -36
  32. package/dist/wirings/ai-agent/ai-agent-turn.d.ts +1 -0
  33. package/dist/wirings/ai-agent/ai-agent-turn.js +1 -0
  34. package/dist/wirings/ai-agent/ai-agent.types.d.ts +46 -1
  35. package/dist/wirings/ai-agent/index.d.ts +8 -7
  36. package/dist/wirings/ai-agent/index.js +5 -4
  37. package/dist/wirings/ai-scorer/ai-scorer-grade.d.ts +26 -0
  38. package/dist/wirings/ai-scorer/ai-scorer-grade.js +33 -0
  39. package/dist/wirings/ai-scorer/ai-scorer-judge.d.ts +17 -0
  40. package/dist/wirings/ai-scorer/ai-scorer-judge.js +92 -0
  41. package/dist/wirings/ai-scorer/ai-scorer-live.d.ts +15 -0
  42. package/dist/wirings/ai-scorer/ai-scorer-live.js +38 -0
  43. package/dist/wirings/ai-scorer/ai-scorer-registry.d.ts +18 -0
  44. package/dist/wirings/ai-scorer/ai-scorer-registry.js +46 -0
  45. package/dist/wirings/ai-scorer/ai-scorer-sampling.d.ts +8 -0
  46. package/dist/wirings/ai-scorer/ai-scorer-sampling.js +31 -0
  47. package/dist/wirings/ai-scorer/ai-scorer-snapshots.d.ts +10 -0
  48. package/dist/wirings/ai-scorer/ai-scorer-snapshots.js +40 -0
  49. package/dist/wirings/ai-scorer/ai-scorer-worker.d.ts +15 -0
  50. package/dist/wirings/ai-scorer/ai-scorer-worker.js +58 -0
  51. package/dist/wirings/ai-scorer/ai-scorer.d.ts +39 -0
  52. package/dist/wirings/ai-scorer/ai-scorer.js +40 -0
  53. package/dist/wirings/ai-scorer/ai-scorer.types.d.ts +90 -0
  54. package/dist/wirings/ai-scorer/ai-scorer.types.js +4 -0
  55. package/dist/wirings/ai-scorer/index.d.ts +6 -0
  56. package/dist/wirings/ai-scorer/index.js +5 -0
  57. package/dist/wirings/channel/index.d.ts +5 -6
  58. package/dist/wirings/channel/index.js +3 -4
  59. package/dist/wirings/channel/local/local-channel-runner.js +8 -1
  60. package/dist/wirings/cli/channel/cli-raw-channel-runner.js +9 -1
  61. package/dist/wirings/cli/channel/index.d.ts +1 -2
  62. package/dist/wirings/cli/channel/index.js +0 -1
  63. package/dist/wirings/cli/cli-runner.js +13 -1
  64. package/dist/wirings/credential/index.d.ts +1 -1
  65. package/dist/wirings/gateway/index.d.ts +1 -1
  66. package/dist/wirings/http/http-runner.js +8 -2
  67. package/dist/wirings/http/index.d.ts +1 -2
  68. package/dist/wirings/mcp/index.d.ts +1 -1
  69. package/dist/wirings/mcp/mcp-runner.d.ts +15 -0
  70. package/dist/wirings/mcp/mcp-runner.js +18 -5
  71. package/dist/wirings/persona/index.d.ts +3 -4
  72. package/dist/wirings/persona/index.js +2 -3
  73. package/dist/wirings/queue/index.d.ts +1 -3
  74. package/dist/wirings/queue/index.js +1 -3
  75. package/dist/wirings/rpc/addon-runner.d.ts +8 -0
  76. package/dist/wirings/rpc/addon-runner.js +31 -3
  77. package/dist/wirings/rpc/rpc-runner.js +4 -0
  78. package/dist/wirings/rpc/rpc-types.d.ts +8 -0
  79. package/dist/wirings/rpc/wire-addon.d.ts +25 -0
  80. package/dist/wirings/rpc/wire-addon.js +8 -0
  81. package/dist/wirings/scheduler/index.d.ts +1 -1
  82. package/dist/wirings/trigger/index.d.ts +1 -1
  83. package/dist/wirings/virtual-user/index.d.ts +5 -6
  84. package/dist/wirings/virtual-user/index.js +2 -4
  85. package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +85 -15
  86. package/dist/wirings/workflow/feature.d.ts +2 -1
  87. package/dist/wirings/workflow/index.d.ts +5 -16
  88. package/dist/wirings/workflow/index.js +1 -9
  89. package/dist/wirings/workflow/pikku-scenario-service.d.ts +17 -7
  90. package/dist/wirings/workflow/pikku-scenario-service.js +48 -13
  91. package/dist/wirings/workflow/pikku-workflow-service.js +17 -3
  92. package/dist/wirings/workflow/scenario-step.types.d.ts +8 -0
  93. package/dist/wirings/workflow/scenario.types.d.ts +37 -0
  94. package/dist/wirings/workflow/workflow-approval-audit.d.ts +16 -0
  95. package/dist/wirings/workflow/workflow-approval-audit.js +40 -0
  96. package/dist/wirings/workflow/workflow-approval-policy.d.ts +20 -0
  97. package/dist/wirings/workflow/workflow-approval-policy.js +48 -0
  98. package/dist/wirings/workflow/workflow-approval.d.ts +29 -1
  99. package/dist/wirings/workflow/workflow-approval.js +65 -2
  100. package/dist/wirings/workflow/workflow-run-ownership.d.ts +2 -1
  101. package/dist/wirings/workflow/workflow-run-ownership.js +2 -1
  102. package/dist/wirings/workflow/workflow.types.d.ts +2 -37
  103. package/knowledge/decisions/internals/addon-pikku-meta-ships-at-the-package-root-or-under-dist.md +32 -0
  104. package/knowledge/decisions/internals/an-addon-scope-root-loses-to-a-root-the-host-already-declares.md +39 -0
  105. package/knowledge/decisions/internals/index.md +30 -3
  106. package/knowledge/decisions/internals/validate-runs-checks-by-precondition.md +115 -0
  107. package/knowledge/decisions/security/a-function-never-receives-the-secret-service.md +37 -0
  108. package/knowledge/decisions/security/a-workflow-run-is-read-and-approved-by-its-owner.md +30 -14
  109. package/knowledge/decisions/security/an-approval-answer-outlives-the-run-it-answered.md +59 -0
  110. package/knowledge/decisions/security/index.md +3 -1
  111. package/knowledge/questions/index.md +1 -1
  112. package/package.json +3 -2
  113. package/scripts/generate-api-report.mts +143 -18
  114. package/src/api-report.test.ts +2 -2
  115. package/src/errors/index.ts +1 -1
  116. package/src/function/function-runner.test.ts +52 -0
  117. package/src/function/function-runner.ts +5 -9
  118. package/src/function/index.ts +0 -2
  119. package/src/index.ts +0 -35
  120. package/src/pikku-state.ts +5 -0
  121. package/src/public-surface.json +81 -118
  122. package/src/services/ai-agent-runner-service.ts +12 -1
  123. package/src/services/ai-run-state-service.ts +11 -0
  124. package/src/services/in-memory-ai-run-state-service.ts +13 -0
  125. package/src/services/index.ts +7 -58
  126. package/src/services/meta-service.ts +2 -4
  127. package/src/services/scoped-credential-service.test.ts +86 -0
  128. package/src/services/scoped-credential-service.ts +63 -0
  129. package/src/testing/service-tests/ai-storage-service-tests.ts +93 -0
  130. package/src/types/core.types.ts +4 -7
  131. package/src/types/state.types.ts +21 -1
  132. package/src/wirings/actor-flow/index.ts +0 -3
  133. package/src/wirings/ai-agent/ai-agent-finalize.test.ts +186 -0
  134. package/src/wirings/ai-agent/ai-agent-finalize.ts +197 -0
  135. package/src/wirings/ai-agent/ai-agent-interrupt.ts +1 -0
  136. package/src/wirings/ai-agent/ai-agent-memory.ts +54 -38
  137. package/src/wirings/ai-agent/ai-agent-model-config.test.ts +72 -3
  138. package/src/wirings/ai-agent/ai-agent-model-config.ts +49 -1
  139. package/src/wirings/ai-agent/ai-agent-prepare.ts +4 -0
  140. package/src/wirings/ai-agent/ai-agent-runner.ts +71 -40
  141. package/src/wirings/ai-agent/ai-agent-stream-output-hooks.test.ts +353 -0
  142. package/src/wirings/ai-agent/ai-agent-stream.ts +116 -54
  143. package/src/wirings/ai-agent/ai-agent-turn.test.ts +67 -0
  144. package/src/wirings/ai-agent/ai-agent-turn.ts +1 -0
  145. package/src/wirings/ai-agent/ai-agent.types.ts +64 -4
  146. package/src/wirings/ai-agent/index.ts +2 -16
  147. package/src/wirings/ai-scorer/ai-scorer-grade.test.ts +106 -0
  148. package/src/wirings/ai-scorer/ai-scorer-grade.ts +55 -0
  149. package/src/wirings/ai-scorer/ai-scorer-judge.test.ts +143 -0
  150. package/src/wirings/ai-scorer/ai-scorer-judge.ts +120 -0
  151. package/src/wirings/ai-scorer/ai-scorer-live.test.ts +174 -0
  152. package/src/wirings/ai-scorer/ai-scorer-live.ts +56 -0
  153. package/src/wirings/ai-scorer/ai-scorer-registry.ts +63 -0
  154. package/src/wirings/ai-scorer/ai-scorer-sampling.test.ts +34 -0
  155. package/src/wirings/ai-scorer/ai-scorer-sampling.ts +36 -0
  156. package/src/wirings/ai-scorer/ai-scorer-snapshots.test.ts +49 -0
  157. package/src/wirings/ai-scorer/ai-scorer-snapshots.ts +46 -0
  158. package/src/wirings/ai-scorer/ai-scorer-worker.test.ts +122 -0
  159. package/src/wirings/ai-scorer/ai-scorer-worker.ts +69 -0
  160. package/src/wirings/ai-scorer/ai-scorer.ts +76 -0
  161. package/src/wirings/ai-scorer/ai-scorer.types.ts +107 -0
  162. package/src/wirings/ai-scorer/index.ts +24 -0
  163. package/src/wirings/channel/index.ts +1 -20
  164. package/src/wirings/channel/local/local-channel-runner.test.ts +68 -0
  165. package/src/wirings/channel/local/local-channel-runner.ts +8 -1
  166. package/src/wirings/cli/channel/cli-raw-channel-runner.test.ts +23 -0
  167. package/src/wirings/cli/channel/cli-raw-channel-runner.ts +12 -1
  168. package/src/wirings/cli/channel/index.ts +0 -7
  169. package/src/wirings/cli/cli-runner.test.ts +68 -0
  170. package/src/wirings/cli/cli-runner.ts +18 -1
  171. package/src/wirings/credential/index.ts +0 -1
  172. package/src/wirings/gateway/index.ts +0 -3
  173. package/src/wirings/http/http-runner.test.ts +66 -0
  174. package/src/wirings/http/http-runner.ts +10 -2
  175. package/src/wirings/http/index.ts +1 -1
  176. package/src/wirings/mcp/index.ts +0 -1
  177. package/src/wirings/mcp/mcp-runner.test.ts +181 -0
  178. package/src/wirings/mcp/mcp-runner.ts +35 -5
  179. package/src/wirings/persona/index.ts +0 -8
  180. package/src/wirings/queue/index.ts +0 -14
  181. package/src/wirings/rpc/addon-runner.ts +62 -3
  182. package/src/wirings/rpc/addon-secrets.test.ts +391 -0
  183. package/src/wirings/rpc/rpc-runner.test.ts +2 -0
  184. package/src/wirings/rpc/rpc-runner.ts +4 -0
  185. package/src/wirings/rpc/rpc-types.ts +8 -0
  186. package/src/wirings/rpc/wire-addon.ts +33 -0
  187. package/src/wirings/scheduler/index.ts +0 -1
  188. package/src/wirings/trigger/index.ts +0 -1
  189. package/src/wirings/virtual-user/index.ts +0 -16
  190. package/src/wirings/workflow/dsl/workflow-dsl.types.ts +96 -16
  191. package/src/wirings/workflow/feature.ts +2 -5
  192. package/src/wirings/workflow/graph/graph-runner.test.ts +72 -0
  193. package/src/wirings/workflow/index.ts +2 -68
  194. package/src/wirings/workflow/pikku-scenario-service.ts +81 -16
  195. package/src/wirings/workflow/pikku-workflow-service.test.ts +13 -12
  196. package/src/wirings/workflow/pikku-workflow-service.ts +28 -4
  197. package/src/wirings/workflow/scenario-expectations.test.ts +75 -0
  198. package/src/wirings/workflow/scenario-hooks.test.ts +3 -2
  199. package/src/wirings/workflow/scenario-step.types.ts +8 -0
  200. package/src/wirings/workflow/scenario.types.ts +63 -0
  201. package/src/wirings/workflow/workflow-approval-audit.ts +47 -0
  202. package/src/wirings/workflow/workflow-approval-policy.test.ts +524 -0
  203. package/src/wirings/workflow/workflow-approval-policy.ts +68 -0
  204. package/src/wirings/workflow/workflow-approval.ts +113 -9
  205. package/src/wirings/workflow/workflow-run-authority.test.ts +12 -15
  206. package/src/wirings/workflow/workflow-run-ownership.ts +2 -1
  207. package/src/wirings/workflow/workflow.types.ts +1 -63
  208. package/src/wirings-stay-decoupled.test.ts +6 -2
  209. package/tsconfig.tsbuildinfo +1 -1
  210. package/dist/internal.d.ts +0 -3
  211. package/dist/internal.js +0 -2
  212. package/dist/middleware/timeout.d.ts +0 -9
  213. package/dist/middleware/timeout.js +0 -15
  214. package/dist/pikku-response.d.ts +0 -6
  215. package/dist/pikku-response.js +0 -6
  216. package/dist/services/gopass-secrets.d.ts +0 -15
  217. package/dist/services/gopass-secrets.js +0 -76
  218. package/dist/services/http-scenario-actors.d.ts +0 -75
  219. package/dist/services/http-scenario-actors.js +0 -195
  220. package/dist/services/http-user-flow-actors.d.ts +0 -67
  221. package/dist/services/http-user-flow-actors.js +0 -193
  222. package/dist/services/scenario-actors-service.d.ts +0 -127
  223. package/dist/services/scenario-actors-service.js +0 -40
  224. package/dist/services/user-flow-actors-service.d.ts +0 -39
  225. package/dist/wirings/credential/wire-credential.d.ts +0 -48
  226. package/dist/wirings/credential/wire-credential.js +0 -47
  227. package/dist/wirings/oauth2/oauth2-client.d.ts +0 -47
  228. package/dist/wirings/oauth2/oauth2-client.js +0 -263
  229. package/dist/wirings/oauth2/oauth2-routes.d.ts +0 -35
  230. package/dist/wirings/oauth2/oauth2-routes.js +0 -146
  231. package/dist/wirings/scope/wire-scope.d.ts +0 -33
  232. package/dist/wirings/scope/wire-scope.js +0 -32
  233. package/dist/wirings/workflow/dsl/index.d.ts +0 -5
  234. package/dist/wirings/workflow/dsl/index.js +0 -4
  235. package/dist/wirings/workflow/graph/index.d.ts +0 -5
  236. package/dist/wirings/workflow/graph/index.js +0 -4
  237. /package/dist/{services/user-flow-actors-service.js → wirings/workflow/scenario.types.js} +0 -0
@@ -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
@@ -1,9 +1,8 @@
1
1
  import type { SerializedError, CommonWireMeta } from '../../types/core.types.js';
2
- import type { CorePikkuFunctionConfig, CorePikkuFunctionHook } from '../../function/functions.types.js';
2
+ import type { CorePikkuFunctionConfig } 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';
6
- export type { ScenarioStepPhase, ScenarioStepOptions, PikkuScenarioStepWire, PikkuBrowserWire, ScenarioBrowserProvider, } from './scenario-step.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, WorkflowStepMeta, WorkflowStepWire, PikkuWorkflowWire, } from './dsl/workflow-dsl.types.js';
7
6
  import type { WorkflowStepMeta } from './dsl/workflow-dsl.types.js';
8
7
  export interface WorkflowRunWire {
9
8
  type: string;
@@ -141,40 +140,6 @@ export type CoreWorkflow<PikkuFunctionConfig extends CorePikkuFunctionConfig<any
141
140
  middleware?: PikkuFunctionConfig['middleware'];
142
141
  tags?: string[];
143
142
  };
144
- export type CoreFeatureScenario = CorePikkuFunctionConfig<any, any, any> | {
145
- scenario: CorePikkuFunctionConfig<any, any, any>;
146
- data: unknown;
147
- };
148
- export type CoreFeature = {
149
- name: string;
150
- description?: string;
151
- tags?: string[];
152
- scenarios: readonly CoreFeatureScenario[];
153
- before?: CorePikkuFunctionHook;
154
- after?: CorePikkuFunctionHook;
155
- };
156
- export type FeatureMetaEntry = {
157
- scenario: string;
158
- data?: unknown;
159
- };
160
- export type FeatureMeta = {
161
- id: string;
162
- name: string;
163
- description?: string;
164
- tags: string[];
165
- entries: FeatureMetaEntry[];
166
- unresolvedEntries: number;
167
- hasBefore: boolean;
168
- hasAfter: boolean;
169
- };
170
- export type FeaturesMeta = Record<string, FeatureMeta>;
171
- export type FeaturePlanEntry = {
172
- featureId: string;
173
- featureName: string;
174
- scenarioName: string;
175
- data?: unknown;
176
- tags: string[];
177
- };
178
143
  export interface PikkuWorkflow {
179
144
  start: <I>(input: I) => Promise<{
180
145
  runId: string;
@@ -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.
@@ -22,6 +22,7 @@ caller is entitled to assume.
22
22
  - [The actor-flow conversation engine only sees a transport-agnostic target driver](actor-flow-drives-the-target-through-a-transport-seam.md) — The engine never imports the agent runner; the target is injected as run/approve, so scenarios exercise the real wire path
23
23
  - [An actor-flow verdict is the persona's self-evaluation, not an assertion](actor-flow-verdicts-are-llm-self-evaluations.md) — The engine returns what the actor judged plus the transcript; deterministic checks stay with the caller
24
24
  - [Addon package roots resolve by walking node module search paths](addon-package-roots-resolve-by-walking-node-module-search-paths.md) — LocalMetaService finds an addon's directory by scanning resolve.paths, not by require.resolve, because addon packages expose no main entry
25
+ - [An addon's .pikku meta ships at the package root or under dist, and readers probe both](addon-pikku-meta-ships-at-the-package-root-or-under-dist.md) — 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
25
26
  - [Addon singleton services are cached per namespace, not per package](addon-singleton-services-are-cached-per-namespace-not-per-package.md) — Each wireAddon instance gets its own services built from its own overrides, at the cost of one service graph per wired instance
26
27
  - [Bare workflow names from inside an addon are prefixed with the consumer's namespace](addon-workflow-names-are-prefixed-with-the-consumer-namespace.md) — The addon's workflowService is proxied so an addon never has to hardcode the name its consumer chose
27
28
  - [Loading agent context waits for a tool result that may still be landing](agent-context-waits-for-a-tool-result-still-being-written.md) — An interrupted run's tool can still be writing to the thread, and in voice the next turn arrives within seconds — soon enough to load context missing what it is about to be asked about
@@ -39,6 +40,7 @@ caller is entitled to assume.
39
40
  - [A tool's execute() failure is logged before the AI SDK swallows it](ai-agent-tool-execute-failures-are-logged-unconditionally.md) — Every agent tool is wrapped in a logging try/catch, because a thrown tool error otherwise becomes a conversational reply and is invisible server-side
40
41
  - [Voice input transcribes audio parts sequentially and in place](ai-agent-voice-input-transcribes-audio-parts-in-place.md) — Each audio part is replaced by its text where it sat, one at a time, bounding concurrent downloads and preserving content order
41
42
  - [Working memory is persisted only when the merged value validates](ai-agent-working-memory-is-persisted-only-when-valid.md) — A failed schema check logs and drops the update rather than saving it, because invalid state poisons every later read
43
+ - [An addon's scope root loses to a root the host app already declares](an-addon-scope-root-loses-to-a-root-the-host-already-declares.md) — loadAddonFunctionsMeta merges addon scope trees by root name, first declaration wins, so an addon sharing a root with its host contributes nothing — silently
42
44
  - [An interrupt is not a failure, and the non-streaming path throws rather than returning](an-agent-interrupt-is-not-a-failure.md) — It skips the onError hooks and never becomes an errorMessage; with no partial reply to hand back, a typed throw is what distinguishes it from a provider outage
43
45
  - [An interrupt for a run owned by another instance says so, rather than returning false](an-agent-run-owned-by-another-instance-says-so.md) — A bare false is indistinguishable from "already finished", which is the one deployment shape the in-process registry cannot cover
44
46
  - [A wrapped agent-stream send must return the inner send's promise](an-agent-stream-send-must-return-the-inner-sends-promise.md) — Middleware runs asynchronously, so dropping the returned promise turns every awaited channel.send upstream into a no-op
@@ -54,11 +56,15 @@ caller is entitled to assume.
54
56
  - [CLI parse errors are routed by message prefix](cli-parse-errors-are-routed-by-message-prefix.md) — The CLI runners decide between printing help and printing errors by string-matching the prefixes the parser writes, so those message strings are an interface
55
57
  - [CLI stdout is reserved for machine-readable output](cli-stdout-is-reserved-for-machine-readable-output.md) — The default renderer emits single-line NDJSON, diagnostics go to stderr, and --json only hijacks rendering for commands that declared a renderer
56
58
  - [CLI unknown long options warn instead of failing](cli-unknown-long-options-warn-instead-of-failing.md) — Unrecognised --long options are accepted, warned about and dropped so older binaries tolerate newer invocations, while unknown short flags stay hard errors
59
+ - [A column's at-rest form is an axis of its own](core-column-form-is-an-axis-of-its-own.md) — How a value is stored is independent of how sensitive it is, so form carries a required nominal brand on writes while classification stays optional on reads
60
+ - [The data-classification brand is an optional property](core-data-classification-brand-is-an-optional-property.md) — Making __classification__ required would break ordinary Kysely operands, so the brand only constrains values flowing out
57
61
  - [The function runner restores the wire fields it overwrites](core-function-runner-restores-the-wire-fields-it-overwrites.md) — One wire object is reused across nested calls, so functionId, audit, addonNamespace and rpc are saved and put back in a finally
58
62
  - [Hot reload merges generated meta and never replaces it](core-hot-reload-merges-generated-meta-never-replaces-it.md) — Reloading codegen output must preserve runtime-registered meta, which no generated JSON contains
59
63
  - [Hot reload owns its module registry instead of re-importing](core-hot-reload-owns-its-module-registry.md) — Dev reload transpiles to CJS and runs modules through vm.compileFunction, because the native ESM loader map cannot be evicted
60
64
  - [Middleware order is resolution scope first, then priority](core-middleware-order-is-scope-then-priority.md) — Middleware is collected global to function, then stably sorted by priority, deduped, frozen and cached per wire
65
+ - [Schema defaults are applied on every transport, not just HTTP](core-schema-defaults-apply-on-every-transport.md) — Defaults belong to the schema rather than the call's encoding, so they run unconditionally and are cloned per request
61
66
  - [Scopes are an AND gate, separate from permissions](core-scopes-are-an-and-gate-separate-from-permissions.md) — Every declared scope must be held, so adding one can only narrow access — permissions OR, and can only widen it
67
+ - [Pikku state is a global map written only at registration time](core-state-is-a-global-map-written-only-at-registration-time.md) — A symbol-keyed globalThis map holds the wiring registry; nothing per-request may ever be written to it
62
68
  - [Email meta is read uncached because codegen rewrites it mid-session](email-meta-is-read-uncached-because-codegen-rewrites-it-mid-session.md) — getEmailMeta re-reads its file on every call, unlike every other meta accessor, because the file appears and changes during a long-lived session
63
69
  - [Gateway adapters resolve lazily and are promise-cached](gateway-adapters-resolve-lazily-and-are-promise-cached.md) — wireGateway accepts an adapter factory because real adapters need boot-time secrets, which forces the webhook GET route to be registered unconditionally
64
70
  - [Gateway middleware sees wire.rpc on webhook and websocket, but not on listener](gateway-listener-middleware-runs-without-an-rpc-on-the-wire.md) — The listener transport invokes its handler directly, so middleware runs outside any runPikkuFunc call and the lazily-attached rpc is absent
@@ -68,22 +74,27 @@ caller is entitled to assume.
68
74
  - [Hot reload writes into the function map captured at startup, not pikkuState's current one](hot-reload-writes-into-the-function-map-captured-at-startup.md) — A dev-server watcher may have swapped in a codegen-scoped map whose writes are discarded on restore, and schemas are deliberately left alone
69
75
  - [HTTP request bodies are read once and shared between consumers](http-request-bodies-are-read-once-and-shared.md) — The fetch request wrapper memoises the single-use body and builds web Requests lazily, at the cost of holding the whole body in memory
70
76
  - [HTTP route groups cascade config in a fixed, per-field order](http-route-groups-cascade-config-in-a-fixed-order.md) — basePath concatenates, tags and middleware merge outward-in, auth is overridden by the innermost group
77
+ - [The HTTP router matches normalized paths but returns the registered path](http-router-matches-normalized-paths-but-returns-registered-ones.md) — Matching normalizes the leading slash while the match result carries the original key, because pikkuState is keyed by the registered string
78
+ - [The HTTP runner logs through a trace-scoped logger, functions do not](http-runner-logs-through-a-trace-scoped-logger-functions-do-not.md) — Only runner-internal logging is scoped to the request id; functions keep the singleton logger for compatibility
79
+ - [Set-Cookie headers are appended individually, never joined](http-set-cookie-headers-are-appended-never-joined.md) — Every cookie gets its own header line, because Set-Cookie is the one header comma-joining corrupts
80
+ - [HTTP SSE streams flush headers only after middleware has run](http-sse-streams-flush-headers-only-after-middleware.md) — SSE responses defer the header flush and register with the eventHub, at the cost of a stricter ordering the runner must preserve
71
81
  - [HTTP wiring without generated metadata is skipped, not fatal](http-wiring-without-metadata-is-skipped-not-fatal.md) — wireHTTP warns and returns when a route has no metadata, so partial deploy units still boot
72
82
  - [In a scenario a 4xx is data, not an exception](in-a-scenario-a-4xx-is-data-not-an-exception.md) — postScenarioJson and invokeRaw report the status instead of throwing, because a refusal is the expected outcome of a permissions scenario
73
83
  - [In-memory workflow history aliases the live step object](in-memory-workflow-history-aliases-the-live-step-object.md) — stepHistory pushes the same StepState reference that steps holds, so later mutations to a step are visible in its history entry
74
84
  - [Istanbul statement counts attach to the start line only](istanbul-statement-counts-attach-to-the-start-line-only.md) — The istanbul coverage reader credits a statement's hits to its first line, so an enclosing multi-line statement cannot mask an unexecuted inner one
75
85
  - [Local trigger and gateway services assume a single process](local-trigger-and-gateway-services-assume-a-single-process.md) — InMemoryTriggerService and LocalGatewayService start every listener unconditionally with no distributed claiming, so a second instance duplicates every event
76
86
  - [Node-only builtins are imported dynamically](node-only-builtins-are-imported-dynamically.md) — V8CoverageService imports node:inspector inside start() so the module stays loadable on runtimes that have no such builtin
77
- - [One project-shape check, called by both validators](one-project-shape-check-two-validators.md) — workspace validate and fabric validate were separate walks over the same project that duplicated sixteen findings verbatim; the shared half now lives in shared-checks.ts
87
+ - [One project-shape check, called by both validators](one-project-shape-check-two-validators.md) — workspace validate and fabric validate were separate walks over the same project that duplicated sixteen findings verbatim; the shared half now lives in shared-checks.ts and fabric validate is that plus the deploy-shaped checks
78
88
  - [Only functions marked `expose: true` enter a virtual user's catalogue](only-exposed-functions-enter-a-virtual-user-catalogue.md) — Absent is not permissive — an unexposed function 404s over rpc, so offering one spends a step to learn nothing about the product
79
89
  - [Queue group concurrency keeps one shared queue fair](queue-group-concurrency-keeps-one-shared-queue-fair.md) — Per-group slot caps let many producers share a single queue instead of one queue each, at the cost of a cap that must stay under batchSize
80
90
  - [Queue jobs always carry an explicit attempts count](queue-jobs-always-carry-an-explicit-attempts-count.md) — resolveJobOptions always passes attempts so a queue backend can never apply its own retry default, and an explicit retries of 0 is honoured
81
91
  - [Remote addons dispatch over HTTP instead of through local function meta](remote-addons-dispatch-over-http-instead-of-local-meta.md) — wireRemoteAddon ships the addon as a devDependency and posts to the host, so there is deliberately no local handler to resolve
92
+ - [Bare RPC names resolve through the caller's package scope before root](rpc-names-resolve-through-package-scope-before-root.md) — An addon's own functions win over root RPC meta for bare names, and the resolving scope is returned so it can be threaded into runPikkuFunc
82
93
  - [Scenario agent calls sign in on 401 only](scenario-agent-calls-sign-in-on-401-only.md) — postAgent sends the first request with whatever session it holds and logs in only if refused, so an actor can converse with a no-auth agent with no sign-in wiring
83
94
  - [`given` and `when` are sugar for each other; `then` is not](scenario-given-and-when-are-sugar-but-then-is-not.md) — The phase decides whether a step's bindings are alternatives or witnesses, so the same step function called as `when` and as `then` runs differently
84
95
  - [Scenario meta lives apart from app meta but merges when read off disk](scenario-meta-lives-apart-from-app-meta-but-merges-when-read-off-disk.md) — Scenarios generate into .pikku/scenarios so no app module imports them, yet MetaService folds them back into the workflow and function meta
85
96
  - [Scenario steps return drained response records](scenario-steps-return-drained-response-records.md) — A scenario step hands back ScenarioHttpResponse rather than a Response, because the body stream reads once and the return value crosses into the run record
86
- - [Scenarios, steps and personas live in files named for them](scenarios-live-in-files-named-for-them.md) — pikkuScenario/pikkuScenarioStep must be in *.scenario.ts/*.steps.ts and definePersonas in *.virtual-user.ts, an error because the mixing is only cheap to undo while it is one file
97
+ - [Scenarios, steps and personas live in files named for them, and validate errors when they do not](scenarios-live-in-files-named-for-them.md) — pikkuScenario/pikkuFeature/pikkuScenarioStep must be in *.scenario.ts, *.scenarios.ts or *.steps.ts, and definePersonas/runVirtualUser in *.virtual-user.ts or *.vu.ts — an error, because the mixing is only cheap to undo while it is one file
87
98
  - [Scope roots may be co-declared by an addon and its host app](scope-roots-may-be-co-declared-by-an-addon-and-its-host-app.md) — flattenScopeDefinitions dedupes ids because the same root can legitimately be declared twice, and every consumer requires one entry per scope
88
99
  - [Serverless channel disconnect must tolerate a missing channel](serverless-channel-disconnect-must-tolerate-a-missing-channel.md) — A failed channel lookup on disconnect returns quietly, because serverless runtimes deliver disconnect more than once
89
100
  - [sideEffects is an allowlist, because core genuinely has some](side-effects-are-an-allowlist-not-a-boolean.md) — The error registry is built by addError calls that run on import, so claiming sideEffects:false would let a bundler drop it and leave getErrorResponse unable to find any error
@@ -106,7 +117,8 @@ caller is entitled to assume.
106
117
  - [Thread history records what the model was asked, which for a spoken turn is the transcript](thread-history-records-the-transcript-not-the-audio.md) — The wire carried a base64 audio blob; persisting it writes megabytes of unreadable data and loses the only readable record of the turn
107
118
  - [Trigger declaration is split from trigger source](trigger-declaration-is-split-from-trigger-source.md) — Triggers are declared everywhere but subscribed only in the trigger worker, so app processes never open the underlying subscription
108
119
  - [TypedSecretService caches for the process lifetime](typed-secret-service-caches-for-the-process-lifetime.md) — Resolved secrets are cached with no TTL, so a secret rotated out of band is not picked up until restart — tracked as pikkujs/pikku#964
109
- - [Both validators check personas through one shared module, and a persona counts from either the meta or the source](validate-checks-personas-through-a-shared-module.md) — workspace validate and fabric validate are separate implementations, so the persona/actor/knowledge checks live in persona-checks.ts and are called by both
120
+ - [Both validators check personas through one shared module, and a persona counts from either the meta or the source](validate-checks-personas-through-a-shared-module.md) — workspace validate and fabric validate are separate implementations, so the persona/actor/knowledge checks live in persona-checks.ts and are called by both — and a persona is counted from the generated meta or a definePersonas() source scan, whichever answers first
121
+ - [pikku validate runs every applicable check, it does not detect a project kind](validate-runs-checks-by-precondition.md) — one `pikku validate` replaces `pikku workspace validate`; checks declare a precondition and run wherever it holds, because the repos that matter are an app and a pile of addons at the same time
110
122
  - [A scenario's step order for a virtual user is insertion order, not a graph traversal](virtual-user-step-order-comes-from-insertion-order.md) — The CLI writes nodes in declaration order, so following `next` would buy an ordering that is already true and cost a traversal that has to interpret branches
111
123
  - [Only an explicit `false` silences a spoken reply](voice-output-speaks-unless-voice-input-explicitly-says-otherwise.md) — The key being absent means no voice input is wired and nothing reported either way, so those callers keep the behaviour they had before the option existed
112
124
  - [Webhook delivery history records every attempt, best effort](webhook-delivery-history-records-every-attempt-best-effort.md) — The webhook worker persists each attempt before it throws, and a failure to persist is logged rather than allowed to mask the delivery result
@@ -116,14 +128,29 @@ caller is entitled to assume.
116
128
  - [Workflow approval expiry is decided from a recorded deadline, not from a timer firing](workflow-approval-expiry-is-decided-from-a-recorded-deadline.md) — The wake-up job is best-effort liveness; losing, duplicating or delaying it cannot change the gate's answer
117
129
  - [Core declares the scenario browser surface structurally and never imports a driver](workflow-core-never-imports-a-browser-driver.md) — `@pikku/core` must stay dependency-free for edge runtimes, so playwright augments the interface instead of being imported by it
118
130
  - [Workflow DSL meta keeps runtime expressions in their own field, apart from literal values](workflow-dsl-meta-separates-runtime-expressions-from-literals.md) — A string `value` regenerates as a string literal; an `expression` regenerates as code, so the two can never share a field
131
+ - [A feature resolves its scenarios by object identity, never by name or shape](workflow-features-resolve-scenarios-by-object-identity.md) — An unregistered scenario comes back explicitly unresolved rather than silently running as something else
119
132
  - [Inline and queued workflow graph runs share one transition planner](workflow-graph-inline-and-queued-runs-share-one-planner.md) — A second, weaker inline traversal would lose joins, cycle revisits and step provenance that the queued path has
120
133
  - [Workflow graph node notes are non-semantic and excluded from the graph hash](workflow-graph-node-notes-are-excluded-from-the-graph-hash.md) — Documentation on a node must not count as a topology change, or editing a comment redeploys the workflow
134
+ - [An inline workflow run reports its run id the moment it exists, because a failure throws instead of returning](workflow-inline-runs-report-their-run-id-before-they-can-fail.md) — `onRunCreated` is the only moment guaranteed to happen whether the run passes, fails or suspends
121
135
  - [`invocationId` is a workflow step's dedupe key; `stepId` is store-specific and must never be used as one](workflow-invocation-id-is-the-dedupe-key-not-step-id.md) — The invocation id is a frozen UUIDv5 of runId + stepName, identical across retries on every backend
136
+ - [A workflow step goes through the queue only if its function opts in, and there is no inline fallback](workflow-queued-step-dispatch-requires-an-explicit-opt-in.md) — `workflowQueued: true` is the whole decision; a missing queue service is a hard error, not a silent downgrade
122
137
  - [Workflows get their own queues by default, and queue names are resolved from queue meta](workflow-queues-are-per-workflow-by-default.md) — Per-workflow queues stop one slow step head-of-line-blocking every other workflow; `shared-groups` trades that for one set of pollers
138
+ - [A workflow step name repeated in one run gets an ordinal suffix, and the first reach stays bare](workflow-repeated-step-names-get-an-ordinal-suffix.md) — `name`, `name#1`, `name#2` keys each reach separately without changing the durable key of any existing run
139
+ - [A workflow replay reads its steps once and caches only the run's immutable half](workflow-replay-reads-its-steps-once-and-caches-only-the-immutable-half.md) — The per-replay snapshot collapses O(N^2) step reads to one, but caching mutable run fields would make the replay read a lie
123
140
  - [Workflow step retries are owned by the workflow, never by the queue](workflow-retries-are-owned-by-the-workflow-not-the-queue.md) — A step's retry count is resolved once and always passed to the queue as `attempts`, so the queue can never apply its own default
124
141
  - [Workflow run capabilities are extensions, not subclasses](workflow-run-capabilities-are-extensions-not-subclasses.md) — Scenario support lives in a separate module behind `setRunExtension` because a bundler drops an unused module but never an unused class member
142
+ - [The workflow run mirror is an observability sink, never a second source of truth](workflow-run-mirror-is-never-a-source-of-truth.md) — Every mirrored write happens after the authoritative write lands, and a mirror failure can never fail the workflow
125
143
  - [Workflow run polling starts short and backs off to the caller's ceiling](workflow-run-polling-backs-off-to-the-callers-ceiling.md) — `pollIntervalMs` is a ceiling, not a cadence, and the wait lives in its own method so the schedule can be asserted without the clock
144
+ - [The workflow run timeline is a pure fold over durable history, with the row's status as the authority](workflow-run-timeline-is-a-pure-fold-over-durable-history.md) — No IO in the fold keeps time-travel transport-independent; the terminal event comes from `status`, not from a timestamp every backend populates
126
145
  - [Scenario steps default to no retries, and a whole poll is one durable step](workflow-scenario-assertions-never-retry-and-record-one-step.md) — Retrying a failed assertion is wrong for a test primitive; recording the poll as one step means replay returns the outcome, not the loop
146
+ - [Scenario lifecycle hooks are a scenario-only affordance and never mask the failure they follow](workflow-scenario-hooks-are-a-scenario-only-affordance.md) — A durable workflow replays, so a callback that reruns each replay has no honest meaning there
127
147
  - [Scenario prose is rendered out of typed calls, not parsed into them](workflow-scenario-prose-is-rendered-from-typed-calls-not-parsed-from-english.md) — The inversion of cucumber — a readable report with no regex step registry to maintain
148
+ - [A quarantined scenario states its reason in code, not in a CI invocation](workflow-scenario-quarantine-reason-lives-in-code.md) — `skip` carries the why next to the scenario it applies to, and naming the scenario explicitly still runs it
128
149
  - [Scenario step targets are string literals so the inspector can read them statically](workflow-scenario-step-targets-are-string-literals-for-the-inspector.md) — `step/given/when/then` mirror `do`'s RPC shape because the extractor reads a literal, not an imported symbol
150
+ - [A step's compensation handler runs as a durable step of its own, and never compensates itself](workflow-step-compensation-runs-as-its-own-durable-step.md) — A refund or rollback must not fire twice on replay, so `onError` is recorded as `<step>:onError` with retries disabled
151
+ - [A failed workflow step dispatch is transient infrastructure, not a run failure](workflow-step-dispatch-failure-is-transient-not-a-run-failure.md) — Queue-unreachable errors leave the run running and the step pending so the orchestrator replays; marking the run failed loses it
152
+ - [A workflow step lock is held only to claim the step, never across its execution](workflow-step-lock-is-held-only-to-claim-the-step.md) — Holding the advisory lock — and its pooled connection — across step work exhausted the connection pool and self-deadlocked
153
+ - [A workflow step's recorded `rpcName` is provenance only — nothing dispatches off it](workflow-step-rpc-name-is-provenance-only.md) — It exists so a reader can join a runtime step row back to the declaration that produced it, especially when the durable name was built in a loop
154
+ - [A suspend or approval `reason` is the step's durable identity, not just a message](workflow-suspend-and-approval-reasons-are-durable-step-identities.md) — The reason is namespaced and used raw as the step key, so it must be derived deterministically across replays
155
+ - [A suspended workflow run keeps its in-process context; only terminal runs release it](workflow-suspended-runs-keep-their-in-process-context.md) — `suspended` is absent from the terminal set on purpose, and a context is dropped only when nothing is holding it open
129
156
  <!-- /pikku:knowledge-index -->
@@ -0,0 +1,115 @@
1
+ ---
2
+ type: decision
3
+ title: pikku validate runs every applicable check, it does not detect a project kind
4
+ description: one `pikku validate` replaces `pikku workspace validate`; checks declare a precondition and run wherever it holds, because the repos that matter are an app and a pile of addons at the same time
5
+ tags: cli, validate, addons
6
+ ---
7
+
8
+ # pikku validate runs every applicable check, it does not detect a project kind
9
+
10
+ `pikku workspace validate` was named after one of the things a repo can be.
11
+ Adding a second — "is this addon publishable" — invited a second command,
12
+ `pikku addon validate`, and that is where the naming falls apart: the addons
13
+ repo is a workspace *containing* 217 publishable addons. Standing at its root,
14
+ `pikku addon validate` would have to refuse or sweep every package, at which
15
+ point it is doing the workspace thing anyway. The two nouns were never
16
+ alternatives.
17
+
18
+ So there is one command, and it does not detect a kind and dispatch. Each check
19
+ declares the condition under which it means anything, and runs wherever that
20
+ condition holds:
21
+
22
+ - `app-project` — a `pikku.config.json` with no `types/application-types.d.ts`
23
+ beside it. The marker matters: an addon carries a `pikku.config.json` too, and
24
+ the app-shaped checks would report every app convention it has no reason to
25
+ follow, starting with a `packages/functions/` it will never have.
26
+ - `addon-package` — a non-private package that ships generated pikku output.
27
+ Having a `.pikku` directory is not enough on its own: an app's
28
+ `packages/functions` has one, and it is codegen for that app rather than
29
+ something anyone installs. The signal is a `files`/`exports` that carries it
30
+ into the tarball.
31
+
32
+ Detection-as-dispatch fails on the first repo that is two things at once.
33
+ Detection-as-precondition composes: the addons repo plans 217 addon checks and
34
+ no app check; the online-shop template plans one app check and no addon check;
35
+ a repo that is both plans both.
36
+
37
+ ## Targets come from walking, not from `workspaces`
38
+
39
+ The field is an array in one repo and `{ packages: [...] }` in the next, spells
40
+ the same layout as `packages/**` or as six explicit globs, and a package that is
41
+ real but unlisted is exactly the kind of thing worth validating. So discovery
42
+ walks the tree for `package.json` files and lets the preconditions decide.
43
+ Over-collecting targets is free; guessing the glob dialect is not.
44
+
45
+ ## A run that checked nothing must not print a tick
46
+
47
+ The failure mode of auto-detection is that finding nothing looks exactly like
48
+ finding everything and liking it. When no check applies, the renderer says so
49
+ and names what it looked for, rather than reporting success.
50
+
51
+ ## The check the addon case exists for
52
+
53
+ Every relative import in a shipped generated file must resolve to a file that is
54
+ itself shipped — stated as a property, not as "the build script must copy
55
+ `types/`". The build script is not the only way to get this wrong and will not
56
+ always be a `cp`; the generated files already declare what they need.
57
+
58
+ That property was false in all 217 published addons. `cp -r .pikku dist/`
59
+ shipped the generated output but not the `types/application-types.d.ts` it
60
+ imports, and `tsc` never emits a hand-written `.d.ts` to `outDir`, so nothing
61
+ put it in `dist`. Consumers got 8 unresolved-module errors plus 6
62
+ `RequiredServices does not satisfy CoreSecretlessSingletonServices` follow-ons
63
+ inside `node_modules`, for merely depending on an addon. The scaffold template
64
+ had been right the whole time — `templates/function-addon` copies `types/` —
65
+ and the published packages had drifted from it with nothing watching.
66
+
67
+ ## The check walks whichever generated directory a package ships
68
+
69
+ `dist/.pikku` is the target shape (below), but the check cannot assume it: the
70
+ shape is what this work moved addons *to*, and a package that has not moved —
71
+ or was published before it did — still carries `.pikku` at the root, listed in
72
+ `files` and mapped by `exports` as `./.pikku/*`. There it is a public entry
73
+ point rather than build input, and its imports climb one level fewer — to
74
+ `<pkg>/src` and `<pkg>/types` — where the copy under `dist` reaches
75
+ `<pkg>/dist/src` and `<pkg>/dist/types`. Two roots, two ways to fall outside
76
+ the tarball, so the check walks whichever ones are actually shipped rather than
77
+ the one it would prefer to find.
78
+
79
+ `exports` and `imports` get the same treatment one level up: a target outside
80
+ the published file set is the same defect, and the one the import walk cannot
81
+ see, because nothing inside `dist/.pikku` mentions it. That is how a `#pikku`
82
+ still pointing at `./.pikku/pikku-types.gen.ts` hides.
83
+
84
+ ## An addon's entry points all resolve under dist
85
+
86
+ Everything an installed package reaches for lives under `dist`; the addon's own
87
+ build resolves `#pikku` through tsconfig `paths`, so nothing in `exports` or
88
+ `imports` has to point into the source tree, and `files` is just `["dist"]`.
89
+ The alternative — shipping `src/` and `types/` at the root so the existing paths
90
+ resolve as written — publishes TypeScript source and a second copy of
91
+ everything `dist` already has.
92
+
93
+ Checking only `dist` is what let the root `.pikku` stay broken through the first
94
+ round of fixes: in the published `@pikku/addon-assemblyai@0.1.4` tarball, `.pikku`
95
+ ships `.gen.ts` files importing a `../../src/` and `../types/` that the tarball
96
+ does not contain, and the `pikku-bootstrap.gen.js` that consumers import through
97
+ that subpath exists only under `dist`. Everything resolved locally through the
98
+ workspace link and none of it resolved on install.
99
+
100
+ ## Only a package that publishes gets the dist shape
101
+
102
+ The shape describes a tarball, so it means nothing for a `private` package —
103
+ and applying it there actively breaks: `exports` *is* enforced across a
104
+ workspace link, so repointing a private fixture at `dist` makes every consumer
105
+ demand a directory that only a build produces. The three `verifiers/db-schema`
106
+ and `verifiers/addon-registry` fixtures have no build script at all, so `dist`
107
+ never exists for them; the five `e2e/packages` addons build, but their metadata
108
+ is read straight from the source tree before any build has run. Repointing all
109
+ eight left the db-schema verifier unable to resolve
110
+ `dist/.pikku/db/pikku-db-meta.gen.json` and every e2e addon reporting "no
111
+ function metadata".
112
+
113
+ `isAddonPackage` already draws this line — it returns false for `private` — so
114
+ the checks and the shape agree: a package the registry never sees is consumed
115
+ from source.
@@ -0,0 +1,37 @@
1
+ ---
2
+ type: decision
3
+ title: A function never receives the secret service
4
+ description: Every function-, permission- and auth-facing services type is bounded by SecretlessServices, so reaching for `secrets` in a function body is a type error rather than a lint
5
+ tags: services
6
+ ---
7
+
8
+ # A function never receives the secret service
9
+
10
+ `SecretlessServices<Services>` is `Omit<Services, 'secrets'>`
11
+ (`packages/core/src/types/core.types.ts`), and
12
+ `CoreSecretlessSingletonServices` built from it is the constraint every
13
+ function-, permission- and auth-facing type is bounded by
14
+ (`packages/core/src/function/functions.types.ts`). Destructuring `secrets`
15
+ inside a `pikkuFunc` body does not lint — it does not compile.
16
+
17
+ The rule itself is older than the type: a function holding a `SecretService` can
18
+ read every secret in the vault, which makes its blast radius the whole vault
19
+ rather than the one credential it needs, and makes "which secrets does this
20
+ function depend on?" unanswerable. `[PKU950]` enforces the same confinement for
21
+ a `SecretService` reaching a function under an alias, because a rename does not
22
+ change what it is. Encoding it in the type is what makes the honest mistake
23
+ impossible rather than merely reported: secrets are resolved where things are
24
+ constructed — `pikkuServices`, `pikkuWireServices`, addon service factories,
25
+ middleware — and the function is handed the configured client.
26
+
27
+ The cost is that a function which needs to *ask about* a secret rather than read
28
+ one — "is this key set?", for a readiness or provisioning check — cannot do it
29
+ directly either. It goes through a service that holds `secrets` and exposes only
30
+ that question, which is how `@pikku/addon-console` checks whether an installed
31
+ addon's declared secrets are present.
32
+
33
+ **What this rules out:** widening a function's services type back to
34
+ `CoreServices` for a function that "only needs one secret", and passing the
35
+ secret service through under another name — the type follows the shape and
36
+ `[PKU950]` follows the type. It also rules out treating the absence as an
37
+ oversight to be patched with a cast.