From 7a4101185bbfd4c2d100cb3de52ae2805fd3b7cd Mon Sep 17 00:00:00 2001 From: Bryan Frimin Date: Sun, 15 Mar 2026 14:05:23 +0100 Subject: [PATCH] Add per-folder CLAUDE.md for key packages Signed-off-by: Bryan Frimin --- apps/console/CLAUDE.md | 57 ++++++++++ apps/trust/CLAUDE.md | 15 +++ e2e/CLAUDE.md | 48 ++++++++ pkg/agent/CLAUDE.md | 48 ++++++++ pkg/coredata/CLAUDE.md | 164 ++++++++++++++++++++++++++++ pkg/server/api/console/v1/CLAUDE.md | 47 ++++++++ pkg/server/api/mcp/v1/CLAUDE.md | 80 ++++++++++++++ 7 files changed, 459 insertions(+) create mode 100644 apps/console/CLAUDE.md create mode 100644 apps/trust/CLAUDE.md create mode 100644 e2e/CLAUDE.md create mode 100644 pkg/agent/CLAUDE.md create mode 100644 pkg/coredata/CLAUDE.md create mode 100644 pkg/server/api/console/v1/CLAUDE.md create mode 100644 pkg/server/api/mcp/v1/CLAUDE.md diff --git a/apps/console/CLAUDE.md b/apps/console/CLAUDE.md new file mode 100644 index 000000000..dd3926bc7 --- /dev/null +++ b/apps/console/CLAUDE.md @@ -0,0 +1,57 @@ +# apps/console + +React 19 + Vite + TypeScript + Relay + TailwindCSS. Port 5173. + +## Commands + +| Command | Purpose | +|---------|---------| +| `npm run dev` | Start dev server (port 5173) | +| `npm run build` | Production build | +| `npx relay-compiler` | Regenerate Relay artifacts | + +## Routes + +Defined in `src/routes.tsx` with feature-specific route files (e.g. `src/routes/assetRoutes.ts`). + +- Lazy-loaded via `lazy()` from `@probo/react-lazy` +- Data loading: `loaderFromQueryLoader()` + `loadQuery()` (Relay) +- Type: all routes `satisfies AppRoute[]` +- Fallback: `PageSkeleton` or `Fallback` components during loading +- Error boundaries per route group + +## Relay + +Queries, fragments, and mutations are **colocated** in the component that uses them — never in separate `hooks/graph/` files. + +Always use **fragments** to define data requirements. Never create custom TypeScript types for API data — let Relay generate types from fragments. + +### Mutations + +Use `@appendEdge` / `@deleteEdge` directives for Relay store updates: + +```typescript +const createAssetMutation = graphql` + mutation AssetCreateMutation($input: CreateAssetInput!, $connections: [ID!]!) { + createAsset(input: $input) { + assetEdge @appendEdge(connections: $connections) { + node { id name } + } + } + } +`; +``` + +## Permissions + +Inline permission queries in Relay fragments: + +```graphql +canCreate: permission(action: "core:asset:create") +``` + +## Components + +- Form fields: `src/components/form/` +- Dialogs: modal components with mutation handling +- Shared UI: `@probo/ui` package diff --git a/apps/trust/CLAUDE.md b/apps/trust/CLAUDE.md new file mode 100644 index 000000000..8d036dbad --- /dev/null +++ b/apps/trust/CLAUDE.md @@ -0,0 +1,15 @@ +# apps/trust + +React 19 + Vite + TypeScript + Relay + TailwindCSS. Port 5174. + +Same frontend stack as `apps/console/` — see its CLAUDE.md for Relay query patterns, mutation hooks, and component conventions. + +## Trust-specific differences + +- Public-facing trust center app (not an internal dashboard) +- Path-prefix routing: `/trust/{slug}` for Probo-hosted, `/` for custom domains +- Routes: `/overview`, `/documents`, `/subprocessors`, `/updates` +- Auth flow: `/connect`, `/verify-magic-link`, `/full-name` +- Content routes (`/overview`, `/documents`, `/subprocessors`, `/updates`) wrapped in `MainLayout` +- Auth routes (`/connect`, `/verify-magic-link`, `/full-name`) wrapped in `AuthLayout` +- All route groups use `RootErrorBoundary` diff --git a/e2e/CLAUDE.md b/e2e/CLAUDE.md new file mode 100644 index 000000000..8b04d6bef --- /dev/null +++ b/e2e/CLAUDE.md @@ -0,0 +1,48 @@ +# e2e + +End-to-end tests against a running `bin/probod` instance. + +## Prerequisites + +Build the binary first: `make build` (or `SKIP_APPS=1 make build` for backend-only). + +## Running + +``` +make test-e2e +``` + +## Test setup + +`testutil.Setup()` starts `bin/probod` as a subprocess (once per test run) and waits for the GraphQL endpoint to be healthy. + +## Client + +```go +c := testutil.NewClient(t, testutil.RoleOwner) +``` + +The client carries organization/tenant context and provides: +- `c.Execute(query, variables, &result)` — GraphQL queries +- `c.ExecuteConnect(query, variables, &result)` — Connect API queries +- `c.GetOrganizationID()` — current org + +## Factory pattern + +Test data created via `factory.Create*` functions: + +```go +vendorID := factory.CreateVendor(c, factory.Attrs{"name": "Acme"}) +userID := factory.CreateUser(c) +``` + +- `factory.SafeName(prefix)` — random unique names +- `factory.SafeEmail()` — random unique emails +- `factory.Attrs` map for overriding defaults + +## Test structure + +- Always `t.Parallel()` at the test function level +- Inline GraphQL queries as string constants +- Typed result structs per query +- `require.NoError` for mutation/query errors, `assert.Equal` for value checks diff --git a/pkg/agent/CLAUDE.md b/pkg/agent/CLAUDE.md new file mode 100644 index 000000000..94f7cc979 --- /dev/null +++ b/pkg/agent/CLAUDE.md @@ -0,0 +1,48 @@ +# pkg/agent + +LLM agent orchestration framework. + +## Agent construction + +```go +agent := agent.NewAgent( + "agent-name", + "System instructions here", + agent.WithTools(tool1, tool2), + agent.WithHandoffs(otherAgent), + agent.WithModel(model), +) +``` + +Functional options: `WithTools`, `WithHandoffs`, `WithInstructions`, `WithModel`, `WithModelSettings`, `WithMCPServers`, `WithInputGuardrails`, `WithOutputGuardrails`, `WithApproval`, `WithSession`. + +## Execution + +```go +result, err := agent.Run(ctx, messages) +result.FinalMessage().Text() // final output +result.LastAgent // agent that produced the result +``` + +Typed output via `RunTyped[T](ctx, agent, messages)` — validates against JSON Schema. + +## Tool interface + +```go +type Tool interface { + Name() string + Description() string + Parameters() jsonschema.Schema + Execute(ctx context.Context, input json.RawMessage) (string, error) +} +``` + +## Agent-as-tool + +`agent.AsTool(name, description)` wraps an agent as a tool for composition. + +## Limits + +- Max turns: 10 (default) +- Max tool depth: 16 (default) +- Depth tracking prevents infinite recursion in handoffs diff --git a/pkg/coredata/CLAUDE.md b/pkg/coredata/CLAUDE.md new file mode 100644 index 000000000..48a85dde6 --- /dev/null +++ b/pkg/coredata/CLAUDE.md @@ -0,0 +1,164 @@ +# pkg/coredata + +All raw SQL lives here — never in service or handler packages. + +## Entity files + +One file per entity (`asset.go`, `vendor.go`, etc.), plus optional companion `_filter.go` and `_order_field.go` files when needed. No codegen — everything is hand-written. + +Entity structs do **not** have a `TenantID` field — the `tenant_id` column is provided by the `Scoper` (via `scope.GetTenantID()`) at query time, not stored on the Go struct. + +## SQL query pattern + +Every query uses raw SQL with `pgx.StrictNamedArgs` and scope injection via `fmt.Sprintf`: + +```go +q := ` +SELECT id, name, created_at, updated_at +FROM assets +WHERE + %s + AND id = @asset_id +LIMIT 1; +` + +q = fmt.Sprintf(q, scope.SQLFragment()) + +args := pgx.StrictNamedArgs{"asset_id": assetID} +maps.Copy(args, scope.SQLArguments()) +``` + +## Scoper interface + +Every Load/Insert/Update/Delete method takes a `Scoper` parameter for tenant isolation: +- `Scope` — adds `tenant_id = @tenant_id` WHERE clause +- `NoScope` — returns `TRUE` (for cross-tenant operations) + +Insert uses `scope.GetTenantID()` for the tenant_id value. + +## Method patterns + +| Method | Receiver | Returns | Notes | +|--------|----------|---------|-------| +| `LoadByID` | `*Entity` | `error` | Assigns into receiver via `*e = entity` | +| `LoadAllBy*` | `*Entities` (slice type) | `error` | Paginated with `page.Cursor[OrderField]` | +| `CountBy*` | `*Entities` | `(int, error)` | Uses `COUNT(id)` | +| `Insert` | `*Entity` | `error` | Uses `scope.GetTenantID()` for tenant_id | +| `Update` | `*Entity` | `error` | Uses `RETURNING` to reassign receiver | +| `Delete` | `*Entity` | `error` | — | +| `CursorKey` | `*Entity` | `page.CursorKey` | Switch on OrderField, panic on unknown | +| `AuthorizationAttributes` | `*Entity` | `(map[string]string, error)` | Returns org/tenant IDs for authz | + +## Row collection + +- Single row: `pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[T])` +- Multiple rows: `pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[T])` +- Check `pgx.ErrNoRows` → return `ErrResourceNotFound` + +## Sentinel errors + +Defined in `errors.go`: +- `ErrResourceNotFound` — row not found (`pgx.ErrNoRows`) +- `ErrResourceAlreadyExists` — unique constraint violation +- `ErrResourceInUse` — foreign key constraint prevents deletion + +## Filter pattern + +Double pointer fields: `nil` = no filter, `*nil` = IS NULL, `*val` = equals. + +`SQLFragment()` must return a **static** SQL string (no conditional string building) so the prepared statement is always the same. Use `CASE WHEN` in SQL to handle optional filters. + +`SQLArguments()` returns `pgx.StrictNamedArgs` — **every key referenced in the SQL must be set in every code path** (use `nil` for inactive filters). `StrictNamedArgs` rejects missing keys at runtime. + +```go +func (f *VendorFilter) SQLArguments() pgx.StrictNamedArgs { + args := pgx.StrictNamedArgs{ + "show_on_trust_center": nil, + "has_snapshot_filter": false, + "filter_snapshot_id": nil, + } + + if f.showOnTrustCenter != nil { + args["show_on_trust_center"] = *f.showOnTrustCenter + } + + if f.snapshotID != nil { + args["has_snapshot_filter"] = true + if *f.snapshotID != nil { + args["filter_snapshot_id"] = **f.snapshotID + } + } + + return args +} + +func (f *VendorFilter) SQLFragment() string { + return ` +( + CASE + WHEN @show_on_trust_center::boolean IS NOT NULL THEN + show_on_trust_center = @show_on_trust_center::boolean + ELSE TRUE + END + AND + CASE + WHEN @has_snapshot_filter::boolean = false THEN TRUE + WHEN @has_snapshot_filter::boolean = true AND @filter_snapshot_id::text IS NOT NULL THEN + snapshot_id = @filter_snapshot_id::text + WHEN @has_snapshot_filter::boolean = true AND @filter_snapshot_id::text IS NULL THEN + snapshot_id IS NULL + ELSE TRUE + END +)` +} +``` + +## OrderField pattern + +OrderField types must validate their value via `IsValid()` and implement text marshalling: + +```go +type InvitationOrderField string + +const ( + InvitationOrderFieldCreatedAt InvitationOrderField = "CREATED_AT" +) + +func (p InvitationOrderField) Column() string { + switch p { + case InvitationOrderFieldCreatedAt: + return "created_at" + } + panic(fmt.Sprintf("unsupported order by: %s", p)) +} + +func (e InvitationOrderField) IsValid() bool { + switch e { + case InvitationOrderFieldCreatedAt: + return true + } + return false +} + +func (e InvitationOrderField) String() string { return string(e) } + +func (e *InvitationOrderField) UnmarshalText(text []byte) error { + *e = InvitationOrderField(text) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid InvitationOrderField", string(text)) + } + return nil +} + +func (e InvitationOrderField) MarshalText() ([]byte, error) { + return []byte(e.String()), nil +} +``` + +## Argument merging + +Always use `maps.Copy` to combine args from scope, filter, and cursor. Never manually merge. + +## Migrations + +Pure SQL files in `pkg/coredata/migrations/` with timestamp names: `YYYYMMDDTHHMMSSZ.sql`. diff --git a/pkg/server/api/console/v1/CLAUDE.md b/pkg/server/api/console/v1/CLAUDE.md new file mode 100644 index 000000000..d571c7343 --- /dev/null +++ b/pkg/server/api/console/v1/CLAUDE.md @@ -0,0 +1,47 @@ +# pkg/server/api/console/v1 + +GraphQL API using `gqlgen`. Schema-first approach. + +## Generated vs hand-written + +| File | Type | Notes | +|------|------|-------| +| `schema.graphql` | Hand-written | GraphQL schema definition | +| `gqlgen.yaml` | Hand-written | Codegen config | +| `resolver.go` | Hand-written | Root `Resolver` struct and `NewMux` | +| `graphql_handler.go` | Hand-written | Handler setup | +| `v1_resolver.go` | Generated stubs | Resolver method implementations (edit the bodies) | +| `schema/schema.go` | **Generated — DO NOT EDIT** | Executable schema | +| `types/types.go` | **Generated — DO NOT EDIT** | Type definitions | + +## Important rules + +- **Never edit generated files** (`schema/schema.go`, `types/types.go`). Only edit `schema.graphql` and resolver bodies. +- **After any change to `schema.graphql`**, always run codegen: + +``` +go generate ./pkg/server/api/console/v1 +``` + +## Resolver pattern + +Every resolver method follows this sequence: + +1. **Authorize** — `r.authorize(ctx, obj.ID, probo.ActionXxxGet)` +2. **Get service** — `prb := r.ProboService(ctx, tenantID)` +3. **Call service** — `result, err := prb.Foo.Bar(ctx, ...)` +4. **Handle error** — wrap or panic on unexpected errors + +## Pagination + +Relay cursor pattern: +- `page.Cursor[OrderField]` for cursor handling +- Connection types (`*Connection`) with `ParentID`, `Resolver`, `Filter` fields + +## Custom scalars + +`ID`, `Datetime`, `CursorKey`, `Duration`, `BigInt`, `EmailAddr` — mapped in `gqlgen.yaml`. + +## Authentication middleware + +`NewMux()` chains: session → API key → identity presence middlewares. diff --git a/pkg/server/api/mcp/v1/CLAUDE.md b/pkg/server/api/mcp/v1/CLAUDE.md new file mode 100644 index 000000000..019ef163b --- /dev/null +++ b/pkg/server/api/mcp/v1/CLAUDE.md @@ -0,0 +1,80 @@ +# pkg/server/api/mcp/v1 + +MCP (Model Context Protocol) API. Schema-first approach using `mcpgen`. + +## Generated vs hand-written + +| File | Type | Notes | +|------|------|-------| +| `specification.yaml` | Hand-written | Tool definitions, input/output schemas | +| `mcpgen.yaml` | Hand-written | Codegen config | +| `resolver.go` | Hand-written | Resolver struct, `MustAuthorize()`, helpers | +| `v1_handler.go` | Hand-written | `NewMux()`, MCP server setup | +| `middleware.go` | Hand-written | API key authentication | +| `helpers.go` | Hand-written | Pagination helpers | +| `schema.resolvers.go` | **Generated (preserved)** | Tool implementations — edit the bodies | +| `server/server.go` | **Generated — DO NOT EDIT** | Tool registration, `ResolverInterface` | +| `types/types.go` | **Generated — DO NOT EDIT** | Type definitions and JSON schemas | +| `types/*.go` (other) | Hand-written | Type conversion helpers (`NewVendor`, etc.) | + +## Important rules + +- **Never edit generated files** (`server/server.go`, `types/types.go`). Only edit `specification.yaml`, resolver bodies, and hand-written helpers. +- **After any change to `specification.yaml`**, always run codegen: + +``` +go generate ./pkg/server/api/mcp/v1 +``` + +Reads `specification.yaml` and generates server, types, and resolver stubs. + +## Adding a new tool + +1. Define the tool in `specification.yaml` under `tools:` with name, description, hints, inputSchema, outputSchema +2. Define input/output schemas under `components/schemas/` +3. Run `go generate ./pkg/server/api/mcp/v1` +4. Implement the tool body in `schema.resolvers.go` +5. Add type conversion helpers in `types/` if needed + +## Tool definition format + +```yaml +tools: + - name: listVendors + description: List all vendors for the organization + hints: + readonly: true + idempotent: true + inputSchema: + $ref: "#/components/schemas/ListVendorsInput" + outputSchema: + $ref: "#/components/schemas/ListVendorsOutput" +``` + +## Resolver pattern + +```go +func (r *Resolver) ListVendorsTool(ctx context.Context, input types.ListVendorsInput) (*types.ListVendorsOutput, error) { + r.MustAuthorize(ctx, input.OrganizationID, probo.ActionVendorList) + prb := r.ProboService(ctx, input.OrganizationID.TenantID()) + // ... service call, type conversion +} +``` + +- `MustAuthorize()` panics on auth failure — caught by MCP recovery middleware +- Type conversion via `types.New*()` helpers + +## Custom type mappings + +In `specification.yaml`, map Go types with `go.probo.inc/mcpgen/type`: + +```yaml +OrderDirection: + type: string + enum: [ASC, DESC] + go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/page.OrderDirection +``` + +## Authentication + +API key auth via `authn.NewAPIKeyMiddleware`. Mounted at `/mcp/v1`.