<!-- CURSOR_AGENT_PR_BODY_BEGIN -->
## Summary
- create_blank_session becomes async and runs fetch_data_for_spec once on the cold-start path so a brand-new BU lead (e.g. Zax's GM) lands on a doc with populated financial tables instead of empty section headings.
- Mixed-bucket generators (generate_product_detail, generate_minor_products_summary) gain a backward-compatible render_tables_only: bool = False kwarg so their deterministic halves can fire without their LLM halves.
- New SectionEditStatus.AUTO_POPULATED enum value distinguishes deterministic-from-data content from LLM-drafted AUTO_GENERATED content; the create-blank router endpoint surfaces the per-section status map (typed via the new WizardCreateBlankData sub-model) in its response so the FE can render a different review badge.
## Why it's needed
The pre-KLAIR-2775 create_blank_session set every section body to "", leaving cold-start operators staring at empty headings and forcing them to manually trigger every generator just to see numbers. The deterministic tabular halves (FINANCIALS plus the table prefixes in generate_product_detail / generate_minor_products_summary) have no dependence on brainlift content or prior-doc anchors, so they can be rendered immediately on session creation — the only thing that has to be deferred to the post-brainlift step is the LLM narrative.
This trims one full LLM-generation loop off the cold-start path and gives the operator a populated skeleton to start adjusting numbers against.
## Changes
- klair-api/budget_bot/board_doc/models.py — add SectionEditStatus.AUTO_POPULATED plus expanded enum docstring distinguishing it from AUTO_GENERATED.
- klair-api/budget_bot/board_doc/section_generators.py — generate_product_detail and generate_minor_products_summary gain render_tables_only: bool = False. When True, the narrative LLM call is skipped and only the deterministic prefix (Joe-Chart / ARR / P&L / Renewals + product MIPs for product_detail; ARR-by-product table for minor_products_summary) is returned. Default False preserves every existing caller.
- klair-api/budget_bot/board_doc/wizard_orchestrator.py — create_blank_session becomes async, accepts populate_tables: bool = True, and on the populate path fans out via a new _try_render_blank_section helper:
- FINANCIALS → full generate_financials render (already deterministic).
- PRODUCT_DETAIL → generate_product_detail(render_tables_only=True).
- MINOR_PRODUCTS_SUMMARY → generate_minor_products_summary(render_tables_only=True).
- All other section types (GM_COMMENTARY, PRIOR_QUARTER_REVIEW, MIPS, EXEC_SUMMARY, CUSTOM, CF_PLAN) stay empty — operator regenerates after brainlift upload.
- Dispatch-eligible sections (FINANCIALS / PRODUCT_DETAIL / MINOR_PRODUCTS_SUMMARY, pinned via the module-level _BLANK_POPULATE_SECTION_TYPES set) carry AUTO_POPULATED *whether or not* the body came back non-empty — the status tracks "we tried to render this from data" so the FE can distinguish "data unavailable, please refetch" from "operator's narrative pass". Non-dispatch sections carry AUTO_GENERATED.
- Exceptions from fetch_data_for_spec propagate (CLAUDE.md contract — no catch-and-default).
- Cold-start observability WARNINGs fire when FINANCIALS / PRODUCT_DETAIL / MINOR_PRODUCTS_SUMMARY render empty, plus a single aggregate WARNING when zero dispatch-eligible sections populated under populate_tables=True.
- Fetched DataPackage is stashed on session.data_package (field is excluded from the persisted payload) for symmetry with the sibling session creators.
- populate_tables=False short-circuits to the pre-KLAIR-2775 behaviour (no fetch, all sections empty, all AUTO_GENERATED).
- klair-api/routers/board_doc_router.py — wizard_create_blank now awaits the factory and serialises its response via the new typed WizardCreateBlankData Pydantic sub-model carrying sections: list[str] and section_edit_status: dict[str, SectionEditStatus]; a stale enum value would be rejected at construction time rather than silently flowing through the un-typed data map.
- klair-client/src/services/boardDocApi.ts — extend the SectionEditStatus TypeScript union with 'auto_populated' so the FE type matches the backend enum (type-only; badge rendering stays in the FE follow-up).
- klair-api/tests/board_doc/test_wizard_orchestrator.py — new TestCreateBlankSession class pinning six contracts (populates by default, dispatch-eligibility status tracking, populate_tables=False skips fetch, empty-financials WARNING fires, fetch exception propagates, generator exception propagates, dispatch classification per SectionType).
- klair-api/tests/board_doc/test_b9_narrative_generators.py — new test_render_tables_only_skips_llm tests under TestProductDetail and TestMinorProductsSummary patch _llm_generate with a spy and assert zero invocations under the new kwarg. MagicMock import hoisted to module-level.
- klair-api/tests/board_doc/test_save_session_security.py — existing storage-error tests now patch fetch_data_for_spec with an empty DataPackage so they keep exercising the save-error contract offline.
### Contract surface affected
- create_blank_session went from sync → async. The router was the only in-tree caller and has been updated; there are no other callers (rg "create_blank_session" --type py confirms one definition + one caller).
- Blank-session response shape gains a typed section_edit_status: dict[section_id, SectionEditStatus] entry alongside sections in the data map, both modelled by the new WizardCreateBlankData sub-model. Existing fields (sections, session_id, phase, message) are unchanged.
- generate_product_detail / generate_minor_products_summary gain a backward-compatible render_tables_only kwarg defaulting to False. Every existing call site (scripts/run_financials_only.py and tests/board_doc/test_b9_narrative_generators.py) uses the default and is unaffected.
- SectionEditStatus enum gains an AUTO_POPULATED member. New member; producer is the new create_blank_session populate path. FE TS union extended in lockstep.
### TABLES_ONLY_SECTION_TYPES audit (post-B9.10)
The spec asked us to audit whether SectionType.CF_PLAN should be dropped from TABLES_ONLY_SECTION_TYPES now that generate_cf_plan is LLM-first post-B9.10. Decision: stays in the set. The naming caveat docstring at models.py:165-175 (landed under PR #2851 / B9.10) explicitly repurposed the set: it no longer encodes a "deterministic-rendering" claim but rather a product decision that the section is non-addressable through Claire chat (Claire's regenerate_section / rewrite_section will refuse it regardless of content shape). That product semantic is still correct for CF_PLAN. Dropping the membership would change the routing/Claire-addressability contract, which is out of scope for KLAIR-2775.
KLAIR-2775 respects the post-B9.10 LLM-first contract a different way: _try_render_blank_section does not call generate_cf_plan (which would do an LLM call we don't want on the cold-start path). CF_PLAN falls through to the empty-narrative bucket like other LLM sections. The _try_render_blank_section docstring spells this out.
KLAIR-2776 is queued to retire SectionType.CF_PLAN entirely; if it lands first this PR continues to work, and if this PR lands first KLAIR-2776 inherits a cleaner state.
### Zax-specific caveat
Even with this PR shipped, Zax's GM still needs Q3 DDB BudgetSheetUrls row provisioned by Ravi before fetch_data_for_spec returns populated data. Until then the cold-start observability WARNING fires and the FINANCIALS section renders empty — that's by design: a loud signal in the logs beats a silent half-populated doc.
### Reviewer round 1 follow-up
Six follow-up commits address the reviewer drone's findings on the initial revision (one Medium + twelve Low across correctness / error-handling / security / backend / cross-cutting dimensions):
- fix(board-doc): type blank-session response payload — typed WizardCreateBlankData sub-model so a stale enum value is caught at response-build time (Medium · backend-review).
- chore(board-doc): polish render_tables_only comment + drop dead ternary — accurate description of "tables + MIPs, narrative elided" shape; drops dead if parts else "" arm.
- fix(board-doc): dispatch-eligibility status + broader cold-start observability — AUTO_POPULATED now tracks dispatch-eligibility (not body emptiness) so the FE can distinguish "tried but no data" from "left for narrative pass"; parallel WARNINGs on PRODUCT_DETAIL / MINOR_PRODUCTS_SUMMARY empty bodies; softened FINANCIALS WARNING wording; aggregate "produced an empty doc" WARNING; session.data_package stashed for symmetry; docstring polish.
- test(board-doc): pin dispatch classification + generator-exception propagation — new test_propagates_generator_exception + test_try_render_blank_section_dispatch_classification future-proofs the dispatch surface against silent drift.
- chore(tests): hoist MagicMock import in b9 narrative tests — module-level from unittest.mock import MagicMock, patch.
- chore(client): add auto_populated to SectionEditStatus union — FE type union matches the backend enum so new exhaustive switch (status) consumers can't silently drop the new value.
## Breaking changes
None for external callers — create_blank_session is module-internal and the only in-tree caller (the router endpoint) has been updated in this PR. The blank-session response is additive (new section_edit_status key in the typed data payload); existing FE consumers that read sections / session_id / phase / message are unaffected.
## Test plan
### Executed
- [x] cd klair-api && uv run ruff format <touched files> — clean.
- [x] cd klair-api && uv run ruff check <touched files> — All checks passed!.
- [x] cd klair-api && uv run pyright <touched .py files> — 0 errors (one pre-existing warning in wizard_orchestrator.py:7092 about ToolParam invariance, unrelated).
- [x] cd klair-api && uv run pytest tests/board_doc/test_wizard_orchestrator.py -v — 133 passed (including 6 new TestCreateBlankSession tests).
- [x] cd klair-api && uv run pytest tests/board_doc/test_b9_narrative_generators.py -v — all green (including 2 new test_render_tables_only_skips_llm tests).
- [x] cd klair-api && uv run pytest tests/board_doc/test_save_session_security.py -v — all green (existing storage-error contract preserved with the new fetch_data_for_spec patch).
- [x] cd klair-api && uv run pytest tests/board_doc -q --timeout=120 — passes match baseline. The 8 remaining failures (test_review_findings, test_saas_it_ops_benchmark, test_sales_marketing_benchmark, test_section_crud_endpoints, test_support_benchmark) reproduce identically on main at HEAD (verified by git stash && pytest && git stash pop) and are unrelated test-isolation issues with prior tests touching shared logger / fixture state.
- [x] cd klair-client && pnpm tsc --noEmit — clean. pnpm eslint --max-warnings 0 src/services/boardDocApi.ts — clean.
### Follow-up manual validation
- [ ] FE consumes section_edit_status from the blank-session response and renders an auto_populated badge on the deterministic sections (out-of-scope for this PR; backend exposes the signal and TS type is extended).
## Out of scope
- Frontend implementation of the auto_populated badge.
- Auto-firing LLM narrative generators on cold start.
- generate_cf_plan itself — retiring it is KLAIR-2776's scope.
- Brainlift discovery on cold start.
<!-- CURSOR_AGENT_PR_BODY_END -->
<div><a href="https://cursor.com/agents/bc-faa1ebdd-6931-4b94-b6e0-38ee0624824f"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-web-light.png"><img alt="Open in Web" width="114" height="28" src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a> <a href="https://cursor.com/background-agent?bcId=bc-faa1ebdd-6931-4b94-b6e0-38ee0624824f"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img alt="Open in Cursor" width="131" height="28" src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a> </div>