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 modellethe-web-ui-foundation.md (#4, ✓ Reviewed) — palette skeleton, Settings stub, Session adapterlethe-web-ui-aggregates.md (#5, ✓ Reviewed) — /projects endpoint and useProjects hooklethe-search-and-opencode.md (#3, deferred) — owner of /api/v1/search; this task's saved-searches dead-end at the /search stub until #3 shipslethe-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 downlethe-oidc-stub.md (#10, ✓ Reviewed) — provides the dev OP for browser-smoke walks of this taskExtend 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.
Backend — saved-searches resource (additive)
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.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).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=* 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)
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)JumpItem interface widens to PaletteItem = JumpItem | ProjectItem | SessionItem | SavedItem. All four share { kind, label, hint?, ... }; per-kind extra fields carry the navigation payload.String.includes over each item's label.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).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.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.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.
All changes additive:
0002_saved_searches.up.sql is forward-only; 0002_saved_searches.down.sql provided for symmetry. No schema modification of existing tables./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.
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.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).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./api/v1/saved-searches/* routes ignore ?owner= and read owner exclusively from the auth context.(owner, name) is unique; duplicate names per owner produce 409 CONFLICT, never silent overwrite./projects or /sessions; the recent-50 lists come from those endpoints unchanged./settings remains a single route in this task; no sub-routes (/settings/saved-searches, /settings/display) are introduced.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.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./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.useSessions/useProjects carry over without surprises to the four new saved-search hooks./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.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.
saved_searches table + domain package + mountinternal/platform/database/migrations/0002_saved_searches.up.sql (create) — single statement, IV1:
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);
WITHOUT ROWID matches the access pattern (composite PK is the read path).internal/platform/database/migrations/0002_saved_searches.down.sql (create) — DROP INDEX … ; DROP TABLE saved_searches;. Order reversed from up.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.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.apierror.Render (RFC 7807).clampLimit/clampOffset (no pagination — list is small and owner-scoped; avoids a third copy of the helpers per GPC8).internal/domain/savedsearch/repository_test.go (create) — TDD targets, in-memory SQLite via the existing test harness:
List returns [] non-nilCreate then List → row roundtrips (created_at == updated_at == injected now)Create twice with same (owner, name) → second returns CONFLICT-coded errorCreate with same name but different owner → both rows present (IV3)Update non-existent (owner, name) → NOT_FOUNDUpdate rename onto an existing (owner, name) → CONFLICTUpdate query only (newName=nil) → name unchanged, query updated, updated_at advancesDelete non-existent → NOT_FOUND; existing → no error and absent on subsequent ListList order: rows returned by updated_at DESC (insert three with staggered now, assert order).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 VALIDATIONPOST /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 VALIDATIONPOST /saved-searches valid → 201 with rowPOST /saved-searches duplicate → 409 CONFLICT with problem+jsonPUT /saved-searches/missing → 404PUT /saved-searches/x {name:"y"} when y exists → 409DELETE /saved-searches/x → 204; subsequent DELETE → 404internal/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.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.savedsearch: add /api/v1/saved-searches CRUD with 0002 migrationSession.sessionId carry-over + SavedSearch DTOweb/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}`` — unchangedsessionId: d.session_id — new (IV6).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().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).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.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 updatedAtadaptSavedSearch passes name and query through unchangedweb: adapter — add Session.sessionId, SavedSearch DTO; fix composite-id call sites/settingsweb/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.useSessions/useProjects).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.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.Elementdisabled 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).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.name and query + save button); per-row inline edit toggle that swaps the row to two inputs + save/cancel.CONFLICT, 400 VALIDATION) surfaces as a small inline message under the row (read error.detail from APIError).<EmptyState glyph="∅" copy="no saved searches yet" />.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 constuseState<'saved-searches' | 'display'>('saved-searches') — local state; no sub-routes (IV5).<SectionRail> left, <SavedSearchesSection> right when active is saved-searches.<Tag kind="neutral">settings</Tag> (carry from existing).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).web: sectioned /settings with saved-searches CRUDweb/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{ 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 }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.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).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)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).web/src/styles/palette.css (modify) — add .palette-group-head rule (font-size: 11px, uppercase, muted color, padding). One small additive block.web: palette items — projects, sessions, saved searchesrepository_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).String.includes, dominated by visual feel; manual smoke walk covers it.adaptSavedSearch, SavedSearch types).Session.sessionId) and PH3 (useSavedSearches).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.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.down deletes them; that's the documented contract for a standalone resource.interface Session { … sessionId: string } — bare session id field added to the adapter, consumed by palette session-item activate.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.GET /api/v1/saved-searches returns { saved_searches: SavedSearchDTO[] } (owner derived from auth, IV2) — produced by PH1, consumed by PH3's hooks.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).
Restating Design's "all changes additive" with concrete phases:
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)./api/v1/saved-searches; no existing consumer reads them.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./settings stub (<EmptyState glyph="∅" copy="coming in a later task" />); no deployed user is affected.Palette.tsx itself.No removed / renamed JSON fields anywhere. No schema modifications to existing tables. Greenfield modulo the additive Session.sessionId adapter field.
Outcome: shipped — dcafcb2..HEAD (HEAD = 8d80e87); 9 commits across PH1–PH4, two review-fix commits, three doc commits.
Invariants:
0002_saved_searches.up.sql has no FTS / triggers / FK; savedsearch.Repository only writes to saved_searches (grep on the diff is empty).?owner= is silently ignored on read (TestHandler_List_OwnerParamIgnored) and rejected as INVALID on every write path (TestHandler_WritePaths_OwnerParamRejected covers POST/PUT/DELETE). Reviewer found the DELETE gap (Important #2); fix in 8d80e87 plus consistency-pass extension to all three write paths.Create maps PK collision to CONFLICT via isSQLiteConstraint; Update rename collision tested at TestHandler_Update_RenameConflict.useProjects/useSessions unchanged; no new query parameters added (grep on Palette.tsx)./settings remains a single route; find web/src/routes -name 'settings*' → only routes/settings.tsx.Session.id (composite) and Session.sessionId (bare) coexist; URL-path uses (routes/index.tsx:48, routes/project.$.tsx:33, Palette.tsx:137) all pass s.sessionId; React key in SessionsTable.tsx:61 keeps composite intentionally.validateName enforces non-empty, ≤64 chars, no / on Create + Update; six handler tests cover the cases..slice(0, 50) (Palette.tsx:95); projects list is capped server-side by the existing 50-item default limit (functionally equivalent — reviewer noted but did not flag).invalidateQueries({ queryKey: ['saved-searches'] }) on success; cache-invalidation test useSavedSearches.test.ts verifies the round-trip.validateName enforces ≤64 chars; chosen as a defensive default for "label" UX. No UI evidence point surfaced during plan or execute that argued for a different cap.★ save button on /search (#7) is explicitly that task's design call. This task creates saved searches only from /settings per design.enabled: open and staleTime: 30_000 (plan 4.2 explicitly required both as RK2's mitigation). Without them, default TanStack Query v5 staleTime: 0 plus refetch-on-focus/reconnect/mount caused refetch storms on every parent re-render even when the palette was closed. Fixed in 6971b2d by extending useProjects/useSessions/useSavedSearches with an optional { enabled?, staleTime? } second parameter; existing call sites unchanged (parameter is optional).Delete handler did not reject ?owner=, breaking the consistency of the IV2 write-path rejection contract that Create and Update enforced. No test guarded any of the three write paths' rejections. Fixed in 8d80e87: added the same guard to Delete, plus a consistency-pass extension that adds TestHandler_WritePaths_OwnerParamRejected covering all three verbs.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.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.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.savedsearch.Repository/savedsearch.Handler in the e2e test's local steward graph (cmd/lethe/main_e2e_test.go): PH1 added a *savedsearch.Handler inject:"" field on Server and updated cmd/lethe/main.go accordingly, but missed the sibling main_e2e_test.go which constructs its own steward graph that mirrors main.go. TestEndToEnd_MultiUserIsolation panicked at Inject with failed to find dependency: target=server.Server targetField=SavedSearches dependencyType=*savedsearch.Handler. Verify-driven fix-up commit 964d802 adds the import and two MustServiceAsset lines, restoring the e2e test. Filed as a deviation because PH1's plan named only cmd/lethe/main.go for steward registration and the consistency sweep should have grepped for MustServiceAsset siblings before commit.usePaletteData() if the prefetch pattern grows a third caller. Today three call sites pass { enabled, staleTime } to three independent hooks; the duplication is below the rule-of-three threshold. If a fourth surface (e.g. a quick-switcher) wants the same prefetch shape, fold it into one hook.★ save button on /search (UK2) — owned by #7, not this task.Result: passed
Positive:
go test ./internal/domain/savedsearch/... -race -count=1 → ok (22 tests)go test ./internal/... -count=1 → ok (all packages)go test ./cmd/lethe/... -count=1 → ok after fix-up commit 964d802 (initial run panicked: see Deviations)go build ./cmd/lethe/ → 32 MB binarycd web && npm test -- --run → 50 passed across 4 filescd web && npm run build → 376 modules, 491 KB JS / 150 KB gzip; dist/index.html matches tracked artifactNegative:
TestHandler_Create_SlashInNameReturns400 covers POST {name:"a/b"} → 400 VALIDATIONTestHandler_Create_DuplicateReturns409 covers second POST same (owner, name) → 409 CONFLICTTestHandler_Delete_Sequence covers second DELETE → 404 NOT_FOUNDInvariants / assumptions:
grep -iE 'TRIGGER|FOREIGN KEY|FTS|REFERENCES' 0002_saved_searches.up.sql → emptyauth.MustIdentity via ownerOf (handler.go:46-48); ?owner= on writes is rejected as INVALID, on reads is silently ignored — TestHandler_List_OwnerParamIgnored asserts the read pathPRIMARY KEY (owner, name) in 0002 migrationPalette.tsx consumes useProjects/useSessions unchanged; no new query params constructed in either hook; AS1 cap is client-side .slice(0, 50)find web/src/routes -name 'settings*' → only routes/settings.tsx; no sub-route filesSession.id (composite, ${tool}/${host}/${session_id}) and Session.sessionId (bare) both populated in adaptSession; SessionsTable.tsx:61 keeps s.id as React key (unique cross-tool/host); routes/index.tsx:48, routes/project.$.tsx:33, Palette.tsx:137 all use s.sessionId for params.idvalidateName (handler.go:54-72): name == "" → 400, len(name) > 64 → 400, strings.Contains(name, "/") → 400. Called on Create + Update.Palette.tsx:141 → navigate({ to: '/search', search: { q: item.query } }); /search is the foundation stub, no execution wiredPalette.tsx:95 caps sessions list with .slice(0, 50) before mapping to SessionItemInterfaces:
Session.sessionId: string declared at adapters.ts:23, populated at :40, consumed by Palette.tsx:32, 98, 101, 137; bare id never replaced by composite anywhere on the URL pathuseSavedSearches exported from web/src/features/settings/useSavedSearches.ts:9, consumed at Palette.tsx:5, 78 and SavedSearchesSection.tsxGET /api/v1/saved-searches returns { saved_searches: [SavedSearch] } (top-level key); Owner field omitted via json:"-" (IV2). TestHandler_List_OwnerFieldAbsentInJSON asserts the JSON shape; frontend useSavedSearches.ts:8, 16 consumes the data.saved_searches key.Notes:
/settings saved-search CRUD and the widened palette were not exercised in a real browser. tsc + vitest + the production bundle are clean and the backend is verified by handler tests, but a full Chrome walk requires the running server + an authenticated session. Task #10 (lethe-oidc-stub, ✓ Reviewed) ships the cmd/oidc-stub binary that unblocks this, but standing it up and driving the UI is out of reach in autonomous mode without user input. Recommended walk: (a) start lethe with the OIDC dev stub config, (b) sign in, (c) ⌘K and confirm projects/sessions/saved-search items appear in fixed group order, (d) /settings and create / rename / delete a saved search, (e) press a saved-search row in the palette and confirm it lands on /search?q=… (stub).964d802 — PH1 missed cmd/lethe/main_e2e_test.go's steward graph; recorded under Conclusion → Deviations from plan.