@pikku/skills 0.12.6 → 0.12.9

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/skills",
3
- "version": "0.12.6",
3
+ "version": "0.12.9",
4
4
  "description": "The Pikku agent skills — the instruction set coding agents read to build, wire and deploy Pikku projects",
5
5
  "author": "yasser.fadl@gmail.com",
6
6
  "license": "MIT",
@@ -50,9 +50,13 @@ wireAddon({
50
50
  mcp?: boolean,
51
51
  tags?: string[], // Tags applied to all addon functions
52
52
  scopes?: string[], // Required of every function, on top of its own
53
- secretOverrides?: Record<string, string>, // Remap secret names
53
+ secretOverrides?: Record<string, string>, // Remap secret names (and grant them)
54
54
  variableOverrides?: Record<string, string>, // Remap variable names
55
- credentialOverrides?: Record<string, string>, // Remap credential names
55
+ credentialOverrides?: Record<string, string>, // Remap credential names (and grant them)
56
+ secretGrants?: string[], // Secrets the app lends this addon
57
+ credentialGrants?: string[], // Credentials the app lends this addon
58
+ globalSecrets?: string, // Reason for handing over the whole SecretService
59
+ globalCredentials?: string, // Reason for handing over the whole CredentialService
56
60
  })
57
61
  ```
58
62
 
@@ -61,6 +65,58 @@ it would weaken the wiring's own gate — so the addon-level setting can require
61
65
  session but never waive one. The same package wired twice under two namespaces is
62
66
  governed by the union of both instances' scopes and tags.
63
67
 
68
+ ### An addon reads only the secrets it declared
69
+
70
+ An addon's `SecretService` and `CredentialService` are **scoped**: it may read
71
+ the secrets its own source declares (literal `getSecret('X')` calls and
72
+ `wireSecret` definitions, which the CLI collects into `declaredSecrets`) and
73
+ nothing else. Anything undeclared throws `Access denied to secret key: X` at
74
+ runtime. The same holds for credentials, and a scoped addon can never call
75
+ `getAllUsers()`.
76
+
77
+ That works for an addon naming its own secrets. It does not work for a _generic_
78
+ addon whose secret names arrive as data — `@pikku/addon-graph` reads
79
+ `getSecret(auth.credential)`, where the name comes off the workflow node — so
80
+ such an addon declares nothing and is scoped to nothing. Only the consuming app
81
+ can widen it, with one of three fields:
82
+
83
+ ```typescript
84
+ wireAddon({
85
+ name: 'graph',
86
+ package: '@pikku/addon-graph',
87
+
88
+ secretGrants: ['STRIPE_KEY'], // lend these, unrenamed
89
+ secretOverrides: { MAILGUN_KEY: 'PROD_EMAIL_KEY' }, // lend + rename
90
+ // globalSecrets: 'why no static list can cover it' // lend everything
91
+ })
92
+ ```
93
+
94
+ | field | meaning |
95
+ | ----------------- | --------------------------------------- |
96
+ | `secretOverrides` | grant **and** rename |
97
+ | `secretGrants` | grant as-is |
98
+ | `globalSecrets` | grant everything, with a written reason |
99
+
100
+ **Grants name the secret as the addon reads it**, not as your project stores it.
101
+ Scoping is checked _before_ the override map renames anything, so an overridden
102
+ secret is granted by its addon-side key — which is also why an override's key
103
+ grants and its value does not. With no rename in play the two names coincide.
104
+
105
+ `globalSecrets` / `globalCredentials` take the _reason_ for the grant, not a
106
+ boolean, because every grant is enumerated in the deploy manifest
107
+ (`unscopedSecretAddons`, `grantedSecretAddons`). Prefer `secretGrants` — reach
108
+ for `globalSecrets` only when no static list can exist, and never for an addon
109
+ that performs outbound requests, where an unrestricted secret read is an
110
+ exfiltration primitive.
111
+
112
+ A grant naming a secret your project does not declare is a build error from
113
+ `pikku all`, resolved through the override map first:
114
+
115
+ ```
116
+ Secret grant 'STIRPE_KEY' in addon 'graph' (@pikku/addon-graph) targets a secret
117
+ that does not exist. Available secrets: BETTER_AUTH_SECRET, GITHUB_OAUTH
118
+ ```
119
+
64
120
  ### `ref(name)`
65
121
 
66
122
  Type-safe reference to a function — local or addon — for use in any wiring. It
@@ -94,7 +150,8 @@ import { pikkuAddonServices } from '#pikku'
94
150
 
95
151
  export const createSingletonServices = pikkuAddonServices(
96
152
  async (config, { secrets, logger }) => {
97
- const creds = await secrets.getSecret<GithubCredentials>('GITHUB_CREDENTIALS')
153
+ const creds =
154
+ await secrets.getSecret<GithubCredentials>('GITHUB_CREDENTIALS')
98
155
  return { github: new GithubService(creds.reveal()) }
99
156
  }
100
157
  )
@@ -184,11 +241,24 @@ approvalDescription: async (_services, { title }) => `Add a todo called "${title
184
241
  ### Build
185
242
 
186
243
  ```bash
187
- npx pikku all # Generate types
188
- yarn tsc # Compile TypeScript
189
- cp -r .pikku dist/ # Include generated files in dist
244
+ yarn pikku all # Generate types
245
+ yarn tsc # Compile TypeScript
246
+ cp -r .pikku types dist/ # Ship the generated files and the types they import
247
+ yarn pikku validate # Check the published file set holds together
190
248
  ```
191
249
 
250
+ `yarn pikku`, not `npx pikku`: a scaffolded addon carries `@pikku/cli` as a
251
+ devDependency, and building it against a different CLI than it declares is how
252
+ generated output ends up disagreeing with the packaged one. `npx pikku new
253
+ addon` above is the exception — it runs before the addon, and its CLI, exist.
254
+
255
+ `types/` has to be copied alongside `.pikku`: the generated files import
256
+ `SingletonServices`, `Services`, `Config` and `UserSession` from
257
+ `../../types/application-types.d.js`, and `tsc` never emits a hand-written
258
+ `.d.ts` to `outDir`, so nothing else puts it in `dist`. Leave it out and the
259
+ addon installs fine and fails to typecheck in every app that depends on it —
260
+ which is what `pikku validate` is there to catch before you publish.
261
+
192
262
  ## Consuming an Addon
193
263
 
194
264
  ### Install & Register
@@ -204,7 +274,7 @@ import { wireAddon } from '#pikku'
204
274
  wireAddon({ name: 'todos', package: '@my-org/addon-todos' })
205
275
  ```
206
276
 
207
- After registration, run `npx pikku all` to generate types for the addon's functions.
277
+ After registration, run `yarn pikku all` to generate types for the addon's functions.
208
278
 
209
279
  ### Call via RPC
210
280
 
@@ -230,7 +230,7 @@ await server.start()
230
230
 
231
231
  **Lifecycle hooks do not run on this path** — only `pikku dev` and `pikku serve` invoke them. Do your startup work directly in the entrypoint instead.
232
232
 
233
- `pikku workspace validate` warns when a project starts a server by hand *and* depends on no runtime adapter, since that combination means path 1 was available and unused. Silence it with `"lint": { "customServerBootstrap": "off" }` in `pikku.config.json`.
233
+ `pikku validate` warns when a project starts a server by hand *and* depends on no runtime adapter, since that combination means path 1 was available and unused. Silence it with `"lint": { "customServerBootstrap": "off" }` in `pikku.config.json`.
234
234
 
235
235
  ## Code Generation
236
236
 
@@ -243,7 +243,7 @@ const apiKey = services.variables.get('API_KEY')
243
243
  }
244
244
  ```
245
245
 
246
- `customServerBootstrap` is the one evaluated by `pikku workspace validate` rather than codegen: it warns when the root `start`/`dev` script boots a server without `pikku dev` / `pikku serve` and no runtime adapter is installed. Set it to `"off"` to keep a hand-rolled entrypoint, or `"error"` to enforce the hooks.
246
+ `customServerBootstrap` is the one evaluated by `pikku validate` rather than codegen: it warns when the root `start`/`dev` script boots a server without `pikku dev` / `pikku serve` and no runtime adapter is installed. Set it to `"off"` to keep a hand-rolled entrypoint, or `"error"` to enforce the hooks.
247
247
 
248
248
  ## Complete Example
249
249
 
@@ -5,13 +5,14 @@ description: >-
5
5
  notes that say what the app is, in the language its users use. Covers the Open Knowledge Format
6
6
  note (path-as-identity markdown, YAML frontmatter, only `type` required), the sections of the
7
7
  app-project profile (slices, entities, decisions, questions, wishlist) and the one question each
8
- answers, slice status/entities/gherkin rules, the `resource:` URI scheme that ties a note to the
8
+ answers, slice status/entities/gherkin rules, the `resource:` URI scheme tying a note to the
9
9
  code it is about, the shapes that are NOT a knowledge base, and the `pikku knowledge
10
- validate|index` commands. TRIGGER when: user asks to write down a decision, a requirement, an
11
- entity or an open question; asks what the app does or is; asks about knowledge/, notes, slices,
12
- or an index.md; or hands over a product brief to record. DO NOT TRIGGER when: user asks what
10
+ validate|index` commands. TRIGGER when: user asks to write down a decision, requirement, entity
11
+ or open question; asks what the app does or is; asks about knowledge/, notes, slices,
12
+ an index.md, or a diagram, callout or decision block; or hands over a product
13
+ brief to record. DO NOT TRIGGER when: user asks what
13
14
  functions, routes, tables or permissions exist (that is `pikku meta` / `pikku info`, never a
14
- note), or asks to write a scenario test (use pikku-scenario).
15
+ note), or to write a scenario test (use pikku-scenario).
15
16
  installGroups: [core]
16
17
  ---
17
18
 
@@ -142,7 +143,49 @@ And writing again replaces it rather than adding a second
142
143
 
143
144
  - **`status`** is `proposed` → `dispatched` → `built`. Nothing else. Every gate compares it literally.
144
145
  - **`entities`** lists what the slice touches, **at most three**. Past three it is not one buildable piece — split it.
145
- - **The scenario is a fenced `gherkin` block, in the third person.** `Given 'owner' has no entry` — never `Given I have no entry`. A quoted word _means a persona_, which is what lets a reader (and a test) tell who is acting. First person hides that, so it is rejected.
146
+ - **The scenario is a fenced `gherkin` block, in the third person.** `Given 'owner' has no entry` — never `Given I have no entry`. A quoted word _means a persona_, which is what lets a reader (and a test) tell who is acting. First person hides that, so it is rejected. The console draws the keywords as a column and each quoted persona as a chip, so a first-person scenario is visibly a block with no personas in it.
147
+
148
+ ## Showing it
149
+
150
+ A note is markdown, and four kinds of block are **drawn** rather than printed. Every one of them degrades to something readable — a diagram falls back to its source, a callout to a blockquote, a decision to a code block — so writing one costs nothing where it is not rendered.
151
+
152
+ None of this changes the governing rule. A diagram of the schema is still a copy of `pikku meta` that drifts, and it drifts while looking more authoritative than prose would. These are for the part no generator can derive.
153
+
154
+ **```mermaid — when the relationship is the point.** Prose is bad at graphs: "an entry belongs to a day, a day belongs to an owner, and a grant lets another owner read a day" is a sentence a reader has to re-read twice and draw themselves. Reach for one when a note is about how several things relate, an order of steps across time, or a state machine. Do not draw one thing, or two things and an arrow — that is a sentence.
155
+
156
+ ````markdown
157
+ ```mermaid
158
+ flowchart LR
159
+ owner -->|writes| entry
160
+ entry -->|belongs to| day
161
+ owner -->|grants read on| day
162
+ ```
163
+ ````
164
+
165
+ **`> [!NOTE]` — when a line must survive skimming.** Five kinds: `NOTE`, `TIP`, `IMPORTANT`, `WARNING`, `CAUTION`. Use one for the thing a reader who skips the paragraph must still not miss — a trap, a constraint that is easy to violate, an assumption the rest of the note rests on. Two callouts in a note is normal; six means the note has no prose left and nothing stands out.
166
+
167
+ ```markdown
168
+ > [!WARNING]
169
+ > A grant is checked on every request, not cached. A permission change is
170
+ > immediate everywhere, and there is no invalidation step to forget.
171
+ ```
172
+
173
+ **```decision — the answer a decision note owes.** `decisions/` answers "what was chosen, and what does that rule out?", and the second half is the half that gets dropped. The fence makes it checkable: `pikku knowledge validate` warns when a fence says what was chosen and never says what it closes off.
174
+
175
+ ````markdown
176
+ ```decision
177
+ chosen: A revoked grant stops working immediately, everywhere.
178
+ rules-out:
179
+ - A "revoked but valid until midnight" state
180
+ - A scheduled cleanup job
181
+ because: Two people disagreeing about who can see today is worse than one of
182
+ them losing access mid-session.
183
+ ```
184
+ ````
185
+
186
+ It is a **summary, not the note** — the argument continues in prose underneath. `rules-out:` takes one line or a `- item` block, and any value too long for one line wraps onto indented lines under it, as `because:` does above. A decision genuinely argued in prose needs no fence, and validate never asks for one; what it does ask is that a fence you did write is complete.
187
+
188
+ **Fences of any other language are code** — highlighted and copyable, which is right for a snippet and wrong for a scenario or a decision, so do not put either in a bare fence.
146
189
 
147
190
  ## `resource:` — tying a note to the code
148
191
 
@@ -196,7 +239,7 @@ pikku knowledge index # refresh every index.md
196
239
  pikku knowledge index --check # report stale indexes without writing (CI gate)
197
240
  ```
198
241
 
199
- `validate` reports: notes with no `type`, a missing `knowledge/index.md`, a section with no `index.md`, notes flat at the root, sections that duplicate what the project already declares, slices with a bad or missing `status`, slices over three entities, slices with no gherkin block or a first-person one, and every `resource:` that no longer resolves. Errors fail the command; warnings do not.
242
+ `validate` reports: notes with no `type`, a missing `knowledge/index.md`, a section with no `index.md`, notes flat at the root, sections that duplicate what the project already declares, slices with a bad or missing `status`, slices over three entities, slices with no gherkin block or a first-person one, `decision` fences that state no `chosen:` or rule nothing out, and every `resource:` that no longer resolves. Errors fail the command; warnings do not.
200
243
 
201
244
  `index` rewrites only the block between `<!-- pikku:knowledge-index -->` markers, creating a scaffolded `index.md` for a section that has none. It is idempotent — running it twice changes nothing.
202
245
 
@@ -5,7 +5,7 @@ description: >-
5
5
  test coverage. A scenario (pikkuScenario) drives the app the way users do — steps run as actors
6
6
  over the real transport against a running server — so a flow doubles as an e2e test and a
7
7
  staged/production health check. Covers scenario.do / expectEventually / expectError /
8
- expectService, declared steps via pikkuScenarioStep (including browser steps driven by
8
+ expectService / expectScore, declared steps via pikkuScenarioStep (including browser steps driven by
9
9
  @pikku/playwright) written as intent rather than as clicks, with the actions factored into
10
10
  shared browser utilities, actors and environments in pikku.config.json, SCENARIO_ACTOR_SECRET, the
11
11
  `pikku scenario list|run` commands, live function coverage via `pikku dev --coverage`, and
@@ -91,6 +91,7 @@ A scenario takes the same config fields as a workflow (`title`, `description`, `
91
91
  | `scenario.expectEventually(step, rpc, data, predicate, { actor, within, interval })` | Poll until `predicate(out)` passes or `within` elapses. For anything asynchronous — queues, workers, eventual state. |
92
92
  | `scenario.expectError(step, rpc, data, { actor, matches })` | Assert the call **fails**. For fault injection and negative paths. |
93
93
  | `scenario.expectService(step, 'service.method', { actor, calledWith })` | Assert a stubbed service was called. Requires the server to run with `--test`. |
94
+ | `scenario.expectScore(step, runId, scorer, { atLeast, atMost, reference })` | Grade a finished agent run with a declared scorer and assert the score. See below. |
94
95
  | `scenario.given(stepName, step, data, { actor })` | Run a declared `pikkuScenarioStep` as setup. `when` is the same call; `then` also makes the step's bindings witnesses. |
95
96
  | `scenario.runScheduledTask(name)` | Fire a wired scheduler on the target now, rather than waiting for its cron. |
96
97
 
@@ -98,6 +99,37 @@ A scenario takes the same config fields as a workflow (`title`, `description`, `
98
99
 
99
100
  Prefer `expectEventually` over sleeping.
100
101
 
102
+ ### Asserting on an agent's answer (`expectScore`)
103
+
104
+ An agent's output is not comparable to a fixed string, so it is graded rather
105
+ than matched. Declare the rubric with `pikkuAIScorer` (grades in code) or
106
+ `pikkuAIJudge` (grades with a model) in a `*.scorer.ts` file, name it on the
107
+ agent's `scorers`, then assert on the run the scenario just triggered:
108
+
109
+ ```typescript
110
+ const { runId } = await scenario.when('asks for a summary', 'runAssistant', {
111
+ prompt: data.prompt,
112
+ }, { actor: actors.user })
113
+
114
+ await scenario.expectScore('answered briefly', runId, 'brevity', { atLeast: 0.8 })
115
+ ```
116
+
117
+ The default bound is `atLeast: 0.5`, so an unqualified `expectScore` still fails
118
+ a run the scorer graded zero. `atMost` is for a rubric where high is the failure
119
+ (sycophancy, verbosity). `reference` supplies the answer key a
120
+ `requiresReference` judge grades against — live traffic has none, so such a
121
+ judge is only ever reachable from a scenario.
122
+
123
+ Grading goes through the `pikkuScenarioGradeRun` instrumentation RPC on the
124
+ server under test, which grades from the snapshot the runtime kept at the end of
125
+ the run. Two consequences: the run must have happened on **that** server and be
126
+ recent, and the grade is returned to the scenario rather than recorded — a
127
+ test's score never lands among the production figures. Sampling is ignored, so a
128
+ scorer set to grade 1% of live traffic still grades every scenario run.
129
+
130
+ Tag any scenario whose scorer is a judge `ai-live`: it costs a model call, and
131
+ the default suite excludes it.
132
+
101
133
  ### Setup and teardown (`before` / `after`)
102
134
 
103
135
  A scenario config takes `before` and `after`. Both have the **same signature as `func`** — `(services, data, wire)` — with the return value discarded:
@@ -29,11 +29,11 @@ Build durable, multi-step workflows with automatic retry, sleep, suspend/resume,
29
29
 
30
30
  ## Choosing the right factory
31
31
 
32
- | Factory | When to use | Step-graph view? |
33
- |---|---|---|
34
- | `pikkuWorkflowFunc` | **Default for all new workflows.** Sequential + conditional logic; DSL mode (serialisable, replay-safe). ALL `const`/`let` declarations must be at the top level of the function body (not inside blocks). | ✅ Yes |
35
- | `pikkuWorkflowGraph` | DAG / fan-out with nodes and typed refs between them. | ✅ Yes |
36
- | `pikkuWorkflowComplexFunc` | Escape hatch only — arbitrary TypeScript, no top-level restriction (e.g. dynamic inline functions the DSL extractor cannot handle). | ❌ No (loses step-graph view) |
32
+ | Factory | When to use | Step-graph view? |
33
+ | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- |
34
+ | `pikkuWorkflowFunc` | **Default for all new workflows.** Sequential + conditional logic; DSL mode (serialisable, replay-safe). ALL `const`/`let` declarations must be at the top level of the function body (not inside blocks). | ✅ Yes |
35
+ | `pikkuWorkflowGraph` | DAG / fan-out with nodes and typed refs between them. | ✅ Yes |
36
+ | `pikkuWorkflowComplexFunc` | Escape hatch only — arbitrary TypeScript, no top-level restriction (e.g. dynamic inline functions the DSL extractor cannot handle). | ❌ No (loses step-graph view) |
37
37
 
38
38
  **Default to `pikkuWorkflowFunc`.** Use `pikkuWorkflowGraph` ONLY with explicit user approval AND only for a genuine cyclic dependency or Node.js-only import DSL cannot express. Use `pikkuWorkflowComplexFunc` ONLY with explicit user approval — a last-resort escape hatch. Never switch to either just to dodge a PKU641 error; restructure the code instead.
39
39
 
@@ -58,7 +58,11 @@ if (priority === 'high') {
58
58
 
59
59
  ```typescript
60
60
  // CORRECT — workflow factories come from the generated types file
61
- import { pikkuWorkflowFunc, pikkuWorkflowGraph, pikkuWorkflowComplexFunc } from '#pikku/workflow/pikku-workflow-types.gen.js'
61
+ import {
62
+ pikkuWorkflowFunc,
63
+ pikkuWorkflowGraph,
64
+ pikkuWorkflowComplexFunc,
65
+ } from '#pikku/workflow/pikku-workflow-types.gen.js'
62
66
 
63
67
  // WRONG — '#pikku' does not re-export them (TS2305)
64
68
  import { pikkuWorkflowFunc } from '#pikku'
@@ -73,7 +77,10 @@ import { z } from 'zod'
73
77
  import { pikkuWorkflowFunc } from '#pikku/workflow/pikku-workflow-types.gen.js'
74
78
 
75
79
  const ProcessOrderInput = z.object({ orderId: z.string(), amount: z.number() })
76
- const ProcessOrderOutput = z.object({ status: z.string(), discount: z.number().optional() })
80
+ const ProcessOrderOutput = z.object({
81
+ status: z.string(),
82
+ discount: z.number().optional(),
83
+ })
77
84
 
78
85
  export const processOrder = pikkuWorkflowFunc({
79
86
  description: 'Process an order through payment and fulfillment',
@@ -86,7 +93,9 @@ export const processOrder = pikkuWorkflowFunc({
86
93
  let status: string
87
94
 
88
95
  if (data.amount > 1000) {
89
- const d = await workflow.do('Apply bulk discount', 'calcDiscount', { amount: data.amount })
96
+ const d = await workflow.do('Apply bulk discount', 'calcDiscount', {
97
+ amount: data.amount,
98
+ })
90
99
  discount = d.discountPercent
91
100
  }
92
101
 
@@ -129,6 +138,65 @@ await workflow.approval('Manager sign-off', { ... })
129
138
  `workflow.name`, `workflow.runId` and `await workflow.getRun()` identify the
130
139
  current run if a step needs to reference it.
131
140
 
141
+ ### Approval gates: who may answer
142
+
143
+ `workflow.approval(reason, options)` takes a `schema` (a runtime value — the
144
+ payload arrives from an untrusted caller, so a type generic would validate
145
+ nothing), an optional `expiry`, and an optional policy for **who** may answer:
146
+
147
+ ```typescript
148
+ const signOff = await workflow.approval('Manager sign-off', {
149
+ schema: SignOffSchema,
150
+ expiry: '3d',
151
+ approvers: 'not-initiator', // four-eyes: anyone but whoever started the run
152
+ approverScope: 'payments:approve', // and they must hold this scope
153
+ })
154
+ if (signOff.status === 'expired') { ... }
155
+ ```
156
+
157
+ `approvers` is one of:
158
+
159
+ | value | who may answer |
160
+ | ----------------- | -------------------------------------------------------------------------------------------------------- |
161
+ | `any` _(default)_ | anyone the approve entrypoint admits — the gate is a pause for a decision, not an authorization boundary |
162
+ | `owner` | only the user who started the run |
163
+ | `not-initiator` | anyone **except** the user who started the run |
164
+
165
+ Both options are enforced in two phases, because a decision can legitimately
166
+ arrive before the run has reached the gate:
167
+
168
+ - **At submission**, if the run has already reached the gate. Reaching it
169
+ publishes the policy into the run state, so the approve entrypoint can judge
170
+ the caller against it and refuse with a **403**.
171
+ - **On replay**, for a decision that arrived before the gate — there was no
172
+ policy to judge it against yet, so it is accepted and judged when the workflow
173
+ reaches the gate. Failing there discards the decision and leaves the gate
174
+ closed, exactly as a decision that fails the schema does.
175
+
176
+ So the same rejected decision surfaces as an HTTP error or as a silently
177
+ re-closed gate depending on timing. Both are audited.
178
+
179
+ A gate declaring neither option accepts a decision from anyone the approve
180
+ route lets through; gate the route with `auth`/`permissions` to narrow that.
181
+
182
+ #### What survives the run
183
+
184
+ A settled decision carries `decidedBy` and `decidedAt`, so the answer keeps its
185
+ provenance in the step result:
186
+
187
+ ```typescript
188
+ if (signOff.status === 'decided') {
189
+ logger.info(`signed by ${signOff.decidedBy?.userId} at ${signOff.decidedAt}`)
190
+ }
191
+ ```
192
+
193
+ That record is deleted with the run, though — `deleteRun` cascades to steps and
194
+ history — and an attempt that was _refused_ never reaches a step at all. So
195
+ every answer is also written to the audit sink as `workflow.approval.decided`,
196
+ with `outcome: 'success' | 'denied'`, the decider under `userIdentity`, and the
197
+ run, reason and refusal in `metadata`. Wire an `audit` service to keep it; a
198
+ project without one records nothing and is otherwise unaffected.
199
+
132
200
  ### Error handling: `onError`, never try/catch
133
201
 
134
202
  **Do not wrap steps in try/catch.** The DSL extractor serialises the body into a
@@ -137,14 +205,19 @@ graph would no longer describe what actually runs, which is the whole point of
137
205
  the DSL mode. This is a settled design decision, not a temporary limitation.
138
206
 
139
207
  Use the `onError` step option instead: it names an RPC to invoke when the step
140
- has failed *after* exhausting its retries.
208
+ has failed _after_ exhausting its retries.
141
209
 
142
210
  ```typescript
143
- await workflow.do('Charge', 'chargePayment', { orderId }, {
144
- retries: 3,
145
- retryDelay: '1s',
146
- onError: 'refundReservation', // compensation RPC
147
- })
211
+ await workflow.do(
212
+ 'Charge',
213
+ 'chargePayment',
214
+ { orderId },
215
+ {
216
+ retries: 3,
217
+ retryDelay: '1s',
218
+ onError: 'refundReservation', // compensation RPC
219
+ }
220
+ )
148
221
  ```
149
222
 
150
223
  The handler receives `{ error: { message } }`, and the original error is still
@@ -161,7 +234,9 @@ Full step options: `description`, `retries`, `retryDelay`, `onError` (plus
161
234
 
162
235
  ```typescript
163
236
  const users = await Promise.all(
164
- data.userIds.map((userId) => workflow.do(`Fetch user ${userId}`, 'getUser', { userId }))
237
+ data.userIds.map((userId) =>
238
+ workflow.do(`Fetch user ${userId}`, 'getUser', { userId })
239
+ )
165
240
  )
166
241
  ```
167
242
 
@@ -182,7 +257,10 @@ export const userOnboarding = pikkuWorkflowGraph({
182
257
  config: {
183
258
  createProfile: { next: ['sendWelcome', 'setupDefaults'] }, // run in parallel
184
259
  sendWelcome: {
185
- input: (ref) => ({ to: ref('createProfile', 'email'), subject: 'Welcome!' }),
260
+ input: (ref) => ({
261
+ to: ref('createProfile', 'email'),
262
+ subject: 'Welcome!',
263
+ }),
186
264
  },
187
265
  },
188
266
  })