From 1201c57a51d8c67d932833ec2654d082047ea8b7 Mon Sep 17 00:00:00 2001 From: "ashlee@vellum.ai" Date: Mon, 1 Jun 2026 21:38:25 +0000 Subject: [PATCH 1/6] fix(web): gate conversation queries on org-store hydration (LUM-2114) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Conversation list queries fired as soon as assistantId was truthy, without checking whether the organization context was available. In platform mode, the HeyAPI request interceptor reads Vellum-Organization-Id from the org store (with a sessionStorage fallback). On a fresh session (first visit, incognito, cleared data), neither source has a value until fetchOrganizations() completes — producing a headerless request that Django rejects with 400. Add a useHasOrgContext() hook that gates the three conversation query hooks (list, archived, groups) on org-store readiness in platform mode. In local/self-hosted mode, the interceptor uses Bearer auth instead, so no gate is needed. Also fix pre-existing type errors in GlobalSearchResponse — the generated type resolved to { [key: string]: unknown }, leaving consumers untyped. Replace with an explicit interface matching the daemon's actual response shape. Fixes VELLUM-ASSISTANT-WEB-14 Closes LUM-2114 --- .../web/src/domains/chat/api/global-search.ts | 40 +++++++++++++++++-- apps/web/src/hooks/conversation-queries.ts | 26 ++++++++++-- 2 files changed, 59 insertions(+), 7 deletions(-) diff --git a/apps/web/src/domains/chat/api/global-search.ts b/apps/web/src/domains/chat/api/global-search.ts index de97c45e8c5..4960f91410b 100644 --- a/apps/web/src/domains/chat/api/global-search.ts +++ b/apps/web/src/domains/chat/api/global-search.ts @@ -1,12 +1,44 @@ import { searchGlobalGet } from "@/generated/daemon/sdk.gen"; -import type { SearchGlobalGetResponse } from "@/generated/daemon/types.gen"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- -/** Search results grouped by category, as returned by the daemon. */ -export type GlobalSearchResponse = SearchGlobalGetResponse["results"]; +/** + * Search results grouped by category, as returned by the daemon. + * + * The generated `SearchGlobalGetResponse["results"]` type resolves to + * `{ [key: string]: unknown }` because the OpenAPI spec uses + * `additionalProperties`. This interface captures the actual shape so + * consumers get proper type safety. + */ +export interface GlobalSearchResponse { + conversations: Array<{ + id: string; + title: string | null; + excerpt?: string; + updatedAt?: number; + matchCount?: number; + }>; + memories: Array<{ + id: string; + content: string; + }>; + schedules: Array<{ + id: string; + name: string; + expression?: string; + message?: string; + enabled?: boolean; + nextRunAt?: number | null; + }>; + contacts: Array<{ + id: string; + displayName: string; + notes?: string | null; + lastInteraction?: number | null; + }>; +} // --------------------------------------------------------------------------- // API @@ -48,7 +80,7 @@ export async function searchGlobal( return EMPTY_RESULTS; } - return data.results; + return data.results as unknown as GlobalSearchResponse; } catch (err) { // AbortError is expected when debounced queries supersede each other. if (err instanceof DOMException && err.name === "AbortError") { diff --git a/apps/web/src/hooks/conversation-queries.ts b/apps/web/src/hooks/conversation-queries.ts index ce4620ae2f5..50c6bcbefc3 100644 --- a/apps/web/src/hooks/conversation-queries.ts +++ b/apps/web/src/hooks/conversation-queries.ts @@ -49,6 +49,8 @@ import { assertHasResponse, extractErrorMessage, } from "@/utils/api-errors"; +import { isLocalMode } from "@/lib/local-mode"; +import { useOrganizationStore } from "@/stores/organization-store"; import { archivedConversationsQueryKey, conversationsQueryKey, @@ -207,6 +209,21 @@ export async function listArchivedConversations( const QUERY_STALE_TIME_MS = 30_000; +/** + * Platform-mode daemon requests require the `Vellum-Organization-Id` + * header, which the HeyAPI request interceptor reads from the org store + * (with a sessionStorage fallback). On a fresh session neither source + * has a value until `fetchOrganizations()` completes — firing the query + * before that produces a headerless request that Django rejects with 400. + * + * Returns `true` when local/self-hosted (org header not needed) or when + * the org store has been populated. + */ +function useHasOrgContext(): boolean { + const currentOrgId = useOrganizationStore.use.currentOrganizationId(); + return isLocalMode() || currentOrgId != null; +} + /** * Subscribe to the conversation list for the given assistant. * @@ -236,10 +253,11 @@ export function useConversationListQuery( error: Error | null; refetch: () => void; } { + const hasOrgContext = useHasOrgContext(); const query = useQuery({ queryKey: conversationsQueryKey(assistantId), queryFn: () => listConversations(assistantId!), - enabled: enabled && Boolean(assistantId), + enabled: enabled && Boolean(assistantId) && hasOrgContext, staleTime: QUERY_STALE_TIME_MS, }); return { @@ -274,10 +292,11 @@ export function useArchivedConversationListQuery( error: Error | null; refetch: () => void; } { + const hasOrgContext = useHasOrgContext(); const query = useQuery({ queryKey: archivedConversationsQueryKey(assistantId), queryFn: () => listArchivedConversations(assistantId!), - enabled: enabled && Boolean(assistantId), + enabled: enabled && Boolean(assistantId) && hasOrgContext, staleTime: QUERY_STALE_TIME_MS, }); return { @@ -301,12 +320,13 @@ export function useConversationGroupsQuery( assistantId: string | null, enabled: boolean = true, ): { conversationGroups: ConversationGroup[]; isLoading: boolean } { + const hasOrgContext = useHasOrgContext(); const query = useQuery({ ...groupsGetOptions({ path: { assistant_id: assistantId ?? "" }, } as Options), select: (data) => data.groups, - enabled: enabled && Boolean(assistantId), + enabled: enabled && Boolean(assistantId) && hasOrgContext, staleTime: QUERY_STALE_TIME_MS, }); return { From bd53e573fbc7e12e2d68c09d79c2c11eb8f60b48 Mon Sep 17 00:00:00 2001 From: "ashlee@vellum.ai" Date: Mon, 1 Jun 2026 21:45:17 +0000 Subject: [PATCH 2/6] fix(web): gate self-hosted sessions + fix memories type schema Address review feedback: - Include hasPlatformSession in useHasOrgContext so self-hosted/gateway- auth assistants (no platform session) are not blocked by the org gate. - Fix GlobalSearchResponse.memories to match the daemon's actual schema (kind, text, subject, confidence, updatedAt, source) instead of the incorrect {id, content} placeholder. --- apps/web/src/domains/chat/api/global-search.ts | 7 ++++++- apps/web/src/hooks/conversation-queries.ts | 12 +++++++++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/apps/web/src/domains/chat/api/global-search.ts b/apps/web/src/domains/chat/api/global-search.ts index 4960f91410b..37d383a34b7 100644 --- a/apps/web/src/domains/chat/api/global-search.ts +++ b/apps/web/src/domains/chat/api/global-search.ts @@ -22,7 +22,12 @@ export interface GlobalSearchResponse { }>; memories: Array<{ id: string; - content: string; + kind: string; + text: string; + subject: string | null; + confidence: number; + updatedAt: number; + source: "lexical" | "semantic"; }>; schedules: Array<{ id: string; diff --git a/apps/web/src/hooks/conversation-queries.ts b/apps/web/src/hooks/conversation-queries.ts index 50c6bcbefc3..f2996ea1742 100644 --- a/apps/web/src/hooks/conversation-queries.ts +++ b/apps/web/src/hooks/conversation-queries.ts @@ -50,6 +50,7 @@ import { extractErrorMessage, } from "@/utils/api-errors"; import { isLocalMode } from "@/lib/local-mode"; +import { useAuthStore } from "@/stores/auth-store"; import { useOrganizationStore } from "@/stores/organization-store"; import { archivedConversationsQueryKey, @@ -216,12 +217,17 @@ const QUERY_STALE_TIME_MS = 30_000; * has a value until `fetchOrganizations()` completes — firing the query * before that produces a headerless request that Django rejects with 400. * - * Returns `true` when local/self-hosted (org header not needed) or when - * the org store has been populated. + * Returns `true` when: + * - local mode (gateway auth, no org header needed), or + * - no platform session (self-hosted/gateway-auth assistant — the + * interceptor rewrites to the user's gateway with Bearer auth and + * the org store intentionally stays empty), or + * - the org store has been populated. */ function useHasOrgContext(): boolean { const currentOrgId = useOrganizationStore.use.currentOrganizationId(); - return isLocalMode() || currentOrgId != null; + const hasPlatformSession = useAuthStore.use.hasPlatformSession(); + return isLocalMode() || !hasPlatformSession || currentOrgId != null; } /** From 3c11e5492112e4339d41ee0a11dce329e337566e Mon Sep 17 00:00:00 2001 From: "ashlee@vellum.ai" Date: Mon, 1 Jun 2026 22:03:05 +0000 Subject: [PATCH 3/6] =?UTF-8?q?refactor:=20rename=20useHasOrgContext=20?= =?UTF-8?q?=E2=86=92=20useIsOrgReady,=20gate=20all=205=20query=20hooks,=20?= =?UTF-8?q?fix=20GlobalSearchResponse=20types?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename useHasOrgContext to useIsOrgReady (clearer — readiness gate, not React Context) - Add isOrgReady gate to useBackgroundConversationListQuery and useScheduledConversationListQuery for consistency with the other 3 hooks - Fix GlobalSearchResponse interface to match daemon Zod schemas exactly: fields that the daemon marks required (excerpt, updatedAt, matchCount on conversations; expression, message, enabled on schedules; notes, lastInteraction on contacts) are no longer optional - Update JSDoc to reference HeyAPI codegen as the root cause (not additionalProperties in the spec — the spec is correct) Closes LUM-2114 --- .../web/src/domains/chat/api/global-search.ts | 25 ++++++++++--------- apps/web/src/hooks/conversation-queries.ts | 20 ++++++++------- 2 files changed, 24 insertions(+), 21 deletions(-) diff --git a/apps/web/src/domains/chat/api/global-search.ts b/apps/web/src/domains/chat/api/global-search.ts index 37d383a34b7..7075e347e74 100644 --- a/apps/web/src/domains/chat/api/global-search.ts +++ b/apps/web/src/domains/chat/api/global-search.ts @@ -8,17 +8,18 @@ import { searchGlobalGet } from "@/generated/daemon/sdk.gen"; * Search results grouped by category, as returned by the daemon. * * The generated `SearchGlobalGetResponse["results"]` type resolves to - * `{ [key: string]: unknown }` because the OpenAPI spec uses - * `additionalProperties`. This interface captures the actual shape so - * consumers get proper type safety. + * `{ [key: string]: unknown }` because HeyAPI collapses the inline nested + * object despite the spec having full property definitions. This interface + * mirrors the Zod schemas in `assistant/src/runtime/routes/global-search-routes.ts` + * (lines 34–76) so consumers get proper type safety. */ export interface GlobalSearchResponse { conversations: Array<{ id: string; title: string | null; - excerpt?: string; - updatedAt?: number; - matchCount?: number; + updatedAt: number; + excerpt: string; + matchCount: number; }>; memories: Array<{ id: string; @@ -32,16 +33,16 @@ export interface GlobalSearchResponse { schedules: Array<{ id: string; name: string; - expression?: string; - message?: string; - enabled?: boolean; - nextRunAt?: number | null; + expression: string | null; + message: string; + enabled: boolean; + nextRunAt: number | null; }>; contacts: Array<{ id: string; displayName: string; - notes?: string | null; - lastInteraction?: number | null; + notes: string | null; + lastInteraction: number | null; }>; } diff --git a/apps/web/src/hooks/conversation-queries.ts b/apps/web/src/hooks/conversation-queries.ts index 32b396fb7e8..91bc1780d7f 100644 --- a/apps/web/src/hooks/conversation-queries.ts +++ b/apps/web/src/hooks/conversation-queries.ts @@ -299,7 +299,7 @@ const QUERY_STALE_TIME_MS = 30_000; * the org store intentionally stays empty), or * - the org store has been populated. */ -function useHasOrgContext(): boolean { +function useIsOrgReady(): boolean { const currentOrgId = useOrganizationStore.use.currentOrganizationId(); const hasPlatformSession = useAuthStore.use.hasPlatformSession(); return isLocalMode() || !hasPlatformSession || currentOrgId != null; @@ -335,11 +335,11 @@ export function useConversationListQuery( error: Error | null; refetch: () => void; } { - const hasOrgContext = useHasOrgContext(); + const isOrgReady = useIsOrgReady(); const query = useQuery({ queryKey: conversationsQueryKey(assistantId), queryFn: () => listConversations(assistantId!), - enabled: enabled && Boolean(assistantId) && hasOrgContext, + enabled: enabled && Boolean(assistantId) && isOrgReady, staleTime: QUERY_STALE_TIME_MS, }); return { @@ -373,10 +373,11 @@ export function useBackgroundConversationListQuery( isLoading: boolean; isPending: boolean; } { + const isOrgReady = useIsOrgReady(); const query = useQuery({ queryKey: backgroundConversationsQueryKey(assistantId), queryFn: () => listBackgroundConversations(assistantId!), - enabled: enabled && Boolean(assistantId), + enabled: enabled && Boolean(assistantId) && isOrgReady, staleTime: QUERY_STALE_TIME_MS, }); return { @@ -406,10 +407,11 @@ export function useScheduledConversationListQuery( isLoading: boolean; isPending: boolean; } { + const isOrgReady = useIsOrgReady(); const query = useQuery({ queryKey: scheduledConversationsQueryKey(assistantId), queryFn: () => listScheduledConversations(assistantId!), - enabled: enabled && Boolean(assistantId), + enabled: enabled && Boolean(assistantId) && isOrgReady, staleTime: QUERY_STALE_TIME_MS, }); return { @@ -439,11 +441,11 @@ export function useArchivedConversationListQuery( error: Error | null; refetch: () => void; } { - const hasOrgContext = useHasOrgContext(); + const isOrgReady = useIsOrgReady(); const query = useQuery({ queryKey: archivedConversationsQueryKey(assistantId), queryFn: () => listArchivedConversations(assistantId!), - enabled: enabled && Boolean(assistantId) && hasOrgContext, + enabled: enabled && Boolean(assistantId) && isOrgReady, staleTime: QUERY_STALE_TIME_MS, }); return { @@ -467,13 +469,13 @@ export function useConversationGroupsQuery( assistantId: string | null, enabled: boolean = true, ): { conversationGroups: ConversationGroup[]; isLoading: boolean } { - const hasOrgContext = useHasOrgContext(); + const isOrgReady = useIsOrgReady(); const query = useQuery({ ...groupsGetOptions({ path: { assistant_id: assistantId ?? "" }, } as Options), select: (data) => data.groups, - enabled: enabled && Boolean(assistantId) && hasOrgContext, + enabled: enabled && Boolean(assistantId) && isOrgReady, staleTime: QUERY_STALE_TIME_MS, }); return { From aa01ee972e8ec2bf521032140a3108e38673eda7 Mon Sep 17 00:00:00 2001 From: "ashlee@vellum.ai" Date: Mon, 1 Jun 2026 22:31:30 +0000 Subject: [PATCH 4/6] refactor(web): extract useIsOrgReady to shared hook, document convention - Move useIsOrgReady() from private function in conversation-queries.ts to hooks/use-is-org-ready.ts so the org-readiness gate pattern is discoverable and reusable by future daemon query hooks. - Trim the 13-line docstring to a concise 4-line description. - Add org-readiness gating section to STATE_MANAGEMENT.md with TanStack Query dependent-queries reference. --- apps/web/docs/STATE_MANAGEMENT.md | 23 +++++++++++++++++++++ apps/web/src/hooks/conversation-queries.ts | 24 +--------------------- apps/web/src/hooks/use-is-org-ready.ts | 14 +++++++++++++ 3 files changed, 38 insertions(+), 23 deletions(-) create mode 100644 apps/web/src/hooks/use-is-org-ready.ts diff --git a/apps/web/docs/STATE_MANAGEMENT.md b/apps/web/docs/STATE_MANAGEMENT.md index 2fb2a1e652c..7e5a003a196 100644 --- a/apps/web/docs/STATE_MANAGEMENT.md +++ b/apps/web/docs/STATE_MANAGEMENT.md @@ -282,6 +282,29 @@ References: - [TkDodo — Working with Zustand](https://tkdodo.eu/blog/working-with-zustand) — React Query maintainer's guidance on the boundary between server state (RQ) and client/infrastructure state (Zustand) - [Zustand — Reading/writing state outside components](https://zustand.docs.pmnd.rs/guides/reading-and-writing-state-outside-components) +### Org-readiness gating for daemon queries + +Platform-mode daemon requests require the `Vellum-Organization-Id` +header. The org store hydrates asynchronously after auth, so queries +that mount before hydration completes (e.g. conversation queries on +the eager `ChatPage` path) must gate on `useIsOrgReady()`: + +```ts +import { useIsOrgReady } from "@/hooks/use-is-org-ready"; + +const isOrgReady = useIsOrgReady(); +const query = useQuery({ + ...queryOptions, + enabled: enabled && Boolean(assistantId) && isOrgReady, +}); +``` + +Queries mounted inside `` typically don't race +because the lifecycle resolves after org hydration, but the gate is +cheap and safe to add defensively. + +Reference: [TanStack Query — Dependent Queries](https://tanstack.com/query/latest/docs/framework/react/guides/dependent-queries) + ### Canonical migration example When migrating an imperative `setTimeout`-driven fetch loop to diff --git a/apps/web/src/hooks/conversation-queries.ts b/apps/web/src/hooks/conversation-queries.ts index 91bc1780d7f..5bcc5ee17c4 100644 --- a/apps/web/src/hooks/conversation-queries.ts +++ b/apps/web/src/hooks/conversation-queries.ts @@ -56,9 +56,7 @@ import { assertHasResponse, extractErrorMessage, } from "@/utils/api-errors"; -import { isLocalMode } from "@/lib/local-mode"; -import { useAuthStore } from "@/stores/auth-store"; -import { useOrganizationStore } from "@/stores/organization-store"; +import { useIsOrgReady } from "@/hooks/use-is-org-ready"; import { archivedConversationsQueryKey, backgroundConversationsQueryKey, @@ -285,26 +283,6 @@ export async function listArchivedConversations( const QUERY_STALE_TIME_MS = 30_000; -/** - * Platform-mode daemon requests require the `Vellum-Organization-Id` - * header, which the HeyAPI request interceptor reads from the org store - * (with a sessionStorage fallback). On a fresh session neither source - * has a value until `fetchOrganizations()` completes — firing the query - * before that produces a headerless request that Django rejects with 400. - * - * Returns `true` when: - * - local mode (gateway auth, no org header needed), or - * - no platform session (self-hosted/gateway-auth assistant — the - * interceptor rewrites to the user's gateway with Bearer auth and - * the org store intentionally stays empty), or - * - the org store has been populated. - */ -function useIsOrgReady(): boolean { - const currentOrgId = useOrganizationStore.use.currentOrganizationId(); - const hasPlatformSession = useAuthStore.use.hasPlatformSession(); - return isLocalMode() || !hasPlatformSession || currentOrgId != null; -} - /** * Subscribe to the foreground conversation list for the given assistant. * diff --git a/apps/web/src/hooks/use-is-org-ready.ts b/apps/web/src/hooks/use-is-org-ready.ts new file mode 100644 index 00000000000..bf627c197d1 --- /dev/null +++ b/apps/web/src/hooks/use-is-org-ready.ts @@ -0,0 +1,14 @@ +import { isLocalMode } from "@/lib/local-mode"; +import { useAuthStore } from "@/stores/auth-store"; +import { useOrganizationStore } from "@/stores/organization-store"; + +/** + * Gate for queries that need the `Vellum-Organization-Id` header. + * Returns `true` when the org store has hydrated, or when the header + * isn't needed (local mode, no platform session). + */ +export function useIsOrgReady(): boolean { + const currentOrgId = useOrganizationStore.use.currentOrganizationId(); + const hasPlatformSession = useAuthStore.use.hasPlatformSession(); + return isLocalMode() || !hasPlatformSession || currentOrgId != null; +} From ceef65897e83e614c0d97c380c04b5aafc396209 Mon Sep 17 00:00:00 2001 From: "ashlee@vellum.ai" Date: Mon, 1 Jun 2026 22:48:01 +0000 Subject: [PATCH 5/6] =?UTF-8?q?fix(web):=20remove=20isLocalMode()=20from?= =?UTF-8?q?=20useIsOrgReady=20=E2=80=94=20redundant=20and=20buggy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isLocalMode() returned true unconditionally for Electron/local builds, bypassing the org-readiness gate even when a platform session exists (local mode with platform-hosted assistants). The interceptor still needs the Vellum-Organization-Id header for the platform path in that scenario, so the gate must wait for org hydration. The !hasPlatformSession arm already covers the no-platform-session case (pure local, self-hosted, gateway-only auth), making isLocalMode() redundant. Removing it fixes the local+platform gap without adding complexity. --- apps/web/src/hooks/use-is-org-ready.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/apps/web/src/hooks/use-is-org-ready.ts b/apps/web/src/hooks/use-is-org-ready.ts index bf627c197d1..ab6cba7778f 100644 --- a/apps/web/src/hooks/use-is-org-ready.ts +++ b/apps/web/src/hooks/use-is-org-ready.ts @@ -1,14 +1,13 @@ -import { isLocalMode } from "@/lib/local-mode"; import { useAuthStore } from "@/stores/auth-store"; import { useOrganizationStore } from "@/stores/organization-store"; /** * Gate for queries that need the `Vellum-Organization-Id` header. - * Returns `true` when the org store has hydrated, or when the header - * isn't needed (local mode, no platform session). + * Returns `true` when the org store has hydrated, or when no + * platform session exists (self-hosted / gateway-only auth). */ export function useIsOrgReady(): boolean { const currentOrgId = useOrganizationStore.use.currentOrganizationId(); const hasPlatformSession = useAuthStore.use.hasPlatformSession(); - return isLocalMode() || !hasPlatformSession || currentOrgId != null; + return !hasPlatformSession || currentOrgId != null; } From 728d5fa426c898983c29cf02a4e2e59ec7c4a205 Mon Sep 17 00:00:00 2001 From: "ashlee@vellum.ai" Date: Mon, 1 Jun 2026 23:14:06 +0000 Subject: [PATCH 6/6] docs(web): add org-readiness gating pitfall to AGENTS.md --- apps/web/AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md index 856037431a0..9e72a100603 100644 --- a/apps/web/AGENTS.md +++ b/apps/web/AGENTS.md @@ -20,6 +20,7 @@ Read these before making changes: - **Don't ship cross-route state through outlet context.** React Router outlet context [re-renders every consumer when any field changes](https://reactrouter.com/start/framework/outlet), forces a bundled value through every layout, and silently resolves to `undefined` whenever an intermediate `` (a gate, a wrapper) sits between writer and reader. Cross-route state — auth, lifecycle, selection, feature flags, layout slots — belongs in a Zustand store so consumers can subscribe atomically and so intermediate routes don't break the channel. Use outlet context only for one-shot parent→direct-child wiring with no intermediate routes. - **HeyAPI client interceptors**: The daemon, platform, and auth clients have different routing requirements. Daemon SDK requests forward unconditionally to the self-hosted gateway; platform requests use a segment allowlist. Don't share interceptors across clients with different routing needs. Interceptors [chain sequentially](https://heyapi.dev/openapi-ts/clients/fetch#interceptors) — a gate registered after a rewrite interceptor receives the *rewritten* request, so it must check the final URL, not assume platform origin. - **Type colocation**: Types live with the module that owns them. `src/types/` is only for cross-domain types with no clear owning module. Don't create `-types.ts` files to break circular dependencies — use [`import type`](https://www.typescriptlang.org/docs/handbook/modules/reference.html#type-only-imports-and-exports) instead (erased at compile time, no runtime cycle). See [`docs/CONVENTIONS.md` — Top-level shared directories](./docs/CONVENTIONS.md#top-level-shared-directories). +- **Org-readiness gating for daemon queries**: Platform-mode requests need the `Vellum-Organization-Id` header, which the interceptor reads from the org store. The store hydrates asynchronously after auth, so TanStack Query hooks that mount eagerly must gate on `useIsOrgReady()` via the [`enabled` option](https://tanstack.com/query/latest/docs/framework/react/guides/dependent-queries). See [`docs/STATE_MANAGEMENT.md` — Org-readiness gating](./docs/STATE_MANAGEMENT.md#org-readiness-gating-for-daemon-queries). When a topic in `docs/CONVENTIONS.md` grows past ~100 lines and has a coherent boundary, extract it into a `docs/TOPIC.md` sibling with a