This document lives in the repository and is maintained in English. The site does not translate it.
Read this file on GitHubThe locked stack
Settled on 2026-09-09, one question at a time. The API layer was replaced on 2026-09-29, the same way.
Treat this as given. If you believe an entry is wrong, say so once, clearly, then follow it.
Structural
| Multi-tenancy | Only when the project is multi-tenant, because it is a third axis beside Type and Architecture, and a single-tenant project receives none of it. Then: tenant_id on every tenant-scoped table, isolated by a MikroORM filter. No schema-per-tenant. RLS is stronger and unmeasured, and is not adopted. |
| Primary keys | UUID v7, generated by the database: DEFAULT uuidv7(). Not by the application. |
| Delete | Hard delete is the default. deleted_at requires a stated reason: audit, user-facing recovery, legal retention. |
| Timezone | UTC in the database. timestamptz on every time column, without exception. Conversion at the edge only. |
| Database | PostgreSQL 18 or later. Not a variable. |
| Platforms | macOS, Linux and Windows. Windows natively, decided on 2026-10-13: the project in a Windows folder, run by the Windows Node and pnpm, with Docker Desktop on its WSL2 backend only as the engine for Postgres, which is a Linux image. Entirely inside a WSL distribution, with its own Node, pnpm and Docker, is Linux: supported, not tested on WSL specifically. Not supported: Windows tools, Prumo Desktop included, on a project inside the WSL filesystem (\\wsl$, \\wsl.localhost). |
| API architecture | Ports and adapters. src/domain holds entities, ports, use cases, DTOs and errors, and imports neither the ORM nor the HTTP framework. src/infra holds the adapters and the edges. The rule that the domain does not import the infrastructure is a written convention, not a checked one. |
| Errors | Exceptions. No Result type. A domain error extends DomainError and carries a kind; one error handler in infra/http maps kind to a status and answers Problem Details. |
Version floors
These accumulate. Every one is a machine a generated project will not run on.
| Floor | Required by |
|---|---|
| PostgreSQL 18 | uuidv7(), which shipped in September 2025 |
| Node 22.23.4 | Astro 7, whose engines require it; raised from 22.18 on 2026-10-27 for every project, not only one with an Astro web app. 22.18 itself brought type stripping on by default, which loads the TypeScript migrations in the API and its tests |
| pnpm 10.26 | allowBuilds in every generated workspace, added in pnpm 10.26.0. prumo doctor and prumo new refuse an older one |
| TypeScript 6.0 | Every template is pinned to and proved on it. MikroORM v7 alone would require 5.8 |
Core
| Area | Choice |
|---|---|
| Monorepo | pnpm workspaces, with native catalog: for versions. No Turborepo. Applies only when the architecture is monorepo. |
| Language | TypeScript 6: module and moduleResolution nodenext, target ES2023, strict on, strictPropertyInitialization off. Plus noUncheckedIndexedAccess. Not exactOptionalPropertyTypes. The API adds experimentalDecorators and emitDecoratorMetadata, which tsyringe needs. |
| Quality | Biome. Pre-commit is .githooks/pre-commit running biome check --staged --write --no-errors-on-unmatched, wired by "prepare": "node .githooks/install.mjs", which sets core.hooksPath inside a Git repository and does nothing outside one. No husky, no lint-staged. |
| Branches | task/, fix/, chore/, docs/, refactor/ → dev → staging → main; large work task/ → project/ → dev, and a project takes dev to stay current. Decided on 2026-10-27. Every project carries the rule in .prumo/core/git.md, .github/workflows/flow.yml to check it, and .github/rulesets/ for the README's gh api commands that make it required. git init starts on main. |
| API | Fastify 5, with fastify-type-provider-zod. One plugin per module is the controller. DTOs are Zod schemas in the domain: z.strictObject for input, so an unknown field is a 400, and z.object for output, so a field the schema does not list never leaves. @fastify/swagger builds the OpenAPI document from the same schemas and @fastify/swagger-ui serves it outside production. |
| Dependency injection | tsyringe. A port is an interface plus a Symbol token in the same file, injected with @inject(TOKEN); each module registers its adapters in infra/di/<module>.di.ts. reflect-metadata is the first import of every entry point. |
| Build (API) | SWC for everything that runs: @swc-node/register in development, unplugin-swc in tests, @swc/cli for the build, which also rewrites the @/ alias. tsc only checks types. CommonJS output. Migrations are outside the build: Node loads them as TypeScript by type stripping, as ES modules, which migrations/package.json declares. |
| ORM | MikroORM v7, with @mikro-orm/postgresql and @mikro-orm/migrations. An entity is a plain class in the domain with no ORM import; new EntitySchema({ class }) maps it in the infrastructure. One fork per request through RequestContext, and a TransactionManager port for work that must be atomic. |
| Auth | The API's own auth module, on MikroORM like everything else: User and Session entities, ports for the tokens and for each way in. Access token: a JWT for 15 minutes, HS256 through jose. Refresh token: opaque, stored as a hash, rotated on every use, a reused one revoking its session. How people sign in is a question prumo new asks, one or more of: a password, Argon2id through @node-rs/argon2; a 6-digit code sent by email or by text, which creates the account the first time; a phone is read as E.164 through libphonenumber-js and texted only to the countries in SMS_COUNTRIES. The web holds both in httpOnly cookies, checked against WEB_ORIGIN; mobile holds them in expo-secure-store and sends a bearer. @fastify/rate-limit on the routes that take a password or a code, and a limit per destination on every code sent. A global preHandler hook protects every route; a public one says so with config: { public: true }. Two more are questions prumo new asks, and ship only when answered yes: with a password, email verification and password reset, by a 6-digit code sent through a Mailer port that writes to the log; and sign-in with Google, Apple or both, through openid-client, linking by email only when the provider verified it. In the mobile app, the provider's own sheet where there is one, its ID token checked with jose: Apple's on iOS through expo-apple-authentication, on a nonce from the API, and Google's through @react-native-google-signin/google-signin once the app is registered with Google; the browser otherwise. |
| MCP | Only when the project has an api and a web and answers yes, a fourth axis beside Type, Architecture and Multi-tenancy. Then: an MCP server at /api/mcp on @modelcontextprotocol/server 2, stateless, 2026-07-28 protocol only; tools call the same use cases as routes. The API is its own OAuth 2.1 authorization server, in an oauth module: discovery (RFC 9728 and 8414), PKCE S256, the resource as the token's audience (RFC 8707), iss (RFC 9207), clients identified by their metadata document (CIMD) and nothing else, sign-in and consent on the web app, and scopes per module, <module>:read and <module>:write, refused with insufficient_scope. The templates ship with it, and the CLI cuts it out of a project that answered no. |
| Languages | Every project speaks one or more of English, Brazilian Portuguese and Latin American Spanish, its base first, decided on 2026-10-29: prumo new --languages. One language, or several, decided on 2026-10-30: --single-language writes the one language's words where they are shown and keeps nothing of the languages' machinery, since an assistant converts a project when it needs a second; --multi-language speaks from messages. The templates are written for several, and the CLI writes a project of one by inlining its language's messages (cli/src/inline.ts). No i18n library: each app's messages are a typed TypeScript object per language, checked against the base's by the compiler and by a test, read through a small format and negotiation of the template's own, which run alike in the API, a Server Component, an Astro page and React Native. use-intl and next-intl were weighed (they need Intl.Locale, which Hermes documents no support for) and i18next with react-i18next and next-i18next. The API answers in the request's Accept-Language, writes mail in the account's language, and has zod write field messages through z.locales. In a workspace the list is the @app/i18n package, which the API loads as TypeScript through Node's type stripping. |
| Observability | Structured logs from Fastify's built-in Pino, carrying the request id, and a health check. No error reporting and no tracing by default. See Documented, not installed below. |
| Testing | Vitest, Testcontainers, Testing Library. A use case is tested against an in-memory fake of its port, an adapter against a real Postgres, a route through inject(). |
| Web | One type, with a framework chosen per app, Vite by default. Every web app: React, Tailwind, shadcn/ui, react-hook-form with Zod as the resolver, and in the browser TanStack Query, the same contract and the same sign-in. Vite: TanStack Router, all in the browser. Next: the App Router, never the Pages Router; public pages static by default, revalidated where content changes, dynamic only with a stated reason, their data fetched on the server; the signed-in area in the browser. Astro: pages built ahead of time, React islands mounted client:only, no adapter until a page must render per request. |
| Mobile | Expo with prebuild, run in a development build through expo-dev-client, never Expo Go, whose native code lacks MMKV. Expo Router, NativeWind, Reanimated, MMKV, expo-secure-store. |
shadcn/ui is not a dependency, because components are copied into the project. Templates ship its
plumbing and only the components their own screens use, installed by its CLI, on Base UI, which is shadcn's
default primitive library.
Documented, not installed
Prumo never installs a tool requiring a commercial account. Such a tool is documented (how it is wired, what it needs, what it changes) so it is ready the day the developer wants it, and absent until then. Installing a service that needs an account, a DSN and eventually a bill is a commercial decision made on the developer's behalf, inside a project they own.
| Capability | Documented adapter | Default |
|---|---|---|
| Error reporting and tracing | @sentry/node, with its Fastify integration, see api/observability.md |
not installed |
| Sending email | An SMTP adapter with nodemailer, or a provider's HTTP API, behind the Mailer port, see email/mailer.md |
LogMailer, which writes each mail to the API's log |
Deliberately dropped
Nineteen entries left the stack. Each was removed for a stated reason, not trimmed for taste.
| Dropped | Because |
|---|---|
| NestJS | Replaced on 2026-09-29 by a structure of our own on Fastify, so the API no longer depends on a large framework. class-validator, class-transformer, @nestjs/swagger, @nestjs/terminus, nestjs-pino and @mikro-orm/nestjs left with it. |
Result / neverthrow |
Fastify and MikroORM both throw, and two error models in one codebase is worse than either. |
| Better Auth | Replaced on 2026-10-02 by an auth module of our own. It owned a second schema, connection and migration tool beside MikroORM, which once had generated migrations drop every login; its plugins had to move as one family, or a second core broke the build. The cost, accepted: the security of sign-in is now this code's, and email verification, password reset and social sign-in are written here, as questions prumo new asks, rather than taken from the library. |
| ts-pattern | Existed for the Result union. switch with a never default gives the same exhaustiveness for free. |
| dependency-cruiser | Ports and adapters came back, and keeping the domain free of the infrastructure was left to convention. It had also been measured producing a useless file to satisfy no-circular. |
| Scalar | @fastify/swagger-ui is maintained by the Fastify organisation. Scalar releases almost daily, and with exact pins that is a manual upgrade every week. |
| tsx | esbuild does not emit decorator metadata, so a class injected by type fails with TypeInfo not known in development only. |
| The porting rule | Removed explicitly by the restart. |
| Drizzle + drizzle-kit | Replaced by MikroORM. Still on 0.x with an unreleased breaking v1. |
| Turborepo | Its value is caching, and a two-package monorepo does not hurt yet. |
| syncpack | pnpm catalogs do the job natively. |
| knip | Finds nothing on a day-zero project. |
| husky + lint-staged | biome check --staged and core.hooksPath replace both, with no dependency. |
@t3-oss/env-core |
Its distinctive value, keeping server secrets out of a client bundle, does not apply to an API with no bundle. Zod validates the environment by hand in thirty lines. |
| Zustand | TanStack Query owns server state; what remains is usually context-sized. |
| FlashList | FlatList handles small lists, and the swap is local when they grow. |
| MSW | Mocks a network that does not exist yet. |
| Playwright | Downloads a browser to walk screens that do not exist yet. |
exactOptionalPropertyTypes |
The only strictness flag whose friction lands in library types rather than your own code. |
The two rules that decided most of this
The day-zero test. Does this have work on the day the project is born? It removed knip, MSW, Playwright, ts-pattern and Zustand.
Its limit. The test does not apply to anything that owns data, because arriving late is then a migration rather than a swap. That kept MMKV and authentication. Whatever owns data is decided at the start; whatever draws a screen can wait.