AGENTS.md@template/base · git:20260930.1462bf9 · 2026-09-30 · sha256 8c21ce13f1e2d324
AGENTS.md@template/base git:20260930.1462bf9A
Immutable. This exact content never changes and is served at /api/v1/blob/8c21ce13f1e2d324.
# AGENTS.md — {{PROJECT_NAME}} web + mobile platform
Canonical project memory (CLAUDE.md points here). Advisory context — the Stop
hook + CI are the real enforcement. Keep under ~350 lines.
SOURCE: docs/harness/README.md.
## Stack (pnpm monorepo, versions live ONLY in the pnpm-workspace.yaml catalog)
- **apps/web** — Next 16 (App Router, React 19, Server Components + Server
Actions), deployed on Vercel. It SERVES the API: `app/api/trpc/[trpc]/route.ts`
mounts `@app/api`, choosing the cookie-backed Supabase client for browser
sessions and the bearer client for mobile. Server-side identity is
`getUser()`/`getClaims()`, NEVER `getSession()`; the request-scoped client is
built per request, never at module scope. `proxy.ts` refreshes the session
cookie and is NOT an authorization boundary (CVE-2025-29927).
- **apps/mobile** — Expo SDK 57 (React Native 0.86 + React 19, Hermes, New
Architecture, React Compiler on) with expo-router file routes under `app/`.
CNG: `android/`/`ios/` are prebuild OUTPUT — generated, never committed; native
change = config plugin from the reviewed allowlist (`tools/expo-plugins.json`).
Data flows through the tRPC client (`src/lib/trpc/client.ts`) or a vertical's
`./client`; the Supabase session lives in `LargeSecureStore`
(`src/lib/supabase/**` — AES-256-CTR key in expo-secure-store, ciphertext in
AsyncStorage), never JS-visible storage.
- **packages/api** — `@app/api`: the framework-neutral tRPC v11 router
(`publicProcedure` → `authedProcedure` → `orgProcedure`; the version-skew
guard on the base). Imports NO `next/*` — the reversibility wall. Mobile takes
it as a devDependency, `import type` only (Metro does not tree-shake).
- **packages/contracts** — `@app/contracts`: hand-authored zod DTOs + the
GENERATED action/event inventories both surfaces import; the `contracts` gate
regen-diffs the inventories.
- **packages/verticals/\*** — feature domains (never import each other), each a
`.` server barrel + a Metro-safe `./client` barrel (the `exports` census is
`tools/exports-walls.json`).
- **packages/platform/\*** — `{errors, events}` are the KERNEL (import nothing —
the bottom of the graph; the single `ActionOutcome` envelope lives here).
`@app/supabase` owns the five Supabase factories (`service_role` BYPASSES RLS —
ADR-governed Edge Functions only); `@app/env` validates env per surface.
- **packages/design-tokens** — the single OKLCH TS token source →
`src/generated/native.ts` (mobile) + `src/generated/web.css` (Tailwind v4);
`design-system` (web/Radix) and `design-system-native` (NativeWind) share only
tokens + icon paths, never components.
- **Supabase** — Postgres + RLS + Auth, ONE backend for both surfaces. Schema is
SQL-first: `supabase/schemas/*.sql` (declarative) + `supabase/migrations/*.sql`
(append-only). RLS keys on `auth.uid()`; the caller runs as `authenticated`.
`pnpm db:up` = `supabase start` (API 54321, Postgres 54322, Studio 54323).
## Package manager: pnpm 11 (pinned via `packageManager`), Node >= 22
ALWAYS `pnpm`, never `npm`/`yarn`. Workspace deps = `workspace:*`; external
versions = `catalog:` (the catalog is the only place version numbers appear).
## Commands
- `pnpm validate` — **THE GATE**: `node tools/validate.mjs`, the 36-step chain
from `tools/harness.config.mjs` (see below). Must be green before a turn ends.
- `pnpm typecheck` (`tsc -b`) · `pnpm lint` / `pnpm lint:fix` · `pnpm format`
(`biome check --write .`) · `pnpm knip` · `pnpm arch` (depcruise).
- `pnpm test` (`vitest run`) · `pnpm test:mobile` (jest-expo, the RN half) ·
`pnpm test:rls` (`node tests/rls/run-rls.mjs` — live isolation vs the supabase stack:
pgTAP + the supabase-js client suite).
- `pnpm db:up` (`supabase start`) · `pnpm db:reset` · `pnpm db:test` (pgTAP) ·
`pnpm db:types` (regenerate the Supabase type mirror) · `pnpm gen` (types + tokens).
- `pnpm dev:web` · `pnpm dev:mobile` · `pnpm mutation`.
- `node tools/harness-status.mjs` — install state, parked files, last turn, reviewers owed.
## The validate contract (YOU MUST)
- A turn is NOT done until `pnpm validate` is green. The Stop hook re-runs
validate (`--report-all`) + `node tests/rls/run-rls.mjs` + the vitest suite
with coverage + the jest-expo suite with coverage (`mobile-unit` — the two
istanbul maps are merged) + `node tools/check-diff-coverage.mjs` (per-file
floors on every CHANGED file UNDER `apps/*/src` or `packages/*/src` — apps/web is a
declared tier, see docs/harness/enforcement-tiers.md) + `node tools/check-duplication.mjs` (token
clones across `apps/*/src` + `packages/*/src`) + `node tools/check-i18n.mjs`
(no hardcoded user-facing string; Intl/toLocale*/toFixed only in `src/i18n/`)
+ `node tools/check-test-quality.mjs` (every test asserts; nothing focused or
disabled) + `node tools/check-mobile-perf.mjs --closure` (every route has a
Maestro flow AND a startup-budget row) and exits 2 until everything passes.
- **Prove, don't claim.** Show passing gate output; never assert "it works".
- Do NOT edit a test in the same turn as the fix it covers (reward-hacking).
- The 36 gates, in order: `format`, `gate-integrity`, `wiring`, `secrets`,
`types`, `lint`, `suppressions`,
`provenance`, `boundaries`, `resilience`, `observability`, `expo-policy`, `native-deps`, `version-sync`,
`prompts`, `licenses`, `schema-rls`, `tenancy`, `auth-posture`, `data-flow`, `types-drift`, `migrations`,
`db-limits`, `contracts`, `query-shapes`, `rate-limits`,
`parity`, `dead-code`, `architecture`, `build`, `styleguide`, `perf-budget`,
`route-manifest`, `security-headers`, `e2e`, `docs-sync`
(docs/harness/gates-catalog.md documents each).
- The 10 Stop-chain steps, in order: `validate`, `rls-isolation`, `unit`,
`mobile-unit`, `diff-coverage`, `duplication`, `i18n`, `test-quality`,
`mobile-perf`, `reviewer-verdicts`. The last one is the only check in the
harness whose subject is the TURN rather than the tree: every reviewer whose
`MUST BE USED` paths this branch's diff touched must have returned
`VERDICT: PASS` on the tree it was dispatched on, recorded by the
SubagentStart and SubagentStop hooks (the diff runs from the merge base with
the branch's upstream and keeps deletions; `torvalds-reviewer` and
`citation-verifier` are owed on every non-empty diff; a BLOCK stands until the
same reviewer passes). Triggers are reviewed data in
`tools/reviewer-triggers.json`. The steps are FROZEN in `tools/stop.floor.json`: the Stop hook runs
the UNION of the local config and that floor, so a project may APPEND a step
and may never subtract one.
- **Toolchain asymmetry:** gates needing a live database, an install, or a
network-verified toolchain SKIP LOUDLY locally when the prerequisite is
absent and FAIL CLOSED in CI (`CI=true` / `HARNESS_REQUIRE_TOOLCHAINS=1`).
A skip is never a pass — do not treat a SKIPPED line as done.
## Security invariants (NON-NEGOTIABLE — hook- and lint-enforced)
- **RLS on `auth.uid()` IS the authorization boundary.** Every user-scoped table
ships `ENABLE` + `FORCE ROW LEVEL SECURITY` in the SAME migration, with
per-operation policies (`TO authenticated`, `WITH CHECK` on INSERT/UPDATE) keyed
on `auth.uid()`, a leading-column owner index, `REVOKE ALL` from `anon`, `service_role`
AND `authenticated`, then the EXACT grants its policies admit (a GRANT removes nothing;
the default leaves it TRUNCATE). `pnpm gen` regenerates the exact-privilege pgTAP
file, never a hand edit. Web and mobile hit the SAME policies, so isolation
is enforced in ONE place; `supabase/tests/**` (pgTAP) + `tests/rls/` (supabase-js)
prove tenant B cannot read A on every `db reset`. **The owner index must carry the
ORDERING, not just the filter** — `(owner_id, <ORDER BY columns, direction>)` so
one index serves the policy, the sort and the cursor range. Exemptions =
human-reviewed `tools/rls-exempt.json`.
- **`service_role` bypasses RLS — it lives ONLY in an ADR-governed Edge Function**
(`supabase/functions/<name>/index.ts`), built via
`createServiceRoleClient_BYPASSES_RLS(warrant)`. NEVER in a Server Action, tRPC
procedure, Route Handler, component, or the mobile bundle. Migrations `REVOKE ALL`
from `service_role`; a function reaches a table only through an explicit per-table
grant attached to the ADR.
- **Server-side identity is `getUser()`/`getClaims()`, NEVER `getSession()`** — the
cookie is attacker-controlled and `getSession()` does not verify the signature.
The request-scoped Supabase client is built per request from `cookies()`, never at
module scope (a hoisted client leaks one request's identity into another's render).
`proxy.ts` is not an authz boundary (CVE-2025-29927) — it only refreshes the
session cookie.
- **The single error channel.** Procedures (`@app/api`) and web Server Actions return
`ActionOutcome<T>` from `@app/errors` on the DATA channel; a domain failure is a
returned `outcomeErr(appError.X())`, never a thrown error. Only two transport facts
bypass it: the auth middleware's UNAUTHORIZED and the version-skew CONFLICT. The
`app-error-only` ESLint rule is the static half.
- **Boundaries.** `verticals ⊥ verticals`; `shared ↛ verticals`; `platform/* →
{errors,events}` only; `packages/api ↛ next/*` (reversibility wall); `apps/mobile ↛
web-only packages`; `apps/web ↛ react-native`. The dual-barrel `exports` census is
`tools/exports-walls.json`; `@app/api` is absent (mobile `import type` only).
- **Every org-scoped table carries an audit trigger** in the migration that creates it:
`AFTER INSERT OR UPDATE OR DELETE ... FOR EACH ROW EXECUTE FUNCTION
audit.write_row('<tenant col>', '<identity col>')`, **never with a `WHEN` clause**.
The trail is `audit.events` — a schema absent from `[api].schemas` with no client
USAGE (RLS on a partitioned parent does not cascade to partitions, so a `public`
audit table is one URL per month away from every tenant's history). Append-only in
four layers: no update/delete policy, no client grant, a `BEFORE UPDATE OR DELETE`
row trigger (the only one that binds `BYPASSRLS`), and a `BEFORE TRUNCATE` statement
trigger on the parent AND every partition (TRUNCATE triggers are not cloned).
Metadata by default; value capture is a reviewed `tools/audit-columns.json` entry and
is refused for `tools/pii-columns.json` columns. ADR: `docs/adr/20260202-audit-trail.md`.
- **A metered table carries a per-org quota** in the migration that creates it: an
`AFTER INSERT ... REFERENCING NEW TABLE ... FOR EACH STATEMENT` trigger executing
`private.enforce_org_quota('<metric>', '<tenant col>')`, plus the `AFTER DELETE`
release twin. **Never FOR EACH ROW** (it serializes every insert behind the org's
one usage tuple) and **never a RESTRICTIVE policy over a `STABLE` count** (the
planner hoists it to one evaluation per statement against the PRE-statement count,
so a single multi-row INSERT of any size passes wholesale — it fails OPEN). Clients
hold `SELECT` only on `org_usage`/`org_quota`: a tenant that can raise its own limit
has none. Overflow is SQLSTATE `53400` → `appError.quotaExceeded()`, which is
deliberately NOT `rateLimited` — waiting never clears a quota, so a client that
conflates them retries forever. ADR: `docs/adr/20260203-resource-limits.md`.
- **Migrations are append-only.** Never edit or delete a committed migration — add a
new timestamped `supabase/migrations/<timestamp>_<slice>.sql`. Destructive DDL
(DROP/TRUNCATE) needs `-- adr: docs/adr/<file>`; DML in a migration needs
`-- harness-allow-dml: <reason>`.
- **Mobile-bundle purity:** `apps/mobile` never imports a server-graph or
service-role module, `next`, `react-dom`, or `@app/design-system` (DOM). It
reaches data through the tRPC client (`src/lib/trpc/**`) or a vertical's `./client`,
and holds its Supabase session only in `LargeSecureStore` (`src/lib/supabase/**`) —
never JS-visible storage, never a log line.
- **The public config is `NEXT_PUBLIC_SUPABASE_URL` / `NEXT_PUBLIC_SUPABASE_PUBLISHABLE` /
`EXPO_PUBLIC_*` transport only**; the service-role key and any provider secret stay
server-env.
- **Store identity is locked** in `tools/identity.lock.json` (bundle id /
package) — it is upgrade identity and never changes. `version`,
`ios.buildNumber`, `android.versionCode` are DERIVED from package.json in
`app.config.ts`; `eas.json` keeps `appVersionSource: "local"`,
`autoIncrement: false`; `runtimeVersion.policy` stays `appVersion`.
- **Store readiness is gate data**, split since 1.0.0: the harness FLOOR
(`tools/store-policy.json` — targetSdk, the Xcode floor, plugin key map) is
owned; YOUR store decisions (`tools/store-tunables.json` — export compliance
stays DECLARED via `ITSAppUsesNonExemptEncryption`, privacy-manifest rows,
the account-deletion surface, icon policy) are seeded and write-guarded.
Every iOS usage string is reviewed in `tools/expo-permissions.json` `ios[]`,
and an auth surface requires the account-deletion surface (a
`session.deleteAccount` procedure — Apple 5.1.1(v)).
- **Prompt lock discipline:** every LLM prompt file is versioned in its name
(`extract.v1.md`) and hash-locked in `tools/prompts.lock.json`. Changing a
prompt = new `.vN` file + re-run the eval + deliberate lock update (the lock
is write-guard-protected).
- **Shell hygiene** (bash-guard enforced): no destructive raw SQL outside migrations,
and store/signing credentials (`EXPO_TOKEN`, Android keystores, Apple API keys)
never touch shell or repo.
## Quality bar
- Data structures first: design schema/DTO/contract before code.
- Eliminate special-casing; delete code; justify every abstraction. `knip
--strict` stays green. Cognitive complexity <= 15 (ESLint error).
- Adversarial self-review before declaring done: try to break your own code.
- **API surface:** procedures + Server Actions return the ONE envelope
(`ActionOutcome` from `@app/errors`); every wire string bounded (`.max()`);
every list keyset-paginated with an unconditional LIMIT
(`packages/verticals/notes` is the worked pattern).
- **Coverage floors are enforced**: the Stop hook runs BOTH unit lanes with
coverage (vitest for server/packages/pure mobile logic; jest-expo for RN
components/screens), then `tools/check-diff-coverage.mjs` holds every CHANGED
source file to the per-file floors over the merged maps.
- **Coverage is not verification.** `tools/check-test-quality.mjs` (Stop chain)
reds assertion-free tests, `.only` (fatal, no escape), and dead `.skip`
declarations; the mutation lane (`pnpm mutation`, CI-blocking) changes the
code and asks whether a test goes red — a SET-based ratchet against
`tools/mutation-baseline.json`, never a score. Accepting a survivor is a
reviewed human act (empty reasons FAIL). The mutated surface is the owned
floor (`tools/lib/mutation-critical.mjs`) plus YOUR additive
`tools/mutation-scope-extra.json` roots — union semantics; a root matching
zero files reds the scoper.
- **Styling is tokens-only, in BOTH themes.** `@app/design-tokens` (the TS
modules in `packages/design-tokens/src`, OKLCH) is the single source;
`packages/design-tokens/scripts/gen.mjs` compiles them — fail-closed on gamut +
WCAG contrast — into the committed adapters `src/generated/native.ts` (mobile,
via `@app/design-tokens/native`) and `src/generated/web.css` (web, the Tailwind
v4 `@theme`). Mobile components style via `useThemedStyles((palette) =>`
factories over those tokens. No color literals, no inline styles, no hand-edited
generated file — the `styleguide` gate regen-diffs the package (its `gen:check`
script) and source-scans `apps/mobile` for raw values;
`tools/styleguide.manifest.json` is the gate POLICY (accent budget, status
surfaces, primitive boundary, motion seam), not the token values.
- **Controls render through `src/components` primitives** (AppText/Button/
Input/Field/Screen/Toast/EmptyState/OptionRow) — raw text outside AppText is
lint-red; new control styling goes into the primitive.
- **Put testID on the interactive/accessible LEAF element** — Fabric view
flattening can detach a testID riding an unstyled wrapper View: the New
Architecture optimizes layout-only Views out of the native tree
(https://reactnative.dev/architecture/view-flattening). A styled or
accessible element survives; a bare layout View may not.
- **Every effect that registers tears down in the cleanup it RETURNS** — the
perf-budget gate's leak scan pairs `.addEventListener`→`.removeEventListener`/
`.remove()`, `.addListener`→`.remove()`, `setInterval`→`clearInterval`,
`requestAnimationFrame`→`cancelAnimationFrame`, `.subscribe(`→`.unsubscribe()`,
`runAfterInteractions`→`.cancel()`. Mobile apps live for days between cold
starts — a leaked AppState/Keyboard listener compounds. Escape = reviewed
`tools/perf-budget.json` `effectCleanupAllow[]` entry.
- **Write UX follows `features/notes`**: optimistic insert with a temp id,
reconcile-or-rollback in ONE reducer (`useCreateNote.ts`), zod errors inline
at the contract boundary, failures as envelope-code toasts translated via
`i18n/errors.ts` — never a phantom row after a failed write.
- **Every user-facing string is a catalog key** (`src/i18n/catalog.ts`); render
with `t('key')` / `useI18n()`. Plurals via `Intl.PluralRules` with a `count`
param, never an `if`. `Intl`, `toLocale*`, `.toFixed()` are BANNED outside
`src/i18n/` (turn-fatal `i18n` Stop step). Hermes ships NO `Intl.PluralRules`,
`Intl.RelativeTimeFormat`, or `Intl.Locale` — the @formatjs polyfills load
FIRST in `app/_layout.tsx`, unconditionally, so device and Node run the same
CLDR data; avoid `NumberFormat.formatToParts` (Android-only under Hermes).
Error copy comes from the envelope's stable `code`, never its raw `message`.
- **Every screen registers in `src/routes.ts`** (id, `titleKey` — a catalog
key, path, file, `states.{loading,empty,error}` testIDs). The route-manifest
gate closes both ways; chrome lives in `tools/route-allowlist.json`. The
mobile-perf closure requires a Maestro flow + `tools/startup-budget.json` row
per route — an unmeasured screen cannot land.
- **Every WEB page registers too**, through a `page.meta.ts` beside its
`page.tsx` (id, `titleKey`, the three state testIDs). `path` and `file` are
DERIVED — `tools/gen-web-routes.mjs` walks `apps/web/app` and writes the
committed `apps/web/lib/routes.generated.ts` (`pnpm gen`), which the same
route-manifest step regen-diffs. Render each testID as
`data-testid={meta.states.<key>}` so the declared and rendered ids cannot
drift; chrome lives in `tools/web-route-allowlist.json`; `app/not-found.tsx`
is required.
- **Data-dense screens follow `features/matrix`**: FlatList IS the virtualizer
(fixed row height shared with `getItemLayout`, tuned window), keyset
pagination via `useKeysetQuery`, one accessible element per row, an explicit
Load-more control alongside the scroll trigger.
- **Commands follow `features/actions`** (the `app/actions.tsx` modal): typed
registry (`registry.ts` — the `ActionGroup` union makes an unsectioned
command a compile error; titles are catalog keys, ranked over the RESOLVED
text), deterministic pure scorer (`fuzzyScore.ts` — total order: score desc,
title asc, id asc; no Date, no randomness), recents persisted through the kv
seam (`recents.ts` — corrupt payloads read as empty, capped, stale ids
filtered at render). New commands extend the registry, never the modal.
- **Design bar (the `designing-mobile-ui` skill is the full doctrine; the
`design-reviewer` holds diffs to it).** Motion uses motion tokens only,
through the seam (`src/lib/motion.ts`), animates transform/opacity only, and
collapses under reduce-motion by construction. Loading is skeleton→content
(the Skeleton primitive mirrors the incoming layout — never prose; the
states sweep asserts the progressbar role). Touchables render through
PressableScale (scale + 44dp `sizes.minTarget` + optional haptic); cards
through Card; icons from the closed glyph set. One accent moment per region
(the accent budget). Empty states carry a primary action; error surfaces
keep their retry + three registers. UI diffs end with a `design-reviewer`
PASS.
## Provenance
- Non-trivial decision sites (RLS SQL, `getUser`/`getClaims` verification, the
service-role warrant, retry/timeout/sampling constants, ATS/policy choices) carry
`// SOURCE: <authority> [corpus: <id>]` (`-- SOURCE:` in SQL). Ids resolve against
`tools/mcp/corpus/index.json` and `tools/mcp/corpus/project.json` (`corpus_search`
mid-turn); add a new authority to `project.json` in the PR that cites it. Cite an
entry whose `groups` cover the decision's class (cross-group escapes =
human-reviewed `tools/provenance-overrides.json`); a bare URL counts only on a
`tools/lib/citation-domains.mjs` allowlisted host.
- Emit one ADR per slice via `/adr <slice>` (records in `docs/adr/`); then run
`/verify-citations` until it returns `CITATIONS: CLEAN`. Review rounds go in
`docs/reviews/<YYYYMMDD>-<slice>.md`, never in the ADR.
## Spec-first & governance
- **Spec-first** for anything touching auth, RLS, migrations, the native config
surface (`app.config.ts` / `eas.json` / config plugins / permissions), or the
API contract: write `specs/<feature>.md` (template: `specs/_template.md`),
get sign-off, then implement.
- Schema changes follow the expand→contract runbook
(`docs/runbooks/expand-contract.md`) — mobile clients skew by MORE than a
version: store review lags, rollouts are staged, and some installs never
update. Server first, contract phase last.
- `/new-feature <name>` drives the one-turn slice recipe (the
`authoring-vertical-slice` skill): migration + RLS → RLS tests → `./client`
data fn → tRPC procedure (+ web Server Action) → web screen → mobile screen →
tests → provenance → green gate. `/new-action`, `/new-migration`, `/rls-check`
and `/verify-invariants` drive the individual steps.
- Reviewers are read-only subagents (the `docs-sync` gate asserts their
frontmatter stays read-only): `security-reviewer` (MUST run on RLS/migration/
auth changes), `web-security-reviewer` (MUST run on Server Actions, the web
Supabase seam, `proxy.ts`, the tRPC route handler, or `NEXT_PUBLIC_` env),
`mobile-security-reviewer` (keychain/app.config/eas.json/permission changes),
`accessibility-reviewer` and `design-reviewer` on UI changes,
`architecture-reviewer` on structural changes under `packages/**` (layering,
abstraction accounting, cohesion, naming — the judgment half of the anatomy
laws), `torvalds-reviewer` before finishing, `citation-verifier` via
`/verify-citations`.
- PRs paste real `pnpm validate` + `pnpm test:rls` output; CODEOWNERS
({{SECURITY_OWNERS}}) sign off on auth/data/harness surfaces. New MCP servers
or Skills must be registered in `docs/security/approved-tools.md` first. Keep
private data out of the lethal trifecta
(`docs/security/sandbox-and-supply-chain.md`).