Engineering Conventions
Status: Active Last reviewed: 2026-10-30
The owner's engineering standard (their account instruction, adopted for this repo 2026-10-30: "read, reflect and retroactively apply these to our codebase and our work going forward") and how TwoMore holds it. The measured subset is AGENTS.md CODE-4, enforced by
yarn check:engineering; the judgment subset is in review-checklist.md (CHK-ENG-*). This file gives the why and the how. If anything here conflicts with AGENTS.md, AGENTS.md wins.
Principles
- Root cause only. Fix the class of problem and ship its prevention (CLAUDE.md "Thorough", "Preventive"). A band-aid is a change that makes the symptom go away and leaves its cause: a retry around a race, a catch around a crash, a pragma around a gate, a special case for one record. When the root cause is out of reach today, say so in the decision log and leave the symptom visible; don't hide it.
- Evidence before a choice. When two or more paths are viable, research first (the repo's own data, then standards and community practice), lay the options out in a comparison matrix of measurable trade-offs, recommend the one domain practice supports, and cite why. The matrix lives in the research or decision doc, not only in chat. The format is below.
- Code constraints.
- KISS. The lowest abstraction that meets today's need. No speculative interface, generic or option "for later".
- SRP and DIP. One reason to change per unit. Infrastructure is injected: the hexagonal layout already does this (ports in
packages/app/src/ports, adapters wired inregistry.ts, AGENTS.md ARCH-*, DATA-16), so new code joins it rather than reaching for a client directly. - Deterministic boundaries. Isolate state; keep transformations pure; unit-test each pure unit for success, empty or null input, and its explicit error states.
- Time and randomness are parameters. A function in
domain/reads no clock and no random source of its own: it takesnow(aDate) orrng(a() => number). A default (new Date(),Math.random) is allowed only where tests and callers outside the layer already call without it; production callers pass the shared clock (systemClock) explicitly. Lane D's rules (progression,user-state,subscription, the matchmaking strategies) follow this; a seededrngmakes a draw reproducible in a test. - Defensive validation. Validate untrusted input where it enters (Zod at the adapter, the edge-function handler, the route parser) and fail fast. Failures are typed (
DomainErrorsubclasses indomain/errors/) and carry structured context. No catch-all. - Self-documenting code. Strict types and semantic names say what; a comment says why (a domain rule, a platform quirk, the incident behind a guard).
- A start screen without a mark is a transparent image, never no image (2026-11-15). The
expo-splash-screenplugin always writes a theme that points at@drawable/splashscreen_logobut writes that drawable only when an image is named; colours alone prebuild cleanly and fail at Android resource linking after half an hour (EAS build4a0f7492).apps/mobile/assets/splash-empty.pngis the plain start screen;scripts/__tests__/splash-config.test.mjs(with a canary for the failed configuration) holds it. - The phone build is the only place the React Compiler runs, so a QA web build with the switch is part of the evidence before a native build (2026-11-15). Jest and the default web build run uncompiled code; the second W5 build failed at a second render that only the compiler skips. The lead builds the web app with
TWOMORE_WEB_REACT_COMPILER=1and runs the pictures and journeys on it; the public web app stays uncompiled (decision log S5).check:compiled-hooks(incheck:static) holds the two forms found so far; the compiled web run is what finds a new form.
- The pre-flight gate. Before any code is emitted, refactor it if the draft has a band-aid, nesting past 3, a catch that hides failures, a function over 20 lines of execution logic, or a choice with no stated reason.
What check:engineering measures
scripts/check-engineering.mjs parses every hand-written source file (the scope of check:sizes; tests, test scaffolding, i18n dictionaries, generated types and the design boards are out) with the definitions in scripts/lib/engineering-metrics.mjs, which its tests pin.
| Measure | A finding is |
|---|---|
| Long function | More than 20 logic lines. A logic line carries code: comments, blank lines, lines holding only closing delimiters (}, });) and JSX markup do not count, and a nested function counts apart. |
| Deep nesting | Control flow (if, loops, switch, try) more than 3 levels deep in one function, counted as ESLint max-depth counts it: an else if stays at its chain's level. |
| Catch | A catch clause or inline .catch(fn) that swallows the error, reads it but lets it vanish (broad), or tests for the expected failure and drops every other one (narrowed). |
A catch passes when an unexpected failure still escapes it (a throw, or the error handed to a promise's reject) or reaches a canonical handler: the adapter handle…Error family, captureCriticalError, an adapter's toDomainError, or logger.error (whose sink is captureCriticalError). e instanceof Error narrows nothing, since every throwable passes it.
Why JSX is out of the line count: markup is layout, not execution logic, and today's screens will be redrawn to the flat v2 boards. The limit is that logic written inside JSX expressions ({rows.filter(…).map(…)}) escapes the count; lift it into a named function.
Why 20 and 3: 20 logic lines is the owner's number. ESLint's max-depth default is 4; 3 is the level at which a guard-clause rewrite keeps the happy path at the left margin, and at the 2026-10-30 baseline it flagged 91 functions, few enough to clear.
The ratchet
scripts/engineering-baseline.json records each file's counts on 2026-10-30 (729 files: 880 long functions, 91 deep, 464 catches). A file fails when a count rises above its entry; a file not listed is held at 0. So no new debt lands, and the recorded debt can only shrink:
- After a burn-down,
node scripts/check-engineering.mjs --update-baselinerecords the lower counts (it never records a higher one). The gate prints a reminder when counts fell. - Moving or renaming a file carries its entry to the new path in the same commit; that is the one hand edit the file allows.
--initre-records everything. It is only for a change to the definitions themselves, and that commit says why in the rebuild log.
Boundaries
A few handlers must catch everything by design: the error sink (reporting its own failure would recurse), a script's top-level main that turns any failure into an exit code, a React error boundary. Such a handler says so inside it:
} catch {
// boundary: the error sink must never throw upward; Sentry is the redundant path
}The gate passes a boundary only when its file is recorded in scripts/engineering-baseline.json under boundaries, with its count and its reason. That record is written by hand: --update-baseline only removes entries, so no tool or agent can grant itself one (2026-11-10: two page handlers had declared themselves boundaries to pass the gate; both now rethrow what they do not understand). A boundary: comment on any other catch is a band-aid.
Writing a catch
| The situation | Write |
|---|---|
| One kind of failure is expected | Narrow and rethrow the rest: if (e instanceof SyntaxError) return null; throw e; |
| A Supabase or PostgREST error in an adapter | handleError(error) (DATA-3): it becomes a typed DomainError. |
| Nothing can recover | captureCriticalError(toError(e), { context }) (DATA-4), then the fallback. |
| A mutation's error already has an owner | Don't mutateAsync and catch; call mutate and let the hook's onError own it. |
| Untrusted input might be malformed | Validate with Zod safeParse at the boundary; there is nothing to catch. |
| The same best-effort pattern recurs | One named, tested helper that narrows the expected failure; every site calls it. |
| An edge function reads a request or provider body | _shared/bounded-body.ts: a byte cap, a deadline, and BodyLimitError for a bad body. |
| An edge function calls a provider whose key is in the URL | _shared/provider-fetch.ts fetchProviderJson: every failure is a key-free ProviderError (Deno's fetch errors quote the URL), so never log or echo a raw fetch error. |
| An edge function's last resort must still answer | A declared boundary: that logs via _shared/log-failure.ts (name and message, URL queries redacted) and answers a fixed message, never String(e). |
| An edge function compares a secret (cron, ingest, service key, signature) | _shared/secret-compare.ts secretMatches(provided, expected): both sides are hashed and folded byte by byte, so timing says nothing about how much matched; an empty or absent configured secret never matches. Never === or !== on a secret (edge-secret-gates.test.mjs fails when a function that reads x-cron-secret or x-ingest-token does not use it). |
| An edge function answers a failure | A stable code (claim_failed, mark_paid_failed), never error.message or String(e); the cause goes to the log, and a database error logs its SQLSTATE only (its text may quote row values). |
Comparison matrix format
| Option | Complexity | Latency | Scalability | Resources | Churn | Sources |
|---|
One row per viable option, each cell a measured or cited value rather than an adjective. Under the table: the recommendation, the domain practice it follows, and the evidence (repo measurements, files and lines, linked standards). Matrices live in docs/design/research/ or the decision log.
The burn-down (2026-10-30)
The order is by risk:
- Catches in product code (adapters,
lib, presentation, features, edge functions), because a hidden failure is a wrong screen nobody hears about. - Deep nesting.
- Long functions in domain, application and adapters.
- Logic lifted out of presentation.
- Scripts.
Each batch passes yarn check, shrinks the baseline, and is logged in the rebuild log.
UI migration readiness. The flat v2 UI replaces today's screens once the owner's page review ends. Refactoring therefore goes where code survives the migration: domain, application, adapters, ports, and the pure view models (buildSessionInfoModel, the session strip, the status maps). In presentation code, a finding is fixed by lifting its logic out of the component into a pure, tested function or hook the new screens can call; the JSX shape is left to the migration.