Skip to content

Conventions

The house rules, in one place and written for people. CLAUDE.md is the agent brief: the non-negotiables plus a pointer to the page that owns each rule — this one. Commands is the script reference and ADR-0001 holds the decisions the rules come from. When a rule here says "lint-enforced" or "hook-enforced", the enforcement is the source of truth and this page is the explanation.

Toolchain

  • Bun only. bun install; a preinstall guard (scripts/preinstall-guard.js) rejects npm, yarn and pnpm, and bun.lock is the only lockfile. Run scripts as bun run <script> — the scripts themselves are plain Node (scripts/*.js) or Bun (*.ts), and every one takes --help. Bun's test runner is not used; tests are Jest (Testing).
  • Pinned versions. Bun from .bun-version, Node from .node-version (for tools that need it), eas-cli from package.json (always bun run eas ..., never a global install), Maestro at the version .github/workflows/ci.yml pins. bun run doctor checks all of them (Toolchain check).
  • TypeScript 6, strict. @types/* packages are not auto-included; add a package to types in tsconfig.json when its globals are needed. Scripts under scripts/ deliberately stay without @types/node (they require with an eslint-disable line).
  • Expo SDK docs by version. Before writing code against an Expo API, read the docs for the pinned SDK (AGENTS.md carries the URL); APIs move between SDKs.
  • Two linters, one owner each (ADR-0004). bun run lint is oxlint && expo lint: oxlint runs its default rule set first (≈ 0.1 s; .oxlintrc.json holds only ignore patterns), then ESLint owns what oxlint cannot express — eslint-config-expo, RN a11y, simple-import-sort, unused-imports (the ^_ policy) and the local require-testid rule. eslint-plugin-oxlint turns off in ESLint every rule oxlint already runs, so nothing is reported twice. Both linters print warnings and fail only on errors. oxlint and eslint-plugin-oxlint are pinned to the same version and Renovate bumps them together. Local ESLint rules live in eslint-rules/; the folder must not be called eslint/, because expo lint spawns bun eslint … and Bun resolves a local path before the binary.

Commits and PR titles

  • Conventional Commits, lowercase subject. commitlint.config.js extends @commitlint/config-conventional with body / footer line-length checks off. Enforced three times: the lefthook commit-msg hook, the Commitlint job on every commit in the PR range, and the PR title check — because the PR title becomes the squash commit subject on main (JS gate → How merging works).
  • One ticket, one PR, one squash commit, branched off main, merged as soon as the required checks are green, Closes #n in the PR body — not the commit body — so the issue closes on merge; a squash commit's own body does not auto-close anything, so the merge step verifies with gh issue view <n>. /ship-next drives the queue; .claude/execution-queue.md is the ledger and GitHub Issues mirror it. Each ticket is implemented by a fresh subagent in its own git worktree (isolation: "worktree"), so branch switches never race the shared checkout, and an issue labelled deep-dive is grilled with the user and written up first — it is never auto-PR'd. Ledger bookkeeping is its own chore(queue): … commit on main, never part of a ticket PR.
  • Types decide releases (ADR-0002). release-please reads the squash commits on main: feat → minor, fix / perf / revert → patch, a ! after the type or a BREAKING CHANGE: footer → major; these four types are the changelog. docs, ci, test, chore, build, refactor and style are hidden and never open a release PR. Every Renovate PR is chore(deps) (.github/renovate.json5), so a dependency bump alone never releases — mark a dependency change that users should see with a fix/feat follow-up commit. Use a scope where it helps (ci(eas):, test(e2e):, chore(queue):).
  • Never hand-edit version. package.json version is the single source of truth (app.config.ts reads it); the release PR bumps it and the merge is tagged (Release ladder → Store release). To pin a specific next version, add a Release-As: X.Y.Z footer to the PR body.

Hooks (lefthook)

lefthook.yml is installed by the prepare script on bun install:

HookRunsWhy it is here and not only in CI
pre-commitoxlint, then eslint --fix, then prettier --write on staged filesFormatting never reaches a PR diff
commit-msgcommitlint --editA bad subject fails before it exists
pre-pushbun run typecheck, bun run knip, bun run i18n:checkThe fast half of the gate; tests stay local-on-demand to keep pushes short

pre-commit is piped: true, not parallel: eslint and prettier rewrite and re-stage the same files, so running them at once can drop one tool's fix. Piping also means the first failure stops the chain, which is why the cheap oxlint pass (ADR-0004) runs first — the same front pass as bun run lint, read-only here so the fixers own every rewrite. The fixer glob is *.{js,cjs,mjs,ts,mts,tsx} (prettier adds the data/doc extensions), and eslint.config.js re-applies the Expo TypeScript block to .mts / .cts so those lint instead of being skipped.

pre-push deliberately does not run env:check: it validates the developer's own .env.local, which says nothing about the commit being pushed — the required Env check CI job is the real gate. knip stays (it is the one check a PR routinely trips) but is the slowest job here; skip it for a single push with LEFTHOOK_EXCLUDE=knip git push — lefthook's own job filter, so no extra wiring.

Skip a hook only for a chore(queue) ledger commit or an emergency (LEFTHOOK=0 git push); CI runs the same checks anyway.

Source layout

text
src/
  app/            Expo Router routes only (typed routes on). _layout.tsx files own providers and error boundaries.
  components/     Shared UI; components/states = LoadingState / EmptyState / ErrorState; error boundaries.
  features/       One folder per domain (posts, updates): API clients, hooks, feature-local components.
  lib/            App-wide infrastructure: env, sentry, observe, analytics, storage, query-client, devtools.
  providers/      React providers composed by the root layout.
  i18n/           i18next setup + locales/<lang>/common.json (typed keys via i18next.d.ts).
  tw/             Styling primitives (NativeWind) — the View / Text every screen imports.
  __tests__/      Jest tests grouped by kind (screens / components / features / i18n); lib tests sit in lib/__tests__.
  __perf__/       Reassure perf tests (*.perf-test.tsx), never run by Jest.
scripts/          Plain-Node tooling with tests in scripts/__tests__; scripts/lib/ = shared helpers.
.maestro/         The Maestro workspace (flows/, flows/web/, subflows/steps/).
.eas/workflows/   EAS Workflows, one file per workflow.
.github/          Actions workflows, the composite setup action, issue templates, Renovate config.
docs/             One markdown page per concern.
  • Import through the @/ alias (@/lib/env, @/components/states), never relative paths that climb out of a folder. simple-import-sort orders imports (lint-fixed).
  • Route files export a screen as default and nothing else that is not a Router convention (ErrorBoundary, unstable_settings). Logic lives in features/, not in app/.
  • Platform forks use file suffixes (devtools.web.ts next to devtools.ts); knip.jsonc lists them as entries so they are not reported as unused.
  • Naming: kebab-case files (use-update-policy.ts, error-state.tsx), PascalCase components, useX hooks, *.test.tsx / *.perf-test.tsx suffixes, Maestro flow names native/<name> and web/<name>.

Scripts parse arguments and exit the same way

Every script in scripts/ takes its command line through scripts/lib/args.js, which wraps util.parseArgs({ strict: true }) with a per-script option table (type, choices, default, required, multiple, numeric integer / min, and deprecated aliases). That is what makes --flag value, --flag=value and --help / -h behave identically everywhere instead of per-script — do not hand-roll a loop over process.argv.

The shape is always the same:

js
const CLI = { name: 'thing', usage: '…', options: { platform: { type: 'string', required: true } } };

function main(argv) {
  const { values, help } = parseArgs(argv, CLI);
  if (help) return 0; // the lib already printed `usage`

  return 0;
}

runMain(main); // sets process.exitCode; never process.exit()

Exit codes mean one thing each (the header of scripts/lib/args.js is the reference):

CodeMeaning
0the script did its job and what it checks is fine (a skip with a notice counts)
1what it checks failed, or an error the operator must fix
2usage / environment: the command line — or the environment it needs — has to change

process.exit() is banned in scripts: under bun run it can drop piped stdout that has not flushed. Return a code from main and let runMain set process.exitCode.

Scripts run under node; only the wrapper is Bun

Every scripts/*.js file is plain CommonJS that runs under node — the npm scripts spell it node scripts/<x>.js, and bun run <script> only picks the script out of package.json. The reason is not taste: the after_maestro_tests hooks in .eas/workflows/e2e.yml run node scripts/e2e-device-logs.js, node scripts/a11y-audit.js and node scripts/flashlight.js on a worker that checks the project out but never installs node_modules. So:

  • Node built-ins only, node:-prefixed, in those scripts and everything they require (scripts/e2e-common.js, scripts/lib/*.js). No npm package, not even a dev dependency. scripts/__tests__/builtins-only.test.ts walks the require graph and fails on anything else — a bare require('fs') included, because the prefix is what makes the rule greppable.
  • Scripts that are never a hook (e2e-build.js, init.js, repo-settings.js, …) may import packages, but still resolve paths from __dirname, never process.cwd(): they are run from editors, hooks and temp directories, not only from the repo root.
  • Shared helpers live in scripts/lib/: args.js (command line + exit codes), device.js (which, sdkRoot, maestroBin, adbOnline, pickDevice, appId, display — one answer for simulators, emulators and the Android SDK root, for the doctor and the e2e scripts alike), bin.js (binPath(name) / easBin).
  • A repo-pinned CLI is spawned through binPath()node_modules/.bin/<name> — never bunx (which downloads a copy when the install is missing, silently using a different version) and never a bun run <script> hop just to reach a binary. The user-facing docs still say bun run eas …; scripts do not.

App-layer rules

Every pressable and input has a testID

local/require-testid (eslint-rules/rules/require-testid.js) is an error. Maestro selects by id: only — the identifier works as accessibility identifier on iOS, resource-id on Android and DOM id on web — so a component without a testID is untestable end to end. Shared components (the states, buttons) accept testID as a prop and pass it through; screens name theirs by screen (fetch-retry, settings-sentry-test). Jest tests query the same ids.

Strings go through t()

Every user-facing string is t('some.key') from react-i18next, with keys typed against src/i18n/locales/en/common.json. After adding a key, bun run i18n:extract writes it to the catalog (the default value is the key itself, so an untranslated string is visible, not blank) and bun run i18n:check fails CI and pre-push if code and catalog disagree. Tests render the real en catalog; do not mock i18n.

Env is read through @/lib/env

EXPO_PUBLIC_* variables are declared once in src/lib/env.schema.ts (Zod) and read only via import { env } from '@/lib/env' — never process.env in app code. A bad value throws at import time in development and falls back to defaults in production; bun run env:check is the CI gate. A new key goes into the schema, .env.example (documented, empty placeholder) and, for real values, EAS environment variables (bun run eas env:set ...); .env.local is pulled with bun run env:pull, never hand-edited (Environments and secrets). Build-time secrets (SENTRY_AUTH_TOKEN, EXPO_TOKEN) are never EXPO_PUBLIC_.

One config, four variants, no native folders

  • app.config.ts derives the name, bundle id, Android package and URL scheme from APP_VARIANT (development | staging | uat | production). Nothing else hardcodes an identifier; the EAS project id lives once there as EAS_PROJECT_ID. eas.json profiles map 1:1 to variants and set environment so EXPO_PUBLIC_* resolve per rung.
  • CNG only: ios/ and android/ are never committed. Native changes are config plugins or app.config.ts fields, which is what makes @expo/fingerprint the runtime version and lets CI reuse builds. A change that moves the fingerprint is flagged on the PR (Release ladder → Fingerprint drift).

Updates go through useUpdatePolicy

src/features/updates/use-update-policy.ts is the only place that calls expo-updates actions (check, download, reload). Its driver (useUpdatePolicyDriver) is mounted once by the root layout and runs the policy on launch and on every return to the foreground; screens never import expo-updates, and useUpdateInfo is the read-only view for display. This keeps the update behaviour a one-file decision when a project changes it (ADR-0003).

  • Policy per build: EXPO_PUBLIC_UPDATE_POLICY = silent (default: download in the background, apply on the next cold start or idle resume, no UI) | opt-in (same, plus the "Update ready" banner with Restart now / Later) | forced (every downloaded update reloads at once, Sentry flushed first). Set per EAS environment; recommended forced on preview, silent on production.
  • Critical per update: EAS_UPDATE_CRITICAL=1 at publish time makes app.config.ts write extra.updatePolicy: 'forced' into the update manifest; the app reads it from the incoming update and reloads immediately whatever the build policy. It is a workflow input (#137), never an EAS environment variable, and fingerprint.config.js keeps extra out of the runtime version so a critical publish still matches the installed builds.
  • Idle resume (every policy): coming back to the foreground after ≥ 30 minutes in the background (RESUME_RELOAD_AFTER_MS) with an update already downloaded reloads into it.
  • The Updates screen (Settings → OTA updates) shows the active policy and keeps the manual check / download buttons as the test bed.

The session demo and the Stack.Protected guard

The template ships a one-boolean sign-in demo so the shape of an authenticated app is already in place: src/features/session/use-session.ts is a useSyncExternalStore store in the same style as use-update-policy.ts (isSignedIn, signIn(), signOut(), nothing else), the flag is persisted through AsyncStorage — the same storage the query cache uses — and the root layout wraps (tabs) in <Stack.Protected guard={isSignedIn}> with (auth)/sign-in behind the inverse guard. Settings carries the sign-out control.

Three things are deliberate:

  • It is not auth. No tokens, no refresh, no provider SDK, no server check. Stack.Protected is client-side navigation: it hides routes, it never protects data. A real integration swaps the three store functions for the provider's calls and keeps the token in expo-secure-store; everything above useSession() stays as it is.
  • It is removable in one pass. The file header of use-session.ts is the checklist — layout, routes, Settings control, Maestro subflows, bun run i18n:extract. Do that first if your app has no sign-in rather than leaving a dead gate in front of it.
  • Hydration gates the navigator, not the screens. The persisted flag is read asynchronously, so useSession() reports isHydrated: false for the first tick and the root layout returns null until it flips. No navigator means Expo Router has not hidden the native splash yet, so the splash simply stays up a moment longer — a signed-out launch never flashes the tabs, and a persisted session never flashes the sign-in screen. Never render the guarded tree "optimistically" while the flag is still unknown.

+not-found stays outside both guarded groups so an unmatched URL renders it on either side of the gate. Every Maestro flow taps through the gate in its launch subflow; see Testing → End-to-end tests.

Loading, empty and error UI is shared

Screens compose LoadingState, EmptyState and ErrorState from @/components/states instead of hand-rolled placeholders, passing screen-specific testIDs and t() copy. Render errors are caught by ErrorBoundary (@/components/error-boundary) around a subtree, and every route gets RouteErrorBoundary through the root layout's ErrorBoundary export (a route can export its own to override). Both report to Sentry through captureException.

Colour comes from one token set

src/tw/tokens.ts is the single source of truth for app colour (light and dark). Two consumers read it, and neither is allowed its own palette:

  • src/global.css mirrors every token as a CSS custom property — :root for light, the prefers-color-scheme: dark media query for dark — and registers it in @theme inline, which is what makes bg-background, text-foreground, border-border compile.
  • navigationTheme(colorScheme) (src/tw/navigation-theme.ts) maps the same tokens onto React Navigation's Theme for the root ThemeProvider, so headers, tab bars and screen backgrounds agree with what the screens paint. Only colors is ours; fonts stays the platform default.

The CSS mirror is hand-written rather than generated, and src/tw/__tests__/tokens.test.ts parses global.css and fails if any value, key or @theme inline line drifts from the TS. Adding a token is three edits — tokens.ts, both :root blocks, the @theme inline block — and the test names the one you missed. Components never hardcode a hex value.

Telemetry contracts

  • Every screen that loads data calls markInteractive from useObserve() once its content is usable — the empty state counts, loading and error do not. Tests assert it (EAS Observe → The markInteractive contract).
  • Sentry is errors only (src/lib/sentry.ts, no-op without EXPO_PUBLIC_SENTRY_DSN, tracing off); production performance is Observe's job (Performance).
  • Product events go through track() from @/lib/analytics, never a vendor SDK in a screen. One call fans out to both systems the template already has: Observe.logEvent (the event lands on the session next to its launch / TTR / TTI metrics) and a Sentry breadcrumb (so a crash report carries the last few things the user did). Both drop the call when unconfigured — Observe without extra.eas.projectId, Sentry without a DSN — so track() has no "is telemetry on?" branch and is safe to call from a bare checkout. Event names are stable snake_case, past tense, <object>_<verb>, and never carry a value: track('fetch_retried', { source: 'error-state' }), not fetch_retried_from_error_state. Props are attributes, not PII. Adding a third sink is an edit to src/lib/analytics.ts and nothing else; the one shipped call site is the retry in src/app/(tabs)/(home)/fetch.tsx.

Persistence goes through @/lib/storage

src/lib/storage.ts is the only file that imports @react-native-async-storage/async-storage, which is what makes the backend swappable. It has two surfaces because the app needs both:

  • storage.get(key, schema) / storage.set(key, value) / storage.remove(key) — typed, JSON-encoded, validated with a Zod schema on the way out. Everything the app persists itself uses these (use-session.ts stores its flag as storage.get(KEY, z.boolean())).
  • storageDriver — the raw string getItem / setItem / removeItem object, for libraries that serialise for themselves. The TanStack Query persister in query-client.ts is the only consumer.

Reads never throw on bad data: absent, non-JSON and off-schema all come back as null, because all three mean "written by a version of the app that is gone" and every caller already handles "not stored yet". A storage failure — a full or unavailable disk — does reject, and the caller decides what it means.

Moving to expo-sqlite/kv-store (the better default once persisted data outgrows AsyncStorage's single 6 MB Android row) is one import line in that file: it implements the same async method names, plus synchronous variants. Nothing above it changes. Neither backend is a secret store — tokens and anything else that must not be readable from a rooted device belong in expo-secure-store.

CI

Third-party actions are pinned to a commit SHA

Every uses: in .github/workflows/*.yml and .github/actions/setup/action.yml that points at another repo is pinned to a full 40-character commit SHA with the human-readable version in a trailing comment:

yaml
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0

A tag — @v4, and even @v4.4.0 — is a mutable pointer the action's owner can move at any time, so a compromised upstream account silently re-points it at code that runs with our GITHUB_TOKEN and our secrets. A SHA cannot be re-pointed. Renovate keeps the pins current: the helpers:pinGitHubActionDigests preset in .github/renovate.json5 re-resolves each SHA when the upstream tag moves and opens a chore(deps) PR that also rewrites the trailing comment, and the github actions package rule auto-merges minor/patch/digest updates once the JS gate is green. So:

  • Never add a uses: on a tag or a branch. Resolve the SHA first — gh api repos/<owner>/<repo>/commits/<tag> -q .sha dereferences an annotated tag to its commit — and write the matching version in the comment. Never copy a SHA from memory or another repo.
  • The comment is documentation, not a pin: if it disagrees with the SHA, the SHA wins and the comment is a bug.
  • uses: ./.github/actions/... is a local path into this same checkout, not a fetch, so it is not pinned.
  • The same rule applies to anything else CI downloads and executes. curl | bash installers (Maestro's get.maestro.mobile.dev) are replaced by a versioned release archive that is checked against the checksum file published with it, then cached on the version — see the Maestro web job in ci.yml. The Maestro version itself is pinned in three places at once and moved by a Renovate custom manager (CI overview).

Token permissions are granted per job

The workflow-level permissions: block is the read-only floor (contents: read); a job that needs more declares it itself. In docs.yml only the deploy job carries pages: write + id-token: write, so the build job — the one that runs repo code, third-party actions and the whole dependency tree — cannot publish to Pages or mint an OIDC token even if it is compromised.

Delivery rules

  • main is trunk; every merge lands on staging by itself. UAT and production are approval-gated republishes of the same update group — never a re-bundle — and an update only reaches builds with the same fingerprint (Release ladder).
  • Workflow behaviour that depends on something the owner has not set up yet is behind a repo constant — a literal in the YAML, flipped in a PR, with the full list in CI overview → Repo constants. Runs stay green until then.
  • Required checks, merge settings, environments and labels are code (scripts/repo-settings.js); change them there and re-apply, never in the GitHub UI (JS gate → Changing the required set).
  • Bundle budgets are raised only with an Atlas finding in the PR; perf tests are added for every cost you just fixed (Performance).
  • Store releases are two human steps — merge the release PR, approve the production Environment — and the tag is release-please's, never pushed by hand (Release ladder → Store release). fingerprint.config.js keeps the version bump out of the native fingerprint; do not remove that skip.

Docs

  • One page per concern under docs/, named for the concern (native-e2e.md, not e4.md), with a one-line entry in the README's Docs index. A new script goes in Commands; a rule an agent must not break goes in CLAUDE.md too, otherwise a pointer there is enough. CI overview is the entry point for anything that runs in CI.
  • Explain why in the doc and keep the YAML / script comments short and pointing here (EAS caps a workflow file at 16 KiB).
  • Link sections, not just files (js-gate.md#how-merging-works), and cite decisions as "PLAN.md decision N" — the number is a row of ADR-0001, which bun run init ships into the new project unchanged.
  • Prettier formats markdown (tables are re-aligned on commit); bun run format:check is a required check.
  • The same files are the docs site (VitePress, bun run docs:dev to browse it locally, published to GitHub Pages by .github/workflows/docs.yml on push to main). Keep links relative with the .md extension and an anchor where useful (js-gate.md#how-merging-works, adr/README.md): they work on GitHub and the site alike. bun run docs:build fails on a dead relative link and is the Docs required check, so a rename or a moved section is caught on the PR. A new page is added to the sidebar in docs/.vitepress/config.mts (pick the group it belongs to) — except a new ADR, which the Decisions group picks up from docs/adr/ by its H1; docs/index.md is the landing page. Code spans and fences are literal; outside them, avoid bare <tag>-looking text and {{ }} (VitePress parses them as HTML / Vue; write \<p>), and keep README.md files out of docs/ subfolders unless they get a rewrites entry (adr/README.md/adr/).
  • New docs must not contain the template's own identity (name, slug, bundle id, Expo account, GitHub owner) except in the exact spots scripts/init.js rewrites — bun run template:e2e fails on any leftover. Prefer <owner>/<repo> placeholders or a link to the doc that already carries the rewritten value (Template init → What it rewrites).

Not included, and why

Things that look missing and are missing on purpose. Every one of them is a product decision that a template would have to guess at, and guessing wrong costs more than the afternoon it takes to add the real thing. ADR-0001 has the decisions these come from (decision 5: no auth, backend or forms; decision 14: no Storybook); the README's "Commonly added next" section lists the starting points.

Not includedWhy
Push notificationsexpo-notifications needs an APNs key, an FCM service account and a credential per variant before it does anything, and the whole design (who registers a token, where it is stored, what a tap deep-links to) is product-shaped. It is also a native change, so adding it forces a build — cheap to do deliberately, noisy to carry unused.
Feature flagsThe vendor is the decision (LaunchDarkly, PostHog, Statsig, a JSON file on Hosting) and each has a different SDK, caching model and cost. Note that expo-updates already gives you a kill switch per channel and a staged rollout per update (release ladder → Staged rollouts); a flag system is for things the ladder cannot express.
Form libraryZod is already a dependency (the env schema uses it) and react-hook-form + @hookform/resolvers is a two-line install. Wiring a form layer with no forms to hold means inventing field components, and the demo app deliberately has no data entry.
Offline / NetInfoTanStack Query is already persisted to AsyncStorage, so reads survive a cold start without a network layer. Real offline support is mutation queueing and conflict resolution, which is entirely about your backend's semantics. Add @react-native-community/netinfo and the Query onlineManager binding when you know what "offline" has to mean.
Universal / app linksThe per-variant scheme in app.config.ts already handles deep links. Universal links need ios.associatedDomains plus an apple-app-site-association file served from a domain you own (and assetlinks.json with your Play signing fingerprint on Android) — none of which a template can supply, and all of which are a native change.
MMKVreact-native-mmkv is faster than AsyncStorage, but AsyncStorage is what @tanstack/query-async-storage-persister and most of the ecosystem expect, it works on web without a shim, and the persisted query cache is small. Swap it when a profile (Performance) says storage is the cost — not before.

The same rule applies to anything you are tempted to add here: if the template cannot pick the right answer for every project, it ships the seam, not the choice.

Changing a locked decision

The "Locked decisions" table in ADR-0001 is what CLAUDE.md and every doc cite by number (as "PLAN.md decision N", from when the table lived in PLAN.md). To change one:

  1. Open an issue that names the decision number, what changes and why, and what it breaks (workflows, docs, the required-check set).
  2. Grill the proposal (/grill-me exists for exactly this) until the trade-offs are written down.
  3. Record the outcome as an ADR in docs/adr/: copy the template, take the next number, mark the row in ADR-0001 as superseded with a link to it, and add the new record to the ADR index — all in the same PR. There is no second table to update.
  4. Then the implementation PRs, each citing the ADR. The deferred deep dives D1D7 followed the same path: research ticket → grill → ADR → epic (ADR-0001 → Not decided here maps each to the record it landed as).