This document is normative. Where a rule below is marked enforced, a lint rule or test fails the build when it is broken — it is not a matter of taste or reviewer attention. Where a rule is marked reviewed, it is checked by a human at review time.
Read this before adding a feature. The last section is the checklist.
The layer rule
src/app/ routing, layouts, server components, page composition
│
▼
src/components/ presentational and interactive UI
│
▼
src/trpc/ client + routers: input validation, authz, one service call
│
▼
src/server/ services: all business logic, all database access
│
▼
src/lib/ infrastructure: db, auth, authz, permissions, s3, email
│
▼
src/generated/ Prisma client output — never edited by hand
Dependencies point downward only. A layer may import from itself and from layers below it. It may never import from a layer above it. There are no exceptions and no "just this once" — an upward import means the boundary is in the wrong place, and the fix is to move the code, not to add the import.
| Layer | May import | May never import |
|---|---|---|
src/app/ |
anything below | — |
src/components/ |
trpc/client, lib/utils, hooks |
src/server/**, lib/db, lib/auth, lib/s3 |
src/trpc/routers/ |
src/server/services/**, lib/permissions |
lib/db (routers never touch the database) |
src/server/services/ |
lib/**, generated/prisma |
next/headers, lib/auth, anything in components/ or app/ |
src/lib/ |
generated/prisma, other lib |
anything above |
Why services may not read the session
A service receives everything it needs as arguments. It never calls auth.api.getSession() and never imports next/headers.
// wrong — the service now depends on a request being in flight
export async function listProjects() {
const session = await auth.api.getSession({ headers: await headers() });
return db.project.findMany({
where: { organizationId: session.activeOrganizationId },
});
}
// right — a pure function of its inputs
export async function listProjects(ctx: OrgContext, input: ListProjectsInput) {
return db.project.findMany({
where: { organizationId: ctx.orgId, archivedAt: null },
});
}
The right version is callable from a test, a seed script, a cron job, and a CLI without faking a request. The wrong version is callable from exactly one place and is why v1's logic had no unit tests. Enforced.
Directory map
src/
app/
(auth)/ sign-in, sign-up, verify, reset, two-factor
(marketing)/ public landing, pricing
(app)/ authenticated shell
[orgSlug]/
projects/[projectId]/
my-tasks/
inbox/
settings/
api/
auth/[...all]/ Better Auth catch-all
trpc/[trpc]/ tRPC handler
webhooks/polar/ Polar webhook receiver
components/
ui/ shadcn primitives — regenerated, not hand-edited
<feature>/ project/, task/, org/, billing/ …
hooks/
lib/
db.ts Prisma client singleton
auth.ts Better Auth server instance
auth-client.ts Better Auth browser client
authz.ts context resolution + resource guards
permissions.ts role and statement definitions (shared client/server)
s3.ts S3 client + presigners
email.ts
utils.ts
server/
services/
<name>.service.ts business logic + database access
<name>.authz.test.ts cross-organization denial proofs
trpc/
init.ts context + procedure ladder
routers/
<name>.router.ts
_app.ts
generated/prisma/ never edited
One concept per file. When a service file passes roughly 300 lines it is doing more than one thing — split it by the resource it owns.
The three enforced invariants
1. Server code is marked server-only
Every file under src/server/ starts with:
import "server-only";
Enforced by the dailytm/require-server-only ESLint rule. A service file without it fails pnpm lint and CI. The point is not documentation — server-only throws at build time if the module is ever pulled into a client bundle, so a leaked database call becomes a build failure instead of a shipped credential.
2. Tenant data is never fetched by ID alone
// wrong — returns another organization's project
db.project.findUnique({ where: { id: input.projectId } });
// right — a foreign ID is indistinguishable from a missing one
db.project.findFirst({
where: { id: input.projectId, organizationId: ctx.orgId },
});
findUnique is banned outright inside src/server/services/. Enforced by no-restricted-syntax. findFirst does the same job and cannot be written without a where clause you have to look at.
This was v1's single largest defect class: authenticated mutations that never proved the target belonged to the caller's workspace.
3. The tenant comes from the session, never from the URL
ctx.orgId is resolved once, in src/trpc/init.ts, from the session's active organization. A projectId or taskId in the input is a filter, never a source of authority. Reviewed — no lint rule catches this, so it is the first thing a reviewer checks on any new procedure.
The procedure ladder
Defined in src/trpc/init.ts. Pick the highest rung that matches what you touch; never re-derive a guarantee a lower rung already gives you.
| Procedure | Proves | Adds to ctx |
|---|---|---|
baseProcedure |
nothing | — |
protectedProcedure |
valid session | userId, session |
orgProcedure |
active org, caller is a member | orgId, role, member |
projectProcedure |
project belongs to orgId, visibility satisfied |
project |
taskProcedure |
task's project belongs to orgId |
task, project |
A procedure that operates on a project uses projectProcedure. Using orgProcedure and then loading the project by hand duplicates the check, and duplicated checks drift. Reviewed.
Permissions
Roles and their statements live in src/lib/permissions.ts — one file, imported by both the server middleware and the client's useCan() hook.
organization: update, delete ─┐ Better Auth's built-in statements.
member: create, update, delete │ Its own endpoints authorize against
invitation: create, cancel │ these names, so we spread them in
team: create, update, delete │ rather than replacing them.
ac: create, read, update, delete ─┘
project: create, update, delete, archive ─┐ DailyTM's own.
task: create, update, delete, assign │
billing: manage ─┘
The spec called the first group org:update,delete and member:invite,remove,updateRole. We use Better Auth's names instead: the plugin enforces its names natively, so a parallel set would leave two vocabularies for one action and only one of them actually checked. Decided 2026-08-26.
Roles: owner holds everything. admin is adminAc plus all of project and task, minus billing — a plan change is a spending decision. member gets project:create/update/archive and all of task, but not project:delete, because deleting a project takes every task with it and archiving covers the same intent reversibly. viewer holds nothing at all.
Because both sides read the same definition, a control the UI offers is always a control the server allows. Adding a permission means editing one file, not two. A hardcoded role comparison in a component (role === "owner") bypasses this and is a review rejection. Reviewed, and the matrix itself is pinned by src/lib/permissions.test.ts so widening viewer fails the build.
Where the check happens
permittedProcedure(request) in src/trpc/init.ts returns an orgProcedure with the check applied:
create: permittedProcedure({ project: ["create"] })
.input(createProjectInput)
.mutation(({ ctx, input }) => projectService.create(ctx, input)),
A procedure factory rather than .use(requirePermission(...)), because a standalone tRPC middleware is typed against the root context and cannot see ctx.role without a cast. Hanging the check off orgProcedure also makes it impossible to attach to a procedure that never proved an organization.
It is authoritative because ctx.role was read from the member row by orgProcedure, not because of which function compares it. useCan() calls the same hasPermission against the role in the session, and that answer only decides what to render.
Routers are thin
A router procedure does exactly four things, in this order:
- Choose the procedure rung.
- Validate input with Zod.
- Check a permission if one applies.
- Call one service function and return it.
export const projectRouter = createTRPCRouter({
create: orgProcedure
.input(createProjectInput)
.use(requirePermission("project", "create"))
.mutation(({ ctx, input }) => projectService.create(ctx, input)),
});
No database calls, no branching business logic, no data shaping. If a procedure body has an if in it, that if belongs in the service. Enforced for database access (lib/db is not importable from routers); reviewed for logic.
Server prefetch, client subscribe
Data is fetched once, on the server, and the client subscribes to it. A page that renders a spinner while the browser makes a round trip the server could have made is a defect, not a loading state.
src/trpc/server.tsx provides the three pieces: the trpc options proxy, prefetch, and HydrateClient.
The working reference implementation is src/app/(marketing)/status/page.tsx plus src/components/system/status-card.tsx. Copy that shape.
// src/app/(app)/[orgSlug]/projects/[projectId]/page.tsx — Server Component
import { Suspense } from "react";
import { HydrateClient, prefetch, trpc } from "@/trpc/server";
// Required on every prefetching page. See "Why every prefetching page is
// dynamic" below.
export const dynamic = "force-dynamic";
export default async function ProjectPage({ params }: Props) {
const { projectId } = await params;
prefetch(trpc.task.list.queryOptions({ projectId }));
prefetch(trpc.project.byId.queryOptions({ projectId }));
return (
<HydrateClient>
<Suspense fallback={<ProjectBoardSkeleton />}>
<ProjectBoard projectId={projectId} />
</Suspense>
</HydrateClient>
);
}
// src/components/project/project-board.tsx — Client Component
"use client";
import { useSuspenseQuery } from "@tanstack/react-query";
import { useTRPC } from "@/trpc/client";
export function ProjectBoard({ projectId }: { projectId: string }) {
const trpc = useTRPC();
const { data } = useSuspenseQuery(trpc.task.list.queryOptions({ projectId }));
// …paints with hydrated data on first render — no loading flash
}
Rules:
prefetchis not awaited. It is fire-and-forget;HydrateClientdehydrates whatever resolved. Awaiting it serializes your prefetches and slows the page.- The prefetch input and the
useQueryinput must be identical, or the query key differs and the hydrated data is ignored. This is the one failure mode of this pattern, and it fails silently — it just refetches. - Subscribe with
useSuspenseQuery, notuseQuery, and give it a<Suspense>boundary.src/trpc/query-client.tsdehydrates pending queries so the server never blocks on a prefetch. A plainuseQuerytherefore renders its loading branch on the server while the client hydrates with data that has already arrived — a hydration mismatch React only reports in development. Suspending lets React stream the resolved markup into the boundary instead, so both sides emit the same HTML. - The error state is the route segment's
error.tsx.useSuspenseQuerythrows to the nearest boundary — no custom error-boundary component and no extra dependency. Next does not create that boundary for you: it exists only where anerror.tsxfile does, and a segment without one falls through to Next's built-in error page, which knows nothing about the design tokens. This document previously claimed the App Router "already provides one per segment"; it does not, and the release review found five authenticated routes relying on that.(app)/[orgSlug]/error.tsxis the catch-all; add a nearer one only when a segment can say something more specific. Corrected 2026-08-28. - Loading is the
<Suspense fallback>. A component that suspends must not also carry anisPendingbranch; that branch is unreachable and drifts. - Always go through the options factory:
trpc.x.queryOptions(input). Hand-writing{ queryKey, queryFn }loses type-safe key generation and breaks hydration matching. - Cursor-paginated lists use
infiniteQueryOptionswithgetNextPageParam. - Invalidate with the tRPC filters —
queryClient.invalidateQueries(trpc.task.pathFilter())for a whole router,trpc.task.list.queryFilter(input)for one query. Never a hand-built key array. - Refresh RSC-prefetched data with
router.refresh(), notinvalidateQueries. Invalidating auseSuspenseQueryre-suspends its boundary, which unmounts the component holding the observer, which cancels the refetch the invalidation just started: the awaited promise never settles and the list never updates. Re-running the Server Component is both where the data came from and the thing that works.invalidateQueriesstays correct for queries the client owns outright. Found by execution, 2026-08-26. - A Server Component cannot set a cookie. Calling
auth.api.setActiveOrganizationfrom a layout looks like it works and silently drops theSet-Cookie. It is still useful — it writes the session row — which is whysession.cookieCacheis disabled: with the cache on, the stale cookie wins over that row and a layout redirecting on the mismatch redirects forever. - Format dates through
src/lib/format.ts.toLocaleDateString(undefined, …)uses the runtime's locale and timezone, which differ between server and browser, and the resulting hydration mismatch makes React discard the tree. - Optimistic updates snapshot with
queryClient.getQueryData(trpc.x.queryKey(input)), write withsetQueryData, restore the snapshot inonError, andinvalidateQueriesinonSettled.
Why every prefetching page is dynamic
export const dynamic = "force-dynamic" is not optional on a page that prefetches.
createTRPCContext resolves the session, which reads headers(). Because prefetch is fire-and-forget, that read happens in a promise that escapes the render pass, so Next cannot attribute the dynamic access to the route in time. The route stays a static-prerender candidate, the prerender runs, and the build fails with a redacted Error: redacted and Export encountered an error on /<route>/page.
The error names no cause and no file. If a build fails that way, this is why.
In practice this costs nothing: every page that prefetches tenant data is per-request anyway, because the tenant comes from the session.
Reviewed. src/trpc/server.tsx carries server-only at the top, so a client import of it is a build failure.
Postgres conventions
These come from the postgres-best-practices skill and are decided once, here, rather than argued per model.
| Rule | Why |
|---|---|
@db.Timestamptz(3) on every DateTime |
Prisma's Postgres default is timestamp(3) — no timezone. Due dates and timers across timezones silently drift. |
@default(uuid(7)) on DailyTM-owned ids |
UUIDv4 is random and fragments the B-tree on insert. v7 is time-ordered and preserves insert locality. Better Auth generates its own ids; leave those alone. |
snake_case via @map / @@map |
Prisma's default emits quoted mixed-case identifiers ("organizationId"), which must be quoted in every hand-written query. Buyers open psql. |
String (→ text), not varchar(n) |
varchar(n) gives no performance benefit and makes widening a column a migration. Bound length with a CHECK where it matters. |
| Composite indexes: equality columns first, then range/sort | The index is a sorted tree. (organization_id, project_id, rank) serves WHERE org = ? AND project = ? ORDER BY rank; the reverse order does not. |
| Every foreign key column is indexed | Postgres does not create one for you, and unindexed FKs make cascading deletes table scans. |
Migrations, never db push |
Partial indexes and CHECK constraints cannot be expressed in the Prisma schema and ship as raw SQL inside migration files. db push skips them, so the running-timer constraint would silently not exist. |
Row-level security was considered and rejected. RLS would require setting a per-connection tenant variable, which fights connection pooling and Prisma's client model. Tenancy is enforced in the service layer, backed by lint rules and the .authz.test.ts suites. Recorded so it is not re-litigated per model.
Entitlements are the only limit authority
Nothing in the application asks Polar — or any provider — whether an action is allowed. Code asks entitlement.service.ts. Webhooks write entitlements; everything else reads them.
This is what makes billing optional. With no POLAR_ACCESS_TOKEN the plan resolves to SELF_HOSTED with unlimited limits and the billing UI is hidden, and no other module needs to know or care. Reviewed.
Storage is provider-neutral
src/lib/s3.ts talks the S3 protocol to whatever S3_ENDPOINT points at — R2, S3, MinIO, Backblaze, Spaces, Tigris. There is no adapter interface and no provider abstraction, because there is one implementation; the provider is a configuration value.
An Attachment row stores an object key, never a URL. URLs are minted on demand and expire in minutes. A stored URL cannot be re-signed and does not survive a bucket move. Reviewed.
Errors
Services throw TRPCError with the right code. NOT_FOUND is the correct response to a resource in another organization — FORBIDDEN confirms the resource exists and leaks the ID space.
| Situation | Code |
|---|---|
| No session | UNAUTHORIZED |
| Resource in another org, or absent | NOT_FOUND |
| Right org, insufficient role | FORBIDDEN |
| Entitlement limit reached | FORBIDDEN with a plan_limit cause |
| Invalid input past Zod | BAD_REQUEST |
What enforces what
| Rule | Mechanism | Fails at |
|---|---|---|
server-only in src/server/** |
dailytm/require-server-only ESLint rule |
pnpm lint, CI |
No findUnique in services |
no-restricted-syntax |
pnpm lint, CI |
| Components cannot import server code | no-restricted-imports |
pnpm lint, CI |
Routers cannot import lib/db |
no-restricted-imports |
pnpm lint, CI |
| Services cannot read the session | no-restricted-imports on next/headers, lib/auth |
pnpm lint, CI |
| Cross-org access denied | *.authz.test.ts suites |
pnpm test, CI |
| Type safety | tsc --noEmit |
pnpm typecheck, CI |
Adding a feature
In this order. Skipping a step is how inconsistency gets in.
- Schema — model in
prisma/schema.prisma, withorganizationIdand its index. Runpnpm db:generate. - Permission — add the statement to
src/lib/permissions.tsif the feature needs one. - Service —
src/server/services/<name>.service.ts, starting withimport "server-only". Takes(ctx, input). Every query filtered byctx.orgId. - Authz test —
<name>.authz.test.ts. Prove org B getsNOT_FOUNDon an org A ID, for read and for write. Write this before the router. - Router —
src/trpc/routers/<name>.router.ts, four lines per procedure. Register it in_app.ts. - UI — components under
src/components/<feature>/, gated withuseCan(). Ship the empty, loading, error, and permission-denied states in the same change. - Prefetch — the Server Component that owns the route calls
prefetch(trpc.<x>.queryOptions(input))with the same input the client passes, wrapped in<HydrateClient>. - Verify —
pnpm lint && pnpm typecheck && pnpm test && pnpm build.
Better Auth integration points
Better Auth owns more than sessions. Use its extension points rather than rebuilding the behaviour beside them.
| Concern | Where it lives | Not |
|---|---|---|
New org gets an Entitlement row and default project |
organizationHooks.afterCreateOrganization |
a call at every org-creation site |
| Org deletion archives data first | organizationHooks.beforeDeleteOrganization |
a cleanup job hoping to catch it |
| Plan limits on orgs-per-user and members-per-org | organizationLimit / membershipLimit as functions reading Entitlement |
a duplicate check in a service |
| Audit trail for sign-ins, email changes, linked accounts | databaseHooks writing ActivityEvent |
manual logging in route handlers |
| Last owner cannot be removed or leave | built into the plugin | a hand-written guard |
| Invitation expiry, limits, re-invite cancellation | invitationExpiresIn, invitationLimit, cancelPendingInvitationsOnReInvite |
custom invitation state |
| Shareable invite link | invitationUrl() in src/lib/urls.ts, minted alongside the invitation row |
a custom invite-code table |
Two client/server rules that are easy to get backwards:
checkRolePermissionis static and client-side — it renders UI.hasPermissionis the authoritative check and runs on the server. A control may be hidden by the first, but the action is only ever allowed by the second.- Adding or changing a plugin requires re-running
pnpm dlx @better-auth/cli@latest generate. Plugin schema is not inferred after the fact, and a missing table surfaces as a confusing runtime error.