From 8313c4a584deaa3a19ebd450e8138673327d6a65 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 03:28:32 +0000 Subject: [PATCH] fix(marmot): trim trailing blank leaves after removeLeaf MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RFC 9420 §7.8: the ratchet tree has no trailing blank leaves — when a Remove blanks the rightmost leaf, everyone MUST shrink `leaf_count` and drop the (now-orphaned) parent nodes so future `direct_path`, `resolution`, and `treeHash` computations see the new shape. Quartz was blanking the leaf and its direct path but leaving `_leafCount` untouched. Openmls / mdk shrink, so after e.g. `amy removes C (leaf 2)` quartz still had `leafCount = 3` while B's openmls had `leafCount = 2`. Amy's `direct_path` from leaf 0 then had two entries (to the 3-leaf-tree root), but B's tree layout expected one — and openmls rejected the commit with `UpdatePathError(PathLengthMismatch)`. Fix: after blanking the leaf + direct path, walk `leafCount` down past any trailing blanks and truncate the `nodes` list to `nodeCount(leafCount)`. https://claude.ai/code/session_016kAxdp6ubB5CnF9URhCEzP --- .../quartz/marmot/mls/tree/RatchetTree.kt | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/tree/RatchetTree.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/tree/RatchetTree.kt index d0a4af99a..10646bd49 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/tree/RatchetTree.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/tree/RatchetTree.kt @@ -156,16 +156,33 @@ class RatchetTree( /** * Remove a member by blanking their leaf and all parent nodes on the direct path. + * + * RFC 9420 §7.8: trailing blank leaves MUST be trimmed so every participant + * agrees on `leaf_count` (and therefore on `direct_path` lengths). Openmls + * does this after every leaf blank; without the trim, a committer that had + * the removed leaf at the far right end of the tree sends an UpdatePath one + * entry longer than the receiver's tree layout accepts, and the receiver + * errors out with `UpdatePathError(PathLengthMismatch)`. */ fun removeLeaf(leafIndex: Int) { setLeaf(leafIndex, null) - // Blank the direct path val directPath = BinaryTree.directPath(leafIndex, _leafCount) for (nodeIdx in directPath) { if (nodeIdx < nodes.size) { nodes[nodeIdx] = null } } + // Shrink leafCount past any trailing blanks. Parent nodes that become + // orphaned by the shrink are dropped from the `nodes` list so + // treeHash() / resolution() / direct_path() agree with openmls on the + // new tree shape. + while (_leafCount > 0 && getLeaf(_leafCount - 1) == null) { + _leafCount-- + } + val effectiveNodeCount = if (_leafCount > 0) BinaryTree.nodeCount(_leafCount) else 0 + while (nodes.size > effectiveNodeCount) { + nodes.removeAt(nodes.size - 1) + } } /**