Engineering conventions
The code standards, architectural seams, testing strategy and working agreements for this repository. For anyone writing code here; read the nested AGENTS.md for the area you are changing as well. System structure is in architecture overview; security rules are in security.
Repository areas
| Area | Language and runtime | Rule of thumb |
|---|---|---|
src/ | React, Vite, TypeScript, shadcn-ui, Tailwind v4 | UI and thin data access; no compliance rules |
domain/ | Pure TypeScript | Zero I/O and no Supabase import; every compliance rule lives here |
supabase/functions/ | Deno edge functions | Orchestration and I/O around domain/; no rules of their own |
supabase/migrations/ | SQL | The schema and RLS; the source of truth |
db-tests/ | Vitest against local Postgres | Regression suite for isolation and ingestion, plus operational CLIs |
map-service/ | Node 22 with type stripping | Owns its wire contract in src/shared/ |
scripts/ | Node and shell | Dev, guard, seed and maintenance tooling, not application code |
archive/ holds the earlier version of the application, kept for reference. Do not build against it; it is not linted or shipped. (The domain package's legacy-fidelity tests import a few of its modules.)
The seams
Three boundaries keep rules, data access and auth in one place each. ESLint enforces the first and third; the second is enforced by review.
@complied/domain. Only domain/src/** is imported through this alias (vite.config.ts, tsconfig*.json); it is not an npm package. Compliance logic (obligation rules, order registry and decisions, form bundles, XRF classification, licence validation, status normalization, ingestion) is pure functions and types, so the browser, the edge functions and the tests all run the same code. Edge functions import it by relative path. Each folder has an index.ts that re-exports with explicit .ts extensions, and domain/src/index.ts is the public surface.
src/data/ is the only place that talks to Supabase. src/data/supabaseClient.ts holds the single createClient() call, with the anon key only. Every other module reaches the database through small, named, typed, promise-returning functions in per-entity files (projects.ts, buildings.ts, invoices.ts, ...) that call supabase.from(...) or .rpc(...) and throw descriptive errors (`listTrackedBuildings failed: ${error.message}`). Do not put .from() or .rpc() in a component, hook or page.
src/auth/ is the only place that touches supabase.auth.*. It wraps sign-in, sign-out, invites and session state behind AuthProvider, useAuth, usePermission and friends.
eslint.config.js enforces the data and auth seams for every src/**/*.{ts,tsx} file outside src/data/** and src/auth/**:
import/no-restricted-pathsforbids importingsupabaseClient.ts.- A
no-restricted-syntaxbackstop forbids any.from(...)or.rpc(...)call shape, however the client reached the call site.
Do not fight these rules; add a function in src/data/ instead. tsc -b covers src/ only, so edge functions are checked separately by npm run check:functions (Deno).
Reading lists
PostgREST answers at most max_rows rows per request (1,000, [api] in supabase/config.toml) and cuts a longer answer short without an error. .limit(5000) does not lift that cap; it still returns 1,000 rows. Local seed data never crosses 1,000 rows, so an unpaged read looks correct until a real firm outgrows it. Every read in src/data/ that can return more than 1,000 rows therefore uses one of these:
pagedSelect(label, query, { order })(src/data/paging.ts) for a list you need whole.querybuilds a fresh select plus filters for each page, with no order and no range. The helper applies the order, always ends it with a unique tie-break (idunless you name another withtieBreak), and pages until it gets a short page. Without the tie-break, rows that share every sort value (a bulk import'screated_at, say) can repeat or drop between pages.pagedSelectCapped(label, query, { order, maxRows })for a read the browser should not take whole. It returns{ rows, truncated }, and the page must say what it left out ("at least N", "showing 120 of N"). A cap nobody sees is a bug.pagedSelectByIds(label, ids, (chunk) => query, { chunkSize })for a long id list. Ids go in chunks because a longin.(...)overflows the URL, and each chunk is paged in full. Chunking keeps the URL short; it does not page.- A count or aggregate RPC that returns one
jsonbvalue when you need numbers, not rows (group_portfolio_events,field_job_signal_counts,xrf_reading_counts). One row is never cut short. Make itSECURITY INVOKERso it counts only what the caller could list, and give it a db-test showing that one tenant cannot count another's rows. - A window, not a whole history, when a page needs only recent and upcoming rows: filter to the window in the query (for example, undone visits plus the last 90 days and all future ones), then page it. Never sort ascending and cap, because that keeps the oldest rows and drops today's.
ESLint enforces the literal case: in src/data/**, .limit(N) with a number literal above 1,000 is an error. A read whose .limit() is 1,000 or less is a deliberate "the first N" (the newest 30 changes, say) and needs a .order() that makes "first" mean something.
A failed read is an error state, never an empty list. Do not .catch(() => []) a read into "none": show that it could not load (a warning, a banner, "contacts unavailable") so nobody acts on missing data.
Test a list reader at 1,001 or more rows with the fake PostgREST in src/data/testing/fakePostgrest.ts. It truncates at 1,000 the way production does, so a reader that does not page fails. Mock the client with vi.mock("./supabaseClient", ...) as src/data/pagedReaders.test.ts does.
Permissions in the UI
Every action and route is gated by a permission key, never a role name: usePermission('key') or useAnyPermission(...) at the control, and src/auth/routeAccess.ts for routes (read by the route guard, sidebar, command palette and post-login landing). Keys are the typed PermissionKey union in src/auth/permissionKeys.ts, populated from my_permission_keys() so implied keys and impersonation match the server. These gates are UX; RLS is the boundary (permissions).
TypeScript and React
- Compiler flags (
tsconfig.app.json):verbatimModuleSyntax(useimport typefor types),erasableSyntaxOnly(no enums, namespaces or constructor parameter properties, which also keepsmap-service/runnable under Node's type stripping),noUnusedLocals,noUnusedParameters,noFallthroughCasesInSwitch, target ES2023.@typescript-eslint/no-unused-varsis off becausetsccovers it. - Aliases:
@/issrc/;@complied/domainisdomain/src/index.ts;@map-shared/*ismap-service/src/shared/*. - State is plain
useStateanduseEffectoversrc/data/calls, with arefreshKeycounter to refetch after a mutation (seesrc/hooks/useSavedViews.ts). There is no React Query, Zustand or Redux. The one global is auth (AuthProvider). - Routes are code-split where heavy (the map page pulls in
mapbox-gl). OneerrorElementcovers the tree, including lazy-import failures. Staff and portal are separate shells with separate guards. - Shared UI lives in
src/components/redesign/(list and detail primitives, filters, the status kit) andsrc/components/ui/(shadcn primitives). Visual rules: design system. - Display helpers that are pure belong in
src/lib/with a colocated.test.ts; keep them free ofsrc/data/imports so tests run without environment variables. - Errors thrown from
src/data/carry the function name and cause; UI code turns them into readable messages (edge-function failures go throughdescribeEdgeFunctionError).
Edge functions
- Import
withCorsand wrap the handler; authorize withauthorize(); probe ownership withcreateCallerClient(); then use the service-role client (security). - Import shared helpers from
supabase/functions/_shared/and domain code by relative path. Pinesm.shpackage versions. - A function contains no business rules: derive figures, classifications and eligibility in
domain/and call them, so the UI and the function cannot disagree (the XRF report and its ingest preview share one summary module for this reason). - New functions must pass
npm run check:functions; a function that already fails is on theKNOWN_FAILINGratchet inscripts/check-functions.mjs, and fixing one means removing it from that list.
Naming
- Files and docs: components
PascalCase.tsx; data, lib and domain modulescamelCase.ts; tests beside the code asname.test.ts; markdown underdocs/inkebab-case.md; edge-function folderskebab-case. - Data-layer functions read as verbs on entities:
listX,getX,trackBuilding,setTrackedBuildingActive. - Migrations:
YYYYMMDDHHMMSS_short_description.sql. - Permission keys are lower snake case verbs:
view_*reads,manage_*writes,perform_*does field work. - Ingestion feed ids are kebab-case (
hpd-violations);source_idvalues are namespaced by the feed's prefix. - Vocabulary follows the glossary in
REQUIREMENTS.md(for exampletenantis the organisation, not a resident); use the canonical term in code, UI text and docs.
Migrations
- Add one with
npx supabase migration new <name>; apply locally withnpx supabase migration up --local. Merging tomainapplies it to production (deployment). - Your timestamp must sort after every existing file; rebase on
mainand checkls supabase/migrations/*.sql | tail -3before opening the PR. Never rename a migration after it has been applied anywhere; that is what causes drift (local development). - Write idempotent DDL where practical (
if not exists,create or replace), and comment intent and the decision it serves in the header. - RLS: enable it, use permission-key policies with
(select ...)-wrapped helpers, and keepUSING (true)to public reference tables. EverySECURITY DEFINERfunction pinssearch_pathand ships a pairedREVOKE. Checklist: security. - Before dropping or reshaping a table, grep migrations for
REFERENCES public.<table>and for triggers writing into it. - Regenerate
src/integrations/supabase/types.tswhen the schema changes (npx supabase gen types typescript --local). - Server-to-server functions that authenticate themselves need
verify_jwt = falseinsupabase/config.toml, or the platform rejects them before your code runs.
Testing
| Layer | Tool and location | What it proves |
|---|---|---|
| Domain rules | Vitest, domain/tests/ (cd domain && npm test) | Pure logic: obligation rules, form bundles, XRF, licensing, ingestion normalizers and runners against an in-memory Db |
| Legacy fidelity | domain/tests/fidelity/ | The new pipeline matches a committed snapshot generated by running the real legacy Python over a 36-CSV corpus; deliberate divergences are asserted exactly ("we meant to differ by 4"), so an accidental change fails as loudly as an undocumented one. The raw corpus is local-only; without it, per-file assertions skip loudly and only corpus-independent invariants run. Restore the corpus rather than relaxing an assertion. |
| App and pure helpers | Vitest, src/**/*.test.ts and pure supabase/functions/** modules (npm run test) | UI logic, URL codecs, report HTML builders, retry math |
| Edge-function types | Deno, npm run check:functions | Type-correctness of every function and the domain code it imports |
| Database | Vitest, db-tests/ against local Postgres | Cross-tenant leakage matrix, permission matrix and policy lint, ingestion constraints, security fixes |
| Map service | Vitest, map-service/ | SQL compiler, fixture world in in-memory DuckDB, HTTP layer, tenant line |
Guidance:
- Put logic where it can be tested purely. A rule in
domain/gets a unit test; a rule in a component or function usually cannot be tested cheaply. - Use real rows as fixtures. Ingestion tests use rows saved from the live datasets, not invented ones; fetching is an injected dependency (
FeedFetchDeps.fetchJson), so no test calls Socrata. - Keep app unit tests off the real client.
supabaseClient.tsthrows at import time withoutVITE_SUPABASE_URL, and CI has no env file. A type-only import of a data module is fine (erased). A test of asrc/data/reader mocks./supabaseClientwith the fake PostgREST (reading lists). Before claiming a green run, move.env.localaside so it cannot mask this. - Every new tenant table gets leakage coverage in
db-tests, and a new permission key must be enforced somewhere or the policy lint fails. - A skipped or relaxed assertion is not a fix. If a failure looks pre-existing, establish the baseline on a clean checkout first.
db-testsinserts fixture rows intoauth.userson every run and never tears down; do not run it against a database holding real data without asking (local development).- CI runs the root suite, function type-check and map-service suite. The
domain/anddb-tests/suites run locally.
Commit and review cadence
The working loop is: build and tests green, commit, push; open a pull request for anything that changes shared behaviour. Cloudflare Pages auto-deploys the frontend on push to main, and merging a migration or function change deploys it (deployment).
- Write commit messages as a short imperative summary of the change ("Show which violations each dust-wipe room came from"), with detail in the body when the why is not obvious.
- Keep a change coherent: schema, policies, data-layer functions, domain rules and UI for one feature can travel together, but unrelated fixes should not.
- Update the docs that describe what you changed, in the same change. Docs describe what is true in the code today, not plans; open work belongs in issues.
Pre-commit and pre-tool gates
scripts/hooks/pre-tool-gate.mjs is a Claude Code PreToolUse hook (wired in .claude/settings.json for Bash). For agent-run commands it blocks git commit unless tsc --noEmit and npm test pass, blocks a function deploy unless npm run build passes, and blocks writes to the production Supabase database. It does not run for a human's terminal, so run npm run build and npm run test before you commit anyway. npm run lint is local-only (not in this gate, not in CI) until the pre-existing eslint debt is cleared — run it when you touch linted paths, but it is not a merge gate. Details: security.
Never run without asking first: supabase db reset, db-tests/tests/run-tests.sh, and any destructive operation or production write (local development).
Issues and labels
Open work is tracked as GitHub issues on The-Dev-Group/complied, through the gh CLI (docs/agents/issue-tracker.md). Triage uses five labels, unchanged from the defaults (docs/agents/triage-labels.md):
| Label | Meaning |
|---|---|
needs-triage | A maintainer needs to evaluate it |
needs-info | Waiting on the reporter |
ready-for-agent | Fully specified; an unattended agent can take it |
ready-for-human | Needs human implementation |
wontfix | Will not be actioned |
Domain vocabulary and locked decisions (D1 to D21) are in REQUIREMENTS.md; treat output that contradicts a locked decision as a conflict to raise, not to override (docs/agents/domain.md).
Documentation conventions
Canonical docs live in docs/ and the root REQUIREMENTS.md; the docs site copies them at build time (deployment). Each page starts with a one-to-three sentence statement of what it covers and who it is for, states current behaviour in the present tense, uses tables for reference material and Mermaid for diagrams, and links to the one page that owns a fact instead of repeating it. Link code with repo-relative paths.