<!-- CURSOR_AGENT_PR_BODY_BEGIN -->
## Summary
Delivers OBS.1 — the capture half of Coach Claire trace logging: broad-schema Pydantic trace models, a fire-and-forget S3 write service, and a handle_chat/_call_llm hook that assembles and emits one trace per chat turn. Backend-only, no warehouse dependencies. Gated behind CLAIRE_TRACE_ENABLED (default off) so it ships dark.
Relates to KLAIR-2824. Not closing KLAIR-2824 — it also covers OBS.2 (Redshift loader + Accept/Reject disposition capture), which is explicitly out of scope here and remains open/human-in-loop.
## Why it's needed
wizard_orchestrator.handle_chat calls Anthropic directly and is untraced today: the assembled system prompt, injected context blocks, MCP intra-turn round-trips, token usage, latency, and stop reason are all ephemeral. Without a capture path there's no way to build the query/reporting side (OBS.2) or discover which signals separate good turns from bad.
## Changes
- klair-api/models/claire_trace_models.py (new) — ClaireTracedToolCall, ClaireTraceRound, ClaireTraceCreate, ClaireTraceRecord (adds trace_id/created_at/ttl). to_s3_json() returns the full record with a sane per-field char cap (safety valve, not semantic truncation). to_redshift_row() flattens to scalar columns + rounds/tool_calls/context_blocks SUPER-shaped nested structures + a caller-supplied s3_key — the row shape the OBS.2 loader will consume, pinned and unit-tested even though the loader itself doesn't ship here.
- klair-api/services/claire_trace_service.py (new) — ClaireTraceService.log_trace() mirrors arr_gap_trace_service.py's S3 mechanics (bucket, client init, try/except → log). Writes to klair-uploads/budget-bot/claire-traces/turns/{yyyy-mm-dd}/{trace_id}.json. First line returns immediately when CLAIRE_TRACE_ENABLED is unset/false. The whole write body is wrapped in try/except → logger.warning → None; never raises. Deliberately no reader — OBS.2's job, not the ARR-gap write-only anti-pattern (that gap is scoped, not accidental, here).
- klair-api/budget_bot/board_doc/wizard_orchestrator.py:
- _build_step_context gains an additive context_block_sizes: dict[str, int] | None = None kwarg (its only call site passes it) populated via a new _record_block_size helper — records each named system-prompt block's char count with zero behavior change when None.
- _call_llm times each Anthropic round and appends a ClaireTraceRound (model, stop_reason, input/output tokens, latency, tool_use-block count) to an accumulator threaded through the MCP recursion; each execute_mcp_tool round appends ClaireTracedToolCall entries (source="mcp").
- End of handle_chat: assembles a ClaireTraceCreate from in-scope session/turn data and fire-and-forget emits via asyncio.create_task(asyncio.to_thread(claire_trace_service.log_trace, trace)) — off the critical path, matching the existing summarize_brainlift_background fire-and-forget pattern in this file.
- Everything is gated behind one _trace_enabled = claire_trace_service.is_enabled() check up front — with the flag off, no ClaireTraceCreate/ClaireTraceRound/ClaireTracedToolCall objects are built, context_block_sizes stays None, and zero S3 calls happen.
- The trace assemble-and-schedule block is wrapped in the one new broad except Exception in this file, scoped tightly around just that IO, logged with session-id context, documented inline with why (tracing must never break a chat turn). Every other exception path in the touched region still propagates.
- No change to WizardSession.conversation or any existing return contract.
- klair-api/.env.example — documents CLAIRE_TRACE_ENABLED (default 'false').
## Breaking changes
None. Additive-only: new files, an additive optional kwarg on an internal helper with one call site, and a gated hook that is a no-op when CLAIRE_TRACE_ENABLED is unset. handle_chat's returned StepResponse is unchanged whether tracing is on or off.
## Test plan
Ladder run from klair-api/ with uv run pytest:
uv run pytest tests/test_claire_trace_service.py -q # 9 passeduv run pytest tests/test_claire_trace_models.py -q # 8 passed
uv run pytest tests/board_doc/test_claire_trace_hook.py -q # 6 passed
# (scoped total: 23 passed)
uv run pytest tests/board_doc/test_wizard_orchestrator.py \
tests/board_doc/test_chat_tool_calls.py \
tests/board_doc/test_chat_streaming.py \
tests/board_doc/test_chat_tool_only_fallback.py \
tests/board_doc/test_mcp_tools.py -q # 169 passed (no regressions in handle_chat-touched suites)
uv run pytest tests/board_doc -q # 3166 passed, 2 deselected (integration, excluded by default)
Also ran and confirmed clean:
uv run ruff format models/claire_trace_models.py services/claire_trace_service.py budget_bot/board_doc/wizard_orchestrator.py # 0 reformatteduv run ruff check models/claire_trace_models.py services/claire_trace_service.py budget_bot/board_doc/wizard_orchestrator.py # all checks passed
uv run pyright models/claire_trace_models.py services/claire_trace_service.py budget_bot/board_doc/wizard_orchestrator.py # 0 errors, 1 pre-existing warning (unrelated line, confirmed present before this change too)
Per the [backend-test-truth](../blob/main/.claude/skills/backend-test-truth/SKILL.md) skill: the default addopts excludes integration/eval/allow_network markers, and conftest.py mocks RedshiftHandler globally — neither matters here since this change has no Redshift/warehouse code path at all. All boto3/S3 access in the new tests is mocked (patch("services.claire_trace_service.boto3")) — no live S3 call is made anywhere in this PR's tests.
Load-bearing invariant, explicitly tested: test_log_trace_raising_is_swallowed_turn_still_succeeds and test_trace_assembly_exception_does_not_break_the_turn patch log_trace (and, separately, ClaireTraceCreate itself) to raise, and assert handle_chat still returns its normal answer — tracing is observability, not a feature, and must never break or block a chat turn.
## Verification artifacts
to_s3_json() samples captured from the hook tests (system prompt truncated for readability here; full ~16KB prompt is written verbatim in the real S3 object, subject only to the 200K-char safety cap):
Single-shot turn (no MCP tool use — 1 round):
{"session_id": "sample-session",
"user_message": "How does Q2 look for Skyvera?",
"response_text": "Q2 ARR landed at $12.4M, +8% YoY - looking solid.",
"system_prompt": "You are Budget Bot, ... [16076 chars total]",
"context_block_sizes": {
"base_intro": 7372, "document_lifecycle_block": 2284, "dated_evidence_block": 999,
"claim_integrity_block": 1463, "date_awareness_block": 1210, "chat_attachments_block": 0,
"mips_and_goals": 0, "phase_guidance": 1466, "focused_section_block": 411,
"focused_section_findings_block": 0, "full_doc_findings_block": 0,
"active_checks_block": 0, "finding_addressal_directive": 0, "multi_edit_directive": 871
},
"rounds": [
{ "round_index": 0, "model": "claude-opus-4-7", "stop_reason": "end_turn",
"input_tokens": 1450, "output_tokens": 210, "latency_ms": 1.62, "num_tool_use_blocks": 0 }
],
"tool_calls": [],
"total_input_tokens": 1450, "total_output_tokens": 210, "total_latency_ms": 1.62,
"mcp_loop_exit_reason": "no_mcp_tool_uses",
"proposal_tool_use_ids": [], "addressed_finding_ids": [],
"environment": "dev", "trace_id": "sample-trace-single-shot", "ttl": 0
}
MCP-loop turn (2 MCP tool-use rounds + 1 final text round = 3 rounds):
{"session_id": "sample-session",
"user_message": "What's our ARR breakdown by product?",
"response_text": "ARR is $53.8M, up 12% YoY across products.",
"rounds": [
{ "round_index": 0, "stop_reason": "tool_use", "input_tokens": 900, "output_tokens": 40, "num_tool_use_blocks": 1 },
{ "round_index": 1, "stop_reason": "tool_use", "input_tokens": 1100, "output_tokens": 60, "num_tool_use_blocks": 1 },
{ "round_index": 2, "stop_reason": "end_turn", "input_tokens": 1400, "output_tokens": 180, "num_tool_use_blocks": 0 }
],
"tool_calls": [
{ "tool_name": "query_arr", "tool_input": {"bu": "Skyvera"}, "tool_result": "Total ARR: $53.8M (+12% YoY)", "is_error": false, "execution_order": 0, "source": "mcp" },
{ "tool_name": "query_arr", "tool_input": {"bu": "Skyvera", "detail": "by_product"}, "tool_result": "Total ARR: $53.8M (+12% YoY)", "is_error": false, "execution_order": 1, "source": "mcp" }
],
"total_input_tokens": 3400, "total_output_tokens": 280,
"mcp_loop_exit_reason": "no_mcp_tool_uses",
"trace_id": "sample-trace-mcp-loop", "ttl": 0
}
## Out of scope (OBS.2 — not built here)
- Redshift budget_bot_claire_traces table DDL + the S3→COPY loader (push_bulk_to_redshift) — warehouse creds + SUPER-vs-child-table schema decisions.
- Accept/Reject disposition capture on the tool-resolution endpoint (board_doc_router.py) + a ClaireTraceOutcome writer.
- CSV export, trace-viewer UI, evals/scoring, prompt-versioning, dashboards.
- Langfuse / Docker / OTel / DynamoDB.
- Any change to WizardSession.conversation or existing return contracts.
## Linkage note
Per the task spec, KLAIR-2824 should get a Drone spec: obs-1-claire-trace-capture.md attachment pointing at the spec on main. I don't have Linear write access in this environment (no MCP tool exposes attachments/comments), so I could not add it myself — flagging so a human/the dispatching harness can. Separately, klair-api/budget_bot/board_doc/BACKLOG.md already links the OBS.1 spec at trilogy-drones/tasks/klair/obs-1-claire-trace-capture.md (not this repo) — per existing repo precedent (PR #2908), drone task-spec files belong in trilogy-drones, not the product repo, so no spec file was added here.
## Review Round Completeness
- outcome: complete
- round: 1
- dispatched: 5
- reported: 5
- missing: (none)
- cause: complete
- head: e133f8bd872a20189fef33d3dfdaa4cd2176fc19
- run: fanout-3628-2026-08-21T13-40-50-080Z
- review: 4993823363
<!-- drones:round-completeness head=e133f8bd872a20189fef33d3dfdaa4cd2176fc19 run=fanout-3628-2026-08-21T13-40-50-080Z -->
GitHub review #4993823363 was published and all dispatched review dimensions reported against the stamped head. Thread-count signals (unreplied=0) are meaningful for this head only — a later push invalidates the stamp. This section is a harness-shaped, head-bound self-report (not an authenticated out-of-band attestation).
<!-- CURSOR_AGENT_PR_BODY_END -->
<div><a href="https://cursor.com/agents/bc-d4017509-b207-40ba-b0d3-d6baf11f7b34?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-d4017509-b207-40ba-b0d3-d6baf11f7b34&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>