Code Rules
June 21, 2026 · View on GitHub
Core Principles
95% Test Coverage
- 95% line, branch, and function coverage
- No exceptions
- Tests written before or with implementation
- No skipped or commented tests
- Check with
pnpm test:coverage - Coverage checked on every push
Type Safety
- Explicit return types on all functions
- No
anytypes without documented justification - Rely on Drizzle and Zod inference for generated types
- Never manually duplicate inferred types
Fail Fast
- Validate preconditions immediately
- Never defer errors with fallback values
- Missing config = immediate crash with clear message
- Invalid input = reject at boundary, not deep in logic
Never Hide Problems
- No
|| trueto hide command failures - No
2>/dev/nullto suppress errors - No
@ts-ignorewithout explanation - No
eslint-disablewithout justification - No
--forceor--legacy-peer-deps - No
console.logordebuggerin committed code - Fix root causes, don't mask symptoms
Error Handling
- Never swallow errors silently
- Use custom error classes with context
- Log with sufficient detail for debugging
- Graceful degradation where appropriate
- Every external call wrapped in try/catch
Patterns
Single Source of Truth
- Drizzle schema defines database types
- Zod schemas define API contracts
- Types flow from these sources, never duplicated
Environment Detection
- Always use
envUtils(fromcreateEnvUtilities()) for environment branching - Never check
NODE_ENV,CI, orE2Edirectly - Never use
??fallback defaults for environment variables.envConfigdefines values for every mode - If a variable is missing at runtime, fail-fast with a clear error
- Backend middleware: use
c.get('envUtils')(set byenvMiddleware()) - Middleware running before
envMiddleware(): callcreateEnvUtilities(c.env)directly - Service factories: accept
EnvContextand callcreateEnvUtilities()internally
Idempotency
- Every operation safe to retry; every mutating route requires
Idempotency-Key(five declared exemption classes, each backed by an internal wrapper) - Every mutation passes through one of the five
idempotent.*wrappers (byKey,byUpsert,byTransition,byEventId,byExternalPreClaim);runMutationaccepts onlyIdempotent<T> - Never check-then-act: atomic conditional updates (
UPDATE … WHERE expected_state), assert rows affected; on 0 rows, read the actual state — already-done is a no-op, illegal-state is a defect - Storage keys are uuid, never content-addressed
Direct Resource Access
- No gatekeeper services
- Type-safe wrappers for all external resources
- Packages provide safety without network hops
API Client
apps/web/src/lib/api-client.tsis the single source for all typed API calls- All server state management uses TanStack Query hooks wrapping the typed client
- Never use raw
fetch()for API endpoints covered by the typed client - Hono route definitions are the single source of API types (via
AppTypeexport)
Error Responses
- API errors return
{ code: string, details?: object }, with no message field codeis a machine-readable constant exported frompackages/shared/src/schemas/api/error.ts- Frontend maps
codeto user-facing message viafriendlyErrorMessage()from@hushbox/shared - All user-facing error messages live in
packages/shared/src/error-messages.ts - New error codes need: (1) constant in shared error schema, (2) entry in
friendlyErrorMessagemap - Budget/billing notifications use
generateNotifications()(separate system, already user-friendly) - Use
createErrorResponse(code, details?)for all API error responses; neverc.json({ error: ... })
Serverless Mindset
- Handle cold starts gracefully
- No persistent in-memory state
- State lives in database or Redis only
Backend Doctrine
The backend's binding rules, grouped by principle. Mechanisms are described in
ARCHITECTURE.md; these are the constraints on code you write.
Money & Settlement
- Nothing commits mid-run; all money and content commit in the one
settle()transaction, entered only with the brandedSettlementTxhandle - The ledger is double-entry: signed legs per
transactionIdsumming to zero — violating writes must fail at commit - Money is nano-USD
bigint; serialize asNanoUSDstrings at JSON boundaries; neverNumber()-coerce money; intermediate markup math innumeric - Round half-even, once, inside
settle() - Settlement is never balance-guarded — admission is the only gate; negative balances are legal states
- Money is never Redis-only; holds and snapshots are advisory, the ledger is truth
- Budgets and allowances are period-keyed rows written at settlement — never reset jobs
Jobs & Async
- Every must-happen async task is a
jobsrow inserted in the caller's transaction, registered with a payload schema and a mandatory idempotency class - Cron hosts only pollers, retention deletes, and read-only auditors — never delivery
- No message queues, no DLQs; dead jobs are rows, redriven explicitly
Crash Recovery
- Recovery is in-mechanism: leases, TTLs, and lazy checks; read paths never depend on a purge or cleaner having run
- Auditors detect and page; repair is explicit redrive; never add a backup mechanism or a silent self-healing sweep
- Retry and timeout policies only; no in-isolate circuit breakers
Boundaries
- One writer per table; cross-slice writes only through published barrel APIs inside the orchestrator's transaction
- Slice code references only its own slice's schema objects
- Routes hold no business logic and never import repositories; domain imports only its slice's ports
Telemetry
- Log only through the typed
SafeLogFieldslogger;msgaccepts compile-time literals only - Never logged, anywhere: message content, prompts, outputs, keys, ciphertext, PII, request/response bodies
- Errors carry codes, never content; domain code returns
Result, adapters translate throws at ports, an exception reaching a route is a defect (500 + Sentry) - No client-side error/analytics SDKs
- Every metric names its watcher (auditor, dashboard, or alert) or doesn't ship
Registries
- Env vars exist only as
env.configregistry entries (per-mode values, Zod, no fallbacks) - Redis keys exist only as typed key-registry entries (schema + TTL + buildKey)
- Model capability gaps are filled only via
modelOverridesrows — never code
Crypto
- Every blob is versioned; AAD binds the full location tuple including
senderId - Keys are branded types; wraps are domain-separated; nonces are fresh per chunk
- Decompression aborts mid-stream at an absolute byte cap
Changing the Architecture
- Before adopting an excluded service or reversing a deliberate limit, consult
ARCHITECTURE.md— the re-entry conditions are the decision
Accessibility-friendly Conventions
These conventions keep the accessibility widget's CSS overrides effective as the codebase grows. Lint rules in packages/config/eslint.config.js enforce them automatically.
Use Tailwind classes or CSS variables, never inline color/font styles
- ❌
<div style={{ color: '#ff0000', fontSize: 14 }} /> - ✅
<div className="text-destructive text-sm" />
Inline style props for color, backgroundColor, borderColor, fontFamily, fontSize, fill, stroke are banned by ESLint. The widget's contrast and font-scaling toggles can't override values that were hardcoded inline.
Exemptions: native-asset generators (splash-screen, app-icon) that render to PNG. Use eslint-disable-next-line no-restricted-syntax with a comment explaining the exemption.
Use <Img> for content images and <Logo> for decorative branding — never raw <img>
- ❌
<img src="/photo.jpg" alt="..." /> - ✅
<Img src="/photo.jpg" alt="..." />(content image — auto-inverts in inverted-color mode) - ✅
<Logo />(decorative brand mark — exempt from inversion)
Both are exported from @hushbox/ui. The Img wrapper requires alt, defaults to loading="lazy", and supports a decorative prop that adds data-no-invert. Raw <img> in .tsx is banned by ESLint.
Use useAnimationFrame instead of window.requestAnimationFrame
- ❌
const id = window.requestAnimationFrame(tick) - ✅
useAnimationFrame((timestamp) => { /* tick */ })
The wrapper from @hushbox/ui respects prefers-reduced-motion and the user's "stop animations" toggle. Raw requestAnimationFrame is banned by ESLint.
JS animation libraries (gsap, anime, motion-one) are also banned via no-restricted-imports. Use Framer Motion (already in the stack) or CSS animations.
Prefer semantic HTML over ARIA roles
- ❌
<div role="main">,<div role="navigation">,<div onClick={...}> - ✅
<main>,<nav>,<button onClick={...}>
Semantic tags imply roles, support keyboard interactions natively, and integrate with the page-structure landmarks navigator without configuration.
Tag chrome wrappers (sidebar, header, footer, panels surrounding main content) with data-chrome="" for future opt-out behaviors (e.g. focus-mode toggles).
Code Organization
Naming
- Filenames:
kebab-case(e.g.two-factor-setup.tsx,use-delete-account.ts) - Component symbols:
PascalCase(the export name, not the filename) - Hook/utility symbols:
camelCase - Constants:
SCREAMING_SNAKE_CASE - Types:
PascalCase - Tests:
*.test.ts
Structure
- Colocate tests with source
- Shared code in
packages/, never copy-pasted - One component/function per file
index.tsfor exports only
Imports
- External dependencies
- Internal packages (
@/packages/*) - Relative imports
- Type imports last
Testing
Requirements
- Unit tests for all business logic
- Integration tests for database and API operations
- E2E tests for critical user flows
- Integration-first: tests run against real local infra; mocks exist only at true external seams (gateway, payments, email, push) — never for internal slices
- CI's hot path is 100% cassette hits for AI calls — zero charged real calls; a cassette miss is a failure, not a recording (recording happens out-of-band)
- Tests must not depend on execution order
- No hardcoded dates (use time mocking)
- Test behavior, not implementation
What to Test
- Happy paths
- Error conditions
- Edge cases and boundaries
- Idempotency
- Input validation
Security
- Validate all external input with Zod
- Never trust client-provided IDs
- Never interpolate user input in queries
- Never hardcode or log secrets
- Rate limit auth endpoints
Performance
- Measure before optimizing
- Add indexes for common queries
- Cache expensive computations
- Paginate list endpoints
- Stream large responses
- Use workers for tasks >5 seconds
Documentation
When to Comment
Comment durable facts that a future reader with no context cannot derive from the code, names, types, or tests, when the information is load-bearing on correctness or future modification and survives the current task.
Examples:
- Non-obvious business or domain logic
- Source-of-truth designations
- Hidden coupling between files or modules
- Race conditions and ordering constraints
- Security or regulatory requirements the code enforces but doesn't explain
- Performance traps
- Library, browser, or external API quirks
- Rejected alternatives with the reason for rejection
- Code that looks removable but isn't
- Exceptions to established rules
- Subtle edge cases
A wrong comment is worse than no comment. If you can't state the durable fact precisely, leave it out.
When Not to Comment
- Obvious operations
- Self-explanatory names
- Standard patterns
- What code does (code shows this)
- Code you didn't change
Never Include
- Specific file paths that may move
- Hardcoded version numbers
- Specific timing estimates
- Ephemeral values (container IDs, hashes)
- TODO and FIXME
Enforcement
- Pre-commit: Prettier, basic lint
- Pre-push: ESLint, typecheck, tests
- CI: Full test suite, coverage check
- Review: Human judgment on patterns and quality
No exceptions.