# BI "Ledger Room" — Ground-Truth Feature Inventory

**Purpose:** the definitive list of what the REAL shipping BI room does today, so a v3 redesign is grounded in the product, not the mockup's imagination. Every entry is verified against code with `file:line` citations. This is the envelope of what v3 is *allowed* to contain.

> **CORRECTION NOTICE (this revision).** The prior audit read a STALE worktree and wrongly concluded that several shipped features "do not exist." They DO ship on `main` and prod. Verified in `/home/stevan/dev/lore-consolidate` (git `main` @ `5e19772`, matches prod). The features that were wrongly demoted and are now restored as REAL:
> - **Customize-columns "Ledger Modal"** + the whole inline column-enrichment system (Group H below).
> - **Row-limit raise-cap controls** (Show all · 500/1k/All) + **full-result CSV export** via a real server endpoint (Group D below).
> - **Facebook ads-vs-referral** and **proportion/denominator** clarify cases (Group C below).
>
> Also confirmed REMOVED this session and therefore absent from this inventory: the **"Derive column" toolbar button** and the standalone **"£ Realized revenue" toolbar pill** (realized revenue now lives only inside the Customize-columns catalog).

**Audited surfaces:**
- Frontend: `src/institutional_kb/static/js/bi.js` (8350 lines, non-UTF8 → `LC_ALL=C grep -a`), `static/index.html`, `static/css/tailwind.input.css`
- Backend: `src/institutional_kb/api/routers/bi_query.py`, `src/institutional_kb/services/bi_*`, `src/institutional_kb/bi/*`
- Result envelope model: `src/institutional_kb/models/bi_conversation.py`

**Two response shapes (anchor v3 on #2):**
1. Legacy sync `POST /bi/query` → `execute_query`. Carries `confidence_display` but NO insight / clarify / follow-ups / sql_explanation. Largely superseded.
2. **Modern async conversational** path — the one the UI renders. `POST /bi/conversations/{id}/turns` → 202 `{conversation_id, job_id}` → poll → `GET …/conversations/{id}` returns `BiConversationTurn[]`. The rich "smart" layers are added by the **router**, not the execution service. Column-enrichment adds layer on TOP via separate `POST …/turns/{id}/columns/*` endpoints + a poll (Group H).

**Live render path:** `renderTurnAnswer` (`bi.js:5779`) → `renderInsightBlock` (`bi.js:7176`) is the LIVE result renderer. `renderQueryResult` (`bi.js:7747`) has **zero call sites** — it is DEAD, and so is everything reachable only through it (the Save-as-Segment modal + Pipedrive "Enrichments" fieldset in `renderQuickSegmentAction` `bi.js:2128`, called only at `7785/7804` inside dead `renderQueryResult`). Do NOT treat that Save-Segment modal as the column chooser — the real column chooser is the live Customize-columns modal in Group H.

---

## GROUP A — The Shell / Chrome

| # | Feature | Current behavior | States | Cite |
|---|---------|------------------|--------|------|
| A1 | **Global nav rail** (sidebar) | Home, **Insights** (=BI, `#/bi`), Compose, Integrations, Settings. BI labelled "Insights". | active/inactive (`.active`, `aria-current`) | `index.html` sidebar nav (`.sidebar-nav-item`, e.g. `42-64,182-198`) |
| A2 | **BI thread rail / "Memory Stack"** | Right `<aside id="bi-thread-rail">`, header literally **"Memory Stack"**. Lists past inquiries findings-first (`insight_headline || nl_query`), relative time + turn count + non-success status + delete. "New inquiry" buttons. | empty / populated / per-item delete | `bi.js:4851,4853` |
| A3 | **Connection picker** | `<select id="bi-connection-select">` — single **read-only DB-connection** selector ("Select connection…"). NOT a blended source picker. CT+PD federation decided server-side; surfaced after the fact (F9). | empty/selected | `bi.js:4882` |
| A4 | **Theme toggle (light/dark)** | Global `#theme-toggle`, `ThemeManager`, persists `lore_theme`, pre-paint bootstrap. Dark "Ledger Room" look = CT-Ledger tokens, not a BI-forced mode. | light / dark | `index.html` theme bootstrap |
| A5 | **Vault switcher** | `#vault-switcher` populated by `vault-switcher.js`; BI re-renders on `vault-change`. | open/closed; per-vault | `index.html:182` |
| A6 | **User menu / logout** | Initials + email + role badge; dropdown with **Logout** (clears active vault). | open/closed | `index.html` (footer user menu) |
| A7 | **Mobile hamburger + rail toggle** | Global `#mobile-toggle`; BI also has `#bi-rail-toggle` overlaying the Memory Stack below `lg` with a backdrop. | open/closed | `index.html:174,90`; `bi.js:4991` |

---

## GROUP B — The Ask Box (Command Desk)

| # | Feature | Current behavior | States | Cite |
|---|---------|------------------|--------|------|
| B1 | **Ask textarea** | `#bi-query-input`. Placeholder "Ask the ledger…". Auto-grows; **Enter submits, Shift+Enter newline**. | idle / focused / running | `bi.js:630,1274` |
| B2 | **Submit button** | `#bi-query-submit`. Idle "Run query"; running "Running…". Disabled until a question exists AND all clarify steps answered. | idle / running / disabled | `bi.js:4910,631` |
| B3 | **Two-mode field (display ⇄ edit)** | Tokenized DISPLAY block (`#bi-query-display`, recognizer chips) ⇄ editable textarea via an "Edit" link; tag-recall line. | display / edit | `bi.js:483,1267-1274` |
| B4 | **Starter prompts** | Not in the box — live in the empty-state "Editorial Index"; clicking fills the textarea. | (see G) | `bi.js:8168,8246` |

---

## GROUP C — Clarify-Before-Run + server clarify turn

A rich, fully-wired, **source-tagged multi-step clarify scaffold** that runs *before* submit, plus a server-driven clarify *turn* during a run.

| # | Feature | Current behavior | States | Cite |
|---|---------|------------------|--------|------|
| C1 | **Clarify scaffold** | `#bi-query-clarify-scaffold`, header **"Clarify before run"** / "Pin down the source-tagged terms…". `renderBiQueryClarifyScaffold`. | hidden / pending / all-answered | `bi.js:1147,1205,586` |
| C2 | **Recognizer chips with side + status** | Chips in the question preview carry a CT / PD / split side class + `?` (pending) / `✓` (resolved). | pending / resolved; CT/PD/split | `bi.js:482,502` |
| C3 | **Step stepper** | Tabbed steps with pips + CT/PD/split sides; each panel: system badge, question, options, per-step "✕ ignore", `N / M` counter. Step types `decision`/`multi`/`teach`. | per-step answered/unanswered; minimized | `bi.js:788,822,342` |
| C4 | **Source-tagged clarify cases** | FE curated label/question maps cover **9** cases (`BI_CLARIFY_CASE_LABELS`/`QUESTIONS`, `bi.js:87-109`): currency traded-vs-declared, revenue settled/open/forecast, lost-reason deal-vs-person, engagement-channel sparsity, acquisition CT-source vs CRM-origin, app stale field, activated bool-vs-count, meaningful-dates set, utm-codes set. Backend now emits **9 curated deterministic cases** (`CASE_QUESTIONS`/`CASE_ORDER`, `clarification_state.py:40-60`) plus the hard gates below. | per-case decision / multi | FE `bi.js:87-109`; BE `clarification_state.py:40-60` |
| C5 | **Bare-currency HARD gate** | A bare ISO code (SAR/USD/EUR…, GBP excluded) with no side+source forces a 5-option **side (buy/sell) × source (traded-CT / declared-PD)** chooser; pairs skip it. Options carry `source_bindings` consumed by SQL gen. | forced clarify | `clarification_state.py:251-260,1033` |
| C6 | **Facebook ads-vs-referral clarify (REAL)** | `facebook_attribution_clarification` (`clarification_state.py:718`, case id `facebook_ads_vs_referral`). Triggered by "facebook" in the query; SUPPRESSED when the axis is already pinned ("facebook ads"/"paid social" or "facebook referral"/"organic facebook"). Question: **"Facebook Ads (paid) or Facebook.com referral (organic)?"** Two options, each carrying a CT-leg `source_bindings` predicate against `ct.user_analytics.extra`. Recognizer drops the bare-facebook source bind so the backend gate can ask (`recognizer_dictionary.py:619-636,763-780`). Dispatched 3rd (`clarification_state.py:1047`). | forced clarify (paid / organic) | `clarification_state.py:669,718-770,1047`; `recognizer_dictionary.py:619-780` |
| C7 | **Proportion/denominator clarify (REAL)** | `proportion_denominator_clarification` (`clarification_state.py:881`, case id `proportion_denominator_scope`). Triggered when a proportion term ("proportion/share/percentage/percent/pct") co-occurs with a period-denominator phrase ("of the month", "monthly total", …); SUPPRESSED when scope already pinned ("of total"→whole_book, "within the cohort"→within). Question: **"A '% of the month' of what — the cohort's own monthly total, or the whole book's?"** Two options → `within` vs `whole_book`, fed to SQL via `build_proportion_denominator_directive` (`:944`). Dispatched 4th (`:1054`). | forced clarify (within / whole-book) | `clarification_state.py:783-941,944-974,1054` |
| C8 | **Answer persistence across turns** | Resolved answers carry forward within a conversation (`biSessionResolvedTerms` + carryover); a re-mentioned term auto-resolves. Backend persists `client_resolution_state` + `client_recognizer_spans` and detects "this turn answers a prior clarification." | resolved / remapped / pruned | FE `bi.js:76,550`; BE turn fields |
| C9 | **Finish control** | "Looks right ›" enabled only when all steps answered; "moves focus to Run. It does not submit." | enabled/disabled | `bi.js:822` |
| C10 | **Server clarify TURN** | Status `clarify` + `clarification{question, options, clarify_case, resolution_state, multi}`. `renderClarifyTurn` — **"One thing before I query"**, option chips that submit `"{question} — {option}"`, "or just answer in the ask box below". Plus an "Answering" indicator with a ✕ to ask fresh. | clarify-pending | FE `bi.js:6305,6311`; BE clarify outcome |
| C11 | **Clarify sources (backend)** | (a) deterministic ct-pd recognizer `ct_pd_clarification_for_query` (`clarification_state.py:1016`, hard gates: bare-currency → affiliate → facebook → proportion → curated); (b) generic LLM follow-up router `ROUTE_CLARIFY` when ambiguity is material; (c) unbacked-concept guard against the internal column index. | — | `clarification_state.py:1016`; `bi_followup_router.py` |

**FE rendering nuance for the two new cases (C6/C7):** `facebook_ads_vs_referral` and `proportion_denominator_scope` are **NOT** in the curated FE label/question maps (`bi.js:87-109`). They surface via the **backend-supplied** clarify question/options (`renderClarifyTurn`), and if shown through the generic step renderer they fall back to `humanizeBiTerm(clarify_case)` (`bi.js:358-372`). They work, they just aren't pretty-labelled on the FE yet — a small v3 polish, not a gap.

---

## GROUP D — Result Toolbar (confidence · export · row-limit · view · trace)

The toolbar lives inline in `renderInsightBlock` (`bi.js:7250-7268`), grouped as: stats row (row count · row-cap · confidence pill) then a disclosures row (confidence disclosure · Export CSV · enrich rail · Customize columns · money controls · LLM trace · view selector).

| # | Feature | Current behavior | States | Cite |
|---|---------|------------------|--------|------|
| D1 | **Confidence chip + drawer** | A stats-row pill ("High/Medium/Low confidence", `bi-confidence-indicator`) and a clickable disclosure button ("Confidence: …", `bi-confidence-pill`) opening an in-place drawer with score (n%), interpretation, per-factor bullets, spot-check advice. Backend auto-demotes HIGH→MEDIUM on 0 rows. | high / medium / low / unknown | FE `bi.js:7197,7260,6276`; BE `confidence_scoring.py` |
| D2 | **Export CSV — full result (REAL, server-backed)** | `data-action="bi-export-csv"` → `downloadFullResultCsv` (`bi.js:6232`). If the result was **not** truncated it exports the in-memory rows client-side; if it WAS truncated it `GET …/turns/{id}/full-result` (server re-runs the stored SQL at the 10k ceiling, same masking/PII) and exports the complete set. **Non-PII customize-columns enrichments are re-resolved into the export server-side; PII label columns are excluded (INV-ENRICH-3).** | enabled; full / fallback-to-visible | FE `bi.js:6232,6196,7320`; BE `bi_query.py:6703` (`get_conversation_turn_full_result`, audit `bi_conversation_turn_full_result`, `_reapply_active_enrichments_for_export` `:6615`) |
| D3 | **Row-limit raise-cap controls (REAL)** | System default display cap = **100** (`DEFAULT_DISPLAY_CAP`, `query_executor.py:57`). When a result is truncated AND re-runnable, `buildRowCapControls` (`bi.js:6258`) renders a **"Show all"** raise-cap link (`bi-show-all`, →10k) plus **500 / 1k / All** steps (`data-bi-rowcap`). Clicking re-runs the turn's stored SQL at the higher cap (`executeBiQuery({rerunTurnId, rowLimit})`, `bi.js:7326-7339`) and re-renders in place. Server clamps any cap to `[1,10000]` (`clamp_row_limit`, `query_executor.py:285`); `ALLOWED_ROW_LIMITS=(100,500,1000,10000)` (`:61`). Boolean `was_truncated` only — no true total returned. | default-100 / raised / clamped | FE `bi.js:6258,7326`; BE `query_executor.py:47,57,61,285`; `bi_query_execution_service.py:1293,1350` |
| D4 | **View toggles (table / chart / SQL)** | `buildChartTypeSelector` (`bi.js:1815`); types = table, big_number, bar, line, area, pie, plus an optional **sql** pill when SQL exists. | per-type selected | `bi.js:1815,7267` |
| D5 | **LLM trace chip (✦)** | `buildLlmTraceChip` (`bi.js:6353`) — tooltip of which model ran each step (Text→SQL / Insight / Follow-up) + PII posture (CT-hosted / shared / tokenised), from turn `llm_trace`. | hover | `bi.js:6353` |
| D6 | **In-table client pagination** | Below the raise-cap cap, the rendered table paginates client-side at **25 rows/page** (`ROWS_PER_PAGE`). | per-page | `bi.js:7929` |

---

## GROUP E — SQL View + SQL Explanation

| # | Feature | Current behavior | States | Cite |
|---|---------|------------------|--------|------|
| E1 | **SQL view** | `sql` view branch renders generated SQL with a Copy button. Federation renders **multiple labelled legs** (`federation_metadata.sources[].sql`). | has-sql / none | `bi.js:2241-2243` |
| E2 | **SQL explanation ("What this query does")** | Box `bi-sql-explanation` (`bi.js:2494`): a **prose summary paragraph PLUS bullet points** explaining the **SQL itself** (tables/filters/joins/funcs). Shown **only inside the SQL view**. Backend `SqlExplanation{summary, bullets, provider, model, tokenized}`, ≤320-char summary + 0-6 bullets, generated only when `status=="success" and row_count>0`, fail-closed. | present / fallback-to-legacy-summary | FE `bi.js:2494`; BE `bi_sql_explanation.py` |

---

## GROUP F — Insight Summary, Charts, Disclosures, Follow-ups

| # | Feature | Current behavior | States | Cite |
|---|---------|------------------|--------|------|
| F1 | **Verdict headline** | `<h2 data-testid="bi-insight-headline">` (`bi.js:7243`) — one-sentence verdict with the key number. Deterministic fallback (`insightFallbackHeadline`) if LLM down. | present / fallback | FE `bi.js:7243,7177`; BE `bi_insight_summary.py` |
| F2 | **Metric strip** | `buildMetricStrip` (`bi.js:6131`) — total rows + up to 3 lead numeric columns; money(£)/percent-aware; uses column **max**; identifiers excluded. | — | `bi.js:6131,7251` |
| F3 | **Assumptions line** | "Assumed: …" muted mono line from `insight_assumptions` (`bi.js:7249`). **Display-only — NOT editable.** | present/absent | `bi.js:7249` |
| F4 | **Analyst notes** | Bullet list (`insight_notes`, ≤3, `bi.js:7271`) with affiliate-name → CT-admin linkify. Anti-misleading backend prompt rules (capped, rows≠entities, mixed-currency, NULLs, ties, incomplete period). | — | FE `bi.js:7271`; BE `bi_insight_summary.py` |
| F5 | **Charts** | Chart.js render; auto-select (`autoSelectChartType` — date-first→line, category pivot to multi-series, dual-axis, log scale, chronological). Types line/bar/area/pie/big_number/table. | rendered / not-enough-data / no-numeric / chart-not-loaded | `bi.js:2459,1860` |
| F6 | **Chart advisor** | `chart_config.advisory` (self-hosted qwen sense-check) shown above chart as "▸ …"; `log_scale` honored. Advisor does NOT pick the chart — deterministic selector picks; advisor may keep / switch-to-table / request log axis. | advisory present/absent | FE `bi.js:2459`; BE `bi_chart_advisor.py` |
| F7 | **Definition / citation chips** | `buildDefinitionChips` under the chart from `definition_refs` (`bi.js:1838,7274`). | present/absent | `bi.js:1838,7274` |
| F8 | **Disclosures / banners** | 0-row banner (`bi-empty-result-banner`, `bi.js:7212`); column-sanity banner (date-named col holding non-dates → "wrong source column", `:7219`); confidence drawer; assumptions line. | per-condition | `bi.js:7212,7219` |
| F9 | **Federation banner ("Merged result")** | `buildFederationBanner` (`bi.js:6411`) — freshness, coverage ("N of M joined · %"), gaps ("N unmatched · N null-key dropped"), per-source row cards, "Not summed across sources". Behind kill-switch `LORE_BI_FEDERATION_ENABLED` (default OFF). | present when federated | FE `bi.js:6411,6438`; BE `bi_federated_execution_service.py:85` |
| F10 | **Follow-up suggestions ("Next")** | `insight_follow_ups` → ≤3 tappable pill buttons under a "Next" header (`bi.js:7275`); firing one submits a follow-up. | present/absent | FE `bi.js:7275`; BE `bi_insight_summary.py` |
| F11 | **Follow-up routing (incoming turn)** | `bi_followup_router` decides `answer_from_context` / `clarify` / `new_sql` / `refuse`. Flag `LORE_BI_ROUTER_ENABLED` (default ON). | per-route | `bi_followup_router.py:103` |
| F12 | **Context answer (no new query)** | "↑ answered from the previous result — no new query" when `answered_from` set. | — | `bi.js:5797` |
| F13 | **Pivot synthesis** | Regex pivot operators ("break down by / group by / filter to / show same metric for") rewrite the query; surfaced as turn `pivot`. | — | `bi_followup.py` |
| F14 | **Per-result feedback bar** | `bi-feedback` bar (`bi.js:5850`) — "Was this useful?" thumbs up/down + issue taxonomy, persisted per turn id (survives reload, `bi.js:5445`). | — | `bi.js:5850,5445` |
| F15 | **"Ask again" / regenerate** | Per-turn re-run icon button (`data-regenerate`, title "Ask again", `bi.js:7584`) re-runs the turn's EXACT stored SQL (carrying prior clarify resolution), `bi.js:7632-7643`. | — | `bi.js:7584,7632` |

---

## GROUP H — Column Enrichment / "Customize columns" (REAL — the big correction)

A full, always-on (no feature flag) inline column-enrichment system layered on the result toolbar. Result-aware: every affordance appears **only** on a persisted turn with rows that carries an account-id key column (`customizeColumnsKeyIndex >= 0`, `bi.js:6903`). All adds POST a **strict catalog key** (never SQL/free text), dispatch a background job off the request path, return **202 `{turn_id, enrichment_id}`**, and the FE splices a pending skeleton column then polls (`pollDerivedColumn`) → fills or rolls back. PII is resolved **server-side** and returned `is_pii`; PII label columns are display-only and excluded from CSV export (INV-ENRICH-3).

| # | Feature | Current behavior | States | Cite |
|---|---------|------------------|--------|------|
| H1 | **"Customize columns" Ledger Modal** | Toolbar trigger `+ Customize columns` (`buildCustomizeColumnsPanel`, `bi.js:6982`, host `data-bi-customize-host` `:7263`) opens a centered scrim modal appended to `<body>` (`openCustomizeColumnsModal`, `bi.js:7054`): fixed header with live "n of N added" count, **search** box, scrollable grouped checkbox rows, sticky footer Cancel/Apply. Checking STAGES; **Apply** commits the diff by running `appendCatalogColumn` for each newly-checked key **sequentially**. Already-added rows are checked+locked (no server-side removal). | closed / open / staged / applying ("Adding…"); Esc/scrim/Cancel discard | `bi.js:6982,7054,7148` |
| H2 | **Catalog (`BI_COLUMN_CATALOG`)** | 33 columns across groups: **Identity (PII)** client/company/relationship_manager/salesperson/affiliate; **Attributes** country/nationality/citizenship/client_type/user_group(metal band)/campaign/buy+sell currencies/signup+first-activated dates/account_status; **Marketing** utm_source/medium/campaign/term; **Pipedrive** deal stage/status/value/owner(PII)/updated, last-activity/activity-count/lead-qualified dates, person(PII); **Measures** realized revenue (money), referred_users, referred_users_revenue, first_trade_time (FTT), trade_volume, traded_currencies, last_traded_date, trade_count. Per-row badge (PD/UTM/£/DATE/#/CT) + PII pill. | per-row: addable / staged / added-locked | `bi.js:6856-6898` |
| H3 | **Add endpoints** | kind `attribute`/`pii` → `POST …/turns/{id}/columns/attribute`; kind `measure` → `…/columns/measure`; kind `money` → `…/columns/money`. Generic `…/columns` and `…/columns/label` also exist. All vault-from-session, require an account-id key (else 422), 202 + poll `GET …/turns/{id}/enrichments/{enrichment_id}`. | 202 / pending / done / failed / timeout | `bi.js:6937,6948`; `bi_query.py:5241,5457,5598,5525,5561,5664` |
| H4 | **Inline enrich rail + ghost "+" header (ENRICH-006/007)** | A chips-only suggestion rail (`buildEnrichRail` `bi.js:6650`, one chip per detected key type) + a "browse ▸" discovery popover (`buildEnrichBrowse` `:6664`) + a ghost "+" column header in the table — all resolve a REGISTERED enricher via `appendResultColumn` (`bi.js:6716`). Keyboard-navigable popover. | rail present (keys detected) / empty | `bi.js:6650,6664,6716,7347` |
| H5 | **Realized-revenue money column + override (ENRICH-020)** | The money path (`buildMoneyControls` `bi.js:6547`, `appendMoneyColumn`) adds the canonical `realized_revenue_gbp` column via the fail-closed money classifier. On an ambiguous collision-guard refusal, a one-time "treat as money / not money" override (`OperatorMoneyOverride`) appears and re-dispatches the add as a hard proof. Tri-state cells (GBP incl. real 0.00 / em-dash for uncovered). **(Replaces the removed standalone "£ Realized revenue" pill.)** | covered / uncovered / ambiguous-override | `bi.js:6547,7372`; `bi/measure_enrichment.py:223` |
| H6 | **Enrichment coverage banners** | `buildEnrichmentCoverageBanner` (`bi.js:6457`) and `buildMoneyMeasureBanner` (`bi.js:6486`) — honest N-of-M coverage on reloaded enrichment columns. | present/absent | `bi.js:6457,6486` |
| H7 | **Persistence + re-merge** | Done non-PII enrichments persist (`bi_turn_enrichments`) and re-merge onto a reloaded turn without mutating the immutable turn row (`bi/enrichment_merge.py`); PII (`value_map is None`) never re-merges into `data_json`/CSV. | re-merged on reload | `bi/enrichment_merge.py` |

**Backend enrichment modules:** `bi/column_enrichment.py` (non-PII attribute catalog + PII gate `is_pii_column`/`assert_safe_value_sql`; note its docstring calling `ENRICHMENT_TARGETS` "empty" is STALE — the frozenset is populated `:329-613`), `bi/attribute_enricher.py` (deterministic one-slot allowlisted SQL + grain guard), `bi/computed_measure_enrichment.py` (the 5 computed measures `:171-238`), `bi/pipedrive_enrichment.py` (PD deal/person targets on the gold leg, 2 PII), `bi/pii_attribute_resolver.py` (server-side identity resolution; staff/affiliate role scoping; names display-only), `bi/canonical_columns.py` (post-gen alias canonicalization), `bi/measure_enrichment.py` (fail-closed money path), `bi/enrichment_routing.py` (chip→target→leg router), `bi/enrichment_merge.py` (re-merge). PII override enum `money_classifier.OperatorMoneyOverride:196`.

---

## GROUP G — Result States

Routed by `renderTurnAnswer` (`bi.js:5779`): pending→dateline; clarify; success+answered_from→context; success→insight; blocked; warn; else error.

| State | Behavior | Cite |
|-------|----------|------|
| **Loading / dateline** | `renderDatelineTicker` (`bi.js:5966`) — "— querying the ledger…" animated dots + live elapsed seconds. *The dateline IS the loading state — no spinners.* | `bi.js:5966,5781` |
| **Empty — no connection** | "Connect a database to start BI conversations" + Add/View connection buttons. | `bi.js` (no-connection empty) |
| **Empty — no question (Editorial Index)** | "The Ledger Room" / "Ask a question. Get a finding, not a table." with 6 departments + starter queries + answer-shape meta + a "hot" dot. | `bi.js:8168,8246,8273` |
| **Empty — 0 rows** | Amber `bi-empty-result-banner` + fallback headline; backend auto-demotes confidence HIGH→MEDIUM. | `bi.js:7212` |
| **Error** | `renderQueryError` (`bi.js:7893`) — type/message, optional SQL+Copy, suggestions, "Retry question". | `bi.js:7893` |
| **Blocked / refused** | Neutral refusal ("I can only read and analyse the data…") for `refused`; red "Query Blocked" otherwise. | `bi.js` (renderTurnAnswer branch) |
| **Warn / low-confidence (confirm-before-run)** | `renderWarning` (`bi.js:7808`) — confidence-tinted card, factor list, open Generated-SQL `<details>`, "Confirm & Run" (`:7868`) + "Dismiss"; re-asks with `confirm_low_confidence:true`. (LOW auto-runs since 2026-06-13; old block gate is dead.) | `bi.js:7808,7868` |
| **Clarify-pending** | Server clarify turn (C10) + "Answering" indicator; pre-submit scaffold keeps Run disabled. | `bi.js:6305` |
| **Truncated** | `was_truncated` → " · truncated" in stats + the raise-cap controls (D3). Boolean only — no true total. | `bi.js:7189,6258` |
| **Medium / low confidence tinting** | `CONFIDENCE_COLORS`/`CONFIDENCE_LABELS` drive pill/drawer/warn tint. | `bi.js:7196,6274` |

---

## Cross-cutting truths / caveats for v3

- **No feature flag gates** insight / sql_explanation / chart-advisor / **column enrichment / customize-columns / row-limit / full-result export** — they're data-driven and degrade to deterministic fallbacks. Only **Federation** (`LORE_BI_FEDERATION_ENABLED`, default OFF) and the **follow-up router** (`LORE_BI_ROUTER_ENABLED`, default ON) are flagged.
- **Dead/unreachable code (do NOT treat as shipping):** `renderQueryResult` (`bi.js:7747`) has zero call sites → the **Save-as-Segment modal + Pipedrive "Enrichments" fieldset** in `renderQuickSegmentAction` (`bi.js:2128`, called only at `7785/7804`) are unreachable. That is NOT the column chooser; the real one is the live Customize-columns modal (Group H).
- **Export:** the BI room uses the working client/`/full-result` path (D2). A *separate, legacy* `POST /queries/{id}/export` + `GET …/download` pair still **501s** ("result data not retained") — but that is NOT the room's export and should not be cited as "export is a stub."
- **Removed this session (absent from this inventory):** the "Derive column" toolbar button (no FE button; backend `services/derive_column.py` is orphaned/unwired) and the standalone "£ Realized revenue" toolbar pill (realized revenue is now only in the Customize-columns Measures group; removal noted at `bi.js:6544-6546`).
- **No user-facing source/blended picker:** federation is server-decided; UI has only a read-only connection select + the after-the-fact "Merged result" banner. vault_id/source ALWAYS from session.
- The **connection editor** sub-view (SSH tunnel, host-key trust, read-only verification, masked secrets, diagnostics; `bi.js:~3223-4533`) is a full feature set reachable via "Connections" — separate from the conversational room, out of scope for the room redesign.
- qwen self-hosted "niceties" (insight summary / SQL explanation / chart advisor / follow-up router) run **reasoning-off**.

### Real-feature count
**~55 distinct shipping features/controls/states** across 8 groups: A (7) · B (4) · C (11) · D (6) · E (2) · F (15) · H (7) · G (11 states). (Plus the connection-editor sub-view, out of scope.) The prior audit's "~45" undercounted because it dropped the entire Group H enrichment system, the row-limit/full-export controls (D2/D3), and clarify cases C6/C7.
