# Server Architecture

> Deep-dive into `apps/server/` (`@coda/server-app`). For system-level context (infrastructure, deployment, CI/CD, database schema), see the [Architecture Overview](overview.md).

## Overview

The server is an Express API that orchestrates an AI agent loop with native tool use. It receives user queries over HTTP, streams them through Claude on AWS Bedrock, dispatches tool calls to downstream microservices, and returns responses as Server-Sent Events. There is no orchestration framework -- the conversation loop is a simple while-loop that sends messages to Claude, handles tool-use responses, appends results, and repeats until the model produces a final answer.

The server is stateless by design. Conversation history lives in Redis (ElastiCache) with a 7-day TTL, and persistent storage uses Aurora MySQL via Prisma. Multiple Fargate tasks share the same Redis instance, so horizontal scaling requires no sticky sessions or session affinity. Optional infrastructure (Snowflake, Aurora, Notion) degrades gracefully when unavailable -- tools that depend on missing services are simply omitted from the tool list sent to the model.

All configuration is validated at startup through a single Zod schema (`config/load-config.ts`). Observability is handled by Datadog APM (dd-trace, initialized before all imports via `instrument.ts`) and Sentry for error tracking.

## Startup sequence

The entry point (`src/index.ts`) imports `./instrument` (Sentry init), then calls `startServer()` in `src/server.ts`. The `createServer()` factory:

1. Instantiates singletons on `app.locals` -- cache store, conversation cache, conversation repository, memory service
2. Optionally connects Snowflake reader pool, Aurora (Prisma), and Notion OAuth
3. Configures Express: trust proxy, search-api proxy, JSON body parser (35 MB), security headers, CORS (dev only)
4. Mounts routes via `createRoutes()`
5. Registers Sentry error handler, then the custom error handler
6. Serves the React client via `express-static-gzip` with Brotli preference

After `app.listen()`, the startup sequence fires in parallel:

- **Provider warm-up** -- seeds the Bedrock prompt cache with the system prompt + tool definitions via a minimal `ConverseCommand`. The `/health/ready` probe returns 503 until warm-up completes, preventing the load balancer from routing to cold instances.
- **Schema cache polling** -- starts GraphQL and Snowflake schema cache polling on jittered intervals (default 5 min).

Graceful shutdown (`SIGTERM`/`SIGINT`) stops schema polling, drains memory (`memoryService.drain()` -- await in-flight observation appends), closes the HTTP server, disconnects Redis, Prisma, and Sentry.

## Request lifecycle

```
Client POST /api/v1/chats/:id/stream
  |
  v
requestContextMiddleware   — AsyncLocalStorage context
requestLogger              — structured request logging
requireAuth                — Auth0 JWT validation, identity extraction
enrichRequestContext        — populate request context with user data
apiRateLimit               — sliding-window rate limit (100 req/min, Redis-backed)
snowflakeIdentity          — build identity-scoped Snowflake pool
  |
  v
stream-handler.ts          — Zod validation, history load, SSE setup
  |
  v
orchestrator.ts            — streaming agent loop
  |
  v
Redis + Aurora persistence — save conversation, auto-name on first exchange
```

### Auth middleware (`middleware/auth.ts`)

Validates the `Authorization: Bearer <token>` JWT against Auth0's JWKS endpoint. On success, extracts the grass identity claim (`https://grass.theorchard.com/identity`) to populate:

- `res.locals.identityId` -- the user's Orchard identity UUID
- `res.locals.roles` -- comma-separated profile roles
- `res.locals.givenName` -- user's first name (injected into queries as `[User: FirstName]`)

Identity headers (`orchard-identity-id`, `orchard-profile-id`, etc.) are also set on the request for downstream forwarding.

### Snowflake identity middleware (`middleware/snowflake.ts`)

Runs after auth to create a `SecureConnectionPool` scoped to the authenticated user. Session variables carry the user's identity for optional source-team row-level filtering in Snowflake views. If the Snowflake factory is not configured, `snowflakePool` is `undefined` and Snowflake tools gracefully disable.

### Rate limiting (`middleware/rate-limit.ts`)

Two tiers backed by `express-rate-limit` with a Redis (or in-memory) store adapter:

| Tier     | Limit       | Scope    | Purpose                   |
| -------- | ----------- | -------- | ------------------------- |
| `api`    | 100 req/min | All API  | General abuse prevention  |
| `stream` | 10 req/min  | SSE only | Protect Bedrock API costs |

Both use sliding-window counters via Lua scripts in Redis, ensuring limits are enforced globally across all Fargate tasks.

### Stream handler (`routes/stream-handler.ts`)

The SSE streaming endpoint performs:

1. **Validation** -- Zod schema (`streamQuerySchema`) validates the request body
2. **Attachment parsing** -- extracts base64-encoded images/documents into content blocks
3. **History load** -- fetches conversation history from `ConversationRepository` (Redis first, DB fallback)
4. **Load memory** -- `memoryService.loadMemory(identityId)` consolidates observations into facts (100ms timeout, falls back to empty)
5. **Resolve system prompt** -- `getSystemPrompt()` fetches from Langfuse with local file fallback
6. **Inject memory** -- prepend memory section to resolved prompt (user context, tool preferences, domain focus)
7. **Provider resolution** -- resolves the AI provider and model from the request (defaults to Bedrock / Claude Sonnet 4.6)
8. **Thinking budget** -- classifies query intent and assigns a per-intent thinking token budget
9. **SSE setup** -- sets SSE headers (point of no return for HTTP status codes)
10. **Agent loop** -- calls `converseWithTools()` with streaming callbacks that write SSE events
11. **Persistence** -- saves the completed turn to Redis and Aurora via `StreamPersister`
12. **Auto-naming** -- on the first exchange, generates a conversation title using a lightweight model (Haiku)

SSE events emitted: `message_start`, `chunk`, `progress`, `reasoning`, `selection_required`, `clear`, `usage`, `sources`, `suggestions`, `attachments`, `title`, `warnings`, `done`, `error`.

## Agent loop

The core orchestration lives in `ai/orchestrator.ts` (`converseWithTools`). It implements a streaming conversation loop with native tool use -- no LangChain, no orchestration framework.

### Flow

```
converseWithTools()
  |
  +-- Build initial messages (history + query + attachments)
  +-- Build tool availability checker
  |
  +-- WHILE rounds < 15
  |     |
  |     +-- Build active tool config (core + discovered deferred tools)
  |     +-- provider.streamRound(request)
  |     |     |
  |     |     +-- Accumulate content blocks from stream events:
  |     |           textDelta, toolUseStart, toolUseDelta,
  |     |           reasoningDelta, blockStop, messageStop, usage
  |     |
  |     +-- IF stopReason == "end_turn" → BREAK
  |     +-- IF stopReason == "tool_use":
  |           |
  |           +-- Parse tool calls from accumulated blocks
  |           +-- Emit progress steps (CoT timeline)
  |           +-- executeTools() → concurrent dispatch
  |           +-- processAutoBatch() → transparent follow-ups
  |           +-- Resolve source links from tool results
  |           +-- Extract attachments from tool results
  |           +-- Build assistant + tool result messages
  |           +-- CONTINUE
  |
  +-- Generate suggestions (3 follow-up questions)
  +-- Emit usage stats + source links
```

### Key behaviors

- **Max rounds**: 15 (increased from 10 to support multi-step GraphQL workflows: search -> type_info -> query)
- **Extended thinking**: Enabled for Claude 3.7+ and Claude 4+ models. Budget is per-intent (see below).
- **Auto-batching**: After product lookups, transparently fetches account details in a follow-up batch (`auto-batch.ts`)
- **Prompt caching**: System prompt and tool definitions include `cachePoint` markers with 1-hour TTL
- **History sanitization**: Messages are validated, merged, alternated, and capped to the last 20 turns (`sanitize.ts`)
- **Suggestion generation**: After the final answer, generates 3 follow-up questions via a separate non-streaming call
- **Selection emission**: When a tool returns multiple candidates (e.g., account search with ambiguous results), emits a `selection_required` event for client-side disambiguation

### Intent-based thinking budgets (`ai/thinking-budgets.ts`)

Rather than a fixed budget for every query, the server classifies intent via keyword patterns and assigns a scaled budget:

| Intent     | Budget | Example queries                        |
| ---------- | ------ | -------------------------------------- |
| `account`  | 1024   | payee details, tax info, payment terms |
| `contract` | 1024   | contract terms, advances, parties      |
| `product`  | 1024   | UPC/ISRC resolution                    |
| `period`   | 1024   | statement period lookups               |
| `revenue`  | 1536   | period comparison, store breakdown     |
| `ledger`   | 1536   | balance interpretation, adjustments    |
| `general`  | 2048   | broad or ambiguous queries             |

The `BEDROCK_THINKING_BUDGET` env var acts as a global ceiling.

## Provider abstraction

The `ai/providers/` layer abstracts the LLM backend behind the `AIProvider` interface (`providers/types.ts`). The orchestrator never imports SDK types directly.

### AIProvider interface

```typescript
interface AIProvider {
  streamRound(request): AsyncIterable<ProviderStreamEvent>; // streaming round
  converse(request): Promise<ConverseResult>; // non-streaming
  initMessages(history, query, attachments, opts): unknown[]; // build messages
  buildAssistantMessage(blocks, extraToolUses): unknown; // assistant msg
  buildToolResultMessage(results): unknown; // tool results
  supportsThinking(modelId): boolean; // capability check
  resolveModelId(slug): string | null; // slug resolution
  warmUp(): Promise<void>; // cache seeding
  getModelInfo(): ModelInfo[]; // model metadata
  isReady(): boolean; // readiness probe
}
```

### Normalized stream events

All provider implementations emit a common event vocabulary:

| Event            | Purpose                                    |
| ---------------- | ------------------------------------------ |
| `textDelta`      | Streamed text token                        |
| `toolUseStart`   | Tool call begins (with toolUseId and name) |
| `toolUseDelta`   | Incremental tool input JSON                |
| `reasoningDelta` | Extended thinking text                     |
| `blockStop`      | Content block ended                        |
| `messageStop`    | Message ended (with stopReason)            |
| `usage`          | Token counts and latency                   |
| `error`          | Stream-level error                         |

### Provider registry (`providers/registry.ts`)

Maps `ProviderType` enum values to lazily initialized `AIProvider` instances. Currently only Bedrock is implemented. The registry provides:

- `getDefaultProvider()` -- the configured default (via `PROVIDER_DEFAULT` env var)
- `getAllProviders()` -- for startup warm-up and readiness probes
- `getAllModelInfo()` -- aggregated model metadata for the `/api/v1/models` endpoint

### Bedrock provider (`providers/bedrock/provider.ts`)

Implements `AIProvider` using the AWS Bedrock Converse API (`@aws-sdk/client-bedrock-runtime`). Key details:

- Uses `ConverseStreamCommand` for streaming rounds, `ConverseCommand` for non-streaming calls (suggestions, auto-naming, warm-up)
- Prompt cache entries include `cachePoint: { type: "default", ttl: "1h" }` on both system prompt and tool definitions
- Warm-up fires a minimal `ConverseCommand` with `maxTokens: 1` to seed the prompt cache
- Model capability detection: `supportsThinking()` checks for Claude 3.7 Sonnet or Claude 4+ by parsing the model ID string
- Allowed models and slug-to-ID mapping live in `providers/bedrock/constants.ts`

## Langfuse Integration

The system prompt is fetched from Langfuse's prompt registry at session start (`getSystemPrompt()`), with local `system-prompt.md` as fallback. Langfuse provides prompt versioning, A/B testing, and trace correlation. Agent memory injection runs downstream -- it prepends to whatever prompt Langfuse resolves.

## Tool system

### Architecture

```
orchestrator.ts
  |
  +-- buildAvailabilityChecker()   — filter unavailable tools before sending to model
  +-- buildActiveToolConfig()      — core tools + discovered deferred tools
  |
  +-- executeTools(toolUses, req)
        |
        +-- buildAllHandlers()     — once per batch
        |     +-- buildDomainHandlers()     — cached globally (stateless HTTP closures)
        |     +-- buildSnowflakeHandlers()  — per-request (identity-scoped pool)
        |     +-- buildSkillHandlers()      — per-request (depends on domain handlers + pool)
        |     +-- buildNotionHandlers()     — per-request (OAuth connection)
        |     +-- buildRunnerHandlers()     — cached globally
        |
        +-- Promise.all(toolUses.map(executeToolWithHandlers))
              |
              +-- isHandlerEnabled(handler)  — availability guard
              +-- callToolHandler(handler, input, headers)
```

### Handler types (`tools/handler-utils.ts`)

Two forms, unified under `AnyToolHandler`:

- **`ToolHandler`** (plain function) -- always considered enabled. Used by most domain handlers.
- **`ToolHandlerObject`** -- has an `enabled()` predicate and optional `disabledReason`. Used for handlers whose backing infrastructure may be unavailable (Snowflake, Notion). The `enabled()` predicate is checked at both surfacing time (catalog/deferred) and execution time (registry).

### Tool categories

| Category    | Directory            | Examples                                              | Infrastructure              |
| ----------- | -------------------- | ----------------------------------------------------- | --------------------------- |
| Account     | `tools/account/`     | `search_accounts`, `get_payee_info`                   | ows-abacus-account          |
| Royalties   | `tools/royalties/`   | `get_contract`, `search_contracts`, `get_advances`    | ows-royalties               |
| Moneyhub    | `tools/moneyhub/`    | `get_revenue_summary`, `get_revenue_breakdown`        | ows-moneyhub                |
| Product     | `tools/product/`     | `get_product_by_upc`, `get_product_by_isrc`           | ows-product                 |
| Ledger      | `tools/ledger/`      | `get_balance`, `get_adjustments`                      | ows-ledger                  |
| File        | `tools/file/`        | `generate_excel`, `generate_pdf`                      | Local (ExcelJS, PDFKit)     |
| Snowflake   | `tools/snowflake/`   | `query_snowflake`, `describe_snowflake_table`         | Snowflake (key-pair auth)   |
| GraphQL     | `tools/graphql/`     | `query_graphql`, `describe_graphql_type`              | GraphQL gateway             |
| Search      | `tools/search/`      | `search_tools`                                        | In-process catalog          |
| Notion      | `tools/notion/`      | `search_notion`, `read_notion_page`                   | Notion API (OAuth)          |
| Adjustments | `tools/adjustments/` | `validate_adjustment_file`, `submit_adjustment_batch` | ows-ledger / ows-royalties  |
| Runner      | `tools/runner/`      | `run_datasource`                                      | Runner service (ConnectRPC) |

### Deferred tool loading (`tools/deferred.ts`)

To reduce prompt size and cost, tools are split into two tiers:

- **Core tools** (`core: true` on the definition) -- always sent to the model. Includes skills, `search_tools`, and the most commonly used domain tools.
- **Deferred tools** -- discovered via the `search_tools` meta-tool. When Claude calls `search_tools` with a query, the server searches the in-memory catalog (`tools/catalog.ts`) and adds matching tool definitions to the next round's tool config.

The catalog uses keyword matching with glossary boosting -- a glossary JSON file (`tools-glossary.json`) maps domain synonyms (e.g., "payment", "DSP", "advance") to tool names so queries with domain vocabulary still surface the right tools.

### Downstream communication (`services/http-client.ts`)

All domain tool handlers communicate with downstream services through `safeGet` and `safePost` wrappers around native `fetch`. These wrappers:

- Forward auth headers (JWT, cookies, identity headers) from the original request
- Sanitize HTTP errors so internal URLs, tokens, and stack traces are never exposed to Claude
- Map 401/403 to "Access denied", 404 to "Resource not found", 500 to a generic error
- Log slow requests (>3s) at warn level
- Use `AbortSignal.timeout()` with a configurable timeout (default 30s)

## Skills

Skills are high-level aggregation tools that compose multiple domain handler calls into a single result. They reduce LLM round trips for broad questions.

| Skill                     | What it fetches                                               |
| ------------------------- | ------------------------------------------------------------- |
| `account_overview_skill`  | Account details, payment terms, tax info, activity, contracts |
| `contract_overview_skill` | Contract details, terms, parties, advances, exclusions        |
| `revenue_overview_skill`  | Revenue summary, period breakdown, store/artist splits        |
| `graphql_explore_skill`   | Schema search, type discovery, optional query execution       |
| `snowflake_explore_skill` | Schema search, table discovery, optional query execution      |

Skills are built per-request (`buildSkillHandlers` in `ai/skills/index.ts`) because some depend on per-request infrastructure (Snowflake pool). They use `resilientCall()` for fault isolation -- one failing sub-call doesn't reject the entire aggregation.

Skills use the same `ToolDefinition` interface as tools but live in `ai/skills/` to signal their distinct role.

## Caching

### Cache store abstraction (`cache/cache-store.ts`)

The `CacheStore` interface abstracts string and hash operations. Two implementations:

- **`RedisCacheStore`** (`cache/redis-store.ts`) -- backed by ioredis, selected when `REDIS_URL` is configured. Uses Lua scripts for atomic operations (append-to-list, sliding-window rate limiting).
- **`MemoryCacheStore`** (`cache/memory-store.ts`) -- in-memory fallback for local development. Implements the same atomic semantics in JavaScript.

Both implementations also satisfy the `RateLimitStore` interface for rate limiting.

### Conversation cache (`cache/conversation-cache.ts`)

Manages conversation state with a structured key format:

```
chatbot:conversation:{identityId}:{convId}         — message array (JSON)
chatbot:conversation:{identityId}:{convId}:count    — message count
chatbot:conversations:{identityId}                  — hash of conversation metadata
```

All keys have a 7-day TTL. The cache supports:

- Full conversation read/write
- Atomic message append (Lua script in Redis, no read-modify-write races)
- Conversation metadata CRUD (title, starred, timestamps)
- Compare-and-swap updates (prevents auto-naming from overwriting manual renames)
- Cursor-paginated listing with binary search on descending-sorted conversations
- ETag computation for conditional responses

### Conversation repository

The `ConversationRepository` (`db/coda/conversation-repository.ts`) provides a cache-first, DB-fallback read path. On cache miss, it loads from Aurora and back-fills Redis so subsequent requests hit the cache.

## Configuration

All environment variables are validated at startup by a Zod schema in `config/load-config.ts`. The schema defines defaults, types, and constraints for every config value.

Key config groups:

| Group         | Env prefix                               | Description                             |
| ------------- | ---------------------------------------- | --------------------------------------- |
| Auth          | `AUTH0_*`                                | Auth0 domain and audience               |
| Provider      | `BEDROCK_*`                              | Model ID, tokens, temperature, thinking |
| Redis         | `REDIS_URL`                              | Cache backend                           |
| Services      | `OWS_*`                                  | Downstream service base URLs            |
| Snowflake     | `SNOWFLAKE_*`                            | Reader pool credentials and limits      |
| Database      | `CODA_DB_*`                              | Aurora MySQL connection and pool        |
| Identity      | `CODA_DB_IDENTITY_*`                     | HMAC secret and AES key for identity    |
| Schema cache  | `GRAPHQL_SCHEMA_*`, `SNOWFLAKE_SCHEMA_*` | Polling intervals and jitter            |
| Integrations  | `NOTION_*`                               | Notion OAuth credentials                |
| Microservices | `SEARCH_URL`, `RUNNER_URL`               | Search and runner service URLs          |

The `loadConfig()` function accepts an env object (defaults to `process.env`) so tests can call `loadConfig(fakeEnv)` without triggering real env parsing. Policy constraints (e.g., poll interval bounds) are validated after parsing.

## Observability

### Datadog (dd-trace)

`dd-trace` is loaded at process startup via the Node.js `--import` flag in the Dockerfile CMD: `node --import dd-trace/initialize.mjs dist/index.mjs`. This ensures dd-trace instruments all ESM modules before they load. The server compiles to ESM (`.mjs` output via tsdown).

### Sentry (`instrument.ts`)

Sentry is initialized with:

- 10% trace sample rate
- PII sending disabled
- 401/403/429 errors filtered out (operational noise)
- Health probe and asset transactions dropped

The Sentry Express error handler is registered before the custom error handler so exceptions are captured before the generic 500 response.

## Key design decisions

| Decision                       | Reference                                                                 |
| ------------------------------ | ------------------------------------------------------------------------- |
| Native tool use (no framework) | [Architecture Overview](overview.md#native-tool-use)                      |
| SSE over WebSockets            | [Architecture Overview](overview.md#streaming-sse-over-websockets)        |
| dd-trace via `--import` flag   | [Architecture Overview](overview.md#dd-trace-via---import-flag)           |
| `@coda/db` extraction          | [db-package-extraction TRD](../decisions/trds/db-package-extraction.md)   |
| Search service separation      | [search-service TRD](../decisions/trds/search-service.md)                 |
| Stream persistence             | [stream-module TRD](../decisions/trds/stream-module.md)                   |
| Snowflake schema index         | [snowflake-schema-index TRD](../decisions/trds/snowflake-schema-index.md) |

## File reference

```
apps/server/src/
  index.ts                        — entry point (instrument + startServer)
  server.ts                       — Express app factory, startup, shutdown
  instrument.ts                   — Sentry + dd-trace initialization
  app-locals.ts                   — typed Express app.locals
  constants.ts                    — header names, app name, org ID
  config/
    load-config.ts                — Zod schema, env parsing, policy validation
    index.ts                      — singleton config export
  routes/
    index.ts                      — route registry, health probes, middleware chain
    chat-routes.ts                — Express router for /api/v1/chats/*
    stream-handler.ts             — SSE streaming endpoint
    conversation-handlers.ts      — CRUD (list, create, delete, star)
    message-handlers.ts           — message listing, feedback
    feedback-handlers.ts          — feedback submission
    integration-routes.ts         — Notion OAuth flow
    attachment-parser.ts          — multipart attachment extraction
    sse-utils.ts                  — SSE event formatting
    route-utils.ts                — param extraction helpers
  middleware/
    auth.ts                       — Auth0 JWT validation, identity extraction
    rate-limit.ts                 — sliding-window rate limiting (Redis/memory)
    snowflake.ts                  — identity-scoped Snowflake pool
    security-headers.ts           — CSP, HSTS, X-Frame-Options
    request-logger.ts             — structured request logging
    request-context-middleware.ts  — AsyncLocalStorage context
    enrich-request-context.ts     — populate request context
    error-handler.ts              — global 500 handler
    validate-uuid.ts              — UUID route param validation
  ai/
    orchestrator.ts               — streaming agent loop (converseWithTools)
    system-prompt.md              — Claude system prompt
    system-prompt.ts              — prompt loader
    thinking-budgets.ts           — intent classification + budget scaling
    auto-batch.ts                 — transparent follow-up tool calls
    auto-name.ts                  — conversation title generation
    sanitize.ts                   — history validation + alternation + cap
    suggestions.ts                — follow-up question generation
    source-resolvers.ts           — tool result -> source link mapping
    selection-emitter.ts          — disambiguation event emission
    attachment-extractor.ts       — extract attachments from tool results
    providers/
      types.ts                    — AIProvider interface, stream events
      registry.ts                 — provider factory + lazy init
      bedrock/
        provider.ts               — BedrockProvider (Converse API)
        adapters.ts               — Bedrock SDK type adapters
        client.ts                 — BedrockRuntimeClient singleton
        constants.ts              — allowed models, slug mapping
    tools/
      registry.ts                 — tool executor, handler dispatch
      handler-utils.ts            — ToolHandler types, availability gating
      definitions.ts              — all tool definitions (barrel)
      deferred.ts                 — core/deferred split, active config builder
      catalog.ts                  — searchable tool catalog + glossary
      tools-glossary.json         — domain synonym -> tool name mapping
      account/                    — account domain tools
      royalties/                  — royalties domain tools
      moneyhub/                   — moneyhub domain tools
      product/                    — product domain tools
      ledger/                     — ledger domain tools
      file/                       — Excel/PDF generation tools
      snowflake/                  — Snowflake query + schema tools
      graphql/                    — GraphQL query + schema tools
      search/                     — search_tools meta-tool
      notion/                     — Notion integration tools
      runner/                     — datasource runner tools
    skills/
      index.ts                    — skill barrel, handler builder
      account-overview/           — account aggregation skill
      contract-overview/          — contract aggregation skill
      revenue-overview/           — revenue aggregation skill
      graphql-explore/            — GraphQL discovery + query skill
      snowflake-explore/          — Snowflake discovery + query skill
  cache/
    cache-store.ts                — CacheStore + RateLimitStore interfaces
    redis-store.ts                — Redis implementation (Lua scripts)
    memory-store.ts               — in-memory fallback
    conversation-cache.ts         — conversation CRUD, pagination, ETags
  db/
    snowflake/                    — Snowflake reader pool (key-pair auth)
    coda/                         — Aurora DB access (services, persister, repository)
  services/
    http-client.ts                — downstream HTTP client (native fetch)
    notion-client.ts              — Notion API client
    notion-token-service.ts       — Notion OAuth token management
  search/                         — search service ConnectRPC client
  utils/                          — logger, time, env parsing, JSON, errors
```
