# Procedures run one Effect program at their edge

The repository's architectural decision, rationale and consequences.

# Procedures run one Effect program at their edge

**Status:** accepted · **Date:** 2026-10-17 · **Amends:** [ADR-0005](0005-effect-boundary.md) · **Amended:** 2026-10-21 (the `effectProcedure` builder and the honest Context; two tiers, one edge; see the amendments)

**Enforcement:** the lint override for migrated features refuses `@orpc/*` in `*.service.ts`; the exhaustive `switch` in each `<feature>.errors.ts` fails `tsc` when a tag is unmapped; characterization tests pin the client-visible code and message of every pilot failure; `apps/web/scripts/assert-client-bundle-clean.mjs` keeps Effect out of client assets.

[ADR-0005](0005-effect-boundary.md) kept Effect at the server edges (jobs and outbound transports) and said oRPC handlers do not become Effect. Two problems remained in the request path. Service modules threw raw `new ORPCError(...)` (twelve of them in the document submission service alone), so the domain imported the transport and the set of failures an operation could produce was not a type. And services that do external I/O, such as signing and checking storage objects for Document Text, had no shared way to add a timeout or an idempotent retry. ADR-0005 named the trigger for going further: "several handlers composing I/O with retry and timeout". Documents now meets it, and uploads and billing checkout follow the same shape.

## Decision [#decision]

**A migrated procedure runs one Effect program, once, at its edge.** The handler stays an ordinary oRPC Promise handler underneath. A feature builds its procedures with one builder, and each handler is the program:

```ts
const documentsProcedure = effectProcedure(orgProcedure, documentRpcError);

source: documentsProcedure
  .input(documentIdentityInput)
  .handler(({ db, organizationId }, input) => documentSource(db, organizationId, input.fileId)),
```

`effectProcedure` (`packages/api/src/platform/rpc/effect-procedure.ts`) forwards `input`, `output` and `use` to oRPC with oRPC's own types, and its `handler` is the one place that runs the program: it calls `runProcedure` (`packages/api/src/platform/rpc/procedure-edge.ts`) once with the feature's mapper, the call's `signal`, the request id and the request's Workers tracing. `runProcedure` is the only `run*` on that path. It is built on `Effect.runPromiseExit`, like `runEdge`. A success returns the value. A typed failure is converted once by the feature's mapper into an `ORPCError` with an `APP_ERRORS` code. A defect is rethrown, so the existing server error handler logs it and answers 500. A request whose `signal` is already aborted rejects with its reason before the program starts; once the program runs, the client's abort is not wired into it, so it finishes and its result or error is returned (see below). Callers outside oRPC that reuse a service (MCP, the agent tool catalog) go through the same function, so they receive the same `ORPCError` they receive today.

**Domain failures are `Schema.TaggedError`s in `<feature>.errors.ts`.** Services never import `@orpc/*`. Each feature lists its failures once (`TODO_ERRORS`, `DOCUMENT_ERRORS`); the union is derived from that list and the type guard used by `transaction` reads it. Each feature exports one mapper: a `switch` on `_tag` that ends in `satisfies never`, so an unmapped failure is a compile error, and a failure missing from the list cannot be mapped either. A pilot keeps the code and message the client sees today. No error code, i18n key or client resolver changes, and `packages/errors` stays the client-safe Zod registry.

**Services are `Effect.fn` programs that receive their dependencies as arguments.** The request database (`context.db`, already `app_tenant`, [ADR-0009](0009-row-level-security.md)), the Organization ID and, where a service reaches the bucket, the request's storage handles (`context.storage`, `context.direct`) are plain parameters, as they were for the Promise services. Nothing constructs a dependency inside a service or as a default parameter. There is no `Context.Service`, no `Layer` and no `ManagedRuntime`, per request or at module scope. Database work goes through two helpers in `packages/api/src/platform/effect/db.ts`:

- `query` wraps `drained` and turns a SQL failure into a defect, which is today's 500.
- `transaction` runs `db.transaction` inside `drained`. Inside the callback, which is still a Promise, the body throws tagged errors (they are `Error`s). The helper returns the ones the feature recognises as typed failures and turns everything else into a defect.

An interruption from inside the program (a timeout, a deadline) therefore waits for a running transaction to settle instead of abandoning it. **A client that stops waiting cannot undo a commit.** Audited procedures run inside the transaction `withAudit` opens around the handler, so a handler that rejected after the client left would roll back the write and its audit together; that is why `runProcedure` does not interrupt a started program on the client's abort. The response simply goes unread, as before Effect. `todo.abort.test.ts` and `documents.abort.test.ts` block a write on a lock, abort the client mid-statement and check that the write (and its audit) committed. Transaction boundaries and lock order do not change.

**External I/O inside a service** uses the stable modules ADR-0005 already allows: a timeout, and `idempotentRead` only for idempotent reads. A storage `PUT`, a provider `POST` or an email send is never retried in-round.

**What stays as it is.**

- Zod contracts, oRPC routing and middlewares, Better Auth, the Drizzle schema and queries (wrapped, not rewritten), `packages/errors`, web and mobile.
- `@orpc/experimental-effect` stays out.
- Effect Schema is not used for public contracts.
- The cron tick remains one program with one `runEdge`.
- Only `@stability stable` APIs are used.

**Scope.** `todo` and `documents` are the pilots. Other features keep their Promise services until someone changes them, then follow the `domain-feature` skill. `webhooks` (the User's Endpoint and the test delivery) and `integrations` (OAuth connections, agent activity) followed on 2026-10-21; their procedures build on `protectedProcedure`, since an Endpoint belongs to a User, and `integrations.service.ts` receives `systemDb` as an argument for the Better Auth tables it reads through `asSystem`. (Superseded by the two-tier amendment below: every feature with procedures now has a service and an errors file; only the program style differs.)

**Logging and correlation.** `runProcedure` annotates the program with the request ID (`Effect.annotateLogs`). A typed failure mapped to a 4xx is not logged as an error. A defect is logged once by the server error handler, with the request ID. Services do not log their own failures.

## Measurement and abort criterion [#measurement-and-abort-criterion]

Measured on 2026-10-17 on one shared machine, `main` (`cd1dbb7`) against the final branch, which also contains the request ID, the probe flag through the env schema, `requireOrgRole` and the 400 fix. Size and startup use the methods of `docs/measurements/2026-10-12-effect-edges.md`; latency runs `createRouterClient(appRouter)` in process against a disposable local PostgreSQL (after the independent review: 18 paired series alternating the two worktrees, 100 warm-up and 1,000 measured calls each, plus an 18-series A/A run of `main` against itself; the verdict is the true median of the paired deltas, computed by `scripts/measure-procedure-latency.mjs`). The numbers, commands, the earlier pilot round and the raw results are in [the measurement report](../measurements/2026-10-17-backend-structure.md).

| Measure                                                    | Budget              |      `main` |      Branch | Result                                                                                                   |
| ---------------------------------------------------------- | ------------------- | ----------: | ----------: | -------------------------------------------------------------------------------------------------------- |
| Server modules, gzip sum (`measure-worker-build --runs 3`) | +≤ 10 KiB           | 5,275,576 B | 5,347,497 B | +71,920 B: about 7,017 B of code (+6.9 KiB) and 64,903 B of documentation pages compiled into the Worker |
| Effect modules in the graph, server / client               | client 0            |     218 / 0 |     218 / 0 | no Effect in the client; client bytes +20 B                                                              |
| `wrangler check startup`, median `Active` of 7 alternated  | within noise        |     52.2 ms |     48.8 ms | within noise (both near 50 ms under heavy load)                                                          |
| CPU attributed to the Effect chunk at startup              | +≤ 1 ms             |        0 ms |        0 ms | not sampled in any of 28 runs                                                                            |
| `todo.list` p50, median of 18 paired deltas                | +≤ 5 % or +≤ 0.1 ms |    0.641 ms |           — | +0.013 ms (A/A −0.031 ms)                                                                                |
| `todo.byId` p50                                            | same                |    0.226 ms |           — | +0.014 ms (A/A −0.006 ms)                                                                                |
| `todo.byId` p50, `NOT_FOUND`                               | same                |    0.219 ms |           — | +0.040 ms, +18 %, IQR +0.037…+0.052 (A/A −0.013 ms)                                                      |
| `documents.list` p50                                       | same                |    0.270 ms |           — | −0.001 ms (A/A −0.004 ms)                                                                                |

The chunk that contains Effect grew by 1,250 B (gzip -9). With a reference p50 between 0.2 and 0.7 ms, the effective latency limit is 0.1 ms.

**Correction and caveats.** An earlier version of this record gave, as the "median delta of 4 rounds", the lower middle value computed by hand; the true medians of those rounds were `todo.list` +0.103 ms (over the limit by 3 µs, inside the ±0.2 ms noise of those rounds), `todo.byId` −0.018, `NOT_FOUND` +0.017 and `documents.list` +0.020 ms. The table above replaces them. The machine was shared (1-minute load 70 to 221 during the review runs, recorded per sample). The happy paths sit within the A/A spread; the error path costs a real ~40 µs (its whole interquartile range is positive): building the tagged error, the `Exit` and the mapped `ORPCError` instead of `errors.NOT_FOUND()` directly. That is over 5 % but under the 0.1 ms absolute limit. The Effect chunk is byte-identical since the pilots, so startup was not re-measured. These are local numbers, not Cloudflare cold starts or production latency.

**Abort criterion.** If any budget had been exceeded, the pilots would have kept their tagged errors and the single mapper, but their services would have returned Promises again (no `Effect.fn`, no `runProcedure` in the request path), and this record would have been amended with the numbers and that outcome.

**Result:** the latency, client and startup budgets are met by the code, and the server code grows by 6.9 KiB of the 10 KiB budget. The Worker as a whole grows by 70.2 KiB gzip because the site's documentation pages, ADRs and changelog are compiled into it; that growth is content, not Effect, and the abort criterion (which only concerns the pilots) would not remove it. Whether documentation counts against the Worker budget is the owner's decision; until then both pilots stay on Effect.

## Why not the alternatives [#why-not-the-alternatives]

- **Keep raw `ORPCError` in services.** The transport leaks into the domain, MCP and the catalog depend on oRPC types, and nothing proves every failure is mapped.
- **Tagged errors thrown from Promise services, with no Effect program.** It keeps the single mapper but loses the typed failure channel, interruption that waits for a transaction, and composable timeouts. It is the fallback if the measurement fails.
- **A per-request `Layer` or `Context.Service` for the database and tenant.** It costs a scope per call for two arguments. It also invites the module-scope runtime that ADR-0005 forbids ("Cannot perform I/O on behalf of a different request").
- **`@orpc/experimental-effect`.** It is still beta on a beta.

## Consequences [#consequences]

- During the migration the backend has two service styles. The `domain-feature` skill and the backend conventions page teach the new one; a Promise service is not wrong, only unmigrated.
- Procedure spans are named (`Effect.fn("documents.source")`). Since the amendment below they are exported: the Worker `fetch` hands `ctx.tracing` to the request runtime, `createContext` carries it as `context.tracing`, and the builder's `handler` passes it to `runProcedure`, which runs the program under `workersTracer`. Outside workerd there is no tracing and the program runs untraced.
- A new failure is a new tagged error plus one `case`. A new client-visible code still goes through `packages/errors` and every locale, as before.
- The documents pilot preserved behavior: the storage `HEAD` that confirms text receives the program's interruption signal (not the client's), but no new timeout or retry was added. Adding them is a separate, measured change.
- Going further (Effect-native routing, services or Layers) still needs its own ADR.

## Amendment (2026-10-21): the builder and the honest Context [#amendment-2026-10-21-the-builder-and-the-honest-context]

The pilots wrote `context.organizationId!` in every handler and repeated `runProcedure(..., { signal, requestId: context.requestId })` twenty-nine times. The assertion was never needed: after `orgProcedure`, oRPC's merged context already types `organizationId` as `string`, also after `.use(withAudit(...))`. The repetition was the real cost: the place where a program runs, where its errors are mapped and where request capabilities are threaded was spread over every handler.

- **`effectProcedure(base, mapper)`** is a per-feature factory. Its `handler` receives the narrowed context (`db`, `organizationId: string`, `userId`, `requestId`, `signal`, `storage`, `direct`, `tracing`…) and returns an `Effect<A, FeatureError>`. The program's error type is checked against the mapper's union at compile time: a failure the mapper does not know is a type error, and a program that cannot fail (`never`) is accepted. The oRPC types of the resulting procedure are unchanged, so the router type stays serializable (TS7056) and the client sees the same contract.
- **The `Context` carries required capabilities.** `db`, `systemDb`, `session`, `authoritativeSession`, `rpcScopeHeader`, `ip`, `userAgent`, `requestOrigin`, `requestId`, `storage` and `direct` are always present; `createContext`, `createMcpContext` and the one test adapter (`src/testing/make-context.ts`) fill them. The fallbacks `context.systemDb ?? context.db`, `authoritativeSession?.()` and the `INTERNAL_SERVER_ERROR` branch for a missing system handle are gone. What stays optional does so because a real request can lack it: `tracing` (absent outside workerd), `waitUntil` (absent inside a tool transaction, where nothing may outlive the transaction), `adminApi` (absent for MCP-originated contexts, so an access token never acts as an admin), `organizationId`/`organizationRole` (set by `orgProcedure`) and `queuedAudits` (set inside an open transaction).
- **Storage is a request capability.** `createContext` builds `storage` (the files-sdk handle for the request's signal) and `direct` (signed targets and multipart control) once per request, as it builds `db`; the cron tick builds the same pair once per tick (`openStorage` in `platform/jobs/run.ts`) and a storage configuration failure fails only the jobs that need storage. Services take the handle as a parameter typed `StorageFiles` (`@template-cloudflare/storage`), the one capability type that replaced the per-feature `Pick<Files, …>` aliases. Tests pass `memoryStorage()` (`src/testing/memory-storage.ts`, files-sdk's memory adapter plus an in-memory `DirectStorage`) through the context or the job options; the api package has no module mock of the storage package left, except the documents characterization test, which was kept byte-for-byte.
- Not a change of the boundary: no `Layer`, `Context.Service` or `ManagedRuntime` was added; dependencies remain plain arguments.

## Amendment (2026-10-21): two tiers, one edge [#amendment-2026-10-21-two-tiers-one-edge]

The pilots proved the shape; the remaining features did not need everything in it. Wrapping `db.transaction` in `Effect.tryPromise` only to `catchTag` one failure into a result (billing admission), or running a program inside another program's `tryPromise` (reconciliation inside the billing batch), added a runtime without adding a capability. The review of the Effect boundary settled on two tiers. Both share the one rule that matters: **domain failures are tagged errors, listed once, mapped once at the edge; services never import `@orpc/*`; procedures hold no queries.**

- **Tier 1, every feature.** `<feature>.service.ts` holds the queries and the decisions; `<feature>.errors.ts` lists its `Schema.TaggedError`s, derives the union and the guard, and exports the exhaustive mapper. The service may be a Promise that throws the tagged errors. The feature passes its guard to the builder, `effectProcedure(orgProcedure, importRpcError, isImportError)`, and its handlers return the service's Promise; the builder runs it through `runService` (`platform/rpc/procedure-edge.ts`), which maps a recognised rejection once and rethrows anything else as a defect. The feature's files join the D2 and D8 lint lists like a pilot's.
- **Tier 2, when the program composes.** A service becomes `Effect.fn` when it needs what Effect adds: a timeout or `idempotentRead` around external I/O, `Effect.all` over independent reads, interruption that drains a transaction, a race against the caller's abort. The handler then returns the Effect and the same builder runs it through `runProcedure`, with the program's error type checked against the mapper at compile time. `todo`, `documents` (including the content programs the assistant and MCP tools run), `diagnostics` and the billing reconciliation are Tier 2.

Promotion is local: a Tier 1 service becomes Tier 2 by returning an Effect instead of a Promise, and nothing changes in its errors file or its procedures. A feature that is trivially small (the tenancy probe has no domain failure; the flag editor forwards five repository calls) still gets the service file, so queries and defaults have one home, and its mapper is the identity on `never` or a single `case`.

What this amendment does not change: no `Layer`, `Context.Service` or `ManagedRuntime`; `Effect.runPromise` appears only under `platform/effect` (`runEdge`) and `platform/rpc/procedure-edge.ts` (`runProcedure`), which `grep` confirms; the cron tick is still one program; transports keep their Effect and their facades through `runEdge`.

