Workers and reviewers use these client-app principles and engineering rules, with their reasons. If the approved story disagrees, follow it and explain the difference in your handoff.
These govern the apps Forge builds for clients. They add no gate, record or command: their checks run in the cold reads, the review's Simpler rule, the functional check and the check-back.
- Problem first. Every build starts from a job someone does today and what their workaround costs. A spec's Why names that problem and its cost, so we can tell whether building paid off.
- Smallest slice. Build the thinnest end-to-end path that moves the success measure, deployed where the client can use it. Everything else waits for evidence; no story is only setup.
- Only what is needed. Every task, screen, field, endpoint, tool, setting, role and
integration traces to a Done-when item and is needed: a source asks for it (a client note, a
brief answer, a decision, a screen marked Build now at sign-off, a fixed rule of the app, or the
owner's own request), or an item that has a source cannot work without it. An idea that
nothing needs goes as one line to
plans/ideas.md, and nothing is built for it. Anything else is cut, or goes under the spec's Out of scope. - Fewest moving parts. Climb the ladder below on every change. A new dependency, service,
datastore, queue, background job or abstraction layer comes last, and only when the story's
New moving partsline names it, because the client pays for it as long as the app runs. The starter's fixed rules are the base of each app. Build nothing beyond them just in case: modules, contracts, a schema per module andservices.tomlalready leave room to grow. - Delete before adding. Prefer the change that removes a step, screen, field or workaround. Replacing behaviour deletes the old path, its tests and its docs in the same change.
- One home per fact. Store each piece of data once; everything else reads or derives it. Before adding a table, field, cache, export or sync, name the record that already holds it.
- The simplest UI. One path per job, with the fewest screens, fields, clicks and choices, and native controls first. impeccable shapes the visual design and checks it before a demo; emil-design-eng makes interaction feel deliberate on every UI, including prototypes.
- Measure then check back. Success means the client's number moved, not that a feature shipped. Every spec has a metric, baseline, target and check date. At the check-back the choice is to stop, change the slice or remove it; adding more is never the default.
- Reversible. Every change is a pull request a revert undoes. Expand, then contract: add a new table or column before code uses it, keep the old table or column for two releases, and remove it only when no live release uses it. Keep data rather than drop it, and list any one-way step under Risks for the human to decide.
- Runnable by a non-technical client. Errors and empty states say in plain words what happened and what to do next, and the README keeps a short, current "How it works".
- Security and accessibility never simplified away. Input is checked where it enters, every data access checks permission, secrets and personal data stay out of code and logs, data is protected against loss, and every path has keyboard access, labels and readable contrast. Choose the simplest option that is still secure, never simply the simplest option.
The ladder. Stop at the first rung that holds: does it need software at all; does a Done-when
item need this code; is it already in the repo; the standard library; a platform feature (a
database constraint, an HTML control, a framework built-in); a dependency that's already
installed; one line; then the least new code that works. Mark a deliberate shortcut that has a
known limit with a ponytail: <limit>, <upgrade path> comment, so it reads as intent.
- Think before you code. State your assumptions, and never pick silently. If the brief can be
read two ways and both readings can be undone, pick the one closer to Done-when and write
Ruling: <what> - <why>in the commit body, so the reviewer sees it. Stop and say so only for a one-way step, a security question or a new moving part. A file outside your Scope that the change needs you may change; name it and why in your handoff. - Touch only what the task needs. Match the existing style, don't tidy neighbouring code, and mention unrelated dead code instead of deleting it. Remove what your own change left unused. Every changed line should trace to the task, so the reviewer can judge it against the story.
- Joining lines or removing blank lines never counts as a reduction.
- Keep reader load low. Avoid a wrapper or adapter with one caller, a layer that repeats the next layer's methods and arguments, or module or global state where a local variable or return value works. A new reader can answer "where does X come from?" and "what can change X?" in about 30 seconds. Keep structure a real concern requires below. Review reports reader load under its Simpler rule and normal Gain/Work/Harm bar, not as a P1 Standard finding on its own.
- Turn the task into checks first. A Done-when item that changes runtime behaviour becomes one end-to-end test at the boundary the user touches, and a settings, docs, deletion or test-only item is proven by the check the item names; a bug becomes a test that fails before the fix. Then make them pass.
- Fix the root cause. Reproduce the bug before you change the code. Find the first place the code goes wrong. Fix it in the shared function that every affected caller uses. Check sibling callers for the same fault. A guard is a fix only when it handles a valid state.
- Compatibility is a requirement, not a reflex. Unless the story names live users, API consumers or production data, a replacement deletes the old path: no shims, aliases, fallbacks or versioned migrations for data nobody has. When consumers are live, the story says so.
- Give one recommendation. When you present options, lead with the one you recommend, its reason and how sure you are. A list of options without a stance leaves the work to the reader.
- Choose technology on purpose. Use the stack already in the repo first, then the boring,
widely used option. Anything new goes in the story's
New moving partsline with the Done-when item that needs it. Ask the human only when the options differ in cost, lock-in or reversibility.
Structure earns its place by solving a problem the code has today. These rules say where. Add an abstraction layer only at the edge to an outside service. Keep business logic in domain services and pure computation in helpers. Required module boundaries, controllers and data access below keep their roles; do not wrap them in extra layers.
- One deployable app. Build a modular monolith: one backend, one database, one module per business domain. No microservices, and no planning or abstractions for future growth: one app is cheaper to run, test and change, and the client pays for today's needs, not guesses.
- Module boundaries. A module uses another module only through the service that module exports, never through its repository, tables or internal files. This keeps each domain's data owned in one place without a message bus. Use in-process events only when one action must trigger work in modules that shouldn't know about each other, and name them as a moving part.
- Thin controllers. Controllers handle HTTP: parameters, validation, status codes and the permission guard. Business logic lives in services, which never see request or response objects.
- Data access. Query through the ORM in the service that owns the data. Move queries into a repository file when a second service needs them or you need raw SQL, so the SQL has one home.
- Typed boundaries. Every endpoint has typed request and response DTOs. Validate input there, then trust the types inside. Never return ORM objects: they leak fields the caller shouldn't see.
- External services behind a provider. Each external system (payments, email, SMS, a vendor API) gets one provider class behind an interface, only because the concern is real: the vendor can fail, change or be swapped, and tests need a fake. The provider handles authentication, timeouts and retries, maps vendor errors to our error codes, returns our own types and holds no business logic. Pure computation is a helper, not a provider.
- An interface only at an external-service edge. Internal code needs no extra interface, factory or base class, except for a module's public service contract when another module needs that service. Use the domain service or helper directly.
- Shared code. A module's types and validators live in its contracts package, so its backend
and frontend agree. General helpers live in
libs/shared/util. Add a typed API client only when the frontend calls that API, and a notification module only when a Done-when item sends a notification. - File size. Split a file when it holds two unrelated jobs. Never split a file by line count.
- Names say the role. Files are kebab-case with a role suffix (
user.controller.ts,user.service.ts,create-user.dto.ts,stripe.provider.ts,date.helper.ts); classes are PascalCase with the same suffix (UserService); constants are UPPER_SNAKE_CASE.
The starter's modules are libs/auth, libs/projects, libs/organisations and libs/history.
A new client app uses the smallest stack that runs its first story: a pnpm workspace with NestJS,
Prisma and Postgres behind a React app built with Vite and React
Router. Nx builds apps/web and apps/api; each module has a contracts library for its schemas
and types and a server library for its Nest code. libs/core/server holds shared Nest code, and
libs/shared/util holds general helpers. Forge's tests check runs the test command. An existing
app keeps its package manager, lockfile, Dockerfile, test command and folder layout. Anything else
arrives only when a story's New moving parts line names it, and that includes Redis and job queues,
Terraform, OIDC sign-in and a monitoring stack.
Forge's package ships a short how-to per concern for this stack in forge/templates/conventions/:
stack.md, backend.md, api.md, database.md, frontend.md, testing.md and security.md,
plus python.md (the same rules for a FastAPI backend).
Open one only when your task touches its concern; the rules on this page apply either way.
The repo's own rules, its AGENTS.md House rules and conventions, win where they differ from these
default-stack conventions, which apply only to a repo on the default stack.
- REST under
/api/v1, with plural, kebab-case nouns (/api/v1/payment-transactions). An action that isn't create, read, update or delete is a POST after the resource (POST /api/v1/orders/{id}/cancel). GET never changes anything. - Every response uses one envelope, so clients always parse the same fields:
{ "success": true, "data": ..., "error": null }, or on failure{ "success": false, "data": null, "error": { "code", "message", "details", "errorId" } }. - Error codes are UPPER_SNAKE_CASE (
ORDER_NOT_FOUND). The message says in plain words what happened and what to do next. Never return stack traces, SQL or library errors, or secrets. - A validation failure is a 400 with the field errors in
details; it is normal, not an error. The field errors aredetails.fieldErrors: [{field, reason}], so the UI can place them. - PUT replaces the whole resource and PATCH changes part; neither is used for an action.
- Filters are plain query params; search is
?search=. - Lists use keyset paging. They take
limit(20 by default, at most 100) and one cursor,after(thenextCursorof the page before) orbefore(thepreviousCursorof the page after). They returndata.itemswithdata.paging(limit,nextCursor,previousCursor,total,totalCapped);totalstops at 10,000 andtotalCappedsays so. Sorting usessortByandsortOrder(ascordesc). Numbered pages are only for small admin screens. - A breaking change needs a new version while the old one has consumers; with no consumers yet, change it in place.
- Every create, and every action with a side effect (send, pay, notify), accepts an
Idempotency-Key. A repeat with the same key from the same account makes no second record and gives back the first result. An update that sets values takes no key. A webhook stores the sender's event id, so a replay is refused. - Screens, the API, agent tools and any later in-app agent use the same module service for each action, with the same permission check. No route, tool or job changes a module's data around its service.
- An agent account, for a team agent, acts as itself with its role. An agent that a person signs in acts as that person, with that person's permissions, and its principal names the agent, so the change history records both.
- Browser clients that send credentials get CSRF protection.
- Swagger documents every endpoint from its decorators at
/api/docs: a summary, each field with an example, the error responses and the auth it needs. The docs stay true because they're code.
- One global exception filter returns the envelope, hides internals and logs every error with
errorIdand the request'scorrelationId. Never swallow unexpected errors or return success for them. A deliberate fallback states why and logs a warning. - Use the framework logger for structured JSON, never
console.log:timestampUtc,level, fixedmessage,context,environment,serviceName,module,correlationId. AddaccountId,requestIdoreventIdwhen present. Keep messages searchable; put details in thecontextobject. - Log validation failures (4xx) at debug, never as errors.
- Error messages are static text with no interpolated values, so personal data can't slip in.
- Never log passwords, tokens, one-time codes, secrets or personal data; mask what you must keep.
- With
libs/core/server/src/common/request-log.middleware.ts(TypeScript) orset_log_levelinapps/api/app/core/logging.py(Python), the next three rules are built in. Other apps keep their logger, which may write all levels and keep secrets: never log secrets; add these rules only in a story that copies the starter's logger. - At any depth, context fields whose names hold
password,token,secret,keyorauthorizationget[removed]values. Log only needed ids. - Each API request writes one
infoline,API request: context hasmethod, matchedroutepattern,status,durationMs; the line has the request'scorrelationId. Never include query strings, real paths, headers or bodies. Health checks and web pages write no line. LOG_LEVEL(debug,info,warnorerror;infowhen unset) picks the lowest level written.- Mainline sends error alerts from the
errorlines, each with itserrorId. Add no error-tracking service to the app. - Levels:
debugfor detail,infofor normal business events,warnfor unexpected events without failure,errorfor failure,fatalwhen the app cannot continue. APP_ENVholds the environment's name in lower case:developmenton a developer's machine,testin the test stack, anddemo,qa,uat,stagingorproductionwhere a project has them. A project has only the ones it uses. The starters treat every name other thandevelopmentandtestas deployed. Logs go to the console locally and to the host's log service elsewhere.
A Python app writes these names in snake_case (payment_transaction, created_at_utc); see python.md.
- Postgres through Prisma. Tables are PascalCase and singular (
PaymentTransaction); columns are camelCase and don't repeat the table's name (Order.id, notOrder.orderId). - The primary key is
id, a UUID. A foreign key is<entity>Id(userId). Booleans start withis,hasorcan. Times end inAtUtcand are stored in UTC; convert to local time only at the edges. Durations carry their unit in words (expiresInSeconds). Status columns end inStatus(Order.paymentStatus) and hold a fixed set of values mapped in application code. - Every domain table has
id,createdAtUtcandupdatedAtUtc; once the app has accounts, alsocreatedByAccountIdandmodifiedByAccountId, so every change has an owner. Deleting is soft (deletedAtUtc, anddeletedByAccountIdonce there are accounts), which protects against loss; logs, temporary tables and data the client asked to erase are the exceptions. - Automated jobs fill the audit fields with a system account.
- A report is a named, typed read in its module's report service. Each result field has a
plain name with its unit (
revenueInr,durationSeconds). Start with a live query, and move it to a summary table only when the database skill's measure shows it slow, with no change for its callers. - Keep tables normalised; copy data only for a measured performance need, and say why. Use a JSON column only for metadata that is never queried or joined.
- Index every foreign key and every column a frequent query filters on, as
idx_<Table>_<column>, and every unique field. - Many-to-many uses an explicit join table named with both tables in alphabetical order (
RoleUser). - Every schema change is a migration with a timestamped name. Never edit a merged migration. Expand, then contract: add a new table or column before code uses it, keep the old table or column for two releases, and remove it only when no live release uses it.
- Use Postgres full-text search before adding a search engine.
- When a story does add in-process events: name them
<Domain><Action>Event, give each payload anid,version,timestampUtc,initiatedByAccountIdanddata, keep secrets out of it, and make every handler idempotent by recording the event ids it has processed, since events can arrive twice. Handlers never make HTTP calls or call providers directly, catch their own errors, and log the event name, id, outcome and duration.
- React with Vite. TanStack Query holds server data;
useStateholds local state; anything a shared link should reproduce lives in the URL. Start with a router for the app shell, sign-in, protected routes, and 404/403 pages. - Tailwind with shadcn/ui components, which are accessible by default. impeccable and emil-design-eng are required for every UI, prototypes included. impeccable owns layout, type, colour, states, copy and the checking pass: shape before building, then audit and polish before a demo. Run emil-design-eng's review checklist inside impeccable's one batched inspection; allow at most one more checking round. Invoke emil-design-eng with a specific task, never bare. It owns press feedback, easing, durations, popovers, tooltips, drag and when not to animate.
- Stagger only when a list appears as a list. Keep routine app-screen motion under 300 ms; a longer
duration belongs only to one authored landing-page moment. Content is visible by default and
enters with
@starting-styleor transitions, never hidden until a script runs. Share one ease-out token,cubic-bezier(0.23, 1, 0.32, 1). Use CSS or the Web Animations API first; add Motion only when a Done-when item needs springs or drag. Treat prototypes as app UI in impeccable's Operate mode, where Emil's restraint wins over bold effects. Popovers scale from their trigger with shadcn/Radix's transform-origin variable. Do not animate keyboard-driven or very frequent actions. - Every view that loads data handles loading, error and empty. The error says what to do next; the empty state says what the screen is for.
- Forms use shadcn Form with react-hook-form and zod, reusing shared validators and showing API field errors next to their fields.
- Accessibility is part of done: every control is reachable by keyboard with visible focus,
inputs have labels, images have alt text, contrast meets WCAG AA, and a click target is a
<button>or a link, never a clickable<div>.
Windows checklist:
-
A path written into a file or compared as text goes through
json.dumpsoras_posix. -
Tests never assume a drive letter or a '/' separator.
-
File operations in tests use the repo's lock-safe helpers where it has them.
-
Documentation-only changes need no new behaviour test; check claims, commands and links.
-
Every Done-when item needs an end-to-end test through the real entry point when it changes runtime behaviour: Forge's own command; for client apps, the running API with a real database and user flows in a browser through Playwright. Settings, docs, deletions and test-only items are proven by the check the item names. Each bug fix adds a test that fails without the fix. Fake only third-party services at their edge.
-
Each user-facing Done-when item gets one Playwright browser test when it changes runtime behaviour; such an item with no UI gets an HTTP test against the running app and a real database.
-
Unit tests are only for pure logic with many cases, never an item's only proof. Review reports an item proven only by unit tests as a P1
Not doneand never asks for unit tests of helpers. -
Tests own and clean up every process they start, even after a crash; detect and fail on leftover processes when a test worker crashes. Isolate host-side configuration so tests never use or change the developer's settings.
-
Only setup and cleanup file operations retry briefly on Windows file locks, with bounded retries; persistent failures still fail; never retry assertions or whole tests.
-
Tests build their own data with factories, own that data, don't depend on order, and never sleep. A skipped test names the reason next to it.
-
Client tests create their data through the app's API on a fresh database per run, with no shared seed or direct database inserts.
-
Playwright retries are zero; fix intermittent failures by waiting for real app state, never by adding a fixed sleep.
-
New and changed flows get browser tests now; an untouched old flow gets one when a story first changes it, with no backfill.
-
Tests check behaviour at the boundary the user touches, never markup, internal record formats or private calls. A behaviour-preserving refactor must not break a test. There is no coverage target: a number invites tests that prove nothing, and the review's test audit judges each test.
-
forge.toml's
testcommand runs everything CI needs (install, lint, typecheck, all tests), so a green run locally means a greentestscheck.
- TypeScript in strict mode. No
any(useunknownand narrow it), no@ts-ignorewithout a reason next to it, no commented-out code (git keeps history), and name a number whose meaning isn't obvious. - Read environment variables in one config module that validates them at start-up, so a missing
value fails at boot instead of at 3 a.m. Secrets have no defaults,
.envis never committed, and.env.examplelists every variable with a dummy value. - Comments explain why, not what.
- Security basics for every API: HTTPS, security headers (helmet), CORS limited to a named list of
origins, a rate limit on sign-in and other sensitive routes, request validation that rejects
unknown fields, parameterised queries only, and no
eval. Once the app has sign-in, every route that isn't public checks the caller, and every query is scoped to what the caller may see.
- Forge starts every branch and opens every pull request; nothing lands on the default branch except through a reviewed pull request.
- Commit messages are short plain English, and each commit leaves the build working, so any commit can be reverted alone.
forge closegives each pull request a plain-English title and a first line saying what changed for the client. The squash merge keeps both, and the board's history is built from them.
- Once a story adds infrastructure code, every cloud resource is defined there and changes through a pull request, never by hand in a console, so each change is reviewed and reversible.
- On AWS, each client has its own organization with an account per environment. Root credentials have MFA, no access keys, and are never used for daily work; workloads use roles, not keys. The root user's email is a mailing-list address, never a person's.
- Infrastructure as code is Terraform, with
terraform fmt, TFSec and a cost check in CI, added when a story'sNew moving partsline names cloud resources. - Name cloud resources
<org>-<cloud>-<region>-<environment>-<type>-<project>-<name>in lowercase, and tag each one with its owner, environment and project, so costs and ownership stay traceable.