Bring this repository up to the standard
You are working in a repository you may not have written. Your job is to leave it
structurally sounder, more secure, more portable and more verifiable than you found it,
and to leave behind the mechanisms that keep it that way after you are gone.
The standard below was distilled from production codebases built and operated by one
engineer working with AI agents: a wagering platform with an append-only ledger and
automated settlement, a coaching marketplace on Stripe Connect and a calendar provider,
and a multi-tenant AI portal that runs coding agents in sandboxed VMs. Every rule exists
because its absence caused a real defect. Where a rule names the defect, that is the
reason it is a rule and not a preference.
Framework stance: the invariants are framework-agnostic; the examples are TypeScript,
React, Node and Postgres. Three concrete targets are named throughout: Next.js App
Router for a full-stack app, Hono for a standalone or multi-runtime API, Expo for
native. Adapt the naming to whatever the repository uses. Do not adapt the invariants.
What done looks like
When you finish, this repository has these seven things, and not one of them contains a
claim that nothing checks:
- A feature-sliced structure with one-way imports. Domain code lives with its
domain; the router holds only routing; a shared floor never imports upward.
- One data path. Every read and write from a client goes through a versioned API
with a fixed envelope, gated, validated, delegated to a service, and consumed through
one typed client helper. No server actions, no ORM calls from the browser.
- Three-layer security that holds when any one layer is wrong: an application gate
that resolves identity and tenant before any data work, table-level privileges that
start closed, and row-level policies as the net underneath.
- A data model whose types come from the database, whose schema is declarative,
whose migrations are additive and reviewed, and whose value sets cannot drift between
SQL and TypeScript.
- A verification floor: the invariants that cost money, trust or tenancy are caught
by a type, a derived test, a lint rule or a build assertion, each proven by breaking it
on purpose; plus an integration floor that runs the real service layer against a local
database and cannot reach a third party.
- Portability by construction: pure domain logic and contracts in packages a second
client (native, agent, another operator) can import without a build step.
- An instruction layer that stays true: standing instructions, path-scoped domain
rules, an append-only decision ledger with a generated index, and a written loop that
turns each defect into a mechanism.
If some of these exist, audit and repair them. If none do, build them. The governing idea
is the same either way: an instruction nobody checks goes stale, and a stale instruction
is worse than a missing one because an agent follows it without hesitating. Every claim
you write must be verifiable against the code today, or replaced by a mechanism that
verifies itself. Prefer the mechanism.
Absolute constraints
- Never run
git commit, git push, git reset --hard, or any history rewrite unless
the owner tells you to in the message you are answering. Doing work is not consent to
commit it. If the harness supports it, make this a permission prompt, not just prose.
- Never run migrations, seeds, resets or deploys against anything but a local database.
If a command's blast radius is unclear, do not run it. Ask.
- Never read, print, grep or echo a secret value. Check existence or length only.
- Never delete or overwrite an existing file without showing the diff and asking.
- Do not install dependencies without asking. Do not remove a supply-chain control
(install cooldown, audit gate, frozen lockfile) to make a command pass.
- Never weaken a security or billing invariant to satisfy a design principle. A cleaner
abstraction that loosens a gate or an idempotency key is not cleaner.
- Long, compute-heavy runs (a full test suite, a browser walk, a production build, an
eval) are the owner's clock. State what needs running and let them choose. Typecheck,
lint and a single targeted test file are always fine.
- Report what you could not verify. An unverified claim stated as fact is the exact
failure this system exists to prevent.
Phase 1: audit. Change nothing.
Read only. Produce a written report, then stop and wait.
1a. What already exists. Agent instruction files at the repo root and in any tool
config directory (AGENTS.md, CLAUDE.md, .claude/rules/, .cursorrules), plus
README, CONTRIBUTING, ARCHITECTURE, docs and any decision log. For each: path, line
count, rough token cost (words divided by 0.75), last modified, and how much restates
what the code already says. Sample every concrete claim: does that file exist, does that
function have that name, does that command run, is that list complete? Report how many
you checked and how many were wrong. Do not soften it.
1b. Which dialects the code speaks. Do not ask; read. Run these probes and record
counts and locations. Each one is a known defect class from the source codebases:
# Run from the repository root. git grep skips ignored paths (node_modules, build
# output) and takes quoted pathspecs, so these run the same under bash and zsh,
# and every regex is POSIX so BSD and GNU grep agree.
# Server actions (lock the backend to one framework; unusable by mobile/agents)
git grep -n "use server" -- '*.ts' '*.tsx'
# Browser-side database access (bypasses the API boundary)
git grep -nE "createBrowserClient|from\('|from\(\"" -- '*.tsx' | grep -v "auth\."
# Unread write results: supabase-js RESOLVES { error }, it never throws
git grep -nE "await [a-zA-Z_.]+\.from\([^)]*\)\.(insert|update|upsert|delete)" -- '*.ts' | grep -v "const {"
# Silent config fallbacks (set once, forgotten, repo says otherwise)
git grep -nE "process\.env\.[A-Z_]+ \?\? ['\"]" -- '*.ts'
# Type escape hatches
git grep -nE ": any([^[:alnum:]_]|$)|as any([^[:alnum:]_]|$)|@ts-ignore|@ts-expect-error" -- '*.ts' '*.tsx'
# Data fetching in effects
git grep -nE "useEffect\([^)]*fetch|useEffect\([^)]*\.from\(" -- '*.tsx'
# Inline query keys (silently break invalidation)
git grep -nE "queryKey: \[['\"]" -- '*.ts' '*.tsx'
# Raw form controls where a design system exists
git grep -nE "<(input|button|select|textarea)([^[:alnum:]_]|$)" -- '*.tsx'
# Hardcoded colours where tokens exist
git grep -nE "bg-\[#|text-\[#|(bg|text|border)-(red|green|blue|emerald|amber)-[0-9]" -- '*.tsx'
# Error envelope drift
git grep -n "{ error:" -- '*route.ts'
# Route handlers without an explicit return type
git grep -nE "export async function (GET|POST|PUT|PATCH|DELETE)\([^)]*\)[[:space:]]*\{" -- '*route.ts'
# Numeric fallbacks in business logic (hide null bugs)
git grep -nE "(balance|stake|amount|price|total|count)[a-zA-Z_]* \?\? 0" -- '*.ts'
# Server-side getSession() (spoofable)
git grep -n "getSession()" -- '*.ts' | grep -v client
# Reads that can exceed the API row cap
git grep -nE "\.limit\((1[0-9]{3,}|[2-9][0-9]{3,})\)" -- '*.ts'
Then, against the database schema if there is one:
- Which functions have EXECUTE granted to
PUBLIC (a NULL proacl in pg_proc is the
default, and the default is open). Any SECURITY DEFINER function in that set is a
P0: it runs as its owner and bypasses both grants and RLS.
- Which tables grant anything to
anon; which grant writes to authenticated.
- Which foreign keys to the auth users table lack an explicit
ON DELETE.
- Whether the ledger, audit or event tables can be updated or deleted by the service
role.
- Whether TypeScript status unions are hand-written or derived from generated types.
Note where a pattern is consistent and where it has forked into two dialects. A forked
pattern is worth writing down. A consistent one usually is not; the code teaches it.
1c. What has already gone wrong. Evidence, not intuition:
- Commit messages shaped like incidents: fix, revert, hotfix, regression, "again",
"actually". A bug fixed twice is a guardrail waiting to be written.
- Defensive comments: "do not remove", "must run before", "looks redundant but". Each
is an invariant someone learned the hard way and could only write in prose.
- Clusters of TODO, FIXME, HACK. Where they cluster matters more than how many.
- Anything touching money, authentication, tenant or user isolation, data deletion,
external calls, retries, background jobs, webhooks. Silent failure costs most here.
- The same defensive check repeated in many places. Repetition under duress is an
unmechanised invariant.
If history is squashed or short, say so and lean on the other sources. Do not invent
incidents. A short evidenced list is the correct output.
1d. What verification exists. Test framework and how it runs. Whether tests hit a
real local database or mocks. Linter, and whether it gates or reports. Type checking.
CI: what actually blocks a merge, and with what token permissions. Whether anything
stops a test from reaching a third-party service with live credentials. Then the
question that matters: of the invariants in 1b and 1c, which are caught automatically
today, and which depend on someone remembering?
1e. Portability and scale ceilings. Is there a second consumer of the API (mobile,
an agent, an MCP server, a partner) today or in the stated roadmap? Which logic would it
need (validation, pricing, rules) and where does that logic live now? Where are the
hard ceilings: the API row cap, in-memory rate limiters per instance, a single-fixture or
single-tenant foreign key that the roadmap needs to be many-to-many, a queue that does
not exist.
Stop here. Report what exists, what is stale with counts, the invariants with their
evidence, the portability gap, and the gap between what matters and what is checked.
Wait for a decision.
Phase 2: the target shape
This is the reference you audit against and migrate toward. It is written as rules with
the failure each prevents, so you can judge whether the failure applies here. When it
does not, say so in the proposal and skip the rule; do not apply it ceremonially.
A. Structure and layering
- Feature slices, not type folders.
features/<domain>/ owns its vertical:
components/, hooks/, services/, validation.ts (Zod, shared by client form and
API route), types.ts (the contract both services and hooks import), optionally
rules.ts (framework-free business rules) and utils/ (pure predicates). Create only
what the domain needs; empty folders are forbidden. Thin slices (components only, or
data only) are fine.
- The router holds routing primitives only.
page.tsx, layout.tsx, route.ts,
loading.tsx. No _components/ directories under the router. A page is five to seven
lines: it imports a feature component and renders it. Pages never contain form logic,
data fetching or business logic.
- Cross-cutting UI lives in
components/ and nothing else: ui/ (design-system
primitives, no business logic), providers/, layout/, marketing sections. Nothing
new goes into a components/shared/; that directory is where slices go to die.
- One-way imports.
types → lib → hooks → components + features → app. lib/ is the
shared floor and never imports from features/, components/ or app/. A module in
lib/ that reaches up into a feature is that feature's policy wearing a lib/ path;
move it. The one sanctioned exception is an aggregator whose whole job is to present
every domain's tunables in an operator panel, and it is listed as such.
- Derive the layering table, both ways. If you document which modules violate the
direction, a test walks the filesystem and compares. A new violator fails; a fixed one
still listed also fails, so nobody cites a dead violation as precedent.
- Alias imports for cross-directory, relative only for siblings in the same slice.
- A monorepo when a second consumer exists, not before.
apps/{web,mobile,...} over
packages/{types,contracts,domain,adapters,ui,tokens,eslint-config,typescript-config}.
Packages that a native bundler must transpile from source stay dependency-free apart
from the validation library. Until the second consumer arrives, keep pure domain logic
(rules, pricing, catalogues) in files that import nothing framework-specific, so the
lift is a move, not a rewrite.
- Name conventions once. Schemas are
<feature><Action>Schema. Hooks are use*.
Query-key factories are <domain>Keys. Services take the injected database client as
the first argument. Amounts are integers in the smallest unit; timestamps are UTC.
B. The one data path
client component
→ features/<domain>/hooks (React Query)
→ fetchApi<T>('/api/v1/...')
→ route: gate → validate → service → envelope
→ features/<domain>/services (injected admin client, scoped by the RESOLVED owner/tenant id)
→ database
- No server actions. They lock every mutation to one framework and are invisible to
a native app, an agent, an MCP server or a partner. Route handlers are inspectable,
testable and callable by anything that speaks HTTP. This applies to auth callbacks and
integrations too; the only thing the browser SDK does directly is authentication and
push-based realtime.
- The route shape, always in this order:
- Gate first, before any database work. The gate returns a response on failure and
the handler early-returns it.
- Validate the body with a module-level Zod schema from the feature's
validation.ts. Report the first issue as path: message, HTTP 400. A malformed
[id] param answers 404, never a raw cast error 500.
- Call the service with the injected client and a params object. The service returns
an outcome; it never returns a response object.
- Return the envelope:
{ success: true, message, data } or
{ success: false, message }, HTTP status on the response. Never { error }.
Rate-limited is 429 with Retry-After and the same body shape.
- Explicit return type on every handler.
Promise<NextResponse> for the envelope,
Promise<Response> only for a handler that genuinely streams or returns a raw body.
TypeScript infers a return type happily and reports nothing, so an unannotated handler
drops the contract with no signal. Derive the set of raw-body handlers from the code
(a new Response(, a ReadableStream) and fail a Promise<Response> on a handler
that builds none.
- One typed client helper. Every hook calls
fetchApi<T>(url, init?, timeoutMs?).
It sets the JSON header unless the body is FormData, applies a default abort timeout,
reads the envelope once, throws message on failure, returns unwrapped data on
success. Raw fetch in a hook is a defect. The browser timeout stays below the
server's hard kill so the client sees a clean error before the function dies.
- Behaviour lives in the service; every consumer inherits it. A REST route, an agent
tool and an MCP tool all call the same
updateTask; a miss returns false in one
place and becomes 404, a tool error and not_found respectively. Never fix behaviour
in a consumer.
- Input bounds live in
validation.ts field validators and are imported by the API
schema and every tool surface. A .max() hardcoded in a tool will drift.
- Public, no-auth routes are a register, and the register is derived. A test walks
every route file, finds the ones that call no tenant gate, and fails unless each is
named in the rules file with one sentence of why it is safe. It checks presence, never
the reasoning; say so in the test.
Hono, when the API is a separate deployable. Same four steps, same envelope, same
services. Differences that matter:
- Routes must be chained and composed with
app.route('/x', sub) for the type to be
inferred; export type AppType = typeof routes from the composition root.
- Validate with
zValidator('json' | 'form' | 'query' | 'param', schema); without a
validator the client's request type is unknown.
- Return
c.json(body, status) with an explicit status on every arm, or the client
cannot narrow on res.status.
- The typed client
hc<AppType>(baseUrl) replaces hand-written response interfaces on
the web and native clients. In a monorepo, compile the API's types (project references)
or the IDE will compute them on every keystroke.
- One codebase deploys to Node, Bun, Cloudflare Workers, Deno and Vercel. Choose Hono
over framework route handlers when the API has non-React consumers, needs to run
somewhere the frontend does not, or when coupling the backend's release cadence to the
frontend's has become a liability. Keep the envelope identical so clients cannot tell
which one they are talking to.
C. Security, three layers
Any one layer failing must not open the money or tenancy path.
Layer 1: the application gate.
verifyAuth() verifies the session (local JWT verification is fine for the proxy; a
server-authoritative call when you need the user object; never a spoofable session
read on the server) and returns { userId, adminClient } or a 401 response.
- Ownership after identity. A resource the caller does not own is 403 on an owned
surface, 404 on a tenant surface (the same answer as "does not exist", so there is no
existence leak).
verifyAdmin() checks a role stored in a row the user cannot write, never a
self-writable flag. A platform-operator gate answers 404, never 403, and its predicate
is an operator flag, never a role check: an operator who resolves as owner on every
tenant would otherwise open the cross-tenant surface to every client owner.
- Never trust a tenant, owner or account id from the request body or query. It is
resolved from the authenticated user. The one sanctioned body-carried id is on an
operator route whose gate is identity-based, and it is documented as the exception.
- Paid or expensive surfaces take a second gate immediately after the tenant gate: rate
limit, then budget. It returns 429 or 402 with a human message, never a silent
degrade.
- A cron or worker route authenticates with a constant-time bearer comparison against a
secret that fails closed when unset. A webhook authenticates with an HMAC over the
RAW body (never re-serialised before verifying), a replay window where the provider
supports one, and answers 401 on a bad signature.
- The CSRF check lives in the proxy: a mutating
/api/* request whose Origin is
present and not one of this deployment's hosts is 403. Fail closed on an unparseable
origin. Token and HMAC callers send no origin and pass.
- Every post-auth redirect target goes through one
safeNext() that allows only a clean
single-slash relative path, blocking //evil and /\evil.
Where the application reaches the database through a single role (an ORM over a pooled
connection, a service account) rather than per-request roles, layers two and three
collapse into the gate plus the append-only grants: say so in the proposal, and do not
invent roles the database does not have.
Layer 2: table privileges, starting closed.
- Explicit
REVOKE ALL on every table from every role, then the minimum grants back.
anon gets nothing. authenticated gets SELECT only, row-scoped by RLS.
service_role gets CRUD except on append-only tables (ledger, audit, events), where it
gets SELECT and INSERT only. A balance history cannot be rewritten by buggy service
code because the privilege to rewrite it does not exist.
- Revoke EXECUTE from
PUBLIC, not just from named roles. CREATE FUNCTION grants
EXECUTE to the PUBLIC pseudo-role by default and every role is a member of it, so
per-role revokes remove nothing. Add ALTER DEFAULT PRIVILEGES ... REVOKE EXECUTE ON FUNCTIONS FROM PUBLIC or every future function reopens the hole. A source codebase
found its settle, void and create money functions callable with the browser key
this way.
- A new table starts closed. A client-readable table gets RLS, a SELECT policy and a
SELECT grant in its own schema file. A secret, money or infrastructure table gets RLS
on, no policy, no grant: service-role only, served to the client through the API.
- The diff engine drops REVOKEs and does not diff function ACLs at all. Carry the
grants file verbatim and probe the deployed catalog after every grants migration.
Layer 3: row-level security as the net.
- Own-row SELECT policies on user tables; SELECT policies on reference tables; zero
client write policies anywhere, so a direct write is denied even if a grant slips.
- Access helpers are
SECURITY DEFINER, STABLE, set search_path = '', read
auth.uid() internally, and are the single source every policy calls. Grant them to
authenticated only where a policy needs them.
- Test RLS as
authenticated inside a transaction (set local role plus the JWT claims
config). Outside a transaction you silently run as superuser and RLS is bypassed,
which is a false pass.
- A settlement-lock or job table with RLS on and no policies is intentional and must be
documented as such.
Supporting controls, each with its incident:
- Secrets fail closed through one
requireEnv(name); never ?? "" (fails open on an
HMAC) and never ! (a cryptic 500 later). Non-secret config is a typed constant in
the module that owns it, not process.env.X ?? "default".
- An env manifest declares every variable the app reads with a tier
(
required | production | feature | platform) and a note. The production build fails
when a required or production var is missing and names every missing one at once. The
same list feeds an operator config page. A test pins the two. Enforce when absence
produces a healthy-looking lie; leave as feature only when degradation is visible at
the point of use.
- Tunable constants that govern spend or safety are registered in one aggregator
surfaced read-only to the operator, with a
source: file pointer a test resolves.
- Unsubscribe and similar public links use a per-user random UUID token, never an
enumerable row id; GET renders a confirmation, POST mutates (mail scanners fetch every
link); the RFC 8058 one-click headers point at the same URL; the route always redirects
to the same page regardless of outcome so an attacker gets no signal.
- Free text that reaches an HTML sink (email bodies) is escaped. JSX auto-escapes; the
email templates do not.
- Rate limiting is per serverless instance and says so. Public no-auth routes that send
email or spend money IP-limit themselves and carry a honeypot.
- An audit log for operator actions is append-only at the grant level, records both
success and failure paths (including the validation-fail 400), and runs a recursive
key scrubber over its payloads that drops balances, stakes, payouts, emails and tokens.
In development the scrubber throws so the leak surfaces at the call site.
- Every foreign key to the auth users table declares
ON DELETE: cascade for owned
data, set null for attribution, and a deliberate block on primary ownership. Right to
erasure must be executable, not blocked by a constraint nobody chose.
- Check-then-act RPCs (caps, quotas, once-per-user) take a transaction-scoped advisory
lock keyed by the user id as their first statement. The session-scoped variant breaks
under connection pooling.
D. The money path
Skip this section if the repository moves no value. Apply all of it if it moves any.
- The ledger is append-only at the grant level. No UPDATE, no DELETE, for anyone.
Corrections are new signed rows, never edits.
- Settlement is one atomic stored procedure. Resolve, update status, write the
ledger row and adjust the balance in one transaction. Balance arithmetic is
balance = balance + delta, never read-then-write, with a CHECK (balance >= 0)
underneath. Concurrent settlement is guarded by FOR UPDATE SKIP LOCKED, a
settled_at IS NULL predicate and a unique index on (user, type, ref) that makes a
second payout row impossible.
- Idempotency keys are per unit of work, content-addressed, never positional. Per
invocation, per content hash, per provider reference. A positional key (a batch
cursor) double-charges on exactly the jobs that resume.
ON CONFLICT (key) WHERE key IS NOT NULL DO NOTHING must match the partial unique index predicate exactly or
Postgres raises 42P10.
- Writes resolve, they do not throw. The database SDK resolves
{ error } on a
constraint or timeout. A bare await db.from(t).update(v) or a destructure without
error is a fully silent failure. Every write on a money, membership or tenancy path
reads { error }, logs it with context, and throws on a webhook or retry path so the
provider redelivers. Enforce with a lint rule (see Phase 5); satisfy it by reading the
error, never by disabling the rule.
- Money-path reads fail closed. A transient blip read as "no rows" is a mis-charge:
zero members grandfathers the cheapest tier forever, an unresolved customer drops a
paid invoice. Read
{ error } and throw. Scope a second lint rule by table.
- Conditional claims go through one primitive.
claimOne(query) returns
matched | not_matched | error; map only not_matched to 404 or busy. A bare !data
guard hides a real error as a benign no-match with zero operator signal.
- External resources are owned, including their teardown. A cascade reaches only
rows. Whatever creates a subscription, a project, a repo, a VM, owns destroying it on
delete, through a single teardown primitive every delete path reuses, or documents in
the decision ledger why it is retained. Persist the reclaim handle atomically with or
before the create; a handle written in a later statement leaks when the request dies
between them.
- Every non-terminal status has a reaper that is not the in-request
catch. A hard
kill (OOM, max duration, SIGKILL) never runs the catch. An idle-windowed sweep folded
into the jobs worker terminalises processing, publishing, running. Its window is a
parameter, because an updated_at trigger defeats an aged test row.
- Webhook handlers resolve the account from a stored mapping written from an
authenticated context, never from payload metadata; a disagreement is an alarm.
Process synchronously (the provider retries a non-2xx) and rely on the idempotent
grant. Gate on the provider's live-mode flag where events from both modes share an
endpoint.
- A provider's refund and fee flags default to the opposite of what a marketplace wants;
name them explicitly and comment the consequence of each default.
- Stakes, caps, fee percentages and multipliers are configurable data or named
constants, never inline literals; the client displays what the server will honour,
computed by the same function.
E. Data modelling, schema and types
- Declarative schema files are the source of truth, numbered, applied in file
order. A new table, its RLS policy and its grants go in the same file. A policy or
index that references a column added in a later file breaks the shadow database and
disables diffing repo-wide.
- Migrations are generated artefacts, reviewed line by line against the silent-miss
checklist: all DML,
ALTER POLICY, every REVOKE, function ACLs, views,
schema-level privileges. Anything in that list is written by hand. A clean diff proves
only that the engine sees no difference; probe the catalog for grants and ACLs.
- Additive by default. Permitted without discussion: create table, create index,
create function, add nullable or defaulted column, create policy, grant. Forbidden
without a five-step plan (additive shape, backfill job, flip reads, flip writes, drop
old shape after verification): drop table, drop column, narrow a type, mass update,
truncate, rename a live column. Three sanctioned backfills, all idempotent and bounded:
the add-nullable → update → set-not-null triple in one file; an insert-on-conflict to
seed a new table for existing users; a narrowly-scoped integrity fix under ten
thousand rows.
- Never edit an applied migration. Never change the remote through a dashboard. Never
reset a linked database. Migrations are applied before the code that reads them, and the
window between is written down, including what pre-existing surfaces break in it (an
export that enumerates all tenant data reads the new table too).
- Types come from the database. Regenerate after every schema change. Status unions
are
Database["public"]["Enums"]["x"], never a hand-written literal union: a
hand-written surface set compiled, linted and passed every test while every insert on
the new surface violated the CHECK in production, silently, because the write was
fire-and-forget. A test parses the schema files and asserts each TS value set equals
its SQL constraint.
- Query-specific interfaces match the select shape exactly. Joins are typed at the query
with the SDK's override method, not double-cast. Spread from rows rather than listing
fields. No
any, no ignore directives; as only for as const, realtime payloads,
external API responses after a runtime shape check, and enum narrowing with the check
on the line above. No numeric fallbacks in business logic; ?? null for inserts, ?? undefined for SDK params, display fallbacks for nullable UI text only. ?? for
nullable, || only when falsy is intended.
- The API caps every response at a fixed row count regardless of
.limit(). Any read
that can exceed it pages with a stable unique order. An .in() list is bounded by
request-line bytes, not by key count; batch by measured size for the key shape
actually passed.
- Postgres is the queue until job types multiply. One generic
jobs table,
SELECT ... FOR UPDATE SKIP LOCKED in a plpgsql claim function (a language sql
set-returning function gets inlined and the LIMIT stops binding), a visibility-timeout
lease, attempts counting deaths not drives, a bounded max_attempts that
terminalises, a partial unique index for dedup of live jobs, a per-minute cron as the
heartbeat. Swap the dispatcher for a durable external queue when scale demands; the
worker logic does not change.
- Derived counts that several writers must keep true are maintained by a trigger, not by
remembering to set them in each writer.
format() in PL/pgSQL supports %s, %I, %L only. A C-style specifier fails at
runtime on the first winning settlement.
F. Frontend: server state, forms, design system
- Three data tiers, simplest first. Server component fetch → props (no client
cache) for pages that render without refetch. Server fetch → props →
useQuery({ initialData }) when the client must refetch, filter or paginate. Client
useQuery/useMutation for everything that cannot be server-fetched. Never
useState + useEffect to fetch. Dehydrate and hydration boundaries only when a
page genuinely needs SSR plus stale tracking; the framework already does SSR through
props.
- Query keys are declared in the domain's key factory and imported, never written
inline. Nested prefixes so
['stripe','status'] invalidates under ['stripe']; each
namespace has .all. Parameterised keys with arrays are memoised by callers.
Invalidation is scoped and written at the mutation's onSuccess, never
over-invalidated.
staleTime 60 seconds, refetchOnWindowFocus off, one provider at the root, a
singleton client in the browser.
- Forms are one pattern with no exceptions:
useForm + resolver + the design
system's FormField/FormMessage. A form owns its mutation lifecycle; do not also
wrap it in useMutation. Server errors map to form.setError. File inputs keep their
own state inside the same form.
- Per-item pending state is
mutation.variables === id && mutation.isPending, not a
separate useState. Derived lists are useMemo, not a synced state. Destructive
actions confirm first and use one retryable-mutation hook that toasts with a Retry
action; do not also toast in a caller's onError.
- Realtime subscriptions stay manual (
useEffect + channel) with minimal dependency
arrays; optionally write into the query cache.
- Every
useQuery has visible error feedback; every user-visible mutation has
onError. No console.* in client code; the server already logged it.
- Render purity: no
Date.now() or Math.random() in render or in a memo. Use the
query's dataUpdatedAt, a lazy useState initialiser, or pass nowMs into pure
predicates.
- The design system is the only source of primitives. No raw
<input>, <button>,
<select>. Colours come from theme tokens, never hex or palette classes; exceptions
are white or black with opacity on dark surfaces and third-party brand marks. Dynamic
class names are purged by the JIT compiler; keep materialised lookup maps so every
variant exists as a literal. Interactive filter chips are buttons with aria-pressed
or role="radio", never a clickable badge. Repeated typographic treatments are
primitives, not inline classes. When you need smaller than a primitive's default, build
a feature-local styled span and leave the primitive alone.
- Internal navigation uses the framework's link component. Sign-in and sign-out finish
with a full reload, never a client-side push (stale server-component cache). Public
env vars are read as literal
process.env.NEXT_PUBLIC_X; optional chaining or dynamic
lookup prevents inlining and yields undefined in the bundle.
- The request proxy excludes
/api/ (routes do their own auth) and every static asset
type including root-level .js (a service worker behind a redirect fails to register).
- User-facing copy: no em dashes, no AI-slop words, correct pluralisation, one voice.
Emails are light-designed regardless of a dark app, use solid backgrounds (gradients
survive dark-mode inversion while text does not), and bake logo contrast into the
image.
G. Portability: native and shared packages
- The API-first data path is what makes a native app possible. Nothing else in this
document matters for portability if mutations live in server actions.
- Shared packages, in the order a second client needs them: generated database
types; contracts (rules, Zod schemas, catalogues, key factories); pure domain math
(pricing, scoring, settlement predicates); design tokens (palette emitted to whatever
the native styling layer reads); provider adapters. The native app imports the real
pricing function and the real rules, so it cannot quote a value the server rejects.
- React Query is a drop-in on React Native; a hook that depends only on
fetchApi and
a key factory is shareable as-is. The typed API client (hc<AppType> on Hono, or the
envelope helper) is the seam.
- Expo Router with native tabs; the tab bar caps at four or five, so off-tab screens
need an explicit entry point. Every navigation carries its subject as a param; a bare
push opens whatever the screen defaults to. Empty states state the real reason ("squads
publish an hour before kickoff"), never "no data".
- Extract native primitives after duplication appears, not before: press feedback,
screen header, segmented control. Record which screens they replaced.
- The native lint gate must run with
--max-warnings=0; a config that downgrades every
rule to a warning makes a bare lint exit zero no matter what is wrong. Verify a package
resolves before building on it; a decision recorded as done that was never executed is
a recurring class.
- Public env vars on native use the platform's prefix. Nothing native reads a secret.
- Vertical registries for "add a sport, a provider, a tenant type without editing
callers": an exhaustive
switch with a never default so a new union member is a
compile error; a registry lookup that throws on an unregistered key rather than
falling back to the first entry; parseX(value): X | null for any string from the
database or a request that selects a code path, with every caller handling null.
H. Integrations and adapters
- Normalise at the adapter boundary. Each provider implements one interface and
returns only normalised shapes. Raw response types never leave the adapter folder.
Consumers dispatch by a stored
provider column, never by a default.
- Types come from real captured responses, never the spec. Keep scrubbed captures
as test fixtures. One provider documented 97 fields and returned 170; another encodes
a confirmed zero as an absent key.
- Distinguish absent from zero end to end.
null means the provider reported nothing
and the consumer decides (push, refuse, default); 0 is a value. A transformer that
coalesces to zero destroys the distinction the settlement layer needs.
- External ids are strings end to end; convert integers inside the one adapter that
emits them. A
Number() on a UUID silently turns lookups into "no data".
- One orchestration wrapper around every provider-touching call: duration, uniform
error shape, structured logs, one
instanceof on a shared base error class. The
adapter retries HTTP internally with a per-attempt AbortSignal.timeout; the wrapper
handles route-level concerns. Retry mechanism and transient-classifier are separate:
one bounded exponential loop, a per-caller classify.
- Compensating transactions: external create succeeds, internal write fails, clean up the
external resource. Idempotent third-party operations: check-before-create, upsert,
reactivate-existing. Batch APIs with permissive validation so one bad recipient does not
lose ninety-nine; detect 429 and stop batching; pace between batches.
- Never let a client pace-less server job stampede an external gateway: one bounded
concurrency primitive, one retry-with-jitter primitive, both shared.
- Where a provider has two modes (sandbox and live), the local and preview environments
point at sandbox permanently; only production points at live; ids and prices are
per-mode and never hardcoded.
I. Observability and lifecycle
- Structured JSON logs through
logInfo / logError / logAlarm with { context, userId | tenantId, error }. Never raw console output on the server. Log success and
failure; never a silent catch. errText(e) for thrown values that are not Error
(a plain error object stringifies to [object Object]).
- Three levels, and the third is the point.
error is diagnostics and there will
be hundreds of distinct events. alarm means a control fired or money or trust is
provably wrong; never a retryable failure. A test derives the alarm set from call sites
and fails if an event is raised at both levels or alarms grow past a tenth of errors.
- Post-response side effects (metering, persistence) go through one
persist(fn) that
keeps the function alive until the callback settles; a bare void fn() is dropped
when the runtime freezes after the response.
- Every scheduled job emits one healthy-signature line with its counts so "is it alive"
is a grep. An idle run is all zeros, not silence.
- Health and analytics endpoints degrade per query: one failing query renders a dash,
not a blank page. The SDK does not throw on a permission failure; check the resolved
error explicitly and log it.
- Analytics in the client go through one typed
track() over a typed event registry,
gated on consent, never a raw capture call.
J. AI agents, VMs and durable work
Apply when the repository runs models, agents or sandboxes. Skip otherwise.
- Grounding is code-enforced where it can be. Retrieve first; empty retrieval
refuses without calling the model (zero cost); citations are verified against what
retrieval returned, never parsed from prose; a fabricated reference is logged loudly.
Two predicates in one module: the ledger's coarse
refused and the card's strictly
stronger refusalIsShowable; a surface that asserts "I stopped rather than guess" must
exclude coding turns, tool answers and failed searches.
- Retrieved content, repository content and sandbox content are UNTRUSTED. The prompt
says quote-never-obey from one shared constant; the hard backstop for writes is an
approval gate. Approval classes (
DESTRUCTIVE_TOOLS, ADDITIVE_TOOLS) are
single-sourced and read by both the server and the client renderer; a write tool
missing from them hangs the turn.
- Write tools call the same feature services as the routes. Never their own SQL.
- Models are typed code constants,
ModelKey derived from the pricing table, so an
unpriced model is a compile error rather than a turn billed at zero. Budgets, step
caps and stream timeouts are central constants. Keep the model SDK and its React
binding in lockstep; a lone bump forks the SDK into two copies.
- Metering is one ledger row per generation with a deterministic idempotency key,
written through
persist(), and it still reads { error }. Sum usage from the
finished steps, not the top-level total, or a hard mid-loop error meters free.
- Every paid surface is gated on all three doors alike: the HTTP route, the MCP tool,
and the background service. A source-scan test derives the spending symbols and the
gate symbols per file, strips comments first (two such tests were vacuous because a
gate mentioned in a comment satisfied them), and fails a spender without a gate unless
it is listed as deliberately ungated with a decision entry.
- Ingestion holds four invariants: never stampede the gateway (serialise client
uploads, bound concurrency at one chokepoint with retry and jitter); non-destructive
swap (embed then replace, keep the original on failure); honest failure (
failed with
a reason and a retry, never a partial index marked done); no arbitrary caps (background
and resumable rather than truncated). A big job runs on the Postgres queue with a
convergent drive keyed by content hash, so any interruption resumes to the same fixed
point.
- The sandbox is the security boundary. Pin the agent runtime version (billing
parses its event stream). Egress is deny-by-default with an exact host allowlist;
credentials are injected at the firewall edge and never enter the VM. The clone token
is scoped to one repository or the boot fails with nothing reserved. Wipe the tree
before overlaying a repository; exclude build artefacts from history at boot; scan for
secrets before publishing. Call
stop() in finally; meter VM compute as its own
ledger row; persist the VM handle before or with the create; sweep orphans on a timer.
- Durable execution when a multi-step agent must survive a crash or deploy: Vercel
Workflows (
"use workflow" / "use step", a durable agent wrapper that makes each
tool call a retryable step) or an equivalent (Temporal, Inngest, DBOS, Restate). Below
that scale the Postgres queue plus a worker is enough, and the worker body does not
change when the dispatcher does.
- MCP surfaces: a public one self-limits by IP, caps every string input at ten to a
hundred times any legitimate size, and spends no model calls per request; a tenant one
authenticates with a hashed bearer token, resolves the workspace from the token, rejects
an expired token as unknown, and omits write tools entirely for a read-only token. Tool
behaviour lives in the services so the two surfaces cannot drift.
- A brand or knowledge corpus that feeds both pages and an agent is single-sourced; the
hand-written seams are listed and updated in the same change as the page.
K. Dependencies and supply chain
- One package manager, pinned in
packageManager. Frozen lockfile in CI and on the
host.
- A resolution-time cooldown (
minimumReleaseAge, days not hours; pnpm counts it in
minutes, so three days is 4320) against freshly published versions, with exact pins excluded because they have no older fallback. A
PR-layer cooldown in the dependency bot config with security updates exempt. Neither
replaces the other; document which door each covers and what changes on the next
major of the package manager.
audit gates CI at the level the tree can actually hold today; a red gate everyone
ignores is worse than none. Say why the level is what it is, and when to tighten.
- CI workflow token permissions are
contents: read unless a step needs more.
- Do not hand-roll a format a dependency already parses; ask what the tree already
knows before writing a parser. The security content (which bound to enforce) is the
earned part; the byte-poking is not.
Phase 3: propose
Produce one ordered plan. For each item: the invariant, the evidence from Phase 1, the
cheapest mechanism, and what it will not catch. Order by consequence to a paying user,
then by cost.
The mechanism ladder, cheapest first, because the cheapest one that works is the one
that survives:
- A type or signature that makes the wrong thing fail to compile (an exhaustive
switch with
never, a key type derived from a record, a status union derived from
generated types).
- A test that derives what it expects from the codebase: it walks the tree, parses the
schema, reads the config. There is nothing to update, so it cannot go stale.
- A lint rule, custom if the linter supports authoring one, scoped to production
source.
- An assertion at build or startup, so a misconfigured deploy fails before it serves.
- A CI step or hook.
- Prose. Last resort, for what cannot be mechanised, and it says what it does not
catch.
Rules for the plan:
- One guardrail per thing that has actually broken or is on a money, tenancy or
deletion path. If you cannot name the incident, the commit or the defensive comment,
do not propose it.
- No hand-maintained list, ever. If it needs someone to add a line when they add a file,
it is already broken. Derive both sides and compare.
- Structural moves and behaviour changes are separate items. A move that also changes
behaviour cannot be reviewed.
- Name the ceilings you are leaving in place (per-instance limiter, a single-parent
foreign key) and the trigger for revisiting each.
Present it. Wait for approval before building.
Phase 4: migrate the structure
Confirm the working tree is clean, or that every file you will touch is unmodified. If
not, stop and say so.
- One slice at a time. Move a domain's components, hooks, services and validation into
its slice; leave the page as a thin import; run typecheck and lint after each move. A
move is a move: no behaviour change in the same commit-sized unit.
- Introduce the gate, validate and envelope helpers first, then convert routes to the
four-step shape one at a time, each with its explicit return type. Convert server
actions to routes plus hooks; delete the action.
- Introduce
fetchApi and the key factories; convert hooks; delete inline keys.
- Pull pure domain logic (rules, pricing, catalogues) into framework-free modules. If a
second consumer exists or is on the roadmap, create the packages and point both apps
at them; otherwise leave them in place, framework-free, and note the lift in the
ledger.
- Where the schema is not declarative, do not rewrite history. Capture the current state
into declarative files, verify that a diff against a local database built from those
files is empty (a diff against the linked database is the owner's step), and land every
change from then on as an additive incremental. Never regenerate the baseline.
- Where grants are open, write one privilege-tightening migration: revoke from
PUBLIC,
close anon, restrict authenticated to SELECT, restrict append-only tables, set the
default privileges. Then run the security suite. This one is a behaviour change, not a
move: any client that still writes through the browser SDK stops working the moment it
lands. Take the browser-side write sites from the second probe, move each behind the API
first, and land the revoke only when that list is empty. Never apply it to a database
whose clients still write directly.
- Every non-obvious choice you make here gets its ledger entry in the same change.
Phase 5: mechanise the invariants, and break each one
One at a time:
- Write it. Run it. Confirm it passes on the correct codebase.
- Break the invariant deliberately. Copy the file to a temporary location outside the
repository first. Introduce the exact defect the guardrail exists to catch. Run again.
It must fail, and the message must name the file and say what is wrong.
- Restore from the copy, not from memory. Confirm byte-identical. Re-run, green.
- Record what you broke, the failure output, and the restore.
Not optional, and not skippable for being obvious. A guardrail that has never failed is
decoration, and worse than nothing because it manufactures confidence. Expect some of
your first attempts to pass while broken. That is the most common defect in this kind of
work. If you cannot make one fail on purpose, delete it.
Guardrails worth having in most repositories that fit this shape, each derived, each
with its stated limit:
- Unread write results: a lint rule that flags an awaited database write or RPC whose
{ error } is discarded or destructured without error. Precise by design: the awaited
chain must contain .from(...) plus a write method, or .rpc(...). Known gap: a
builder captured to a variable and awaited later.
- Money-table reads: a second rule, scoped by table name, that requires
{ error }
on SELECTs of the wallet, membership, ledger and grant tables. It narrows the judgement;
it does not remove it.
- Route contract: every route handler declares
Promise<NextResponse> or, only when
the file builds a raw body, Promise<Response>.
- Public-route register: every route file that calls no tenant gate is named in the
rules file with a justification.
- Admin gate: every operator route and operator page uses the one gate; a
hand-rolled inline check fails even when correct, because duplication was the defect.
- Layering: the
lib → features edge table is derived both ways.
- Enum parity: each TypeScript value set that mirrors a SQL CHECK or enum equals it;
status unions are derived from generated types.
- Env manifest: no duplicates; production build refuses and names every missing
variable; feature and platform tiers are never enforced; the CI production-build env
block contains every enforced key.
- Config registry: every
source: pointer resolves to a file that exports the
constant.
- Docs enumerations: every path a live doc names exists; no doc hand-enumerates the
rules corpus or the schema files; docs that declare
Status: PLAN or an archived
banner in their first fifteen lines are exempt.
- Decision log: every
> SUPERSEDED stamp cites an existing entry title; the
generated index is not stale.
- Alarm tier: no event is raised at both
error and alarm; alarms stay under a
tenth of errors.
- Paid-surface gating: every file that calls a spending symbol also calls a gate
symbol, comments stripped, or is listed as deliberately ungated with a decision.
- Audit labels: the canonical action set, its label map and its writers are one
type; a call-site scan rejects an action nothing emits.
- Egress: the test setup patches
fetch and the HTTP modules to refuse any
non-local host, and a test in the suite asserts the guard is installed.
Every scanner that reads source strips comments first with a small lexer that respects
strings and templates. Two scanners in the source codebases were provably vacuous because
a symbol in a prose comment satisfied them.
Phase 6: the verification floor
- Integration tests run the real service layer against a local database. The setup
file hard-refuses a non-local URL before any client is built. Fixtures provision real
tenants through the real onboarding service. Row-level tests use an
authenticated-role client for a real signed-in user, which is what RLS actually
sees. Test files run serially against one shared local database.
- Money-path unit tests run against captured real provider responses, not against
the spec.
- A SQL security suite asserts anon lockout on every table, append-only behaviour
for the ledger and audit tables even as the service role, EXECUTE denied on every
SECURITY DEFINER function from anon, and seed counts by identity not by table count.
Counts use an exact-match helper; a substring match passes "48" against "8". It is a
deploy gate, run after every migration.
- A browser layer that checks claims, not just requests. A repeatable walk of every
key surface at mobile and desktop viewports records console errors and same-origin
failures. If the product asserts things on screen (a balance, a receipt, a status, a
refusal), a flow drives that surface and a judge compares the claim against the data
behind it. It runs against an isolated copy of the tree on its own port with neutralised
credentials behind a deny-by-default egress firewall that logs every blocked call; a
plain dev server holds live credentials underneath its local overrides and must never be
driven. It is not CI-gated, so it is a discipline written down, with the judge rule
that a red step is suspected of being the harness before it is called a product bug.
- A read-only canary against production for the things code cannot see: provider
dashboard settings, DNS, certificates, what is actually deployed. Each item carries its
probe command.
- CI on every push to the deploying branch: install frozen, lint, typecheck,
dependency audit at the held level, a production build with dummy env (which runs the
env assertion), start the local database, run the tests.
permissions: contents: read.
- The re-run matrix is a table in the rules file: which change requires which check.
It says which suite, and it says what the suite cannot see.
- Two rules about what a test may assert. A control whose effect lives in a third
party is tested by reading the third party's observable state back, never your own
branch; a stored content type was correct and inert for months until someone read it
back out of the bucket. And a test that is green locally can be green while production
violates the invariant; say so in the test when the invariant depends on a migration
being applied.
- Test the outcome, not the branch; keep the happy-path twin so a hard-coded safe value
cannot pass; name the safe set, not the unsafe one; build fixtures the way production
builds them.
- Deliberately absent is a section, not a silence. Tell "deferred on purpose" from
"forgotten" by listing every known gap with its reason.
Phase 7: the instruction layer, the ledger and the loop
Now write the standing instructions and the domain rules, with the guardrails in place
so the prose no longer has to carry what a check now covers.
The root file. AGENTS.md is read by most agent tools and is the cross-tool
standard; in a monorepo the nearest one wins. If the repository uses a tool-specific file
(CLAUDE.md), either make it import the root file or keep it to what is genuinely
tool-specific. Either way, one short file an agent reads before every task, holding only
what cannot be inferred from the code: what is deliberately not obvious, what is
dangerous, the commands to run, the consent rules, and a pointer to where the domain rules
live. It does not hold a directory tour, a framework explanation, or any list of files,
modules or tables. Point at the directory instead.
The domain rules. One file per area, named for the area, under a rules directory.
At the top, declare the paths it governs (paths: frontmatter, or whatever the local
tooling reads) so it loads when that area is touched and stays out of the way otherwise;
cross-cutting rules carry no glob and load always. Inside: how the area works and why,
the invariants, the mistakes already made there, and what to re-run before calling a
change done. Do not restate what another file owns. Past roughly two hundred and fifty
lines, split the history out rather than append. The rules file holds the rule; the
narrative goes to the ledger or an archived history file it points at.
If an instruction file already existed, this phase is mostly deletion: remove everything
the code, the filesystem or a new guardrail now states, and move area-specific content
into its own file. Report the token cost before and after. If it grew, justify it.
The ledger. An append-only decision log, newest first, one entry per non-obvious
decision: YYYY-MM-DD — Title, what was decided, what was rejected, why. It records
reasoning, not instructions. An entry later overturned is stamped in place, where a
reader meets the dead claim: > SUPERSEDED <date> by "<title>" — <one clause why>. Cite
the title, not just the date; a date alone can point at twenty entries. Once the log is
too long to read whole, generate an index of titles and line numbers from the log itself
and add a check that fails when the index is stale. Never hand-maintain the index.
The backlog. One index file that owns no content and points at every source that
owns unfinished state, each with its shape (append-only, delete-as-you-close, plan,
runbook). A doc that describes unbuilt code declares Status: PLAN in its first fifteen
lines so the docs test stands down for it. Closing an item means deleting it from its
owning source.
Harness enforcement. Where the agent tooling supports permission rules, add
ask-prompts on commit and push and deny rules on reading production env files and on
destructive linked-database commands. The prose is the rule; the prompt is the guarantee.
The loop, written into the standing instructions as the closing section:
- A decision gets its ledger entry in the same change as the code, never after.
- When something breaks twice, build the check. Do not add a paragraph.
- Never write a check that needs a hand-maintained list. Derive both sides.
- When a check replaces a rule, delete the rule in the same change. When a check makes
an existing claim false, fix every place the claim appears.
- A rules file must shrink as often as it grows. Delete on sight: a claim the code no
longer supports, a waiver whose bug is fixed, a seam that got a body.
- Surgical edits only. Never rewrite an instruction file from scratch.
- The docs tests check structure, not truth: a path that resolves, a register that is
complete, a count that matches. A confidently wrong sentence still passes every one of
them. That part stays yours, and it is the part that matters.
- If these files only ever grow, the loop has stopped running.
Final report
State plainly: which of the seven parts existed before and which you created; every
structural move, as a list of from → to; each guardrail, what it catches and what it does
not; which you proved by breaking and which you could not; what you deleted; the
migrations you wrote and the exact operator steps to apply them in order; the ceilings you
left in place and their triggers; what you found in the audit but deliberately did not
act on; and every claim you could not verify. Recommend nothing you have not tested.