Skip to content

fix(wiki): one reading of a draft section for every consumer (#589) - #590

Merged
cdeust merged 3 commits into
mainfrom
fix/compile-mirror-headings
Sep 16, 2026
Merged

cdeust merged 3 commits into
mainfrom
fix/compile-mirror-headings

Conversation

@cdeust

@cdeust cdeust commented Sep 16, 2026

Copy link
Copy Markdown
Owner

Summary

A draft's sections were read in four places, each spelling out the shape for itself and disagreeing:

  • core/draft_compiler.py skipped a section whose heading was falsy, so the published Markdown never carried an untitled section.
  • handlers/wiki_compile.py built the wiki.pages mirror as {heading: body} with no guard, so the same section landed in the row under an empty key. A file and its mirror described the same page differently.
  • core/draft_curator.py::_missing_required_sections called .strip() on whatever it found, raising AttributeError inside evaluate_draft for a heading that is not text, and on a Section object whose heading is empty.
  • handlers/wiki_refine.py had its own _is_text since ADR-1070, the only one of the four that was right, and ADR-1070 only closed the client entry point: a draft written by wiki_synthesize or before that change still reaches the other three.

mcp_server/shared/wiki_sections.py now states the shape once (is_text, heading_of, body_of, titled_sections) and all four read a section through it (ADR-1071). wiki_refine_draft also refuses two sections carrying the same heading, which the mirror's mapping silently deduped, the second body replacing the first.

Closes #589

Type of change

  • Bug fix (non-breaking change which fixes an issue)

Test plan

  • New tests_py/shared/test_wiki_sections.py: the four functions over dicts, Section objects, missing fields, blank and non-text values.
  • New tests_py/handlers/test_wiki_compile_sections_mirror.py: compiles a draft holding a blank heading, a missing heading and a numeric heading on SQLite, then asserts wiki.pages.sections holds only the titled section and the published Markdown omits the orphan prose.
  • New tests_py/core/test_draft_curator_sections.py: four malformed shapes through evaluate_draft, reported as a missing required section instead of raising, plus a well-formed section still approving.
  • New test_duplicate_headings_are_refused in tests_py/handlers/test_wiki_refine_sections.py.
  • Three of those fail on the previous code, one with the AttributeError this fixes.
  • Full suite: 8716 passed, 8 skipped, no errors.
  • ruff check, ruff format --check: clean. pyright mcp_server/: 0 errors. scripts/check_craftsmanship.py --base origin/main: OK. scripts/check_doc_claims.py: OK.

Audit notes

  • shared/ imports the standard library only, so core, handlers and infrastructure can all read a section the same way (docs/module-inventory.md § Dependency Rules, where the new module is catalogued).
  • The curator's behavior for a well-formed draft is unchanged; a malformed section now reads as a missing required section.

Coding-standards compliance

  • §2.2 Layer dependency direction preserved.
  • §4.1 / §4.2 New module is 51 lines; no method over the caps.
  • §8 No numeric constant added.
  • §9 No dead code, no TODO.

Breaking changes

None outward. A wiki_refine_draft call submitting two sections with the same heading now returns a validation error rather than silently keeping the last body.

Reviewer checklist

  • CHANGELOG.md updated under Unreleased / Fixed.
  • Documentation updated (ADR-1071, docs/module-inventory.md).
  • No secrets or PII in the diff.
  • CI passes on the latest commit.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Cz7A6goxVzfKL9NT7k4zDH

The Markdown renderer skipped a section with no heading while
handlers/wiki_compile.py mirrored it into wiki.pages.sections under an
empty key, so a file and its row described the same page differently.
core/draft_curator.py called .strip() on whatever the section held,
raising AttributeError on a heading that was not text, and on a Section
object whose heading was empty.

mcp_server/shared/wiki_sections.py states the shape once (is_text,
heading_of, body_of, titled_sections) and the renderer, the mirror, the
curator's required-section lookup and wiki_refine_draft's validation all
read a section through it (ADR-1071). wiki_refine_draft also refuses two
sections carrying the same heading, which the mirror's mapping silently
deduped.

Three of the new cases fail on the previous code, one with the
AttributeError. Full suite: 8716 passed, 8 skipped.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cz7A6goxVzfKL9NT7k4zDH
@cdeust

cdeust commented Sep 16, 2026

Copy link
Copy Markdown
Owner Author

ZETETIC-REVIEW: APPROVE

Summary

PR #590 introduces mcp_server/shared/wiki_sections.py (stdlib only: is_text, heading_of, body_of, titled_sections) and routes core/draft_compiler.py, handlers/wiki_compile.py, core/draft_curator.py and handlers/wiki_refine.py::_validate_against_contract through it. It closes both gaps from issue #589 (the empty-key mirror entry, the .strip() AttributeError in the curator) and adds a duplicate-heading refusal in wiki_refine_draft. Evidence gathered below; found no defect, several observations recorded as non-blocking.

1. Behavior equivalence (core/draft_compiler.py)

Reproduced the old inline loop vs. the new titled_sections() side by side (repro script, cases below):

case old new same?
well-formed ({"heading":"Overview","body":"text"}) [('Overview','text')] same yes
numeric heading {"heading":7,"body":"x"} [('7','x')] [] (dropped) changed
numeric body {"heading":"Overview","body":7} [('Overview','7')] [('Overview','')] changed
whitespace-only body " " kept as " " emptied to "" changed
heading with padding " Overview " rendered unstripped rendered stripped to "Overview" changed
"", None, 0, False heading dropped dropped yes

The 7 to dropped/emptied case is exactly the scenario the task asked me to check for. It is a real behavior change, but it is the intended one: ADR-1071's is_text requires an actual non-blank str, so a numeric heading/body, previously silently coerced via str()/or "", is now treated the same "not usable" way the compiler already treated a blank heading, matching what the curator and the mirror now do. It is covered by tests (tests_py/shared/test_wiki_sections.py::test_titled_sections_drops_what_a_page_cannot_carry, tests_py/handlers/test_wiki_compile_sections_mirror.py). For a genuinely well-formed draft (both fields already non-blank text), only the whitespace-stripping-on-render difference remains, which is a cosmetic tightening, not a functional regression. _missing_required_sections/_section_is_filled behavior for a placeholder body is unchanged. titled_sections still returns (heading, body) and _section_is_filled still applies its own length/placeholder check on body downstream (mcp_server/core/draft_curator.py:58-59, untouched by the diff).

Non-blocking: the PR's "Audit notes" state only that curator behavior for a well-formed draft is unchanged; it doesn't call out that the compiler's malformed heading/body handling changed too. Worth a one-line mention in the ADR's Consequences section for future readers, but the change is correct and tested, not blocking.

2. Other raw consumers of sections

Grepped "sections"/.sections across mcp_server/ (excluding tests):

  • core/wiki_staleness.py:140 (harvest_page_refs_typed) and core/wiki_symbol_extract.py:140 (harvest_page_symbols) read page.get("sections"). They operate on published pages (dict-shaped, built by wiki_compile via titled_sections), so they now see the corrected mirror. Both also carry a defensive elif isinstance(sections, list) fallback that reads only body (never heading), so they were never exposed to the heading bug either way. Non-blocking DRY observation, not a defect.
  • core/wiki_source_backfill.py:82 (_from_body), same: reads the published page's dict-shaped sections, consistent with the mirror.
  • core/draft_curator.py:93 (evaluate_draft) reads the raw draft list and passes it into _missing_required_sections, which now goes through titled_sections. Confirmed via tests_py/core/test_draft_curator_sections.py.
  • handlers/wiki_synthesize.py:217-219 builds sections from draft.sections (a Section dataclass with guaranteed-text fields at construction time), not affected.
  • handlers/wiki_refine.py:175,226,338: wiki_get_draft echoes the raw stored draft (informational only, no rendering decision); _validate_against_contract is the one that now goes through heading_of/body_of/titled_sections.
  • handlers/wiki_verify.py:94, handlers/wiki_migrate.py:204 build/consume dict-shaped sections from parsed Markdown or migration frontmatter, unrelated to draft-section reading.

No consumer disagrees with the corrected mirror; no raw reader was missed.

3. Layer placement and stdlib only claim

mcp_server/shared/wiki_sections.py:12-14 imports only __future__ and typing.Any. Matches docs/module-inventory.md § Dependency Rules (shared/ is Python stdlib only, reachable from core/infrastructure/handlers/server). The new inventory line (docs/module-inventory.md diff) accurately describes the module. Confirmed correct.

4. Mutation testing (fix strength)

Ran against mcp_server/shared/wiki_sections.py (restored after each, git status --short clean afterward):

  • Mutant A: titled_sections keeps sections whose heading is None (drop the if heading is not None: guard). Result: 2 tests failed (test_wiki_sections.py::test_titled_sections_drops_what_a_page_cannot_carry, test_wiki_compile_sections_mirror.py::test_the_mirror_holds_only_the_sections_the_page_carries).
  • Mutant B: heading_of skips .strip() (return heading if is_text(heading) else None). Result: 1 test failed (test_wiki_sections.py::test_heading_of[padded]).
  • Restored via cp /tmp/wiki_sections_orig.py mcp_server/shared/wiki_sections.py; git status --short confirmed clean.

Both mutants killed. Suite is mutation-strong on the changed lines.

5. Gates (all run in the worktree, .venv already synced)

  • UV_NO_SYNC=1 uv run ruff check gives All checks passed!
  • UV_NO_SYNC=1 uv run ruff format --check gives 1463 files already formatted
  • UV_NO_SYNC=1 uv run python scripts/check_craftsmanship.py --base origin/main gives Craftsmanship gate: OK
  • UV_NO_SYNC=1 uv run python scripts/check_doc_claims.py gives doc claims OK (1 declared not a claim exemption(s))
  • .venv/bin/python -m pyright mcp_server/ gives 0 errors, 0 warnings, 0 informations
  • CORTEX_CLAUDE_DIR=<throwaway> UV_NO_SYNC=1 uv run pytest -q gives 8716 passed, 8 skipped, 2 warnings, 459 subtests passed in 266.17s. Matches the PR body's claim exactly.

6. Factual claims (PR body / ADR-1071 / CHANGELOG / module-inventory)

  • Issue [bug] a headless or non-text section heading still reaches wiki.pages.sections and the curator #589's three points (empty-key mirror, .strip() AttributeError, silent dedup of duplicate headings) are each addressed: verified in mcp_server/handlers/wiki_compile.py:158, mcp_server/core/draft_curator.py diff, and mcp_server/handlers/wiki_refine.py's new seen/duplicate check plus tests_py/handlers/test_wiki_refine_sections.py::test_duplicate_headings_are_refused.
  • "Three of those fail on the previous code, one with the AttributeError": plausible given the diff (the curator's non-string-heading case raises AttributeError on .strip() in the old code; the other three new test files exercise behavior the old code didn't have). Not independently re-verified against the pre-fix commit, but consistent with the diff and not load-bearing for the verdict.
  • Section 4.1/4.2 "new module is 51 lines": confirmed, file is exactly 51 lines including docstring.
  • CHANGELOG entry accurately describes the fix; wiki/manifest.json ADR-1071 entry present and correctly pathed; docs/module-inventory.md line accurate (checked above).
  • "Breaking changes: none outward, duplicate heading now returns a validation error": confirmed via _validate_against_contract diff and its test.

Issues

Blocking

None found.

Non-blocking

  • ADR-1071 / PR "Audit notes" could note the compiler's malformed heading/body handling changed (numeric input now dropped/emptied) alongside the curator note, for a future reader diffing behavior. See section 1 above.
  • core/wiki_staleness.py:140-147 and core/wiki_symbol_extract.py:140-147 each carry their own ad hoc isinstance(sections, list) body-extraction fallback, duplicating a smaller slice of what shared/wiki_sections.py now centralizes. Not a defect (they never touch heading), but a candidate for a future body_of-based simplification.

Hand-offs

None. No defect requiring engineer/architect/security-auditor escalation.

Verdict

APPROVE

…589)

The review of PR #590 measured the delta case by case: the old loop
coerced with str(), so a numeric heading rendered as 7 and a numeric body
rendered under its heading, while titled_sections drops both. A padded
heading now renders stripped and a whitespace-only body renders empty. A
well-formed draft renders exactly as before. ADR-1071's consequences say
so; no code or test changed.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cz7A6goxVzfKL9NT7k4zDH
@cdeust

cdeust commented Sep 16, 2026

Copy link
Copy Markdown
Owner Author

ZETETIC-REVIEW: REQUEST_CHANGES

Summary

PR #590 head moved to 505ac5a, a docs-only follow-up to 11a3b05 that answers my first non-blocking note from the prior review. Confirmed the diff touches only the two ADR-1071 files. The added paragraph is mostly accurate against what I measured, but one clause overstates the delta: it says both the numeric-heading and numeric-body cases are "now dropped," when only the numeric-heading case drops the whole section. The numeric-body case keeps its section (heading intact) with an emptied body, not dropped. Requesting a small wording fix before approval.

Diff scope

git diff 11a3b059..505ac5a7 --stat:

docs/adr/ADR-1071-one-reading-of-a-draft-section-for-every-consumer.md          | 2 ++
wiki/adr/cortex/1071-one-reading-of-a-draft-section-for-every-consumer.md       | 2 ++
2 files changed, 4 insertions(+)

Both files add the identical paragraph (wiki page and its docs/adr mirror stay in sync, as required). No code, test, or other doc file touched.

Wording check against measured behavior

Added paragraph: "Changed, for malformed data only: the renderer used to coerce with str(), so a numeric heading rendered as 7 and a numeric body rendered under its heading. Both are now dropped, a whitespace-only body renders empty, and a padded heading renders stripped. A well-formed draft renders exactly as before, checked case by case against the previous loop."

Re-ran the exact cases against mcp_server.shared.wiki_sections.titled_sections on this head (505ac5a, unchanged code since 11a3b05):

  • titled_sections([{"heading": 7, "body": "x"}]) gives []. The whole section is dropped. Matches "numeric heading... now dropped."
  • titled_sections([{"heading": "Overview", "body": 7}]) gives [('Overview', '')]. The section is not dropped: the heading survives and the section stays on the page, only the body becomes an empty string. This does not match "Both are now dropped."
  • Whitespace-only body: confirmed empties to "", matches "a whitespace-only body renders empty."
  • Padded heading: confirmed strips to the bare heading, matches "a padded heading renders stripped."
  • Well-formed draft: confirmed identical to the old loop, matches "renders exactly as before."

So four of the five clauses are accurate. "Both are now dropped" overstates the numeric-body case: it implies the section disappears from the published page the way the numeric-heading case does, but it actually stays, under its valid heading, with an empty body where the number used to render. That is a materially different reader-facing outcome (an empty section heading with no prose, versus no section at all), so it is worth the correction.

Gates run (docs-only delta)

  • UV_NO_SYNC=1 uv run python scripts/check_doc_claims.py gives doc claims OK (1 declared not-a-claim exemption(s)).
  • UV_NO_SYNC=1 uv run python scripts/check_project_wiki.py gives Project wiki mirrors match their canonical sources.

Neither gate is scoped to catch this kind of semantic inaccuracy (they check claim-exemption bookkeeping and mirror sync, not behavioral correctness of prose), which is why the manual re-check above is the one that matters here.

Issues

Blocking

  • docs/adr/ADR-1071-one-reading-of-a-draft-section-for-every-consumer.md (new paragraph) and its mirror wiki/adr/cortex/1071-one-reading-of-a-draft-section-for-every-consumer.md (same paragraph): "Both are now dropped" overstates the numeric-body case. Required change: reword to distinguish the two outcomes, for example "the numeric heading case is now dropped outright; the numeric body case keeps its section under the valid heading, with the body rendering empty where the number used to appear."

Non-blocking

None new. The duplicated body-only fallback reader in wiki_staleness.py/wiki_symbol_extract.py from the prior review remains a future DRY candidate, out of scope for this PR, no action needed here.

Hand-offs

None.

Verdict

REQUEST_CHANGES. Minimum change to unblock: correct the "Both are now dropped" clause in both ADR-1071 files (docs/adr mirror and wiki/adr/cortex) to state the numeric-body case keeps its section with an emptied body rather than being dropped.

)

The previous wording said a numeric heading and a numeric body are "both
now dropped". Measured on this head: titled_sections([{"heading":
"Overview", "body": 7}]) returns [("Overview", "")], so that section
keeps its place under its valid heading and renders with an empty body,
while a section whose heading is not text is dropped outright. The two
outcomes are now stated separately, as the review of PR #590 required.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cz7A6goxVzfKL9NT7k4zDH
@cdeust

cdeust commented Sep 16, 2026

Copy link
Copy Markdown
Owner Author

ZETETIC-REVIEW: APPROVE

Summary

PR #590 head moved to f2646ee, a second docs-only follow-up to 505ac5a that fixes the overstatement I flagged in round 2. Confirmed the diff touches only the two ADR-1071 files, and every clause in the reworded paragraph now matches my own measurement. No further changes required.

Diff scope

git diff 505ac5a7..f2646ee7 --stat:

docs/adr/ADR-1071-one-reading-of-a-draft-section-for-every-consumer.md          | 2 +-
wiki/adr/cortex/1071-one-reading-of-a-draft-section-for-every-consumer.md       | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)

Both files replace the same one sentence, wiki page and its docs/adr mirror stay in sync. No code, test, or other doc file touched.

Wording check against measured behavior

New paragraph: "Changed, for malformed data only: the renderer used to coerce with str(). A section whose heading is not text is now dropped outright, where it used to render under a heading such as 7. A section whose body is not text keeps its place under its valid heading and renders with an empty body, where the number used to appear. A whitespace-only body renders empty and a padded heading renders stripped. A well-formed draft renders exactly as before, checked case by case against the previous loop."

Re-ran all five cases against mcp_server.shared.wiki_sections.titled_sections on this head:

  • titled_sections([{"heading": 7, "body": "prose"}]) gives []. Matches "dropped outright."
  • titled_sections([{"heading": "Overview", "body": 7}]) gives [('Overview', '')]. Matches "keeps its place under its valid heading and renders with an empty body." This is the clause that was wrong in the previous round ("Both are now dropped"); it is now accurate.
  • titled_sections([{"heading": "Overview", "body": " "}]) gives [('Overview', '')]. Matches "a whitespace-only body renders empty."
  • titled_sections([{"heading": " Overview ", "body": "text"}]) gives [('Overview', 'text')]. Matches "a padded heading renders stripped."
  • titled_sections([{"heading": "Overview", "body": "text"}]) gives [('Overview', 'text')], unchanged from the old inline loop. Matches "renders exactly as before."

All five clauses hold. No remaining overstatement.

Gates run (docs-only delta)

  • UV_NO_SYNC=1 uv run python scripts/check_doc_claims.py gives doc claims OK (1 declared not-a-claim exemption(s)).
  • UV_NO_SYNC=1 uv run python scripts/check_project_wiki.py gives Project wiki mirrors match their canonical sources.

Issues

Blocking

None.

Non-blocking

None new. The duplicated body-only fallback reader in wiki_staleness.py/wiki_symbol_extract.py, noted in round 1, remains a future DRY candidate, out of scope for this PR.

Hand-offs

None.

Verdict

APPROVE

@cdeust
cdeust merged commit d548fc1 into main Sep 16, 2026
28 checks passed
@cdeust
cdeust deleted the fix/compile-mirror-headings branch September 16, 2026 08:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[bug] a headless or non-text section heading still reaches wiki.pages.sections and the curator

1 participant