# CLAUDE.md Maple is an OpenTelemetry observability platform: a Foldkit (Vite) dashboard + Effect on the backend, ClickHouse/Tinybird as the warehouse, PlanetScale Postgres for relational state. ## Workspace layout - `apps/*`: deployables (web, api, ai, ingest, alerting, cli, landing, sandbox, ...). - `packages/*`: shared code that knows Maple (`domain`, `query-engine`, `backend`, `ui`, `db`, ...). `views` is the Foldkit kit, charts and service map that `apps/web` and `apps/local-ui` share. - `lib/*`: zero Maple knowledge, could ship as a standalone OSS library. A `lib/` package that imports `@maple/domain` must move to `packages/`. New packages default to `packages/`. `@maple-dev/effect-orm` lives in the separate effect-orm repo; change it there. ## Commands ```bash bun dev # full alchemy dev stack at https://[.].localhost bun dev api web # subset bun --filter=@maple/web dev # single app, raw port bun run test # Vitest via turbo (NOT `bun test`) bun typecheck bun run tinybird:manifest # after editing datasources.ts bun run local-schema:bump # local chDB schema bump for a datasources.ts change bun db:up && bun db:migrate:local bun run --cwd packages/db db:generate # new migration (effect-orm) bun run dev:signin # one-shot signed-in Clerk link (dev user david+clerk_test@gmail.com) ``` Dev password fallback: `Maple-Dev-Kx92qZ!`. Toolchain is pinned in `mise.toml`; keep `bun` in sync with `packageManager`. ## Backend architecture - `apps/ai` runs the public MCP server and the shared agent runtime (the `ChatSession` Durable Object) behind investigations and their follow-ups, PR review and the Slack/Discord bot. `apps/api` forwards `/mcp`, `/api/chat/*`, `/internal/chat/*` to it over a service binding; OAuth stays in `apps/api`. - An investigation is **one agent turn** ending in `submit_diagnosis`. Do not split it across planner/lanes/validator. Sub-agents are fine elsewhere (e.g. review fan-out) via `Subagent` from `@yielded/agent/subagent`. - LLM code (Effect AI + `@yielded/agent`) is only in `apps/ai`; Maple-specific wiring lives in `apps/ai/src/platform/Llm.ts`. - Shared services live in `packages/backend`, imported via `@maple/backend/*` subpaths. ### Service conventions - Services acquire their dependencies in the constructor; public methods must not leak implementation services in their Effect requirements. Request-scoped context (transactions, tenant, actor) stays on the invocation, never captured at construction. - Each service owns `static readonly layer = Layer.effect(this, this.make)` with internal deps via `Layer.provide`. `Env`, `Database`, caches, and bindings come from the app root. ## Warehouse queries No Tinybird pipes/endpoints. All queries use the ClickHouse DSL in `@maple/query-engine` and run through `WarehouseQueryService.compiledQuery()`. Never `fetch()` `/v0/sql`. Add a query in `packages/query-engine/src/ch/queries/*.ts`, export from `src/ch/index.ts`: ```typescript const rows = yield* warehouse.compiledQuery(tenant, CH.compile(CH.myQuery({ limit: 50 }), { orgId, startTime, endTime }), { profile: "list", context: "myQuery", }) ``` - Every query **must** filter `OrgId` (`$.OrgId.eq(param.string("orgId"))`); enforced at runtime. - Pass the unrun `CH.compile` Effect; never `Effect.orDie` it at a call site. - Identity UInt64s (hashes/ids) must be `toString()`-wrapped in the SELECT. `rowSchema`s use `CH.CHNumber`, never `Schema.Number`. - Request fields that reach `param.*` or column comparisons are validated at the HTTP boundary (`TinybirdDateTime`, `BucketSeconds`, `WarehouseDateTime`) so bad input is a 400, not a 500. - The query-engine root barrel stays driver-free (web/cli import it). - Pipe rows are typed by the query that fills them (`PipeRow`, `ch/pipe-rows.ts`); no hand-written row interfaces in domain. - Read `docs/warehouse-rollups.md` before adding a materialized view. Query perf work uses `bun run bench:queries` (see `docs/query-benchmarking.md`); empty-table timings prove nothing. ## Application database (Postgres / effect-orm) Tables in `packages/db/src/tables/` (`@maple/db/tables`), reached via Hyperdrive binding `MAPLE_DB`. Queries use `@maple-dev/effect-orm`: `database.execute((db) => db.run(PG.from(T)...))`, `db.transaction(effect)`. Rows are typed by the table: branded ids, literal unions, schema-typed jsonb, timestamps as epoch ms (`PG.timestamptzMillis`), so compare them with `=== null`, never by truthiness. - No untyped SQL. A query the builder lacks is a `PG.sql` template with a typed result. - Readers that must tolerate old jsonb shapes select the column with `PG.undecoded`. - Change a table, then `bun run --cwd packages/db db:generate --name `: it diffs the tables against the newest snapshot and writes `packages/db/drizzle/_/migration.sql`. `parity.test.ts` fails when the tables and the migrations disagree. - One pool per invocation via `withPgConnectionScope`; connections never outlive the invocation. Fork DB work off a request only with `forkRequestScoped`. - Tests use `createTestDb()` (PGlite, `packages/backend/src/platform/test-pglite.ts`). - **Migrations apply in the prd alchemy deploy.** `db:migrate` is for local databases; never point it at prd. - PR previews (only with the `preview` label) get their own Neon branch, migrated by the deploy (`docs/pr-previews.md`). ## Conventions - **Schemas:** Effect Schema, not Zod. `Schema.optionalKey()` for JSON HTTP/domain schemas, `Schema.optional()` only where `undefined` is a real value (search params, MCP tool params). - **Errors:** expected failures use `Schema.TaggedError` with a namespaced tag, `message`, and schema-backed context (`Schema.Defect()` for unknown causes). Keep them in the typed channel; remap with `catchTag`/`catchTags`. No plain `Error`, throws, or `Data.TaggedError`. Branded IDs in error context; undecodable input goes in a `raw*` string, never cast to a brand. - **Types:** `Record` is a lint error (repo is at zero). Use `Record`. - **Library source:** read `node_modules/effect/src/`, `node_modules/@effect/*/src/`, and `node_modules/alchemy/src/` for APIs at the installed version. - **Span status codes:** Title case: `"Ok"`, `"Error"`, `"Unset"`. Only 5xx is `Error` for server spans. Custom attributes use `maple.*`. - **Self-tracing:** the API traces itself through ingest; keep `withTracerDisabledWhen()` and avoid spans on hot paths like token validation. - **UI:** the dashboard (`apps/web`) and Maple Local (`apps/local-ui`) are Foldkit + `@foldkit/ui` + Tailwind 4, Nucleo icons. Dashboard pages are built from the kit only; read `apps/web/CLAUDE.md` before UI work (find the component in `packages/views/src/ui/INDEX.md`, build missing ones in the kit). Add icons to `packages/views/src/ui/icons.generated.ts` (element trees) from the local Nucleo DB: ```bash sqlite3 "~/Library/Application Support/Nucleo/icons/data.sqlite3" \ "SELECT id, name, set_id FROM icons WHERE klass='outline' AND grid=24 AND name LIKE '%search%';" ``` - `@/` resolves to each app's source. - **Tests:** assert behavior, not source. `test-slop/*` rules (`lib/oxlint-config/plugins/test-slop.mjs`) ban constant-vs-literal pins, existence-only/does-not-throw tests, Tailwind class pins and tests that read source files. Import and architecture boundaries are `no-restricted-imports` overrides in `.oxlintrc.json`, not tests. ## MCP tools Every tool is `server.define({ parameters, output, hints, handler, render })` (`apps/ai/src/mcp/tools/types.ts`); the registry owns decode, aliases, errors, output encoding and rendering. `registry.contract.test.ts` enforces it across the catalog. - Parameters come from `apps/ai/src/mcp/lib/params.ts` (`P.timeWindow`, `P.service`, `P.limit`, `P.oneOf`...): one spelling per concept, retired names only as `aliases`. - Output is an Effect Schema in `packages/domain/src/mcp-outputs/` (`outputSchema` / `structuredContent`, which `apps/cli` decodes). The model reads `render(output)`, a `ToolDoc` whose `next` calls the registry validates. - Failures are typed (`McpInvalidInputError`, `McpNotReadyError`, `McpUnavailableError`, `McpQueryBudgetError`, `McpQueryError`); no `isError` literals in tools. ## Repository sandbox Internal-only agent tools (`sandbox_*`, `apps/ai/src/mcp/tools/sandbox.ts`) run commands against a git clone in Cloudflare's Sandbox container (`apps/sandbox`, prd only). Invariants: - Refs are validated (`unsafeRef`) and encoded before the GitHub call. - The clone credential never appears in command arguments. - Commands run unprivileged, read-only, no egress; refuse to run if the network namespace fails. Test with `bun run --cwd apps/sandbox verify:image` (needs Docker); it catches what unit tests can't. ## PR previews A PR labelled `preview` deploys the full stack to `app-pr-.maple.dev` / `api-pr-.maple.dev` on the Clerk development instance (`docs/pr-previews.md`). Default to `preview:web` for dashboard, API or AI changes: it deploys only the Workers on the same seeded data, faster and without a VPC. Use `preview` only to test ingest, Electric live sync, landing, or a connect flow that sends telemetry. To sign in to either: ```bash infisical run --env dev -- bun run preview:signin # one-shot signed-in link, valid 2 minutes ``` It signs in as the shared reviewer `preview-reviewer+clerk_test@maple.dev` in the `Preview` org, which the deploy's smoke test seeds with demo telemetry. Humans can instead use the "PR preview" form on the sign-in page with `PREVIEW_SIGNIN_KEY` (Infisical `dev`). - Never type the key into a form or print it: mint the link with the command above and open it. - `{"enabled":false}` from `GET /api/auth/preview/sign-in` means the deploy ran without the key; re-run the preview deploy. - A preview hostname that will not resolve locally right after deploy is a cached NXDOMAIN; it clears on its own (or use `curl --resolve` with `dig @1.1.1.1`). ## Docs Read the relevant doc before touching an area: `error-issue-lifecycle.md` (anything in `packages/backend/src/services/errors/`), `warehouse-rollups.md`, `service-map-architecture.md`, `ingest-wal-durability.md`, `secret-encryption.md` (any `*_ciphertext` column), `local-mode.md`, `infra.md`, `api-v2.md`, `otel-spec/`, `dashboard-grid.md` (the dashboard canvas and `lib/grid-engine`), `design-rules.md` (tokens and the `maple/*` design lint rules for `apps/web` and `packages/ui`).