Status: proposal, 2026-08-04. Design worked out with John this session. Related: JSONHTL_SPEC (the "semantic document tree, like the DOM" design model), proposals/verb-taxonomy, gdata-server/todo ("section-aware edits").
In the flat model (a list of blocks where a heading just implies hierarchy by position) the request "append a paragraph to the outer section, after its sub-sections" is inexpressible. Example:
## Discussion <- target
Para A
Para B
### Causes <- sub-heading
Para C
### Fixes
Para D
## Next Section
"At the outer level" (belongs to Discussion) forces placement BEFORE ### Causes — because anything after a sub-heading reads as part of that sub-heading. "Later" (after the sub-sections) forces placement after Para D, where it reads as part of ### Fixes — the opposite of outer level. You can have one or the other, not both, precisely because the hierarchy is implied, not real.
A real tree removes the contradiction: a section is a container that holds its own blocks AND its child sections, so a trailing paragraph can be an unambiguous direct child of the outer section, after the inner ones — exactly as an HTML <section> works, and exactly why CSS selectors are well-defined over the DOM.
A new base block type. title is the heading text (plain string, as heading.text is today); the rendered heading level is the nesting depth (a section renders its title as hN where N = base depth + nesting), so no explicit level is needed. content is an ordinary block list that may itself contain nested section blocks.
{"section": {
"title": "Discussion",
"content": [
{"para": ["Para A"]},
{"para": ["Para B"]},
{"section": {"title": "Causes", "content": [{"para": ["Para C"]}]}},
{"section": {"title": "Fixes", "content": [{"para": ["Para D"]}]}},
{"para": ["Concluding paragraph \u2014 unambiguously belongs to Discussion, after its sub-sections."]}
]
}}
This is the HTML <section><h/>…<section>…</section>…</section> shape. An optional explicit level may override depth-derived level for edge cases; normally omit it.
heading+following-blocks, and the new nested section. Nothing existing breaks.heading of level L opens a section; blocks until the next heading of level <= L (recursively) become its content. Offer it as a tool (and as a one-off pass over the corpus).section blocks. Mixed documents are legal but discouraged; the normaliser migrates them.heading remains valid (legacy) so migration can be gradual, per the JSONHTL_SPEC "converge in time" note.> = direct sub-section; descendant (space) = any depth. "Discussion > Fixes" vs "Discussion Fixes".["Discussion", "Fixes"].:nth(k) disambiguates a repeated title at a level (e.g. two "Completed").:nth-child(n) = the nth block of any type; :nth-of-type(t)(n) = the nth block of type t (para/list/table/section). So "after paragraph 2" = para:nth-of-type(2), distinct from "after block 2" = :nth-child(2).Operate on a section's content list (a real array). Vocabulary mirrors Perl/Python list ops so it is obvious and consistent:
| Op | Perl/Python | Effect |
|---|---|---|
| section.push | push / append | append block(s) at end of content ("outer level, later" is now well-defined) |
| section.unshift | unshift / insert(0) | prepend block(s) at start of content |
| section.pop | pop | remove & return last block |
| section.shift | shift / pop(0) | remove & return first block |
| section.insert | splice / insert(i) | insert before/after a position or child selector |
| section.replace | a[i]=x | replace a block at a position/selector |
| section.remove | del / splice | delete a block (or range) at a position/selector |
| section.read | — | return content as a scoped outline (index, type, title/preview, id) |
insert clamps out-of-range (Python-style); replace/remove error out-of-range; pop/shift on empty content -> error.push/insert a section block — nesting is explicit, so there is no heading-level guesswork (the flat model's trap).rev, affected block id(s), new length; pop/shift return the removed block.if_rev supported. In a batch, each op resolves against the state left by the previous op (so rename-then-push must use the new title).details is already a real container (its own content); it is consistent with section. v1 selectors address the top-level section tree only, not into details (revisit later).put, which regenerates all ids on block-count change).:nth(k).at = root addresses the whole content list; push appends at document end.Per the Sync Rule, a structural change touches: JSONHTL_SPEC (add section as a base block; document level-by-depth), JSONHTL_SCHEMA (recursive block_section), gdata-server/linter (register + rules), notes_web.py and notes_browser.py (render section -> heading + recursed content), gdata_mcp_server.py (the section.* op family, mirroring table_op), notes_client.py, and agent writers.
section to JSONHTL_SPEC/SCHEMA/linter; both renderers render it (accept both flat + nested). Non-breaking. Ship + observe.:nth, nth-child/of-type) + core ops push/unshift/pop/shift/read via a section_op dispatcher (REST + MCP), keeping the ids sidecar in step.insert/replace/remove with before/after + child selectors.prefix:true / :starts-with() qualifier if we find we miss it — kept here so the idea is not lost.relevel to fix nesting depth at the destination (moving an H2 subtree under an H3 otherwise breaks nesting).details.✅ Phase 0 (allow + render the section block) shipped 2026-08-04: JSONHTL_SPEC 0.4, JSONHTL_SCHEMA v4, gdata-server/linter v5; both HTML renderers and jsonhtl_md render sections; unit + live tested. ✅ Phase 1 (normaliser) shipped 2026-08-04: jsonhtl_sections.py (nest_content / flatten_content / normalise_note), round-trip validated against the whole public corpus (472/472 identical). Phases 2-3 (the section.* editing ops) not yet started.