<!-- CURSOR_AGENT_PR_BODY_BEGIN -->
## Summary
- Adds a bounded, side-effect-free preflight package at klair-api/budget_bot/board_doc/provisioning/ that builds a deterministic, reviewable campaign-manifest candidate for an explicit (year, quarter) — no wall-clock default anywhere in the call chain.
- Enumerates the 9 BU / 12 CF active roster, drafts recipients from the canonical owner mapping, resolves each entity's Q3 seed strategy (roll_forward / blank_no_prior) and Q4 target-source mapping, and produces a row-order-independent canonical JSON + SHA-256, a human-readable table, and a reconciliation summary.
- Ships a --dry-run-only CLI surface; no other mode is accepted.
## Why it's needed
The Q4 2026 Budget Bot rollout covers 21 entities (9 BU + 12 CF). Before any document is created, permission is granted, campaign row is written, or email is sent, we need a reviewable, reproducible candidate that a human can approve — one that fails closed on every ambiguous or missing input rather than guessing (e.g. picking the "newest" of several prior-quarter documents, or silently falling back to blank when a source search actually failed).
## Changes
- entities.py — active roster (BusinessUnit minus INACTIVE_BUS), plus a checked-in, reviewed APPROVED_EXCLUSIONS hook. A stale or duplicate exclusion entry surfaces as a missing_entity / duplicate_entity finding rather than being silently applied or ignored.
- recipients.py — drafts recipients via budget_bot.access_control.get_owner_emails_for_bu (the canonical KLAIR-3219 mapping — Colin Guilfoyle's exact address for AI Engineering & Builder Team comes from this function, not a local special case). Distinct missing_owner / invalid_recipient findings.
- sources.py — fail-closed Q3 seed-strategy resolution behind an injected Q3SourceReader protocol (mirrors find_prior_docs's shape without touching DynamoDB/Drive). Zero candidates → blank_no_prior with recorded evidence; exactly one → roll_forward with the exact doc id/revision; multiple distinct documents, conflicting revisions for the same document, malformed rows, or an inaccessible search each block with a distinct finding code and never auto-select or fall back to blank.
- targets.py — Q4 target-source mapping behind an injected Q4TargetReader protocol, using the existing resolve_bu_name alias resolver. Distinct missing_q4_source, alias_conflict (same entity, disagreeing alias rows), and duplicate_target_document (two different entities, same target) findings.
- manifest.py — ProvisioningManifest/ManifestRow models; canonical_json() sorts rows and findings before dumping (with sort_keys=True) so the SHA-256 digest is stable regardless of input row order or repeated runs; render_table() for human review; reconciliation_summary() accounts for every entity as ready/excluded/blocked.
- preflight.py — build_provisioning_manifest(*, year, quarter, q3_reader, q4_reader, approved_exclusions=...), the single orchestrator. Both readers are always caller-injected; this package ships no network-backed reader implementation.
- cli.py — --dry-run-only argparse surface; --execute and omitting --dry-run both exit 2 with a clear message before any manifest is built.
The returned manifest is the sole authority a future provisioning/send step should consult — not EMAIL_TO_BU_MAP or either raw reader output directly.
## Breaking changes
None — this is a new, self-contained package with no changes to existing modules.
## Risks and mitigations
- Risk: a future caller could wire a network-backed reader that leaks a real Drive/DynamoDB call into what looks like a "preflight". Mitigation: both Q3SourceReader and Q4TargetReader are Protocols with no shipped implementation, and test_provisioning_side_effects.py patches every known write/network seam (boto3 client/resource construction, DynamoDBWizardStorage, gdoc_sync clone/sync, outbound sockets/DNS) to raise on first use, then asserts the full preflight still completes.
- Risk: the fail-closed policy in sources.py/targets.py could be loosened later to "just pick one" under rollout time pressure. Mitigation: each fail-closed branch has a focused, named test (test_provisioning_sources.py, test_provisioning_targets.py) asserting the specific finding code fires and that no strategy/target is set alongside it.
- Risk: the checked-in APPROVED_EXCLUSIONS mechanism could be misused to silently drop entities. Mitigation: it ships empty, requires a non-empty reason per entry, and a stale/duplicate entry produces a missing_entity/duplicate_entity finding instead of applying silently.
## Test plan
Focused provisioning tests (all new, all passing):
cd klair-apiuv run pytest tests/board_doc/test_provisioning_entities.py tests/board_doc/test_provisioning_recipients.py \
tests/board_doc/test_provisioning_sources.py tests/board_doc/test_provisioning_targets.py \
tests/board_doc/test_provisioning_manifest.py tests/board_doc/test_provisioning_preflight.py \
tests/board_doc/test_provisioning_cli.py tests/board_doc/test_provisioning_side_effects.py -v
# 63 passed
Full board_doc suite (regression check, network-denied by the existing autouse fixture):
uv run pytest tests/board_doc/ -q# 3627 passed, 2 deselected (the two allowlisted live-network integration tests), 111.90s
Ruff + Pyright, scoped to the new package:
uv run ruff format budget_bot/board_doc/provisioning/ tests/board_doc/test_provisioning_*.py --checkuv run ruff check budget_bot/board_doc/provisioning/ tests/board_doc/test_provisioning_*.py
# All checks passed!
uv run pyright budget_bot/board_doc/provisioning/
# 0 errors, 0 warnings, 0 informations
test_provisioning_side_effects.py specifically patches boto3.client/boto3.resource, DynamoDBWizardStorage.save/_ensure_table_exists, gdoc_sync.clone_google_doc/sync_to_google_doc, and outbound sockets/DNS to raise on first use, then asserts the full preflight (and CLI dry-run) still completes — proving no Drive write, permission change, session/DynamoDB campaign write, SES send, or network call occurs. It also asserts services.budget_notification_service (which constructs a live SES client at import time) is never imported as a side effect of building a manifest.
- [x] Focused provisioning tests pass (63/63)
- [x] Full tests/board_doc/ suite still passes (3627/3627, 2 deselected)
- [x] Ruff format/check clean on all new files
- [x] Pyright clean on the new package (tests excluded from pyright per repo config)
- [ ] Manual/computer-use testing — not applicable; this is a pure backend/CLI change with no UI surface
## Follow-ups
- A future ticket must implement production, network-backed Q3SourceReader/Q4TargetReader implementations (e.g. wrapping find_prior_docs-equivalent search and a real target-source registry) — deliberately out of scope here.
- Actual provisioning execution (creating documents, granting permissions, writing campaign rows, sending mail) driven off a *reviewed* manifest is a separate, later ticket.
Closes KLAIR-3246
<!-- CURSOR_AGENT_PR_BODY_END -->
<div><a href="https://cursor.com/agents/bc-00bf4a2d-59dc-4c68-bb9e-1077ba43d958?cursor_ref=pr_footer&cursor_cta=open_in_web"><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-00bf4a2d-59dc-4c68-bb9e-1077ba43d958&cursor_ref=pr_footer&cursor_cta=open_in_cursor"><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>