@loomwork/effect-runtime 0.0.0-stage → 0.1.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.
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/authz.ts ADDED
@@ -0,0 +1,424 @@
1
+ // ── Capability protection (Loom 2.0 K2) — the kernel's authority ring ─────────
2
+ //
3
+ // Every side effect flows through EffectRuntime.execute, so that is the one boundary
4
+ // a library CAN enforce (unlike hardware memory protection). A CapabilityAuthorizer
5
+ // decides, per invocation, whether the calling execution holds the authority to run
6
+ // the effect. Grants are unforgeable: an execution cannot grant itself a capability —
7
+ // grants live only in the authorizer, wired statically at boot (P5). A denied effect
8
+ // never runs; the denial is emitted as a durable EFFECT_DENIED fact (P9) and fails
9
+ // the step. Enforcement is opt-in: no authorizer ⇒ today's permissive behaviour.
10
+
11
+ /**
12
+ * K12.1 — an authenticated principal chain, populated at the trust boundary
13
+ * (A2A server / resume API) and propagated immutably through dispatch. The
14
+ * KERNEL CARRIES IT OPAQUELY and never interprets it — authorizers, budget
15
+ * scopes, and stores do. Keeping the kernel free of a tenant model is what keeps
16
+ * Loom a generic execution kernel (P10). Extra fields are allowed; a
17
+ * `delegation` (K12.4) rides here.
18
+ */
19
+ export interface PrincipalChain {
20
+ /** The tenant boundary — an opaque, pseudonymous id (never human PII; see K10). */
21
+ tenant?: string;
22
+ /** The acting identity. */
23
+ actor?: string;
24
+ /** The identity this action is performed on behalf of (delegation origin). */
25
+ onBehalfOf?: string;
26
+ /** K12.4 — a verifiable attenuated-delegation token, checked at the callee. */
27
+ delegation?: DelegatedGrant;
28
+ /**
29
+ * R3k (ADR 0111 D2/D4) — set ONLY by the kernel's principal resolver, never by a caller: the
30
+ * execution's recorded principal carried a delegation (this is its digest id) that no codec or
31
+ * resolver could restore. The effect seam refuses every effect of such a principal BEFORE any
32
+ * authorizer is consulted ({@link principalBlocked}); it never falls back to a broader identity.
33
+ */
34
+ delegationUnresolved?: string;
35
+ /** R3k — the recorded principal was crypto-shredded: every effect is denied (never the resumer's). */
36
+ principalShredded?: true;
37
+ /** R3k — the recorded principal could not be rehydrated (a `ref` with no codec, an unknown record version). */
38
+ principalUnresolved?: string;
39
+ [key: string]: unknown;
40
+ }
41
+
42
+ /**
43
+ * R3k (ADR 0111 D4) — why the KERNEL refuses every effect of this principal before any
44
+ * authorizer is asked, or `undefined` when it does not. A `PolicyAuthorizer`, a `GrantTable` or a
45
+ * 3P authorizer would otherwise judge a chain whose delegation could not be restored on its own
46
+ * terms (an unattenuated child after a crash is the privilege escalation this closes).
47
+ */
48
+ export function principalBlocked(principal: PrincipalChain | undefined): string | undefined {
49
+ if (principal === undefined) return undefined;
50
+ if (principal.principalShredded === true) {
51
+ return "the execution's recorded principal was crypto-shredded";
52
+ }
53
+ if (principal.delegationUnresolved !== undefined) {
54
+ return `the execution's recorded delegation "${String(principal.delegationUnresolved)}" could not be restored (no principalCodec/resolver)`;
55
+ }
56
+ if (principal.principalUnresolved !== undefined) {
57
+ return `the execution's recorded principal could not be restored: ${String(principal.principalUnresolved)}`;
58
+ }
59
+ return undefined;
60
+ }
61
+
62
+ export interface CapabilityRequest {
63
+ toolId: string;
64
+ /**
65
+ * R12 — the sub-resource this call targets, when the effect declares one
66
+ * (`Effect.resourceOf`): e.g. `"<server>/<tool>"` for `mcp.invoke`. A grant naming
67
+ * {@link capabilityIds | `<toolId>:<resource>`} authorizes exactly that resource; a grant on
68
+ * the bare `toolId` still authorizes every resource of the effect (unchanged behaviour).
69
+ */
70
+ resource?: string;
71
+ executionId: string;
72
+ workflowId: string;
73
+ stepId: string;
74
+ /** K12.1 — the authenticated principal chain, if one was established. */
75
+ principal?: PrincipalChain;
76
+ }
77
+
78
+ /**
79
+ * R12 — the capability ids a request is authorized under: the effect id (covers every resource
80
+ * of the effect — the legacy, coarse grant) and, when the call names a resource, the qualified
81
+ * `<toolId>:<resource>` id (covers just that resource).
82
+ */
83
+ export function capabilityId(req: CapabilityRequest): string {
84
+ return req.resource === undefined ? req.toolId : `${req.toolId}:${req.resource}`;
85
+ }
86
+
87
+ export function capabilityIds(req: CapabilityRequest): readonly string[] {
88
+ return req.resource === undefined ? [req.toolId] : [req.toolId, `${req.toolId}:${req.resource}`];
89
+ }
90
+
91
+ export interface CapabilityDecision {
92
+ allowed: boolean;
93
+ reason?: string;
94
+ }
95
+
96
+ // The protection ring. Pure and synchronous so the check is deterministic and
97
+ // replay-safe (the same request always yields the same decision).
98
+ export interface CapabilityAuthorizer {
99
+ authorize(req: CapabilityRequest): CapabilityDecision;
100
+ }
101
+
102
+ export class CapabilityDeniedError extends Error {
103
+ readonly toolId: string;
104
+ readonly reason: string;
105
+ constructor(toolId: string, reason: string) {
106
+ super(`Capability denied: effect "${toolId}" — ${reason}`);
107
+ this.name = "CapabilityDeniedError";
108
+ this.toolId = toolId;
109
+ this.reason = reason;
110
+ }
111
+ }
112
+
113
+ export interface GrantTableOptions {
114
+ /**
115
+ * Which field identifies the "principal" whose grants apply. Default: the
116
+ * workflowId (a flow is the unit of authority). Return `executionId` for
117
+ * per-execution grants, or read a grant-set id from the request.
118
+ */
119
+ principal?: (req: CapabilityRequest) => string;
120
+ }
121
+
122
+ /**
123
+ * The least-privilege authorizer: a static table mapping a principal to the set of
124
+ * effect ids it may invoke (`"*"` grants all). Anything not explicitly granted is
125
+ * denied. Grants are declared at boot and cannot be mutated at runtime, so authority
126
+ * is fixed and auditable.
127
+ */
128
+ export class GrantTable implements CapabilityAuthorizer {
129
+ private readonly grants: Map<string, Set<string>>;
130
+ private readonly principalOf: (req: CapabilityRequest) => string;
131
+
132
+ constructor(grants: Record<string, readonly string[]>, options: GrantTableOptions = {}) {
133
+ this.grants = new Map(Object.entries(grants).map(([k, v]) => [k, new Set(v)]));
134
+ this.principalOf = options.principal ?? ((req) => req.workflowId);
135
+ }
136
+
137
+ authorize(req: CapabilityRequest): CapabilityDecision {
138
+ const principal = this.principalOf(req);
139
+ const granted = this.grants.get(principal);
140
+ if (granted === undefined) {
141
+ return { allowed: false, reason: `principal "${principal}" has no grants` };
142
+ }
143
+ if (granted.has("*") || capabilityIds(req).some((id) => granted.has(id))) {
144
+ return { allowed: true };
145
+ }
146
+ return {
147
+ allowed: false,
148
+ reason: `effect "${capabilityId(req)}" not granted to "${principal}"`,
149
+ };
150
+ }
151
+
152
+ /**
153
+ * A read-only projection of the static grants — principal → granted effect ids,
154
+ * or `"*"` when all effects are granted. For pre-flight tooling (e.g. `@loomwork/verify`)
155
+ * that needs to inspect authority without a live {@link CapabilityRequest}.
156
+ */
157
+ view(): Record<string, readonly string[] | "*"> {
158
+ const out: Record<string, readonly string[] | "*"> = {};
159
+ for (const [principal, set] of this.grants) {
160
+ out[principal] = set.has("*") ? "*" : [...set];
161
+ }
162
+ return out;
163
+ }
164
+ }
165
+
166
+ export interface DelegationAuthorizerOptions {
167
+ /**
168
+ * Shared secret used to verify delegation signatures at this trust boundary.
169
+ *
170
+ * NOTE this is SYMMETRIC: a party that can verify a token can also mint one.
171
+ * That is why {@link DelegationAuthorizerOptions.base} is required — see below.
172
+ */
173
+ secret: string;
174
+ /**
175
+ * The callee's OWN authority — an effect is allowed only if it is in BOTH the
176
+ * verified delegated set AND the callee's own grants (never ambient escalation).
177
+ *
178
+ * REQUIRED, and it is load-bearing for security rather than a convenience.
179
+ * Because `secret` is symmetric, the verifier is also a valid minter: a callee
180
+ * can always sign itself a token granting `"*"`, and that token is
181
+ * cryptographically indistinguishable from a genuine one. The delegation check
182
+ * therefore cannot bound a callee's authority on its own — only the
183
+ * intersection with `base` can. With `base` set, minting a token gains a callee
184
+ * nothing it did not already hold, which is what makes the symmetric secret
185
+ * safe here.
186
+ *
187
+ * This option used to be optional, documented as "omit to allow the full
188
+ * verified delegated set (the callee delegates all of its authority decisions
189
+ * upstream)". That mode could not deliver what it claimed: upstream cannot
190
+ * retain authority over a callee that holds the minting key. Delegating
191
+ * authority ACROSS a trust boundary needs asymmetric signatures (the verifier
192
+ * must not be able to mint), which this primitive does not implement.
193
+ */
194
+ base: CapabilityAuthorizer;
195
+ /** Clock for token-expiry checks (epoch ms). Injectable for tests; defaults to `Date.now`. */
196
+ now?: () => number;
197
+ }
198
+
199
+ /**
200
+ * K12.4 — authorizes an effect against a VERIFIED attenuated delegation carried
201
+ * in `req.principal.delegation`. The token's signature is checked first (a
202
+ * forged or tampered delegation is denied outright), then the effect must be in
203
+ * the **intersection** of the delegated grants and the callee's own authority
204
+ * (`base`) — a delegate can never invoke an effect the delegator lacked, nor one
205
+ * the callee itself lacks. No delegation present ⇒ falls through to `base`.
206
+ *
207
+ * TRUST MODEL — delegator and callee share one secret, so they are one trust
208
+ * domain. The signature stops a THIRD party (a caller, a middlebox) forging or
209
+ * tampering with a grant set in transit; it does not, and cannot, stop the
210
+ * verifier minting its own. Authority over the callee comes from `base`, never
211
+ * from the token. Cross-organisation delegation, where the verifier must not be
212
+ * able to mint, is out of scope for this primitive and needs asymmetric
213
+ * signatures. See ADR 0024.
214
+ */
215
+ export class DelegationAuthorizer implements CapabilityAuthorizer {
216
+ constructor(private readonly options: DelegationAuthorizerOptions) {
217
+ // The type alone is not the enforcement — JS callers and `as any` bypass it,
218
+ // and an absent `base` silently turns the intersection into a no-op, which
219
+ // reads as a working gate while bounding nothing.
220
+ if (options.base === undefined || options.base === null) {
221
+ throw new Error(
222
+ "DelegationAuthorizer: `base` is required — the delegation secret is symmetric, so a " +
223
+ "verified token cannot bound a callee's authority on its own (see ADR 0024)."
224
+ );
225
+ }
226
+ }
227
+
228
+ authorize(req: CapabilityRequest): CapabilityDecision {
229
+ // Defence in depth: the kernel already refuses these before any authorizer (principalBlocked).
230
+ const blocked = principalBlocked(req.principal);
231
+ if (blocked !== undefined) return { allowed: false, reason: blocked };
232
+ const token = req.principal?.delegation;
233
+ if (token === undefined) {
234
+ return this.options.base.authorize(req);
235
+ }
236
+ if (!verifyDelegation(this.options.secret, token)) {
237
+ return { allowed: false, reason: "delegation signature invalid (forged or tampered)" };
238
+ }
239
+ // Reject expired tokens (exp is signature-bound, so it can't be extended). A token
240
+ // without exp never expires — the same as before this field existed.
241
+ if (token.exp !== undefined && (this.options.now?.() ?? Date.now()) >= token.exp) {
242
+ return { allowed: false, reason: `delegation expired at ${token.exp}` };
243
+ }
244
+ // K12.4 — bind the token to its intended holder. A delegation minted for
245
+ // `issuedTo` may only be presented BY that principal (`req.principal.actor`).
246
+ // The signature proves authenticity, not that the bearer is the intended
247
+ // holder — without this check a captured/misrouted token is a confused deputy,
248
+ // usable by whoever holds it.
249
+ if (req.principal?.actor !== token.issuedTo) {
250
+ return {
251
+ allowed: false,
252
+ reason: `delegation issued to "${token.issuedTo}" but presented by "${req.principal?.actor ?? "(none)"}"`,
253
+ };
254
+ }
255
+ const delegated = new Set(token.grants);
256
+ const inDelegated = delegated.has("*") || capabilityIds(req).some((id) => delegated.has(id));
257
+ if (!inDelegated) {
258
+ return {
259
+ allowed: false,
260
+ reason: `effect "${capabilityId(req)}" not in the delegated grant set`,
261
+ };
262
+ }
263
+ // Intersection: the callee must ALSO hold the authority (no ambient escalation).
264
+ // This is what bounds a callee that could mint its own token — never the signature.
265
+ const baseDecision = this.options.base.authorize(req);
266
+ if (!baseDecision.allowed) {
267
+ return {
268
+ allowed: false,
269
+ reason: `delegated, but the callee itself lacks "${req.toolId}": ${baseDecision.reason ?? ""}`,
270
+ };
271
+ }
272
+ return { allowed: true };
273
+ }
274
+ }
275
+
276
+ // ── K12.4 — verifiable attenuated delegation (macaroon-lite) ─────────────────
277
+ //
278
+ // The evaluation's amendment: "an attenuated grant set carried as a plain
279
+ // request field is forgeable by any caller or middlebox." So a delegation is
280
+ // SIGNED (HMAC) by the delegator and VERIFIED at the callee's trust boundary
281
+ // before its grants are honored. Attenuation can only NARROW: a holder derives
282
+ // a more-restricted token, never a broader one, because the delegated grant set
283
+ // is bound into the signature. The intersection is computed from the VERIFIED
284
+ // set, never a forgeable field.
285
+
286
+ import { createHash, createHmac, timingSafeEqual } from "node:crypto";
287
+
288
+ /** A signed delegation: the grant subset a delegator conferred, HMAC-bound. */
289
+ export interface DelegatedGrant {
290
+ /** The delegated capability ids (effect ids the callee may invoke on the caller's behalf). */
291
+ grants: string[];
292
+ /** The principal this token was issued to (bound into the signature). */
293
+ issuedTo: string;
294
+ /** Optional absolute expiry (epoch ms), bound into the signature; rejected once past. */
295
+ exp?: number;
296
+ /** HMAC-SHA256 over `${issuedTo}\n${sorted grants}[\nexp=…]` with the shared delegation secret. */
297
+ sig: string;
298
+ }
299
+
300
+ // `exp` is appended only when present, so tokens without it sign the exact prior payload
301
+ // (backward-compatible), while a tampered `exp` breaks the signature.
302
+ function delegationPayload(issuedTo: string, grants: readonly string[], exp?: number): string {
303
+ const base = `${issuedTo}\n${[...grants].sort().join(",")}`;
304
+ return exp !== undefined ? `${base}\nexp=${exp}` : base;
305
+ }
306
+
307
+ /** Mint a delegation token: the delegator signs the subset it confers on `issuedTo`. */
308
+ export function signDelegation(
309
+ secret: string,
310
+ issuedTo: string,
311
+ grants: string[],
312
+ exp?: number
313
+ ): DelegatedGrant {
314
+ const sig = createHmac("sha256", secret)
315
+ .update(delegationPayload(issuedTo, grants, exp))
316
+ .digest("hex");
317
+ return { grants: [...grants], issuedTo, ...(exp !== undefined && { exp }), sig };
318
+ }
319
+
320
+ /**
321
+ * R3k (ADR 0111 D1) — a stable, NON-bearer identifier of a token, for the durable principal
322
+ * record: SHA-256 over the signature, so the log can say which delegation an execution held
323
+ * without holding anything that can be presented. The token itself is never written.
324
+ */
325
+ export function delegationDigest(token: DelegatedGrant): string {
326
+ return createHash("sha256").update(String(token.sig)).digest("hex");
327
+ }
328
+
329
+ /** Verify a delegation's signature (constant-time). A forged/tampered token fails. */
330
+ export function verifyDelegation(secret: string, token: DelegatedGrant): boolean {
331
+ const expected = createHmac("sha256", secret)
332
+ .update(delegationPayload(token.issuedTo, token.grants, token.exp))
333
+ .digest("hex");
334
+ const a = Buffer.from(expected, "hex");
335
+ const b = Buffer.from(token.sig, "hex");
336
+ return a.length === b.length && timingSafeEqual(a, b);
337
+ }
338
+
339
+ /**
340
+ * Attenuate a delegation to a narrower subset, re-signed for a new holder — only
341
+ * capabilities already in the token survive, and the parent's expiry is inherited.
342
+ * Requires the shared secret (the delegator/mint authority performs it); a
343
+ * pure-holder chained-HMAC variant is a future option.
344
+ *
345
+ * "Never broadening" is enforced on BOTH axes:
346
+ *
347
+ * - **Grants.** A capability absent from the parent cannot appear in the child. A parent
348
+ * holding `"*"` may attenuate to any concrete subset (that is narrowing) — previously
349
+ * such a subset was filtered against the literal `["*"]` array and came back EMPTY, so a
350
+ * wildcard delegator could not attenuate at all.
351
+ * - **Expiry.** The parent's `exp` is inherited, and `exp` may only be brought EARLIER.
352
+ * This was the important one: `exp` used not to be propagated, so attenuating a token
353
+ * that expired in an hour produced a child that **never expired** — narrowing the grant
354
+ * set while silently making the token immortal.
355
+ *
356
+ * Throws on an unverifiable parent. The signature is what makes the grant set trustworthy,
357
+ * and this function holds the secret — so attenuating an unchecked token would launder a
358
+ * forged one into a validly signed child. The old contract merely *documented* that the
359
+ * caller must pass a verified token; a security primitive should not depend on that.
360
+ */
361
+ export function attenuateDelegation(
362
+ secret: string,
363
+ token: DelegatedGrant,
364
+ issuedTo: string,
365
+ subset: string[],
366
+ exp?: number
367
+ ): DelegatedGrant {
368
+ if (!verifyDelegation(secret, token)) {
369
+ throw new Error(
370
+ "attenuateDelegation: parent delegation failed verification — refusing to sign a child from an unverified token"
371
+ );
372
+ }
373
+ const parentIsWildcard = token.grants.includes("*");
374
+ const narrowed = parentIsWildcard ? [...subset] : subset.filter((g) => token.grants.includes(g));
375
+ // Inherit the parent's expiry; an explicit `exp` may only tighten it.
376
+ const inherited = token.exp;
377
+ const nextExp =
378
+ inherited === undefined ? exp : exp === undefined ? inherited : Math.min(inherited, exp);
379
+ return signDelegation(secret, issuedTo, narrowed, nextExp);
380
+ }
381
+
382
+ import type { Scheduler } from "@loomwork/scheduler";
383
+ import type { Warmer } from "./types";
384
+
385
+ export interface EffectRuntimeOptions {
386
+ /** When set, every effect is authorized against it; ungranted effects are denied (K2). */
387
+ authorizer?: CapabilityAuthorizer;
388
+ /**
389
+ * When set, effects run through this scheduler (M6) — bounded concurrency + priority
390
+ * ordering across all of a runtime's effects (admission control). Omit for the
391
+ * zero-overhead direct path.
392
+ */
393
+ scheduler?: Scheduler;
394
+ /** Advisory pre-warm hook, invoked off-log before each effect (M6). */
395
+ warmer?: Warmer;
396
+ /** ADR 0119 — where a swallowed delta-sink throw is reported. Defaults to `console.warn`. */
397
+ onWarn?: (message: string) => void;
398
+ }
399
+
400
+ /**
401
+ * R16 / R17 — THE identity key: `[tenant, actor, onBehalfOf]`, one string, shared by everything that
402
+ * scopes state to "who" (a conversation's owner, a goal's owner). Two principals are the same identity
403
+ * iff all three fields are equal (an absent field equals only an absent field), so a delegate acting
404
+ * `onBehalfOf` someone is a DIFFERENT identity from that someone. A credential (a delegation token)
405
+ * is not part of identity. Keep one definition: two keys for one idea is how a scope check drifts.
406
+ */
407
+ export function identityKeyOf(i: { tenant?: string; actor: string; onBehalfOf?: string }): string {
408
+ return JSON.stringify([i.tenant ?? null, i.actor, i.onBehalfOf ?? null]);
409
+ }
410
+
411
+ /**
412
+ * The identity key of a principal, or `undefined` when it names no actor (it can own nothing) or the
413
+ * kernel marked it unusable ({@link principalBlocked}: shredded, delegation or record unresolved).
414
+ */
415
+ export function principalIdentityKey(principal: PrincipalChain | undefined): string | undefined {
416
+ if (principal === undefined || principalBlocked(principal) !== undefined) return undefined;
417
+ const actor = principal.actor;
418
+ if (typeof actor !== "string" || actor.trim() === "") return undefined;
419
+ return identityKeyOf({
420
+ actor,
421
+ ...(typeof principal.tenant === "string" && { tenant: principal.tenant }),
422
+ ...(typeof principal.onBehalfOf === "string" && { onBehalfOf: principal.onBehalfOf }),
423
+ });
424
+ }
@@ -0,0 +1,25 @@
1
+ import { SuspendError } from "@loomwork/core";
2
+ import type { Effect, EffectContext } from "../types";
3
+
4
+ export interface HumanEffectConfig {
5
+ on: string;
6
+ timeout?: number;
7
+ correlationKey?: (input: unknown) => string;
8
+ }
9
+
10
+ export class HumanEffect<TIn = unknown, TOut = unknown> implements Effect<TIn, TOut> {
11
+ readonly kind = "human" as const;
12
+
13
+ constructor(private readonly config: HumanEffectConfig) {}
14
+
15
+ execute(input: TIn, _ctx: EffectContext): Promise<TOut> {
16
+ const correlationKey =
17
+ this.config.correlationKey !== undefined ? this.config.correlationKey(input) : undefined;
18
+
19
+ throw new SuspendError({
20
+ on: this.config.on,
21
+ ...(this.config.timeout !== undefined && { timeout: this.config.timeout }),
22
+ ...(correlationKey !== undefined && { correlationKey }),
23
+ });
24
+ }
25
+ }
@@ -0,0 +1,25 @@
1
+ import type { HandlerFn, HandlerLoader, HandlerRef, HandlerSpec } from "@loomwork/core";
2
+ import type { Effect, EffectContext } from "../types";
3
+
4
+ function isHandlerRef<TIn, TOut>(spec: HandlerSpec<TIn, TOut>): spec is HandlerRef<TIn, TOut> {
5
+ return (spec as HandlerRef<TIn, TOut>)._loomHandlerRef === true;
6
+ }
7
+
8
+ export class LazyEffect<TIn = unknown, TOut = unknown> implements Effect<TIn, TOut> {
9
+ readonly kind = "tool" as const;
10
+ private resolved: HandlerFn<TIn, TOut> | undefined;
11
+
12
+ constructor(private readonly spec: HandlerSpec<TIn, TOut>) {}
13
+
14
+ async execute(input: TIn, ctx: EffectContext): Promise<TOut> {
15
+ if (!this.resolved) {
16
+ if (isHandlerRef(this.spec)) {
17
+ this.resolved = this.spec.fn as HandlerFn<TIn, TOut>;
18
+ } else {
19
+ const mod = await (this.spec as HandlerLoader<TIn, TOut>)();
20
+ this.resolved = mod.default;
21
+ }
22
+ }
23
+ return this.resolved(input, ctx);
24
+ }
25
+ }
@@ -0,0 +1,39 @@
1
+ import type { TaintedValue } from "@loomwork/classify";
2
+ import type { Effect, EffectContext, EffectDelta, EffectReasoningHook } from "../types";
3
+
4
+ export interface LlmEffectOptions {
5
+ /**
6
+ * Advisory pre-warm — invoked by the kernel ahead of use to keep the
7
+ * provider's connection/model cache hot (see {@link Effect.warm}). Wired by
8
+ * `createLlmEffect` to the provider's `warm()`. Latency-only.
9
+ */
10
+ warm?: () => void | Promise<void>;
11
+ /** ADR 0119 — the configured delta sink the runtime guards (see `Effect.deltaSink`). */
12
+ deltaSink?: (chunk: EffectDelta, ctx: EffectContext) => void;
13
+ /** ADR 0133 D6 - the configured reasoning sink the runtime guards (see `Effect.reasoningSink`). */
14
+ reasoningSink?: (delta: TaintedValue<string>, ctx: EffectContext) => void;
15
+ /** ADR 0133 D7 - what the composition root may ask about the effect's reasoning posture. */
16
+ reasoning?: EffectReasoningHook;
17
+ }
18
+
19
+ export class LlmEffect<TIn = unknown, TOut = unknown> implements Effect<TIn, TOut> {
20
+ readonly kind = "llm" as const;
21
+ readonly warm?: () => void | Promise<void>;
22
+ readonly deltaSink?: (chunk: EffectDelta, ctx: EffectContext) => void;
23
+ readonly reasoningSink?: (delta: TaintedValue<string>, ctx: EffectContext) => void;
24
+ readonly reasoning?: EffectReasoningHook;
25
+
26
+ constructor(
27
+ private readonly fn: (input: TIn, ctx: EffectContext) => Promise<TOut>,
28
+ options: LlmEffectOptions = {}
29
+ ) {
30
+ if (options.warm !== undefined) this.warm = options.warm;
31
+ if (options.deltaSink !== undefined) this.deltaSink = options.deltaSink;
32
+ if (options.reasoningSink !== undefined) this.reasoningSink = options.reasoningSink;
33
+ if (options.reasoning !== undefined) this.reasoning = options.reasoning;
34
+ }
35
+
36
+ execute(input: TIn, ctx: EffectContext): Promise<TOut> {
37
+ return this.fn(input, ctx);
38
+ }
39
+ }
@@ -0,0 +1,59 @@
1
+ import type { Effect, EffectContext, EffectMode, ErrorChannelSchema } from "../types";
2
+
3
+ export interface ToolEffectOptions {
4
+ /**
5
+ * K9.3 — declared delivery semantic for this tool (see {@link Effect.idempotent}).
6
+ * `false` = mutating & non-idempotent: recovery parks an unknown-state attempt at
7
+ * EFFECT_INDOUBT instead of silently re-running it. Omit for the legacy
8
+ * at-least-once re-run.
9
+ */
10
+ idempotent?: boolean;
11
+ /**
12
+ * DE-4 — declared failure channels for this tool (see {@link Effect.errors}).
13
+ * Pass the same map declared on the `tool$` contract so the kernel's registry
14
+ * fallback sees it. Omit for legacy untyped failure semantics.
15
+ */
16
+ errors?: Record<string, ErrorChannelSchema>;
17
+ /**
18
+ * ADR 0073 — which invocation seam this tool belongs to. Pass the same value
19
+ * declared on the `tool$` contract; this registered copy is the enforced one.
20
+ * Omit to keep today's behaviour on both seams.
21
+ */
22
+ mode?: EffectMode;
23
+ /**
24
+ * R12 — the sub-resource a call targets, for per-resource authorization (see
25
+ * {@link Effect.resourceOf}). Omit for effect-wide authorization only.
26
+ */
27
+ // biome-ignore lint/suspicious/noExplicitAny: the option is not generic; the effect's TIn types the call site
28
+ resourceOf?: (input: any) => string | undefined;
29
+ }
30
+
31
+ export class ToolEffect<TIn = unknown, TOut = unknown> implements Effect<TIn, TOut> {
32
+ readonly kind = "tool" as const;
33
+ readonly idempotent?: boolean;
34
+ readonly errors?: Record<string, ErrorChannelSchema>;
35
+ readonly mode?: EffectMode;
36
+ readonly resourceOf?: (input: TIn) => string | undefined;
37
+
38
+ constructor(
39
+ private readonly fn: (input: TIn, ctx: EffectContext) => Promise<TOut>,
40
+ options: ToolEffectOptions = {}
41
+ ) {
42
+ if (options.idempotent !== undefined) {
43
+ this.idempotent = options.idempotent;
44
+ }
45
+ if (options.errors !== undefined) {
46
+ this.errors = options.errors;
47
+ }
48
+ if (options.mode !== undefined) {
49
+ this.mode = options.mode;
50
+ }
51
+ if (options.resourceOf !== undefined) {
52
+ this.resourceOf = options.resourceOf;
53
+ }
54
+ }
55
+
56
+ execute(input: TIn, ctx: EffectContext): Promise<TOut> {
57
+ return this.fn(input, ctx);
58
+ }
59
+ }
package/src/index.ts ADDED
@@ -0,0 +1,48 @@
1
+ export { EffectRuntime } from "./EffectRuntime";
2
+ export { LazyEffect } from "./effects/LazyEffect";
3
+ export { ToolEffect, type ToolEffectOptions } from "./effects/ToolEffect";
4
+ export { LlmEffect, type LlmEffectOptions } from "./effects/LlmEffect";
5
+ export { HumanEffect } from "./effects/HumanEffect";
6
+ export { EffectModeViolationError } from "./types";
7
+ export type {
8
+ DeltaSink,
9
+ Effect,
10
+ EffectDelta,
11
+ EffectContext,
12
+ EffectKind,
13
+ EffectMiddleware,
14
+ EffectReasoningDefaults,
15
+ EffectReasoningHook,
16
+ EffectMode,
17
+ EffectNext,
18
+ ErrorChannelSchema,
19
+ ResourceSnapshot,
20
+ TransientIdentity,
21
+ TransientInvokeOptions,
22
+ Warmer,
23
+ } from "./types";
24
+ export {
25
+ CapabilityDeniedError,
26
+ capabilityIds,
27
+ GrantTable,
28
+ DelegationAuthorizer,
29
+ signDelegation,
30
+ verifyDelegation,
31
+ attenuateDelegation,
32
+ delegationDigest,
33
+ principalBlocked,
34
+ principalIdentityKey,
35
+ identityKeyOf,
36
+ } from "./authz";
37
+ export { PolicyAuthorizer } from "./policy";
38
+ export type { PolicyRules, PolicyMatchRule, PolicyAuthorizerOptions } from "./policy";
39
+ export type {
40
+ CapabilityAuthorizer,
41
+ CapabilityDecision,
42
+ CapabilityRequest,
43
+ DelegatedGrant,
44
+ DelegationAuthorizerOptions,
45
+ EffectRuntimeOptions,
46
+ GrantTableOptions,
47
+ PrincipalChain,
48
+ } from "./authz";