Skip to content

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 ​

  1. 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.
  2. 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.
  3. 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 in registry.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 takes now (a Date) or rng (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 seeded rng makes 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 (DomainError subclasses in domain/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-screen plugin always writes a theme that points at @drawable/splashscreen_logo but writes that drawable only when an image is named; colours alone prebuild cleanly and fail at Android resource linking after half an hour (EAS build 4a0f7492). apps/mobile/assets/splash-empty.png is 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=1 and runs the pictures and journeys on it; the public web app stays uncompiled (decision log S5). check:compiled-hooks (in check:static) holds the two forms found so far; the compiled web run is what finds a new form.
  4. 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.

MeasureA finding is
Long functionMore 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 nestingControl 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.
CatchA 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-baseline records 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.
  • --init re-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:

ts
} 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 situationWrite
One kind of failure is expectedNarrow and rethrow the rest: if (e instanceof SyntaxError) return null; throw e;
A Supabase or PostgREST error in an adapterhandleError(error) (DATA-3): it becomes a typed DomainError.
Nothing can recovercaptureCriticalError(toError(e), { context }) (DATA-4), then the fallback.
A mutation's error already has an ownerDon't mutateAsync and catch; call mutate and let the hook's onError own it.
Untrusted input might be malformedValidate with Zod safeParse at the boundary; there is nothing to catch.
The same best-effort pattern recursOne 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 answerA 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 failureA 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 ​

OptionComplexityLatencyScalabilityResourcesChurnSources

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:

  1. Catches in product code (adapters, lib, presentation, features, edge functions), because a hidden failure is a wrong screen nobody hears about.
  2. Deep nesting.
  3. Long functions in domain, application and adapters.
  4. Logic lifted out of presentation.
  5. 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.

Markdown remains the source of truth. Run yarn docs:check before handoff.