Where code lives, why it is layered the way it is, and where to put something new.
Read architecture.md first for the processes; this page is about the source
tree inside them.
Every feature is the same four files plus a router. Data flows down, and each layer knows only about the one below it.
flowchart TD
R["<b>router</b> — app/api/routes/<br/><i>HTTP: status codes, dependencies</i>"]
S["<b>service</b> — app/services/<br/><i>business rules, authorization, transactions</i>"]
P["<b>repository</b> — app/repositories/<br/><i>queries. The only place SQL is written</i>"]
M["<b>model</b> — app/models/<br/><i>SQLAlchemy tables</i>"]
SC["<b>schema</b> — app/schemas/<br/><i>request/response shapes on the wire</i>"]
R --> S --> P --> M
R -.uses.-> SC
S -.returns.-> SC
@router.get("", response_model=PaginatedResponse[ProjectResponse], ...)
async def list_projects(
current_user: CurrentUser,
service: ProjectServiceDep,
query: Annotated[ListQuery, Query()],
) -> PaginatedResponse[ProjectResponse]:
return await service.list(query, actor=current_user)One service call per route. No business logic, no if user.is_admin, no query building. The
one exception in the whole codebase is ask_question, which must both run a pre-flight check
and return a streaming response — and even there, all the policy is in the service.
Authorization lives in the service, not the route, so reindex and delete cannot drift
apart in what they permit. Where a write needs more than membership, the service names the
permission via access.require_permission — reindex, delete, module create/edit/delete,
generation, change-set apply/discard, checklist item edit, result recording, and mock-data
record edit each name one. A plain list or read has no call beyond the membership scope
resolve_project_scope already applies. The only router-level gate is require_admin, and only
on roles.py where every route is admin-only.
Python attributes and Postgres columns are snake_case. Every JSON body is camelCase. The
translation happens in exactly one class — ApiModel in app/schemas/base.py, which applies
Pydantic's to_camel alias generator. Every request and response schema inherits it.
A schema on plain BaseModel silently ships snake_case keys. That is a defect, and
tests/test_api_model.py exists to catch it. It matters most for the SSE event payloads,
which never pass through a response_model — so FastAPI validates nothing about them, and the
SSE_EVENT_MODELS tuple that test walks is the only thing holding them to the rule.
Every error is {"detail": {"code": ..., "message": ...}}, built by AppError. ErrorCode
values are a wire contract — add members, never rename them. A 422 adds a fields map
keyed by the camelCase field name.
404when the caller should not learn it exists — another user's conversation, or a project the caller holds no membership on. Project existence is not public: once projects are not shared, "you may not see this" and "this does not exist" are the same answer, and a403would confirm a private repository exists to anyone who can guess an id.403when the caller may see the resource but not do this to it — a member whose role is too low to delete the project. They can already see it in their own list, so a404would contradict what the UI just rendered.
Backwards, this either leaks existence or hides something the user can already see in a list.
This is the single most consequential constraint in the repo, and it is two functions in
app/core/access.py:
def resolve_project_scope(user: AuthenticatedUser) -> ProjectScope:
"""Which projects this caller may read."""
if user.is_admin:
return ProjectScope.all()
return ProjectScope.of(grant.project_id for grant in user.grants.values())
def require_permission(user, project_id, permission) -> None:
"""What this caller may do to one of them. Raises 404 or 403."""All read scoping goes through resolve_project_scope, and every gate through
require_permission. A route, service or query that filters projects on its own is a defect
even when its output is currently correct, because it puts scoping in two places —
tests/test_scoping_is_single_point.py enforces that as a grep.
That discipline is what made per-project RBAC a change to one function's body rather than a
rewrite: phase 2.1 replaced the return ProjectScope.all() that shipped in phase 1, added
require_permission beside it, and touched none of the 15 call sites it had at the time
(there are 14 today).
Two helpers sit beside them in the same file, so that no service carries its own copy of a
check that is really about access. require_readable_project loads a project, or raises 404
when the caller may not see it. require_answerable refuses a project whose index is not ready,
or was built by another embedding model. Five services call the first and three call the
second.
ProjectScope is an explicit dataclass rather than list[UUID] | None, because a None
sentinel meaning "unrestricted" is fail-open — a bug that forgets to set it hands out
everything. An empty ids means no projects, never all of them. narrowed_to is how an
orthogonal filter such as ?ownerless=true applies on top of the resolver's answer instead
of replacing it, so the filter never becomes a second enforcement point.
The grants themselves are loaded once per request by AuthContextMiddleware, which is what
keeps both functions synchronous — see architecture.md.
Its counterpart resolve_conversation_owner sits in the same file so the contrast is visible
rather than folklore: projects are reached through membership, conversations are private to one
user and have no admin bypass at all. The permission catalogue in app/core/permissions.py
deliberately contains no conversation.* member, which is what makes the admin bypass in
require_permission safe — there is nothing there to bypass into, and tests/test_permissions.py
fails if one is ever added.
created_by is not ownership. It is attribution. The RBAC migration read it once to
backfill an owner membership per project; after that, what a caller may do comes from their
membership's role, never from that column, and it never scopes reads.
backend/app/
├── main.py FastAPI app + lifespan (topics, probes, queue, the live hub)
├── worker.py the separate process: 4 consumers + retry ladders + reconcile sweep
├── config.py Settings — the only place os.environ is read
├── cli.py seed-admins and restore-system-roles; seed-admins runs from
│ the container entrypoint
│
├── api/
│ ├── deps.py shared dependencies (CurrentUser, AdminUser, service factories)
│ └── routes/ 21 routers, 96 routes
│
├── core/ cross-cutting: access, audit, crypto, errors, feedback, grant_cache,
│ logging, middleware, notifications, passwords, permissions, rate_limit,
│ repo_url, role_seed, security
├── db/session.py engine + sessionmaker
├── models/ 12 modules, 26 tables
├── repositories/ 24 repositories — the only place SQL is written
├── schemas/ request/response shapes, all on ApiModel
├── services/ 22 services — business rules and authorization,
│ plus path_tree.py (pure tree shaping, no I/O) and
│ index_guards.py (the shared "is the index stable" refusals)
│
├── ingestion/ cloner, walker, chunker, embedder/, vector_store, pipeline, errors
├── observability/ the AI call log: features, record, recorder, trace_ids, langfuse_sink
├── queue/ topics, producer, consumer, retry, protocol, checklist, mock_data, eval
├── rag/ retriever, chat, prompts, prompt_version, answer_style, answerer,
│ │ grounding, capability, errors
│ └── graph/ build.py, nodes.py, state.py
├── checklist/ generator, model_output, operations, source
├── mockdata/ generator, model_output, operations
├── eval/ generator, runner, sampling, model_output, notifications — the synthetic Q&A harness
├── live/ kinds, events, staging, fanout, bus, kafka, stream — the live-update
│ stream behind `GET /events` (issue #48)
└── mail/ compose, sender, outbox, recording, templates/ — outbound email
| Module | Holds |
|---|---|
kinds.py |
LiveKind — what a live event can be about. Imports nothing from app |
events.py |
LiveEvent and its constructors — ids only, never content |
staging.py |
stage_live_event, and the after_commit/after_transaction_end hooks that publish or discard it |
fanout.py |
LiveEventFanout — one bounded queue per open stream in this process, plus resync_all/close_all |
bus.py |
LiveEventHub, and InMemoryLiveEventBus — the test double for both the publisher and the hub |
kafka.py |
KafkaLivePublisher and KafkaLiveEventHub — the group-less consumer, and DisabledLiveEventHub |
stream.py |
live_event_stream — the body of GET /events: ready, per-event and per-heartbeat re-checks, the heartbeat itself |
app/api/routes/events.py mounts it as GET /events. See .claude/rules/live-events.md for the
full file list this feature touches outside app/live/, including live_event_visible_to in
app/core/access.py.
| Module | Holds |
|---|---|
features.py |
CallFeature, the four metadata keys, and call_config / scope_config — scope as inherited metadata |
record.py |
CallStart, CallRecord (no field can hold content) and the two-method CallSink protocol |
recorder.py |
CallRecorder — the LangChain callback that turns the call lifecycle into start and end records |
trace_ids.py |
trace_id_for and trace_url — a trace id derived from the subject's own id, never stored |
langfuse_sink.py |
LangfuseSink, CallLog and build_call_log — the only module that imports langfuse, and only when enabled |
See .claude/rules/call-log.md. Langfuse itself is five containers behind an opt-in Compose
profile; the development stack is unchanged without LANGFUSE=1.
Per-project access is spread across the same layers as any other feature, plus three modules in
core/ that have no equivalent elsewhere:
| Module | Holds |
|---|---|
core/permissions.py |
Permission — the source of truth for which permissions exist — the groups the role-matrix UI renders, and the viewer/editor/owner permission sets |
core/role_seed.py |
ensure_system_roles, reconciling the three system roles to those sets. One implementation, called by the migration, the test harness, and cli.py restore-system-roles |
core/grant_cache.py |
The Redis read-through cache for a user's grants, with epoch and per-user invalidation |
models/membership.py |
Role, RolePermission, ProjectMembership |
repositories/role.py, repositories/membership.py |
Their queries, including load_grants (the middleware's hot path), ownerless_project_ids and projects_solely_owned_by |
services/role.py, services/membership.py |
Role CRUD with system-role immutability; granting, changing and revoking membership |
api/routes/roles.py, api/routes/members.py |
/roles + /permissions (admin-only, gated at the router) and /projects/{id}/members |
Every service write that changes who may reach what evicts the grant cache after the commit — a role-definition change bumps the epoch, a membership change deletes that user's key.
config.py— what is configurablemodels/project.py— the richest table, and the lease/generation columnsapi/routes/projects.py→services/project.py→repositories/project.py— one feature through all four layerscore/access.py+core/permissions.py— who may see what, and what they may doingestion/pipeline.py— the write path end to endrag/graph/build.py— the answer graph in one screen
Next.js App Router, and not a thin client.
frontend/
├── app/
│ ├── (auth)/ login, change-password
│ ├── (app)/ dashboard, projects, ask, checklist,
│ │ settings (users, roles)
│ └── api/
│ ├── [...path]/ the one route the browser talks to — proxies everything
│ └── auth/ login, logout, refresh — the only handlers that write cookies
├── components/
│ ├── ui/ shadcn on the Base UI base (30 components)
│ ├── ask/ checklist/ mock-data/ projects/ roles/ users/ feature components
│ ├── form/ feedback/ output-feedback/ layout/ shared shells
├── hooks/ React Query hooks
├── lib/ api client, query keys, SSE parser, auth/session
└── proxy.ts route protection + refresh on navigation
Each screen is a thin page.tsx (a Server Component) plus a *-screen.tsx client component.
No token is ever readable by a script on the page.
- Two cookies, both httpOnly, both
Path=/:askrepo_access(the JWT) andaskrepo_session(the backend's refresh cookie, stored verbatim).Path=/differs from the backend's/authscope on purpose —proxy.tsruns at/projectsand is only sent cookies whose path matches. app/api/[...path]/route.tsis the only route the browser calls. It attaches the bearer, stripsset-cookiefrom every backend response, and relays the body untouched.- Refresh happens in two places, and that split is structural. A Server Component cannot set
a cookie, so a token refreshed during render could never be persisted. Navigations refresh in
proxy.ts; browser fetches and the answer stream refresh inside the API proxy, on the401status line, before any body is read — which is what keeps it safe on the SSE route. API_URLis server-only. There are noNEXT_PUBLIC_*variables; re-adding the prefix would inline the value into the client bundle.
Tailwind CSS 4, CSS-first @theme — there is no tailwind.config.js for tokens.
- Never write a
dark:colour utility. The token already knows what dark means.bg-card, notbg-white dark:bg-slate-900. - Never use a palette utility.
bg-zinc-50names a colour, not a role, so it does not follow the theme. Usebg-card,text-muted-foreground,border-border. frontend/app/globals.cssis the only file allowed to contain a raw hex colour.- Composition uses
render={<Component />}, neverasChild—asChilddoes not exist on the Base UI base and fails silently.
Full reference: design.md.
| I want to… | Do this |
|---|---|
| Add a route to an existing resource | Schema in app/schemas/, method on the service, thin route. Update backend/README.md's route table |
| Add a whole new resource | Run /scaffold-route — it generates schema, model, migration, repository, service and router to the repo's conventions |
| Add a setting | app/config.py, backend/.env.example, and docs/configuration.md — all three in the same change |
| Change a table | Model, then uv run alembic revision --autogenerate, then read what it generated. See data.md |
| Change how retrieval works | app/rag/retriever.py. Both filters are mandatory — see rag.md |
| Change a prompt | app/rag/prompts.py, then run uv run pytest -m model. No ordinary test can catch a bad prompt |
| Add a graph node | app/rag/graph/nodes.py + wire it in build.py. It must degrade, never block. Test it with the run_node harness |
| Add a model call | config=call_config(CallFeature.X) on the call, and a CallFeature value if it is a new kind. tests/test_call_sites_tagged.py fails otherwise — see llm.md "The call log" |
| Add a background job | A topic trio in app/queue/topics.py, a handler, a consumer in worker.py, and a lease on the row |
| Add a screen | page.tsx + *-screen.tsx, a hook in hooks/, nav entry per design.md |
| Add a UI component | npx shadcn@latest add <name> into components/ui/. Do not hand-write one that shadcn ships |
backend/pyproject.toml selects ANN (type-hint coverage), T20 (bans print()), LOG/G
(logging correctness), RUF, plus E/F/I/UP/B. # noqa and # type: ignore need a
reason on the same line.
If you add a convention, wire it into the config in the same change — a rule lint can enforce should be enforced there.
Run everything CI runs with make check: ruff, prettier, mypy, pytest, vitest.
.claude/rules/ holds 19 rule files that encode conventions this page only summarises —
router.md, persistence.md, response-api.md, rag.md, ingestion.md, notifications.md,
design-system.md, forms.md, navigation.md, frontend-bff.md, feedback.md, call-log.md, and others.
They are written for coding agents
but are the most precise statement of each convention, and worth reading before a change in the
area they cover.
Two apply to everything: contradiction-halt.md (if a request contradicts the PRD or a rule,
report it rather than working around it) and documentation.md (if your change makes a doc
wrong, fixing it is part of the same change).
architecture.md— the processes and their lifecyclesdata.md— tables, leases, soft deleterag.md·llm.md·langgraph.md— the AI pathconfiguration.md— every setting../backend/README.md— the exhaustive route table../frontend/README.md— frontend scripts and env