Context
Split out of #17839 rather than added to it, so each ticket resolves in one PR. #17839 teaches menu.List to render a parentId hierarchy from a TreeStore. This ticket covers building that hierarchy from path-addressed contributions — a src/data/ concern, not a menu one.
Naming it separately was requested explicitly during design review with @neo-opus-grace, on the grounds that discovering it mid-implementation is worse than scoping it up front: without it, a consumer whose entries arrive as paths still cannot construct the tree #17839 renders.
Live latest-open sweep: checked the latest 20 open issues at 2026-08-28T23:25:01Z; no equivalent found.
The Problem
Plugin and module architectures address hierarchy by path, not by parent key. A contributor declares where it belongs — 'Group', 'A/B/C' — without knowing which siblings exist or which of them already created the intermediate groups.
Two properties make this awkward against a parentId store:
- Intermediate nodes have no declaring record.
'A/B/C' implies A and A/B exist, but nobody declares them. A parentId store needs them to exist as records before a child can reference one.
- Materialisation must be idempotent and order-independent. Two contributors declaring
'Group/A' and 'Group/B' — in either order, at any time during load — must converge on one Group node. Materialising twice must not produce two.
Absent a primitive, every consumer writes the same recursive assembler over a Map and rebuilds wholesale on each change. #17839 documents that pattern appearing independently in more than one codebase.
The Architectural Reality
Neo.data.TreeStore (src/data/TreeStore.mjs) maintains its Structural Layer in #childrenMap (:72-75), keyed by parentId, and Neo.data.TreeModel (src/data/TreeModel.mjs:29-71) declares parentId, depth, isLeaf, childCount, collapsed, siblingCount, siblingIndex. The store already owns the invariants that materialisation must preserve — depth, leaf state, sibling counts — which is why this belongs to the data layer rather than to any consumer.
The direct precedent is src/data/normalizer/Tree.mjs, which exists to perform the analogous transform: nested children arrays from external APIs into the flat parentId shape the TreeStore consumes. Its class docblock states the role explicitly. Path-string → flat parentId records is the same category of transform against a different input encoding, and its natural home is alongside it.
The Fix
Provide a single materialisation entry point that accepts a path and a leaf payload, creates any missing ancestors exactly once, and returns the leaf.
- Substrate:
src/data/normalizer/ sibling, or a TreeStore method delegating to it. Directory choice follows the existing normalizer precedent; no novel placement.
- Ancestor identity must derive deterministically from the path prefix, so the same prefix always resolves to the same node regardless of who materialises it first.
- Separator is configurable, defaulting to
/; path segments must be escapable so a segment may legitimately contain the separator.
- Materialisation must maintain the store's own invariants (
depth, isLeaf, childCount, sibling fields) rather than writing records that bypass them.
Contract Ledger Matrix
| Target Surface |
Source of Authority |
Proposed Behavior |
Fallback |
Docs |
Evidence |
Neo.data.normalizer.Tree |
src/data/normalizer/Tree.mjs |
Unmodified; the new path normalizer is a sibling, not a change to it |
n/a |
learn/guides/datahandling/TreeStore.md |
Verified: file exists, docblock states the nested-children → parentId role |
Neo.data.TreeStore |
src/data/TreeStore.mjs |
Gains a materialise-by-path entry point; Structural Layer semantics unchanged |
Existing record-level add API stays the primary path |
TreeStore guide |
Verified: #childrenMap at :72-75 |
Neo.data.TreeModel fields |
src/data/TreeModel.mjs:29-71 |
Maintained by materialisation, not redefined |
n/a |
TreeStore guide |
Verified present: parentId, depth, isLeaf, childCount, collapsed, siblingCount, siblingIndex |
Neo.data.normalizer.Path |
new |
Public sibling of normalizer.Tree. normalize(data) for bulk path-addressed entries; materialize(path, data, exists) emits ancestors-first; splitPath(path) exposes the prefix descriptors |
n/a — additive |
class JSDoc + TreeStore guide |
Shipped in PR #17854; placement precedent is normalizer/Tree.mjs |
Neo.data.normalizer.Path grammar configs |
new |
separator and escapeChar are exactly one UTF-16 code unit and must differ, validated on set; keyProperty, nameProperty, pathProperty are field-name Strings |
invalid grammar throws at construction rather than parsing a different tree |
field JSDoc + guide |
Reviewer falsifier: '', '::' and separator === escapeChar each parsed silently before validation |
Neo.data.TreeStore#splice sort behaviour |
src/data/TreeStore.mjs |
A mutation with no visible delta re-applies active sorters to the touched levels before updateSiblingStats, so getChildren() honours its documented sort order on a collapsed branch. Gated on autoSort && sorters.length, matching every sort trigger in Collection.Base |
autoSort: false or no sorters keeps declaration order, unchanged, on the hidden and visible paths alike |
TreeStore guide |
Pre-existing divergence, reachable by plain sequential add(); control spec pins that arm |
Decision Record impact
none. Same review as #17839: no ADR in learn/agentos/decisions/ governs data-store hierarchy.
Acceptance Criteria
Out of Scope
- Rendering.
menu.List consuming a TreeStore is #17839.
- Removing or deprecating any existing
TreeStore add/load API. This is an addition.
- Reconciling deletions — removing a leaf does not garbage-collect now-empty ancestors. Deliberately excluded: whether an empty group should disappear is a consumer policy question, not a store invariant, and guessing it here would bake one consumer's answer into the engine.
- Ordering policy for materialised siblings beyond determinism. #17839 owns the menu-facing ordering AC.
Avoided Traps / Gold Standards Rejected
Rejected: putting this in src/menu/. It would make a data transform reachable only through a component, and the next path-addressed consumer that is not a menu would reimplement it. The normalizer/ precedent already establishes where input-shape transforms live.
Rejected: deriving ancestor ids from insertion counters. Ids must be a deterministic function of the path prefix; otherwise two contributors racing the same prefix produce two nodes, which is precisely the defect this ticket removes.
Trap: bypassing store invariants. Writing ancestor records directly into the Structural Layer without maintaining depth / childCount / sibling fields produces a store that renders correctly and reports ARIA incorrectly — the same silent, view-invisible failure mode as the dead isTreeGrid inference noted in #17839.
Related
- #17839 — the rendering half; this ticket is its named Out-of-Scope follow-up
- #9405 / #9406 —
data.TreeModel / data.TreeStore
Handoff Retrieval Hints
query_raw_memories: "idempotent path materialisation TreeStore parentId ancestors grouping path"
- Key anchors:
src/data/normalizer/Tree.mjs (the precedent transform), src/data/TreeStore.mjs:72-75 (#childrenMap), src/data/TreeModel.mjs:29-71 (field set)
- Design inputs came from two independent consumers of the path-addressed contribution shape; the awkward parts are enumerated in #17839's "Design inputs from a second real consumer".
Origin Session ID: 16636a26-a8e1-4f27-9c7b-9365f5000c0f
Context
Split out of #17839 rather than added to it, so each ticket resolves in one PR. #17839 teaches
menu.Listto render aparentIdhierarchy from aTreeStore. This ticket covers building that hierarchy from path-addressed contributions — asrc/data/concern, not a menu one.Naming it separately was requested explicitly during design review with @neo-opus-grace, on the grounds that discovering it mid-implementation is worse than scoping it up front: without it, a consumer whose entries arrive as paths still cannot construct the tree #17839 renders.
Live latest-open sweep: checked the latest 20 open issues at 2026-08-28T23:25:01Z; no equivalent found.The Problem
Plugin and module architectures address hierarchy by path, not by parent key. A contributor declares where it belongs —
'Group','A/B/C'— without knowing which siblings exist or which of them already created the intermediate groups.Two properties make this awkward against a
parentIdstore:'A/B/C'impliesAandA/Bexist, but nobody declares them. AparentIdstore needs them to exist as records before a child can reference one.'Group/A'and'Group/B'— in either order, at any time during load — must converge on oneGroupnode. Materialising twice must not produce two.Absent a primitive, every consumer writes the same recursive assembler over a
Mapand rebuilds wholesale on each change. #17839 documents that pattern appearing independently in more than one codebase.The Architectural Reality
Neo.data.TreeStore(src/data/TreeStore.mjs) maintains its Structural Layer in#childrenMap(:72-75), keyed byparentId, andNeo.data.TreeModel(src/data/TreeModel.mjs:29-71) declaresparentId,depth,isLeaf,childCount,collapsed,siblingCount,siblingIndex. The store already owns the invariants that materialisation must preserve — depth, leaf state, sibling counts — which is why this belongs to the data layer rather than to any consumer.The direct precedent is
src/data/normalizer/Tree.mjs, which exists to perform the analogous transform: nestedchildrenarrays from external APIs into the flatparentIdshape theTreeStoreconsumes. Its class docblock states the role explicitly. Path-string → flatparentIdrecords is the same category of transform against a different input encoding, and its natural home is alongside it.The Fix
Provide a single materialisation entry point that accepts a path and a leaf payload, creates any missing ancestors exactly once, and returns the leaf.
src/data/normalizer/sibling, or aTreeStoremethod delegating to it. Directory choice follows the existing normalizer precedent; no novel placement./; path segments must be escapable so a segment may legitimately contain the separator.depth,isLeaf,childCount, sibling fields) rather than writing records that bypass them.Contract Ledger Matrix
Neo.data.normalizer.Treesrc/data/normalizer/Tree.mjslearn/guides/datahandling/TreeStore.mdchildren→parentIdroleNeo.data.TreeStoresrc/data/TreeStore.mjs#childrenMapat:72-75Neo.data.TreeModelfieldssrc/data/TreeModel.mjs:29-71parentId,depth,isLeaf,childCount,collapsed,siblingCount,siblingIndexNeo.data.normalizer.Pathnormalizer.Tree.normalize(data)for bulk path-addressed entries;materialize(path, data, exists)emits ancestors-first;splitPath(path)exposes the prefix descriptorsnormalizer/Tree.mjsNeo.data.normalizer.Pathgrammar configsseparatorandescapeCharare exactly one UTF-16 code unit and must differ, validated on set;keyProperty,nameProperty,pathPropertyare field-name Strings'','::'andseparator === escapeChareach parsed silently before validationNeo.data.TreeStore#splicesort behavioursrc/data/TreeStore.mjsupdateSiblingStats, sogetChildren()honours its documented sort order on a collapsed branch. Gated onautoSort && sorters.length, matching every sort trigger inCollection.BaseautoSort: falseor no sorters keeps declaration order, unchanged, on the hidden and visible paths alikeadd(); control spec pins that armDecision Record impact
none. Same review as #17839: no ADR inlearn/agentos/decisions/governs data-store hierarchy.Acceptance Criteria
'A/B/C'createsA,A/Band theCleaf, correctly parented, in one call.'Group/A'then'Group/B'produces oneGroupnode with two children — and the reverse order produces the identical store state.depth,isLeaf: falseandchildCount; leaves carryisLeaf: true.siblingIndex,siblingCount) are correct after mixed-order insertion.npx playwright test).learn/guides/datahandling/TreeStore.md.Out of Scope
menu.Listconsuming aTreeStoreis #17839.TreeStoreadd/load API. This is an addition.Avoided Traps / Gold Standards Rejected
Rejected: putting this in
src/menu/. It would make a data transform reachable only through a component, and the next path-addressed consumer that is not a menu would reimplement it. Thenormalizer/precedent already establishes where input-shape transforms live.Rejected: deriving ancestor ids from insertion counters. Ids must be a deterministic function of the path prefix; otherwise two contributors racing the same prefix produce two nodes, which is precisely the defect this ticket removes.
Trap: bypassing store invariants. Writing ancestor records directly into the Structural Layer without maintaining
depth/childCount/ sibling fields produces a store that renders correctly and reports ARIA incorrectly — the same silent, view-invisible failure mode as the deadisTreeGridinference noted in #17839.Related
data.TreeModel/data.TreeStoreHandoff Retrieval Hints
query_raw_memories:"idempotent path materialisation TreeStore parentId ancestors grouping path"src/data/normalizer/Tree.mjs(the precedent transform),src/data/TreeStore.mjs:72-75(#childrenMap),src/data/TreeModel.mjs:29-71(field set)Origin Session ID: 16636a26-a8e1-4f27-9c7b-9365f5000c0f