The rules file I write before any code
· 5 min read

What goes into the CLAUDE.md I hand Claude Code, taken from the one in my kindergarten SaaS, and why a rule the build can check beats a paragraph asking nicely.
On this page
In a new repo of my own, the first thing I write isn't code. It's the file at the root that tells the agent how this codebase works, and I write it before there is anything for it to be wrong about. In Roudhati, my SaaS for Tunisian kindergartens, that file is 139 lines. It came out of a brief I wrote before there was any feature code, and its opening paragraph says nothing at all about the stack.
# CLAUDE.md — روضتي (Roudhati)
A SaaS platform for Tunisian kindergartens and daycare centres. The person using
it daily is a kindergarten director on a mid-range Android phone, in Arabic,
often with children in the room. That constraint decides most arguments.
## Non-negotiables
Read these before writing code. Violating one is a bug, not a style preference.
1. **Money is an integer count of millimes.** 1 DT = 1000 millimes. No floats,
no `Decimal`, no `parseFloat` anywhere near an amount. Format only through
`formatMoney`, parse only through `parseMoney`, both in `@roudhati/utils`.
2. **RTL is the direction, not a theme.** `dir="rtl"` on `<html>`. Use logical
properties (`ms-*`, `me-*`, `ps-*`, `pe-*`, `start-*`, `end-*`). Never
`ml-*`, `mr-*`, `pl-*`, `pr-*`, `left-*`, `right-*`, `text-left`,
`text-right`. A component that breaks in RTL is a broken component.
3. **Every tenant-scoped query is filtered by `kindergartenId`.** Not by hand in
each query — by the Prisma client extension, with Postgres RLS behind it.
4. **No hardcoded UI strings.** Every string goes through `messages/ar.json`,
even though Arabic is the only language in v1.
5. **Western Arabic digits (0-9), never Eastern (٠-٩).** That is how Tunisians
write numbers.
6. **Nothing involving money is ever hard-deleted.** Payments, expenses and
children soft-delete via `deletedAt`.
It starts with who is holding the phone
Every argument about an interface has a reference user behind it, and when nobody names one, a default takes over: a laptop, a wide screen, English, left to right, both hands free. An agent inherits that default from everything it has read, and I inherit it too when I'm moving fast. Naming the director at the top of the file turns it into a tie-break rule instead. Where two options are both reasonable, the one that survives on a narrow screen in Arabic with a child pulling at your arm wins, and I don't have to make the case again in the next prompt.
It reaches past layout. The browser tests run on a Pixel 7 viewport in ar-TN and Africa/Tunis, with a comment in the config saying why: A mid-range Android is the target device, not a desktop browser.
Why each of the six is there
Money is an integer count of millimes
The dinar has three decimal places, so one dinar is a thousand millimes, and a float can't hold a running balance of them without eventually lying. Nothing dramatic happens when it does. A balance comes out a millime short after a carry-forward, in a ledger the director is checking against her paper notebook, and from then on she trusts the paper. One float also tends to recruit others: a parseFloat in a form handler, a division in a component, a rounding in a sum. So amounts are parsed in one place and formatted in one place, and the module says the same thing in its own header: Parse at the edge, format at the edge, integers in between.
RTL is the direction, not a theme
Nearly all the Tailwind in the world is left to right, so ml-4 is what comes out of an agent by default, and often out of me. A margin on the wrong side doesn't crash, doesn't fail typecheck, and survives a screenshot you only glance at. It reaches the phone.
The tenant filter nobody can forget
Rule three names a mechanism instead of asking anyone to remember, because a reminder is only as good as the attention of whoever writes the fiftieth query. The filter is injected once:
/**
* Models that carry `kindergartenId` directly. Writes to these get the tenant
* stamped on automatically so no service can forget it, and reads get the
* filter injected so a missing `where` cannot return another tenant's rows.
* // …
*/
const TENANT_MODELS = new Set<string>([
'User',
// …
'ReceiptSequence',
]);
// …
// update / delete address a single row by unique id. Injecting the
// tenant into `where` is rejected by Prisma's unique-input typing, so
// RLS is what stops a cross-tenant id here: the row is invisible and
// the operation raises a not-found instead of touching it.
return query(next);
The extension is the ordinary path and row-level security is the backstop, and that division is written down in the one place where it would otherwise read like an oversight.
Strings, digits and things that must not vanish
Rule four asks for every string to go through messages/ar.json while Arabic is the only language there is. It costs nothing now, and it means French later is a file rather than a sweep through every component. It also puts all the copy in one place, where it can be read as copy.
Rule five exists because asking a model for an Arabic interface tends to produce Eastern digits, which is a fair guess and the wrong one here. Tunisians write 0-9.
Rule six is about receipts. A recorded payment prints a receipt that goes home with a parent, so a payment row that can be deleted outright leaves a piece of paper with nothing behind it. PaymentAllocation is the audit trail for money, and deletedAt keeps the ledger reconstructable.
Rules the build can check
Two of the six aren't only prose. They're in the lint config, with the reasoning next to them:
/**
* Physical CSS properties are banned in apps/web. RTL is the direction of this
* product, not a theme, and a 4px margin on the wrong side is exactly the kind
* of defect that survives review and reaches a director's phone. The build
* catches it instead.
*/
const PHYSICAL_PROPERTY_PATTERN =
'/(^|[\\\\s"\'`])(ml|mr|pl|pr|border-l|border-r|rounded-l|rounded-r|left|right)-|text-(left|right)(\\\\s|$|["\'`])/';
// …
/** Money is an integer count of millimes; a float near an amount is a bug. */
const MONEY_RULES = [
{
selector: "CallExpression[callee.name='parseFloat']",
message:
'parseFloat has no place in this codebase. Money is integer millimes — use parseMoney from @roudhati/utils.',
},
// …
];
A rules file is read at the start of a session, and sessions get long. A lint error arrives at the moment the mistake is made, and it arrives whether the person making it is the agent or me on a Friday. Anything in the file that a selector could express is a rule I'd rather move into the config.
The section I added after it bit me
### Migrations: what Prisma cannot see, Prisma deletes
The initial migration ends with a hand-written block — partial unique indexes,
`CHECK` constraints, RLS policies and the trigram search indexes — because
`schema.prisma` cannot express any of it.
Prisma ignores what it does not model (partial indexes, check constraints, RLS)
but it **does** model ordinary indexes and extensions. Anything in that
category which exists in the database but not in `schema.prisma` is drift, and
the next `prisma migrate dev` writes a migration that drops it. This already
happened once to the two `gin_trgm_ops` indexes; they are now declared in
`schema.prisma` with an explicit `map:` so the generated name matches the
migration.
<!-- … -->
That section wasn't there on day one. It went in with the commit that fixed the thing it describes. The first prisma migrate dev after the initial migration produced a migration dropping both trigram indexes on the Arabic search columns, because those indexes were created by hand-written SQL and never declared in the schema, so Prisma read them as drift and tidied them away. Nothing would have failed. Arabic search would simply have stopped using an index, and got slower as a kindergarten filled up.
The fix and the paragraph belong in the same commit. A fix on its own leaves the same surprise waiting six weeks later under a different name, and by then neither of us remembers the shape of it.
A rule that corrects the model
Qrder has a rules file of a different kind. It's five lines, and it arrived with the repo, because create-next-app now writes one:
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->
The CLAUDE.md beside it is one line long: @AGENTS.md.
Nothing in there is a policy about my code. It's a correction about what the model believes, and it works because Next ships its own documentation inside the installed package, so the correction has somewhere specific to point. The two kinds of rule also fail differently. Breaking a policy shows up as inconsistency you can spot by reading. Stale knowledge shows up as code that looks completely idiomatic and calls an API that no longer exists, which typecheck sometimes catches and sometimes doesn't.
What I keep out
139 lines is about the ceiling. What earns a place: the non-negotiables, the commands, where logic is allowed to live, the tests that are required rather than nice to have, and two lists that save whole conversations. One is Ask before, which covers adding a dependency, changing the fee ledger's shape, and anything touching PaymentAllocation. The other is Out of scope for v1, which lists the things v1 doesn't do and ends with an instruction to say so rather than build it.
What stays out: anything the code states clearly by itself, and anything that changes week to week. Both turn the file into something nobody trusts, which is worse than not having it.
What I'd change
Two of the six non-negotiables are enforced by the build and four are prose. The two easiest to break quietly are the hardcoded strings and the Eastern digits, and both are single tokens a selector could see: Arabic-Indic digit literals anywhere in the source, and bare text in JSX that isn't coming out of a translation call. They'd sit in the same no-restricted-syntax list as the money and RTL rules. Every rule that moves into the lint config is a line the file no longer has to ask anyone to remember.
Was this any good?