Skip to content
Docs

Command palette

Jump to a page or view, or find a todo by name.

Changelog

Procedures run one Effect program at their edge

The repository's architectural decision, rationale and consequences.

Markdown

Status: accepted · Date: 2026-10-17 · Amends: ADR-0005 · 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 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 underneath. A feature builds its procedures with one builder, and each handler is the program:

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.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), 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 Errors). 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

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.

MeasureBudgetmainBranchResult
Server modules, gzip sum (measure-worker-build --runs 3)+≤ 10 KiB5,275,576 B5,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 / clientclient 0218 / 0218 / 0no Effect in the client; client bytes +20 B
wrangler check startup, median Active of 7 alternatedwithin noise52.2 ms48.8 mswithin noise (both near 50 ms under heavy load)
CPU attributed to the Effect chunk at startup+≤ 1 ms0 ms0 msnot sampled in any of 28 runs
todo.list p50, median of 18 paired deltas+≤ 5 % or +≤ 0.1 ms0.641 ms—+0.013 ms (A/A −0.031 ms)
todo.byId p50same0.226 ms—+0.014 ms (A/A −0.006 ms)
todo.byId p50, NOT_FOUNDsame0.219 ms—+0.040 ms, +18 %, IQR +0.037…+0.052 (A/A −0.013 ms)
documents.list p50same0.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 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

  • 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

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

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.TaggedErrors, 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.

Edit on GitHubDocumentation is in English.