@db-lyon/flowkit 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,258 +1,258 @@
1
- # Custom tasks
2
-
3
- Tasks are the building blocks of flowkit. Each task is a class that extends `BaseTask` and implements an `execute()` method.
4
-
5
- ## Anatomy of a task
6
-
7
- ```typescript
8
- import { BaseTask, type TaskResult } from '@db-lyon/flowkit';
9
-
10
- interface MyOptions {
11
- url: string;
12
- retries?: number;
13
- }
14
-
15
- export default class FetchData extends BaseTask<MyOptions> {
16
- get taskName() {
17
- return 'fetch_data';
18
- }
19
-
20
- protected validate() {
21
- if (!this.options.url) {
22
- throw new Error('url option is required');
23
- }
24
- }
25
-
26
- async execute(): Promise<TaskResult> {
27
- const { url, retries = 3 } = this.options;
28
-
29
- const response = await fetch(url);
30
- if (!response.ok) {
31
- return {
32
- success: false,
33
- error: new Error(`HTTP ${response.status}`),
34
- };
35
- }
36
-
37
- const data = await response.json();
38
- return {
39
- success: true,
40
- data: { body: data, status: response.status },
41
- };
42
- }
43
- }
44
- ```
45
-
46
- ### Required members
47
-
48
- | Member | Description |
49
- |--------|-------------|
50
- | `get taskName()` | A human-readable name used in logging |
51
- | `execute()` | Async method that performs the work and returns a `TaskResult` |
52
-
53
- ### Optional members
54
-
55
- | Member | Description |
56
- |--------|-------------|
57
- | `validate()` | Called before `execute()`. Throw to abort with a validation error. |
58
-
59
- ### Available on `this`
60
-
61
- | Property | Description |
62
- |----------|-------------|
63
- | `this.options` | The merged options (task defaults + step overrides), typed as `TOptions` |
64
- | `this.ctx` | The `TaskContext` passed to the flow runner — read-only, see below |
65
- | `this.logger` | A child logger scoped to this task instance |
66
- | `this.resolve(name, options?)` | Build another task by configured name or class path, unexecuted |
67
- | `this.call(name, options?)` | `resolve()` plus `run()`, returning its `TaskResult` |
68
-
69
- ## Calling other tasks
70
-
71
- `this.call(name)` resolves `name` the same way a flow step does. A configured task name is looked up in the `tasks:` config and dispatched through its `class_path`, inheriting its configured `options` as defaults; anything you pass as `options` merges over them and wins. A name with no configured entry resolves as a class path directly, so a bare `vendor.tasks.Thing` still works.
72
-
73
- ```yaml
74
- tasks:
75
- soql_query:
76
- class_path: caseops.tasks.SoqlQuery
77
- options:
78
- org: ${org.username}
79
- ```
80
-
81
- ```typescript
82
- // Runs caseops.tasks.SoqlQuery with { org: 'admin@example.com', query: 'SELECT ...' }
83
- const result = await this.call('soql_query', { query: 'SELECT Id FROM Case' });
84
- ```
85
-
86
- The `${ns.path}` references in those configured defaults are interpolated for you, against the same scope the calling task itself runs under. The `options` you pass are your own runtime data and are **never** interpolated, so a `${...}` you computed reaches the task verbatim rather than being reinterpreted as configuration.
87
-
88
- Calling a task requires a registry on the context, which `FlowRunner` supplies. A task constructed by hand without one throws.
89
-
90
- ## The task lifecycle
91
-
92
- When `task.run()` is called (by the flow runner):
93
-
94
- 1. `validate()` runs — throw here to reject bad options
95
- 2. `execute()` runs — return a `TaskResult`
96
- 3. The result gets a `duration` field added automatically
97
- 4. If `validate()` or `execute()` throws, the error is caught and returned as `{ success: false, error }`
98
-
99
- You never call `run()` yourself in normal usage — the flow runner handles it.
100
-
101
- ## TaskResult
102
-
103
- ```typescript
104
- interface TaskResult {
105
- success: boolean;
106
- data?: Record<string, unknown>; // arbitrary output data
107
- error?: Error; // populated on failure
108
- duration?: number; // milliseconds, set by run()
109
- }
110
- ```
111
-
112
- Return `{ success: true }` for success and `{ success: false, error }` for expected failures. Unexpected exceptions are caught automatically.
113
-
114
- ## TaskContext
115
-
116
- The context carries host-supplied state to every task in a flow run — database connections, API clients, configuration:
117
-
118
- ```typescript
119
- const runner = new FlowRunner({
120
- // ...
121
- context: {
122
- logger: myLogger,
123
- db: databaseConnection,
124
- apiKey: process.env.API_KEY,
125
- },
126
- });
127
- ```
128
-
129
- Inside a task:
130
-
131
- ```typescript
132
- async execute(): Promise<TaskResult> {
133
- const db = this.ctx.db as Database;
134
- // ...
135
- }
136
- ```
137
-
138
- Treat the context as read-only. Each task is handed its own derived context, so assigning a key inside a task (`this.ctx.cached = x`) does not reach the next step, another task, or a sub-agent. To share mutable state, put a mutable object on the context up front and write into that:
139
-
140
- ```typescript
141
- context: { cache: new Map() } // this.ctx.cache.set(...) is visible everywhere
142
- ```
143
-
144
- ## Registering tasks
145
-
146
- ### By name
147
-
148
- ```typescript
149
- const registry = new TaskRegistry();
150
- registry.register('fetch_data', FetchData as any);
151
- ```
152
-
153
- The YAML can then reference it directly:
154
-
155
- ```yaml
156
- tasks:
157
- fetch_data:
158
- class_path: fetch_data
159
- ```
160
-
161
- ### By class path
162
-
163
- ```typescript
164
- registry.registerClassPath('my.tasks.FetchData', FetchData as any);
165
- ```
166
-
167
- ### Bulk registration
168
-
169
- ```typescript
170
- registry.registerAll({
171
- fetch_data: FetchData as any,
172
- transform: TransformData as any,
173
- upload: Upload as any,
174
- });
175
- ```
176
-
177
- ### Dynamic resolution
178
-
179
- If a `class_path` isn't found in the registry, flowkit converts dots to path separators and looks for a file on disk:
180
-
181
- | class_path | Files checked |
182
- |------------|---------------|
183
- | `tasks.FetchData` | `tasks/FetchData.ts`, `tasks/FetchData.js`, `tasks/FetchData/index.ts`, `tasks/FetchData/index.js` |
184
- | `lib.etl.Extract` | `lib/etl/Extract.ts`, `lib/etl/Extract.js`, ... |
185
-
186
- The module must have either a `default` export or a named export matching the last segment of the path (e.g., `FetchData`). The export must extend `BaseTask`.
187
-
188
- ## Built-in: ShellTask
189
-
190
- `ShellTask` executes shell commands through the platform shell and streams output.
191
- Register it under any name you like:
192
-
193
- ```typescript
194
- import { ShellTask } from '@db-lyon/flowkit';
195
-
196
- registry.register('shell', ShellTask as any);
197
- ```
198
-
199
- Then use it in YAML:
200
-
201
- ```yaml
202
- tasks:
203
- lint:
204
- class_path: shell
205
- description: Run the linter
206
- options:
207
- command: npm run lint
208
-
209
- build:
210
- class_path: shell
211
- description: Build the project
212
- options:
213
- command: npm run build
214
- cwd: /path/to/project
215
- timeout: 120000
216
- ```
217
-
218
- ### ShellTask options
219
-
220
- | Option | Type | Default | Description |
221
- |--------|------|---------|-------------|
222
- | `command` | `string` | (required) | The shell command to execute |
223
- | `cwd` | `string` | `undefined` | Working directory |
224
- | `timeout` | `number` | `300000` (5 min) | Timeout in milliseconds |
225
- | `signal` | `AbortSignal` | `undefined` | Cancels one programmatic invocation; cannot be specified in YAML |
226
-
227
- On success, `result.data.output` contains the trimmed stdout. On failure, `result.data` includes `exitCode`, `stderr`, and `stdout`.
228
-
229
- Existing YAML consumers need no change. Programmatic callers that need
230
- cancellation pass an invocation-specific `AbortSignal` in the task options.
231
- After cancellation, Flowkit waits for the shell to close, with a bounded
232
- fallback if the operating system does not report closure (one second on POSIX,
233
- three seconds on Windows). That fallback does not guarantee all descendants
234
- have exited. At the deadline Flowkit requests force termination and releases
235
- its Node handles for the root shell and any Windows `taskkill` helper before
236
- returning. On POSIX,
237
- signal-bearing invocations use a dedicated process group: Flowkit requests
238
- `SIGTERM`, waits 250ms for cooperative cleanup, then escalates the group to
239
- `SIGKILL`. Terminal Ctrl+C is not delivered to that separate group, so use the
240
- supplied `AbortSignal` for cancellation.
241
-
242
- On Windows, Flowkit asks `taskkill /T /F` to terminate the shell tree and does
243
- not kill the shell while that traversal is in progress. If `taskkill` fails or
244
- the three-second deadline expires, Flowkit requests force termination and
245
- releases its Node handles for the root and helper. Windows and POSIX descendants that escape the managed process
246
- tree or process group may still survive; Node does not provide a portable
247
- guarantee of complete descendant termination.
248
-
249
- Trailing stdout and stderr fragments are captured once, including when no
250
- `signal` is supplied. This corrects the duplicate final-partial-line output in
251
- earlier releases.
252
-
253
- ## Listing registered tasks
254
-
255
- ```typescript
256
- const names = registry.listRegistered();
257
- // ['fetch_data', 'shell', 'my.tasks.Transform', ...]
258
- ```
1
+ # Custom tasks
2
+
3
+ Tasks are the building blocks of flowkit. Each task is a class that extends `BaseTask` and implements an `execute()` method.
4
+
5
+ ## Anatomy of a task
6
+
7
+ ```typescript
8
+ import { BaseTask, type TaskResult } from '@db-lyon/flowkit';
9
+
10
+ interface MyOptions {
11
+ url: string;
12
+ retries?: number;
13
+ }
14
+
15
+ export default class FetchData extends BaseTask<MyOptions> {
16
+ get taskName() {
17
+ return 'fetch_data';
18
+ }
19
+
20
+ protected validate() {
21
+ if (!this.options.url) {
22
+ throw new Error('url option is required');
23
+ }
24
+ }
25
+
26
+ async execute(): Promise<TaskResult> {
27
+ const { url, retries = 3 } = this.options;
28
+
29
+ const response = await fetch(url);
30
+ if (!response.ok) {
31
+ return {
32
+ success: false,
33
+ error: new Error(`HTTP ${response.status}`),
34
+ };
35
+ }
36
+
37
+ const data = await response.json();
38
+ return {
39
+ success: true,
40
+ data: { body: data, status: response.status },
41
+ };
42
+ }
43
+ }
44
+ ```
45
+
46
+ ### Required members
47
+
48
+ | Member | Description |
49
+ |--------|-------------|
50
+ | `get taskName()` | A human-readable name used in logging |
51
+ | `execute()` | Async method that performs the work and returns a `TaskResult` |
52
+
53
+ ### Optional members
54
+
55
+ | Member | Description |
56
+ |--------|-------------|
57
+ | `validate()` | Called before `execute()`. Throw to abort with a validation error. |
58
+
59
+ ### Available on `this`
60
+
61
+ | Property | Description |
62
+ |----------|-------------|
63
+ | `this.options` | The merged options (task defaults + step overrides), typed as `TOptions` |
64
+ | `this.ctx` | The `TaskContext` passed to the flow runner — read-only, see below |
65
+ | `this.logger` | A child logger scoped to this task instance |
66
+ | `this.resolve(name, options?)` | Build another task by configured name or class path, unexecuted |
67
+ | `this.call(name, options?)` | `resolve()` plus `run()`, returning its `TaskResult` |
68
+
69
+ ## Calling other tasks
70
+
71
+ `this.call(name)` resolves `name` the same way a flow step does. A configured task name is looked up in the `tasks:` config and dispatched through its `class_path`, inheriting its configured `options` as defaults; anything you pass as `options` merges over them and wins. A name with no configured entry resolves as a class path directly, so a bare `vendor.tasks.Thing` still works.
72
+
73
+ ```yaml
74
+ tasks:
75
+ soql_query:
76
+ class_path: caseops.tasks.SoqlQuery
77
+ options:
78
+ org: ${org.username}
79
+ ```
80
+
81
+ ```typescript
82
+ // Runs caseops.tasks.SoqlQuery with { org: 'admin@example.com', query: 'SELECT ...' }
83
+ const result = await this.call('soql_query', { query: 'SELECT Id FROM Case' });
84
+ ```
85
+
86
+ The `${ns.path}` references in those configured defaults are interpolated for you, against the same scope the calling task itself runs under. The `options` you pass are your own runtime data and are **never** interpolated, so a `${...}` you computed reaches the task verbatim rather than being reinterpreted as configuration.
87
+
88
+ Calling a task requires a registry on the context, which `FlowRunner` supplies. A task constructed by hand without one throws.
89
+
90
+ ## The task lifecycle
91
+
92
+ When `task.run()` is called (by the flow runner):
93
+
94
+ 1. `validate()` runs — throw here to reject bad options
95
+ 2. `execute()` runs — return a `TaskResult`
96
+ 3. The result gets a `duration` field added automatically
97
+ 4. If `validate()` or `execute()` throws, the error is caught and returned as `{ success: false, error }`
98
+
99
+ You never call `run()` yourself in normal usage — the flow runner handles it.
100
+
101
+ ## TaskResult
102
+
103
+ ```typescript
104
+ interface TaskResult {
105
+ success: boolean;
106
+ data?: Record<string, unknown>; // arbitrary output data
107
+ error?: Error; // populated on failure
108
+ duration?: number; // milliseconds, set by run()
109
+ }
110
+ ```
111
+
112
+ Return `{ success: true }` for success and `{ success: false, error }` for expected failures. Unexpected exceptions are caught automatically.
113
+
114
+ ## TaskContext
115
+
116
+ The context carries host-supplied state to every task in a flow run — database connections, API clients, configuration:
117
+
118
+ ```typescript
119
+ const runner = new FlowRunner({
120
+ // ...
121
+ context: {
122
+ logger: myLogger,
123
+ db: databaseConnection,
124
+ apiKey: process.env.API_KEY,
125
+ },
126
+ });
127
+ ```
128
+
129
+ Inside a task:
130
+
131
+ ```typescript
132
+ async execute(): Promise<TaskResult> {
133
+ const db = this.ctx.db as Database;
134
+ // ...
135
+ }
136
+ ```
137
+
138
+ Treat the context as read-only. Each task is handed its own derived context, so assigning a key inside a task (`this.ctx.cached = x`) does not reach the next step, another task, or a sub-agent. To share mutable state, put a mutable object on the context up front and write into that:
139
+
140
+ ```typescript
141
+ context: { cache: new Map() } // this.ctx.cache.set(...) is visible everywhere
142
+ ```
143
+
144
+ ## Registering tasks
145
+
146
+ ### By name
147
+
148
+ ```typescript
149
+ const registry = new TaskRegistry();
150
+ registry.register('fetch_data', FetchData as any);
151
+ ```
152
+
153
+ The YAML can then reference it directly:
154
+
155
+ ```yaml
156
+ tasks:
157
+ fetch_data:
158
+ class_path: fetch_data
159
+ ```
160
+
161
+ ### By class path
162
+
163
+ ```typescript
164
+ registry.registerClassPath('my.tasks.FetchData', FetchData as any);
165
+ ```
166
+
167
+ ### Bulk registration
168
+
169
+ ```typescript
170
+ registry.registerAll({
171
+ fetch_data: FetchData as any,
172
+ transform: TransformData as any,
173
+ upload: Upload as any,
174
+ });
175
+ ```
176
+
177
+ ### Dynamic resolution
178
+
179
+ If a `class_path` isn't found in the registry, flowkit converts dots to path separators and looks for a file on disk:
180
+
181
+ | class_path | Files checked |
182
+ |------------|---------------|
183
+ | `tasks.FetchData` | `tasks/FetchData.ts`, `tasks/FetchData.js`, `tasks/FetchData/index.ts`, `tasks/FetchData/index.js` |
184
+ | `lib.etl.Extract` | `lib/etl/Extract.ts`, `lib/etl/Extract.js`, ... |
185
+
186
+ The module must have either a `default` export or a named export matching the last segment of the path (e.g., `FetchData`). The export must extend `BaseTask`.
187
+
188
+ ## Built-in: ShellTask
189
+
190
+ `ShellTask` executes shell commands through the platform shell and streams output.
191
+ Register it under any name you like:
192
+
193
+ ```typescript
194
+ import { ShellTask } from '@db-lyon/flowkit';
195
+
196
+ registry.register('shell', ShellTask as any);
197
+ ```
198
+
199
+ Then use it in YAML:
200
+
201
+ ```yaml
202
+ tasks:
203
+ lint:
204
+ class_path: shell
205
+ description: Run the linter
206
+ options:
207
+ command: npm run lint
208
+
209
+ build:
210
+ class_path: shell
211
+ description: Build the project
212
+ options:
213
+ command: npm run build
214
+ cwd: /path/to/project
215
+ timeout: 120000
216
+ ```
217
+
218
+ ### ShellTask options
219
+
220
+ | Option | Type | Default | Description |
221
+ |--------|------|---------|-------------|
222
+ | `command` | `string` | (required) | The shell command to execute |
223
+ | `cwd` | `string` | `undefined` | Working directory |
224
+ | `timeout` | `number` | `300000` (5 min) | Timeout in milliseconds |
225
+ | `signal` | `AbortSignal` | `undefined` | Cancels one programmatic invocation; cannot be specified in YAML |
226
+
227
+ On success, `result.data.output` contains the trimmed stdout. On failure, `result.data` includes `exitCode`, `stderr`, and `stdout`.
228
+
229
+ Existing YAML consumers need no change. Programmatic callers that need
230
+ cancellation pass an invocation-specific `AbortSignal` in the task options.
231
+ After cancellation, Flowkit waits for the shell to close, with a bounded
232
+ fallback if the operating system does not report closure (one second on POSIX,
233
+ three seconds on Windows). That fallback does not guarantee all descendants
234
+ have exited. At the deadline Flowkit requests force termination and releases
235
+ its Node handles for the root shell and any Windows `taskkill` helper before
236
+ returning. On POSIX,
237
+ signal-bearing invocations use a dedicated process group: Flowkit requests
238
+ `SIGTERM`, waits 250ms for cooperative cleanup, then escalates the group to
239
+ `SIGKILL`. Terminal Ctrl+C is not delivered to that separate group, so use the
240
+ supplied `AbortSignal` for cancellation.
241
+
242
+ On Windows, Flowkit asks `taskkill /T /F` to terminate the shell tree and does
243
+ not kill the shell while that traversal is in progress. If `taskkill` fails or
244
+ the three-second deadline expires, Flowkit requests force termination and
245
+ releases its Node handles for the root and helper. Windows and POSIX descendants that escape the managed process
246
+ tree or process group may still survive; Node does not provide a portable
247
+ guarantee of complete descendant termination.
248
+
249
+ Trailing stdout and stderr fragments are captured once, including when no
250
+ `signal` is supplied. This corrects the duplicate final-partial-line output in
251
+ earlier releases.
252
+
253
+ ## Listing registered tasks
254
+
255
+ ```typescript
256
+ const names = registry.listRegistered();
257
+ // ['fetch_data', 'shell', 'my.tasks.Transform', ...]
258
+ ```
package/docs/guards.md ADDED
@@ -0,0 +1,133 @@
1
+ # Guards
2
+
3
+ A guard is a `before`/`after` pipeline around one host operation. It sits on a seam the host already has (an RPC call, a write, a command dispatch) and may veto that operation, act on it, or observe its result.
4
+
5
+ The pipeline knows nothing about what any guard does. Access policy, source control checkout, audit logging, rate limiting and approval gating are all just guards.
6
+
7
+ Guards are distinct from [`FlowRunnerHooks`](api-reference.md#flow), which fire around flow steps. A hook observes a step; a guard wraps one host operation and can deny it.
8
+
9
+ ```ts
10
+ import { GuardRegistry, runGuarded, guardContextBase, lazy } from '@db-lyon/flowkit/guard';
11
+ ```
12
+
13
+ ## The context
14
+
15
+ Flowkit requires only a scratch map. Everything else belongs to the host.
16
+
17
+ ```ts
18
+ interface GuardContext {
19
+ readonly meta: Map<string, unknown>;
20
+ }
21
+ ```
22
+
23
+ Extend it with whatever your operation carries, and build it with `guardContextBase()`:
24
+
25
+ ```ts
26
+ interface CallContext extends GuardContext {
27
+ readonly method: string;
28
+ readonly params: Record<string, unknown>;
29
+ /** Files this call will modify. Computed on demand. */
30
+ files(): string[];
31
+ }
32
+
33
+ function makeCallContext(method: string, params: Record<string, unknown>): CallContext {
34
+ const ctx = { ...guardContextBase(), method, params } as CallContext;
35
+ ctx.files = lazy(ctx, 'files', () => classify(method, params));
36
+ return ctx;
37
+ }
38
+ ```
39
+
40
+ `lazy(ctx, key, compute)` caches into `meta`, so enrichment that is expensive to compute and that most guards never consult costs nothing when it is ignored and is computed once when several guards want it.
41
+
42
+ `meta` is also how guards talk to each other. A `before` hook can stash what it did and the matching `after` hook, or a later guard, can read it back.
43
+
44
+ ## Writing a guard
45
+
46
+ ```ts
47
+ interface Guard<Ctx extends GuardContext, TResult> {
48
+ readonly name: string;
49
+ readonly order?: number;
50
+ appliesTo?(ctx: Ctx): boolean | Promise<boolean>;
51
+ before?(ctx: Ctx): Promise<void>;
52
+ after?(ctx: Ctx, result: TResult): Promise<TResult | void>;
53
+ }
54
+ ```
55
+
56
+ - **`before`** runs before the operation. Throw to deny it: the operation never happens and your error propagates unchanged, so the host's own error type survives.
57
+ - **`after`** runs after a successful operation. Return a value to replace the result; return nothing to leave it alone.
58
+ - **`appliesTo`** decides whether the guard participates at all. Default is always.
59
+ - **`order`** sorts the chain, lower first. Ties break by name, so a registry built from an unordered source still runs deterministically.
60
+
61
+ ```ts
62
+ const sourceControl: Guard<CallContext, unknown> = {
63
+ name: 'source-control',
64
+ order: 10,
65
+ appliesTo: (ctx) => ctx.files().length > 0,
66
+ before: async (ctx) => {
67
+ const denied = await checkout(ctx.files());
68
+ if (denied.length) throw new Error(`locked by another user: ${denied.join(', ')}`);
69
+ },
70
+ };
71
+ ```
72
+
73
+ ## Running the pipeline
74
+
75
+ ```ts
76
+ const guards = new GuardRegistry<CallContext, Result>().registerAll([sourceControl, audit]);
77
+
78
+ async function call(method: string, params: Record<string, unknown>): Promise<Result> {
79
+ return runGuarded(makeCallContext(method, params), guards, () => transport.send(method, params));
80
+ }
81
+ ```
82
+
83
+ `before` hooks run in registration order, then `invoke`, then `after` hooks in reverse order, so a guard's two halves nest rather than interleave.
84
+
85
+ Applicability resolves once, up front. A guard whose `before` changes the answer to its own `appliesTo` (a source-control guard that checks a file out, making it writable) still gets its `after` half.
86
+
87
+ With an empty registry `runGuarded` is exactly `invoke()`, so it is safe to install on a seam before any guard exists.
88
+
89
+ ## Guards from tasks
90
+
91
+ A host that already loads tasks from config or from plugins gets guards for free. Name a task `guard.<name>.<phase>` and `discoverTaskGuards` turns it into a guard. No separate activation concept is needed, because the task registry is already the list of everything the host was given.
92
+
93
+ ```yaml
94
+ tasks:
95
+ guard.p4.beforeWrite:
96
+ class_path: ./guards/perforce.js
97
+ guard.audit.after:
98
+ class_path: ./guards/audit.js
99
+ ```
100
+
101
+ `<phase>` is `before` or `after`, optionally suffixed with a scope the host declared:
102
+
103
+ | Task name | Runs |
104
+ | --- | --- |
105
+ | `guard.audit.before` | before every operation |
106
+ | `guard.audit.after` | after every successful operation |
107
+ | `guard.p4.beforeWrite` | before an operation the `write` scope claims |
108
+
109
+ ```ts
110
+ const guards = discoverTaskGuards<CallContext, Result>(taskRegistry, {
111
+ scopes: { write: (ctx) => ctx.files().length > 0 },
112
+ contextFor: (ctx) => ({ logger, registry: taskRegistry, transport: ctx.transport }),
113
+ optionsFor: (ctx, result) => ({
114
+ method: ctx.method,
115
+ params: ctx.params,
116
+ paths: ctx.files(),
117
+ ...(result !== undefined ? { result } : {}),
118
+ }),
119
+ onDeny: (info) => new PolicyError(`blocked (${info.ctx.method}): ${info.reason}`),
120
+ });
121
+ ```
122
+
123
+ `contextFor` is called per operation, so a guard can be bound to whatever the operation belongs to rather than to a single ambient target. A host driving several connections should hand each guard the one serving the call it is guarding.
124
+
125
+ A scope named by a task but not registered by the host is an error at discovery time. A typo in a plugin's task name surfaces at startup rather than becoming a guard that silently runs on everything.
126
+
127
+ ### Denial and failure
128
+
129
+ A `before` guard denies by returning `success: false` or by throwing. Both route to `onDeny`, because `BaseTask.run` turns an exception into a failed result: a guard that crashes denies the operation rather than waving it through, which is the safe direction for the thing standing between a caller and a mutation.
130
+
131
+ `onError` is separate and narrower. It fires only when the guard task cannot be constructed at all, an unresolvable class path or a module that fails to import, where nothing about the operation was evaluated.
132
+
133
+ An `after` guard observes the result and cannot replace it, since a task returns a `TaskResult` rather than the host's result type. A failure is reported through `onAfterFailure` instead of failing an operation that already happened. Reach for a hand-written `Guard` when you need an `after` hook that transforms the result.