Status: accepted · Date: 2026-10-17 · Amends: ADR-0005
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 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
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:
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.TaggedErrors 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) 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:
querywrapsdrainedand turns a SQL failure into a defect, which is today's 500.transactionrunsdb.transactioninsidedrained. Inside the callback, which is still a Promise, the body throws tagged errors (they areErrors). 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-effectstays out.- Effect Schema is not used for public contracts.
- The cron tick remains one program with one
runEdge. - Only
@stability stableAPIs 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
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.
| 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
- Keep raw
ORPCErrorin 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
LayerorContext.Servicefor 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
- During the migration the backend has two service styles. The
domain-featureskill 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. Passingctx.tracingfrom the Workerfetchinto 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 throughpackages/errorsand every locale, as before. - The documents pilot preserved behavior: the storage
HEADthat 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.