# API Design Guidelines

Guidelines for designing RESTful APIs in the Cherry Studio Data API system.

> **File organization is separate from path design.** For which `schemas/*.ts` file a route belongs in, see [Schema File Organization](./api-types.md#schema-file-organization). This guide covers the *shape* of paths only.

## Path Naming

| Rule | Example | Notes |
|------|---------|-------|
| Use plural nouns for collections | `/topics`, `/messages` | Resources are collections |
| Use kebab-case for multi-word paths | `/user-settings` | Not camelCase or snake_case |
| Express hierarchy via nesting | `/topics/:topicId/messages` | Parent-child relationships |
| Avoid verbs for CRUD operations | `/topics` not `/getTopics` | HTTP methods express action |

## Resource ↔ Table Naming

When a route is backed by a SQLite table, the route, table, and type names MUST express one shared domain noun, each in its own layer's casing:

| Layer | Convention | Example |
|---|---|---|
| DB table | singular snake_case | `agent_session` |
| REST route (collection) | plural kebab-case | `/agent-sessions` |
| Schema / entity type | singular PascalCase | `AgentSessionEntity` |
| Inferred row type | `XxxRow` ([§5.3](../naming-conventions.md#53-drizzle-schema-inferred-row-types)) | `AgentSessionRow` |

A route noun that diverges from its backing table's concept is drift — fix the route, not the table.

**Exceptions** (noun diverges from a single table):

| Case | Example |
|---|---|
| Shared / library resource | `/skills`, `/models` |
| Nested sub-resource | `/agents/:agentId/tasks` |
| Aggregate / derived / non-CRUD | `/topics/search`, `/topics/stats` |

## HTTP Method Semantics

| Method | Purpose | Idempotent | Typical Response |
|--------|---------|------------|------------------|
| GET | Retrieve resource(s) | Yes | 200 + data |
| POST | Create resource | No | 201 + created entity |
| PUT | Replace entire resource | Yes | 200 + updated entity |
| PATCH | Partial update | Yes | 200 + updated entity |
| DELETE | Remove resource | Yes | 204 / void |

## Standard Endpoint Patterns

```typescript
// Collection operations
'/topics': {
  GET: { ... }   // List with pagination/filtering
  POST: { ... }  // Create new resource
}

// Individual resource operations
'/topics/:id': {
  GET: { ... }    // Get single resource
  PUT: { ... }    // Replace resource
  PATCH: { ... }  // Partial update
  DELETE: { ... } // Remove resource
}

// Nested resources (use for parent-child relationships)
'/topics/:topicId/messages': {
  GET: { ... }   // List messages under topic
  POST: { ... }  // Create message in topic
}
```

## Greedy Path Parameters

Use a greedy param when a single path-param value may itself contain `/`.
This avoids URL-encoding (which the project does not use) and keeps composite
identifiers readable in the path.

**Syntax:** `:<name>*` (trailing `*` on a `:`-prefixed segment).

**Position:** valid as the **last** segment, or **in the middle** of a pattern
anchored by static / plain-param trailing segments. A pattern may contain at
most one greedy param — a second greedy is rejected defensively to keep route
matching unambiguous.

**Semantics:**

- Matches **one or more** consecutive path segments and exposes the raw joined
  string (segments rejoined with `/`) as `params.<name>`.
- Does **not** match zero segments — the capture is required.
- The captured value is **not decoded**; any `/`, `::`, `%`, etc. inside it is
  preserved verbatim, consistent with the rest of the router.
- There is no `*`-as-any-segment or `**` wildcard — only `:name*`.

**Examples:**

```typescript
// Tail greedy — composite ID at end of path
'/models/:uniqueModelId*'
  '/models/openai::gpt-4'                          → { uniqueModelId: 'openai::gpt-4' }
  '/models/qwen::qwen/qwen3-vl'                    → { uniqueModelId: 'qwen::qwen/qwen3-vl' }
  '/models/fireworks::accounts/fireworks/models/x' → { uniqueModelId: 'fireworks::accounts/fireworks/models/x' }
  '/models'                                        → no match (greedy requires ≥1 segment)

// Middle greedy — free-form ID wrapped by static anchors
'/models/:uid*/order'
  '/models/a/b/c/order'                            → { uid: 'a/b/c' }
  '/models/qwen::qwen/order'                       → { uid: 'qwen::qwen' }
  '/models/order'                                  → no match (greedy requires ≥1 segment)
  '/models/a/b/c'                                  → no match (trailing anchor mismatch)

// Mixed leading plain + middle greedy + trailing anchor
'/providers/:providerId/models/:uid*/actions'
  '/providers/openai/models/qwen/qwen3-vl/actions' → { providerId: 'openai', uid: 'qwen/qwen3-vl' }
```

**When to reach for this:**

- Composite identifiers whose component can include `/`
  (e.g. OpenRouter/Fireworks-style model IDs).
- Third-party IDs where you cannot control the character set.
- Attaching sub-actions (`/…/order`, `/…/actions`) to resources whose ID
  contains `/`.

**When NOT to use it:**

- For nanoid/UUID-style IDs that never contain `/` — prefer the plain `:id`
  form so the route stays strictly 1-to-1 with its shape.

## PATCH vs Dedicated Endpoints

### Decision Criteria

Use this decision tree to determine the appropriate approach:

```
Operation characteristics:
├── Simple field update with no side effects?
│   └── Yes → Use PATCH
├── High-frequency operation with clear business meaning?
│   └── Yes → Use dedicated endpoint (noun-based sub-resource)
├── Operation triggers complex side effects or validation?
│   └── Yes → Use dedicated endpoint
├── Operation creates new resources?
│   └── Yes → Use POST to dedicated endpoint
└── Default → Use PATCH
```

### Guidelines

| Scenario | Approach | Example |
|----------|----------|---------|
| Simple field update | PATCH | `PATCH /messages/:id { data: {...} }` |
| High-frequency + business meaning | Dedicated sub-resource | `PUT /topics/:id/active-node { nodeId }` |
| Complex validation/side effects | Dedicated endpoint | `POST /messages/:id/move { newParentId }` |
| Creates new resources | POST dedicated | `POST /messages/:id/duplicate` |

### Naming for Dedicated Endpoints

- **Prefer noun-based paths** over verb-based when possible
- Treat the operation target as a sub-resource: `/topics/:id/active-node` not `/topics/:id/switch-branch`
- Use POST for actions that create resources or have non-idempotent side effects
- Use PUT for setting/replacing a sub-resource value

### Examples

```typescript
// ✅ Good: Noun-based sub-resource for high-frequency operation
PUT /topics/:id/active-node
{ nodeId: string }

// ✅ Good: Simple field update via PATCH
PATCH /messages/:id
{ data: MessageData }

// ✅ Good: POST for resource creation
POST /messages/:id/duplicate
{ includeDescendants?: boolean }

// ❌ Avoid: Verb in path when noun works
POST /topics/:id/switch-branch  // Use PUT /topics/:id/active-node instead

// ❌ Avoid: Dedicated endpoint for simple updates
POST /messages/:id/update-content  // Use PATCH /messages/:id instead
```

## Non-CRUD Operations

Use verb-based paths for operations that don't fit CRUD semantics:

> For sortable resources (drag-and-drop ordering), do not invent ad-hoc endpoints — follow the canonical `PATCH /{resource}/:id/order` pattern documented in the [Ordering Guide](./data-ordering-guide.md).


```typescript
// Search
'/topics/search': {
  GET: { query: { q: string } }
}

// Statistics / Aggregations
'/topics/stats': {
  GET: { response: { total: number, ... } }
}

// Resource actions (state changes, triggers)
'/topics/:id/archive': {
  POST: { response: { archived: boolean } }
}

'/topics/:id/duplicate': {
  POST: { response: Topic }
}
```

## Query Parameters

| Purpose | Pattern | Example |
|---------|---------|---------|
| Pagination (offset) | `page` + `limit` | `?page=1&limit=20` |
| Pagination (cursor) | `cursor` + `limit` | `?cursor=1700000000000:abc&limit=20` |
| Sorting | `sortBy` + `sortOrder` (see `SortParams` in [api-types.md](api-types.md)) | `?sortBy=createdAt&sortOrder=desc` |
| Filtering | direct field names | `?status=active&type=chat` |
| Search | `q` or `search` | `?q=keyword` |

For offset-vs-cursor selection and the `<key>:<id>` cursor wire format, see the [Pagination Guide](./data-pagination-guide.md).

## Response Status Codes

Use standard HTTP status codes consistently:

| Status | Usage | Example |
|--------|-------|---------|
| 200 OK | Successful GET/PUT/PATCH | Return updated resource |
| 201 Created | Successful POST | Return created resource |
| 202 Accepted | Async task accepted | Return task reference |
| 204 No Content | Successful DELETE | No body |
| 400 Bad Request | Invalid request format | Malformed JSON |
| 400 Invalid Operation | Business rule violation | Delete root without cascade, cycle creation |
| 401 Unauthorized | Authentication required | Missing/invalid token |
| 403 Permission Denied | Insufficient permissions | Access denied to resource |
| 404 Not Found | Resource not found | Invalid ID |
| 409 Conflict | Concurrent modification or data inconsistency | Version conflict, data corruption |
| 422 Unprocessable | Validation failed | Invalid field values |
| 423 Locked | Resource temporarily locked | File being exported |
| 429 Too Many Requests | Rate limit exceeded | Throttling |
| 500 Internal Error | Server error | Unexpected failure |
| 503 Service Unavailable | Service temporarily down | Maintenance mode |
| 504 Timeout | Request timed out | Long-running operation |

### Success Status Constants

Use the `SuccessStatus` constants to avoid magic numbers:

```typescript
import { SuccessStatus } from '@shared/data/api/apiTypes'

SuccessStatus.OK          // 200 - Request succeeded
SuccessStatus.CREATED     // 201 - Resource created
SuccessStatus.ACCEPTED    // 202 - Async task accepted
SuccessStatus.NO_CONTENT  // 204 - Success with no body
```

### Handler Status Code Behavior

**Automatic Inference (Default)**

The API server automatically infers status codes based on HTTP method:

| Method | Default Status | Condition |
|--------|----------------|-----------|
| POST | 201 Created | Always |
| DELETE | 204 No Content | When handler returns `undefined` |
| DELETE | 200 OK | When handler returns data |
| GET/PUT/PATCH | 200 OK | Always |

```typescript
// Status codes are inferred automatically - no extra code needed
'/topics': {
  POST: async ({ body }) => {
    return await topicService.create(body)  // Returns 201
  }
},

'/topics/:id': {
  GET: async ({ params }) => {
    return await topicService.getById(params.id)  // Returns 200
  },

  DELETE: async ({ params }) => {
    await topicService.delete(params.id)
    return undefined  // Returns 204
  }
}
```

**Custom Status Codes**

Override the default by returning `{ data, status }`:

```typescript
import { SuccessStatus } from '@shared/data/api/apiTypes'

'/async-tasks': {
  POST: async ({ body }) => {
    const task = await taskService.createAsync(body)
    return { data: task, status: SuccessStatus.ACCEPTED }  // Returns 202
  }
},

'/topics/:id': {
  DELETE: async ({ params }) => {
    const deleted = await topicService.delete(params.id)
    return { data: deleted, status: SuccessStatus.OK }  // Returns 200 with data
  }
}
```

**Type Safety**

Custom status codes are type-safe - only valid `SuccessStatusCode` values are allowed:

```typescript
// ✅ Valid
return { data: result, status: SuccessStatus.CREATED }
return { data: result, status: SuccessStatus.ACCEPTED }

// ❌ Compile error - 999 is not a valid SuccessStatusCode
return { data: result, status: 999 }
```

## Error Response Format

All error responses follow the `SerializedDataApiError` structure (transmitted via IPC):

```typescript
interface SerializedDataApiError {
  code: ErrorCode | string  // ErrorCode enum value (e.g., 'NOT_FOUND')
  message: string           // Human-readable error message
  status: number            // HTTP status code
  details?: Record<string, unknown>  // Additional context (e.g., field errors)
  requestContext?: {        // Request context for debugging
    requestId: string
    path: string
    method: HttpMethod
    timestamp?: number
  }
  // Note: stack trace is NOT transmitted via IPC - rely on Main process logs
}
```

**Examples:**

```typescript
// 404 Not Found
{
  code: 'NOT_FOUND',
  message: "Topic with id 'abc123' not found",
  status: 404,
  details: { resource: 'Topic', id: 'abc123' },
  requestContext: { requestId: 'req_123', path: '/topics/abc123', method: 'GET' }
}

// 422 Validation Error
{
  code: 'VALIDATION_ERROR',
  message: 'Request validation failed',
  status: 422,
  details: {
    fieldErrors: {
      name: ['Name is required', 'Name must be at least 3 characters'],
      email: ['Invalid email format']
    }
  }
}

// 504 Timeout
{
  code: 'TIMEOUT',
  message: 'Request timeout: fetch topics (3000ms)',
  status: 504,
  details: { operation: 'fetch topics', timeoutMs: 3000 }
}

// 400 Invalid Operation
{
  code: 'INVALID_OPERATION',
  message: 'Invalid operation: delete root message - cascade=true required',
  status: 400,
  details: { operation: 'delete root message', reason: 'cascade=true required' }
}
```

Use `DataApiErrorFactory` utilities to create consistent errors:

```typescript
import { DataApiErrorFactory, DataApiError } from '@shared/data/api'

// Using factory methods (recommended)
throw DataApiErrorFactory.notFound('Topic', id)
throw DataApiErrorFactory.validation({ name: ['Required'] })
throw DataApiErrorFactory.database(error, 'insert topic')
throw DataApiErrorFactory.timeout('fetch topics', 3000)
throw DataApiErrorFactory.dataInconsistent('Topic', 'parent reference broken')
throw DataApiErrorFactory.invalidOperation('delete root message', 'cascade=true required')

// Check if error is retryable
if (error instanceof DataApiError && error.isRetryable) {
  await retry(operation)
}
```

### SQLite Constraint Translation

When a Service writes to the database, SQLite constraint violations (UNIQUE,
FOREIGN KEY, CHECK, NOT NULL) come out as `DrizzleQueryError` with the real
error buried in the `.cause` chain. Translate them to `DataApiError` with
`withSqliteErrors` from `src/main/data/db/sqliteErrors.ts`:

```typescript
import { defaultHandlersFor, withSqliteErrors } from '@data/db/sqliteErrors'

const [row] = await withSqliteErrors(
  () => this.db.insert(tagTable).values(dto).returning(),
  defaultHandlersFor('Tag', dto.name)
)
```

`defaultHandlersFor` covers the common CRUD case (UNIQUE → 409, FK → 404,
CHECK / NOT NULL → 422). Spread and override any specific kind when needed.
Any unrecognized error is rethrown unchanged — see the file's JSDoc for the
full API contract and the "do not replace pre-validation" discipline note.

## Naming Conventions Summary

| Element | Case | Example |
|---------|------|---------|
| Paths | kebab-case, plural | `/user-settings`, `/topics` |
| Path params | camelCase | `:topicId`, `:messageId` |
| Query params | camelCase | `sortBy`, `pageSize` |
| Body fields | camelCase | `createdAt`, `userName` |
| Error codes | SCREAMING_SNAKE | `NOT_FOUND`, `VALIDATION_ERROR` |

## DataApi Scope & Boundaries

DataApi is exclusively for **persistent business data** backed by SQLite. Operations that do not meet this criteria must use traditional IPC handlers.

### Eligibility Criteria

All three conditions must be met before adding a DataApi endpoint:

1. The operation **reads or writes persistent business data** in a SQLite table
2. The data is **user-created, irreplaceable** (loss would be severe)
3. A **database table schema** exists (or will be created) for this data

If any condition is not met, use an IPC handler in `src/main/ipc.ts` or a lifecycle service instead.

Meeting all three is **necessary but not sufficient** — the operation must also satisfy the side-effect boundary below.

### Hard Rule: No Non-Data Side Effects

A DataApi handler and the service behind it may perform **one kind of effect only: SQLite reads/writes via Drizzle.** Any filesystem, network, process-spawn, child-window, or external-service call on a DataApi code path is a boundary violation — **regardless of how many layers deep it is hidden, and even when the operation also legitimately writes the database.**

DataApiService is the **data** business-logic layer (persisting and querying records), **not** the application's business-logic layer. A non-data side effect bundled into a DB write is still a non-data side effect.

**Mixed operations** ("write a row *and* write a file") are split: a business/lifecycle service in main owns the orchestration and the side effect, calls the Entity Service for the DB part, and is triggered from the renderer via a dedicated IPC channel. The side effect never rides through DataApi.

### Anti-patterns: What Does NOT Belong in DataApi

| Anti-pattern | Why It's Wrong | Correct Approach |
|---|---|---|
| `POST /windows/open` | No database operation, pure side effect | IPC: `IpcChannel.Window_Open` |
| `POST /services/restart` | Process control is not a data operation | IPC: `IpcChannel.Service_Restart` |
| `GET /system/info` | Stateless system query, no persistence | IPC: `IpcChannel.App_Info` |
| `POST /notifications/send` | Triggers external side effect | IPC: `IpcChannel.Notification_Send` |
| `POST /backup/start` | Complex workflow orchestration, not CRUD | IPC: `IpcChannel.Backup_Backup` |
| `POST /auth/login` | OAuth flow, external service integration | IPC: dedicated auth handler |
| `GET /mcp/tools` | Runtime service query, not persisted data | IPC: `IpcChannel.Mcp_ListTools` |
| `POST /jobs` (enqueue) / `DELETE /jobs/:id` (cancel) | Workflow command on `JobManager` infrastructure, not CRUD | Business service in main calls `application.get('JobManager').enqueue(...)` / `.cancel(...)`. For renderer-initiated triggering, use a dedicated IpcApi route (e.g. `knowledge.add_items`). Job DataApi is GET-only. |

### Why Misuse is Harmful

Routing non-data operations through DataApi causes concrete problems:

- **Automatic retry is dangerous for side effects**: DataApi retries failed requests with exponential backoff. Retrying a "send notification" or "restart service" operation means it executes multiple times.
- **SWR caching is meaningless for commands**: `useQuery` caches and deduplicates responses. Caching the result of "open window" or "start backup" has no value and can mask failures.
- **Layered architecture becomes hollow**: Handler → Service → SQLite is designed for data flow. Without a database layer, the Service layer becomes a pass-through wrapper with no purpose.
- **Test patterns don't match**: DataApi tests mock database operations (Drizzle queries, transactions). Side-effectful operations need entirely different test strategies (mocking external services, verifying calls).

## Zod Schema & DTO Conventions

Four rules govern every schema file under `src/shared/data/api/schemas/`. Follow them verbatim.

### A. Use `type` for `XxxSchemas` route tables

```typescript
// ✅ Adopt
export type TagSchemas = {
  '/tags': { GET: {...}; POST: {...} }
}

// ✅ With composition
export type GroupSchemas = { '/groups': {...} } & OrderEndpoints<'/groups'>

// ❌ Deprecated
export interface TagSchemas { ... }
```

**Rationale:** route tables never extend or declaration-merge; `type` supports intersection composition and eliminates `interface`/`type` mixing.

### B. Drop `Dto` from Zod schema names; keep `Dto` on TS type names

```typescript
// ✅ Adopt
export const CreateTagSchema = TagSchema.pick({ name: true, color: true })
export type CreateTagDto = z.infer<typeof CreateTagSchema>

// ❌ Deprecated
export const CreateTagDtoSchema = ...
```

**Rationale:** `CreateXxx` already signals "DTO"; `DtoSchema` is NestJS class-based convention, not Zod community practice (tRPC / Colin Hacks / Standard Schema all use `XxxSchema`). Keep `Dto` on type names to distinguish DTOs from entity types (`Tag` vs `CreateTagDto`).

**Exceptions:** value objects (`TagEntityRefSchema`) and reorder body DTOs (`ReorderGroupsSchema`) already match this rule — don't add `Dto`.

### C. Derive DTOs via `.pick()` whitelist with field atoms and `z.strictObject`

```typescript
// 1. Field atoms — share between entity, DTO, query
export const TagNameSchema = z.string().trim().min(1).max(64)
export const TagColorSchema = z.string().regex(/^#[0-9a-fA-F]{6}$/)

// 2. Entity with z.strictObject (rejects unknown fields)
export const TagSchema = z.strictObject({
  id: z.uuidv4(),
  name: TagNameSchema,
  color: TagColorSchema.nullable(),
  createdAt: z.iso.datetime(),
  updatedAt: z.iso.datetime()
})
export type Tag = z.infer<typeof TagSchema>

// 3. Create DTO — whitelist pick
export const CreateTagSchema = TagSchema.pick({ name: true, color: true })
export type CreateTagDto = z.infer<typeof CreateTagSchema>

// 4. Update DTO — chain from Create
export const UpdateTagSchema = CreateTagSchema.partial()
export type UpdateTagDto = z.infer<typeof UpdateTagSchema>
```

**Rules:**

- **Never `.omit(AutoFields)`** — adding an entity field would auto-expose it (overposting risk). Always whitelist via `.pick({...})`.
- **Always `z.strictObject`** on entity schemas — second line of defense against overposting.
- **Update derivation depends on Create's defaults**:
  - `UpdateSchema = CreateSchema.partial()` is safe **only when Create has no `.default()`**.
  - When Create carries `.default()`, derive Update from the entity directly: `UpdateSchema = EntitySchema.pick(...).partial()` — Zod v4 retains defaults through `.partial()`, and they leak into PATCH bodies otherwise (Zod issues #4799, #5642).
  - **Preferred**: keep Zod schemas free of `.default()` and own defaults at the DB or service layer. See [Default Values & Nullability](./best-practice-default-values-and-nullability.md).
- **Zod v4 gotcha:** `.pick()`/`.omit()` strip `.refine()`/`.check()` validators (working as designed, Zod discussion #4706). If entity has cross-field checks, re-attach them after pick via `.refine()` or `.safeExtend()`.

**When to write a DTO by hand instead of picking:**

1. Entity has ≤ 3 fields and no auto-managed columns — pick is noise.
2. Entity is a discriminated union — `.pick`/`.omit` don't support unions.
3. DTO type differs from entity type (e.g., entity stores `Date`, DTO takes ISO string) — reuse field atoms instead.
4. DTO-from-DTO derivation (`UpdateModelSchema = CreateModelSchema.omit({...})`) is fine — Zod officially endorses this and overposting risk doesn't apply (source is already a DTO, not the entity).

**When to extract a `XXX_MUTABLE_FIELDS` constant for `.pick(...)`:**

Extract when **both** conditions hold:

1. Create and Update DTOs share the same pick set (i.e. `UpdateSchema = CreateSchema.partial()`).
2. The pick set has ≥ 5 fields (inline spans multiple lines and hurts readability).

Otherwise inline `.pick({...})`:

- Few fields (≤ 4) — inline is a one-liner, a named constant only adds indirection (see `tags.ts`).
- Create and Update have **different** pick sets — a single `MUTABLE_FIELDS` constant would mislead readers into thinking the sets are shared; pick inline in each DTO instead (see `topics.ts`).

### D. Write every DataApi schema in Zod; no `drizzle-zod`, no pure TS `interface` DTO

All entity schemas and DTOs in `src/shared/data/api/schemas/` MUST be hand-written Zod schemas.

- **No `interface XxxDto`** — violates Electron trust-boundary validation (renderer → main IPC requires runtime validation per Electron security checklist #17).
- **No `drizzle-zod`** — the library is being deprecated in drizzle v1, and its generated schemas have TS type bugs on `.pick()`/`.omit()` that conflict with Rule C.

**Rationale:** DataApi crosses an IPC trust boundary; TS `interface` provides zero runtime defense against schema drift, mass assignment, or a compromised renderer. Zod parse cost (~25µs) is negligible compared to IPC round-trip latency. Schema-first is the industry standard (tRPC, Next.js 13+, Standard Schema Alliance).

**Response types stay as TS `interface`.** Rule D covers **entities and DTOs** — not response shapes. Responses flow `main → renderer`, the **opposite** direction of the IPC trust boundary: main constructs them from trusted state, renderer consumes them after type-checked IPC plumbing. Runtime validation on that edge is cost without security benefit. Examples that correctly stay as `interface`: `DeleteMessageResponse`, `ActiveNodeResponse`, `PersistTemporaryChatResponse`, `TreeResponse`, `BranchMessagesResponse`, `TreeNode`, `SiblingsGroup`, `BranchMessage`.

**Exception:** when a type is **both** a response payload and an entity (e.g., `Topic` is returned from `GET /topics/:id` and also represents a row in the DB), Zod-ify it as an entity per Rule C — the entity role wins.

### E. Default values do not live in Zod schemas

Avoid `.default()` on entity, Create, and Update schemas. Defaults belong at the DB layer (stable values), via Drizzle `$defaultFn` (dynamic per-row values like UUIDs / timestamps), or in the owning service (tunable product values that may evolve). Putting defaults in Zod schemas creates three problems:

| Problem | Why |
|---|---|
| Caller asymmetry | `.default()` runs at `.parse()`. Handler-driven inserts get them; seeders / internal callers don't, producing inconsistent rows. |
| Type duality | `.default()` makes `z.input` and `z.output` diverge — bodies see optional fields, services see required ones. Pairs of `…Body` / `…Dto` types proliferate to hide the gap. |
| PATCH leakage | Zod v4 retains defaults through `.partial()`, so any `UpdateSchema` derived from a `CreateSchema` with defaults materializes them on omitted PATCH fields, overwriting row state (Rule C). |

If a default truly must live in Zod (e.g., a query-string baseline like `page = 1` on `ListXxxQuerySchema`), confine it to the **specific schema** it applies to — never on the entity, Create, or Update schemas.

For the cross-layer placement decision tree, see [Default Values & Nullability](./best-practice-default-values-and-nullability.md).

## Template Path vs Hook Binding

The data hooks (`useQuery`, `useMutation`, `useInfiniteQuery`, `usePaginatedQuery`) accept two equivalent ways to supply path parameters. They produce byte-for-byte identical SWR cache keys, but suit different call-site shapes.

| Form | Use when |
|---|---|
| Concrete path — `useQuery(providerPath(id))` | The id is stable in the caller's scope (props, hook arg, closed over in a single component) |
| Template path — `useQuery('/providers/:providerId', { params: { providerId: id } })` | One hook instance operates on multiple ids over its lifetime (sidebar actions, command palette, URL handlers) |

Pick based on **where the id comes from**, not personal preference:

- `<ProviderSettings providerId={id}>` — id is stable → concrete path (`providerPath(id)`). Template form would add typing noise (`params` on every trigger) without benefit.
- `useProviderActions()` hook exposing `deleteProviderById(id)` — id varies per call → template path. The alternative would be dropping back to imperative `dataApiService.delete(...)` and hand-rolling `invalidate(...)`, which loses `isLoading` / declarative refresh / optimistic rollback.

Don't mix both forms for the same resource inside one module — although cache keys are identical, readers have to hold two mental models. Pick one and stay consistent.

**Concurrent trigger caveat**: a single template `useMutation` instance shares `isMutating`/`error` across all `params`. For true concurrent writes on different ids (e.g., deleting multiple rows in parallel), mount one hook per row bound to a concrete path. See [DataApi in Renderer → Concurrent trigger caveat](./data-api-in-renderer.md#caveat-concurrent-trigger-on-template-usemutation).

## Matcher Semantics: Cache vs DataApi

Cherry Studio has two cache layers with different key shapes and different invalidation matchers. They look similar but **are not interchangeable**:

| Layer | Key shape | Match syntax | Example |
|---|---|---|---|
| **Cache** (`useCache`, `useSharedCache`) | Schema-defined: fixed key or template with `${placeholder}` segments (see [cache-schema-guide.md](./cache-schema-guide.md)) | Concrete key → exact match; template key in `subscribeSharedChange` → regex compiled from template, fires per concrete instance | `subscribeSharedChange('web_search.provider.last_used_key.${providerId}', cb)` |
| **DataApi** (`useQuery`, `useMutation` refresh, `useInvalidateCache`) | `[path, query?]` tuple with REST-style paths | Exact string match on `key[0]` with optional `/*` prefix | `refresh: ['/providers', '/providers/*']` |

Why the two differ:

- **Cache keys are schema-constrained and dot-separated**: `web_search.provider.last_used_key.google`. Template subscription uses a regex derived from the template (each `${}` → `[\w\-]+`) so a single subscription covers every concrete instance, including ones registered at runtime.
- **DataApi keys mirror REST resource paths**: `['/providers/abc', { limit: 10 }]`. The structure is rigid (it maps to server routes), so a simple exact-or-prefix matcher is enough and more predictable than regex.

**Implication for reviewers**:

- Don't copy a `${}` template from a cache key into `refresh` options. `refresh: ['/providers/${providerId}/*']` is a bug — the `${}` is left as a literal string, not interpolated. Use template literal backticks (`` `/providers/${providerId}/*` ``) or compute the key in the function-form refresh.
- Cache same-value writes short-circuit via `isEqual` from es-toolkit/compat (no broadcast, no subscriber fire). DataApi `refresh` has no such short-circuit — each call triggers a refetch.
