Introduce dense order keys for manual ordering - #12285
Draft
rymsha wants to merge 74 commits into
Draft
Conversation
NodeService.list answered with the whole listing at once, however large: the scroll behind it accumulates every entry before anything is returned, so listing a tree of millions of nodes holds millions of entries in memory at both levels. A caller may now set a batch size and repeat the call with the cursor each batch answers with. The cursor names the position after the last entry scanned, compared the way the index compares paths, so a continuation is one sized query rather than a scroll, entries hidden by permissions or depth still advance it, and ground already passed is never revisited - deletions between batches included, which is what a clean-up loop needs. The cursor is taken from the scan rather than from the entries kept, so a batch may be empty while the listing is not finished; the contract is therefore to continue until a batch answers with no cursor, and the javadoc says so.
CleanUpAuditLogCommand resolved every batch through the search index and had to refresh it after each one, since the next search would otherwise answer with the nodes just deleted. Enumerate the log in batches instead: the cursor only moves forward over ground the deletions leave behind, so one storage refresh up front is the only refresh the whole clean-up needs, and the search index is not consulted at all. A record is now aged by its node timestamp - the moment it was written - where the search filtered on the time field of the record, which a caller may set freely. The two coincide for every record the system writes, and the timestamp is the only moment storage holds.
The javadoc promised a positive number while the builder accepts zero as the default that turns batching off, so say so. Also take the last scanned entry through Iterables.getLast instead of a reduction over the whole batch.
The two build-time refusals and the defaults of ListNodesParams were asserted nowhere.
Both walkers held the whole listing at once and used nothing of it but the ids: an entry weighs a whole path, and in a deep tree every descendant repeats its ancestry, so the paths dominated the memory of walks that never read them. Consume the listing in batches and keep bare ids - the set a dump needs anyway to dump each node once across branches, and the list an export needs for the exact total its progress listener is owed, which a count from the index could not give since it ignores what the caller is permitted to read. The batches replace a scroll, which pinned a point-in-time view of the listing. A node moved across the cursor while a walk runs may now be listed twice, which the ids absorb, or escape the listing - the trade the batches make for never holding an entry per node.
A path names a position that a move takes away, so a path-ordered scan let a node moved across the cursor be listed twice or escape the listing altogether. An id is the one thing about a node that never changes: scanned by id, a node moved within the listing keeps its place and is observed exactly once, and the cursor is compared without normalization, since ids are held by the index exactly as the node exposes them. Order was the only thing path gave a batched scan, and none of the batched walkers reads the listing in order - the vacuum filters by age, the dump feeds a set, the export reads by id. An unbatched listing keeps its path order, which the application and job listings show to people. The batched permission itest gave the hidden node an id chosen by chance, which chose its scan position too; it is now set explicitly.
The batching loops narrate themselves; what stays is what a reader cannot recover from the code - why one refresh suffices, why the count comes from the entries, why the cursor is the last scanned id, and what bounds the batch size.
Both walkers enumerated full branch entries and read two fields of them: the id names the node to rewrite or copy, the path names its parent in the map that keeps the walk parent-first. The version id, the three blob keys and the timestamp of every descendant were fetched, parsed and held for nothing. The branch query now takes the fields a caller wants of a hit, and the command answers a walker with bare id-and-path pairs. A permission requirement is refused on that projection, since deciding one costs exactly the access control key it exists to not fetch - and neither walker filters: move runs its walk with full rights because authority over the root is authority over the subtree, and duplicate decides readability on the nodes it loads anyway.
…listeners #12065 A single upfront total cannot say the amount of work is unknown, and obliges the operation to resolve all of it before starting. resolved(int) carries the total resolved so far - called any number of times, each call replacing the previous value, -1 or silence meaning unknown. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
… unknown #12065 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
Every tree walker already holds its work list before touching anything, so each now tells its listener how much was resolved - move and delete exactly, duplicate as a running total that shrinks as the permission cascade skips entries. Content commands forward the totals to their own listeners, which recover the reach of the 7.16 setTotal in the running-total shape. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
The command resolved the active versions of the whole subtree - one ACL read and two storage lookups per node and branch - before reporting anything or applying anything, and held every resolved version in memory at once. It now walks the subtree in strides over the batched branch listing: each stride is resolved, reported through the growing resolved total, and applied before the next is fetched. First progress moves after one stride instead of after the whole tree, and memory holds one stride instead of all of it. Applying node-by-node instead of branch-by-branch preserves the version origin: the caller-supplied branch order still decides, per node, which branch stores the new version and which ones it is pushed to. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
Move and delete assert the exact total their walks report; duplicate asserts the shrinking sequence around an unreadable child. The content-level tests drive the listener delegates with plain listeners that override nothing but the processed counts, pinning that the total callbacks are optional. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
A per-stride total made progress a sawtooth: the bar neared its end and the next stride grew the denominator by another stride. The batched listing already knows how many raw entries the scan has in front of it, so each stride now projects what remains at the weight per node of what it has resolved, and the reported total settles near its final value at once, corrected exactly by the last stride. The listener javadoc now says a projected total is a legitimate resolved report. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
…2065 A search that requested fields still shipped every hit's whole source and picked the fields out on this side of the wire. The requested names now ride the request as a source filter, so a walker's listing ships ids and paths instead of whole branch entries, and a path-only search stops shipping whole documents. Nothing reads a hit's document id, so the branch index id staying nodeId_branch is not disturbed - the node id remains a filtered source field. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
The stored source is one blob, so the filter saved neither the read nor the one full parse - it only moved the parse to the other side of a fetch that is in-process anyway, and the branch documents it was aimed at are small. The real gain would be a query that asks for node ids alone and derives them from the branch document id without touching the source - none exists today. This reverts commit e67f1c6. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
The lean branch-query projection served nothing the walkers could not do themselves: the query layer answers whole branch entries again, and move and duplicate repack them into their own two-field pairs, letting the entries go as soon as they are mapped - the same retained memory for a walk, none of the query-layer machinery. Duplicate keeps deciding what is permitted exactly as before: the caller-context reads omit unreadable nodes, and an omitted parent takes its whole subtree out of the duplication. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
Duplicate repacked entries into id-and-path pairs only to join them straight back to the full nodes it reads for copying - and those nodes, like every copy accumulating in the result, dominate the memory of the walk regardless. The storage get answers in the requested order, so the path order of the listing survives into the fetched nodes and the walk iterates them directly: no repacking, no id map. Unreadable nodes are absent from the get's answer, which shrinks the resolved total at once; a readable node below an unreadable parent is still skipped one by one by the cascade, pinned in the test by a readable grandchild under the hidden child. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
… in stride #12065 The bulk node get re-fetched from the index the very branch entries the listing had just answered, read every subtree version upfront to decide read access the access blobs - almost always cached - already carry, and held every full node until the walk ended. The walk now settles the duplicable set from the entries and access blobs alone, where a prohibited node takes its whole subtree with it as the contiguous run of path-ordered entries right behind it, reports the exact resolved total once, and completes each node from its version blobs only when its turn comes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
…12065 Every step of the walk re-fetched from the index the branch entry the listing had just answered, and fetched the version metadata even when no attributes resolver was there to read it. The walk now carries the entries themselves - they hold the version keys - completes each node from its version blobs when its turn comes, and touches the version metadata only for a resolver that asks for the attributes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
Resolving a stride ran a command per node that looked up the branch entry and the version metadata per branch, one by one; denying ran a full node read per node to see permissions the cached access blob already carries; and applying read the full node by id again, re-fetching the branch entry a third time. A stride now resolves with one bulk get per branch, denies from the access blob - the walk only ever reaches nodes the caller may read, settled upstream - completes each node from its version blobs when rewriting it, and touches the version metadata only for a resolver that asks for attributes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
A multi-get fans out to one get per document on its shard anyway, so bulking the stride bought nothing and inherited a request-size ceiling. Better: the stride's entries already answer the context branch - the listing returned them - so a single-branch apply resolves without a single lookup, and only the other branches cost one entry get per node. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
…children #12065 Every manually ordered parent cost the export a second listing of its children - answered by scanning its whole subtree, since the branch index narrows by prefix only - and a full re-read of children the walk reads anyway. The walk now notes each node's name, order value and timestamp as it passes, sheds what belongs to parents known to keep no manual order, and writes every order file once the walk has read everyone. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
The enumerating listing could only answer the handful of providers by scanning their whole subtree - every user and group in the system. The query narrows on the parent, like every other principal lookup here. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
The sync flows split their walks by level only to bound memory, which a single severely huge level defeats. The layers service now answers ids batch by batch with the listing's own cursor, every walk stays bounded whatever the shape of the tree, and the emptiness check over a subtree stops at the first batch that fails it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
It reads as an optimization and is the opposite: the storage index holds no parent field, so the direct children are found by walking the same subtree and dropping the deeper levels afterward. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
Unreleased, and not supposed to be used: it read as the lighter choice while costing the same subtree scan plus a filter, and its silent narrowing hid whatever a subtree unexpectedly held. A listing now lists the subtree. The one caller that genuinely wants levels - the layer sync descent - keeps its direct children by filtering the batches where the need lives, and the flat system listings lose nothing but the trap. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
…dren #12065 The descent used to refetch children level by level, refetching ground the previous level had already enumerated. Now one grouped fetch per top node takes the whole subtree and the recursion slices it: a parent is still synced before its children, but no level costs another scan. Sorted-event sync reads the reordered level through search - the same index the sort itself relies on - instead of enumerating the subtree. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
The cleanup walked the target tree level by level, and every level was bought by rescanning the whole subtree below it. The target subtree is known upfront, so the walk now takes the same grouped fetch the sync descent takes and slices it - the snapshot survives its own walk, since a content that still has children is never deleted by it. That removes the last user of the depth-filtered children listing, which goes away with its filtering plumbing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
The javadoc of list, enumerate and their types explained how the answer is produced - storage rather than search index, scrolls, sized requests, node-id ordering, what the index counts while cutting a batch. A caller cannot rely on any of that, and it goes stale the moment the mechanism changes. What is left is what a caller can hold the API to: what the answer holds, what it costs, when a node becomes visible, how far the at-most-once guarantee reaches, what a bound and a cursor promise, which exceptions come out. The numbers are named where they used to be described - a batch is at most 10 000 rather than "as much as the index will answer in one request", and the remaining count says what it counts instead of calling itself exact. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
Implementation classes, tests and the benchmark carried running commentary on what the code next to it was doing and why. It says nothing the code does not, and it is one more thing to keep true. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
The remaining count came straight out of the index as a long, which is more range than a node count can use and more than a script can hold: JavaScript has no long, and every listener, batch size and count already in the API is an int. It is now an int, saturated where the index's number crosses into the API, and the two callers that had to clamp it themselves add plainly instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
Every other operation listener is a running total plus one domain-named method carrying how many items are done: resolved(int) and nodesMoved, nodesDeleted, nodesPushed, permissionsApplied. The audit clean-up had resolved(int) plus start(int batchSize), a countless processed() called once per record, and finished(). processed() is deprecated for recordsDeleted(int), which the default forwards to once per record, so a listener implementing only the old name hears exactly what it heard before. start(int) is deprecated for two reasons: resolved already announces the work before the first delete, and the batch size it publishes is the ceiling this API deliberately keeps private. finished() is deprecated because the clean-up is over when it returns, which the caller that started it already knows - the task handler logs the two lines around its own call now, and its listener reports progress on a report interval of its own rather than on whatever batch size it was handed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
The task handler reports progress once every thousand records, so a clean-up that ended mid-interval left the task showing the count of the last full thousand - a scheduled clean-up of nine hundred records showed none of them at all. The handler now reports once more when cleanUp returns, which is also where it logs that the clean-up is over. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
Deleting the listing's non-recursive mode quietly widened three system listings from the children of a folder to its whole subtree. VirtualAppService.list was wrong from that moment: a virtual application keeps its resources in a subtree of its own node, so every folder and every yaml file below an application came back as an application, each mapped to whatever key its path happened to yield. The application repo and the scheduled job listings hold flat folders today and so answered the same as before, but they asked the wrong question and would break the day anything gains a child. All three now keep the entries whose parent is the folder they listed - which is what the deleted mode did, since the branch index carries no parent field and answered the mode by filtering the same scan. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
The export enumerated in batches and then accumulated every entry into one list before writing anything, so it held an entry per node in the subtree - the whole point of enumerating in batches, given up one line later. It now writes each batch as it arrives: the first batch states the total, the root goes out, and every batch is exported and forgotten. Only the child order of manually ordered parents outlives a batch, which is what the order files are written from at the end. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
A manually ordered parent has its children's order synced through a search of the target level. Where the parent's order was already equal to the source's, nothing was sorted first, so nothing had refreshed the index either - and children written earlier in the same synchronization were invisible to that search, leaving their order unsynced with no sign of it. The sync now refreshes before reading the level, so everything written up to that point counts. Concurrent modifications remain out of reach, as they are for every search. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
Codacy's Analysis Summary3 new issues (≤ 0 issue)
|
| @JsonProperty("data") @JsonDeserialize(contentUsing = ImmutablePropertyArrayDeserializer.class) final List<ImmutableProperty> data, | ||
| @JsonProperty("childOrder") @JsonDeserialize(using = ChildOrderDeserializer.class) final ChildOrder childOrder, | ||
| @JsonProperty("manualOrderValue") final Long manualOrderValue, | ||
| @JsonProperty("orderKey") final String orderKey, |
A manual order value today is a long resolved against the sibling set: assigning one queries the neighbors, inserting between two of them halves a fixed gap that runs out after thirty targeted inserts and then silently duplicates the lower neighbor, and two branches performing the same insertion mint the identical value by construction, colliding when their sibling sets merge. An order key is a base-62 fraction followed by the id of the node that holds it. Between two distinct keys there is always a third, so nothing is ever renumbered; the id suffix makes concurrently minted keys unequal by construction, whatever branch, layer or server minted them; and a birth key encodes the inverted creation instant, so creating a node and moving one first are the same read-free operation, and the relative operations need only the anchor keys the caller already holds. A run of inserts in one direction consumes space linearly rather than by halving, and a position never grows past a cap that turns hostile input into a clean refusal instead of index growth. Keys are stored plain and leave the server as tokens: a version character, the key, and a truncated HMAC-SHA512 tag - the construction the redirect checksums already use - under a subkey derived once from a master key and bound to the scope the token was issued for. A client can replay positions it was handed but cannot mint one, which keeps exact placement out of reach of standard means, and verification tolerates fumbles loudly: wrong scope, wrong version, truncation and tampering are each rejected. A golden token pins the exact wire format against an independently computed reference value. Nothing is wired to storage yet: this is the primitive and its properties, testable in isolation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ANkY5TN9xn2cFKHk1sfX4B
A node is born with an order key minted from its creation instant and its own id, carried in the version data, indexed beside the manual order value, and preserved through storage, dump and import. Nothing reads it yet unless asked to: the new child order sorts by the key ascending, which shows newest first - the order the default child order already shows - with nodes that predate keys after every keyed node, ordered among themselves by the default order, which is the order a key would have frozen for them. Switching a parent to the new order is a metadata write: the children already hold the keys that make the order, so none of them is read or rewritten, where the legacy manual order reads the sibling set and versions every child to seed its values. The legacy order is untouched and keeps working from its own field. An imported node keeps the key of the dump through an internal channel of the create command; the parameters of a create cannot carry a key, so a key still cannot be chosen by standard means. The discriminator cap of the codec rises to the longest node id, which a dump of a node with a hundred-character name found to be longer than assumed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ANkY5TN9xn2cFKHk1sfX4B
Everything that carries or steers a manual order value is deprecated in favor of order keys: the value on node and content and their builders and editable forms, the placement controls of node creation and import, the index path, the child order built on the value, and the seeding of a sort - which order keys make unnecessary, since the children already carry the keys that make the order. The detection predicate stays undeprecated: telling a legacy manually ordered parent apart is how the remaining code paths and the eventual migration find their work. Reordering likewise stays: naming a child to move is not tied to how its position is stored. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ANkY5TN9xn2cFKHk1sfX4B
rymsha
force-pushed
the
claude/order-key-integration
branch
from
August 23, 2026 15:05
7fe1483 to
af31009
Compare
rymsha
changed the base branch from
master
to
claude/xp-issue-12065-alternative-pur3l4
August 23, 2026 15:05
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## claude/xp-issue-12065-alternative-pur3l4 #12285 +/- ##
==============================================================================
+ Coverage 87.09% 87.10% +0.01%
- Complexity 20840 20941 +101
==============================================================================
Files 2610 2613 +3
Lines 69337 69709 +372
Branches 5749 5804 +55
==============================================================================
+ Hits 60391 60723 +332
- Misses 6273 6292 +19
- Partials 2673 2694 +21 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
The search index sorts strings by a case-folded copy of the value, so under a case-sensitive alphabet the index and the codec disagreed on the order of keys that differed only by letter case - a key minted between two others could display outside them. Under the 36 lowercase digits, folding is the identity and the two orders are one; node ids, the discriminators, are lowercase by their own definition already. The instant takes seven digits in the smaller radix and reaches the year 4453; the jitter takes four and is wider than before. The longest key stays well inside the length the index folds for sorting. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ANkY5TN9xn2cFKHk1sfX4B
The export already treats a manually ordered parent the right way: it writes the names of the children in display order and leaves the values behind, and the import mints fresh ones. Parents ordered by keys now do the same. The exporter lists their children in key order in the same order file, and the importer, seeing a parent ordered by keys, mints a fresh chain along that list: the first child starts it and every next one is placed after the one before, the id of each node the discriminator of its own key. A child that had no key at export time gets one like the rest, in the position the order file gave it - an import drains the keyless tail for free. The keys of the source never travel: a key belongs to the node that holds it where it lives, and two imports of one export into two places must not mint the same keys. The round-trip test caught the index case-folding mismatch fixed in the previous commit: with case-sensitive keys, the display order and the export order disagreed whenever same-second jitter differed by case. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ANkY5TN9xn2cFKHk1sfX4B
An inherited content now holds the key it holds in the project it comes from. Creation sync reads the raw key of the source node and hands it through an internal import form to the node the import builds; update sync copies a changed key when the target still inherits its sort, and a keyless source - stored before order keys - dictates nothing; the children resync after a sort change covers parents ordered by keys the way it covers parents ordered by values. Raw keys move only inside the synchronizer, which reads and writes them through the internal layers service; the public content surface never carries one. Layers share content ids, so a key stays true to its discriminator wherever it is inherited. Node patching is the sanctioned way to write an exact placement, and now actually is one: the editable node exposes the key, and a changed key is validated on its way in. The test that pinned this caught that node equality ignored the key, so a patch changing nothing else was dropped as a no-op by the unchanged-version shortcut. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ANkY5TN9xn2cFKHk1sfX4B
Hiding the raw key behind a token protected nothing: the rule that no caller chooses an exact placement lives entirely on the write side, where the server mints every stored position itself - jittered, and suffixed with the id of the node holding it - whatever anchors it was handed. Reading, meanwhile, was already open one tier down and the relative order of every key observable through sorting. So the key is now readable where the content is: an opaque string on Content, to compare and hand back, never to parse. The token layer is gone. Reordering takes the anchors this opens up: a child is placed after one sibling key, before another, between the two, or - with no anchors - at the top, which a fresh birth key is. Nothing reads the sibling set; garbage and misordered anchors are refused by the codec's own validation. Content carrying the key simplifies what the last commit built: the synchronizer compares placements on the contents it already holds, and a content import places at the key of the content given to it - the import tier keeps exact placements, as it always has at the node level. Writing stays closed: the content patch surface does not carry the key, and the equality gate of content updates is deliberately blind to it, so a placement change never masquerades as a content change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ANkY5TN9xn2cFKHk1sfX4B
lib-content gains contentLib.sort({key, childOrder, reorder}): 'manual'
maps to ChildOrder.orderKeyOrder(), any other string parses as an order
expression, and reorder entries carry afterOrderKey/beforeOrderKey
anchors read off the sibling contents. reorder without childOrder flips
the parent to manual ordering in the same call. Serialized contents now
expose the read-only _orderKey.
lib-node exposes _orderKey on read and accepts it through update/patch
editors - the sanctioned node-level escape hatch, validated server-side
by PatchNodeCommand. Create deliberately does not accept it: exact
placement stays out of standard create means.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ANkY5TN9xn2cFKHk1sfX4B
The update/patch tier split was blurred by update delegating to PatchNodeCommand with the caller's editor verbatim: an update editor could set EditableNode.orderKey and it stuck. Now convertUpdateParams wraps the editor and resets the key to the source value, so update edits content, never placement. Layer sync's internal setOrderKey moves to nodeService.patch - same command underneath, now also the honest name for what it does. lib-node mirrors the split: the _orderKey property is applied only on the patch path (UpdateNodeExecutor gains an orderKeyEditable switch), and the TS types advertise _orderKey on patch and read shapes only. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ANkY5TN9xn2cFKHk1sfX4B
repo.sort gains the surface contentLib.sort already has: childOrder
'manual' maps to ChildOrder.orderKeyOrder(), reorder entries carry
nodeId plus afterOrderKey/beforeOrderKey anchors read off the sibling
nodes, and reorder without childOrder flips the parent to manual in the
same call. The existing {key, childOrder} form works unchanged. This
closes the gap where node-API scripts had no standard way to place a
child between two siblings.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ANkY5TN9xn2cFKHk1sfX4B
rymsha
force-pushed
the
claude/order-key-integration
branch
from
August 24, 2026 05:28
b78203f to
23b502e
Compare
The repository root takes the same path as every other parent under order keys: the flip to orderKeyOrder is a metadata write on the root version, children created under root carry the birth key of their creation instant and land first, and anchored reorders place them by sibling keys. The legacy manualOrderValue resolver still skips root - deliberately left as-is, since the key regime never reads it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ANkY5TN9xn2cFKHk1sfX4B
Base automatically changed from
claude/xp-issue-12065-alternative-pur3l4
to
master
August 24, 2026 09:10
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A new manual-ordering primitive wired through the node layer, export/import, layer synchronization, an anchored reorder API, and the JS library surface. Eleven commits, stacked on #12281 (base branch). Design discussed with @sry-enonic.
Why
_manualOrderValueresolves each value against the sibling set and splits fixed gaps:between()halves a2^31gap — 30 targeted inserts into one slot, then it silently mints an exact duplicate of the lower neighbor;The design in one paragraph
An order key is
position '.' node-id, stored plain and sorted lexicographically: a base-36 fraction (lowercase, because the index sorts case-folded) followed by the id of the node holding it, so two live keys are unequal by construction whatever branch, layer or server minted them. A birth key encodes the inverted creation instant — creating a node and moving one to the top are the same zero-read operation. Between two distinct keys there is always a third, so nothing is ever renumbered. The write rule carries all the enforcement: the server mints every stored position itself — jittered, discriminated — whatever anchors it is handed. Reading is therefore open: the key is an opaque string onNodeandContent, to compare and hand back as a reorder anchor, never to parse. (An earlier revision wrapped reads in HMAC tokens; that layer is gone — it protected nothing the write rule doesn't, and the raw order was observable through sorting anyway.)What each commit adds
core-internal): birth/between/before/after over dense keys; a directional run consumes space linearly (10 000 inserts, length unchanged — test); hammering one gap survives 1000+ levels then refuses cleanly at a length cap (test)._orderKey.ChildOrder.orderKeyOrder()=_orderKey ASC, _ts DESC, _id ASC— keys decide; keyless nodes (pre-feature versions, delivered verbatim by pushes) tail in default order, which keeps a flip metadata-only and visually seamless on legacy folders; the id keeps the tail total so paginated listings never skip or duplicate. Verified against embedded ES (missing: _last).isManualOrder()stays as the legacy-parent detector, reordering stays.manualChildOrder.txtlists key-ordered children in display order; import mints a fresh chain along it, keyless children included — an import drains the keyless tail for free.Content.getOrderKey()(inContent.equals, deliberately not in the update no-op gate — placement must never masquerade as a content change);ReorderChildNodeParams/ReorderChildContentParamsgainafterOrderKey/beforeOrderKey;SortNodeCommandplaces a child after/before/between the anchor keys the caller read off the siblings, or first with no anchors — zero sibling reads, codec-validated anchors, misordered or garbage anchors refused.EditableNode.orderKeyis the sanctioned exact-placement channel (node patch), codec-validated; the pinning test caughtNode.equalsignoring the key, which made key-only patches silent no-ops.contentLib.sort({key, childOrder, reorder})—childOrder: 'manual'maps toorderKeyOrder(), any other string parses as an order expression,reorderentries carryafterOrderKey/beforeOrderKeyanchors read off the sibling contents, andreorderwithoutchildOrderflips the parent to manual in the same call. Serialized contents expose the read-only_orderKey. lib-node exposes_orderKeyon read; node create deliberately does not accept it — exact placement stays out of standard create means.NodeServiceImplresets any order-key edit an update editor makes back to the stored value, so update edits content, never placement; only node patch carries the exact-placement capability. Layer sync's internal setter moves tonodeService.patch(the same command update delegates to — now also the honest name). lib-node mirrors the split:_orderKeyis applied only on the patch path and typed only on patch/read shapes.repo.sortgains the same surface ascontentLib.sort—'manual', anchoredreorderentries (nodeId+afterOrderKey/beforeOrderKey), reorder-implies-manual — closing the gap where node-API scripts had no standard way to place a child between two siblings. The existing{key, childOrder}form works unchanged.orderKeyOrder()is a metadata write on the root version, a node created under root lands first by its birth key, and anchored reorders place root children by sibling keys. The legacy resolver's root skip (resolvePotentialManualOrderValue) stays as-is on purpose: the key regime never reads it, and the legacy-root ordering fix was dropped from Speed up root access on the node read and create paths #12283, which is about root access performance.Found by the test suite along the way
NodeIdcan be 256 chars — the discriminator cap was too small.Node.equals/hashCodemust include the key, or key-only patches no-op silently.Verified (final tree)
itest-core · itest-core-content · core-api · core-repo · core-content · core-internal · core-export all green; lib-content + lib-node green incl. TS compile and eslint.
Not in this PR
The generic HMAC key service extraction (dropped with the token layer — the redirect checksums keep their key reading as before); migration of legacy manually ordered parents (root included — see commit 11); collapsing
orderKeyOrder()to its first leg once keyless versions are formally impossible (the stored-expression-per-parent design makes that a one-line change whenever wanted).🤖 Generated with Claude Code
https://claude.ai/code/session_01ANkY5TN9xn2cFKHk1sfX4B