# 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)

**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. Its body is a single call:

```ts
handler: ({ context, input, signal }) =>
  runProcedure(
    documentSource(context.db, context.organizationId!, input.fileId),
    documentRpcError,
    {
      signal,
      requestId: context.requestId,
    },
  );
```

`runProcedure` (`packages/api/src/platform/rpc/procedure-edge.ts`) 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)) and the Organization ID are plain parameters, as they were for the Promise services. 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.

**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")`) but not exported. Passing `ctx.tracing` from the Worker `fetch` into the oRPC context is a later change, outside this record.
- 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.

