Skip to main content
The tenant console (src/app/(console)/) is the client-facing product: Home, Loops, Issues, Remediation, Sources, and Settings, all wrapped by ConsoleLayout (src/app/(console)/layout.tsx) behind the cl_tenant_session cookie. Every data call in this surface goes through the same session-gated proxy documented in Proxy and sessions; this page maps each call to the component that makes it and what the UI does with the result.

Sign-in surfaces

A 401 from POST /accept-invite does not mean the invitation itself failed. The backend accepts the invitation (creates the account, marks it accepted) and only then attempts to auto-login the new user — and that auto-login step requires the tenant to already be live. The frontend’s error handling reflects this exactly: “Your account was created, but this workspace isn’t live yet” rather than a generic failure message.
ConsoleLayout itself (a server component) is the auth gate for every route under (console): it reads cl_tenant_session, fetches ${BACKEND_URL}/me directly with that cookie attached, and redirects to /login if either the cookie is missing or the fetch fails. On success it builds the console’s Session object (src/lib/session.ts) from the returned TenantIdentity:
mapPermissionsToWorkspaceRole() (src/lib/api/tenantAuth.ts) turns the backend’s real permission strings into one of four display roles, checked in this exact priority order: users.manageRisk Admin, sources.manageLoop Owner, reviews.writeAnalyst, else → Auditor. Two gates in src/lib/session.ts derive from that role by rank (Auditor < Analyst < Loop Owner < Risk Admin):
  • canReview(session) — rank ≥ Analyst. Gates every remediation mutation (assign owner, change status, mark a step done, adopt a suggested plan, submit a loop decision).
  • canOperate(session) — rank ≥ Loop Owner. Gates source creation/upload in SourcesClient.
The backend is the actual authority for every one of these actions — require_permission(...) dependencies on each mutating route (see services/api/remediation_api.py, services/api/sources_api.py) reject an unauthorized call independently of anything the console decides to render. canReview/canOperate only control whether a button is enabled or disabled; they are not the security boundary.

The insights snapshot

Every report-backed view (Home, Loops, Issues, Remediation) is built on one shared hook: useConsoleData() (src/lib/data/useConsoleData.ts), which wraps a single query: The response carries all nine insight collections in one coherent read — five deterministic, model-derived collections (MODEL_COLLECTIONS: summary, themes, issues, severity, lens) and four of the five LLM-derived collections the frontend’s LLM_COLLECTIONS constant lists (fishbone, theme_summaries, narratives, recommendations) — plus dataset_version, model_version, model_ready, all_ready, and share_onboarding_outputs. The backend chooses a single (dataset_version, model_version) pair before returning any collection, specifically so one render can never mix results from two different pipeline runs. LLM_COLLECTIONS itself has a fifth entry, cause_remediation, but services/api/insights_api.py treats it as _EXPORT_ONLY_COLLECTIONS: the snapshot endpoint serves _CLIENT_COLLECTIONS = _ALL_COLLECTIONS - _EXPORT_ONLY_COLLECTIONS, so cause_remediation never appears in GET /insights/snapshot — it’s only populated in the XLSX export (see Export). The snapshot’s nine collections are the five model collections plus the other four LLM collections.
useConsoleData() runs the (expensive, 9-collection) adaptation inside TanStack Query’s select option rather than in each consuming component’s render body, specifically so the ~10 call sites across the console share one memoized result instead of each re-running the adapter on every render. It also polls every 4 seconds — but only while at least one collection is still "generating":

The four states a view can be in

useConsoleData() exposes a four-way discriminated result (ConsoleDataResult in src/lib/data/useConsoleData.ts), and every report-backed view branches on it in the same order: locked is a real-tenant-only state: a tenant onboarded with “share onboarding outputs” turned off (see Admin portal wiring’s Activation step), with nothing ingested yet, gets <LockView> — a soft “dashboards populate automatically” message pointing at /sources — instead of a self-service empty state, because this tenant was deliberately onboarded so the client never triggers the pipeline themselves. error is kept distinct from a plain empty report specifically so a genuine fetch/parse failure (after TanStack’s retries are exhausted) never gets silently presented as “no report yet.”

ready, in more detail

A ready result can still have work in flight: the five MODEL_COLLECTIONS being ready is enough to unblock the view, but the four LLM_COLLECTIONS (fishbone, theme summaries, narratives, recommendations) can still be "generating". useConsoleData() surfaces that as a pending: CollectionName[] array alongside data, and InsightsBanner (src/components/shell/InsightsBanner.tsx, mounted once in ConsoleLayout under the pagebar) renders a live status line for it:
consoleData.narrative (the LLM-written portfolio narrative, once generated) is consumed only by HomeView, which prefers it over the deterministic, template-built hero line whenever it’s present.

Which view reads which collection

Every collection name (CollectionName) is a union of MODEL_COLLECTIONS and LLM_COLLECTIONS; ConsoleData itself is the adapted shape (src/lib/data/types.ts, built by adaptClientInsights()) that all four report-backed views actually consume, not the raw wire response.

Remediation: CAPs and reviews

The remediation domain is entirely tenant-scoped — src/lib/api/hooks-tenant-remediation.ts is explicit that this is “the sole production remediation boundary.” Every hook wraps useQuery/useMutation directly.
POST /remediation/caps/{cap_id}/steps/{step} and POST /remediation/reviews are defined in services/api/remediation_api.py but are not currently included in services/api/product_app.py’s REMEDIATION_OPERATIONS allowlist. product_app.create_product_app() silently drops any router route whose method+path isn’t in its allowlist (it only raises if a promised operation is missing, not if the router has extra routes) — verified by constructing the app and enumerating its routes: neither operation is registered on the deployed product API. Concretely, this means PlanModal’s “Mark done”/“Reopen” step buttons and LoopDrawer’s Approve/Reject/Escalate buttons call operations that currently 404 against the deployed product surface, even though the frontend, the router, and its own test suite (tests/test_remediation_api.py) all implement and exercise them. If you’re debugging a step-completion or review-submission failure in a deployed environment, check the allowlist in product_app.py first — the frontend code is not the likely cause. services/api/product_app.py’s REMEDIATION_OPERATIONS frozenset is the single place both operations need to be added to close this gap.
RemediationView merges live tenant CAPs with the report’s suggested fix plans (data.causes) itself — a loop with no adopted CAP shows every suggested cause-group with a “Suggested” chip and an “Adopt as plan” action; adopting one replaces only that suggestion (matched by problem statement), leaving every other suggested fix for the same loop listed alongside it. Pagebar’s “Action queue” count is every cap whose status is proposed, blocked, or completed — the three states that represent a plan currently awaiting a human decision.

Export

The export is the authenticated tenant’s own coherent snapshot — same “one dataset/model version pair” guarantee as /insights/snapshot — rendered server-side as an XLSX workbook (issues, loops, causes, and fix plans as of the last completed pipeline run).

Sources

SourcesView (src/components/views/SourcesView.tsx) is a thin permission wrapper around SourcesClient (src/components/views/SourcesClient.tsx), passing canOperate(session) down as canManageSources. Both calls run inside one handleUpload() function with local uploading/uploadError state rather than useMutation, since the sequence (conditionally create a source, then upload to it) doesn’t map cleanly onto a single mutation key.

Members

SettingsView’s Members tab (src/components/views/SettingsView.tsx) is read-only in the MVP: