@loomwork/effect-runtime 0.0.0-stage → 0.1.1

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 (53) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +8 -0
  3. package/README.md +65 -2
  4. package/dist/EffectRuntime.d.ts +121 -0
  5. package/dist/EffectRuntime.d.ts.map +1 -0
  6. package/dist/EffectRuntime.js +422 -0
  7. package/dist/EffectRuntime.js.map +1 -0
  8. package/dist/authz.d.ts +228 -0
  9. package/dist/authz.d.ts.map +1 -0
  10. package/dist/authz.js +281 -0
  11. package/dist/authz.js.map +1 -0
  12. package/dist/effects/A2AEffect.d.ts +14 -0
  13. package/dist/effects/A2AEffect.d.ts.map +1 -0
  14. package/dist/effects/A2AEffect.js +21 -0
  15. package/dist/effects/A2AEffect.js.map +1 -0
  16. package/dist/effects/HumanEffect.d.ts +13 -0
  17. package/dist/effects/HumanEffect.d.ts.map +1 -0
  18. package/dist/effects/HumanEffect.js +21 -0
  19. package/dist/effects/HumanEffect.js.map +1 -0
  20. package/dist/effects/LazyEffect.d.ts +10 -0
  21. package/dist/effects/LazyEffect.d.ts.map +1 -0
  22. package/dist/effects/LazyEffect.js +28 -0
  23. package/dist/effects/LazyEffect.js.map +1 -0
  24. package/dist/effects/LlmEffect.d.ts +27 -0
  25. package/dist/effects/LlmEffect.d.ts.map +1 -0
  26. package/dist/effects/LlmEffect.js +27 -0
  27. package/dist/effects/LlmEffect.js.map +1 -0
  28. package/dist/effects/ToolEffect.d.ts +38 -0
  29. package/dist/effects/ToolEffect.d.ts.map +1 -0
  30. package/dist/effects/ToolEffect.js +31 -0
  31. package/dist/effects/ToolEffect.js.map +1 -0
  32. package/dist/index.d.ts +12 -0
  33. package/dist/index.d.ts.map +1 -0
  34. package/dist/index.js +30 -0
  35. package/dist/index.js.map +1 -0
  36. package/dist/policy.d.ts +76 -0
  37. package/dist/policy.d.ts.map +1 -0
  38. package/dist/policy.js +212 -0
  39. package/dist/policy.js.map +1 -0
  40. package/dist/types.d.ts +416 -0
  41. package/dist/types.d.ts.map +1 -0
  42. package/dist/types.js +28 -0
  43. package/dist/types.js.map +1 -0
  44. package/package.json +56 -3
  45. package/src/EffectRuntime.ts +484 -0
  46. package/src/authz.ts +424 -0
  47. package/src/effects/HumanEffect.ts +25 -0
  48. package/src/effects/LazyEffect.ts +25 -0
  49. package/src/effects/LlmEffect.ts +39 -0
  50. package/src/effects/ToolEffect.ts +59 -0
  51. package/src/index.ts +48 -0
  52. package/src/policy.ts +296 -0
  53. package/src/types.ts +428 -0
package/src/types.ts ADDED
@@ -0,0 +1,428 @@
1
+ import type { TaintedValue } from "@loomwork/classify";
2
+ import type { EffectKind, EffectMode } from "@loomwork/core";
3
+ import type { PrincipalChain } from "./authz";
4
+
5
+ export type { EffectKind, EffectMode };
6
+
7
+ /**
8
+ * A tool was invoked from the wrong side of the determinism boundary — an
9
+ * `ambient` effect inside an execution, or a `journaled` effect transiently.
10
+ *
11
+ * This is a contract violation, not an authorization denial: it is thrown
12
+ * plainly rather than recorded as an EFFECT_DENIED fact, because the caller used
13
+ * the wrong entry point (a programming error) rather than lacking authority.
14
+ */
15
+ export class EffectModeViolationError extends Error {
16
+ readonly toolId: string;
17
+ readonly declared: EffectMode;
18
+ /** The seam the call actually came through. */
19
+ readonly attempted: "journaled" | "ambient";
20
+ constructor(toolId: string, declared: EffectMode, attempted: "journaled" | "ambient") {
21
+ super(
22
+ declared === "ambient"
23
+ ? `Effect "${toolId}" is declared ambient and must not be invoked inside an execution — its result would be journaled, and a call whose count/order is user-driven poisons replay. Use EffectRuntime.invokeTransient (ADR 0066).`
24
+ : `Effect "${toolId}" is declared journaled and must not be invoked transiently — a transient call has zero durable footprint, so the durable fact the caller expected would silently not exist. Use ctx.execute inside an execution.`
25
+ );
26
+ this.name = "EffectModeViolationError";
27
+ this.toolId = toolId;
28
+ this.declared = declared;
29
+ this.attempted = attempted;
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Running resource totals for the owning execution (Loom 2.0 K3). Threaded into
35
+ * each effect's context so accounting/quota middleware can meter usage and enforce
36
+ * budgets. Accumulated durably in `variables._resources` via RESOURCE_METERED, so
37
+ * the snapshot is deterministic on replay.
38
+ */
39
+ export interface ResourceSnapshot {
40
+ tokens: number;
41
+ costUsd: number;
42
+ ms: number;
43
+ count: number;
44
+ // ADR 0032 (R1) — additional dimensions; present only once something meters them.
45
+ memBytes?: number;
46
+ wallMs?: number;
47
+ netBytes?: number;
48
+ /**
49
+ * ADR 0115 — open metering dimensions (`gpu_seconds`, `credits`, ...), present only once
50
+ * something meters one. Read with `Object.hasOwn`: the keys are caller-chosen names.
51
+ */
52
+ custom?: Readonly<Record<string, number>>;
53
+ /** Set once a quota has been exceeded. */
54
+ exceeded?: boolean;
55
+ }
56
+
57
+ /**
58
+ * ADR 0119 — one live chunk of a streaming effect's output, delivered to the per-call
59
+ * sink a caller attached ({@link EffectContext.onDelta}).
60
+ *
61
+ * - TEXT chunk: `delta` carries the text (never empty), WRAPPED as untrusted content
62
+ * (`TaintedValue<string>`, ADR 0085): model output is third-party text, and the
63
+ * wrapper makes `guardRequest` refuse it if it is fed back into a prompt. A UI tail
64
+ * unwraps explicitly (`unwrapTainted`) to display it.
65
+ * - REASONING chunk (`reasoning` set, `delta` empty; ADR 0133 D6): a readable increment of the
66
+ * model's reasoning, a channel SEPARATE from text so no consumer of text deltas sees it by
67
+ * accident. Wrapped as untrusted (`llm-reasoning:<stepId>`). It passes the middleware
68
+ * `deltaGuard`s (a guard instance of its own, so reasoning never pollutes the running text a
69
+ * guard accumulates) and reaches the PER-CALL sink only, never the effect's configured
70
+ * `deltaSink`. Live-only and never journaled.
71
+ * - RESET chunk (`reset: true`, `attempt: n`, empty `delta`): attempt `n > 1` of the
72
+ * call is starting (retry middleware re-ran the effect) — discard everything shown
73
+ * so far and re-render from this attempt's deltas.
74
+ * - TERMINAL chunk (`done: true`, empty `delta`, `usage` if the provider reported
75
+ * one): sent ONLY after the call has succeeded (middleware output phase and parse
76
+ * included). On failure or abort there is no terminal chunk — the call's promise
77
+ * settlement is authoritative.
78
+ */
79
+ export interface EffectDelta {
80
+ readonly delta: TaintedValue<string>;
81
+ /** ADR 0133 D6 - a reasoning increment; `delta` is empty on such a chunk. */
82
+ readonly reasoning?: TaintedValue<string>;
83
+ readonly done?: true;
84
+ readonly reset?: true;
85
+ readonly attempt?: number;
86
+ readonly usage?: { readonly inputTokens: number; readonly outputTokens: number };
87
+ }
88
+
89
+ /** A per-call, live-only, synchronous, fire-and-forget delta sink (ADR 0119). */
90
+ export type DeltaSink = (chunk: EffectDelta) => void;
91
+
92
+ export interface EffectContext {
93
+ executionId: string;
94
+ stepId: string;
95
+ workflowId: string;
96
+ attempt: number;
97
+ /**
98
+ * The resolved effect id for this invocation. The runtime sets it on the view
99
+ * of the context the MIDDLEWARE chain receives (additive — hand-built contexts
100
+ * simply leave it unset, and the effect itself still receives the caller's
101
+ * context unchanged). Lets cross-cutting middleware key per-tool configuration
102
+ * — e.g. claim-check digest declarations — without a lookup side channel.
103
+ */
104
+ readonly toolId?: string;
105
+ /**
106
+ * ADR 0140 - the `TaskDefinition.kind` of the durable frame this effect runs in (`"tool"` for an
107
+ * `agentLoop$` tool call, `"llm"` for its model turn, `"memory"` for its compaction), set by the workflow
108
+ * runtime on durable steps. Absent on a transient (`invokeTransient`) call and on hand-built contexts.
109
+ * Lets middleware that must touch only an agent's TOOL frames (the opt-in claim-check) tell them from
110
+ * a model or summarizer call that shares no id vocabulary with them.
111
+ */
112
+ readonly taskKind?: string;
113
+ /**
114
+ * L30 — set only on a transient call stamped as an execution (`ctx.decide`'s
115
+ * scope). Several such calls share `(executionId, stepId = toolId)`, so a site
116
+ * that mints an id from those two (A2A task id, redaction secret-ref id) MUST
117
+ * append this when present, or two calls collide. Undefined on durable steps
118
+ * (their stepId is unique) and on bare transient calls.
119
+ */
120
+ readonly callKey?: string;
121
+ /**
122
+ * ADR 0119 — the per-call delta sink of a transient call that asked for one. A
123
+ * streaming effect (`createLlmEffect({ stream: true })`) calls it for each delta;
124
+ * the runtime forwards to the effect's CONFIGURED sink ({@link Effect.deltaSink}) from
125
+ * here, after the guards, so the configured sink is governed on every path. Live-only: never journaled, and not
126
+ * called on replay. The runtime hands the effect a wrapper that goes silent once the
127
+ * call settles or aborts and swallows a throw of the CALLER's sink. It throws in ONE
128
+ * case: a middleware delta guard (e.g. a safety delta validator) refused the running
129
+ * text — that throw means "sever the stream", so an effect MUST let it propagate
130
+ * (do not catch it around the call). Absent when no runtime is
131
+ * involved or neither a caller nor the effect has a sink. When the runtime REFUSED streaming
132
+ * (an output validator has no delta form) it is a silent no-op — never absent.
133
+ */
134
+ readonly onDelta?: DeltaSink;
135
+ emit(eventType: string, payload: unknown): void;
136
+ /**
137
+ * Cooperative cancellation for this effect. Aborts when the owning execution is
138
+ * cancelled. Long-running/I/O effects (LLM, tool, A2A) should forward it to
139
+ * `fetch`/providers and check it at await points. Optional and undefined by
140
+ * default, so non-cancellable effects pay nothing.
141
+ */
142
+ readonly signal?: AbortSignal;
143
+ /**
144
+ * Running resource totals for this execution BEFORE this effect (K3). Undefined
145
+ * when accounting is not wired, so effects/middleware that don't meter pay nothing.
146
+ */
147
+ readonly resources?: ResourceSnapshot;
148
+ /**
149
+ * K9.2 — deterministic idempotency key for this effect invocation:
150
+ * `executionId:stepId` (no attempt component — the folded attempt counter
151
+ * advances on crash-recovery re-execution, which is exactly when the key must
152
+ * stay stable). Recovery re-runs of the same logical attempt therefore carry
153
+ * the SAME key, so tool authors can deduplicate at the provider. A fresh key
154
+ * (`executionId:stepId:rN`) is minted only by a durable new-logical-attempt
155
+ * fact — an EFFECT_INDOUBT "retry-fresh" verdict.
156
+ */
157
+ readonly idempotencyKey?: string;
158
+ /**
159
+ * Scheduling priority for this effect (M6), used when the EffectRuntime routes
160
+ * effects through a Scheduler with a finite concurrency cap. A `@loomwork/scheduler`
161
+ * Priority value (0 = Immediate … 3 = Idle). Undefined ⇒ Normal.
162
+ */
163
+ readonly priority?: number;
164
+ /**
165
+ * K12.1 — the authenticated principal chain for the owning execution, carried
166
+ * opaquely and handed to the K2 authorizer. Set at the trust boundary
167
+ * (start/resume). Undefined ⇒ no principal (today's behavior).
168
+ */
169
+ readonly principal?: PrincipalChain;
170
+ }
171
+
172
+ /**
173
+ * Options for a transient (non-durable) effect invocation — {@link
174
+ * EffectRuntime.invokeTransient}. Every field is optional; the empty case is a
175
+ * bare one-shot call with no cancellation and no principal.
176
+ *
177
+ * There is deliberately no `executionId`/`stepId`/`attempt`/`emit` here: those
178
+ * are the internal {@link EffectContext} shape the façade OWNS and synthesizes,
179
+ * so a caller never couples to it (R2). None of these fields is ever persisted —
180
+ * a transient invocation writes nothing (R1).
181
+ */
182
+ export interface TransientInvokeOptions {
183
+ /**
184
+ * Optional cooperative cancellation (R5). Forwarded verbatim as
185
+ * `EffectContext.signal`, so it propagates to the effect's `fetch`/sandbox/
186
+ * provider and aborts the in-flight call. Lets a caller bound a transient
187
+ * invocation with a deadline.
188
+ */
189
+ readonly signal?: AbortSignal;
190
+ /**
191
+ * Optional authenticated principal chain. If an authorizer is wired on the
192
+ * runtime (K2 protection ring), it is still enforced transiently — an
193
+ * unauthorized effect never runs. Undefined ⇒ no principal (today's behavior).
194
+ */
195
+ readonly principal?: PrincipalChain;
196
+ /**
197
+ * A trace/metric tag, never persisted (there is nothing to persist to).
198
+ * Defaults to the `toolId`. Without `as` it is NOT only a tag: it becomes the
199
+ * synthetic context's `executionId` (`transient:<label>`), which the authorizer
200
+ * and middleware see — a `GrantTable`/`PolicyAuthorizer` configured to grant
201
+ * per execution decides on it. Wrappers that memoise must key on it.
202
+ */
203
+ readonly label?: string;
204
+ /**
205
+ * L30 — run AS an existing execution instead of the throwaway `transient`
206
+ * identity: the synthesized {@link EffectContext} carries this `workflowId`/
207
+ * `executionId`, so the K2 authorizer's flow-scoped grants, delegation checks and
208
+ * trace/middleware correlation see the deciding execution. It changes only WHAT
209
+ * IS CLAIMED, never what is written: the call is still transient (R1-R5
210
+ * unchanged — no journal, no-op `emit`, same middleware chain, `ambient`
211
+ * effect-mode check). `stepId` defaults to the toolId, exactly as a bare call, so
212
+ * stepId-keyed per-tool configuration (redaction, pricing, retry) matches.
213
+ *
214
+ * Trust model: this is the transient analogue of the EffectContext a holder of
215
+ * the runtime can already hand-build via `execute`; it is NOT a capability check.
216
+ * `ctx.decide`'s scope is the only thing flow code is handed, and it pre-stamps
217
+ * the identity, so flow code cannot choose one. Do not forward untrusted input
218
+ * into `as`.
219
+ *
220
+ * Because the identity is an AUTHORIZATION INPUT (`GrantTable` keys on
221
+ * `workflowId`), any wrapper that memoises, dedupes or caches transient calls
222
+ * MUST key on the identity (`as`) as well as `[principal, input]` — and on
223
+ * `label` too, which names the transient execution when `as` is absent (the
224
+ * simplest safe key is every option except `signal`) — otherwise a result
225
+ * produced under one execution's grants can be served to another's.
226
+ */
227
+ readonly as?: TransientIdentity;
228
+ /**
229
+ * Running resource totals of the execution the call is stamped as, so metering /
230
+ * `nestedBudget` ADMISSION control can refuse an over-budget execution. The
231
+ * CHARGE is not recorded: `emit` is the no-op sink, so a transient call's spend
232
+ * never reaches the fold (a decider's LLM spend is invisible to the budget).
233
+ */
234
+ readonly resources?: ResourceSnapshot;
235
+ /**
236
+ * ADR 0119 — a live delta sink for this call, surfaced as `EffectContext.onDelta`.
237
+ * Synchronous and fire-and-forget; a throw is swallowed (and warned), never fails
238
+ * the call. Does not change what the call records (nothing — it is transient).
239
+ */
240
+ readonly onDelta?: DeltaSink;
241
+ }
242
+
243
+ /** The execution identity a transient invocation is stamped with (L30). */
244
+ export interface TransientIdentity {
245
+ readonly workflowId: string;
246
+ readonly executionId: string;
247
+ /** Defaults to the toolId. Keep it that way if per-tool config is keyed by stepId. */
248
+ readonly stepId?: string;
249
+ /**
250
+ * Disambiguates calls that share `(executionId, stepId)` (several calls to one
251
+ * tool within an execution): surfaces as {@link EffectContext.callKey}.
252
+ */
253
+ readonly callKey?: string;
254
+ }
255
+
256
+ /**
257
+ * Advisory pre-warm hook (M6). Called off the durable path before an effect runs so a
258
+ * backend can keep a model/connection/cache hot. It is latency-only — it MUST NOT emit
259
+ * events or influence outcomes (P10 advisory plane). A failed/absent warm just means a
260
+ * cold call.
261
+ */
262
+ export interface Warmer {
263
+ warm(toolId: string): void;
264
+ }
265
+
266
+ /**
267
+ * Minimal structural view of a Zod schema's `safeParse` — kept structural so
268
+ * `@loomwork/effect-runtime` stays zod-free (the same dependency-light idiom as
269
+ * core's AgentSkillDef). A `Record<string, z.ZodSchema>` satisfies it directly.
270
+ */
271
+ export interface ErrorChannelSchema {
272
+ safeParse(
273
+ input: unknown
274
+ ): { success: true; data: unknown } | { success: false; error: { message: string } };
275
+ }
276
+
277
+ /**
278
+ * ADR 0133 D7 - app-level defaults for model-reasoning capture, structural so the composition root
279
+ * needs no dependency on `@loomwork/llm`. `store` is the sidecar where readable reasoning is kept
280
+ * (a content store satisfies it); `capture` defaults to on iff a store is configured.
281
+ */
282
+ export interface EffectReasoningDefaults {
283
+ store?: {
284
+ put(bytes: Uint8Array): Promise<{ readonly __loomContent: string }>;
285
+ get(ref: { readonly __loomContent: string }): Promise<Uint8Array | null>;
286
+ };
287
+ capture?: boolean | ((ctx: EffectContext) => boolean);
288
+ live?: boolean;
289
+ }
290
+
291
+ /**
292
+ * ADR 0133 D7 - what the composition root may ask of an LLM effect about model reasoning: whether its
293
+ * provider can return any, how it is configured now, and to adopt app-level defaults (the effect's
294
+ * own settings win field by field). Not part of the call path.
295
+ */
296
+ export interface EffectReasoningHook {
297
+ /** The provider declares `capabilities.reasoning`. */
298
+ readonly capable: boolean;
299
+ /** `keeps`: a store is configured and capture is not off. `off`: capture is explicitly off. `unconfigured`: neither. */
300
+ posture(): "keeps" | "off" | "unconfigured";
301
+ /** Adopt defaults; `false` is the explicit, silent opt-out (capture off) for whatever the effect did not set itself. */
302
+ adopt(defaults: EffectReasoningDefaults | false): void;
303
+ }
304
+
305
+ export interface Effect<TIn = unknown, TOut = unknown> {
306
+ kind: EffectKind;
307
+ /** ADR 0133 D7 - present on an LLM effect built by `createLlmEffect`. */
308
+ readonly reasoning?: EffectReasoningHook;
309
+ /**
310
+ * K9.3 — the effect's declared delivery semantic. `false` marks a mutating,
311
+ * non-idempotent effect: recovery refuses to silently re-run an attempt in an
312
+ * unknown state (it parks the execution at EFFECT_INDOUBT for an operator
313
+ * verdict). `true` asserts re-runs are safe (dedupe at the provider via
314
+ * `EffectContext.idempotencyKey`). Undeclared (`undefined`) keeps the legacy
315
+ * at-least-once re-run — existing effects behave byte-identically (G2).
316
+ */
317
+ readonly idempotent?: boolean;
318
+ /**
319
+ * DE-4 (ADR 0060) — the effect's declared failure channels (channel name →
320
+ * payload schema). Like `idempotent`, a declaration the kernel reads from the
321
+ * registry: a `TypedFailure` thrown on a declared channel is recorded as a
322
+ * typed STEP_FAILED and substituted on replay. A step-level `errors:`
323
+ * declaration wins over this registry fallback. Undeclared channels keep the
324
+ * legacy untyped at-least-once failure semantics (G2).
325
+ */
326
+ readonly errors?: Record<string, ErrorChannelSchema>;
327
+ /**
328
+ * Which invocation seam this effect belongs to — `"journaled"` (inside an
329
+ * execution) or `"ambient"` (transient only). Like `idempotent`, a declaration
330
+ * the runtime reads from the registry: `execute` refuses an `ambient` effect
331
+ * and `invokeTransient` refuses a `"journaled"` one, both with
332
+ * {@link EffectModeViolationError}. Undeclared keeps today's behaviour on both
333
+ * paths (G2).
334
+ *
335
+ * This is a boundary check, not a journaling mode: an `ambient` effect is not
336
+ * "an effect that skips its journal" — it is one that may not enter an
337
+ * execution at all, so there remains exactly one replay model.
338
+ */
339
+ readonly mode?: EffectMode;
340
+ /**
341
+ * ADR 0119 — the effect's own CONFIGURED live-delta sink (e.g. `createLlmEffect({ onDelta })`).
342
+ * Declared here so the RUNTIME owns delivery: the effect calls `ctx.onDelta` and the runtime
343
+ * forwards text and reset chunks (never the terminal one) to this sink only AFTER the
344
+ * middleware delta guards ran, on durable and transient calls alike; it is silent (fail
345
+ * closed) under an output validator that has no delta form. Called directly by the effect
346
+ * only when there is no runtime (`ctx.onDelta` absent).
347
+ */
348
+ readonly deltaSink?: (chunk: EffectDelta, ctx: EffectContext) => void;
349
+ /**
350
+ * ADR 0133 D6 - the effect's configured live REASONING sink (e.g. `createLlmEffect({ reasoning:
351
+ * { live: true, onDelta } })`). Reasoning is a channel of its own, so it never reaches
352
+ * {@link deltaSink}; the runtime forwards a reasoning increment here only AFTER the middleware
353
+ * delta guards ran, still wrapped as untrusted content, and is silent under an output validator
354
+ * that has no delta form (fail closed), exactly like the text sink.
355
+ */
356
+ readonly reasoningSink?: (delta: TaintedValue<string>, ctx: EffectContext) => void;
357
+ /**
358
+ * R12 — the sub-resource one call of this effect targets, for per-resource authorization.
359
+ * ONE registered effect can front many resources (`mcp.invoke` fronts every tool on an MCP
360
+ * server); an authorizer that only sees the effect id can allow all of them or none. When
361
+ * declared, the runtime evaluates it on the call's input and puts the result on
362
+ * `CapabilityRequest.resource`, so a grant or policy can name `<effectId>:<resource>`.
363
+ *
364
+ * Pure and cheap — it runs before the effect, on the raw (pre-redaction) input. Returning
365
+ * `undefined` (or throwing) means "no resource": only an effect-wide grant then applies, so a
366
+ * per-resource grant fails CLOSED rather than matching nothing-in-particular.
367
+ *
368
+ * It is evaluated on the input as the call site passed it, BEFORE any middleware runs: a
369
+ * middleware that rewrites the input (e.g. the tool name) after authorization is not seen by
370
+ * the authorizer, so never rely on a middleware to change what the authorization decision
371
+ * names.
372
+ */
373
+ resourceOf?(input: TIn): string | undefined;
374
+ /**
375
+ * Advisory pre-warm (latency-only, off the durable path). If present, the
376
+ * kernel may call it BEFORE the effect is needed to spin up a cold resource
377
+ * the effect depends on — a K7 isolated child process, an LLM provider
378
+ * connection/model cache, a remote worker. Like the {@link Warmer} seam it is
379
+ * strictly latency: it MUST NOT emit events or influence outcomes (P10), and a
380
+ * failure is a silent no-op → the first real call just runs cold. Idempotent
381
+ * and safe to call repeatedly.
382
+ */
383
+ warm?(): void | Promise<void>;
384
+ execute(input: TIn, ctx: EffectContext): Promise<TOut>;
385
+ }
386
+
387
+ /**
388
+ * Invokes the downstream middleware, or finally the effect itself. Call `next()`
389
+ * to pass this middleware's input through unchanged; call `next(override)` to
390
+ * feed a TRANSFORMED input to the effect (e.g. K10.3 redaction resolving secret
391
+ * references). The override flows to the effect body, not back to the log.
392
+ */
393
+ export type EffectNext = (input?: unknown) => Promise<unknown>;
394
+
395
+ /**
396
+ * Wraps a single effect execution — the cross-cutting seam (retries, logging,
397
+ * tracing, idempotency) around every tool/LLM/human/a2a call.
398
+ *
399
+ * Replay-safety: middleware runs ONLY when an effect actually executes. Completed
400
+ * steps short-circuit in `WorkflowContext.run` before `EffectRuntime.execute` is
401
+ * called, so replayed/resumed runs never re-invoke middleware for work already in
402
+ * the log. The one in-flight step that re-runs on crash-recovery re-runs its
403
+ * middleware too, so any middleware with external side effects must tolerate
404
+ * at-least-once (prefer emitting durable events via `ctx.emit`).
405
+ *
406
+ * Ordering is static (fixed at `EffectRuntime` construction) so it is
407
+ * deterministic across replay: `middleware[0]` is outermost.
408
+ */
409
+ export interface EffectMiddleware {
410
+ (ctx: EffectContext, input: unknown, next: EffectNext): Promise<unknown>;
411
+ /**
412
+ * ADR 0119 — declares that this middleware INSPECTS THE OUTPUT of the effect (an
413
+ * output-phase safety validator). A streamed call would deliver text to a live sink
414
+ * before that inspection could run, so: if any middleware in the chain has
415
+ * `inspectsOutput: true`, the runtime REFUSES streaming to BOTH the per-call sink and
416
+ * the effect's configured `deltaSink` (warns; the call completes non-streamed). Fail-closed — no configuration streams text past an
417
+ * output check that cannot see it. A middleware whose every output check has a delta
418
+ * form covers it with {@link deltaGuard} and leaves this unset.
419
+ */
420
+ readonly inspectsOutput?: boolean;
421
+ /**
422
+ * ADR 0119 — a per-call-attempt guard over the RUNNING text of a streamed call.
423
+ * Called once per attempt to build a `(delta) => void`, which the runtime invokes for
424
+ * each text delta BEFORE it is delivered; it THROWS to sever the stream (the
425
+ * offending delta is not delivered).
426
+ */
427
+ readonly deltaGuard?: (ctx: EffectContext) => (delta: string) => void;
428
+ }