<!-- CURSOR_AGENT_PR_BODY_BEGIN -->
## Summary
Extends _reconcile_addon_session_from_doc (KLAIR-2932) so it also reconciles section structure — rename / add / delete — against the live Google Doc, not just section content. Wires the same reconcile into addon_conformance so the "Check structure" report reflects the actual Doc shape.
## Why it's needed
The content reconcile shipped under KLAIR-2932 fixed Claire / review grading stale content vs the live Doc. It does not reconcile structure: native Doc renames / adds / deletes of whole sections never update session.spec.sections. So anything that reads spec structure — conformance (addon_conformance ~7927), the FE "Check structure" coaching, the add-on sidebar's section list — silently mis-flagged whenever the user edited headings in the Doc. Observed (P5.11 conformance testing, KLAIR-2920):
* A natively-deleted GM Commentary still showed "Structure looks good" (no ADD gap surfaced).
* Q3-renamed sections still carried their stale Q2 spec titles in the sidebar.
This change makes the existing /addon/* reconcile the single load-bearing surface where the session catches up to the Doc — so conformance and the sidebar both see the same structure the user is staring at.
## Changes
* Content-anchored structural pass (_apply_structural_reconcile in routers/board_doc_router.py):
* Rename — unmatched spec body and unmatched Doc body with symmetric normalised-token containment ≥ RENAME_SIMILARITY (0.6) paired greedily 1:1; spec section's title ← Doc title, content ← Doc body, while id / section_type / required_data are preserved.
* Add — remaining unmatched Doc heading becomes a SectionConfig(section_type=CUSTOM) via _slugify_section_id + _unique_section_id, appended to spec.sections with the Doc body in generated_sections.
* Delete — remaining unmatched spec section is removed via remove_section (cascade: comments, edit status, refresh-orphans, user_commentary, section_feedback) only when a distinctive line of its content (≥ DELETE_CONTENT_ABSENT_MIN_LINE_LEN = 20 chars, non-boilerplate) is absent from the live Doc text. Otherwise preserved + logged with skipped_reason=delete_content_present.
* is_degraded_parse skip — checked against both the at-entry mapping AND re-checked against fresh state inside the closure. Degraded parse ⇒ structural pass skipped entirely (content merge still runs).
* Content-absent delete disambiguator — distinctive-line scan is the load-bearing guard against destructive delete on a parse-miss / restructure scenario (heading boundary failed but the blob is still in the Doc under a different parent).
* In-closure re-derivation — the structural diff is computed AND applied inside the _save_with_merge_retry_or_raise closure against the freshly re-fetched s.spec / s.generated_sections. A snapshot-derived diff would clobber a concurrent sibling-tab writer (parity with KLAIR-2932's H1 fix for the content merge). The outer no-op gate is widened to detect pure-structural diffs (dropped Drive titles / unmatched spec ids) so a "content unchanged but structure changed" reconcile still enters the closure.
* addon_conformance reconcile wiring — added session = await _reconcile_addon_session_from_doc(session) before compute_conformance. Closes the loop: native heading delete → structural reconcile drops the spec section → conformance emits the missing canonical-section ADD gap. Reconcile is best-effort (its own broad-catch envelope), so a Drive failure still degrades to the pre-KLAIR-2934 read-only behaviour.
Contract surface affected: none. _apply_structural_reconcile is module-private to board_doc_router.py; RENAME_SIMILARITY / DELETE_CONTENT_ABSENT_MIN_LINE_LEN are new public module constants on the router (referenced by tests).
## Breaking changes
None — additive structural pass inside the existing _reconcile_addon_session_from_doc envelope plus a reconcile call inside addon_conformance. merge_sections_3way, the in-app /sync, /reload-from-doc, and the other /addon/* routes are untouched.
## Test plan
uv run pytest tests/board_doc/test_addon_structural_reconcile.py -v
11 new tests pin the load-bearing invariants:
| Test | Pins |
|---|---|
| test_structural_rename_updates_title_preserves_id_and_content | Rename: title swap, id/type/required_data preserved, content kept |
| test_structural_rename_with_minor_edit_still_pairs | Rename + minor edit: body lightly edited but ≥ threshold still pairs |
| test_structural_add_new_heading_appends_custom_section | Add: new Doc heading → CUSTOM SectionConfig + content |
| test_structural_delete_when_content_absent_removes_section | Delete: heading + distinctive content absent from Doc → removed (+ cascade) |
| test_structural_delete_preserved_when_content_present_in_doc | NOT-delete safety invariant: distinctive content still in Doc → preserved + skipped_reason=delete_content_present logged |
| test_structural_pass_skipped_on_degraded_parse | Degraded safety invariant: structural pass skipped; nothing wiped |
| test_structural_ambiguous_rename_preserves_both_sides | Ambiguity ⇒ preserve (one Doc body matching two spec sections → no rename) |
| test_structural_pass_is_idempotent_on_unchanged_doc | Idempotent: revision pre-gate; second reconcile is a no-op |
| test_structural_pass_re_derives_against_fresh_state_on_cas_retry | In-closure re-derivation observes a sibling write injected on the merge-retry re-fetch |
| test_addon_conformance_emits_add_gap_after_native_delete_reconcile | Conformance integration: native delete + reconcile → GM Commentary ADD gap |
| test_structural_constants_are_module_level_names | Constants exposed as module-level symbols (eval-check grep + future contract) |
Pre-existing add-on / reconcile / conformance / section-CRUD tests stay green:
uv run pytest tests/board_doc/test_addon_structural_reconcile.py \tests/board_doc/test_addon_reconcile.py \
tests/board_doc/test_addon_conformance.py \
tests/board_doc/test_addon_chat.py \
tests/board_doc/test_addon_review_run.py \
tests/board_doc/test_addon_propose.py \
tests/board_doc/test_addon_section_crud.py
→ 97 + 11 = 108 passed.
## Verification artifact
$ uv run pytest tests/board_doc/test_addon_structural_reconcile.py -v============================= test session starts ==============================
collected 11 items
tests/board_doc/test_addon_structural_reconcile.py::test_structural_rename_updates_title_preserves_id_and_content PASSED [ 9%]
tests/board_doc/test_addon_structural_reconcile.py::test_structural_rename_with_minor_edit_still_pairs PASSED [ 18%]
tests/board_doc/test_addon_structural_reconcile.py::test_structural_add_new_heading_appends_custom_section PASSED [ 27%]
tests/board_doc/test_addon_structural_reconcile.py::test_structural_delete_when_content_absent_removes_section PASSED [ 36%]
tests/board_doc/test_addon_structural_reconcile.py::test_structural_delete_preserved_when_content_present_in_doc PASSED [ 45%]
tests/board_doc/test_addon_structural_reconcile.py::test_structural_pass_skipped_on_degraded_parse PASSED [ 54%]
tests/board_doc/test_addon_structural_reconcile.py::test_structural_ambiguous_rename_preserves_both_sides PASSED [ 63%]
tests/board_doc/test_addon_structural_reconcile.py::test_structural_pass_is_idempotent_on_unchanged_doc PASSED [ 72%]
tests/board_doc/test_addon_structural_reconcile.py::test_structural_pass_re_derives_against_fresh_state_on_cas_retry PASSED [ 81%]
tests/board_doc/test_addon_structural_reconcile.py::test_addon_conformance_emits_add_gap_after_native_delete_reconcile PASSED [ 90%]
tests/board_doc/test_addon_structural_reconcile.py::test_structural_constants_are_module_level_names PASSED [100%]
======================== 11 passed, 1 warning in 0.79s =========================
The two safety-invariant cases that pin the conservative-bias rules are explicitly green:
* test_structural_delete_preserved_when_content_present_in_doc — the parse-miss case where a section's heading went unstyled but its distinctive body line is still visible elsewhere in the Doc. Without this guard the structural pass would destructively wipe content the user still has.
* test_structural_pass_skipped_on_degraded_parse — a degraded parse (many title-matched sections come back empty) forces the structural pass to skip entirely; the at-entry session content and spec are byte-identical before/after.
Static-analysis + formatter pass:
$ uv run ruff format routers/board_doc_router.py tests/board_doc/test_addon_structural_reconcile.py2 files left unchanged
$ uv run ruff check routers/board_doc_router.py tests/board_doc/test_addon_structural_reconcile.py
All checks passed!
$ uv run pyright routers/board_doc_router.py
0 errors, 0 warnings, 0 informations
Refs KLAIR-2906 (epic), follows KLAIR-2932 (content reconcile), surfaced by KLAIR-2920 P5.11 conformance mis-flags.
<!-- CURSOR_AGENT_PR_BODY_END -->
<div><a href="https://cursor.com/agents/bc-b06c40db-a26e-4b0e-b269-2b0c70b04af7"><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-b06c40db-a26e-4b0e-b269-2b0c70b04af7"><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>