@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
@@ -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.
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  type: decision
3
- title: A workflow run is read and approved by its owner
4
- description: A run started through a session records that user and only that user may read it or answer its approval gates; a run with no recorded owner has no ownership to enforce
3
+ title: A workflow run is read by its owner, and answered by whoever its gate declares
4
+ description: A run started through a session records that user and only that user may read it; who may answer an approval gate is the gate's own declaration, and a run with no recorded owner has no ownership to enforce
5
5
  tags: workflow
6
6
  ---
7
7
 
8
- # A workflow run is read and approved by its owner
8
+ # A workflow run is read by its owner, and answered by whoever its gate declares
9
9
 
10
10
  `WorkflowRunWire.pikkuUserId` has always been recorded on every run started
11
11
  through a session — `RPCService.startWorkflow` copies it off the wire — and
@@ -14,9 +14,9 @@ routes (which stream `output` and `error`) and `approveStep`, which took no
14
14
  session at all and rejected only an already-resolved gate.
15
15
 
16
16
  `assertWorkflowRunOwner`
17
- (`packages/core/src/wirings/workflow/workflow-run-ownership.ts`) is the one check
18
- both paths share. `approveStep` takes the caller's session and asserts it, and
19
- the generated status routes assert it against the run they were already reading.
17
+ (`packages/core/src/wirings/workflow/workflow-run-ownership.ts`) is the check the
18
+ **read** paths share: the generated status routes assert it against the run they
19
+ were already reading.
20
20
 
21
21
  **A run with no recorded owner is not gated.** Triggers, schedulers and routes
22
22
  wired without auth start runs with no `pikkuUserId`; there is nobody to compare a
@@ -24,11 +24,27 @@ caller against, and inventing one would reject the framework's own callers rathe
24
24
  than secure anything. Gate those at the entrypoint with `auth` or `permissions`.
25
25
 
26
26
  This is ownership, not an approver model. It answers "is this your run", not "are
27
- you entitled to approve this particular gate" — `WorkflowApprovalOptions` still
28
- carries no approver, role or permission, and a second approver on someone else's
29
- run is still a matter for the route's own `permissions`.
30
-
31
- **What this rules out:** treating a run id as a capability, and adding a new run
32
- read path that does not take a session. It does not rule out a richer approver
33
- model on `WorkflowApprovalOptions` later; that would narrow this gate, never
34
- replace it.
27
+ you entitled to approve this particular gate".
28
+
29
+ ## Approving is a separate question, and the gate answers it
30
+
31
+ `approveStep` originally shared `assertWorkflowRunOwner`, which made "only the
32
+ initiator may answer" the one available rule. That is right for "confirm your own
33
+ action" and exactly wrong for four-eyes sign-off, where the initiator is the one
34
+ person who must not sign. Which applies is a property of the decision, so it is
35
+ declared on the gate — `approvers` (`'any' | 'owner' | 'not-initiator'`) and
36
+ `approverScope` on `WorkflowApprovalOptions`.
37
+
38
+ It is enforced wherever the policy is known. Reaching the gate publishes the
39
+ policy into the run state, so a decision submitted after that is judged by the
40
+ approve entrypoint and refused with a 403. A decision can legitimately be
41
+ recorded before the run has reached the gate, and that one has no policy to be
42
+ judged against yet — it is judged on replay instead, alongside payload
43
+ validation, and discarded if it fails.
44
+
45
+ The default is therefore `any`: a gate is a pause for a decision, not an
46
+ authorization boundary, and a route that needs one has `auth`/`permissions`.
47
+
48
+ **What this rules out:** treating a run id as a capability, adding a new run read
49
+ path that does not take a session, and re-deriving who may approve from who
50
+ started the run.
@@ -0,0 +1,59 @@
1
+ ---
2
+ type: decision
3
+ title: An approval answer outlives the run it answered
4
+ description: Run state holds a decision only while the gate is open, so the settled answer carries decidedBy/decidedAt into the step result and every attempt is written to the audit sink, which has no foreign key to the run
5
+ tags: workflow, audit
6
+ ---
7
+
8
+ # An approval answer outlives the run it answered
9
+
10
+ An approval gate is asked for so it can be answered for afterwards. "Who
11
+ released the funds" is the entire point of four-eyes sign-off, and until this
12
+ change none of it was kept.
13
+
14
+ Run state is where a decision *waits*, not where it is kept. The record under
15
+ `workflowRuns.state.__approval_<hex>` is overwritten by the next write to that
16
+ key, and cleared outright — `decidedBy: undefined` included — whenever a
17
+ decision is refused on replay. A trail that erases exactly the events worth
18
+ keeping is not a trail.
19
+
20
+ So the answer is recorded twice, in two places with different lifetimes.
21
+
22
+ ## The settled decision keeps its provenance
23
+
24
+ `ApprovalOutcome<T>` carries `decidedBy` and `decidedAt` alongside `data`, so
25
+ the answer reaches `workflowStep.result` and, through it,
26
+ `workflowStepHistory` — append-only, and already the per-step event log. Both
27
+ are spread in only when present, so a gate answered without a session keeps the
28
+ shape it had before there was anything to record.
29
+
30
+ ## The audit sink is what survives deletion
31
+
32
+ `deleteRun` cascades: `workflowStep` is `onDelete('cascade')` from
33
+ `workflowRuns`, and `workflowStepHistory` from `workflowStep`. Deleting a run
34
+ therefore deletes the sign-off with it — and a *refused* attempt never reaches a
35
+ step at all, so it was never in that trail to begin with.
36
+
37
+ Every answer — accepted, refused at submission, or cleared on replay — is
38
+ written to the `AuditService` as `workflow.approval.decided`, with
39
+ `outcome: 'success' | 'denied'`, the decider under `userIdentity.pikkuUserId`,
40
+ and the run, reason, scopes and refusal in `metadata`. The `audit` table holds
41
+ no foreign key to any workflow table, which is precisely why it is the right
42
+ home.
43
+
44
+ `auditApprovalDecision`
45
+ (`packages/core/src/wirings/workflow/workflow-approval-audit.ts`) is a module of
46
+ its own rather than a method on `PikkuWorkflowService`: the trail is a separate
47
+ concern from running the workflow, and the service is already at the 2000-line
48
+ limit `source-files-stay-composable.test.ts` enforces.
49
+
50
+ **A sink that fails is logged, never thrown.** The trail must not be the reason a
51
+ decision is lost. A project with no `audit` service wired records nothing and is
52
+ otherwise unaffected — the sink is opt-in, and `auditSchema` is deliberately not
53
+ in `pikkuSchemas`.
54
+
55
+ **What this rules out:** a dedicated `workflowApprovals` table. It would have to
56
+ be implemented in all seven backends and would still be deleted with the run
57
+ unless it deliberately broke the cascade — at which point it is the audit table
58
+ with extra steps. If approvals later need to be *queried* as a first-class
59
+ entity rather than read back from the trail, that is when to revisit.
@@ -10,11 +10,12 @@ A rule about who may do what, and which way it fails when it is unsure.
10
10
 
11
11
  <!-- pikku:knowledge-index -->
12
12
  - [A dropped audit write is always logged](a-dropped-audit-write-is-always-logged.md) — The no-op audit service falls back to the singleton logger when the wire carries none, so an unconfigured audit call is never silent
13
+ - [A function never receives the secret service](a-function-never-receives-the-secret-service.md) — 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
13
14
  - [A graph run starts at an entry node the graph declared](a-graph-run-starts-at-an-entry-node-the-graph-declared.md) — startNode may only name a node in meta.entryNodeIds, and no generated route offers it, because otherwise a caller picks which half of the graph to skip
14
15
  - [A permission gets a wire it cannot reply on](a-permission-gets-a-wire-it-cannot-reply-on.md) — The permission wire is typed with Out = never so a permission cannot send on the channel; that narrowing is not a subtype, so the call site asserts
15
16
  - [A step runs the function the workflow dispatched it with](a-step-runs-the-function-the-workflow-dispatched-it-with.md) — StepState records the step's function name so the worker can reject a queue message naming a different one, because the step executes under the run owner's identity
16
17
  - [A virtual user is never offered a scenario, platform or addon step](a-virtual-user-is-never-offered-a-step-that-would-forge-its-own-oracle.md) — Being able to invoke "the webhook arrives" lets the user manufacture the outcome it exists to discover, which invalidates every finding downstream
17
- - [A workflow run is read and approved by its owner](a-workflow-run-is-read-and-approved-by-its-owner.md) — A run started through a session records that user and only that user may read it or answer its approval gates; a run with no recorded owner has no ownership to enforce
18
+ - [A workflow run is read by its owner, and answered by whoever its gate declares](a-workflow-run-is-read-and-approved-by-its-owner.md) — A run started through a session records that user and only that user may read it; who may answer an approval gate is the gate's own declaration, and a run with no recorded owner has no ownership to enforce
18
19
  - [An actor's missing approval decision defaults to denied](actor-flow-missing-approval-decisions-default-to-denied.md) — Every pending tool call gets an explicit decision; an id the persona LLM omitted is denied, so a dropped field can never read as consent
19
20
  - [Actor sign-in is proven by Set-Cookie, not a non-empty jar](actor-sign-in-is-proven-by-set-cookie-not-a-non-empty-jar.md) — HttpScenarioActor tracks its own signedIn flag and requires the sign-in response itself to set a cookie, because a populated jar proves nothing
20
21
  - [Actor sign-in only works for actor-flagged users](actor-sign-in-only-works-for-actor-flagged-users.md) — The scenario actor secret mints sessions for user rows flagged actor and nothing else, so holding it never impersonates a real user
@@ -30,6 +31,7 @@ A rule about who may do what, and which way it fails when it is unsure.
30
31
  - [An agent thread key is always prefixed with the trusted principal](ai-agent-thread-ownership-composes-the-session-principal.md) — Ownership keys are composed as principal:resourceId, so a client id can sub-divide its own boundary but never widen it
31
32
  - [Agent tool permission filtering reads the live function config, not the metadata](ai-agent-tool-filtering-reads-the-live-function-config.md) — The pikkuAuth brand survives only on live permission objects, so a metadata-driven check would silently admit every gated tool
32
33
  - [An agent approval is claimed before the tool runs](an-agent-approval-is-claimed-before-the-tool-runs.md) — resolveApproval is a compare-and-swap returning whether this caller won, because the read that precedes it is not a claim and ten concurrent approvals would otherwise mean ten refunds
34
+ - [An approval answer outlives the run it answered](an-approval-answer-outlives-the-run-it-answered.md) — Run state holds a decision only while the gate is open, so the settled answer carries decidedBy/decidedAt into the step result and every attempt is written to the audit sink, which has no foreign key to the run
33
35
  - [An empty owners constraint matches nothing](an-empty-owners-constraint-matches-nothing.md) — owners is an authorization boundary, so every storage backend must treat [] as no rows rather than no filter
34
36
  - [An exposed function with no gate is reported at codegen, not at boot](an-exposed-ungated-function-is-a-codegen-warning.md) — The check runs in the inspector where function meta and every wireAddon declaration are both in hand, because neither source alone can tell a gated function from an ungated one
35
37
  - [An upload is counted as it arrives, not buffered and then measured](an-upload-is-counted-as-it-arrives-not-buffered-then-measured.md) — Reading the whole body before checking its size hands an unauthenticated caller a way to spend the server's memory
@@ -12,5 +12,5 @@ gets recorded.
12
12
 
13
13
  <!-- pikku:knowledge-index -->
14
14
  - [A channel's middleware list accepts bare factories that nothing ever resolves](channel-middleware-accepts-bare-factories-that-nothing-resolves.md) — CoreChannel.channelMiddleware admits CorePikkuChannelMiddlewareFactory, but no runner calls one, so a bare factory would stall the chain
15
- - [An unauthorized channel reply is sent outside the channel's declared Out type](unauthorized-channel-replies-escape-the-declared-out-type.md) — processMessageHandlers sends a bare string on auth failure that no generated client type admits, and the alternatives were never worked through
15
+ - [An unauthorized channel reply is sent outside the channel's declared Out type](unauthorized-channel-replies-escape-the-declared-out-type.md) — processMessageHandlers sends a bare string on auth failure, which no generated client type admits nobody has decided what the typed shape should be
16
16
  <!-- /pikku:knowledge-index -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/core",
3
- "version": "0.12.80",
3
+ "version": "0.12.82",
4
4
  "description": "The Pikku runtime — functions, wirings, services, middleware and types",
5
5
  "author": "yasser.fadl@gmail.com",
6
6
  "license": "MIT",
@@ -22,6 +22,7 @@
22
22
  "./dist/errors/errors.js",
23
23
  "./dist/wirings/rpc/rpc-runner.js",
24
24
  "./dist/wirings/rpc/remote-addon-auth.js",
25
+ "./dist/wirings/workflow/workflow-approval-policy.js",
25
26
  "./dist/wirings/workflow/workflow-errors.js",
26
27
  "./dist/wirings/workflow/workflow-run-ownership.js",
27
28
  "./dist/wirings/workflow/pikku-scenario-service.js"
@@ -49,6 +50,7 @@
49
50
  "./node-host-resolver": "./dist/utils/node-host-resolver.js",
50
51
  "./mcp": "./dist/wirings/mcp/index.js",
51
52
  "./ai-agent": "./dist/wirings/ai-agent/index.js",
53
+ "./ai-scorer": "./dist/wirings/ai-scorer/index.js",
52
54
  "./gateway": "./dist/wirings/gateway/index.js",
53
55
  "./cli": "./dist/wirings/cli/index.js",
54
56
  "./cli/command-parser": "./dist/wirings/cli/command-parser.js",