~bigbes/lethe

d6bca49b6562e12a3491b37bd464d667d306e696 — Eugene Blikh a month ago ac7e06f
docs(lethe-web-ui-palette-savedsearch): plan + execute deviations
1 files changed, 304 insertions(+), 0 deletions(-)

A docs/tasks/lethe-web-ui-palette-savedsearch.md
A docs/tasks/lethe-web-ui-palette-savedsearch.md => docs/tasks/lethe-web-ui-palette-savedsearch.md +304 -0
@@ 0,0 1,304 @@
# lethe-web-ui-palette-savedsearch

**Status:** Designed
**Module:** `sourcecraft.dev/bigbes/lethe`
**Branch:** master
**Worktree:** none
**Parent RFC:** Personal AI Assistant Log Aggregator (2026-04-25)
**Sibling tasks:**
- `lethe-server.md` (#1, ✓ Verified) — owner of `/api/v1`, auth, error model
- `lethe-web-ui-foundation.md` (#4, ✓ Reviewed) — palette skeleton, Settings stub, `Session` adapter
- `lethe-web-ui-aggregates.md` (#5, ✓ Reviewed) — `/projects` endpoint and `useProjects` hook
- `lethe-search-and-opencode.md` (#3, deferred) — owner of `/api/v1/search`; this task's saved-searches dead-end at the `/search` stub until #3 ships
- `lethe-web-ui-search.md` (#7, blocked on #3) — search route that consumes saved-search "execute"
- `lethe-web-ui-settings-display.md` (#8, deferred) — fills the `display` slot of the Settings sidebar this task lays down
- `lethe-oidc-stub.md` (#10, ✓ Reviewed) — provides the dev OP for browser-smoke walks of this task

## Design

### Purpose

Extend the ⌘K palette beyond jump-shortcuts to surface `PROJECT` and `SESSION` items plus user-defined saved searches. Add a saved-searches storage layer (one table, four endpoints, owner-scoped). Replace the `/settings` stub with a sectioned layout that hosts saved-search CRUD UI today and leaves a populated slot for #8's Display section.

**In scope**: backend `saved_searches` table + domain package + four endpoints; palette item-type expansion (`jump | project | session | saved`); recent-50 prefetch from existing `/projects` and `/sessions`; sectioned `/settings` layout with the `Saved searches` section live and a placeholder for `Display`; one carry-over fix on the `Session` adapter to unblock palette session-navigation without re-introducing the composite-id-in-URL bug.

**Out of scope**: `/api/v1/search` and the search results UI (#3 + #7); Settings → Display (#8); palette server-side fuzzy filtering; saved-search filter fields beyond `query`; saved-search clone/duplicate UI.

### Chosen approach

**Backend — saved-searches resource (additive)**

- Migration `0002_saved_searches.up.sql`: one table `saved_searches(owner, name, query, created_at, updated_at)` with composite PK `(owner, name)` and a covering index `(owner, updated_at DESC)`. No FTS, no triggers, no foreign keys — the table is a standalone island.
- Domain package `internal/domain/savedsearch/` with `repository.go` + `repository_test.go` + `handler.go` + `handler_test.go`, mirroring the `project/` and `session/` shapes (steward injection, `Init`, `Mount`, `apierror.Render` for errors).
- Four routes mounted by `Server.Init` inside the existing `/api/v1` group:
  - `GET    /api/v1/saved-searches` — list owner's rows, ordered by `updated_at DESC`.
  - `POST   /api/v1/saved-searches` — create `{ name, query }` → 201 with row body; 409 `CONFLICT` on duplicate name; 400 `VALIDATION` on empty/over-length name or empty query.
  - `PUT    /api/v1/saved-searches/{name}` — patch `{ name?, query? }`; 404 `NOT_FOUND` if absent; 409 `CONFLICT` on rename collision; 400 `VALIDATION` on empty fields.
  - `DELETE /api/v1/saved-searches/{name}` — 204 No Content; 404 if absent.
- Owner derives exclusively from the auth context. **No `?owner=*` admin sentinel** — saved searches are intrinsically per-user; cross-owner reads have no useful semantic.

**Frontend — palette extension (no `/projects` or `/sessions` query-shape changes)**

- On `open=true`, palette fires three TanStack queries with `staleTime: 30s`:
  - `/api/v1/projects?limit=50` (recent projects by last-active)
  - `/api/v1/sessions?limit=50` (recent sessions by started-at)
  - `/api/v1/saved-searches` (all of the user's, by updated-at)
- The `JumpItem` interface widens to `PaletteItem = JumpItem | ProjectItem | SessionItem | SavedItem`. All four share `{ kind, label, hint?, ... }`; per-kind extra fields carry the navigation payload.
- Render groups in fixed order: filtered jumps → matching projects → matching sessions → matching saved searches. The inline "search '$q'" suggestion (already present) still appears at the top when query is non-empty.
- Filtering is pure client-side case-insensitive `String.includes` over each item's `label`.
- Per-kind activate:
  - `jump` — `navigate({ to: item.path })` (unchanged).
  - `project` — `navigate({ to: '/project/$', params: { _splat: cwd } })`.
  - `session` — `navigate({ to: '/session/$tool/$host/$id', params: { tool, host, id: sessionId } })` using the bare `sessionId` (see carry-over below).
  - `saved` — `navigate({ to: '/search', search: { q: query } })`. The `/search` route is a stub today; the link is a no-op-with-a-tag until #7. PC1.

**Frontend — sectioned `/settings` layout**

- `web/src/routes/settings.tsx` becomes a single-route, two-column shell: left rail with section titles, right panel rendering the active section. State is local `useState<'saved-searches' | 'display'>('saved-searches')` — no sub-routes (IV5).
- The `display` entry renders disabled with a `"in #8"` muted tag so the slot is visible but unselectable. #8 fills the panel and removes the muted tag.
- New folder `web/src/features/settings/`:
  - `SectionRail.tsx` — vertical nav primitive (~25 lines, plain divs + classnames).
  - `SavedSearchesSection.tsx` — list table + inline "new saved search" form (name + query) + per-row edit/delete buttons; mutations through TanStack Query, invalidation on success.
- API client extensions in `web/src/api/`:
  - `useSavedSearches()` — query.
  - `useCreateSavedSearch()`, `useUpdateSavedSearch()`, `useDeleteSavedSearch()` — mutations with `onSuccess: invalidate('saved-searches')`.

**Carry-over folded in (one tiny scope item)**

`web/src/api/adapters.ts` — the `Session` interface gains `sessionId: string` (bare id) alongside the existing composite `id` (kept for compat with code that already reads it). The single existing call site in `web/src/routes/index.tsx` that builds `/session/$tool/$host/$id` switches `params.id` from `s.id` (composite) to `s.sessionId` (bare). The palette session-item activate uses the same bare field. **Why fold in:** this task constructs the same `/session/$tool/$host/$id` URL the existing call site does; without the fix, the palette would either reproduce the carry-over bug or invent a parallel workaround (`s.id.split('/')[2]`). Exposing the bare field once and using it twice closes the foundation Conclusion's "Composite-id-in-URL" carry-over for free.

### Backwards-compatibility check

All changes additive:
- Migration `0002_saved_searches.up.sql` is forward-only; `0002_saved_searches.down.sql` provided for symmetry. No schema modification of existing tables.
- New endpoints under new path prefix `/api/v1/saved-searches`; no existing consumers.
- `Session.sessionId` is a new adapter field; nothing removed. Existing readers of `Session.id` keep working.
- `Settings` route replaces a stub (`<EmptyState glyph="∅" copy="coming in a later task" />`); no deployed user is affected.
- `Palette` item-union widens; no external consumer of those types beyond the file itself.

Greenfield modulo the one additive adapter field.

### TDD: yes (scoped)

- **Yes** for: `internal/domain/savedsearch/repository_test.go` (4 CRUD methods + duplicate-name + missing-name + ordering edge cases); `internal/domain/savedsearch/handler_test.go` (auth gating, all 4 routes, RFC 7807 error shapes, JSON DTO round-trip); TS `adapters.test.ts` extension covering the new `sessionId` field and the saved-search DTO round-trip; vitest for `useSavedSearches` cache-invalidation on mutation.
- **No** for: `SectionRail` and `SavedSearchesSection` chrome (visual / interactive — typecheck + a manual smoke walk via the OIDC dev stub from #10 covers feel); palette filter UX (single `String.includes`, dominated by visual feel).

### Invariants

- IV1 — `saved_searches` is a standalone island: no FTS triggers, no foreign keys, no cross-table writes; only the `saved_searches` table is touched by this task's repository.
- IV2 — All `/api/v1/saved-searches/*` routes ignore `?owner=` and read owner exclusively from the auth context.
- IV3 — `(owner, name)` is unique; duplicate names per owner produce 409 `CONFLICT`, never silent overwrite.
- IV4 — Palette adds no new query parameters to `/projects` or `/sessions`; the recent-50 lists come from those endpoints unchanged.
- IV5 — `/settings` remains a single route in this task; no sub-routes (`/settings/saved-searches`, `/settings/display`) are introduced.
- IV6 — `Session.id` (composite) and `Session.sessionId` (bare) coexist on the adapter; the only existing call site changed by this task is `web/src/routes/index.tsx` (its `navigate` switches `params.id` to `s.sessionId`). New call sites added by this task (the palette session-item activate) read `s.sessionId` directly from the start.
- IV7 — Saved-search `name` is validated server-side: non-empty, ≤ 64 chars, no `/`. The 64-char cap (UK1) and the slash exclusion are enforced in the create + update handlers and surface as 400 `VALIDATION`.

### Principles

- PC1 — Saved-search activate navigates to `/search?q=…` even though `/search` is a stub today; do not implement any search functionality in this task. **Why:** scope hygiene — task #7 owns the search route. **How to apply:** if the urge arises during execute to "make saved-search actually run a query", stop and add it to a #7 follow-up note instead.
- PC2 — Palette item ordering is kind-grouped (jumps → projects → sessions → saved), not relevance-ranked. The inline "search '$q'" suggestion sits above all groups when the query is non-empty; it is the only item that floats above the kind order. **Why:** stable order beats fuzzy ranking at personal-aggregator scale; users learn positions over a few opens. **How to apply:** when adding a new palette item kind in the future, append it to the group order, don't interleave by score; reserve the above-groups slot for the search-suggestion only.

### Assumptions

- AS1 — Top-50 recent projects + top-50 recent sessions cover the overwhelming majority of palette opens for personal-scale data (O(dozens) of projects, O(hundreds) of recent sessions). **Why this matters:** if AS1 fails, palette feels "incomplete" and option (b)/(c) live-server-filter from Question 1 becomes the right next move. **Verify in Conclusion:** report observed prefetch-coverage from a manual smoke walk.
- AS2 — TanStack Query mutation + invalidation patterns from `useSessions`/`useProjects` carry over without surprises to the four new saved-search hooks.

### Unknowns

- UK1 — Reasonable name length cap for saved searches. Locked to 64 chars in IV7; chosen as a defensive default for "label" UX. Revise during plan only if there's a UI evidence point.
- UK2 — Whether the "save current search" affordance lives on the `/search` route (#7) or is creatable only from `/settings`. Initial answer: only from `/settings` for this task — the search route doesn't exist yet to host the affordance. #7's design will decide whether to add a `★ save` button.

## Plan

Approach: four phases — PH1 lands the saved-searches resource end-to-end on the backend (migration + domain package + steward wiring) so the frontend has a real endpoint to call; PH2 ships the adapter additions (Session.sessionId carry-over + SavedSearch DTO) the rest of the frontend phases depend on; PH3 builds the API hooks + sectioned `/settings` layout; PH4 widens the palette. PH1 ⊥ PH2 (different stacks, disjoint paths); PH3 + PH4 fan out from PH2 and run on disjoint frontend paths.

### PH1 — Backend: `saved_searches` table + domain package + mount

- **1.1** `internal/platform/database/migrations/0002_saved_searches.up.sql` (create) — single statement, IV1:
  ```sql
  CREATE TABLE saved_searches (
      owner      TEXT NOT NULL,
      name       TEXT NOT NULL,
      query      TEXT NOT NULL,
      created_at INTEGER NOT NULL,
      updated_at INTEGER NOT NULL,
      PRIMARY KEY (owner, name)
  ) WITHOUT ROWID;
  CREATE INDEX idx_saved_searches_owner_updated
      ON saved_searches (owner, updated_at DESC);
  ```
  No FTS, no triggers, no FK (IV1). `WITHOUT ROWID` matches the access pattern (composite PK is the read path).
- **1.2** `internal/platform/database/migrations/0002_saved_searches.down.sql` (create) — `DROP INDEX … ; DROP TABLE saved_searches;`. Order reversed from up.
- **1.3** `internal/domain/savedsearch/repository.go` (create) — package `savedsearch`. Mirrors `internal/domain/session/repository.go` shape.
  - `type SavedSearch struct { Owner string; Name string; Query string; CreatedAt int64; UpdatedAt int64 }` with matching `db` and `json` tags (`owner` is `db` only — never marshaled to clients per IV2).
  - `type Repository struct { Database *database.Database \`inject:""\` }`
  - `func (r *Repository) Init(_ context.Context) error { return nil }`
  - `func (r *Repository) List(ctx context.Context, owner string) ([]SavedSearch, error)` — `WHERE owner = ? ORDER BY updated_at DESC`. Returns non-nil zero-length slice on empty (matches `project.Repository.List`).
  - `func (r *Repository) Create(ctx context.Context, s SavedSearch) error` — `INSERT INTO saved_searches(owner,name,query,created_at,updated_at) VALUES(?,?,?,?,?)`. On `sqlite3.ErrConstraintPrimaryKey` (or driver's UNIQUE/PK error) → `culpa.WithCode(..., "CONFLICT")` with public detail "saved search with that name already exists" (IV3).
  - `func (r *Repository) Update(ctx context.Context, owner, oldName string, newName *string, newQuery *string, now int64) (SavedSearch, error)` — single `UPDATE … WHERE owner=? AND name=?` with conditional SET clauses; `RowsAffected == 0` → `NOT_FOUND`; PK collision on rename → `CONFLICT`. Returns the post-update row via a follow-up `SELECT` in the same context (read-after-write).
  - `func (r *Repository) Delete(ctx context.Context, owner, name string) error` — `DELETE WHERE owner=? AND name=?`; `RowsAffected == 0` → `NOT_FOUND`.
  - Respects: IV1 (single-table writes), IV3 (composite PK enforces 409).
- **1.4** `internal/domain/savedsearch/handler.go` (create) — mirrors `internal/domain/session/handler.go:33-47`.
  - `type Handler struct { Repo *Repository \`inject:""\` }`
  - `Init`, `Mount(r chi.Router)` registering: `r.Get("/saved-searches", h.List)`, `r.Post("/saved-searches", h.Create)`, `r.Put("/saved-searches/{name}", h.Update)`, `r.Delete("/saved-searches/{name}", h.Delete)`.
  - `func (h *Handler) ownerOf(r *http.Request) string { return auth.MustIdentity(r.Context()).User }` — derives owner from auth identity only; `?owner=` is read but ignored on read paths and rejected as `INVALID` on write paths (IV2). Single helper, one source.
  - `validateName(name string) error` — non-empty, `len ≤ 64`, no `/` → 400 `VALIDATION` with public detail (IV7, UK1). One source for create + update.
  - `validateQuery(query string) error` — non-empty → 400 `VALIDATION`.
  - `List` writes `{ saved_searches: []SavedSearch }` (omits `Owner` field via `json:"-"` on `SavedSearch.Owner`).
  - `Create` decodes `{ name, query }`, validates, calls `Repo.Create` with `now := time.Now().Unix()` injected for both timestamps, returns `201` with the new row (re-read or constructed locally).
  - `Update` reads `{name}` from URL, decodes `{ name?, query? }` (both optional but at least one required → 400 if both nil), validates non-nil fields, calls `Repo.Update`, returns 200 with the updated row.
  - `Delete` reads `{name}` from URL, calls `Repo.Delete`, returns 204 No Content.
  - All errors flow through `apierror.Render` (RFC 7807).
  - Respects: IV2, IV3, IV7. **No** `clampLimit`/`clampOffset` (no pagination — list is small and owner-scoped; avoids a third copy of the helpers per GPC8).
- **1.5** `internal/domain/savedsearch/repository_test.go` (create) — TDD targets, in-memory SQLite via the existing test harness:
  - empty DB → `List` returns `[]` non-nil
  - `Create` then `List` → row roundtrips (created_at == updated_at == injected now)
  - `Create` twice with same `(owner, name)` → second returns `CONFLICT`-coded error
  - `Create` with same `name` but different `owner` → both rows present (IV3)
  - `Update` non-existent `(owner, name)` → `NOT_FOUND`
  - `Update` rename onto an existing `(owner, name)` → `CONFLICT`
  - `Update` query only (newName=nil) → name unchanged, query updated, updated_at advances
  - `Delete` non-existent → `NOT_FOUND`; existing → no error and absent on subsequent `List`
  - `List` order: rows returned by `updated_at DESC` (insert three with staggered now, assert order).
- **1.6** `internal/domain/savedsearch/handler_test.go` (create) — TDD targets, mirrors `internal/domain/session/handler_test.go` setup:
  - `GET /saved-searches` unauthenticated → 401 (auth middleware) — covered indirectly by reusing the test fixture; a single sanity case.
  - `GET /saved-searches` authenticated → 200 with `{ saved_searches: [] }`; `Owner` field absent in JSON.
  - `GET /saved-searches?owner=alice` (any caller) → 200, identical body to no-param call (IV2: owner param ignored on read).
  - `POST /saved-searches` `{name:"",query:"x"}` → 400 `VALIDATION`
  - `POST /saved-searches` `{name:"a/b",query:"x"}` → 400 `VALIDATION` (slash exclusion — IV7)
  - `POST /saved-searches` `{name:"<65 chars>",query:"x"}` → 400 `VALIDATION` (UK1 cap)
  - `POST /saved-searches` `{name:"x",query:""}` → 400 `VALIDATION`
  - `POST /saved-searches` valid → 201 with row
  - `POST /saved-searches` duplicate → 409 `CONFLICT` with problem+json
  - `PUT /saved-searches/missing` → 404
  - `PUT /saved-searches/x` `{name:"y"}` when `y` exists → 409
  - `DELETE /saved-searches/x` → 204; subsequent `DELETE` → 404
- **1.7** `internal/server/server.go:51-67` (modify) — add `SavedSearches *savedsearch.Handler \`inject:""\`` field; in `Init` (line 100-106) add `s.SavedSearches.Mount(r)` inside the `/api/v1` `Route` block. Import alongside existing `project`/`stats` imports.
- **1.8** `cmd/lethe/main.go:96-132` (modify) — add `savedsearchRepo := &savedsearch.Repository{}` and `savedsearchHnd := &savedsearch.Handler{}` to the registered slice and `mgr.AddComponent` block, mirroring `projectRepo`/`projectHnd` placement (between `statsHnd` and `serverSvc`). Add the import.
- Commit: `savedsearch: add /api/v1/saved-searches CRUD with 0002 migration`

### PH2 — Frontend adapter: `Session.sessionId` carry-over + SavedSearch DTO

- **2.1** `web/src/api/adapters.ts:21-34` (modify) — `Session` interface gains `sessionId: string` (bare id) alongside the existing composite `id`. Both populated by `adaptSession` (`adapters.ts:36-51`):
  - `id: \`${d.tool}/${d.host}/${d.session_id}\`` — unchanged
  - `sessionId: d.session_id` — new (IV6).
- **2.2** `web/src/api/adapters.ts` (modify, append after the Stats block) — add saved-search types:
  - `interface SavedSearchDTO { name: string; query: string; created_at: number; updated_at: number }`
  - `interface SavedSearch { name: string; query: string; createdAt: string; updatedAt: string }` (ISO)
  - `function adaptSavedSearch(d: SavedSearchDTO): SavedSearch` — ISO conversion via `new Date(unix * 1000).toISOString()`.
- **2.3** `web/src/routes/index.tsx:48` (modify) — change `params: { tool: s.tool, host: s.host, id: s.id }` to `params: { tool: s.tool, host: s.host, id: s.sessionId }` (IV6, closes the foundation Conclusion's "Composite-id-in-URL" carry-over).
- **2.4** `web/src/routes/project.$.tsx:33` (modify) — same swap as 2.3 at the project-detail row-click site. **Deviation note**: design said "single existing call site"; in the time since that doc was approved a second call site shipped in #5 (lethe-web-ui-aggregates). Fixing both is the spirit of the carry-over fold-in (per design rationale: "without the fix, the palette would either reproduce the carry-over bug or invent a parallel workaround"); fixing only one would leave a worse split than today. Documented under Deviations to flag for review.
- **2.5** `web/src/api/adapters.test.ts` (modify) — TDD. Add cases:
  - `adaptSession` populates `sessionId === d.session_id` (bare id)
  - `adaptSession` keeps `id` as the composite (regression — verify both coexist, IV6)
  - `adaptSavedSearch` round-trip: `created_at=1700000000` → `createdAt === '2023-11-14T22:13:20.000Z'`; same for `updatedAt`
  - `adaptSavedSearch` passes `name` and `query` through unchanged
- Commit: `web: adapter — add Session.sessionId, SavedSearch DTO; fix composite-id call sites`

### PH3 — Frontend: saved-search hooks + sectioned `/settings`

- **3.1** `web/src/features/settings/useSavedSearches.ts` (create) — TanStack Query hooks. Mirrors `web/src/features/projects/useProjects.ts:1-45` shape.
  - `function useSavedSearches(): UseQueryResult<SavedSearch[]>` — keyed `['saved-searches']`, calls `apiFetch<{ saved_searches: SavedSearchDTO[] }>('/api/v1/saved-searches')`, maps through `adaptSavedSearch`. `staleTime: 30_000` to match palette prefetch.
  - `function useCreateSavedSearch(): UseMutationResult<SavedSearch, Error, { name: string; query: string }>` — `POST /api/v1/saved-searches`, `onSuccess: () => queryClient.invalidateQueries({ queryKey: ['saved-searches'] })`.
  - `function useUpdateSavedSearch(): UseMutationResult<SavedSearch, Error, { oldName: string; name?: string; query?: string }>` — `PUT /api/v1/saved-searches/${encodeURIComponent(oldName)}`, same invalidation.
  - `function useDeleteSavedSearch(): UseMutationResult<void, Error, { name: string }>` — `DELETE /api/v1/saved-searches/${encodeURIComponent(name)}`, same invalidation.
  - Respects: AS2 (TanStack mutation+invalidation pattern carries over from `useSessions`/`useProjects`).
- **3.2** `web/src/features/settings/useSavedSearches.test.ts` (create) — TDD: cache-invalidation behavior. One representative case: render `useSavedSearches` and `useCreateSavedSearch` under a shared `QueryClient`; mock `apiFetch` to return `[]` initially, `[{name:'a',query:'q',…}]` after the POST; assert that after `mutate({name:'a',query:'q'})` resolves, the query result re-fetches and contains the new row. Uses `@testing-library/react` `renderHook` + `waitFor`.
- **3.3** `web/src/features/settings/SectionRail.tsx` (create) — vertical nav primitive (~25 lines, plain divs).
  - `function SectionRail<K extends string>(props: { sections: { key: K; label: string; disabled?: boolean; tag?: string }[]; active: K; onSelect: (k: K) => void }): React.JSX.Element`
  - Renders one row per section; `disabled` rows are not clickable and render the `tag` (e.g. `"in #8"`) as a muted span; the active row gets the `--accent-soft` background + 2px accent left border (matches `SessionsTable` cursor styling — GPC8 reuse).
- **3.4** `web/src/features/settings/SavedSearchesSection.tsx` (create) — list table + inline new-form + per-row edit/delete.
  - `function SavedSearchesSection(): React.JSX.Element` — uses the four hooks from 3.1.
  - States: list view (existing rows, "+ new" row with two inputs `name` and `query` + `save` button); per-row inline edit toggle that swaps the row to two inputs + save/cancel.
  - Mutation success → React Query invalidates and re-renders; inline form clears.
  - Server error (409 `CONFLICT`, 400 `VALIDATION`) surfaces as a small inline message under the row (read `error.detail` from `APIError`).
  - Empty state: `<EmptyState glyph="∅" copy="no saved searches yet" />`.
- **3.5** `web/src/routes/settings.tsx` (replace, currently `settings.tsx:1-21`) — sectioned shell.
  - `const SECTIONS = [{ key: 'saved-searches', label: 'Saved searches' }, { key: 'display', label: 'Display', disabled: true, tag: 'in #8' }] as const`
  - `useState<'saved-searches' | 'display'>('saved-searches')` — local state; **no sub-routes** (IV5).
  - Two-column layout: `<SectionRail>` left, `<SavedSearchesSection>` right when active is `saved-searches`.
  - SubBar shows `<Tag kind="neutral">settings</Tag>` (carry from existing).
- **3.6** `web/src/styles/settings.css` (create) — `.settings-shell` (two-column grid: 220px sidebar + 1fr panel), `.section-rail`, `.section-row`, `.section-row.active`, `.section-row.disabled`, `.saved-searches-table`, `.saved-search-form` rules. Imported at the top of `routes/settings.tsx` (matches the per-route import pattern set by `home.css`/`projects.css`/`stats.css`).
- Respects: IV5 (single route, no sub-routes), AS2.
- Commit: `web: sectioned /settings with saved-searches CRUD`

### PH4 — Frontend: Palette extension (jump | project | session | saved)

- **4.1** `web/src/shell/Palette.tsx:9-22` (modify) — replace the `JumpItem` interface and `JUMP_ITEMS` constant block with the wider union:
  - `type PaletteItem = JumpItem | ProjectItem | SessionItem | SavedItem`
  - All four share `{ kind; label; hint?: string }`; per-kind extras carry navigation payload:
    - `interface JumpItem  { kind: 'jump';    label; hint; path: string }` (unchanged)
    - `interface ProjectItem { kind: 'project'; label: string /* cwd */; hint?: string; cwd: string }`
    - `interface SessionItem { kind: 'session'; label: string /* `${tool} · ${host} · summary?`*/; hint?: string; tool: string; host: string; sessionId: string }` — **uses `sessionId` (IF1)**, never composite (IV6)
    - `interface SavedItem  { kind: 'saved';   label: string /* name */; hint?: string; query: string }`
- **4.2** `web/src/shell/Palette.tsx:24-47` (modify, inside `Palette`) — when `open === true`, fire three TanStack queries with `staleTime: 30_000`:
  - `useProjects({ since: 'all' })` (existing hook from `web/src/features/projects/useProjects.ts` — no new query parameter on the endpoint, IV4)
  - `useSessions({})` from `features/home/useSessions.ts` — no `cwd` filter, no new param (IV4); cap to first 50 client-side via `.slice(0, 50)` (AS1).
  - `useSavedSearches()` from PH3 (IF2).
  - `enabled: open` on each so closing the palette stops refetching.
- **4.3** `web/src/shell/Palette.tsx:49-57` (modify) — replace the single-list filter with kind-grouped filtering. Filter each list by `String.prototype.toLowerCase().includes(q.toLowerCase())` over `label`. Build `filtered = [...filteredJumps, ...filteredProjects.slice(0,50), ...filteredSessions, ...filteredSaved]` — fixed group order (PC2). When `q !== ''` and **any** group has zero matches, the existing "search '$q'" suggestion still floats above all groups (PC2).
- **4.4** `web/src/shell/Palette.tsx:59-70` (modify) — `fire(idx)` switch on the activated item's `kind`:
  - `jump` → `navigate({ to: item.path })` (unchanged)
  - `project` → `navigate({ to: '/project/$', params: { _splat: item.cwd } })`
  - `session` → `navigate({ to: '/session/$tool/$host/$id', params: { tool: item.tool, host: item.host, id: item.sessionId } })` — bare id (IV6)
  - `saved` → `navigate({ to: '/search', search: { q: item.query } })` (PC1 — `/search` is the stub from foundation, no execution wired)
- **4.5** `web/src/shell/Palette.tsx:104-135` (modify) — render groups in fixed order with a thin section header per group only when the group is non-empty (`<div className="palette-group-head muted mono">projects</div>` etc.). The existing `palette-row` markup is per-item; the `kind` span already carries the kind label (visual cue stays).
- **4.6** `web/src/styles/palette.css` (modify) — add `.palette-group-head` rule (font-size: 11px, uppercase, muted color, padding). One small additive block.
- Respects: IV4, IV6, PC1, PC2, AS1.
- Commit: `web: palette items — projects, sessions, saved searches`

### Test strategy

- PH1 TDD: every bullet in 1.5 / 1.6 is a failing test first. `repository_test.go` covers IV3 (composite PK) and IV1 (table-isolation by virtue of single-table writes). `handler_test.go` covers IV2 (owner ignored on read, derived from auth on write), IV7 + UK1 (validation cases).
- PH2 TDD: cases in 2.5 cover IV6 (both fields coexist) and SavedSearch DTO round-trip.
- PH3 TDD: only the cache-invalidation case in 3.2 (per Design's TDD scope: cache-invalidation tests on, layout chrome off).
- PH4 no TDD: filter UX is `String.includes`, dominated by visual feel; manual smoke walk covers it.

### Order & dependencies

- PH1 ⊥ PH2 (Go vs TS, disjoint paths).
- PH3 depends on PH2 (`adaptSavedSearch`, `SavedSearch` types).
- PH4 depends on PH2 (`Session.sessionId`) and PH3 (`useSavedSearches`).
- PH3 + PH4 also runtime-depend on PH1's endpoints — typecheck-independent, but smoke-testing them needs PH1 in place.
- Sequence: PH1 → PH2 → PH3 → PH4. PH1 and PH2 may run in parallel since their paths are disjoint.

### Risks / rollback

- **RK1** — PH1's `Update` rename SQL relies on the SQLite driver surfacing PK-collision as a constraint error code that `culpa.WithCode(..., "CONFLICT")` translates correctly. Mitigation: the `repository_test.go` `Update`-rename-collision case in 1.5 fails fast if the error mapping is off; existing `session.Repository.Get` already maps SQLite errors via the same pattern.
- **RK2** — PH4's three-query prefetch on every palette open could refetch faster than `staleTime` if React StrictMode double-mounts (dev only). Mitigation: `staleTime: 30_000` plus `enabled: open` means `open=false` disables the queries entirely; tested by walking the palette open/close cycle.
- **RK3** — Migration rollback: 0002 down drops the table outright. Acceptable because the design declares saved-searches a "standalone island" (IV1) and there is no data we can't reconstruct. If a user has populated saved-searches in prod, `down` deletes them; that's the documented contract for a standalone resource.

### Interfaces

- IF1 — `interface Session { … sessionId: string }` — bare session id field added to the adapter, consumed by palette session-item activate.
- IF2 — `useSavedSearches(): UseQueryResult<SavedSearch[]>` (and three mutation hooks) exported from `web/src/features/settings/useSavedSearches.ts` — produced by PH3, consumed by PH4 for palette saved-search prefetch.
- IF3 — `GET /api/v1/saved-searches` returns `{ saved_searches: SavedSearchDTO[] }` (owner derived from auth, IV2) — produced by PH1, consumed by PH3's hooks.

### Interface graph

- PH1                  -> IF3                     @ internal/domain/savedsearch/, internal/platform/database/migrations/, internal/server/server.go, cmd/lethe/main.go
- PH2                  -> IF1                     @ web/src/api/adapters.ts, web/src/api/adapters.test.ts, web/src/routes/index.tsx, web/src/routes/project.$.tsx
- PH3  IF3             -> IF2                     @ web/src/features/settings/, web/src/routes/settings.tsx, web/src/styles/settings.css
- PH4  IF1, IF2        ->                         @ web/src/shell/Palette.tsx, web/src/styles/palette.css

Wave check: PH1 and PH2 share no paths and consume no shared IF — they form Wave 1. PH3 forms Wave 2 (consumes IF3). PH4 forms Wave 3 (consumes IF1, IF2).

### Backwards-compat check

Restating Design's "all changes additive" with concrete phases:

- PH1 migration `0002_saved_searches.up.sql` is forward-only on a new table; no modification of any existing table. `down.sql` provided for symmetry; data loss on rollback is the documented contract for an island resource (IV1).
- PH1 endpoints live under a new path prefix `/api/v1/saved-searches`; no existing consumer reads them.
- PH2 adds `Session.sessionId` (additive); existing readers of `Session.id` continue to work (IV6 — both coexist). Two call-site swaps (`routes/index.tsx`, `routes/project.$.tsx`) replace a known buggy URL shape with the correct one — strictly an improvement; no rollback regression.
- PH3 replaces the `/settings` stub (`<EmptyState glyph="∅" copy="coming in a later task" />`); no deployed user is affected.
- PH4 widens the palette item union; no external consumer of those types beyond `Palette.tsx` itself.

No removed / renamed JSON fields anywhere. No schema modifications to existing tables. Greenfield modulo the additive `Session.sessionId` adapter field.

## Conclusion

### Deviations from plan

- **PH2 — second `Session.id` call site also fixed (`web/src/routes/project.$.tsx:33`)**: Plan bullet 2.4 already flagged this. Design said "single existing call site"; a second call site shipped in #5 (lethe-web-ui-aggregates) between design approval and execute. User explicitly approved option 1 (fix both) before dispatch. Both call sites now pass `s.sessionId` to `params.id`; React `key` in `SessionsTable.tsx:61` keeps `s.id` (composite) intentionally — IV6's "both coexist" exists for exactly this asymmetry.
- **PH3 — `apiFetchVoid` helper added in `web/src/api/client.ts`** (outside PH3's declared Owns set): backend DELETE `/api/v1/saved-searches/{name}` returns 204 No Content; the existing `apiFetch<T>` calls `resp.json()` unconditionally and would throw `SyntaxError` on an empty body. The dispatcher's pre-flight authorized this exact helper so the foundation invariant ("all API calls flow through `apiFetch`/its sibling — no raw `fetch` outside `client.ts`") stays intact. Single new exported function; mirrors `apiFetch` except it skips JSON decode and returns `void`.
- **PH3 — `internal/server/web/dist/index.html` regen included in the commit** (outside PH3's declared Owns set): Vite stamps content-hash bundle filenames; `npm run build` rewrote `dist/index.html` to point at the new asset hashes. #5's verify recorded the same artifact as a follow-up commit (`f6611d7`); catching it in-phase here is the corrected pattern.