These rules apply to packages/api. Each one has the same shape: rule, why, example, anti-pattern and enforcement. The enforcement level is one of [type], [lint], [test], [build], [deploy] or [convention]. A [convention] rule depends on review alone, so it is the first candidate for a stronger check. The reasons behind the larger rules are recorded in ADR-0011 (feature shape) and ADR-0010 (Effect in procedures).
One folder per feature, one name per role
Rule. A feature lives in packages/api/src/features/<feature>/. Its files are named <feature>.<role>.ts:
| Role | File | Required |
|---|---|---|
| Contract | <f>.contract.ts | when the feature has procedures |
| Procedures | <f>.procedures.ts | when the feature is exposed through oRPC |
| Service | <f>.service.ts | when the feature has procedures |
| Errors | <f>.errors.ts | when the feature has domain failures |
| Rules, enums, policy | <f>.rules.ts, <f>.enums.ts, <f>.policy.ts | optional, no I/O |
| Jobs | <f>.jobs.ts | when the feature runs in the cron tick |
| Raw HTTP edge | <f>.http.ts | when a Worker route sits outside oRPC |
| Internals | <f>.<topic>.ts | as needed |
| Tests | <f>.<topic>.test.ts; procedure tests are <f>.procedures.test.ts | as needed |
Code that belongs to no feature lives in packages/api/src/platform/ (rpc, effect, jobs, http, data-table).
Why. The name of a file tells you what it may import and who may import it. A feature can be read, moved or deleted as one folder.
Example.
features/documents/
documents.contract.ts documents.procedures.ts documents.service.ts
documents.errors.ts documents.policy.ts documents.jobs.ts
documents.queue.ts documents.chunk.ts documents.lifecycle-plugin.tsAnti-pattern. A sub-folder per layer (documents/services/), a second name for the same role (router.ts next to procedures.ts), or queries written inside the procedures file of a migrated feature.
Enforcement. [test] scripts/feature-boundaries.test.mjs (in pnpm test) · [lint] direction overrides in .oxlintrc.json.
The contract is a leaf
Rule. <f>.contract.ts starts with the LEAF MODULE header. It imports only zod, other leaves (db/schema/*.enums, *.enums.ts, other contracts) and import type from anywhere.
Why. Web and mobile import the contract. One value import of a server module puts server code in a browser bundle or an app binary (ADR-0002).
Example.
// LEAF MODULE: imported by web and mobile. Only zod, leaves and `import type`.
import { PRIORITIES } from "@template-cloudflare/db/schema/todo.enums";
import { z } from "zod";
export const todoCreateInput = z.object({ text: z.string().min(1), priority: z.enum(PRIORITIES) });Anti-pattern. Importing a constant from a Drizzle table module, or from a service, "because it is already there".
Enforcement. [test] scripts/leaf-modules.test.mjs, which also fails when a features/*/*.contract.ts lacks the header · [lint] packages/config/oxlint-client-boundary.json · [build] assert-client-bundle-clean.mjs.
No barrel; the public surface is exports
Rule. No index.ts re-exports a feature. Other packages and apps import an enumerated subpath of @template-cloudflare/api. Canonical subpaths are ./<feature>/<role>, for example ./todo/contract and ./billing/http. Web and mobile use the canonical subpaths only; the earlier ./routers/*.schema aliases were removed once the mobile app moved to them.
Why. A barrel is how server code reached the browser. An enumerated map makes every new cross-package import a reviewed decision.
Example. import { todoListInput } from "@template-cloudflare/api/todo/contract";
Anti-pattern. A wildcard "./*" export, or a ./routers/* subpath brought back.
Enforcement. [test] packages/api/src/exports.test.ts: every target exists, no ./routers/* subpath comes back, nothing exports a procedure, job or service beyond the listed server entries, and every subpath web and mobile import exists. A removed subpath also fails tsc in the app that imports it.
Imports flow one way
Rule.
- Only
platform/rpc/app-router.tsand the agent tool catalog import*.procedures. - Only
platform/jobs/run.tsimports*.jobs. platform/**does not import features, except those two files and two declared edges:platform/rpc/procedures.tsreadsfeatures/admin/admin.roles(the admin guard) andplatform/rpc/context.tsbuildsfeatures/admin/admin.api(theadminApicapability).- A feature imports another feature's contract, enums, service, errors, policy or rules. Any other cross-feature import is declared in the boundary test.
- In a migrated feature,
*.procedures.tsimports nodrizzle-ormand no table module (enums are fine): queries live in the service. The lint rule refuses value imports ofdrizzle-orm, the schema barrel@template-cloudflare/db/schemaand the per-table modules@template-cloudflare/db/schema/<table>(enum leaves andimport typepass). A relational query throughcontext.db.query.*needs no import, so the rule cannot see it; that one is left to review. - There are no import cycles in
packages/api/srcandpackages/db/src.
Why. Each rule keeps a dependency the reader can predict. A domain module that imports a router or a job is the first sign the layering has rotted.
Example. todo.service.ts imports enqueueUserWebhooks from webhooks.events.ts, a declared edge, to queue deliveries inside its transaction.
Anti-pattern. A service that imports another feature's queue or lease module to "reuse a query".
Enforcement. [lint] no-restricted-imports overrides in .oxlintrc.json: D3 (procedures), D4 (jobs), D5 (platform) and D8 (queries in migrated procedures, listed per feature) · [test] scripts/feature-boundaries.test.mjs (D6; a declared edge that disappears also fails, so the list only shrinks) · [test] scripts/import-cycles.test.mjs (D7). The cycle check runs in pnpm test, not in pnpm lint, because oxlint's import plugin made the whole lint about 3.7 times slower.
Tenant data goes through context.db
Rule. A procedure, tool or gateway reads and writes tenant data through context.db, which runs as app_tenant for the session's User and Active Organization. The organizationId filter stays in every query. Crossing tenants is possible only through asSystem(db, reason, fn) with a listed SystemReason, and the work happens inside fn.
Why. Row-level security enforces isolation in the database; the filters are defence in depth (ADR-0009).
Example.
export const listTodos = Effect.fn("todo.list")(function* (
db: Database,
organizationId: string,
input: TodoListInput,
) {
return yield* query(() => db.select().from(todo).where(eq(todo.organizationId, organizationId)));
});Anti-pattern. Opening a pool with createDb in a feature. Returning the handle from asSystem. Network I/O inside withTenant.
Enforcement. [lint] the pool-import restriction and check-system-call-sites.mjs · [test] RLS isolation, catalog and matrix suites · [convention] no network I/O inside withTenant.
Org roles are checked only where a product rule needs them
Rule. Membership in the Active Organization is the default authorization for Organization data (orgProcedure). When a product rule limits an operation to some Org roles, chain requireOrgRole from platform/rpc/require-org-role.ts after orgProcedure. Do not compare role strings inside a service.
Why. orgProcedure already reads the Member's role in its membership statement, so the check costs no extra round trip, and a missing or unknown role fails closed with FORBIDDEN. Row-level security checks membership, not roles, so the role check belongs in the procedure (ADR-0009).
Example.
remove: todoProcedure
.use(requireOrgRole("owner", "admin"))
.input(z.object({ id: z.number() }))
.handler(({ db, organizationId }, input) => deleteTodo(db, organizationId, input.id)),Roles are not hierarchical: requireOrgRole("admin") refuses an owner, so list every role that may act. Chain it after orgProcedure, which reads the role: the middleware requires organizationRole in its input context, so protectedProcedure.use(requireOrgRole(...)) is a type error. The middleware exists and is tested (owner, admin and admin,member pass; member and unknown roles are refused), but no procedure applies it yet: today every Member may do everything in their Organization.
Anti-pattern. Reading member.role again in a service, or treating the platform role (user/admin) as an Org role.
Enforcement. [test] platform/rpc/require-org-role.test.ts · [convention] where it is applied.
Domain failures are tagged errors, mapped once
Rule. A failure a caller can act on is a Schema.TaggedError in <f>.errors.ts. The feature exports one mapper, a switch on _tag ending in satisfies never, that turns each failure into an ORPCError with an APP_ERRORS code. Services never import @orpc/*.
Why. The failures of an operation become a closed type, the mapper is exhaustive by construction, and the client keeps seeing the stable codes it localizes (error handling).
Example.
// abridged from packages/api/src/features/documents/documents.errors.ts
export class DocumentNotFound extends Schema.TaggedError<DocumentNotFound>()(
"DocumentNotFound",
{},
) {}
export type DocumentError = DocumentNotFound | DocumentSourceChanged;
export function documentRpcError(error: DocumentError) {
switch (error._tag) {
case "DocumentNotFound":
return new ORPCError("NOT_FOUND");
case "DocumentSourceChanged":
return new ORPCError("CONFLICT", { message: "The original document changed" });
default:
return error satisfies never;
}
}Keep the code and message the client saw before a migration. documents.errors.ts repeats the messages the service used to throw; todo.errors.ts passes APP_ERRORS.NOT_FOUND.message, because the handler used to throw errors.NOT_FOUND(). A switch over a union with one member ends in return error._tag satisfies never.
Anti-pattern. throw new ORPCError("CONFLICT") inside a service. A string error. A new code that is missing from a locale.
Enforcement. [type] the exhaustive mapper · [lint] D2 in .oxlintrc.json: no @orpc/* in the services, rules and policies of migrated features, listed by path, with the features still to migrate named in the override's comment · [test] characterization tests in packages/api/src/testing/characterization/, locale tests.
A procedure runs one program
Rule. A feature builds its procedures with one effectProcedure(orgProcedure, <feature>RpcError) from platform/rpc/effect-procedure.ts. Each handler returns the service program; the builder's handler is the only place that runs it with the feature mapper, the request signal, the request ID and the request's tracing. There are two tiers (ADR-0010, amendment):
- Tier 1 (every feature). The service is a Promise that throws the feature's tagged errors. The feature passes its guard as the builder's third argument,
effectProcedure(orgProcedure, importRpcError, isImportError), and the builder runs the Promise throughrunService: a recognised rejection is mapped once, anything else is rethrown as a defect (500).imports,proposals,notifications,flags,tenancy,assistant,uploadsand the billing procedures are Tier 1. - Tier 2 (when the program composes). The service is an
Effect.fnbecause it needs a timeout,idempotentRead,Effect.all, a race against the caller's abort or interruption that drains a transaction. The handler returns the Effect and the builder runs it throughrunProcedure; the program's error type must fit the mapper's union, ortscfails.todo,documents,diagnosticsand the billing reconciliation are Tier 2. Promotion is local: return an Effect instead of a Promise; the errors file and the procedures do not change.
A handler may be async to read configuration before calling the service; what it returns is still the one program. The signal is honoured only before the program starts: an already aborted request is refused, but once the program runs, a client that goes away does not interrupt it, so the write and its Audit Event commit and the response goes unread. A service is an Effect.fn that receives db, organizationId and, when it reaches the bucket, the request's storage or direct handle as arguments; it never builds a dependency itself or as a default parameter. Database work uses query and transaction from platform/effect/db.ts. Both drain: if the program itself is interrupted (a timeout or deadline inside it), the statement or transaction settles before the program ends. query(run) hands run that interruption signal, not the client's, which a service may pass to an external read (the documents pilot passes it to the storage HEAD).
Why. There is one place where typed failures become transport errors, where cancellation is handled and where request capabilities are threaded, instead of one copy per handler. An audited procedure runs inside the transaction withAudit opens around the handler, so a handler that rejected because the client left would roll back the write and its audit; finishing the started program prevents that (todo.abort.test.ts, documents.abort.test.ts). A running transaction is drained instead of abandoned (ADR-0010, ADR-0005).
Example.
const documentsProcedure = effectProcedure(orgProcedure, documentRpcError);
export const documentsRouter = {
source: documentsProcedure
.input(documentIdentityInput)
.handler(({ db, organizationId }, input) => documentSource(db, organizationId, input.fileId)),
confirmText: documentsProcedure
.input(documentIdentityInput)
.handler(({ db, organizationId, storage }, input) =>
confirmDocumentText(db, organizationId, input.fileId, storage),
),
};The handler's context is the chain's context as oRPC narrowed it: after orgProcedure, organizationId is a string and session is non-null, so there is no !. userId and signal are spelled out next to it.
Anti-pattern.
context.organizationId!, or a hand-writtenrunProcedure(..., { signal, requestId })in a handler.createStorage()orcreateDirectStorage()inside a service or as a default parameter.Effect.tryPromisearounddb.transactiononly tocatchTagone failure into a result; a Promise withtry/catchsays the same with less (Tier 1).Effect.runPromiseinside a service, or a Promise facade called from inside another program (two runtimes per call).- A second
run*in the same call. - A
Layer,Context.ServiceorManagedRuntimethat holds a pool, whether at module scope or per request. - Handling interruption by hand.
Enforcement. [type] the program's error type against the mapper (Tier 2) · [test] platform/rpc/procedure-edge.test.ts (runProcedure, runService) and platform/effect/db.test.ts · [lint] D8 keeps queries out of migrated procedures · [convention] one program per handler, no Layer or runtime; grep "Effect.runPromise(" outside platform/effect and platform/rpc/procedure-edge.ts is empty.
The Context carries capabilities, not options
Rule. Context (platform/rpc/context.ts) is what every procedure, tool and gateway may count on: db, systemDb, session, authoritativeSession, rpcScopeHeader, ip, userAgent, requestOrigin, requestId, storage and direct are always present. Only createContext, createMcpContext and the test adapter src/testing/make-context.ts assemble one. A capability is optional only when a real request can lack it, and the type says when: tracing (outside workerd), waitUntil (inside a tool transaction), adminApi (MCP-originated contexts), organizationId/organizationRole (before orgProcedure) and queuedAudits (outside an open transaction).
Why. A fallback such as context.systemDb ?? context.db or authoritativeSession?.() is a second code path that only tests exercised, and a throw INTERNAL_SERVER_ERROR for a missing capability is a runtime check of what the type should have said. Storage built once per request shares the request's abort signal and is handed down like db (ADR-0010, amendment).
Example. A test context is makeContext({ db, user, organizationId, storage: memoryStorage().files }); a tool scope is a spread of the Context with db and organizationId replaced.
Anti-pattern. A test that writes { db, session } and calls it a Context. A vi.mock("@template-cloudflare/storage") where makeContext({ storage }) or openStorage in the job options would do.
Enforcement. [type] the Context type · [convention] one factory per entry point.
External I/O has a deadline; only reads retry
Rule. A service that calls storage or a provider sets a timeout. It retries only idempotent reads, with idempotentRead. A PUT, a provider POST, a webhook delivery or an email send makes one attempt.
Why. A repeated write can duplicate a side effect. The outbox and lease policy decides when to try again.
Example. idempotentRead wraps the RevenueCat subscriber GET, Expo getReceipts and the import source reads. The storage HEAD that confirms Document Text (documents.service.ts) has a 10 s timeout, retries: 0 and the program's abort signal, and runs before any lock or transaction; the documents pilot kept that behavior instead of adding a retry. The signed PUT the browser performs is never retried by the server.
Anti-pattern. Effect.retry around a POST.
Enforcement. [convention] · [test] retry tests with TestClock.
Configuration is declared once
Rule. Every environment variable is declared in packages/env and read through it, including runtime switches such as DB_TENANT_PIPELINE and TRUSTED_PROXIES.
Why. The schema is the list of what the deploy must provide. A missing or malformed server secret fails validation when the server environment loads. The runtime switches in @template-cloudflare/env/runtime-flags are read on every call instead: an invalid value never becomes a different setting, but falls back to the safe behavior and is logged once per isolate (runtime switches).
Anti-pattern. process.env.SOMETHING in a feature.
Enforcement. [lint] node/no-process-env for packages/** in .oxlintrc.json and for apps/web/src in its nested config. NODE_ENV and EXPO_OS are allowed because bundlers replace them. Named exceptions: packages/env/**, tests and packages/api/src/testing/**, CLIs (packages/*/scripts/**, project-setup-cli, db/src/seed.ts) and the test-only provider switch in assistant.provider.ts. Runtime switches go through @template-cloudflare/env/runtime-flags, which reads them on every call.
Logs separate expected from unexpected
Rule. An expected failure (a mapped 4xx) is not logged as an error; the server error handler writes it at debug level. An unexpected failure (a defect, a 500) is logged once by the server error handler, with the request ID that the response also returns in x-request-id. Logs carry identifiers, never credentials, prompts or private payloads.
Why. Error logs then mean something needs attention, and a user can quote one ID that leads to the log line.
Anti-pattern. console.error in a service for a NOT_FOUND.
Enforcement. [test] the server error handler tests · [convention] for payload content.
Lists page one way per endpoint
Rule. Use the pagination rule in oRPC conventions. Use a cursor for lists that grow without bound. Use offset only where the interface needs a total or page numbers. Every query has a total order with an id tie-breaker.
Enforcement. [test] list tests · [convention] the choice of strategy.
Key files
packages/api/src/platform/rpc/procedures.tspackages/api/src/platform/rpc/effect-procedure.tspackages/api/src/platform/rpc/procedure-edge.tspackages/api/src/platform/rpc/context.tspackages/api/src/platform/effect/db.tspackages/api/src/features/todo/todo.service.tspackages/api/src/features/documents/documents.errors.ts.oxlintrc.json