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 | in migrated features |
| 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. The mobile app keeps four legacy aliases (./routers/{todo,integrations,billing,notifications}.schema) that point at the same files. The web app uses canonical subpaths only.
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 web import of a legacy routers/*.schema alias.
Enforcement. [test] packages/api/src/exports.test.ts: every target exists, each alias points at its canonical file, nothing exports a procedure, job or service beyond the listed server entries, every subpath web and mobile import exists, and the web app uses no alias. The alias ban is a test rather than an oxlint rule because an oxlint override replaces a rule's options instead of merging them: banning the aliases in apps/web/src would mean repeating the whole client boundary there.
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: orgProcedure
.use(requireOrgRole("owner", "admin"))
.input(z.object({ id: z.number() }))
.handler(({ context, input, signal }) =>
runProcedure(deleteTodo(context.db, context.organizationId!, input.id), todoRpcError, {
signal,
requestId: context.requestId,
}),
),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 unmigrated features named in the override's comment · [test] characterization tests in packages/api/src/testing/characterization/, locale tests.
A procedure runs one program
Rule. In a migrated feature, the handler calls runProcedure once with the service program, the feature mapper, the request signal and the request ID. 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 and organizationId as arguments. 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 and where cancellation is handled. 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.
source: orgProcedure.input(documentIdentityInput).handler(({ context, input, signal }) =>
runProcedure(documentSource(context.db, context.organizationId!, input.fileId), documentRpcError, {
signal,
requestId: context.requestId,
}),
),Anti-pattern.
Effect.runPromiseinside a service.- 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. [test] platform/rpc/procedure-edge.test.ts and platform/effect/db.test.ts · [lint] D8 keeps queries out of migrated procedures · [convention] one runProcedure per handler, no Layer or runtime.
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.