For developers
Building a module
The recipe every screen follows (read, write, tenancy, tests, docs) and the checklist that says when it is done.
The definition of done
A module is done when it:
- reads real data from the database,
- saves real data,
- checks permission on the server for the page and every action,
- writes an audit entry for every save,
- has loading, empty and error states,
- works at 360 px wide,
- has its tests passing,
- is documented here, and the changelog has a line.
1. Read
Every page starts with a guard:
const access = await guardPage('invoices'); // or guardSignedIn() for a person's own pages
It checks permission on the server, logs a refusal as security.access_denied, and loads the
org's dataset. A list that can pass 200 rows pages on the server with lib/data/paginate.ts. A
screen that needs something the dataset lacks gets a function in apps/app/lib/data/<module>.ts.
2. Write
A server action in _actions.ts next to the page, wrapped in runAction():
export async function recordPayment(input: PaymentInput) {
return runAction(async () => {
const actor = await requireAccess('receivables', 'edit');
const data = paymentSchema.parse(input);
const verdict = paymentRule(data); // from packages/core
if (!verdict.ok) throw new RuleError(verdict.reason);
await withOrg(actor.orgId, async (db) => {
await db.payment.create({ data: { ... } }); // never pass orgId
await writeAudit(db, actor, { action: 'payment.recorded', entity: ..., ... });
await notify(db, { userId: load.sp }, { title: `${load.lid} paid`, href: ... });
});
revalidatePath('/receivables');
});
}
The form passes the action to useHarborForm({ action }) from lib/forms, which shows the toast
and the refusal message.
3. Rules go in packages/core
Business rules live in packages/core and run on the server, never only in the UI. A rule is a
pure function that returns a verdict with a plain-words reason. Every new rule gets a Vitest test.
4. Tenancy
Tenant data is reached only through withOrg(orgId, fn) from @harbor/db. A new table with
orgId needs its row-level security policy in the same migration; tenant.test.ts fails without it.
5. Tests
| Test | Where |
|---|---|
| A unit test for every new rule | packages/core/<rule>.test.ts |
| An end-to-end test for the module's main flow | apps/app/e2e/<module>.spec.ts |
Page guard, requireAccess and writeAudit present | apps/app/lib/access.test.ts (automatic) |
| A docs page covers the screen | apps/app/lib/docs-coverage.test.ts (automatic) |
If an action really writes nothing worth auditing, add a // no-audit: <reason> line and the
access test accepts it.
6. Docs
Update the page in apps/web/content/docs that describes the screen, or add one (and its slug in
apps/web/lib/docs/manifest.ts), and list the panel routes it covers in its panel: frontmatter.
Add a line to reference/changelog.mdx. The authoring guide is
apps/web/content/docs/README.md.
The non-negotiables
| Rule | Why |
|---|---|
Money is integer cents, BigInt in the database; format only via packages/core/money | No rounding errors |
date for business dates, timestamptz for events; format via packages/core/dates | No time-zone drift |
| Every save writes an audit event | The audit trail is complete |
| Permissions checked on the server | Hiding a menu is not security |
| Secrets never reach the browser | |
Server Components by default; "use client" only for real interactivity | |
Reuse the enums in packages/core (load-status.ts, load-vocabulary.ts) | One source of truth |
Panel UI reuses apps/app/components/ui and components/charts | One design |
| Status is a colour and a word; numbers right-aligned, tabular; container numbers never truncated | Readability |
| WCAG 2.2 AA, keyboard reachable, visible focus | Accessibility |