-
Notifications
You must be signed in to change notification settings - Fork 0
ENGINEERING CHARTER
doc: ENGINEERING-CHARTER repo: praycalc master-source: ../../.claude/docs/ENGINEERING-CHARTER.md read-order: § 22 (When in Doubt) → § 21 (Common AI-Agent Mistakes) → relevant section → .claude/memory/decisions.md master-sha256: 59e2e36ad8f8896d4f23ff09e8e23c7623c6e19c06d6866ec8f959332fa6d522 derivation: scripts/sync-charter.mjs edit-policy: read-only — edit the master, then run sync-charter.mjs
Wiki copy of the Ummeco Engineering Charter. Authoritative master lives at
.claude/docs/ENGINEERING-CHARTER.mdin the ummeco PPI. This page is regenerated byscripts/sync-charter.mjs— do not hand-edit.
AI AGENTS: READ THIS FIRST. This is THE canonical project engineering charter. Every Wave 4 and Wave 5 standard is consolidated here. If you find a contradiction between this charter and any sprint file, planning doc, or per-repo PRI, THIS CHARTER WINS — surface the contradiction to the user as drift.
The PPI (
~/Sites/ummeco/.claude/CLAUDE.md) covers project identity / domains / infra. This charter covers engineering execution standards.
Build technology that helps Muslims live their deen, connect with their ummah, and make Islamic knowledge accessible to the world. Free, authentic, community-first.
-
praycalc/— standalone prayer time calculator (public) -
islamwiki/— Islamic knowledge base (public) -
chatislam/— AI chat dawah + Q&A (public) -
ummat/— private monorepo:app/,pro/,dev/,chat/,backend/ -
flock/— family wellness app (private; transferred from unyeco 2026-04-13)
Per D-P7-13 (Path α): every artifact — code, schema, API, doc, ticket, commit — is authored to the standard a Principal/Senior engineer would defend in design review. There is no "we'll fix it later" tier of work. If a pattern is documented in this charter, it is mandatory the first time the surface is touched.
This charter exists primarily to prevent AI agents from making repeated, costly mistakes. Each section ends with the most common AI-agent failure mode for that surface and the canonical fix. Section 21 is the consolidated mistake catalog.
| # | Invariant | Decision |
|---|---|---|
| 1 | nSelf-only backend; no Supabase/Neon/PlanetScale/managed DB | PPI hard rule |
| 2 | Hasura GraphQL only at runtime; Drizzle for migrations only | PPI hard rule |
| 3 | pnpm only; never npm/yarn/bun install
|
GCI hard rule |
| 4 | Cloudflare Turnstile is canonical CAPTCHA | D-P3-20 |
| 5 | Umami is canonical analytics | D-P3-21 |
| 6 | nChat is canonical chat backbone | D-P3-23 |
| 7 | No Firebase backend (FCM token registration only) | D-P3-41 |
| 8 | Migration target for AI = nself-ai (D-P3-44) | D-P3-44 |
| 9 | Tier enum: Basic / Standard / Premium / Enterprise (NEVER Starter/Growth/etc.) |
D-P7-12 |
| 10 | Brand colors via @ummat/brand tokens; never hardcode hex |
Wave 4 design system |
| 11 | All multi-tenant tables carry org_id NOT NULL |
Wave 5 database |
| 12 | All money fields stored in *_cents BIGINT
|
Wave 5 database |
| 13 | UUID v7 PK on every table | Wave 5 database |
| 14 | RLS via Hasura JWT claims, never via subqueries | D-P7-05, Wave 5 |
| 15 | TypeScript strict + noUncheckedIndexedAccess
|
Wave 4 types |
| 16 | zod validation at every API and webhook boundary | Wave 5 api-design |
| 17 | Conventional Commits via commitlint | Wave 4 cicd |
| 18 | WCAG 2.2 AA baseline | D-P3-11 |
| 19 | Local dev HTTPS port = 8543 (not 443) |
PPI |
| 20 | Flock prefix = fl_ per current PPI (note: D-P3-10 mentioned flo_; PPI rule is canonical) |
PPI § Database prefixes |
AI-NOTE: If a PR proposes adding any tech that contradicts these invariants, reject the PR and surface to user. These are hard locks.
Canonical decision lock list. Every entry below is FROZEN — if you find code
that contradicts a D-* decision, that is drift; surface to user immediately
with the file path and decision ID.
| Decision | Subject | Lock |
|---|---|---|
| D-P3-19 | Flock prefix fl_
|
PPI |
| D-P3-20 | Cloudflare Turnstile canonical CAPTCHA | LOCKED |
| D-P3-21 | Umami canonical analytics | LOCKED |
| D-P3-23 | nChat canonical chat backbone | LOCKED |
| D-P3-41 | No Firebase backend | LOCKED |
| D-P3-44 | nself-ai migration target for AI | LOCKED |
| D-P5-14 | (see decisions.md) | LOCKED |
| D-P5-15 | (see decisions.md) | LOCKED |
| D-P6-01 | (see decisions.md) | LOCKED |
| D-P7-01 | P7 scope = Option C honest scope | LOCKED |
| D-P7-02 |
up_org is canonical tenant table |
LOCKED |
| D-P7-03 | uuid up_sites + template/template_data columns |
LOCKED |
| D-P7-04 |
up_iqama_schedule daily-row model canonical |
LOCKED |
| D-P7-05 | RBAC dual-mode: x-hasura-user-id (hub) + x-hasura-org-id (entity) |
LOCKED |
| D-P7-06 | Signage devices V2 canonical; delete V1 folder | LOCKED |
| D-P7-07 | i18n localePrefix: 'as-needed' (V1) |
LOCKED |
| D-P7-08 | Next.js (authed) route group |
LOCKED |
| D-P7-09 | Template-specific tables: up_sites_{imam,charity,masjid}
|
LOCKED |
| D-P7-10 | Retire ummat.charity apex post charity.ummat.pro
|
LOCKED |
| D-P7-11 | Plural table names: up_donations (NOT up_donation) |
LOCKED |
| D-P7-12 | Tier enum: Basic / Standard / Premium / Enterprise | LOCKED |
| D-P7-13 | Path α — full-quality, no quality debt deferred | LOCKED |
Full text of every decision: .claude/memory/decisions.md (search for the ID).
- TypeScript:
kebab-case.tsfor utilities,PascalCase.tsxfor components - Tests adjacent:
Foo.tsx↔Foo.test.tsx(NOT__tests__/) - Stories adjacent:
Foo.stories.tsx - Folders:
kebab-case - Routes (Next.js App Router):
kebab-casesegments,[param]dynamic,(group)groupings - Server Actions:
actions.tscolocated with route - Migrations:
NNNNNN_<scope>_<verb>_<subject>.sql(zero-padded, monotonic)
- File and default export:
PascalCase - Props interface:
<ComponentName>Props - Compound:
<Card.Header>,<Tabs.Trigger>
-
use<Thing>— must be a function, must obey React rules of hooks - Server-only hooks:
use<Thing>Server(RSC) — but prefer plain async functions for RSC
- Branded IDs:
OrgId,UserId,DonationId(see § 5) - Generic params:
T,U,K extends keyof T(single letter when shape is obvious; descriptive otherwise —TItem,TError) - Discriminated unions over string literals when state matters
-
interfacefor object shapes and props;typefor unions, intersections, mapped
- Queries:
Get<Subject>orList<Subject>s(e.g.,GetMasjid,ListDonations) - Mutations:
<Verb><Subject>(e.g.,CreateDonation,UpdateOrgSettings) - Subscriptions:
On<Subject><Event>(e.g.,OnDonationCreated) - Fragments:
<Subject>Fieldsor<Subject>SummaryFields - File names:
<operation-name>.graphql(kebab) insrc/graphql/
| Prefix | Domain |
|---|---|
up_* |
Ummat Pro core (multi-tenant entity-scoped) |
ua_* |
Ummat App (consumer) |
uc_* |
Ummat Chat |
pc_* |
PrayCalc |
iw_* |
Islam.wiki |
ci_* |
ChatIslam |
fl_* |
Flock |
jz_* |
Jobs / async work |
nk_* |
nChat backbone tables |
tr_* |
Translations / i18n |
sa_* |
Super-admin / platform-level |
lg_* |
Logs / append-only |
sh_* |
Shared lookups (countries, currencies) |
sc_* |
Scheduling primitives |
cron_* |
Cron / scheduler state |
obs_* |
Observability (metrics, traces) |
Standard columns on every table:
id UUID PRIMARY KEY DEFAULT gen_uuid_v7()created_at TIMESTAMPTZ NOT NULL DEFAULT now()-
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()(trigger-maintained) -
deleted_at TIMESTAMPTZ(soft delete; nullable) -
org_id UUID NOT NULL REFERENCES up_org(id)(multi-tenant tables only) - Money:
amount_cents BIGINT NOT NULL,currency CHAR(3) NOT NULL
export const TIER = ['Basic', 'Standard', 'Premium', 'Enterprise'] as const;
export type Tier = typeof TIER[number];
// PRICES (USD): Basic=$0 (Free trial), Standard=$299, Premium=$599, Enterprise=$2499RETIRED names (NEVER use): Starter, Growth, Established, Pro, Free, Plus, Lite.
- Use logical properties:
ms-*/me-*(margin-inline-start/end), NOTml-*/mr-*(RTL safety) - Brand tokens via
@ummat/brandTailwind config plugin — NEVER hex literals in class strings - Component-level: prefer
classNamecomposition viacn()helper (clsx + tailwind-merge)
UPPER_SNAKE_CASE- Public (browser-exposed):
NEXT_PUBLIC_*prefix only when truly safe - Per-app namespacing:
UMMAT_PRO_*,PRAYCALC_*,CHATISLAM_*, etc. - Documented in each app's
.env.examplewith one-line comment
- Public:
/,/about,/donate - Authed: under
(authed)group (D-P7-08) — same URLs, different layout - API:
/api/<verb>-<resource>(Next.js route handlers); prefer Server Actions or Hasura
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"exactOptionalPropertyTypes": true,
"verbatimModuleSyntax": true,
"moduleResolution": "bundler",
"target": "ES2023"
}
}export type OrgId = string & { readonly __brand: 'OrgId' };
export type UserId = string & { readonly __brand: 'UserId' };
export type DonationId = string & { readonly __brand: 'DonationId' };
export const orgId = (s: string): OrgId => s as OrgId;AI-NOTE: never pass a raw string where an OrgId is expected. The compiler
catches this only if you maintain the brand discipline.
type SubscriptionState =
| { status: 'pending'; createdAt: Date }
| { status: 'active'; activatedAt: Date; renewsAt: Date }
| { status: 'past_due'; lastFailureAt: Date; retryCount: number }
| { status: 'comped'; grantedBy: UserId; reason: string }
| { status: 'canceled'; canceledAt: Date };-
any— banned. Useunknownand narrow. -
as any— banned (rule:@typescript-eslint/no-explicit-any). -
// @ts-ignorewithout// JUSTIFY: <reason; ticket #>on the line above — banned. -
// @ts-expect-errorallowed only when paired with// JUSTIFY:line.
Every API route, Server Action, webhook handler, message queue consumer, and worker job MUST validate input via zod at the entry point. No exceptions. Cast-to- type at the boundary is forbidden.
- Mandatory:
graphql-codegenruns in CI with--checkflag - Generated files in
src/graphql/__generated__/— NEVER hand-edit - CI gate:
pnpm graphql:checkmust pass on every PR
/**
* FILE: <path>
* PURPOSE: <one sentence: what this file is responsible for>
* INVARIANTS:
* - <hidden truth caller must know>
* - <state guarantee>
* RELATED:
* - <other file paths>
* - <ADR or doc>
* DO NOT:
* - <common mistake to avoid>
* - <pattern that broke production>
*/
/**
* Grants a complimentary subscription to an organization.
*
* @param input - validated by `grantCompSchema`; `orgId` must exist
* @returns Subscription in `comped` state with audit row written
* @throws {OrgNotFoundError} when `orgId` does not match a row
* @throws {InvalidGrantorError} when `grantedBy` lacks `comp_grant` permission
*
* @example
* await grantComp({ orgId, grantedBy: actor.id, reason: 'pilot launch', tier: 'Premium' });
*
* AI-NOTE: this is a privileged action. The caller MUST be inside an audit
* transaction created by `withAudit(actor, 'comp_grant', () => ...)`.
* Calling this directly without that wrapper will trip the audit-required
* trigger and rollback.
*/Required sections: Purpose · Install · Usage · API · Testing · Maintainers · Related. Stub READMEs are a charter violation.
Path: .claude/docs/adr/NNNN-<slug>.md. Template: Context, Decision, Status,
Consequences, Alternatives Considered, Related D-* IDs.
-- PURPOSE: <one line>
-- ROLLBACK: <down.sql path | "NOT REVERSIBLE because <reason>">
-- DEPENDS: <prior migration IDs>
-- CONSUMERS: <packages or services that read/write the changed tables>
-- DATA-TOUCH: <NONE | READ | WRITE | DESTRUCTIVE>
-- LOCK-HEAVY: <YES | NO> (if YES, requires off-peak window note)Code shows WHAT. Comments must explain WHY. See § 20 for the seven approved comment patterns and the forbidden list.
| From | To | Allowed? |
|---|---|---|
apps/* |
packages/@ummat/* |
YES |
apps/* |
apps/* |
NO — extract to package |
packages/* |
apps/* |
NO — packages cannot depend on apps |
packages/* |
packages/* |
YES if no cycle |
-
@ummat/types— branded IDs, discriminated unions, shared type aliases -
@ummat/schema— zod schemas (depends only on zod +@ummat/types) -
@ummat/brand— design tokens (no app code, no React)
-
dependency-cruiserruns in CI; any cycle or forbidden edge fails the build - ESLint
import/no-cycleat file level - Custom rule:
no-deep-relative-imports—../../../foois banned across package boundaries; must go through the package's public entry
-
Slots over prop explosion. A
Cardacceptsheader,body,footerslots rather than 12 boolean props. -
Compound for tightly-coupled.
Tabs.Root / Tabs.List / Tabs.Trigger / Tabs.Content. -
Polymorphism via
asChild(Radix-style) when the same visual ships as different elements. -
forwardRefon every leaf interactive component. Required for focus management, popovers, menus, tooltips. Non-negotiable.
Every block that fetches or accepts user input must implement all 7 — no exceptions:
- Empty — no data yet, with CTA where actionable
- Loading — skeleton matching final layout (no spinner-only)
- Partial — some data, some still loading
- Loaded — happy path
- Error — recoverable; with retry affordance
- Offline — explicit network-down state
- Forbidden — authorized user lacks permission; explain why
- Every interactive element: keyboard reachable, visible focus,
aria-labelif no text - Every form field: associated
<label>,aria-describedbyfor help/error - Color contrast: 4.5:1 text, 3:1 UI components
- Target size 24×24 CSS px minimum (D-P3-11)
- Focus not obscured (D-P3-11 — sticky headers must offset focus)
- Drag alternatives where dragging is the only path (D-P3-11)
-
axeruns in every Playwright test; zero violations gate
- Logical properties only (
ms-*/me-*/ps-*/pe-*) -
dir="rtl"flips at<html>based on locale - Icons that have direction (arrows, chevrons): mirror via
[dir=rtl]:scale-x-[-1]
import { brand } from '@ummat/brand';
// CORRECT
className={`bg-[${brand.green.mid}]`} // ❌ STILL WRONG — hex in class
className="bg-brand-green-mid" // ✅ Tailwind plugin maps tokenHex literals in any source file under apps/*/src/** or packages/@ummat/ui/src/**
are a charter violation. Lint: @ummat/no-hardcoded-brand-color.
- One story file per component
- Required stories: Default, AllVariants, AllStates (the 7 above), AllSizes, RTL, Dark
- Visual regression via Chromatic or Percy on every PR
- Pages and layouts are RSC by default
- Drop to Client (
'use client') only for interactivity, browser APIs, or stateful UI
- TanStack Query (
@tanstack/react-query) — never fetch inuseEffect - Cache key convention:
[<resource>, <id?>, <params?>] - Suspense + ErrorBoundary at route boundary
- React Hook Form + zodResolver
- Server Actions for submission (Next.js); never bare fetch from form
- Optimistic updates via
useOptimisticwhere safe
-
nuqsfor query-string-backed state (filters, pagination, tabs) - Never store in
useStatewhat should survive a refresh
- Zustand for cross-tree state (UI prefs, locale override, theme)
-
NEVER store PII in client state — use Zustand
persistmiddleware only for non-PII - Tokens in HttpOnly cookies — never
localStorage
- Hasura subscriptions for live data (donations feed, signage status)
- Bound subscription scope by
org_idfilter at the query level - Tear down on unmount; never leak
[Hasura Action / Webhook / Cron] → [Use Case] → [Domain] → [Infra (Repository / HTTP / Queue)]
↑
Pure functions
- Use case: orchestration, transactions, audit, idempotency
- Domain: pure business rules, no I/O
- Infra: repository implementations, HTTP clients, queue producers
- One repository class per aggregate root
- Returns domain types, never DB rows
- Lives in
packages/@ummat/repos/
export class AppError extends Error {
constructor(public code: string, message: string, public details?: unknown) {
super(message);
}
}
export class NotFoundError extends AppError { /* code: 'not_found' */ }
export class ForbiddenError extends AppError { /* code: 'forbidden' */ }
// ...
export type Result<T, E = AppError> = { ok: true; value: T } | { ok: false; error: E };- BullMQ for queues; one queue per work type
- Idempotency by job id (deterministic from input)
- Retry with exponential backoff (3 attempts, 2x growth, jitter)
- Dead-letter queue for terminal failures
Thin adapters: parse input via zod → call use case → map Result to Hasura response. No business logic in handlers.
-
id UUID PRIMARY KEY DEFAULT gen_uuid_v7()— v7 for time-sortable created_at TIMESTAMPTZ NOT NULL DEFAULT now()-
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()+ trigger to maintain -
deleted_at TIMESTAMPTZ(soft delete; partial indexWHERE deleted_at IS NULL) -
org_id UUID NOT NULL REFERENCES up_org(id)on every multi-tenant table
- Always
*_cents BIGINT NOT NULL - Always paired
currency CHAR(3) NOT NULL(ISO 4217) - Never
NUMERICfor money — precision drift across services - Never JS
numberin code paths that touch money — usebigintor string
- Hasura JWT claims drive RLS, NOT subqueries
- Per D-P7-05:
x-hasura-user-idfor hub-style multi-entity listings;x-hasura-org-idfor entity-scoped CRUD - Custom permission predicates only when JWT-claim model insufficient — document why
- Every FK gets an index (Postgres does NOT auto-index FKs)
- Partial indexes for filtered queries:
WHERE deleted_at IS NULL,WHERE status = 'active' - Composite indexes ordered by selectivity descending
-
CREATE INDEX CONCURRENTLYpost-launch (avoid table locks)
- Allowed for flexible config (template_data, settings, feature flags)
- Banned for queryable content — promote to columns or join tables
- D-P7-09 governs template-specific data: dedicated tables, not JSONB
- Trigger-maintained aggregate caches preferred over materialized views for hot paths
- Audit triggers on every privileged-action table
- Hasura GraphQL first. CRUD, listing, joins, subscriptions.
- Server Actions second. Form submissions, mutations bound to a single page.
- Route handlers third. Webhooks, file uploads, streaming, server-sent events.
- Status codes: 200/201/204 success; 400/401/403/404/409/422 client; 500/502/503 server
- Error shape (always):
{ "error": { "code": "stable_machine_code", "message": "human", "details": {}, "request_id": "..." } }-
request_idpropagated viaX-Request-Idheader
- Verify signature BEFORE parsing body
- Idempotency by event id (store in
lg_webhook_events) - 200 fast (within 1s) — defer work to queue
- Stripe webhook payloads: zod-validate the event variant — NEVER cast
- Hasura Auth canonical (no custom JWT issuers)
- Access token: 15-minute lifetime
- Refresh token: 30-day lifetime, rotating, HttpOnly cookie
super_adminentity_owner- Anyone executing comp grants
- Anyone modifying billing config
- RBAC at app layer + RLS at DB layer (defense in depth)
- Permission catalog in
packages/@ummat/auth/permissions.ts - Audit row on every privileged action — non-bypassable trigger
- Stable string codes for machine handling
- Human messages for UI (i18n via
@ummat/i18nkeys, NEVER hardcoded English) -
detailsfor structured context (validation errors, etc.)
- Use
Result<T, E>for fallible operations - Throw only for truly unexpected (programmer error, system fault)
-
AbortController+ per-call timeout (default 5s, configurable) - Exponential backoff retry (jittered)
- Circuit breaker per upstream dependency
- Dead-letter queue for persistent failures
Required fields on every log line: request_id, entity_id, user_id, route, status, latency_ms.
Structured JSON only. Levels: trace/debug/info/warn/error/fatal.
- Per-event metrics catalog in
packages/@ummat/observability/metrics.ts - Exposed at
/metrics(Prometheus format) on every service - SLO dashboards in Grafana
- OpenTelemetry SDK in every service
- Trace ID propagation via W3C
traceparentheader - Sampled traces: 100% errors, 1% success
- All
level >= errorto Sentry -
request_idandtrace_idattached to every Sentry event
- Public status page:
status.ummat.dev(Upptime per Phase 1 D)
| Shape | LCP | FID | CLS | TBT | JS | CSS | Image (hero) |
|---|---|---|---|---|---|---|---|
| Marketing | 1.8s | 100ms | 0.1 | 200ms | 100KB | 30KB | 100KB |
| App page | 2.5s | 100ms | 0.1 | 300ms | 200KB | 50KB | 150KB |
| Dashboard | 3.0s | 100ms | 0.1 | 500ms | 300KB | 60KB | n/a |
- Lighthouse CI on every PR (mobile + desktop)
- Bundle-size diff comment on PR (
@next/bundle-analyzer) - ISR + on-demand revalidation for marketing/content
- KV cache (Cloudflare KV) for slug→entity_id lookups
| Layer | Tool | Coverage Bar |
|---|---|---|
| Unit | Vitest | 80% global / 90% critical-path |
| Integration | Vitest + testcontainers | every use case |
| E2E | Playwright | 84-permission matrix per template |
| Visual regression | Chromatic or Percy | every PR |
| Mutation | Stryker | money paths required |
| Load | k6 | per release; SLO-bound |
| A11y | axe in Playwright | zero violations |
A flaky test (passes / fails on identical inputs) must be quarantined within 24h and fixed within one sprint. Three flake quarantines in a sprint = team retro.
- format (prettier) → lint (eslint) → typecheck (tsc) → test (changed) → secret scan (gitleaks)
<type>(<scope>): <subject> where type ∈ feat|fix|refactor|chore|docs|test|build|ci|perf|style|revert.
<type>/<ticket-id>-<slug> — e.g., feat/T-P7-A1-15-add-up-donations-view.
Required sections: What · Why · How · Tested · Screenshots (UI changes) · Checklist · Related ADR / D-* IDs.
- ≥1 approval required
- All checks pass (lint, typecheck, test, build, lighthouse, dep-cruiser, codegen-check)
- No force push
- Linear history
Gated per GCI Version & Release Lock. NO version bumps, NO tags, NO publish without
a STORM-approved release plan in .claude/planning/release-{version}.md.
- gitleaks pre-commit + CI gate
- All secrets via
~/.claude/vault.envor per-environment.env(never committed)
| Cred | Rotation |
|---|---|
| OAuth secrets | 90 days |
| Database passwords | 180 days |
| API keys (Stripe, Anthropic) | 365 days or on staff change |
| JWT signing key | 365 days with overlap window |
| Webhook signing secrets | on every consumer change |
-
pnpm audit --audit-level=highgate on PR - Dependabot enabled with security-only auto-PRs
- License-checker: MIT / Apache-2.0 / BSD / ISC only (GPL flagged to user)
- SBOM via
cyclonedx-npmpublished per release - Pinned (no caret) for high-risk:
stripe,next,jsonwebtoken,@hasura/*
-
Invariant —
// INVARIANT: org_id matches actor's claimed org_id post-RLS check -
Contract —
// CONTRACT: callers must hold the audit transaction (see withAudit) -
Decision tree —
// WHY this branch: cents-only path; alt was Decimal but breaks FFI to Stripe webhook payload -
Workaround —
// WORKAROUND: Hasura issue #9012 — Remote Schema doesn't pass headers; revisit at Hasura ≥ 2.40 -
Reference —
// SEE: ADR-0014 | docs/billing-state-machine.md | issue #312 -
Surprising-behavior —
// CAUTION: this looks like a normal upsert but emits a domain event with side effects -
AI-instruction —
// AI: when modifying this, first read docs/RLS.md; common mistake is widening the predicate
- "// adds two numbers" (code shows that)
- "// returns the user" (code shows that)
- Multi-paragraph philosophy
- TODO without ticket reference:
// TODO: T-P7-XX-NN — extract X helper - Personal-name attributions ("// Aric: ...")
- AI provider attribution ("// generated by ...") — banned by GCI
See § 6.
See § 6.
interface ButtonProps {
/** Visual variant. `primary` triggers brand color from @ummat/brand. */
variant: 'primary' | 'secondary' | 'destructive' | 'ghost';
/** Whether to render as another element via Radix-style asChild. */
asChild?: boolean;
/** REQUIRED if no children — provides accessible name. */
'aria-label'?: string;
}/**
* Subscription state machine:
*
* ┌─────────┐ grant ┌────────┐
* trial ─►│ pending │ ───────► │ active │
* └─────────┘ └────────┘
* ▲ │
* │ retry │ comp_grant
* │ ▼
* ┌─────────┐ ┌────────┐
* │ past │ ◄────── │ comped │
* │ due │ revoke └────────┘
* └─────────┘
* │
* │ cancel
* ▼
* ┌─────────┐
* │canceled │
* └─────────┘
*
* Transitions enforced by trigger up_subscription_state_check (migration 00NN).
*/For each: PROBLEM → SOLUTION → DETECTION.
| # | Problem | Solution | Detection |
|---|---|---|---|
| 1 | Hardcoding brand color hex (e.g., #79C24C) |
Use @ummat/brand token via Tailwind plugin |
CI lint @ummat/no-hardcoded-brand-color
|
| 2 | Wrong tier name (Starter / Growth / Free / Plus) | Use D-P7-12 enum: Basic / Standard / Premium / Enterprise
|
CI lint @ummat/canonical-tier-name
|
| 3 | Direct Postgres client from app code | Always go through Hasura GraphQL | ESLint no-restricted-imports for pg/postgres in apps/*
|
| 4 | Stripe webhook payload cast to type | zod-validate the event variant first | Custom rule: @ummat/require-zod-at-route-boundary
|
| 5 | Hardcoded English in JSX | next-intl <Trans> / t('key')
|
eslint-plugin-i18next/no-literal-string |
| 6 |
as any cast |
Fix the underlying type |
@typescript-eslint/no-explicit-any (error level) |
| 7 | Modifying generated GraphQL files | Run codegen and edit source schema | CI gate pnpm graphql:check
|
| 8 | Editing .claude/ via Write/Edit tool |
Use claude-write / claude-edit scripts |
PreToolUse hook auto-approve-claude-dirs.sh
|
| 9 | New table without org_id (multi-tenant) |
Add org_id UUID NOT NULL REFERENCES up_org(id)
|
Migration lint custom rule |
| 10 | New API route without zod parse | Add zod parse at top of handler | Rule: @ummat/require-zod-at-route-boundary
|
| 11 | No file header | Add canonical 5-field block (§ 6) | Rule: @ummat/file-header-required
|
| 12 | Bare TODO without ticket |
// TODO: T-P7-XX-NN — explanation |
no-restricted-syntax for bare TODO |
| 13 | Cycle in module imports | Restructure or extract leaf package | import/no-cycle |
| 14 | Brand color drift (the praycalc-style mistake) | Charter § 8 + lint catches | @ummat/no-hardcoded-brand-color |
| 15 | Migration without ROLLBACK section | Write down.sql or document irreversibility |
@ummat/migration-header-required |
| 16 | Reaching across apps via deep relative path | Extract to @ummat/<x> package |
@ummat/no-deep-relative-imports |
| 17 | Skipping CR-B because "small change" | CR-B is GATE per Weight (Wave 4 cr-rubrics) | Process discipline (PR template + reviewer assignment bot) |
| 18 | Adding deps without audit | CI pnpm audit --audit-level=high
|
Automated CI gate |
| 19 | New feature without ADR | Author ADR before opening PR | Required label adr-attached on feat: PRs |
| 20 | Commit without conventional format | commitlint rejects | Pre-commit hook + CI |
| 21 | Reintroducing the /v1/auth/* prefix on hasura-auth calls |
hasura-auth 0.36 at auth.ummat.dev serves bare paths only (/signin/email-password, /signup/email-password, /token, /signout, /signin/passwordless/email); the /v1/auth/* prefix has no nginx upstream and 502s in prod |
Live probe: curl -I https://auth.ummat.dev/v1/auth/signin/email-password must never be treated as a passing check; grep for v1/auth in client auth code |
| 22 | Pointing billing calls at api.praycalc.com
|
api.praycalc.com is the Hasura CORS host only — it 404s on /billing. Billing lives at https://smart.praycalc.com/billing; web goes through /api/billing/* proxy routes, desktop calls smart.praycalc.com directly (Tauri CSP must allow it) |
grep for api.praycalc.com near billing/checkout/portal code |
| 23 | Trying to "unset" a Vercel header in a later headers() rule |
Vercel headers cannot be unset once set by an earlier matching rule — only overridden. To make a route iframe-able again, override with a permissive/invalid value (e.g. X-Frame-Options: ALLOWALL so browsers ignore it, with CSP frame-ancestors governing instead), never rely on a later rule removing it |
Manual header check: curl -I <route> and confirm no conflicting X-Frame-Options: DENY/SAMEORIGIN survives from an earlier rule |
AI-NOTE: This catalog is the cheatsheet. If a code change does not appear on the right side of any of these mistakes, it is acceptable. If it appears on the left side, the lint will catch it — do not waste a CR cycle on it.
Read in this order:
- This charter (you are here) — section that matches your surface
-
.claude/memory/decisions.md— search for the relatedD-*ID -
.claude/planning/p7-wave4-*.md— Wave 4 standard for the surface -
.claude/planning/p7-wave5-*.md— Wave 5 deep dive (db / api) - The relevant per-repo PRI (
<repo>/.claude/CLAUDE.md) - If still unclear: surface to user. Do NOT guess. Drift from a guess compounds across the codebase faster than asking for 30 seconds of clarification.
- Charter is updated only via STORM-approved planning docs
- Each charter update bumps the
authored:date in the front-matter - Charter sections are referenced by
§ Nin PRs, ADRs, and tickets - Drift between charter and code is a TICKET, not a fix-in-passing — file it
End of Engineering Charter. Read § 21 before every code change. Read § 22 when stuck.
PrayCalc by Ummat Dev | MIT License