# Effect stays at the server edges

The repository's architectural decision, rationale and consequences.

# Effect stays at the server edges

Effect 4 entered the template as a local utility: fourteen server files, each running its own `Effect.run*`, with spans nobody exported. The cron handler fanned out five jobs behind a shared `AbortSignal`, and every job noticed cancellation on its own. Three copies of a `Retry-After` parser had two different caps, and no transient failure was retried inside a round. The question was how far to take Effect: keep it local, coordinate the edges, or make the backend Effect-native.

## Decision [#decision]

**Edges only (depth N1).** Effect runs where the server executes work it owns end to end: the scheduled jobs and the outbound transports (webhook POST, Expo push, RevenueCat, email), plus the diagnostics probe that already used it. Public contracts stay Zod, requests stay oRPC, authentication stays Better Auth, database access stays Drizzle. oRPC handlers do not become Effect (`@orpc/experimental-effect` is beta on a beta), and no Context.Service/Layer refactor happens without a new ADR.

**Server only.** No client tree imports `effect`. `apps/web/scripts/assert-client-bundle-clean.mjs` fails the build if `effect/Effect` reaches a client asset, and ADR-0002's lint keeps server modules out of client imports.

**Stable APIs only.** Use modules and functions marked `@stability stable` in the installed package (`Effect`, `Queue`, `Stream`, `Schedule`, `Cause`, `Exit`, `Tracer`, `Duration`, `Option`, `Result`, `Schema.TaggedError`, `effect/testing` `TestClock`). Since 4.0.2 an `unstable` module may break in a minor release; adopting one (`http`, `rpc`, `workflow`, `cluster`, `persistence`, `observability` OTLP…) needs its own ADR. [ADR-0006](0006-effect-persisted-queue.md) restricts native SQL/persistence to a workerd spike; production imports use the stable modules and Drizzle.

**One `run*` per entry point.** The cron tick is one program, `scheduledJobs` in `packages/api/src/jobs/run.ts`, run once by `runScheduledJobs` from `scheduled()`. Its jobs return `Effect` and never run themselves. A transport exposes its Effect (`webhookAttempt`, `sendExpoPushEffect`, `verifyRevenueCatEffect`, `sendEmailEffect`) and a thin Promise facade for callers outside Effect (oRPC routes, Better Auth, tests); the facade is the only `run*` on that path and goes through `runEdge` (`packages/api/src/lib/edge.ts`), which maps interruption to the transport's `cancelled` outcome.

**Typed errors are converted once, at the edge.** Inside a program failures are `Schema.TaggedError`s. They become the transport's result union, a persisted row or an `ORPCError` in one `catchTags` where the program meets the outside, not in every helper. Interruption is never caught by hand (`Cause.hasInterrupts` lives in `runEdge` only). A rejection that must not become a defect uses `tryPromise`, or `drained` when the promise owns database state: `drained` aborts the signal and waits for the promise to settle, so a scope never closes the pool under a running transaction.

**One deadline, one pool, independent jobs.** `scheduledJobs` acquires the job pool with `acquireRelease` in the program scope, runs the six jobs with `Effect.all({ mode: "result" })` (a typed failure or a defect in one never interrupts the others), then runs document indexing after those jobs settle, and applies one `Effect.timeoutOption` of 25 s to the whole tick. Documents have their own 15 s budget, bounded by the remaining tick deadline. [ADR-0008](0008-documents-and-large-uploads.md) records the memory gate and ordering. The deadline interrupts every job; interruption reaches in-flight fetches through the signal `tryPromise`/`drained` pass on; the pool closes last, on success, failure or interruption. Imports reserve cleanup time with a 15 s work budget. The job pool bounds lost query responses with a 2 s client query timeout; the [fault measurement](../measurements/2026-10-13-persisted-queue-spike.md) explains why a SQL statement timeout alone was insufficient.

**Retries only for idempotent reads.** `idempotentRead` (`packages/api/src/lib/retry.ts`) is exponential backoff with jitter, two retries inside a budget, honouring a `Retry-After` up to 2 s and handing a longer one back to the persistent policy. It applies to the RevenueCat subscriber GET, Expo `getReceipts` and import source GETs. A webhook POST and an email send are never retried in-round: a repeated write can duplicate a side effect, so they make one attempt and the outbox/lease policy decides. `retryAfterMs` is the one `Retry-After` parser, capped at 24 h.

**Zod for third-party payloads.** Provider responses (RevenueCat, Expo) are validated with Zod, the same library as the contracts; a parse failure enters the error channel through `Effect.try`. Effect Schema is used for `TaggedError` only.

**No I/O in a module-level runtime.** On Workers a socket belongs to the request that opened it; a `ManagedRuntime` or `Layer` built at module scope that opens a pool fails on the next request with "Cannot perform I/O on behalf of a different request". A module-level runtime may only hold pure services (a tracer, validated config). Anything with I/O is created per invocation, as the job pool is.

**Native tracing.** Effect spans go to Cloudflare's own tracing, not to an OTLP exporter. `workersTracer` (`packages/api/src/lib/tracer.ts`) opens a `tracing.startSpan` per Effect span, copies scalar attributes, records failures and ends it; `runScheduledJobs` runs the tick inside `tracing.enterSpan("jobs.scheduled")`. The tracing API comes from `ctx.tracing` in the handler, typed structurally, so no module imports `cloudflare:workers` and the code runs untraced in Node (dev, vitest, `jobs:run`). `observability.traces.enabled` is on in `apps/web/wrangler.jsonc`.

## Consequences [#consequences]

- Cloudflare's API has no explicit parent and `startSpan` never becomes active, so the hierarchy is flat: every Effect span is a sibling under `jobs.scheduled`, next to the platform's fetch and Hyperdrive spans. There is no `setStatus` yet; failures show as recorded exceptions and interruptions as `effect.interrupted = true`.
- Timeout tests use `@effect/vitest` and `TestClock` with the production values (10 s per transport, 25 s per tick) instead of waiting a few real milliseconds.
- When the deadline hits, the tick reports `interrupted: true` with empty counters; the work itself is persisted row by row, and the job spans carry what each job did.
- The cost is a server chunk evaluated at every cold start; `docs/measurements/2026-10-12-effect-edges.md` records its size. Splitting it with a dynamic `import()` is against the repo rules and was not done.
- Going further (Effect-native handlers, services per request) needs a new ADR and one of these triggers: oRPC 2 and `@orpc/experimental-effect` stable; several handlers composing I/O with retry and timeout; or a startup measurement showing the chunk is free.

