|
| 1 | +# AGENTS.md — Effection agent contract |
| 2 | + |
| 3 | +This file is the behavioral contract for AI agents working with the Effection |
| 4 | +codebase. |
| 5 | + |
| 6 | +Agents must not invent APIs, must not infer semantics from other ecosystems, and |
| 7 | +must ground claims in the public API and repository code. |
| 8 | + |
| 9 | +If you are unsure whether something exists, consult the API reference: |
| 10 | +https://frontside.com/effection/api/ |
| 11 | + |
| 12 | +## Core invariants (do not violate) |
| 13 | + |
| 14 | +### Operations vs Promises |
| 15 | + |
| 16 | +- **Operations** are lazy. They execute only when interpreted (e.g. `yield*`, |
| 17 | + `run()`, `Scope.run()`, `spawn()`). |
| 18 | +- **Promises** are eager. Creating a promise (or calling an `async` function) |
| 19 | + starts work; `await` only observes completion. |
| 20 | +- You must not claim that a promise is "inert until awaited". |
| 21 | +- You must not use `await` inside a generator function (`function*`). Use |
| 22 | + `yield*` with an operation instead (e.g. `yield* until(promise)`). |
| 23 | + |
| 24 | +### Structured concurrency is scope-owned |
| 25 | + |
| 26 | +- Scope hierarchy is created automatically by the interpreter; application code |
| 27 | + should not manage scopes manually. |
| 28 | +- "Lexical" in Effection: scope hierarchy follows the lexical structure of |
| 29 | + operation invocation sites (e.g. `yield*`, `spawn`, `Scope.run`), not where |
| 30 | + references are stored or later used. |
| 31 | +- Work is owned by **Scopes**. |
| 32 | +- When a scope exits, all work created in that scope is halted. |
| 33 | +- References do not extend lifetimes. Returning a `Task`, `Scope`, `Stream`, or |
| 34 | + `AbortSignal` does not keep it alive. |
| 35 | + |
| 36 | +### Effects do not escape scopes |
| 37 | + |
| 38 | +- Values may escape scopes. |
| 39 | +- Ongoing effects must not escape: tasks, resources, streams/subscriptions, and |
| 40 | + context mutations must remain scope-bound. |
| 41 | + |
| 42 | +## Operations, Futures, Tasks |
| 43 | + |
| 44 | +### Operation |
| 45 | + |
| 46 | +- An `Operation<T>` is a recipe for work. It does nothing by itself. |
| 47 | +- Operations are typically created by invoking a generator function |
| 48 | + (`function*`). |
| 49 | + |
| 50 | +### Future |
| 51 | + |
| 52 | +- A `Future<T>` is both: |
| 53 | + - an Effection operation (`yield* future`) |
| 54 | + - a Promise (`await future`) |
| 55 | + |
| 56 | +### Task |
| 57 | + |
| 58 | +- A `Task<T>` is a `Future<T>` representing a concurrently running operation. |
| 59 | +- A task does not own lifetime or context; its scope does. |
| 60 | + |
| 61 | +## Entry points and scope creation |
| 62 | + |
| 63 | +### `main()` |
| 64 | + |
| 65 | +- You should prefer `main()` when writing an entire program in Effection. |
| 66 | +- Inside `main()`, prefer `yield* exit(status, message?)` for termination; do |
| 67 | + not call `process.exit()` / `Deno.exit()` directly (it bypasses orderly |
| 68 | + shutdown). |
| 69 | + |
| 70 | +### `exit()` |
| 71 | + |
| 72 | +- `exit()` is an operation intended to be used from within `main()` to initiate |
| 73 | + shutdown. |
| 74 | + |
| 75 | +### `run()` |
| 76 | + |
| 77 | +- You may use `run()` to embed Effection into existing async code. |
| 78 | +- `run()` starts execution immediately; awaiting the returned task only observes |
| 79 | + completion. |
| 80 | + |
| 81 | +### `createScope()` |
| 82 | + |
| 83 | +- You must not use `createScope()` for normal Effection application code. |
| 84 | +- You may use `createScope()` only for **integration** between Effection and |
| 85 | + non-Effection lifecycle management (frameworks/hosts/embedders). |
| 86 | +- You must observe `destroy()` (`await` / `yield*`) to complete teardown. |
| 87 | + Calling `destroy()` without observation does not guarantee shutdown |
| 88 | + completion. |
| 89 | + |
| 90 | +### `useScope()` |
| 91 | + |
| 92 | +- Use `yield* useScope()` to capture the current `Scope` for integration (e.g. |
| 93 | + callbacks) and re-enter Effection with `scope.run(() => operation)`. |
| 94 | + |
| 95 | +## `spawn()` |
| 96 | + |
| 97 | +**Shape (canonical)** |
| 98 | + |
| 99 | +```ts |
| 100 | +const op = spawn(myOperation); // returns an OPERATION |
| 101 | +const task = yield * op; // returns a TASK (Future) and starts it |
| 102 | +``` |
| 103 | + |
| 104 | +**Rules** |
| 105 | + |
| 106 | +- `spawn()` does not start work by itself. Yielding the spawn operation starts |
| 107 | + work. |
| 108 | +- A spawned task must not outlive its parent scope. |
| 109 | + |
| 110 | +## `Task.halt()` |
| 111 | + |
| 112 | +**Rules** |
| 113 | + |
| 114 | +- `task.halt()` returns a `Future<void>`. You must observe it (`await` / |
| 115 | + `yield*` / `.then()`), or shutdown is not guaranteed to complete. |
| 116 | +- `halt()` represents teardown. It can succeed even if the task failed. |
| 117 | +- If a task is halted before completion, consuming its value (`yield* task` / |
| 118 | + `await task`) fails with `Error("halted")`. |
| 119 | + |
| 120 | +## Scope vs Task (ownership) |
| 121 | + |
| 122 | +| Concept | Owns lifetime | Owns context | |
| 123 | +| ------- | ------------: | -----------: | |
| 124 | +| `Scope` | ✅ | ✅ | |
| 125 | +| `Task` | ❌ | ❌ | |
| 126 | + |
| 127 | +## Context API (strict) |
| 128 | + |
| 129 | +**Valid APIs** |
| 130 | + |
| 131 | +- `createContext<T>(name, defaultValue?)` |
| 132 | +- `yield* Context.get()` |
| 133 | +- `yield* Context.expect()` |
| 134 | +- `yield* Context.set(value)` |
| 135 | +- `yield* Context.delete()` |
| 136 | +- `yield* Context.with(value, operation)` |
| 137 | + |
| 138 | +**Rules** |
| 139 | + |
| 140 | +- You must treat context as scope-local. Children inherit from parents; children |
| 141 | + may override without mutating ancestors. |
| 142 | +- You must not treat context as global mutable state. |
| 143 | + |
| 144 | +## `race()` |
| 145 | + |
| 146 | +**Rules** |
| 147 | + |
| 148 | +- `race()` accepts an array of operations. |
| 149 | +- It returns the value of the first operation to complete. |
| 150 | +- It halts all losing operations. |
| 151 | + |
| 152 | +## `all()` |
| 153 | + |
| 154 | +**Rules** |
| 155 | + |
| 156 | +- `all()` accepts an array of operations and evaluates them concurrently. |
| 157 | +- It returns an array of results in input order. |
| 158 | +- If any member errors, `all()` errors and halts the other members. |
| 159 | +- If you need "all operations either complete or error" (no fail-fast), wrap |
| 160 | + each member to return a railway-style result (e.g. `{ ok: true, value }` / |
| 161 | + `{ ok: false, error }`) instead of letting errors escape. |
| 162 | + |
| 163 | +## `call()` |
| 164 | + |
| 165 | +**Rules** |
| 166 | + |
| 167 | +- `call()` invokes a function that returns a value, promise, or operation. |
| 168 | +- `call()` does not create a scope boundary and does not delimit concurrency. |
| 169 | +- If you need to report failures without throwing (e.g. so other work can |
| 170 | + continue), catch errors and return a railway-style result object instead of |
| 171 | + letting the error escape. |
| 172 | + |
| 173 | +## `lift()` |
| 174 | + |
| 175 | +**Rules** |
| 176 | + |
| 177 | +- `lift(fn)` returns a function that produces an `Operation` which calls `fn` |
| 178 | + when interpreted (`yield*`), not when created. |
| 179 | + |
| 180 | +## `action()` |
| 181 | + |
| 182 | +**Rules** |
| 183 | + |
| 184 | +- Use `action()` to wrap callback-style APIs when you can provide a cleanup |
| 185 | + function. |
| 186 | +- You must not claim `action()` creates an error or concurrency boundary; it |
| 187 | + does not. |
| 188 | + |
| 189 | +## `until()` |
| 190 | + |
| 191 | +**Rules** |
| 192 | + |
| 193 | +- `until(promise)` adapts an already-created `Promise` into an `Operation`. |
| 194 | +- Prefer `until(promise)` over `call(() => promise)` when you have a promise—it |
| 195 | + is shorter and clearer. |
| 196 | +- It does not make the promise cancellable; for cancellable interop, prefer |
| 197 | + `useAbortSignal()` with APIs that accept `AbortSignal`. |
| 198 | + |
| 199 | +## `scoped()` |
| 200 | + |
| 201 | +**Rules** |
| 202 | + |
| 203 | +- Use `scoped()` to create a boundary such that effects created inside do not |
| 204 | + persist after it returns. |
| 205 | +- You must use `scoped()` (not `call()`/`action()`) when you need boundary |
| 206 | + semantics. |
| 207 | + |
| 208 | +## `resource()` |
| 209 | + |
| 210 | +**Shape (ordering matters)** |
| 211 | + |
| 212 | +```ts |
| 213 | +resource(function* (provide) { |
| 214 | + try { |
| 215 | + yield* provide(value); |
| 216 | + } finally { |
| 217 | + cleanup(); |
| 218 | + } |
| 219 | +}); |
| 220 | +``` |
| 221 | + |
| 222 | +**Rules** |
| 223 | + |
| 224 | +- Setup happens before `provide()`. |
| 225 | +- Cleanup must be in `finally` (or after `provide()` guarded by `finally`) so it |
| 226 | + runs on return/error/halt. |
| 227 | +- Teardown can be asynchronous. If cleanup needs async work, express it as an |
| 228 | + `Operation` and `yield*` it inside `finally` (wait for teardown to finish)—do |
| 229 | + not fire-and-forget cleanup. |
| 230 | + |
| 231 | +## `ensure()` |
| 232 | + |
| 233 | +**Rules** |
| 234 | + |
| 235 | +- `ensure(fn)` registers cleanup to run when the current operation shuts down. |
| 236 | +- `fn` may return `void` (sync cleanup) or an `Operation` (async cleanup). |
| 237 | +- You should wrap sync cleanup bodies in braces so the function returns `void`. |
| 238 | + |
| 239 | +## `useAbortSignal()` |
| 240 | + |
| 241 | +**Rules** |
| 242 | + |
| 243 | +- `useAbortSignal()` is an interop escape hatch for non-Effection APIs that |
| 244 | + accept `AbortSignal`. |
| 245 | +- The returned signal is bound to the current scope and aborts when that scope |
| 246 | + exits (return, error, or halt). |
| 247 | +- You should pass the signal to a **leaf** async API call, not thread it through |
| 248 | + a nested async stack. |
| 249 | +- If the choice is "thread an AbortSignal through a nested async stack" vs |
| 250 | + "rewrite in Effection", you should prefer rewriting in Effection. |
| 251 | + |
| 252 | +**Gotchas** |
| 253 | + |
| 254 | +- You must not assume AbortController provides structured-concurrency |
| 255 | + guarantees. See: |
| 256 | + https://frontside.com/blog/2025-08-04-the-heartbreaking-inadequacy-of-abort-controller/ |
| 257 | + |
| 258 | +## Streams, Subscriptions, Channels, Signals, Queues |
| 259 | + |
| 260 | +### Stream and Subscription |
| 261 | + |
| 262 | +- A `Stream<T, TClose>` is an operation that yields a `Subscription<T, TClose>`. |
| 263 | +- A `Subscription` is stateful; values are observed via |
| 264 | + `yield* subscription.next()`. |
| 265 | + |
| 266 | +### `on(target, name)` and `once(target, name)` (EventTarget adapters) |
| 267 | + |
| 268 | +**Rules** |
| 269 | + |
| 270 | +- `on()` creates a `Stream` of events from an `EventTarget`; listeners are |
| 271 | + removed on scope exit. |
| 272 | +- `once()` yields the next matching event as an `Operation` (it is equivalent to |
| 273 | + subscribing to `on()` and taking one value). |
| 274 | + |
| 275 | +### `sleep()`, `interval()`, `suspend()` |
| 276 | + |
| 277 | +**Rules** |
| 278 | + |
| 279 | +- `sleep(ms)` is cancellable: if the surrounding scope exits, the timer is |
| 280 | + cleared. |
| 281 | +- `interval(ms)` is a `Stream` that ticks until the surrounding scope exits |
| 282 | + (cleanup clears the interval). |
| 283 | +- `suspend()` pauses indefinitely and only resumes when its enclosing scope is |
| 284 | + destroyed. |
| 285 | + |
| 286 | +### `each(stream)` (loop consumption) |
| 287 | + |
| 288 | +**Rules** |
| 289 | + |
| 290 | +- You must call `yield* each.next()` exactly once at the end of every loop |
| 291 | + iteration. |
| 292 | +- You must call `yield* each.next()` even if the iteration ends with `continue`. |
| 293 | + |
| 294 | +**Gotchas** |
| 295 | + |
| 296 | +- If you do not call `each.next()`, the loop throws `IterationError` on the next |
| 297 | + iteration. |
| 298 | + |
| 299 | +**Shape (ordering matters)** |
| 300 | + |
| 301 | +```ts |
| 302 | +for (let value of yield * each(stream)) { |
| 303 | + // ... |
| 304 | + yield * each.next(); |
| 305 | +} |
| 306 | +``` |
| 307 | + |
| 308 | +### Channel vs Signal vs Queue |
| 309 | + |
| 310 | +| Concept | Send from | Send API | Requires subscribers | Buffering | |
| 311 | +| --------- | ------------------------------ | ------------------------- | ----------------------- | ------------------------------- | |
| 312 | +| `Channel` | inside operations | `send(): Operation<void>` | yes (otherwise dropped) | per-subscriber while subscribed | |
| 313 | +| `Signal` | outside operations (callbacks) | `send(): void` | yes (otherwise no-op) | per-subscriber while subscribed | |
| 314 | +| `Queue` | anywhere (single consumer) | `add(): void` | no | buffered (single subscription) | |
| 315 | + |
| 316 | +### `Channel` |
| 317 | + |
| 318 | +**Rules** |
| 319 | + |
| 320 | +- Use `createChannel()` to construct a `Channel`. |
| 321 | +- Use `Channel` for communication between operations. |
| 322 | +- You must `yield* channel.send(...)` / `yield* channel.close(...)`. |
| 323 | +- You must assume sends are dropped when there are no active subscribers. |
| 324 | + |
| 325 | +### `Signal` |
| 326 | + |
| 327 | +**Rules** |
| 328 | + |
| 329 | +- Use `createSignal()` to construct a `Signal`. |
| 330 | +- Use `Signal` only as a bridge from synchronous callbacks into an Effection |
| 331 | + stream. |
| 332 | +- You must not use `Signal` for in-operation messaging; use `Channel` instead. |
| 333 | +- You must assume `signal.send(...)` is a no-op if nothing is subscribed. |
| 334 | + |
| 335 | +### `Queue` |
| 336 | + |
| 337 | +**Rules** |
| 338 | + |
| 339 | +- Use `createQueue()` to construct a `Queue`. |
| 340 | +- You may use `Queue` when you need buffering independent of subscriber timing |
| 341 | + (single consumer). |
| 342 | +- You must consume via `yield* queue.next()`. |
| 343 | + |
| 344 | +## `subscribe()` and `stream()` (async iterable adapters) |
| 345 | + |
| 346 | +**Rules** |
| 347 | + |
| 348 | +- Use `subscribe(asyncIterator)` to adapt an `AsyncIterator` to an Effection |
| 349 | + `Subscription`. |
| 350 | +- Use `stream(asyncIterable)` to adapt an `AsyncIterable` to an Effection |
| 351 | + `Stream`. |
| 352 | +- You must not treat JavaScript async iterables as Effection streams without |
| 353 | + wrapping. |
| 354 | +- You must not use `for await` inside a generator function. Use `stream()` to |
| 355 | + adapt the async iterable, then `each()` to iterate. |
| 356 | + |
| 357 | +**Shape (async iterable consumption)** |
| 358 | + |
| 359 | +```ts |
| 360 | +for (const item of yield * each(stream(asyncIterable))) { |
| 361 | + // ... |
| 362 | + yield * each.next(); |
| 363 | +} |
| 364 | +``` |
| 365 | + |
| 366 | +## `withResolvers()` |
| 367 | + |
| 368 | +**Rules** |
| 369 | + |
| 370 | +- `withResolvers()` creates an `operation` plus synchronous `resolve(value)` / |
| 371 | + `reject(error)` functions. |
| 372 | +- After resolve/reject, yielding the `operation` always produces the same |
| 373 | + outcome; calling resolve/reject again has no effect. |
0 commit comments