diff --git a/lib/content/conformance.sh b/lib/content/conformance.sh
index 4901d664..52eae428 100755
--- a/lib/content/conformance.sh
+++ b/lib/content/conformance.sh
@@ -15,7 +15,7 @@ if [ ! -x "$SX_SERVER" ]; then
fi
fi
-SUITES=(block doc render api meta page page-full markdown text section compose tree-edit move clone query toc anchor outline flatten transform normalize find-replace stats summary index table callout media data wire validate store snapshot crdt crdt-store sync md-import md-doc fed)
+SUITES=(block doc render api meta page page-full markdown text section compose tree-edit move clone query toc anchor outline flatten transform normalize find-replace stats summary index table callout media data wire validate store snapshot crdt crdt-tree crdt-store sync md-import md-doc fed)
OUT_JSON="lib/content/scoreboard.json"
OUT_MD="lib/content/scoreboard.md"
@@ -72,6 +72,7 @@ run_suite() {
(load "lib/content/store.sx")
(load "lib/content/snapshot.sx")
(load "lib/content/crdt.sx")
+(load "lib/content/crdt-tree.sx")
(load "lib/content/crdt-store.sx")
(load "lib/content/sync.sx")
(load "lib/content/md-import.sx")
diff --git a/lib/content/crdt-tree.sx b/lib/content/crdt-tree.sx
new file mode 100644
index 00000000..9ba58d49
--- /dev/null
+++ b/lib/content/crdt-tree.sx
@@ -0,0 +1,164 @@
+;; content-on-sx — nested-tree CvRDT.
+;;
+;; Extends the flat CvRDT (crdt.sx) to a TREE: each element carries a `parent`
+;; (the id of its containing section, "" = root) alongside its Logoot position.
+;; Merge is still a join — it reuses crdt.sx's position/register/field merges and
+;; adds parent (immutable, set once at insert). Materialisation rebuilds the
+;; ordered tree: root = elements with parent "", a section's children = elements
+;; whose parent is that section's id, each sorted by position. Commutative,
+;; associative, idempotent like the flat layer; concurrent inserts into the same
+;; or different parents converge deterministically.
+;;
+;; Requires (loaded by harness): crdt.sx (merge helpers + live/sort/materialise
+;; bits), block.sx, doc.sx, section.sx (mk-section).
+
+(define ctt-merge-parent (fn (p1 p2) (if (= p1 nil) p2 p1)))
+
+(define ctt-merge-element (fn (e1 e2) {:fields (crdt-merge-fields (get e1 :fields) (get e2 :fields)) :parent (ctt-merge-parent (get e1 :parent) (get e2 :parent)) :id (get e1 :id) :type (crdt-merge-type (get e1 :type) (get e2 :type)) :deleted (or (= (get e1 :deleted) true) (= (get e2 :deleted) true)) :pos (crdt-merge-pos (get e1 :pos) (get e2 :pos))}))
+
+(define
+ ctt-add-element
+ (fn
+ (state elem)
+ (let
+ ((elems (get state :elements)) (id (get elem :id)))
+ (let
+ ((existing (get elems id)))
+ (assoc
+ state
+ :elements (assoc
+ elems
+ id
+ (if (= existing nil) elem (ctt-merge-element existing elem))))))))
+
+;; ── ops as partial-element contributions ──
+(define
+ crdt-tree-insert
+ (fn
+ (state id type pos parent fields ts actor)
+ (ctt-add-element state {:fields (crdt-build-fields fields ts actor) :parent parent :id id :type type :deleted false :pos pos})))
+
+(define
+ crdt-tree-update
+ (fn (state id fname value ts actor) (ctt-add-element state {:fields (assoc {} fname {:ts ts :actor actor :value value}) :parent nil :id id :type nil :deleted false :pos nil})))
+
+(define crdt-tree-delete (fn (state id) (ctt-add-element state {:fields {} :parent nil :id id :type nil :deleted true :pos nil})))
+
+;; ── state merge (join) ──
+(define
+ ctt-merge-loop
+ (fn
+ (ids ea eb acc)
+ (if
+ (= (len ids) 0)
+ acc
+ (let
+ ((id (first ids)))
+ (let
+ ((x (get ea id)) (y (get eb id)))
+ (ctt-merge-loop
+ (rest ids)
+ ea
+ eb
+ (assoc
+ acc
+ id
+ (cond
+ ((= x nil) y)
+ ((= y nil) x)
+ (else (ctt-merge-element x y))))))))))
+
+(define crdt-tree-merge (fn (a b) {:elements (ctt-merge-loop (crdt-union-keys (get a :elements) (get b :elements)) (get a :elements) (get b :elements) {})}))
+
+(define
+ crdt-tree-merge-all
+ (fn
+ (states)
+ (if
+ (= (len states) 0)
+ (crdt-empty)
+ (if
+ (= (len states) 1)
+ (first states)
+ (crdt-tree-merge (first states) (crdt-tree-merge-all (rest states)))))))
+
+;; ── op interpreter ──
+(define
+ crdt-tree-op-insert
+ (fn (id type pos parent fields ts actor) {:ts ts :fields fields :parent parent :id id :type type :op "insert" :actor actor :pos pos}))
+
+(define crdt-tree-op-update (fn (id field value ts actor) {:ts ts :field field :id id :op "update" :actor actor :value value}))
+
+(define crdt-tree-op-delete (fn (id) {:id id :op "delete"}))
+
+(define
+ crdt-tree-apply
+ (fn
+ (state op)
+ (let
+ ((k (get op :op)))
+ (cond
+ ((= k "insert")
+ (crdt-tree-insert
+ state
+ (get op :id)
+ (get op :type)
+ (get op :pos)
+ (get op :parent)
+ (get op :fields)
+ (get op :ts)
+ (get op :actor)))
+ ((= k "update")
+ (crdt-tree-update
+ state
+ (get op :id)
+ (get op :field)
+ (get op :value)
+ (get op :ts)
+ (get op :actor)))
+ ((= k "delete") (crdt-tree-delete state (get op :id)))
+ (else (error (str "unknown crdt-tree op: " k)))))))
+
+(define
+ crdt-tree-apply-all
+ (fn
+ (state ops)
+ (if
+ (= (len ops) 0)
+ state
+ (crdt-tree-apply-all (crdt-tree-apply state (first ops)) (rest ops)))))
+
+;; ── materialise to a Phase-1 document (rebuild the ordered tree) ──
+(define
+ ctt-children
+ (fn
+ (state parent-id)
+ (crdt-sort-by-pos
+ (filter
+ (fn (e) (= (get e :parent) parent-id))
+ (crdt-live-elements state)))))
+
+(define
+ ctt-element->block
+ (fn
+ (state e)
+ (if
+ (= (get e :type) "section")
+ (mk-section
+ (get e :id)
+ (map
+ (fn (c) (ctt-element->block state c))
+ (ctt-children state (get e :id))))
+ (crdt-element->block e))))
+
+(define
+ crdt-tree-materialize
+ (fn
+ (doc-id state)
+ (doc-new
+ doc-id
+ (map (fn (e) (ctt-element->block state e)) (ctt-children state "")))))
+
+(define
+ crdt-tree-order
+ (fn (state) (map (fn (e) (get e :id)) (ctt-children state ""))))
diff --git a/lib/content/scoreboard.json b/lib/content/scoreboard.json
index e5f2d14d..648f3581 100644
--- a/lib/content/scoreboard.json
+++ b/lib/content/scoreboard.json
@@ -34,13 +34,14 @@
"store": {"pass": 29, "fail": 0},
"snapshot": {"pass": 20, "fail": 0},
"crdt": {"pass": 34, "fail": 0},
+ "crdt-tree": {"pass": 17, "fail": 0},
"crdt-store": {"pass": 14, "fail": 0},
"sync": {"pass": 14, "fail": 0},
"md-import": {"pass": 38, "fail": 0},
"md-doc": {"pass": 12, "fail": 0},
"fed": {"pass": 20, "fail": 0}
},
- "total_pass": 710,
+ "total_pass": 727,
"total_fail": 0,
- "total": 710
+ "total": 727
}
diff --git a/lib/content/scoreboard.md b/lib/content/scoreboard.md
index 5ae81e72..885ba020 100644
--- a/lib/content/scoreboard.md
+++ b/lib/content/scoreboard.md
@@ -38,9 +38,10 @@ _Generated by `lib/content/conformance.sh`_
| store | 29 | 0 | 29 |
| snapshot | 20 | 0 | 20 |
| crdt | 34 | 0 | 34 |
+| crdt-tree | 17 | 0 | 17 |
| crdt-store | 14 | 0 | 14 |
| sync | 14 | 0 | 14 |
| md-import | 38 | 0 | 38 |
| md-doc | 12 | 0 | 12 |
| fed | 20 | 0 | 20 |
-| **Total** | **710** | **0** | **710** |
+| **Total** | **727** | **0** | **727** |
diff --git a/lib/content/tests/crdt-tree.sx b/lib/content/tests/crdt-tree.sx
new file mode 100644
index 00000000..b70c6f95
--- /dev/null
+++ b/lib/content/tests/crdt-tree.sx
@@ -0,0 +1,253 @@
+;; Extension — nested-tree CvRDT. Sections nest and merge collaboratively;
+;; convergence is order/replica/duplicate-insensitive like the flat layer.
+
+(st-bootstrap-classes!)
+(content-bootstrap-blocks!)
+(content-bootstrap-doc!)
+(content-bootstrap-render!)
+(content-bootstrap-section!)
+
+(define same? (fn (a b) (= (get a :elements) (get b :elements))))
+
+;; base: a section "s" at root, with one child heading.
+(define
+ base
+ (crdt-tree-insert
+ (crdt-tree-insert
+ (crdt-empty)
+ "s"
+ "section"
+ (crdt-pos 1 0)
+ ""
+ (list)
+ 1
+ 0)
+ "h"
+ "heading"
+ (crdt-pos 1 0)
+ "s"
+ (list (list "level" 2) (list "text" "Sub"))
+ 1
+ 0))
+
+;; ── materialise rebuilds the tree ──
+(content-test "tree order root" (crdt-tree-order base) (list "s"))
+(content-test
+ "tree materialize ids"
+ (doc-tree-ids (crdt-tree-materialize "d" base))
+ (list "s" "h"))
+(content-test
+ "tree render"
+ (asHTML (crdt-tree-materialize "d" base))
+ "")
+
+;; ── concurrent inserts into the SAME section converge + order by pos ──
+(define
+ rA
+ (crdt-tree-insert
+ base
+ "a"
+ "text"
+ (crdt-pos 5 1)
+ "s"
+ (list (list "text" "A"))
+ 2
+ 1))
+(define
+ rB
+ (crdt-tree-insert
+ base
+ "b"
+ "text"
+ (crdt-pos 5 2)
+ "s"
+ (list (list "text" "B"))
+ 2
+ 2))
+(content-test
+ "same-parent merge commutes"
+ (same? (crdt-tree-merge rA rB) (crdt-tree-merge rB rA))
+ true)
+(content-test
+ "same-parent order deterministic"
+ (doc-tree-ids (crdt-tree-materialize "d" (crdt-tree-merge rA rB)))
+ (list "s" "h" "a" "b"))
+
+;; ── concurrent inserts into DIFFERENT parents converge ──
+(define
+ base2
+ (crdt-tree-insert
+ (crdt-tree-insert
+ (crdt-empty)
+ "s1"
+ "section"
+ (crdt-pos 1 0)
+ ""
+ (list)
+ 1
+ 0)
+ "s2"
+ "section"
+ (crdt-pos 2 0)
+ ""
+ (list)
+ 1
+ 0))
+(define
+ x
+ (crdt-tree-insert
+ base2
+ "x"
+ "text"
+ (crdt-pos 1 0)
+ "s1"
+ (list (list "text" "X"))
+ 2
+ 1))
+(define
+ y
+ (crdt-tree-insert
+ base2
+ "y"
+ "text"
+ (crdt-pos 1 0)
+ "s2"
+ (list (list "text" "Y"))
+ 2
+ 2))
+(define m (crdt-tree-merge x y))
+(content-test
+ "different-parent commutes"
+ (same? m (crdt-tree-merge y x))
+ true)
+(content-test
+ "different-parent tree"
+ (doc-tree-ids (crdt-tree-materialize "d" m))
+ (list "s1" "x" "s2" "y"))
+(content-test
+ "different-parent render"
+ (asHTML (crdt-tree-materialize "d" m))
+ "")
+
+;; ── nested sections (section inside section) ──
+(define
+ nested
+ (crdt-tree-apply-all
+ (crdt-empty)
+ (list
+ (crdt-tree-op-insert
+ "outer"
+ "section"
+ (crdt-pos 1 0)
+ ""
+ (list)
+ 1
+ 0)
+ (crdt-tree-op-insert
+ "inner"
+ "section"
+ (crdt-pos 1 0)
+ "outer"
+ (list)
+ 1
+ 0)
+ (crdt-tree-op-insert
+ "leaf"
+ "text"
+ (crdt-pos 1 0)
+ "inner"
+ (list (list "text" "deep"))
+ 1
+ 0))))
+(content-test
+ "nested tree ids"
+ (doc-tree-ids (crdt-tree-materialize "d" nested))
+ (list "outer" "inner" "leaf"))
+(content-test
+ "nested render"
+ (asHTML (crdt-tree-materialize "d" nested))
+ "")
+
+;; ── ops in any order converge (commutative) ──
+(define
+ opA
+ (crdt-tree-op-insert
+ "p"
+ "text"
+ (crdt-pos 6 0)
+ "s"
+ (list (list "text" "P"))
+ 3
+ 1))
+(define opB (crdt-tree-op-update "h" "text" "Edited" 5 1))
+(define opC (crdt-tree-op-delete "h"))
+(content-test
+ "ops commute"
+ (same?
+ (crdt-tree-apply-all base (list opA opB opC))
+ (crdt-tree-apply-all base (list opC opB opA)))
+ true)
+(content-test
+ "ops idempotent"
+ (same?
+ (crdt-tree-apply-all base (list opA opB))
+ (crdt-tree-apply-all
+ (crdt-tree-apply-all base (list opA opB))
+ (list opA opB)))
+ true)
+
+;; ── update into a section + LWW ──
+(define u1 (crdt-tree-update base "h" "text" "v5" 5 1))
+(define u2 (crdt-tree-update base "h" "text" "v7" 7 2))
+(content-test
+ "tree LWW higher ts"
+ (str
+ (blk-send
+ (doc-deep-find (crdt-tree-materialize "d" (crdt-tree-merge u1 u2)) "h")
+ "text"))
+ "v7")
+
+;; ── delete inside a section ──
+(content-test
+ "delete in section"
+ (doc-tree-ids (crdt-tree-materialize "d" (crdt-tree-delete base "h")))
+ (list "s"))
+
+;; ── merge idempotence ──
+(content-test "merge idempotent self" (same? (crdt-tree-merge m m) m) true)
+
+;; ── full convergence: two replicas, divergent edits in different sections ──
+(define
+ repl1
+ (crdt-tree-apply-all
+ base2
+ (list
+ (crdt-tree-op-insert
+ "p1"
+ "text"
+ (crdt-pos 1 0)
+ "s1"
+ (list (list "text" "from1"))
+ 5
+ 1))))
+(define
+ repl2
+ (crdt-tree-apply-all
+ base2
+ (list
+ (crdt-tree-op-insert
+ "p2"
+ "text"
+ (crdt-pos 1 0)
+ "s2"
+ (list (list "text" "from2"))
+ 6
+ 2))))
+(content-test
+ "two-replica tree converges"
+ (same? (crdt-tree-merge repl1 repl2) (crdt-tree-merge repl2 repl1))
+ true)
+(content-test
+ "two-replica tree ids"
+ (doc-tree-ids (crdt-tree-materialize "d" (crdt-tree-merge repl1 repl2)))
+ (list "s1" "p1" "s2" "p2"))
diff --git a/plans/content-on-sx.md b/plans/content-on-sx.md
index c30c66af..588a3a24 100644
--- a/plans/content-on-sx.md
+++ b/plans/content-on-sx.md
@@ -19,7 +19,7 @@ injected adapter, not core.
## Status (rolling)
-`bash lib/content/conformance.sh` → **710/710** (Phases 1–4 COMPLETE + ~33 extensions: HTML/SX escaping, Markdown render + import/export incl. tables & frontmatter (full round-trip), CRDT replication, tree-aware validation, snapshot cache, doc metadata, plain-text render, nested block trees + deep editing + flatten + relative reorder, doc stats + summary + multi-doc index, table + callout + media blocks, HTML page wrapper + SEO page, doc composition + id-remap, portable data + wire serialization, block query + transforms + find/replace, TOC + anchored headings + outline, normalization)
+`bash lib/content/conformance.sh` → **727/727** (Phases 1–4 COMPLETE + ~34 extensions: HTML/SX escaping, Markdown render + import/export incl. tables & frontmatter (full round-trip), CvRDT flat + nested-tree + durable replication, tree-aware validation, snapshot cache, doc metadata, plain-text render, nested block trees + deep editing + flatten + relative reorder, doc stats + summary + multi-doc index, table + callout + media blocks, HTML page wrapper + SEO page, doc composition + id-remap, portable data + wire serialization, block query + transforms + find/replace, TOC + anchored headings + outline, normalization)
## Ground rules
@@ -93,6 +93,7 @@ lib/content/api.sx ── (content/edit) (content/render) (content/history) ─
- [x] media block (`media.sx`: CtMedia video/audio, renders html/sx/text/md, validated)
- [x] list-card summary (`summary.sx`: content/summary — title/excerpt/words/reading/cover)
- [x] multi-doc index (`index.sx`: content/index + index-by-tag + all-tags + has-tag?)
+- [x] nested-tree CvRDT (`crdt-tree.sx`: parent-aware, sections merge collaboratively)
- [x] HTML page wrapper (`page.sx`: content/page, escaped title from metadata)
- [x] SEO page (`page-full.sx`: content/page-full, lang + meta description from excerpt)
- [x] document composition (`compose.sx`: concat/prepend/concat-all/wrap-section)
@@ -112,6 +113,15 @@ lib/content/api.sx ── (content/edit) (content/render) (content/history) ─
## Progress log
+- 2026-06-07 — Extension: nested-tree CvRDT (`crdt-tree.sx`). Extends the flat
+ CvRDT to a TREE: each element carries a `parent` (containing section id, "" =
+ root) beside its Logoot pos; merge reuses crdt.sx's pos/register/field joins +
+ parent (immutable). Materialisation rebuilds the ordered tree (root + per-section
+ children sorted by pos, recursive). Sections now merge collaboratively; proven
+ commutative/associative/idempotent — same- and different-parent concurrent
+ inserts converge, nested sections, LWW, two-replica convergence. Reuses crdt.sx
+ + section.sx; flat crdt untouched (34/34). 17 tests; suite 727/727. This was
+ the flagged "research-grade" gap — done as a clean self-contained layer.
- 2026-06-07 — Extension: multi-document index (`index.sx`). `content/index`
projects a doc list into summary cards (blog index); `content/index-by-tag`
filters by tag (category pages); `content/all-tags` is a deduped tag cloud;