How we write code, structure changes, and ship safely in this repo.
Contributing a change? Branch from
main, open a PR — see docs/CONTRIBUTING.md. The PR conventions below apply.
- TypeScript strict,
noUncheckedIndexedAccess,exactOptionalPropertyTypes. Set intsconfig.base.json; do not relax per-package. - Node 20+, ESM modules (
"type": "module"everywhere). - Formatting: Prettier defaults, 2-space indent, single quotes for JS/TS, trailing commas.
- Naming:
camelCasefor values,PascalCasefor types/classes,kebab-casefor file names,SCREAMING_SNAKEonly for env vars. - No
any, no@ts-ignorewithout a comment explaining the reason. - Comments are rare. Only for non-obvious WHY — workarounds, hidden invariants, surprising behavior. Never narrate WHAT the code does.
types → db → core → (mcp-server | web | worker | sync-bridge)
Rules:
typesdepends on nothing.dbdepends only ontypes.coredepends ontypes+db. It must never import fromapps/*.- Apps depend on
core+db+types. They must not import from each other; share logic via a new package.
Violations block CI.
Every write path in packages/core or packages/db must uphold the invariants in KNOWLEDGE.md §5:
- Knowledge immutability — no in-place edit of persisted rows.
- Provenance —
sourceSessionIdsorextractedBynon-empty. - Scope boundary — queries always filter by owner.
- Embedding required — nightly backfill for stragglers.
- Confidence/decay ranges — clamp, never trust caller.
- Anti-principles need evidence.
- Peer Card overrides KEA.
- Autoskill requires user approval.
- Audit log is append-only — no code path deletes AuditLog rows, not even GDPR erase.
- No silent auth fall-through —
getCurrentUserId()must 503 when no auth is configured. - Voucher claims are transactional (SELECT FOR UPDATE).
- Retrieval scoring weights (
kra.tsWEIGHTS) require a benchmark run before change — export a fixture (BENCHMARK_USER_ID-scoped on any multi-user host so no other account's prompts leave the DB) and runpnpm --filter @brain/core run benchmark:retrieval fixture.json(NDCG@5, KRA vs cosine); record before/after indocs/VALIDATION.mdin the same PR. The regression bar is the delta vs the cosine baseline on the current fixture, not an absolute score — absolutes are fixture-dependent (first real-corpus run 2026-07-06: KRA 0.4514 vs cosine 0.3036; the retired seed fixture had cosine at 1.0). - Knowledge rows track actual usage —
usageCount,successCount,failureCountmust be bumped via the designated helpers, not direct SQL writes. - Env-var passthrough. A new
process.env.*read inapps/worker,apps/web, orapps/mcp-serverMUST also be added to that service's explicitenvironment:allowlist indeploy/docker-compose.yml(e.g.VAR: ${VAR:-default}). The services use an allowlist, notenv_file, so an unlisted var set in.envis silently ignored at runtime — the v1.10.0 autoskill classifier shipped un-toggleable in prod this way (fixed v1.10.2). Same trap as theKEA_MODELpassthrough note already in the compose file. - Backup image major must match the db major. The
backupservice (prodrigestivill/postgres-backup-local:<major>) and thedbservice (pgvector/pgvector:pg<major>) indeploy/docker-compose.ymlmust share a PostgreSQL major —pg_dumprefuses to dump a newer-major server, and it fails silently at 3am, not in CI. Bump both together. Every nightly backup was failing this way for ~3 weeks after a pg16 upgrade left the backup tag at:15(fixed v1.11.1). - Alarm on the artifact, not the process. A monitor for a periodic job (backups, crons) must check the freshness of the job's output (e.g. newest
/data/backups/last/*.sql.gzmtime vsBACKUP_DUMP_MAX_AGE), not the process's health signals — container healthchecks and green gates hid a total pg_dump failure for three weeks (v1.11.1). An existing-but-empty output directory is the failure state, not "unconfigured". - Piped-export integrity.
docker compose exec -Ttruncates stdout at 64 KiB with exit code 0 — a piped export can be silently cut mid-JSON. For payloads that can exceed that, write to a file inside the container anddocker compose cpit out; either way, validate integrity (parse the JSON, check expected counts) before trusting any piped export. Found the hard way on the first fixture export (2026-07-06).
Additionally, the multi-tenant invariants from KNOWLEDGE.md §12.17–§12.24 are authoritative — every Organization, Project, MCPToken, and Knowledge mutation must pass their guards.
Invariant violations are bugs, not features. Fix the violation; do not loosen the invariant.
| Scope | Location | Expected |
|---|---|---|
| Unit (core) | packages/core/src/__tests__ |
pure-function coverage for scoring, exporter helpers, parsers, effectiveness, uplift, email; 450 total across all packages as of MVP complete |
| Unit (web) | apps/web/lib/brain/__tests__ (Phase 1, planned) — first real coverage landed 2026-07-14 at apps/web/app/api/**/*.test.ts (route Handler tests, vitest.config.ts) |
hook state machines (useOracle, useKnowledge), parsers (parseSSE), proxy (check()) — planned; what actually shipped first is Route Handler tests (GET/POST/PATCH functions called directly, no HTTP server), see the auth-mocking note below |
| Integration | packages/core/__tests__/integration |
session-to-knowledge, outcome-feedback-loop, retrieveScored round-trip |
| Benchmark (harness + unit) | packages/core/src/retrieval-benchmark.ts, packages/core/src/__tests__/retrieval-benchmark.test.ts |
pure NDCG@5 harness (KRA vs cosine) reusing kra.ts scoreItem; the run/export entrypoints live in packages/core/scripts/ (outside the CI tsconfig — not typechecked). See docs/VALIDATION.md. |
| E2E | apps/web/e2e |
28 specs covering every surface + tokens + onboarding + tweaks + i18n + docs-i18n + a11y + responsive + mobile-overflow + streaming. Run the full suite locally with pnpm --filter @brain/web e2e (dev stack must be up). The anonymous subset (welcome-public-urls, docs-i18n, healthz) runs automatically in CI via the onboarding-e2e gate whenever an onboarding/unauth surface changes — see CICD.md. security.spec.ts joined authed-e2e's file list (2026-07-10) — its "negative path" tests force their own clean storageState via test.use() since the project default is pre-authenticated. Lesson (2026-07-10): a spec file existing in the repo, even with prior passing history, does not mean it runs in CI — every workflow here passes an explicit file list to playwright test, not a directory glob. Before writing "new e2e regression test" in a commit/PR, grep the .github/workflows/*.yml file lists to confirm the file is actually on one — the CI job passing is not itself evidence. |
| E2E fixture | packages/db/prisma/seed.ts (an early PR) |
Deterministic seed produces 1 admin user (Alex) + 1 org + 1 project + 6 sessions + 16 knowledge rows + 4 autoskill proposals. Idempotent via upsert; safe to re-run. Wired in prisma.config.ts#migrations.seed (Prisma 7 location — NOT package.json#prisma.seed, which silently no-op's in v7). Counts are load-bearing — skills.spec.ts asserts ≥16, sessions.spec.ts asserts ≥6, autoskill.spec.ts asserts 4. |
| Visual regression | apps/web/e2e/visual.spec.ts (an early PR, scaffold) |
24 baselines: 6 surfaces × 2 viewports × 2 themes. Inert by default; gated on PWUPDATE=1 (regenerate) or RUN_VISUAL=1 (diff) so it doesn't flake every dev's pnpm e2e. Baseline PNGs land once Phase 2 (data-volatile masking attrs) and the e2e CI Option B (in-stack runner) are ready. |
Run pnpm turbo run test before every commit. A red test blocks merge. If you add a pure helper (parser, scorer, renderer), add at least one test in the same PR.
Route Handler unit tests must mock @/auth and next/headers globally, not per-file (2026-07-17/18). apps/web/auth.ts calls NextAuth(config) at module scope; under vitest's plain Node ESM resolution (no Next.js bundler in the loop), next-auth@5's internals import next/server, which fails to resolve outside Next's own runtime — any test that transitively imports @/lib/brain/auth fails at module load, 0 tests collected, not a logic failure. Separately, next/headers's cookies() relies on Next's request-scoped AsyncLocalStorage, which doesn't exist when a Route Handler is invoked directly as a plain function (as every test here does) — it throws "called outside a request scope," surfacing as a 500 where 200 was expected, once the module-load issue above is fixed and the test actually runs. Both are fixed once, globally, in apps/web/vitest.setup.ts (wired via vitest.config.ts's test.setupFiles): vi.mock("@/auth", ...) short-circuits the whole export surface one layer below @/lib/brain/auth (which stays real/testable — tests still vi.spyOn(authLib, "getCurrentUserId")), and vi.mock("next/headers", async (importOriginal) => ({ ...await importOriginal(), cookies: vi.fn(async () => ({ get: () => undefined })) })) preserves every other export. This was discovered late — the branch that added apps/web's first unit tests wasn't pushed for ~30 hours after landing, so neither failure had a real CI signal until a final pre-merge review forced the push. Any future apps/web/app/api/**/*.test.ts file gets both fixes for free from the shared setup file; do not re-mock either module per-file.
Testing LLM-backed units (keyless CI). Don't mock the provider SDK. Extract the pure cores (prompt build / response parse / decide) and test those directly; pass the LLM call itself as an injectable seam (deps.call, mirroring ExtractOpts.judge) so a test supplies canned output. The autoskill classifier (autoskill-classifier.ts) + its llm-dispatch test are the canonical example — every branch (parse, verdict→routed, flag/shadow, fail-soft fallback, empty-input no-call) runs in CI without an API key, because provider dispatch lives behind the shared packages/core/src/llm.ts callLLMText seam.
Anonymous-surface CI gate (v1.3.0, #1). The onboarding-e2e workflow
(.github/workflows/onboarding-e2e.yml) builds + boots the app and runs the
anonymous Playwright specs whenever a PR touches an onboarding/unauth surface
(apps/web/app/{welcome,signin,forgot-password,reset-password,accept-invite},
layout.tsx, the locale/install code). It's a required check on main, and
a fast green no-op on PRs that don't touch those paths — so it's opt-out by
path, not opt-in by label. This closed the gap where three user-visible bugs
once shipped past CI because the suite only asserted signed-in behaviour. See
CICD.md.
Authed-surface CI tier (2026-06-09, required since 2026-06-10). The
authed-e2e workflow (.github/workflows/authed-e2e.yml) boots the app in
credentials mode with the seeded fixture (the env-admin maps onto seeded Alex
via ADMIN_EMAILS=alex@brain.local), signs in once via e2e/auth.setup.ts,
and runs the signed-in suite — dashboard, sessions, skills, nav, plus a 375px
mobile-overflow regression net (mobile-overflow.spec.ts, #67: asserts no
horizontal scroll on the three main surfaces) — chromium
only with --retries=1, path-gated on apps/web/** + packages/{core,db}/**.
It is a required check on main. Excluded by design: oracle/streaming (no
LLM key in CI), signout (destroys the shared auth state), visual/responsive
(baseline flake).
Rate-limit rule for in-stack test suites (2026-06-10, #52). Any suite that
drives the booted app from a single IP must raise
RATE_LIMIT_MCP_PER_MINUTE in the app's env: the production default (200/min,
apps/web/proxy.ts) trips under a test burst, /api/* starts returning 429,
and surfaces render their error/empty branches — which presents as
intermittent, different-test-each-run failures that no timeout increase can
fix. The 12-iteration authed-tier rollout burned days on timing theories before
the Playwright trace .network log showed the 429s. Corollary debugging
rule: for CI Playwright failures, download the report artifact and read
data/*.md (error context + page snapshot) and the trace zip's .network
entries — run-log greps drown in app log noise.
Playwright top-5 smoke (v0.14.0). A separate deployed-brain Playwright suite
(apps/web/tests/e2e/) covers the top-5 authenticated surfaces against a
LIVE brain: sign-in, dashboard, oracle, sessions, skills. It needs an auth
cookie + a target host and is run on demand (e.g. post-deploy validation). See
APPROACH.md §5ah for the rationale.
Validation discipline: exercise the artifact, not just the tests (2026-05-11). "CI is green" is a necessary precondition, not sufficient evidence that a change does what it claims. For any PR whose value depends on runtime behavior — installers, deploy scripts, observability changes, schema migrations, anything that touches the data plane — run the actual artifact against the actual system before claiming completion. An early PR (MCP observability + installer v2) shipped a bug in its own installer that all tests passed: the unit test used JSON.parse, the installer used a sed-regex that didn't handle JSON-escaped quotes, and the failure mode was an orphan Session — the very thing the PR was meant to detect. The bug surfaced only when the installer ran end-to-end and the DB counters were re-read. Rule: if your PR description says "ships a fix for X," your test plan must include exercising the deployed artifact and observing X actually fixed in the conditions it claims to fix. The audit-friendly read-only diagnostic script at .dev/brain-learning-evidence.sh is the canonical example of a "did X actually fix?" probe.
ESM module-wrapper testing rule (2026-05-14). When a module function (runCrossExtractDaily) wraps another module function (extractFromCrossSessions) defined in the same file, vi.spyOn(mod, "wrappedFn") does NOT intercept calls made by the wrapper — Node's ESM module resolution binds the wrapper's call site to the local function reference at load time, not to the namespace export. Rule: if your test needs to mock a function called from inside the same module's wrapper, use dependency injection — accept the inner function as an optional parameter of the wrapper, default it to the real implementation, and let tests pass a stub. vi.mock(module) is the alternative but it fully replaces the module, making the wrapper itself impossible to test. An early PR's runCrossExtractDaily({ extract? }) is the canonical example.
Env-var passthrough rule (2026-05-12). Setting a variable in .env proves only that the file contains the value. It says nothing about whether any specific container reads it. An early PR spent significant time on a misdiagnosis because KEA_MODEL was correctly set in .env but the worker service's environment: block in deploy/docker-compose.yml didn't pass it through — the worker silently ran with the in-code default. Rule: any env var that gates runtime behavior must (a) appear under the relevant service's environment: block in deploy/docker-compose.yml with a ${VAR:-default} form, (b) be verifiable from inside the container with docker compose exec <service> printenv | grep <VAR>, and (c) carry a one-line comment near its compose entry explaining what it controls — so the next operator who adds a new var or renames an old one doesn't have to trace the chain through code. Worker queues + LLM provider routing are the highest-stakes example.
Major-version config-location rule (2026-05-17). When a dependency bumps a major version, re-read its config-resolution rules — config locations move silently. An early PR wired prisma db seed via package.json#prisma.seed (correct for Prisma 5/6) and shipped CI-green. The first deploy printed ⚠️ No seed command configured and continued because Prisma 7 reads seed from prisma.config.ts#migrations.seed; the package.json location is deprecated and silently no-op'd. Hotfix in an early PR. Rule: when bumping a major version, grep the codebase for every file that names the dependency, then exercise each code path against a real runtime — not against unit tests, which never see the deprecated-location warning. Common silent-deprecation locations: package.json "extras" fields (eslint, prettier, jest, prisma), tsconfig.json extends, .babelrc, next.config.*. Prisma 7's additional gotcha: new PrismaClient() without an explicit driver adapter throws — any standalone script (seeds, one-off backfills) must construct PrismaPg first, matching the runtime singleton in packages/db/src/index.ts.
Vocabulary discipline (2026-05-20). One concept = one word, used consistently across nav, headings, captions, tooltips, error messages, and docs. The canonical glossary lives at /docs/concepts/vocabulary (also published in the webapp's docs index under "Start here"). The five user-facing words are Brain · Skill · Session · Oracle · Proposal; the advanced acronyms (KEA / KRA / MCP / SQS) appear only in tooltips and runbook prose. Anywhere a different word appears for the same concept — "knowledge" for skill, "Autoskill" for proposal, "Teach" without an object — that's drift to fix. The newcomer-eye walk now has three questions: (1) does this state actually move anything visible? (2) does any visible label leak an internal identifier? (3) is the same concept named the same way everywhere? — see docs/APPROACH.md §5ag for the rationale.
Problem-framing discipline (2026-06-27). When writing user-facing copy, the README, or docs, do not frame the problem as "AI coding tools are stateless / they forget everything / every session starts from zero." Modern tools (Claude Code, Cursor, Copilot, ChatGPT) ship memory now, so that hook is inaccurate and a knowledgeable reader bounces off it. Frame the real, differentiated pain: built-in AI memory is siloed — per tool, per project, and per person (it doesn't carry between Claude Code/Cursor/Copilot, across repos, or to teammates), a black box (you can't inspect, correct, or curate it), and vendor-locked / not yours. Position External Brain as the one shared, inspectable, self-hosted knowledge layer across every MCP tool, project, and team that you own — with user/project/team/org scopes it's built for enterprise knowledge reuse (a skill learned once becomes the team's). It also self-improves: autoskill proposes new skills from sessions, reinforces the rules that pay off, and lets weak ones decay, so each project gets better on its own without anyone hand-writing rules. Use it alongside a tool's built-in memory, not instead of it. Positioning copy lives across several surfaces — update ALL of them, not just one: the README hook (H1 + lede), README §Why + the "Why not just use the memory built into Claude Code or Cursor?" FAQ, the HOW_IT_WORKS.md thesis, and — easy to forget, because it isn't a repo file — the GitHub repo About + topics, set via gh repo edit --description "…" --add-topic … --remove-topic …. The rationale is docs/APPROACH.md §5aw.
SEO / discoverability discipline (2026-06-27). Discoverability is a maintained surface, not an afterthought. Keep the high-intent search terms in the most-weighted places: the README H1 + first paragraph, the GitHub About description, and the topics (GitHub caps these at 20). Terms that matter: MCP server, self-hosted, AI coding memory, Claude Code, Cursor, open source, enterprise, RAG. Every image needs descriptive alt text (it is indexed). Optimise for the searcher who has outgrown built-in memory — they search "shared / self-hosted / enterprise AI memory", not "AI memory". Do not keyword-stuff: dense-but-natural beats a wall of terms, and a stuffed sentence reads as machine-written, which undercuts the positioning. See the problem-framing discipline above for the core copy surfaces; image alt text and link-in-first-comment are additional SEO-specific surfaces. Rationale in docs/APPROACH.md §5ax.
Reference-honesty discipline (2026-07-02). Public docs must not cite a PR #NNN / (#NNN) from the pre-open-source private history — a number above the public repo's PR ceiling is evidence a reader cannot resolve, which quietly undercuts the "honest catalog" ethos. When narrating history, use a date and a description ("an early PR", "early PRs") rather than an unverifiable number, or a real (≤ ceiling) reference. This is enforced: scripts/check-doc-refs.sh runs as the doc-refs CI job and fails the build on any phantom reference (CSS hex, external …cli #NNN, and docs/internal/** are allow-listed). Rationale in docs/APPROACH.md §5ay.
Newcomer-eye walk-through rule (2026-05-19). After every UX-affecting change, walk every routed surface — including the auth-flow surfaces (/signin, /forgot-password, /reset-password) and the empty-state variants of each authenticated screen — as a first-time visitor with no AI background. Ask of every chip, badge, breadcrumb, tooltip, model identifier, slug, short id, and queue name: "what does this mean to someone who's never used this app before?" Fix anything you'd ask that question about. The 30-iteration sweep (early PRs, 2026-05-17 → 2026-05-19) found ~30 issues in 3 passes; pass 3 caught the auth-flow surfaces that passes 1 and 2 never opened. Rule: declaring a UX pass "done" after one round leaves the entry surfaces broken for the visitor who matters most: the one who hasn't logged in yet. Internal identifiers leaking through (model id, slug, short id, env-var names in error copy, raw queue names) are the second-most-common newcomer-eye finding; the first is decorative state that updates client-side but doesn't move any visible thing. Validate each iteration with a string-presence probe (e.g. curl /signin | grep -c "sonnet 4.6" should return 0 after iter 6) — typecheck alone never asserts user-facing copy.
Pre-commit hook — activate once per clone.
git config core.hooksPath .githooksThe hook runs typecheck + @brain/core unit tests on non-doc commits (doc-only commits skip). Skip with git commit --no-verify only for emergency hotfixes.
Adding a new navigation surface (route, hash, admin page)?
Every new top-level URL or shell hash-route must land in the same PR as:
- The route/component itself (
apps/web/app/...orapps/web/components/brain/...). - An entry in
scripts/nav-smoke.shso the curl smoke covers it. - An E2E spec under
apps/web/e2e/— at minimum "page renders, no 5xx." - If it's auth-gated: a check in
scripts/verify-lockdown.shonly if the gating behaviour differs from the generic/api/*pattern.
The nav-smoke list is intentionally hard-coded (not route-discovered) so a new surface cannot slip through silently.
- Single-purpose commits. A commit changes one thing.
- Conventional-ish messages:
feat: ...,fix: ...,docs: ...,refactor: ...,chore: .... Not strict Conventional Commits; readability > tooling. - Mention the layer:
feat(core/kea): …,fix(mcp-server/tools): …. - Do not commit secrets.
.env*files are gitignored; double-checkgit statusbefore push. - Do not commit
node_modules,dist,.next,.turbo— gitignored. - No
--no-verify. Fix the hook failure, don't bypass it.
Branch model. Standard GitHub flow: branch from main (feature/<slug>,
bugfix/<slug>, docs/<slug>), open a PR, merge after green CI. main is the
only long-lived branch — no develop, no promotion flow. Never push directly to
a protected branch. Deploy a single Docker Compose stack: ./scripts/dev-up.sh for local, or
./scripts/deploy.sh for the server (TLS via the edge profile); both auto-run
scripts/verify-lockdown.sh + scripts/smoke.sh. Contributor-facing rules in
docs/CONTRIBUTING.md.
Every PR must:
- Link to the relevant doc section or research file.
- Include tests for new behavior.
- Pass
pnpm turbo run lint typecheck test. - Note any invariant it touches in the description.
- Include a "Risk" section for anything beyond a trivial change: what could regress, how to roll back.
- Honest test plans. The PR's "Test plan" / "Validated" section is a contract, not a wish-list. Checks the author actually performed get ✅ with specifics ("CI passed typecheck/test/build/fresh-DB migrate"; "ran the modal locally and confirmed the new placeholder renders"). Checks a reviewer should perform get ⬜ unchecked with a clear "(reviewer to verify)" or "(agent could not run locally)" note. Never write a list of unchecked boxes labelled as if they were performed — see
AGENTS.mdPR descriptions: honest test plans for the full rule and the failure mode it exists to prevent.
Reviewer checklist:
- Invariants from
KNOWLEDGE.md §5preserved. - No new cross-package import that violates the dependency graph.
- No new env var without a corresponding
.env.exampleentry. - No new Prisma model without consideration of multi-tenancy and soft-delete.
- Test plan distinguishes ✅ performed checks from ⬜ reviewer-to-verify.
- Every MCP tool handler authenticates first. The
AuthContextparameter is not optional. - Every REST handler authenticates first. NextAuth session or API token; unauthenticated requests return 401.
- Every top-level authed route directory needs its own
layout.tsxauth guard — do not rely on a sibling route's guard, and do not assume "it fetches authed data" is protection enough. A client component that 401s on its own fetches still returns HTTP 200 for the page itself; the browser renders the full page shell — inputs, buttons, "Create" — around the failed fetch. Found 2026-07-10:/settings/*had no guard (unlike/admin/layout.tsxand the main app shell'spage.tsx, both of which redirect anonymous visitors server-side), so an anonymous visitor saw a fully-functional-looking token form with the literal stringHTTP 401rendered where content should be. When adding one, copy the nearest matching access model, not the nearest file.admin/layout.tsx's bareif (!anySignInConfigured()) redirect(...)is deliberately strict — it intentionally locks out dev-shim (ALLOW_DEV_AUTH=true) deployments, because single-user dev-shim has no per-role admin surface to gate. Copying that gate onto/settings(first attempt, same day) silently locked dev-shim users out of/settings/tokens— their only path to create an MCP token — even though dev-shim resolves a real user fine everywhere else. The right template for any non-admin authed route isapp/page.tsx's three-way gate:anySignInConfigured() ? require a real session : devAuthAllowed() ? pass through : redirect("/signin?error=auth_not_configured"). Ask "does this route need to exclude dev-shim specifically, or just anonymous visitors?" before choosing which existing guard to model. - Audit your audit. A security script that reports PASS must probe the actual attack surface, not a proxy for it.
scripts/verify-lockdown.shoriginally sent MCP requests withoutAccept: application/json, text/event-streamand got back 406 — reportingfail-closedwhen the server was actually rejecting at content negotiation, before auth could run. A real MCP client sends the Accept header and gets 200 +serverInfoon unauth'dinitialize. Rule: if your security audit script reports PASS, open a browser / real client and try the thing manually at least once before you believe the green light. Every lockdown probe should exercise the shape a live attacker or real client uses. - Auth-mode divergence is always a misconfiguration, never a feature.
ALLOW_DEV_AUTH=truewith OAuth envs set but empty-valued (AUTH_GITHUB_ID="") silently activates dev-shim mode — the app serves the first User row to every anonymous caller. Seedocs/KNOWLEDGE.md §12.14for the full truth table anddocs/SECURITY.md §"declared-but-empty OAuth env trap"for the diagnostic recipe. If you touchapps/web/auth.ts, consider adding a boot-time refusal for this combination rather than making the operator figure it out from the/api/meprobe. - Credentials mode is the phase-1 pilot default. One admin account, username +
ADMIN_PASSWORD_HASH(bcryptjs, cost 12) in.env.pnpm hash-admin-password '<plaintext>'generates the hash; the script refuses passwords shorter than 12 chars. The runtime verifier (apps/web/lib/brain/admin-credentials.ts) is constant-time on username and runs bcrypt against a dummy hash on wrong-username attempts to avoid timing side-channels. GitHub OAuth coexists cleanly — both providers can be active at once (CREDENTIALS+OAUTHmode). If you need to add a second admin, provision a secondUserrow withrole='admin'via the admin UI after OAuth is enabled; credentials mode is intentionally one-operator-one-account. - Scope-filter queries at the repository level, not the handler level. A handler that forgets to pass
ownerUserIdshould still get an empty result. - Any new Knowledge/Session-style listing must apply the project filter. Call
getActiveProject(userId)to resolve the active project, then pass the result tobuildKnowledgeWhereV2/buildSessionWhere/buildProposalWherefrom@brain/core/scope-filter.buildKnowledgeWhereV2is the V2 helper that understands the three visibility states (private/project/org) and acceptsaccessibleProjectIdsfor org-level sharing — use it for any Knowledge filter added after Phase 4. A listing that omits the filter is a data-leak bug. For raw pgvector SQL paths (kra.ts, oracle.ts) usebuildRawProjectFilterV2instead — it returns{ sql, params }for appending to the existing query. Seedocs/KNOWLEDGE.md §12.19and§12.22for the normative rules andpackages/core/src/scope-filter.tsfor the implementation. - Most retrieval is owner-scoped by default (
ownerUserId = caller) — a query that widens deliberately must say so at the call site.action-items.tswas the first exception: task lookups are project-bounded, not owner-scoped, because an assignee is rarely the item's creator (the project boundary is the isolation line, per its file-header comment).findSupersessionCandidates(packages/core/src/meeting-extract.ts) is the second: it searches project-wide for a decision an extracted one might replace, because the decision being reversed may have been taught by any project member, not just the person reviewing this extraction. Both document the "why" inline; do not add a third project-wide exception without the same justification. - Anonymous endpoints: validate the gate before revealing existence. On an unauthenticated endpoint, order your checks so a caller who can't pass the gate learns nothing about what exists behind it.
POST /api/auth/registervalidates the voucher before the email-exists check — otherwise an anonymous caller (no voucher) could reademail_takenvsvoucher_requiredand enumerate accounts. Theforgot-passwordroute does the same thing more strongly (always 200). Same family as the constant-time credentials verifier: what you reveal, and in what order, is part of the threat model. Pair every anonymous endpoint with a per-IP rate limit (rateLimitCheck+getRateLimitStore). - Hash bcrypt outside DB transactions. bcrypt at cost 12 is ~200ms by design. Never call
bcrypt.hash/createUserCredentialinside adb.$transaction— it holds a row/connection lock for the full hash. Pre-compute the hash, then pass it into the transactional write (seeclaimVoucher({ passwordHash })andPOST /api/auth/register). - Org-management endpoints require
requireOrgMember. Any endpoint that reads or mutates org members, projects, invites, or org-scoped settings must callrequireOrgMember(db, orgId, userId, minRole?)from@brain/core/orgbefore executing. This throwsBrainError{code:"FORBIDDEN_ORG"}for non-members and enforces the role matrix (owner ≥ admin ≥ member). Do not implement role checks inline in route handlers. - Platform-admin endpoints require
requireAdmin. Any endpoint under/api/admin/*must callrequireAdmin(userId)fromapps/web/lib/brain/admin-auth.ts. This checksUser.role === "admin"and throws 403 otherwise. Do not gate these withrequireOrgMember— they are platform-level, not org-level. - Never log tokens, passwords, or full embeddings. The
redactFields()helper in@brain/core/loggerwalks up to depth 4 and scrubs any key matching (case-insensitive)password | token | apikey | api_key | authorization | cookie | secret | set-cookie | x-api-key | openai_api_key | anthropic_api_key | google_gemini_api_key | brain_mcp_token | database_url.captureErrorapplies it to itsfieldsargument automatically; if you hand-build a log payload, callredactFields()yourself or prefer attaching the rawerrand letting the pino serializer handle shape. - Content sanitization before injection into LLMs.
brain_retrieve_knowledgeoutput is user-generated content; treat it as untrusted. Strip known prompt-injection patterns.
For any new feature that measures or improves Brain quality, surface the value to the user. Don't bury it in benchmarks.
A feature that captures a useful signal but never shows it to the user is a missed flywheel turn. The user needs to see the Brain working — both to trust it and to know how to improve it.
The existing signals establish the standard:
- Groundedness header — per-answer: renders while the answer streams, not after. The user sees
🧠 Grounded on N rules · M sessions · strongbefore the model finishes. - "Why this answer" panel — per-citation: type chip, effectiveness badge,
WHEN:trigger line, last-used time. Not a debug view — the default collapsed toggle makes it a first-class answer surface. - Effectiveness badge on every Skills row — per-rule: ✓ green / ~ yellow / ✗ red / — Untested / ○ Unused. The user can sort and filter by this; it is not buried in a detail pane.
- Thumbs feedback loop — live: clicking thumbs up/down on an Oracle answer immediately bumps the effectiveness counters on the cited rules. The user gets instant confirmation that their signal is wired to the Brain.
Every new Brain-improvement feature must answer: "How does a user know this is working?" If the answer is "they can check the benchmark results", the feature is incomplete. Add a widget, a badge, a header, or an indicator that makes the value observable during normal use — not just in CI.
Every error that leaves the box must be readable by a future agent — human or AI — with no access to the current conversation. The contract is narrow and worth memorizing:
-
Throw a
BrainErrorat any API boundary you own.@brain/coreexports it; construct with{code, category, message, remediation?, retryable?, status?, fields?, cause?}. Use aSCREAMING_SNAKE_CASEcode that a future reader can grep for (EMBEDDING_NO_PROVIDER,ORACLE_CAP_EXCEEDED,DB_UNIQUE_VIOLATION). Use one of the 10 fixed categories:auth | db | http | llm | embedding | validation | rate-limit | external | config | internal. Remediation is a hint the reader can act on, not a restatement of the message. -
Never swallow an error at a boundary. The canonical shape at a
catchsite is:try { ... } catch (err) { await captureError(log, err, { op: "verb.noun", ...otherFields }, "human-readable-msg"); throw err; // or map to an HTTP envelope }
captureErroremits the structured line AND forwards to Sentry whenSENTRY_DSNis set. UsewithTimer(log, "verb.noun", fn)for anything worth a dashboard graph — it emits exactly oneok/errorline withdurMsand re-throws on failure. -
Wrap the request edge with the request helper. Next.js routes →
withApi("verb.noun", handler)fromapps/web/lib/brain/log.ts. MCP tool handlers → the dispatch inapps/mcp-server/src/index.tsalready wraps everyCallToolRequestwithwithRequest(shortId(), ...). Worker job handlers → theboss.work(...)callbacks inapps/worker/src/index.tsalready wrap withwithRequest("job-${id}", ...)andcaptureError. Never skip the wrapper: it's what connects the failure line to therequestIdechoed in thex-request-idresponse header. -
Pick
opnames by verb-noun.oracle.ask,knowledge.create,embedding.call,kea.extract,mcp.tool,rate-limit.redis. This is the primary group-by column in any future log-dashboard query. Keep them stable; renaming anopis a breaking change to observability. -
Outcome tag per boundary. Every timed/error line carries
outcome: "ok" | "error" | "timeout". Error lines additionally carry the serializederr(category/code/remediation/stackHead). A request is never mid-air by the time its log line is emitted — the pino call is the last thing before the function returns or throws. -
Secret hygiene: logger, errors, and envelopes all redact.
BrainError.fields,captureError.fields, and the SentrybeforeSendhook all pass throughredactFields. A token can still leak if you stringify it into amessage— don't do that. If the field name is semantic (fooToken) the redactor catches it; if you invent a new name for a secret, add it toREDACT_KEYSinpackages/core/src/logger.tsin the same PR. -
Requests are correlatable end-to-end.
withRequestuses AsyncLocalStorage so every nestedlog.*()automatically inheritsrequestId. The request helper echoes that id inx-request-idon the response. When a user pastes an error back to an AI agent, the agent can grep the production log by that id and see every line the request produced — no bespoke tracing infra required.
An error log line you will see in prod:
{"level":"error","time":"…","service":"web","requestId":"abc12345","op":"oracle.ask",
"route":"/api/oracle","method":"POST","status":502,"durMs":812,"outcome":"error",
"err":{"name":"BrainError","code":"EMBEDDING_ALL_PROVIDERS_FAILED","category":"embedding",
"message":"All embedding providers failed.","retryable":true,
"remediation":"Check provider API keys and quotas. Logs show which provider failed and why.",
"stackHead":["Module.tryChain (packages/core/src/embedding.ts:119:9)", "…"]}}Everything the agent needs to continue — stable code, actionable remediation, exact throw site — is in that single line.
| Path | Budget | Breach action |
|---|---|---|
brain_retrieve_knowledge (p50) |
< 300 ms | investigate pgvector index |
brain_retrieve_knowledge (p99) |
< 1 s | page |
brain_report_session_outcome |
< 500 ms (enqueue only) | never block on KEA |
brain_ask_oracle (first token) |
< 1.5 s | investigate model latency |
brain_log_event |
< 100 ms | investigate DB write path |
KEA extraction runs in the worker with 3-second budget, enforced.
- Create
apps/mcp-server/src/tools/<name>.tsexporting aToolDef. - Validate input with a zod schema.
- Call out to
@brain/corefunctions; do not inline logic in the handler. - Register in
apps/mcp-server/src/tools/index.ts. - Document in
docs/MCP_TOOLS.md. - Add a schema test that parses valid + invalid input.
MCP surface-area principle — read + safe-write, not destructive (v0.14.0, an early PR). The MCP tool surface is for operations a coding agent should be able to perform autonomously during a session: reading knowledge/skills/sessions, recording outcomes, creating projects, starting sessions, teaching new rules. Destructive operations — deleting a project, renaming an organisation, moving sessions between projects, inviting/removing teammates, rotating an admin password, revoking another user's token — stay in the webapp behind an explicit human click. The line is: if undoing the operation requires looking at an audit log, it doesn't belong in MCP. A new tool proposal that crosses this line needs an explicit decision in the PR description; the default answer is "build it in the webapp instead." This keeps the agent's blast radius bounded to operations whose mistakes are recoverable through ordinary use.
The webapp is a client-side SPA at / with six stateful surfaces. Design tokens, layout rules, and responsive breakpoints live in apps/web/app/globals.css and are sourced from the handoff bundle at research/design/ (External Brain design via Claude Design).
Rules when touching frontend:
- Design tokens are not Tailwind utilities. Use CSS variables (
--accent,--ink,--bg-elev-*,--k-*,--font-mono) defined inglobals.css. Tailwind 4 remains available for hypothetical new code but the Brain surfaces do not use it. - State-based navigation, not route-based. See
APPROACH.md §4.6anddocs/NAVIGATION.md. A new surface editsroutes.ts+ i18n +components/brain/app.tsx— never a sibling folder underapp/. - Every Brain component is a client component. They read
BRAIN_DATAtoday; when wired to the API, prefer React Server Components for the data-fetching parent and keep interactive children as clients. - i18n first, English authoritative — three languages (EN/TH/DE). Every user-visible string routes through
useT()/translate(). Add keys to all three dictionaries in the same commit. EN is authoritative; TH and DE are full translations, not scaffolds. AI-generated translations are acceptable as a starting point for new keys but must be flagged indocs/KNOWN_ISSUES.md(under the i18n section) so a native speaker can sweep them later. Do not hard-code user strings. Use beginner-friendly terminology (e.g. "Rule of thumb" instead of "Heuristic", "Metadata" instead of "Frontmatter") to ensure accessibility for non-technical users. (History: TH and DE were briefly deleted in v0.13 as a simplification, then restored in v0.14 after user feedback — the operator base actually spans those languages. The lesson is inAPPROACH.md §5al.) - Long-form docs content is the one exception to the
useT()rule — translate it as parallel data, not dictionary keys. The in-app/docsprose lives inapps/web/lib/brain/docs-content.tsas structuredDocPageobjects (title/summary/sections/bullets), not short UI strings. Translate it via the parallelDOCS_TH/DOCS_DErecords and resolve withgetDoc(lang, slug), which falls back to the EN page per-slug so a missing translation degrades to English, never a 404 or a raw key. Short docs chrome (index title, section group headings, "Related concepts", etc.) lives inDOCS_CHROMEviagetDocsChrome(lang). The/docspages are client components (useLang()) so the unauth<LocalePicker>switches the whole surface in place with no reload; they still SSR in the cookie-resolved locale viaLangProvider. New concept pages add a slug to all three records in the same PR; AI-translated TH/DE prose for new pages should still be flagged indocs/KNOWN_ISSUES.mdfor review (the original #59 batch was operator-reviewed & accepted 2026-06-22; v1.9.0 addedusing-from-your-agent/graph/decisions, flagged for the next sweep). Two couplings to keep: (1) a new page must also be added to theDOCS_SECTIONSindex array — a page absent from it exists at its URL but never appears on/docs; (2) any nav-surfaceHelpPopoverdocHrefmust point at a slug that actually exists.graphanddecisionsshipped withHelpPopover"Read more" links pointing at concept pages that didn't exist yet (dead links) — author the doc and wire thedocHrefin the same change. For text the user types to an English-speaking agent (the agent-promptcallouts onusing-from-your-agent), keep the source English in every locale — translate only the surrounding instructional prose. - Two help primitives — page-level
HelpPopovervs inlineInfoDot.HelpPopover(help-popover.tsx) is the "what is this surface?" popover in a route header (What / What you do here / Related + adocHref).InfoDot(info-dot.tsx) is a term-level "?" placed next to a single word (a badge, a column header) that tooltips a one-line definition and optionally links to a concept page. Reach forInfoDotonly when a term has no existing explanation — most surfaces already carrytitle=tips or aHelpPopover, so a blanket tooltip sweep duplicates them and adds clutter (violates §10a "quiet by default"). The genuine gaps closed in v1.9.0 were the Skills type filter and the dashboardPulseLineQuality (SQS) number. - Translation strings must never embed dynamic numbers, counts, or sample values. Mock placeholder text like
"0 items retrieved · 2 cited"is the bug pattern from PR #95 — the literal2shipped to all three languages and rendered even when the real count was 0. Counts go in either (a) format-string substitutions ("{n} cited"interpolated at render time) or (b) a separate trailing key wrapped in a conditional render ({count > 0 && <> · {count} {t("oracle.citedInline")}</>}). When you write a translation string, ask: "if a future reader sees this number in this dictionary entry, is there any way it stays correct as data changes?" If the answer is no, it doesn't belong in the dictionary. - Persisted UI state uses the
bp_*localStorage prefix. Current keys:bp_tweaks,bp_route. Prevents collisions with other apps on the same origin. - Zero-error nav. Any change that touches the shell, keyboard handler, or palette must pass the checklist in
docs/NAVIGATION.md §3before merge. - Font loading via
next/font, not<link>. Seeapps/web/app/layout.tsx— adding a font family means adding the loader and the CSS variable. - Pre-hydrate theme script. The inline
<script>inapp/layout.tsxreadsbp_tweaksand applies theme/accent/density/lang before React mounts. Do not remove it; removing it causes a visible flash of default theme. - Server-component env vars need
force-dynamic.deploy/Dockerfilebuilds with dummy env vars (see "Dummy env so env validation at top-level doesn't crash the build" in the builder stage). Anyapp/**/page.tsx,app/**/layout.tsx, orapp/**/route.tsthat readsprocess.env.Xat module/server-component scope must addexport const dynamic = "force-dynamic"— otherwise Next.js statically pre-renders the page during the Docker build with empty values and the deployed container's realprocess.envis never re-read.NEXT_PUBLIC_*vars are exempt (they're inlined at build time on purpose). This is the earlier lesson — v0.14.0 shipped/welcomewith a server-injectedBRAIN_MCP_PUBLIC_HOSTNAMEthat produced an empty URL in prod for exactly this reason; v0.14.2 fixed it by addingforce-dynamic. When you write a new page that reads deploy env, add the directive at the top before you forget. The class-of-bug audit lives atdocs/GUIDELINES.md §10. - Public URLs come from
req.headers['x-forwarded-host'], notreq.url. Inside the Docker container,req.urlresolves to the internal upstream (e.g.http://0.0.0.0:3000), not the public hostname. Constructions likenew URL("/foo", req.url)will emithttps://0.0.0.0:3000/fooin Location headers — wrong for any client behind nginx/Caddy. Either (a) use a path-only Location header (RFC 7231 §7.1.2 permits relative refs, browsers resolve against the request URL), or (b) readx-forwarded-host+x-forwarded-protofromheaders()if you genuinely need the absolute form. The favicon redirect is the worked example. - Mount-gate every client-only global reachable from render (
#418class). Readingwindow/navigator/localStorage/Date.now()/Math.random()during render (incl.useMemoanduseState(initFn)) makes the server's HTML differ from the client's first render → React#418. The rule: initialize state to an SSR-safe default and read the real value inuseEffectafter mount (the patternuseRoute/useProjectScope/useTweaksuse). Repeat offenders found:ShowEverythingFoldlocalStorage (#fixed 06-05),useEnvLabelhostname +/welcomesnippetwindow.location(#fixed 06-07). Before merging shell/dashboard/first-run changes, rungrep -n "typeof window\|typeof navigator"over the touched files and confirm each hit is inside an effect or handler, not a render path. A component that only ever mounts after a client interaction (e.g. a modal opened on click) is exempt — it never hydrates. - Relative time goes through
@brain/core/format-relative— never hand-roll. v0.15.0 consolidated four divergent local formatters (oracle/graph/connection-status each rendered "2 days ago" differently) onto the canonicalformatRelative(iso)→just now / Nm / Nh / Nd / Mmm D. Import via the subpath@brain/core/format-relative(it's split out specifically so it doesn't pull@sentry/nodeinto the browser bundle). Keep the hydration-safe wrapper pattern (<RelativeTime>/<RelativeText>render the absolute date on SSR + first paint, swap to relative inuseEffect) —formatRelativereadsDate.now(), so rendering it directly causes hydration mismatches (audit FE5, #103). - User-facing copy is plain English; internal metric names stay in code/docs/DB (Phase R.5). Surfaces show "Quality" / "Answer relevance" / "Brain"; the code/schema/docs keep
SQS/NDCG@5/KEA. The normative mapping lives indocs/KNOWLEDGE.md. When adding a new metric, give it a plain-English label and keep the precise name in atitle=tooltip, not the visible text.
The webapp is a dense knowledge tool, not a marketing surface. These principles govern what appears on screen and when — code-style rules are in §1, design tokens in §10. Full rationale + examples live in docs/DESIGN_PRINCIPLES.md; this section is the terse normative version.
- Progressive disclosure. Default to the smallest useful surface. Power lives one click, one hover, or one expand away — never on the default canvas. The session-card → per-session value drill-down (an early PR) plus its dashboard wiring + project-level companion (an early PR) are the reference pattern: the row stays terse; the depth is opt-in.
- Earned surface area. A panel, tab, or card only ships once the data to populate it exists. Empty dashboards train users to ignore the UI. If a feature has no data on day one, gate it behind a real precondition (
hasSessions,hasKnowledge,hasProject) and show nothing until the precondition is met — not a placeholder.ProjectsList(an early PR) is the canonical implementation: returnsnullat 0 projects, one compact row at 1, full list at ≥2. - Quiet by default, loud on demand. Background work (KEA, cross-extract, decay) is continuous. The UI whispers it through small status surfaces (last-extracted timestamp, count badges) and only escalates to a banner or modal when the user must act. No toast for routine success. The "Right now" section was demoted below user-facing surfaces in an early PR for this reason.
- One principle per disagreement. When two contributors disagree on a UI call, the resolution is "which of the three above does this serve?" — not taste. If none of them apply, the change is probably scope creep.
Casual shorthand for these three together: "deceptively simple" — the surface looks light, the depth is real.
v0.14.0 validated these at scale. The dashboard + every destination page (Skills, Sessions, Oracle, Graph, Autoskill, Settings) were re-walked under the three principles in a single coordinated sweep. Empty states were re-gated (panels return null until they have data, never placeholder copy); detail panes consolidated under the per-session / per-project drill-down pattern from earlier PRs; the "Right now" system-chatter band sits below user-facing content on every surface that has both. The principles are no longer aspirational — they are what the production surfaces look like, and any new surface should match. See APPROACH.md §5aj for the subagent-driven dispatch pattern used to land the sweep.
Do not. The 5-category rule ontology is deliberate and research-backed. If a new category is truly needed:
- Propose in a PR that amends
docs/KNOWLEDGE.md §2andresearch/knowledge/08-knowledge-ontology.md. - Discuss with reviewers before writing code.
- Migration: backfill existing knowledge; update KEA prompt; update KRA formula; update formatter.
A new type is a cross-cutting change — expect 15+ files touched.
The one shipped exception (V2.0, 2026-07-07): non-rule type values.
action_item (meeting to-dos / open questions) was added as a type value on
the existing string column — no entity, no migration — precisely because it is
NOT a rule and must be excluded from rule semantics. If you ever add another
non-rule value, its mandatory sweep is the inverse of step 3: exclude it
from KRA semantic retrieval and the Oracle's semantic context (share
RULE_TYPES_PREDICATE in kra.ts), keep it out of the KEA/learnings enums,
never record its injections as SessionKnowledgeApplication rows
(loop-health metric purity), and block it from the visibility-travel paths
— promote, fork-to-project, and any org-scoped serving query (the
2026-07-10 security review found action_item rows could be org-promoted and
leak meeting content into every project's Oracle; for non-rules the isolation
line is the project, full stop). Surfacing must be deterministic (tag/field
match), never embedding-scored. See docs/KNOWLEDGE.md §2 and the spec
docs/superpowers/specs/2026-07-07-brain-v2-meeting-doc-intelligence-design.md.
This repo is explicitly designed to be extended with AI assistance. When doing so:
- Give the AI
docs/BLUEPRINT.mdand the relevantdocs/*.mdfiles as context, not the full codebase. - Prefer small, reviewable diffs. An AI-generated 1000-line change is a review failure, not a productivity win.
- Always have the AI run tests before declaring a task complete.
- If the AI proposes violating an invariant, reject the proposal and restart.
This is the same spirit the platform is built to serve: AI + persistent knowledge + human oversight.
Conventions established during the wiring pass (2026-04). Follow them on every new route or hook.
Hook fallback (use-*.ts)
Every lib/brain/use-*.ts hook must: (a) fetch with cache: "no-store"; (b) on failure set loadState = "mock" and return seed data from BRAIN_DATA; (c) expose the error message so the surface can render a "seed" chip. Reference: use-knowledge.ts, use-sessions.ts, use-autoskill.ts.
View-model indirection
API routes return view types from lib/brain/views.ts (KnowledgeItemView, ProposalView, SessionView, …), never raw Prisma models. Mapper functions (toKnowledgeItemView, toProposalView, toSessionView) are the schema→GUI contract — update the mappers when the Prisma schema changes, not the components.
Auth on every route
Every API route that touches @brain/db must call getCurrentUserId() from @/lib/brain/auth first, wrap the handler body in try/catch, and return authErrorResponse(err) on failure. Scope checks (row.ownerUserId !== userId) return 403 before any mutation.
Zod schemas for write bodies
Every POST/PATCH/DELETE body must pass through a zod parse before use. Pattern: const body = bodySchema.parse(await req.json()). ZodError is caught and returned as 400 via authErrorResponse.
Client write paths return the fresh view
PATCH/POST endpoints return the updated view ({ item: toKnowledgeItemView(row) }) so the client can splice it into state without a separate refetch. See use-knowledge.ts update/fork for the pattern.
exactOptionalPropertyTypes
tsconfig has exactOptionalPropertyTypes: true. Never explicitly assign undefined to optional fields; omit the key from the object literal. For Prisma data objects, build a Record<string, unknown> and conditionally insert keys.
Next.js webpack (not turbopack)
next build runs with the --webpack flag (already in apps/web/package.json) until Turbopack supports extensionAlias for NodeNext .js→.ts resolution across workspace packages. The webpack config in apps/web/next.config.ts sets resolve.extensionAlias; do not remove it.
Polling cadence
useCounts polls every 30 s; useLiveExtraction polls every 15 s. Do not add further polling surfaces without a SWR/SSE strategy — revisit before scaling.
Streaming surfaces (SSE)
Long-running endpoints that the user is waiting on (Oracle answers, future KEA spot-checks) stream Server-Sent Events. Contract: one route per streamed capability, Content-Type text/event-stream, event: <kind> + data: <json> pairs, a terminal event: done. Frontend consumers parse with a minimal parseSSE(body: ReadableStream) generator — do not pull in EventSource since we POST. See apps/web/app/api/oracle/stream/route.ts and apps/web/lib/brain/use-oracle.ts for the canonical shape. All new stream producers must forward req.signal into the underlying LLM SDK (cost/correctness).
Rate limiting (apps/web/proxy.ts)
Next 16 renamed middleware.ts → proxy.ts. One proxy runs for every /api/* request and enforces sliding windows keyed by client IP. Adding a new endpoint class: (a) add a classify() branch with a distinct name; (b) either reuse an existing RATE_LIMIT_* env or add one to .env.example; (c) verify the x-ratelimit-* headers in a local curl before merging. Store selection is automatic: Redis when REDIS_URL is set, in-memory otherwise — both implement the async Store interface in packages/core/src/rate-limit.ts. Keep check() signature stable across stores.
Production auth enforcement
apps/web/lib/brain/auth.ts calls refuseDevShimInProduction() before falling through to the dev shim. When NODE_ENV=production, the shim throws AuthError(500) unless ALLOW_DEV_AUTH_IN_PRODUCTION=true is set explicitly (intended for VPN-only deploys). Never relax this — it's the final guard against shipping a deploy that serves every visitor as the first User row.
Audit log + secret redaction
Mutating admin-surface-visible actions go through writeAudit({actorUserId, action, targetType, targetId, payload, ip, userAgent}) from @brain/core/audit. Payloads are redacted with a recursive 4-deep key-pattern match against token|secret|password|apiKey|authorization. New audit actions: add the string literal to the Action union in packages/core/src/audit.ts; there is no schema migration required because the column is free-form text. Already wired: knowledge create/patch/delete/fork + token create/revoke/rotate.
Cost-cap alerts
packages/core/src/cost.ts emits a warn log at 80% and an error log (+ Sentry captureError) at 100% of MAX_ORACLE_COST_USD_PER_DAY. Dedup is in-process, keyed ${userId}:${day} — so each process alerts once per user per UTC day. Multi-replica deploys will alert N × (users × 2) times per day in the worst case; that's accepted as cheaper than a shared dedup table.
Knowledge edits fork, not patch
Never send ruleText / triggerText / rationale in a PATCH body — the server returns 409 immutable_field (KNOWLEDGE.md §5.1). For semantic edits, POST to /api/knowledge/[id] with { action: "fork", overrides: { ruleText } }. The UI already does this via useKnowledge.fork.
Auth is dual-mode; never silent-fallthrough
lib/brain/auth.ts resolves getCurrentUserId() in one of two mutually exclusive modes. If authEnabled() (the three AUTH_GITHUB_* + AUTH_SECRET envs set), the JWT session is the only accepted identity and a missing session throws AuthError("not_signed_in", 401) — we never fall through to the dev shim when OAuth is configured. This is load-bearing: a silent fallthrough would expose the dev user's data to unauthenticated requests. Don't soften this check.
MCP tokens are per-user, hashed, revocation-first
When issuing a token, point the operator at /settings/tokens and let the wizard generate the install command — don't paste raw bearer values into chat or tickets. The wizard interpolates the token client-side and never sends it back over the wire.
/api/tokens POST returns the raw value exactly once; it is never retrievable again. Every brain_* call re-verifies revokedAt IS NULL AND (scheduledRevokeAt IS NULL OR scheduledRevokeAt > NOW()) on the token — revocation is immediate across the fleet with no cache warmup. When adding a new tenant-scoped MCP capability, filter by auth.userId at the query layer, not the handler layer — same rule as the REST endpoints (NAVIGATION.md §12.3).
If you suspect a token may have been seen by someone unintended, use POST /api/tokens/:id/rotate with { graceHours: 0 } — this is the fast-revoke path. The old token is rejected at the next auth check (within seconds) while the new token is immediately usable. Use DELETE /api/tokens/:id only when you know the token was compromised and have the new token already configured — hard revoke is instant and has no grace window. The audit log records token.rotate and token.revoke actions with { oldTokenId, newTokenId, graceHours } / { tokenId } respectively; the raw token value is never logged.
Provider routing via ANTHROPIC_BASE_URL
packages/core/src/oracle.ts uses the Anthropic SDK whenever model.startsWith("claude") OR ANTHROPIC_BASE_URL is set. When routing to an Anthropic-compatible provider (Z.ai GLM, Bedrock, Vertex), pass the provider's own model name (e.g. glm-5.1) in ORACLE_MODEL — don't rename it to look Claude-ish. Add the price row in packages/core/src/cost.ts so cost-cap accounting works.
Embedding routing via EMBEDDING_BASE_URL (provider-agnostic by design)
packages/core/src/embedding.ts uses the OpenAI SDK pointed at whatever EMBEDDING_BASE_URL resolves to; falls back to OpenAI's default URL when unset. Key resolution order: EMBEDDING_API_KEY → GOOGLE_GEMINI_API_KEY → OPENAI_API_KEY → ANTHROPIC_API_KEY. The dimensions request parameter is always sent — choose a provider/model combo where that parameter is honored (OpenAI text-embedding-3-*, DashScope text-embedding-v3 / v4, Google gemini-embedding-001). Providers that ignore it and return their native dimension produce vectors of the wrong length and insert failures on the pgvector column.
Adding a new embedding provider (Gemini is today's recommendation; Qwen3 and others are supported by the same plumbing): (a) add the provider's OpenAI-compatible base URL to the three-option block in .env.example, (b) if the provider uses a distinctly-named key env var (GOOGLE_GEMINI_API_KEY, AZURE_OPENAI_KEY, etc.), extend the getClient() fallback chain in embedding.ts, (c) confirm the provider honors the dimensions parameter, (d) document pricing in packages/core/src/cost.ts if chat models from the same provider are also used. No other code changes needed. If your target provider doesn't expose an embedding endpoint at all (Z.ai GLM is chat-only), document it in .env.example and do NOT silently fall back to OpenAI.
Adding a new MCP client to the install wizard (v1.7.0 added Antigravity + GitHub Copilot this way): (a) add a pure generator to packages/core/src/install-snippets.ts that returns the InstallSnippet shape — do not reuse the brainMcpEntry helper, which emits transport:{type,url} that most clients reject; each client keys its remote server differently (Antigravity serverUrl, VS Code servers+headers, JetBrains family servers+requestInit.headers, Copilot CLI mcpServers). (b) Register it in apps/web/components/brain/token-install-wizard.tsx in the three places that must stay in sync — the ClientId union, the CLIENT_OPTIONS array, and the snippetFns Record (the Record<ClientId,…> is total, so a missing key fails typecheck). (c) Add a unit case in packages/core/src/__tests__/install-snippets.test.ts pinning the distinguishing detail (serverUrl vs url, requestInit.headers vs headers) and that the body parses as JSON. (d) Document it in docs/CLIENTS.md. Optional telemetry: widen SessionClientType (packages/types), the zod .enum and the JSON-schema enum array in start-session.ts, and the dashboard clientLabel switch — this is migration-free because clientType is a String column (schema.prisma), not a Prisma enum, so the change stays inside the autonomous-CD no-migration carve-out.
Dockerfile / deploy patterns (Phase V, 2026-04-22)
- Compose services hidden behind a
profiles:list are not rebuilt by the defaultdocker compose build. If you add aprofile, also teach the caller to pass--profile <name> build— otherwise edits to the base image ship stale into one-shot jobs likebootstrap.scripts/deploy.shdoes this correctly; custom flows must mirror it. - Workspace apps should run from
src/viatsxin the final image, not fromdist/node. Cross-package TypeScript resolution diverges too much between the host's cachedtscand a fresh Dockertscrun to rely on compiled artifacts; lean on the sametsxinvocation used fordev. See theworkerandmcp-serverDockerfile stages for the pattern (WORKDIR /app/apps/<app>+ absolutenode_modules/.pnpm/node_modules/.bin/tsxpath). - Next.js page-data collection imports every route server-side at build time. If a module's top-level runs any side-effectful init (Prisma client, logger sinks, etc.), gate it with a build-time
SKIP_DB_INIT=1or similar and use a throwing Proxy as the stub. Runtime containers never set the flag. - Prisma's client output belongs outside
node_modulesin a pnpm workspace. Setgenerator client { output = "../src/generated/client" }inschema.prisma; import from there. The.pnpmstore's hardlinked files are read-only from the generator's perspective and silently emit stubs otherwise. - Always install
openssl+ca-certificatesin every Debian-slim stage that hosts Prisma. Prisma's engine-detection picks the wrong binary withoutlibssl.so.3present.
BuildKit cache mounts (2026-04-24, measured)
- The
next buildstep gets twoRUN --mount=type=cache,…,sharing=lockedmounts:/repo/apps/web/.next/cache(webpack module graph) and/repo/node_modules/.cache(swc + next-babel intermediates).pnpm installsimilarly mounts/pnpm/store, withPNPM_STORE_DIR=/pnpm/storeexported in the base stage so pnpm actually places its content store there.sharing=lockedprevents races between parallel stage builds hitting the same cache dir. scripts/deploy.shandscripts/dev-up.shexportDOCKER_BUILDKIT=1 COMPOSE_DOCKER_CLI_BUILD=1 BUILDX_NO_DEFAULT_ATTESTATIONS=1immediately before$COMPOSE build. Without the first two, the cache-mount syntax is silently ignored on older engines; without the third, buildx attaches a provenance + SBOM manifest list that adds ~20–30 s per stage and doubles image-unpack time.- Measured warm-warm incremental rebuild of the
webstage = 2 min 29 s (webpack compile 40 s). Warm Docker + cold.next/cache= 3 min 9 s (webpack 112 s). First-ever build on a pruned machine still costs 20–30 min; the mount prevents recurrence, not first occurrence. Don't downgrade the claim — measure before re-tuning.deploy/DEPLOY.md §"Build speed"has the canonical numbers. - If webpack suddenly takes 100+ s on what should be a warm build, suspect a wiped BuildKit builder:
docker buildx inspect defaultand look forStatus: stoppedor a different builder name. - Never add
cache: trueorinline-cacheflags blindly; they interact badly withsharing=locked. Test withdocker compose build --progress=plainand confirm bothRUN --mount=type=cache,...lines appear verbatim in the output. - Webpack is intentional for now (NodeNext + Turbopack
extensionAliasgap). When Turbopack addsextensionAlias, dropbundler: "webpack"fromnext.config.tsand the cache mount on.next/cachebecomes obsolete.
Modal mount pattern
Top-level modals (Tweaks, Teach, Notifications, UserMenu) are mounted in app.tsx with open boolean props and return null when closed. They share the .cmdk-scrim class for the backdrop. Escape closes all modals.
Progressive disclosure on detail panes
Three-column surfaces (Skills, Graph, Oracle) hide the rightmost column on first mount. The grid template flips from "<fixed> 1fr <detail>" to "<fixed> 1fr" when no selection is active, and the detail <section> / <aside> is wrapped in {selected && (...)} so it doesn't render at all. Every detail pane carries: (a) a click-to-open affordance in the list column, (b) an X icon-button close control in the header, (c) a useEffect that binds Escape to setSelectedId(null) while a selection is active. Do NOT restore auto-select-first-item effects — first-visit should be uncluttered.
Graph canvas density
Node labels truncate at 42 chars on the canvas (full label kept in <title> + inspector). Long KEA rules + any e2e-authored test titles otherwise overlap into a wall of unreadable text. Whenever you add a new canvas visualisation, plan for label length BEFORE shipping — a density cap is cheaper than a retrofit.
pg-boss v10+ queue registration
Every queue must be created with boss.createQueue(name) immediately after boss.start() and before any schedule(name, ...) or work(name, ...) call. Pre-v10 pg-boss auto-created queues on first schedule; v10 enforces the FK (pgboss.schedule_name_fkey) and crashes with Queue X not found. createQueue is idempotent — call it for every known queue on every boot. Missing this check will crash-loop your worker silently (only visible in docker logs worker).
Test-created rows must not leak across runs
Any E2E test that inserts into the shared DB (fork, create, teach flows) MUST include a cleanup in afterAll that deletes the authored rows. Without it, every run pollutes the seed — Graph labels wear out first because of density, but it also inflates list counts and retrieval scores silently. The fork-on-edit test's "MODIFIED: This is a test edit from Playwright e2e suite." marker is the canonical pattern — author deliberately-unique strings so cleanup selectors are trivially correct.
Zero-error navigation iteration — the continuous loop
A navigation regression (a surface 500s on mount, a redirect loops, a hash route loses focus) is the class of bug that passes typecheck and still ships broken. The process that prevents this is deliberately small so it actually runs every iteration. Define it as:
- Change lands (code edit, schema migration, route rename, env flip).
- Reload the affected service —
./scripts/reload.sh webfor anything UI-facing,./scripts/dev-up.sh(local) /./scripts/deploy.sh(server) for schema changes. - Nav smoke —
./scripts/nav-smoke.shcurls every shell hash-route, every auth route, every admin route, every API probe. 0 = all 2xx/3xx, 1 = at least one surface 5xx'd. Takes <10s. - Lockdown audit —
./scripts/verify-lockdown.shconfirms no accidental un-gating. Runs automatically at the end ofreload.sh weband both deploy scripts. - If either script fails: fix, go back to step 2. Do NOT commit with a failing smoke or audit — a green CI is not a substitute for a green
./scripts/nav-smoke.shbecause nav-smoke hits the real running stack, not a mocked one. - If both pass: commit. Pre-commit hook re-runs typecheck + unit tests; CI re-runs the full build + E2E (112 specs). Any red step restarts the loop.
Stop-condition for the loop: three consecutive green passes through steps 2–4 on an untouched tree. A single green pass can be luck; three consecutive passes means the change is actually stable.
Adding a new nav surface means editing scripts/nav-smoke.sh in the same PR — the list is intentionally hard-coded so a new surface can't hide from the smoke.
Security zero-error loop before any release
Run docs/SECURITY.md §"Zero-error iteration loop" end-to-end before tagging a release. Eight steps: typecheck, unit tests, auth-guard audit on every API route, unauth lockdown probe, full E2E, security spec specifically, MCP bearer fail-closed, audit-log spot-check. Every failed step re-starts the whole loop — auth regressions cascade. The audit one-liner to catch new routes that forget the guard:
find apps/web/app/api -name route.ts | while read f; do
grep -qE "getCurrentUserId|requireAdmin|handlers|authErrorResponse" "$f" \
|| echo "$f" | grep -qE "(healthz|readyz|auth/\[)" \
|| echo "NEEDS AUTH REVIEW: $f"
doneExpected output: empty. Run before every PR merge that adds a new route.
Auth mode matrix is explicit, never silent
Three modes: OAuth (AUTH_GITHUB_* + AUTH_SECRET), dev shim (ALLOW_DEV_AUTH=true), unconfigured (neither — returns 503). Never silent fall-through between modes. The secure-by-default auth_not_configured response is load-bearing: a freshly-deployed VM that the operator hasn't configured must NOT serve anyone's data. See apps/web/lib/brain/auth.ts::getCurrentUserId for the three-branch dispatch.
Voucher claims are transactional, never lazy
claimVoucher() locks the VoucherCode row with SELECT … FOR UPDATE inside a transaction so two concurrent claims on the last seat of a multi-use code cannot both succeed. Never short-circuit this with a cached check → update flow; the race is real when an org-wide code has a low seat count.
E2E patterns (2026-04-22)
playwright.config.tslives at the app root (apps/web/playwright.config.ts), not nested undere2e/. Playwright resolves config from the runner's CWD; the nested path is never discovered.- Run chromium-only and serial — the suite shares a live Postgres + MCP stack, and parallel workers corrupt fixtures.
workers: 1,fullyParallel: false. - Do NOT enable Playwright's
webServerblock. The caller is expected to have the stack running (pnpm turbo run devor./scripts/deploy.sh). This keeps tests out of the build graph. - Dodge the onboarding modal globally via
playwright.config.tsuse.storageStatewith{ origin, localStorage: [{ name: "bp_onboarded", value: "true" }] }. Theonboarding.spec.tsoverrides withtest.use({ storageState: undefined })for its own cases. This is cheaper than each spec re-runningaddInitScript. - Register
page.waitForResponse(...)BEFOREpage.goto(...)whenever a test depends on a hook's fetched state. The response can land before Playwright's listener attaches if registered after navigation — hooks then appear to be in "loading" or "mock" mode when the test body runs. Pattern:const resp = page.waitForResponse(...).catch(() => null); await page.goto(...); await resp;. - Skip-on-infra, not retry-on-infra. When a test fails because rate-limit kicked in (hook fell back to BRAIN_DATA mock IDs that don't exist in DB) or seed was consumed by a prior test, call
test.skip(true, "<specific reason>")rather than weakening the assertion. TheskipIfMockMode(page)helper inautoskill.spec.tsis the canonical example — it reads the "API unreachable" banner text and bails. Skips are green-with-reason; fake retries hide the signal. - Serial suites consume seed. Reject/apply tests mutate rows; after enough runs, the seed is exhausted. Every test that depends on a seed row must handle
count === 0explicitly withtest.skip— do not let the test cascade-fail and dragdescribe.configure({ mode: "serial" })siblings down with it. - Retry-until-hydrated for keyboard tests. Global
keydownlisteners attach insideuseEffect, which commits after the first paint. The first keypress in a fresh page can race hydration. Wrap the first press inexpect(async () => { await press; await expectURL; }).toPass({ timeout: 5_000 }). - Prefer
role=+ i18n-agnostic selectors where practical, but accept that some panels (e.g. Tweaks after a language switch) exposearia-labelstrings that change. Positional selectors (.tweak-seg button.first()) are acceptable when documented. Avoid regex selectors that match shell chrome (/apply/imatched the "Auto-apply HIGH" toggle — scope to.panel buttonfor proposal-level buttons). - Next 16
proxy.tsCANNOT declare a runtime —export const config = { runtime: "nodejs" }fails the build with "Route segment config is not allowed in Proxy file". Keep only thematcherkey. - In-memory rate-limit saturates under burst E2E load. 16 specs × ~5 API calls each in a 5-minute window can exceed
RATE_LIMIT_MCP_PER_MINUTE=200. The proper fix is a shared Redis store (Wave 2 ships one; setREDIS_URLin the E2E deploy). The test-side fix isskipIfMockMode()— see above.
The REBUILD/ folder at the repo root is the canonical resource for anyone
porting External Brain to a new machine using AI-assisted (vibe) coding.
REBUILD/
00-START-HERE.md — master index, rules, phase map
01-foundation.md — monorepo + @brain/types + @brain/db (full schema + seed)
02-core-intelligence.md — @brain/core (KRA · KEA · Oracle · decay · snippets)
03-mcp-server.md — apps/mcp-server (Bearer auth · 12 tools · 4 resources)
04-worker.md — apps/worker (pg-boss · 9 jobs · embeddings backfill)
05-web-app.md — apps/web (NextAuth · dashboard · Oracle · Skills · Admin)
06-deploy-ci.md — Docker Compose · Dockerfile · scripts · CI workflows
07-env-catalog.md — complete .env reference
08-acceptance-criteria.md — 9-point definition of done + sign-off checklist
Each file:
- Opens with a copy-paste agent prompt block — hand it verbatim to the AI
- Contains complete specs for that phase (data models, algorithms, invariants)
- Closes with a runnable checkpoint the builder must pass before the next phase
When to update REBUILD/: any change to the schema, the MCP tool surface, the
auth model, the install-snippet shapes, or the deployment topology should be mirrored
in the relevant REBUILD phase file so the rebuild guide stays current with the
running system.
Two mandatory unit tests that REBUILD/02 pins (never skip these):
// antigravity: serverUrl (not url)
// githubCopilotJetbrains: requestInit.headers (not headers)Both are silent-failure traps — wrong key, zero error, no connection. See
REBUILD/02-core-intelligence.md §2.16 and docs/KNOWN_ISSUES.md §0h.
docs/BLUEPRINT.md— the product.docs/APPROACH.md— philosophy and decision framework.docs/KNOWLEDGE.md— ontology and lifecycle (normative).docs/KNOWN_ISSUES.md— what to watch out for.docs/NAVIGATION.md— frontend navigation surfaces and zero-error checklist.REBUILD/00-START-HERE.md— vibe-coding rebuild guide.research/knowledge/README.md— 7,900 lines of prior analysis.Architecture diagram— visual: 3-layer system architecture for new contributors.AI Application diagram— visual: where LLMs and embeddings are used.Process Logic diagram— visual: end-to-end session lifecycle.Skill Development diagram— visual: how the platform develops and evaluates skills.Knowledge Algorithm diagram— visual: algorithm governing knowledge lifecycle and refinement.