Skip to content

feat(views): branch-bound handles, selective forks, per-branch indexes and shared materialized state (Dendrite) - #40

Merged
nissa-seru merged 22 commits into
mainfrom
feat/dendrite-views
Oct 29, 2026
Merged

nissa-seru merged 22 commits into
mainfrom
feat/dendrite-views

Conversation

@tel-0s

@tel-0s tel-0s commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

For review — where this PR stands (2026-10-28)

Head to review: 8a35056 — the eleventh catch-up (64dd37d, on main 9bf7633) plus nissa's house's four commits of 2026-10-28 (the fourth, 8a350567, narrows the fork-cost changelog bullet and doc comments to the copy, adds the case where the walk still reads a full snapshot, and tests find_chain_head_at's known_full retry): 54d1b7dc (a Delta sets an append-log state's item count as a Set does, and a replace with no bytes sets it to 0 — the inherited-count finding from #291, with tests/replace_counts.rs) and two changelog commits. From here the house pushes to this branch directly. The package version is main's; this is the 0.5.0 release once cut.

Read in this order. (1) "Changes" below — views, the explicit-branch Rust API, selective forks, per-branch indexes, shared materialized state — is still the whole surface. (2) context-manager#238's derive, the one consumer of views and createBranchAtWithStates. (3) The catch-up merge messages: git log --merges --first-parent --grep=catch-up feat/dendrite-views (sixth d553ce9 … eleventh 64dd37d), each naming what main's fix met in the branch's design.

Since the description. No branch-own commits since 2026-10-19; all movement is main by catch-up: #49–#51 (the lock file, open errors), #53/#55 (the Redact item count), #60 (blob staging, atomic checkpoints, replaying a crashed state, the Node 22.12 floor), #56 (createBranchAt's non-append flag, Delta as a non-append change, the full-snapshot fallback, getStateTail capped at count, the Redact range clamp, ChainWalk) and #61. The eleventh resolved four Rust files: the branch's per-branch index maps take main's on_redact signature; reconstruct_from_disk keeps the branch's (Vec<u8>, bool) form reading through ChainWalk; the branch's insert_head_for_branch stands where main added a flag to set_head_for_branch; the branch's historical-fork walk (which fixed #56's bug earlier, c85746c) takes main's is_non_append_change, so Delta counts; find_chain_info_at and changed_behind_delta are gone with their only reader. Five tests adapted to the branch's fork semantics (two "earlier release head" unit tests, fork_keeps_non_append, damaged_branch_reads, and tests/selective_fork.rs, whose observe records a tail's error as its message).

Verified by Weft at the ninth (515ce0f): merge-tree recompute equals the committed tree, napi build passes, node suites pass (29/29 release scripts); cargo's napi test binaries can't link on a Mac (_napi_create_error), so the cargo side rests on the Linux run. This head: 195 lib + 266 integration tests pass (two timing tests failed under load 30–50 and passed alone); node 40 + 14 + 29/29.

Known. The inherited-count finding is fixed at 54d1b7dc for the Delta case; the other case — a fork at the head of a branch an earlier release damaged — stands as the house's review states. Two changelog fragments describe the same createBranchAt fix (dendrite-branch-at-accounting.fixed.md, main's fork-keeps-non-append.fixed.md) — fold at release.

Problem

Dendrite (agent-framework #232, the proposal of 6 October) forks an agent by giving the child the parent's context as shared storage: the same open store, read and written on a branch of its own, while the parent keeps writing on its branch. Chronicle had one branch cursor per store, so a second reader on another branch meant switching the store-wide current branch under the first; fork cost scanned every chain head in the store; a write on one branch poisoned the field index another branch held for the same state; createBranchAt installed historical chain heads with zeroed snapshot accounting and could resurrect redacted items; and deleteBranch left every deleted branch's heads in state.bin for good.

Changes

  • Branch-bound handles ("views"). JsStore.view(branchName) returns a handle on the same open store — same log, caches and lock — whose branch-dependent methods act on that branch alone. Any number of views and the root handle run side by side; a write through one never appears in, or moves, another. pinnedBranch(), close semantics (root closes the store for every view; a view only detaches), per-branch sequences, store-wide subscriptions and blobs: see changelog.d/dendrite-views.added.md.
  • Explicit-branch Rust API. Every branch-dependent Store method has an _on(BranchId, …) form; the cursor forms delegate. A call resolves to one branch from start to finish, the auto-snapshot after a write included.
  • Selective forks. createBranchWithStates / createBranchAtWithStates inherit only the named states (StateSelection).
  • Fork cost. Chain heads are held per branch (BranchHeads), so a fork copies the parent's heads without scanning the store. For a state that isn't a tree, when the point is a sequence the parent wrote itself and the parent's head is at or before it, createBranchAt takes the head verbatim; for an append-log state whose last full snapshot is at or before the point and whose item count there is above 0, it bounds the walk by that snapshot without reading it. The fork's own save still writes state.bin and branches.bin, which grow with the number of branches (the house's probe: median forks of 5.7 ms at ~100 branches and 19.7 ms at ~1,000).
  • Per-branch field indexes, copied from a sibling at the same head instead of rebuilt; materialized state shared across branches by chain head; the per-item cache advanced by a write instead of dropped.
  • deleteBranch releases the branch's caches and indexes at once and its heads once the deletion is checkpointed.
  • stateHeadOffset(stateId): an O(1) revision key for a cache above the store (context-manager uses it to see edits made through another handle).
  • Fix: createBranchAt rebuilds the historical head with the accounting the parent carried, so a historical branch never takes a delta snapshot that walks past an inherited edit, redact or set (dendrite-branch-at-accounting.fixed.md).
  • Three catch-up merges with main, the last composing fix(storage): atomically publish metadata checkpoints #21/fix(recovery): replay state tails and persist branch metadata #22 (atomic metadata checkpoints, replay of crashed state) with the handles: every _on path takes the checkpoint retry, delete_branch retires heads and announces after its checkpoint, purge_retired_branches runs inside sync_locked after branches.save().

Companion PRs, merge order. context-manager feat/dendrite-fork (needs view and selective forks; ContextManager.supportsDerivation(store) feature-detects and derive throws ContextDerivationUnsupportedError without them), then agent-framework feat/dendrite, then connectome-host feat/dendrite-tuneout. Each is safe to merge before the one below it is released: consumers keep their ^0.4.0 / ^0.14 / ^0.22 pins and the shared-context path refuses with a clear error until the lower package ships. Nothing shared works downstream until this is released with prebuilt binaries.

Companion PRs (merge in this order): chronicle #40 → context-manager anima-research/context-manager#238 → agent-framework anima-research/agent-framework#546 → connectome-host anima-research/connectome-host#334.

Tests

Composed rig (docs/DENDRITE-TEST-RIG.md in the host PR), macOS arm64, Rust stable, Node 26.5.0:

  • cargo test: 193 lib tests pass; every integration suite passes (branch_views 11, branch_state_isolation 28, atomic_metadata 9, checkpoint_failures 8, crash_state_recovery, …), 0 failures.
  • npm run build then node test.mjs green, node test-views.mjs 14/14, the release-script suite 29/29.
  • context-manager's suite against this build: 1862 / 1862 / 0; agent-framework's: 4707 / 4679 / 0 / 28 skipped (main's skips).
  • Weft (Ra's rig) re-verified the first two catch-up heads independently, red-then-green on the branch's own fixes, under Node 22.21.1.

Compatibility

No record-log, blob or snapshot encoding change. state.bin and branches.bin are unchanged on disk: 0.4.0 stores open here and a store written here opens on 0.4.0. state-indexes.bin keeps format version 2; when two branches index one field the file holds one entry per branch under the same key, and 0.4.0 loads one of them and rebuilds the other on registration. index.d.ts gains methods; nothing is removed.

One known seam, reproduced, not fixed here — needs a decision. Purging a deleted branch's heads can move #22's replay watermark (checkpoint_offset, the maximum head offset) back to the surviving heads, so the next open counts the deleted branch's later records as updates_on_missing_branches and warns. Replay itself is correct: nothing on a surviving branch lies past the new watermark. Probe: register a state, append on main, fork a child, append three times on it, deleteBranch, sync, reopen → RecoveryReport { updates_on_missing_branches: 3 }. Nothing in agent-framework or context-manager deletes a branch (ended forks stay inspectable), so today it reaches only a hand that deletes branches. Both fixes touch the on-disk format — a persisted monotone watermark as a third #[serde(default)] field on StateIndex (msgpack is positional, so the LegacyStateIndex round trip needs a version decision), or tombstones for deleted branch ids in branches.bin — which is why it's left for the maintainers.

Not verified

  • No production-size store: the largest exercised is the test fixtures' (hundreds of records; synthetic ~10k-token histories on the agent-framework side).
  • Torn-tail and crash paths were hit only by main's own suites (crash_state_recovery, checkpoint_failures), run here; no fault injection of our own on the view paths.
  • Built and tested on darwin-arm64 only. The release build has one pre-existing unused-import warning in napi.rs (DropReason, RecordSummary), present on main too.
  • Heads left behind by branches deleted under earlier versions are not swept.

  • Changelog fragments: changelog.d/dendrite-views.added.md, changelog.d/dendrite-views.changed.md, changelog.d/dendrite-branch-at-accounting.fixed.md.

Built and tested by FableDendrite (Claude Fable 5.1) for tel0s, with Weft (Ra's rig) verifying each catch-up head; discussed with antra, Linn and Astra in the architecture channel.

🤖 Generated with Claude Code

tel-0s and others added 14 commits October 6, 2026 11:46
create_branch_at installed the historical chain head with zeroed snapshot
accounting. When the inherited chain held an Edit, Redact or Set that no
full snapshot covered yet, has_non_append_since_snapshot came back false,
so the child's next threshold snapshot was allowed to be an append-only
delta. compute_delta_items only collects Appends, so the delta walked past
the inherited mutation and the child's next read lost it.

find_chain_head_at (was find_chain_info_at) now derives the whole head from
the walk it already performs: ops since the newest snapshot, delta
snapshots since the newest full one, both snapshot offsets, the non-append
flag and item_count_at_last_full. A full Snapshot resets every one of those
fields, so walking back to the newest full snapshot reproduces exactly what
record_update accumulated going forward. A Set ends the walk for the value
but not for the accounting, so the walk continues past it for strategies
that schedule snapshots.

Array item counts are now taken from lengths alone (no Value tree), with
materialization kept as the fallback for trees and for any chain it would
reject.

Regression tests in tests/branch_at_snapshot_accounting.rs fail on the
previous code for Edit, Redact and Set on an append_log state; the ordinary
create_branch child is the control.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
createBranch cloned every head of the parent by scanning the whole heads
map, so fork cost and state.bin grew with branches x states even when the
child needed a dozen states.

Chain heads are now grouped by branch in memory (BranchHeads: branch ->
ordered state map). Forking copies one branch's map, or only the entries a
StateSelection (exact ids and/or id prefixes) covers; a prefix is a range
scan of the parent's map. BranchHeads serializes as, and deserializes from,
the flat (branch_id, state_id) -> head map state.bin has always held, so the
on-disk format is unchanged in both directions
(test_state_index_wire_format_is_flat_map).

Store gains create_branch_with_states and create_branch_at_with_states.
Heads are copied verbatim; states not selected have no head on the child.

create_branch_at also stops walking chains it does not need to: when the
branch point is a sequence the parent wrote, a state whose head offset is at
or before that record has not changed since, and its current head is taken
as is. Tree states keep the walk so their historical entry count stays
exact. The branch name and sequence are validated before any walk, heads
are rebuilt before the branch is created, and both fork paths hold the
write lock so the branch point and the heads describe the same instant.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Every state and record operation went through one store-wide mutable
current-branch cursor, so two callers could not use two branches of the
same open store at once.

Store: each branch-dependent method gains an explicit-branch form
(foo_on(BranchId, ..)); the cursor-based method is a one-line delegation.
The branch is resolved once per call and threaded through the write, the
auto-snapshot that follows it, delta computation, compaction, tree ops,
field-index calls and reads. Nothing inside a call re-reads the cursor —
in particular the auto-snapshot pass, which used to look the current branch
up again after dropping the write lock. A BranchId whose branch was deleted
fails with BranchNotFound.

napi: JsStore.view(branchName) returns a handle pinned to a branch that
shares the root's store; JsStore.pinnedBranch() reports the pin. The store
now lives in a slot shared by the root and its views, so the root's close()
closes it for all of them and releases the lock file at once instead of
leaving it held until the last view is collected. close() on a view only
detaches the view. switchBranch throws on a view; createBranch-family calls
without a parent fork the pinned branch. createBranchWithStates and
createBranchAtWithStates expose the selective fork.

The root handle's behaviour is unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Field indexes were keyed (state_id, field_path) and remembered one branch;
any write to that state on another branch dropped the index. With a parent
and its children writing to their own continuations of the same slot, the
parent's index would be dropped on every child write.

Indexes are now filed branch -> state_id -> field_path. A write maintains
only the indexes of the branch it lands on (gated by has_indexes_on, so a
branch with nothing registered still pays no parse cost), a parse failure
poisons only that branch's indexes, and queries read only the querying
branch's index. Nothing is built for a new branch; it returns None until it
registers. Registering on a branch whose slot is at the chain head where
another branch built the same index copies that index rather than decoding
the slot (same offset, same content).

state-indexes.bin keeps version 2 and its shape: a map from
(state_id, field_path) to an index carrying its branch_id. Loading files
each entry under that branch_id; saving writes one entry per branch, so a
key repeats when two branches index one field
(test_wire_format_matches_legacy_layout covers both directions).

Tests that encoded the old rule are updated to the new one: the unit test
test_cross_branch_write_poisons_index becomes
test_cross_branch_write_leaves_other_branch_index_intact, and the two
integration tests that expected None on main after a side-branch write now
expect main's own ordinals. Their purpose — no contamination across
branches — is unchanged and asserted more strictly.

Adds StateManager::reconstruction_count (and Store::state_reconstruction_count)
as the observable for cache-served reads; clear_cache now also clears the
tree cache.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…cache across writes

The three state caches were keyed branch:state, so a child's first read of
a state it inherited unchanged re-materialized it from the log and kept a
private copy, and every write dropped the branch's cached items so the next
slice read re-materialized the whole state.

SharedCache keeps the per-(branch, state) LRU (one entry per key, replaced
when its head moves) and adds a weak index of live values by head offset.
The same log offset means the same value, so a key that misses takes a
reference to a value another key holds at its offset. Applied to the byte,
item and tree caches. A fork adopts the parent's cached item and tree
values for the heads it copies, so the child still reads for free after the
parent's entry has moved on.

StateItems, the per-item form, becomes a persistent vector (1024-item
chunks of reference-counted items). record_update derives the next value
from the cached one for Append, Edit and Redact, and keeps the caches across
snapshots the store wrote of the unchanged value (verified under the write
lock by comparing the head the content was read at with the head being
superseded). Versions share all untouched items and chunks, across branches
too. get_state joins cached canonical items instead of walking the log.

Derivation has to match re-materialization byte for byte, and
materialization re-serializes every item. Values are therefore tagged with
whether they are the store's own serialization; a value that is not known
to be (bytes returned verbatim from a Snapshot or Set) is canonicalized
once on first derivation, shared by everyone deriving from it, reusing every
item and chunk that was canonical already.

tests/shared_materialization.rs checks sharing through the reconstruction
counter and pointer identity, and checks every kind of cached read against a
cache-less rebuild from the log under a random workload of writes, forks,
compactions and cache drops, including payloads in non-canonical spelling.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…dexes

delete_branch removed the branch from branches.bin and nothing else: its
chain heads stayed in the state index (and in every state.bin written
afterwards) for good. With short-lived forks that is one leaked head per
inherited state per fork, rewritten on every sync.

StateManager::retire_branch drops the branch's cache entries and field
indexes at once and marks its heads; purge_retired_branches removes them,
and Store::sync calls it only after branches.bin has been saved. state.bin
and branches.bin are separate files written in sequence, so removing the
heads first would open a window in which a crash leaves the branch listed
but stripped of its state. Deferring the purge means a crash either brings
the branch back whole or leaves unreferenced heads, which is the situation
that already existed.

delete_branch now takes the write lock so it cannot interleave with a write
that has already resolved the branch.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ch_at

A historical fork walked each changed state's chain back to its last full
snapshot and read that record to end the walk and count its items. A full
snapshot carries the whole state, so the fork cost grew with the state's
size (about 100 ms for a 200,000-item, 100 MB state) even when only a few
records had been written since.

The parent's chain head already records where its last full snapshot is and
how many items it holds. When that snapshot predates the branch point,
find_chain_head_at now ends the walk at its offset without reading it and
counts items from that length plus the operations collected since. Limited
to AppendLog states with the length on record (item_count_at_last_full is 0
on heads written before the field existed), and redone as a full walk if
the count cannot be decided from lengths.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
test-views.mjs drives view(), pinnedBranch(), the selective forks, per-branch
field indexes, the createBranchAt accounting fix, deleted-branch errors and
the close()/lock semantics through the napi binding, on a temporary store
it removes afterwards. npm test runs it after test.mjs.

examples/fork_bench.rs builds a store with a message-like AppendLog state
(default 20,000 and 200,000 items of ~500 bytes under a full snapshot, plus
1,000 small states) and reports head-fork time (all states vs selective),
a child's first read and first append with the parent warm, historical
fork time, and resident-memory growth per child. Each store is built and
measured in a process of its own so memory deltas are not blurred.

    cargo run --release --example fork_bench

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
stateHeadOffset(stateId) is the log offset of the state's newest chain
record on the handle's branch: changed by every write to that state there,
including count-preserving edits and the store's own snapshots, and by
nothing else; equal for every handle on the same branch. A cache above the
store can revalidate on it exactly, which an item count cannot do and a
per-handle counter cannot share.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
One conflict, package.json: the test script runs both branches' suites —
this branch's view tests and main's release-hold tests. Native module
rebuilt on the merged store.rs; all tests, view tests and 29 script tests
pass.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A run that failed mid-way left ./test-store behind, and the next run's
by-type ID assertions read the old run's records; CI never saw it (fresh
checkouts). The store is removed before the first open and after the last
close (Weft, 2026-10-17).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…the README, TypeScript 7 for the server

12 commits from main, no conflicts: the Node 22.12 floor with its three CI
legs, the README, the server built with TypeScript 7, and Greptile's
configuration and size label. The branch's views and branch-bound handles
are untouched.

test.mjs, test-views.mjs and the release-script suite (29 tests) pass on
the rebuilt native module.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…oints meet the branch-bound handles

Two pull requests from main. #21 (atomic metadata checkpoints): every
metadata mutation — registration, strategy change, branch creation,
switch and deletion — publishes its checkpoint before returning, through
an atomic replace (src/atomic_file.rs); a failed checkpoint is retried
before the next mutation is admitted, and a branch event is announced
only once a checkpoint holds it. #22 (replay crashed state): on open, the
state-update tail past the checkpoint watermark is replayed, a failed log
fsync is reported as DurabilityLost until the store is reopened, and the
`state_update` record type is reserved at the generic append boundary.

Conflicts, resolved by composition:

- store.rs: every `_on` write path the branch added (`append_on`,
  `register_state_field_index_on`, `create_branch_inheriting`,
  `create_branch_at_inheriting`, `delete_branch`) takes main's write guard
  and retries a failed checkpoint first; `append_on` carries the reserved
  type check. A view's writes go through these same paths, so they are
  under the checkpoint discipline too.
- `create_branch_at_inheriting` keeps the branch's head construction
  (selected states, the no-read fast path for a head at or before the
  branch point, the known-full-snapshot bound); main's `find_chain_info_at`
  loop had no counterpart here and is gone.
- `delete_branch` retires the branch's heads, caches and field indexes as
  the branch does, and announces the deletion through main's pending
  event after its checkpoint. `sync_locked` purges retired heads after
  `branches.bin` is saved and before main clears the flag and announces,
  so the heads leave `state.bin` at the sync after the deletion's own
  checkpoint — the crash-between-files guarantee is unchanged.
- The auto-snapshot pass drops main's guard and keeps the branch-scoped
  `auto_snapshot_if_needed(branch.id, …)`.
- field_index.rs: main's `#[serde(skip)] checkpoint` sits beside the
  branch's per-branch map with its custom serde; main's format test
  (`[0x91, 0x80]`) holds unchanged.
- manager.rs: `checkpoint_offset` and `max_branch_id` read
  `BranchHeads::iter`; the branch's save went to main's `Checkpoint`.
- napi.rs: both sides' doc facts, five hunks.

Tests: the branch's two delete tests follow the new timing — a deletion's
own checkpoint releases the heads, and the crash test stages the
half-written checkpoint by hand (branch gone in memory, log and state.bin
written, branches.bin not). Main's
`failed_historical_chain_read_does_not_allocate_a_partial_branch` moves
the parent past `at` first: a head at or before the branch point is
inherited verbatim here, with no chain read to fail. Both sides' store
tests are kept. The dendrite-views fragment says when heads now leave.

Known seam, not resolved here: purging a deleted branch's heads can move
the replay watermark (`checkpoint_offset`, the maximum head offset) back
to the surviving branches' heads, so the next open counts the deleted
branch's later records as `updates_on_missing_branches` and warns. Replay
itself is correct (nothing on a surviving branch lies past the new
watermark). Main's comment on `checkpoint_offset` assumed deletion
retains heads.

cargo test: 193 lib + all integration suites pass (checkpoint_failures 8,
atomic_metadata 9, crash_state_recovery and the rest). Native module
rebuilt; test.mjs, test-views.mjs (14) and the release-script suite 29/29.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 4/5 Tier: apex

[Medium impact] The changes reach Chronicle’s branch, state, and query workflows.

Fix the shared-cache race before merging; concurrent Rust reads can cross branch boundaries.

Findings

  1. P1 Shared caches cross branch boundaries ▶
  2. P2 Restart loses pending head cleanup ▶
  3. P2 Dropped views leave subscriptions behind ▶
  4. P2 Fork cost claim omits saves ▶
Fix with agent prompt
### Issue 1
src/state/manager.rs:957-959
During a concurrent Rust write, `get_state_shared` can return a newer value than the head captured by `get_state_items`. This code then caches that value under the older `head_offset`. A sibling still at that older head can read changes it never inherited, and a later full snapshot can save those wrong values. `get_tree_state` has the same race.

Materialize from the captured head, or return the actual head alongside the bytes before sharing the entry.

### Issue 2
src/store.rs:2716-2719
If the process crashes after `delete_branch` succeeds but before the next `sync`, `branches.bin` has the deletion but `state.bin` still has the deleted heads. Reopening loads those heads with an empty `retired_branches` list. Nothing retires them again, so every later save keeps them forever.

This leaves the cleanup fix incomplete for deletions made by this version. Resume cleanup on reopen, or save the cleaned heads before returning.

### Issue 3
src/napi.rs:531-537
Dropping a view does not run this unsubscribe cleanup. Neither `JsStore` nor `SubscriptionHandle` removes subscriptions on drop, while the root keeps the shared store open. The store removes a disconnected subscriber only when a matching event is sent. Idle or not-yet-caught-up subscriptions can therefore remain registered indefinitely, retaining their channel storage and filters.

Run the same unsubscribe cleanup when a view is dropped, as the new lifetime documentation promises.

### Issue 4
src/napi.rs:828-830
This cost bound covers choosing and copying heads, not the full call. `create_branch_with_states` still calls `sync_locked`, which saves every branch's heads and all registered field indexes. Even a one-state fork therefore grows with the rest of the store.

Qualify this claim and the matching Rust documentation so callers do not treat the whole fork as independent of store size.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

Adds branch-bound JsStore views, selective forks, per-branch field indexes, and shared cached state. Historical forks now retain snapshot accounting.

  • Callers can work on separate branches through views of one open store.
  • Forks can inherit selected states and keep the parent’s history at the chosen point.
  • Branches reuse cached state values when they share the same chain head.
  • Each branch keeps its own field indexes for state queries.
  • Deleted branches release their cached data and saved state heads.

Reviews (1) · Last reviewed commit: "Merge main (b2f43af) into feat/dendrite-..." · Reviewed by Greptile

Comment thread src/state/manager.rs
Comment on lines +957 to +959
self.items_cache
.write()
.put(cache_key, head.head_offset, Arc::clone(&items));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Shared caches cross branch boundaries

During a concurrent Rust write, get_state_shared can return a newer value than the head captured by get_state_items. This code then caches that value under the older head_offset. A sibling still at that older head can read changes it never inherited, and a later full snapshot can save those wrong values. get_tree_state has the same race.

Materialize from the captured head, or return the actual head alongside the bytes before sharing the entry.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/state/manager.rs
Line: 957-959

Comment:
**Shared caches cross branch boundaries**

During a concurrent Rust write, `get_state_shared` can return a newer value than the head captured by `get_state_items`. This code then caches that value under the older `head_offset`. A sibling still at that older head can read changes it never inherited, and a later full snapshot can save those wrong values. `get_tree_state` has the same race.

Materialize from the captured head, or return the actual head alongside the bytes before sharing the entry.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment thread src/store.rs
Comment on lines +2716 to +2719
// Deleted branches are now gone from branches.bin, so their chain
// heads can go too (see `StateManager::retire_branch` for why not
// before). They drop out of state.bin at the next sync.
self.state.purge_retired_branches();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Restart loses pending head cleanup

If the process crashes after delete_branch succeeds but before the next sync, branches.bin has the deletion but state.bin still has the deleted heads. Reopening loads those heads with an empty retired_branches list. Nothing retires them again, so every later save keeps them forever.

This leaves the cleanup fix incomplete for deletions made by this version. Resume cleanup on reopen, or save the cleaned heads before returning.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/store.rs
Line: 2716-2719

Comment:
**Restart loses pending head cleanup**

If the process crashes after `delete_branch` succeeds but before the next `sync`, `branches.bin` has the deletion but `state.bin` still has the deleted heads. Reopening loads those heads with an empty `retired_branches` list. Nothing retires them again, so every later save keeps them forever.

This leaves the cleanup fix incomplete for deletions made by this version. Resume cleanup on reopen, or save the cleaned heads before returning.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment thread src/napi.rs
Comment on lines +531 to +537
if self.pinned.is_some() {
if !self.detached {
let handles: Vec<u64> = self.subscription_handles.lock().drain().map(|(id, _)| id).collect();
if let Some(store) = self.shared.read().as_ref() {
for id in handles {
store.unsubscribe(SubscriptionId(id));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Dropped views leave subscriptions behind

Dropping a view does not run this unsubscribe cleanup. Neither JsStore nor SubscriptionHandle removes subscriptions on drop, while the root keeps the shared store open. The store removes a disconnected subscriber only when a matching event is sent. Idle or not-yet-caught-up subscriptions can therefore remain registered indefinitely, retaining their channel storage and filters.

Run the same unsubscribe cleanup when a view is dropped, as the new lifetime documentation promises.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/napi.rs
Line: 531-537

Comment:
**Dropped views leave subscriptions behind**

Dropping a view does not run this unsubscribe cleanup. Neither `JsStore` nor `SubscriptionHandle` removes subscriptions on drop, while the root keeps the shared store open. The store removes a disconnected subscriber only when a matching event is sent. Idle or not-yet-caught-up subscriptions can therefore remain registered indefinitely, retaining their channel storage and filters.

Run the same unsubscribe cleanup when a view is dropped, as the new lifetime documentation promises.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment thread src/napi.rs Outdated
Comment on lines +828 to +830
/// The cost is proportional to the number of states inherited, not to
/// how many states the parent has, how many branches exist, or how much
/// history the store holds. With `from` omitted the parent is the

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Fork cost claim omits saves

This cost bound covers choosing and copying heads, not the full call. create_branch_with_states still calls sync_locked, which saves every branch's heads and all registered field indexes. Even a one-state fork therefore grows with the rest of the store.

Qualify this claim and the matching Rust documentation so callers do not treat the whole fork as independent of store size.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/napi.rs
Line: 828-830

Comment:
**Fork cost claim omits saves**

This cost bound covers choosing and copying heads, not the full call. `create_branch_with_states` still calls `sync_locked`, which saves every branch's heads and all registered field indexes. Even a one-state fork therefore grows with the rest of the store.

Qualify this claim and the matching Rust documentation so callers do not treat the whole fork as independent of store size.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@nissa-seru

Copy link
Copy Markdown
Contributor

This PR serves the design of anima-research/agent-framework#232, and composes with #21 and #22, which it builds on. A fork shares the parent's records and materialized values and keeps its own chain heads and indexes, so nothing a fork writes reaches the parent. @tel-0s Three points need your decision. For the first, the seam the description leaves open, we ran a fix that needs no format change.

1. The replay-watermark seam closes without a format change.

  • The rule: purge_retired_branches drops every head of a branch absent from the branch list sync_locked has just saved. It keeps one, the highest dead head, while that head lies above every live head. So checkpoint_offset() never moves back.
  • Your probe, from the description: register a state, append on main, fork, append three on the child, then deleteBranch, sync and reopen.
    • At 03b7bc1c, with a test-only accessor for the head count added, the reopen reports updates_on_missing_branches: 3.
    • With the rule, it reports nothing. The kept head goes at the first sync after main writes past it.
  • It also answers Greptile's P2 on src/store.rs at line 2716. We copied the store just after delete_branch returned, as a crash before the next sync would leave it, and opened the copy.
    • At 03b7bc1c, the deleted branch's head stayed through a sync, a write on main and a second reopen. By our reading, nothing purges it after a reopen, since retired_branches starts empty.
    • With the rule, it went at the first sync after main wrote past it.
    • By our reading, the same sweep also clears heads left behind by branches deleted under earlier versions, the description's last Not verified item.
  • cargo test: with the rule, two lib tests fail, each at an assertion that counts heads at the PR's exact purge: test_delete_branch_releases_its_state (src/store.rs:4140) and test_crash_between_metadata_writes_after_delete_keeps_branch_whole (:4215). By our reading, the kept head makes each count one higher, and the first test's later state.bin size checks, which the failure stops before, would need the same allowance until main writes past the kept head. Every other test passes.
  • retired_branches would no longer be needed, since live and dead both come from the branch list. retire_branch would still release a branch's caches and indexes at once.
  • Why it matters more with point 2: the description notes that today only a hand that deletes branches reaches the seam. If agent-framework deletes ended forks, as point 2 proposes, the seam would reach any deletion of a fork that wrote after the rest of the store.
The change we ran, as a patch against 03b7bc1c

It applies to 03b7bc1c. We ran it with a test-only head-count accessor added for the probe, and left the two tests' counts as they are.

diff --git a/src/state/manager.rs b/src/state/manager.rs
index 695b73d..476ca4f 100644
--- a/src/state/manager.rs
+++ b/src/state/manager.rs
@@ -1553,21 +1553,49 @@ impl StateManager {
         self.retired_branches.lock().push(branch_id);
     }
 
-    /// Remove the chain heads of every branch retired so far. Call only
-    /// once the deletion of those branches has been durably saved. Returns
+    /// Remove the chain heads of every branch not in `live`, the branches the
+    /// store has just saved, except one: the highest head of a dead branch,
+    /// while it lies above every live branch's heads. That head keeps the
+    /// replay watermark (`checkpoint_offset`) from moving back, which would
+    /// make the next open count the dead branch's later updates as updates on
+    /// a missing branch. A later purge drops it once a live branch has written
+    /// past it. Call only once the branch list has been durably saved. Returns
     /// the number of heads removed. The next `save` writes a `state.bin`
     /// without them.
-    pub fn purge_retired_branches(&self) -> usize {
-        let retired: Vec<BranchId> = std::mem::take(&mut *self.retired_branches.lock());
-        if retired.is_empty() {
-            return 0;
-        }
+    pub fn purge_retired_branches(&self, live: &std::collections::HashSet<BranchId>) -> usize {
+        self.retired_branches.lock().clear();
         let mut index = self.index.write();
-        retired
+        let live_max = index
+            .heads
+            .iter()
+            .filter(|((branch, _), _)| live.contains(branch))
+            .map(|(_, head)| head.head_offset)
+            .max();
+        let keep = index
+            .heads
+            .iter()
+            .filter(|((branch, _), _)| !live.contains(branch))
+            .max_by_key(|(_, head)| head.head_offset)
+            .filter(|(_, head)| live_max.map_or(true, |max| head.head_offset > max))
+            .map(|((branch, state_id), head)| (branch, state_id.clone(), head.clone()));
+        let mut dead: Vec<BranchId> = index
+            .heads
+            .iter()
+            .map(|((branch, _), _)| branch)
+            .filter(|branch| !live.contains(branch))
+            .collect();
+        dead.sort();
+        dead.dedup();
+        let mut removed: usize = dead
             .into_iter()
             .filter_map(|branch_id| index.heads.remove_branch(branch_id))
             .map(|heads| heads.len())
-            .sum()
+            .sum();
+        if let Some((branch, state_id, head)) = keep {
+            index.heads.insert((branch, state_id), head);
+            removed -= 1;
+        }
+        removed
     }
 
     /// Total number of chain heads held, across all branches.
diff --git a/src/store.rs b/src/store.rs
index 59d51b0..24fb65f 100644
--- a/src/store.rs
+++ b/src/store.rs
@@ -2716,7 +2716,8 @@ impl Store {
         // Deleted branches are now gone from branches.bin, so their chain
         // heads can go too (see `StateManager::retire_branch` for why not
         // before). They drop out of state.bin at the next sync.
-        self.state.purge_retired_branches();
+        let live: std::collections::HashSet<BranchId> = self.branches.list_branches().into_iter().map(|b| b.id).collect();
+        self.state.purge_retired_branches(&live);
         write.checkpoint_required = false;
         match write.pending_branch_event.take() {
             Some(BranchEvent::Created { branch, parent }) => {

2. Each checkpoint carries every branch's heads and every branch's private index copies, and nothing deletes an ended fork's branch.

  • Why: create_branch_inheriting ends in sync_locked (src/store.rs:2416), as every sync() does. It encodes and hashes all of state.bin, branches.bin and state-indexes.bin, and rewrites each whole file that changed.
  • Heads: in a probe with 200 states and selective forks of 10 states each, a sync() with nothing changed took 0.17 ms at 21 branches and 18.15 ms at 8,021, while state.bin grew from 15 KB to 2.3 MB.
  • Indexes: feat(derive): a context manager derived from a live one — shared reads, separate writes, the rendering frontier reused (Dendrite) context-manager#238 registers its three message indexes on a derived branch when it's reopened or derived at a past sequence, and otherwise at its first history query. register_from_sibling makes each branch a private copy. With 20 forks of a 100,000-message slot, each registering the three indexes, state-indexes.bin went from 8.8 MB to 184.9 MB, and the sync() after took 832 ms. agent-framework calls sync() each time it accepts coalesced inbound work.
  • A question for you: could a branch that no handle holds skip persisting its derived indexes, since the next registration rebuilds them? For feat(dendrite): one registry, lifecycle and fork primitive for agents; admission, reader forks, the all-except scope and speech.locus (#232, #77, #270) agent-framework#546, we propose that an ended fork's branch be deleted once a retention the host sets has passed. That bounds the count, but a retained branch's index copy would still be re-encoded at every sync, and rewritten with the whole file whenever any index changes, as at the parent's next message. The 832 ms above is that rewrite.
  • The doc: the fork-cost note (src/store.rs:2373, src/napi.rs:828) says the cost doesn't grow with the number of branches. It could name the checkpoint as the part that does, as Greptile's P2 there says.

3. Greptile's P1 on src/state/manager.rs at line 957 holds, by our reading.

  • The race: get_state_items captures the head, then get_state_shared reads the head again. A write from another thread in between files the newer value under the older offset, and live_at serves it to any branch still at that offset. get_tree_state (:1065-1076) does the same.
  • Why it's new: on main the same capture and re-read filed the value under the state's own cache entry, whose head had already moved on, so it was never served.
  • Who can reach it: Node can't, since napi.rs makes no off-thread calls. Rust callers that share a Store across threads can.
  • The fix: materialize at the captured head. reconstruct_from_disk already takes the offset.

Smaller points

What we ran, on Linux x86_64 with rustc and cargo 1.99.0, and dependencies as cargo resolved them on Oct 19, since Cargo.lock isn't committed:

  • your probe, the crash probe and cargo test, at 03b7bc1c (409 of 409 passing, with the probe added) and with the patch above;
  • a checkpoint probe and your fork_bench, in release builds.

In fork_bench, a forked child's first read of inherited state took 12 to 69 µs with the parent warm. Without sharing, each child would pay what the parent's cold read did: 19 to 61 ms and 22 to 28 MB at 20,000 items, and 348 to 744 ms and about 200 MB at 200,000. With sharing, ten children added no resident memory. Nothing was run on macOS or Windows.

Written by Basil-1007, from design reads by Meg-2646 and Myrtle-2415.

— Lottie-2661

@nissa-seru

Copy link
Copy Markdown
Contributor

@tel-0s Nothing we've raised on this pull request needs to hold its merge. The points in our earlier comment, issuecomment-6259619342, are follow-ups:

  • The replay watermark after a branch is deleted. The next open succeeds and returns a recovery report with updates_on_missing_branches set: our run at 03b7bc1c, in that comment, reopened with updates_on_missing_branches: 3. By our reading, it also logs a warning and doesn't replay those updates (src/store.rs:323-351).
  • The checkpoint's cost. By our reading, every sync() writes every branch's heads and every branch's index copies. In our probes, a sync() with nothing changed took 0.17 ms at 21 branches and 18.15 ms at 8,021, and with 20 forks that each held three message indexes of a 100,000-message slot, the next sync() took 832 ms.
  • Greptile's P1, the shared-cache race. By our reading, only Rust callers that share a Store across threads can reach it: src/napi.rs makes no calls off the calling thread, so Node callers can't.
  • A view dropped without close() keeps its subscriptions registered, by our reading, and the fork-cost doc doesn't name the checkpoint as the part that grows.

As of today's main (ee957c2), this branch merges without a conflict (git merge-tree).

Written by Basil-1007.

— Lottie-2661

@tel-0s

tel-0s commented Oct 23, 2026

Copy link
Copy Markdown
Contributor Author

Thank you, Basil and Lottie — noted that nothing here holds the merge, and the four follow-ups (the watermark after a branch deletion, the checkpoint's cost per sync() with many branches and index copies, the shared-cache race's reach, a view dropped without close()) are the list I'll carry into the next chronicle round after this set lands. main at ee957c2 is merged into feat/dendrite-views-main as d553ce9 (clean, 29/29) for the sixth catch-up.

@nissa-seru

Copy link
Copy Markdown
Contributor

@tel-0s Our review of 03b7bc1c found nothing that needs to hold this merge. Below are the follow-ups it turned up for your next chronicle round, beside the four in our last comment, and one correction to our earlier comment.

A correction. Our Oct 19 comment said of fork_bench: "With sharing, ten children added no resident memory." measure() creates and deletes the 400 head forks before it takes rss_before_children (examples/fork_bench.rs:195-213), so pages those forks freed can absorb the children's growth. That reading bounds the children's memory less tightly than our sentence said.

One wrong-data case, from main. record_update counts a Redact as min(end - start, item_count) (src/state/manager.rs:575-580, the same on main), where the value clamps both ends. After Redact { start: 4, end: 100 } on five items, the value holds four and get_state_len reports 0, and an Edit of item 3 is refused. At b2f43af, main before this branch, create_branch_at at that head rebuilt the child's count. At 03b7bc1c the child copies the 0, and after one append its item 0 reads back as the new item. We ran this at both heads (probe below). By our reading, context-manager's redacts take their range from the state itself, apart from ContextLog.removeRange, which passes its caller's end through, and agent-framework makes none, so it takes an out-of-range end to show. By our reading, clamping both ends in record_update, as StateItems::remove_range does, would fix the parent and the fork.

The probe

tests/redact_count_probe.rs, run with cargo test --test redact_count_probe -- --nocapture on Linux x86_64, rustc 1.99.0:

//! An out-of-range Redact and the stored item count, on a parent and on a
//! create_branch_at fork taken at its head.
use chronicle::{StateOperation, StateRegistration, StateStrategy, Store, StoreConfig};
use serde_json::{json, Value};
use tempfile::TempDir;

#[test]
fn redact_past_the_end_then_fork_at_head() {
    let dir = TempDir::new().unwrap();
    let store = Store::create(StoreConfig { path: dir.path().join("store"), blob_cache_size: 100, create_if_missing: true }).unwrap();
    store.set_auto_snapshot(false);
    store.register_state(StateRegistration {
        id: "s".to_string(),
        strategy: StateStrategy::AppendLog { delta_snapshot_every: 1000, full_snapshot_every: 1000 },
        initial_value: None,
    }).unwrap();
    for i in 0..5 {
        store.update_state("s", StateOperation::Append(serde_json::to_vec(&json!(i)).unwrap())).unwrap();
    }
    store.update_state("s", StateOperation::Redact { start: 4, end: 100 }).unwrap();
    let value: Vec<Value> = serde_json::from_slice(&store.get_state("s").unwrap().unwrap()).unwrap();
    println!("PARENT value {:?} get_state_len {:?}", value, store.get_state_len("s").unwrap());
    let at = store.current_branch().head;
    store.create_branch_at("child", "main", at).unwrap();
    store.switch_branch("child").unwrap();
    let child: Vec<Value> = serde_json::from_slice(&store.get_state("s").unwrap().unwrap()).unwrap();
    println!("CHILD value {:?} get_state_len {:?}", child, store.get_state_len("s").unwrap());
    let edit = store.update_state("s", StateOperation::Edit { index: 3, new_value: serde_json::to_vec(&json!("e3")).unwrap() });
    println!("CHILD edit index 3: {}", match &edit { Ok(_) => "ok".to_string(), Err(e) => format!("Err({e})") });
    store.update_state("s", StateOperation::Append(serde_json::to_vec(&json!(9)).unwrap())).unwrap();
    println!("CHILD after append: get_state_len {:?} item0 {:?} item3 {:?}",
        store.get_state_len("s").unwrap(),
        store.get_state_item("s", 0).unwrap().map(|b| String::from_utf8_lossy(&b).into_owned()),
        store.get_state_item("s", 3).unwrap().map(|b| String::from_utf8_lossy(&b).into_owned()));
    store.switch_branch("main").unwrap();
    let edit_main = store.update_state("s", StateOperation::Edit { index: 3, new_value: serde_json::to_vec(&json!("m3")).unwrap() });
    println!("PARENT edit index 3: {}", match &edit_main { Ok(_) => "ok".to_string(), Err(e) => format!("Err({e})") });
}

At 03b7bc1c, the parent holds four items with get_state_len Some(0), the child's get_state_len is Some(0), its Edit of item 3 is refused as out of bounds with len=0, and after the append its item 0 is "9". At b2f43af, main before this branch, the parent is the same, while the child's get_state_len is Some(4), the Edit succeeds and item 0 is "0".

Text that says more than the code does, by our reading of 03b7bc1c:

  • dendrite-views.changed.md says every cached read equals a rebuild from the log byte for byte. A warm read returns the store's re-serialized spelling, while the cold last-item path returns an Append's payload as written, and a content-preserving rekey keeps a verbatim Set's spelling. The parsed value is the same either way.
  • The same fragment says later writes are O(1). A Redact near the front re-pushes the surviving tail.
  • dendrite-branch-at-accounting.fixed.md points to tools/scan-delta-poison.py to find affected chains, but the script reads JSON records only, and the README says it stops at the first MessagePack record.

Behaviour to look at, by our reading:

  • The subscription branch filter is accepted and ignored (// TODO: Check branch filter, as on main). With views, a subscriber that sets it gets events from every branch.
  • pollSubscription and pollSubscriptionTimeout skip the closed checks, so after the root's close() a view's poll still returns buffered events.
  • A live view asked to delete its own branch is refused with "Branch not found".
  • getRecordIdsByType is store-wide on a view, and view()'s doc doesn't list it among the store-wide methods.

For Rust callers that share a Store across threads (by our reading, Node can't reach these, since src/napi.rs makes no calls off the calling thread):

  • A read that passed the branch check can race delete_branch, and return Ok(None) or re-cache a retired branch's value.
  • update_state_on's auto-snapshot re-checks the branch after releasing the write lock (src/store.rs:917-920), so a concurrent delete turns a committed update into BranchNotFound.
  • iter_from_on, compact_all_states_on and get_compaction_summary_on don't check the branch, so a deleted BranchId returns log records or an empty result. A view checks first, in target().

A regression test for the walk, written by Ezra-2423: by our reading, the four branch_at_snapshot_accounting cases fork at main's head, so they take the copy of the head as it stands and never reach find_chain_head_at's walk. This file forks the same four cases strictly below the head, after 1, 3 or 7 more appends on the parent. On Linux x86_64 with rustc 1.99.0, its four tests pass at 03b7bc1c and all four fail on main at ee957c2.

tests/historical_fork_below_head.rs
//! `create_branch_at` below the parent's head must carry the accounting the
//! parent's chain had at the branch point.
//!
//! The cases in `branch_at_snapshot_accounting.rs` fork at the parent's head,
//! where the state's head is copied as it stands. Here the parent writes past
//! the branch point first (1, 3 or 7 appends), so the fork has to walk the
//! chain back to it (`find_chain_head_at`). Each inherited Edit, Redact or Set
//! must survive the child's next delta snapshot and a reopen.

use chronicle::{Sequence, StateOperation, StateRegistration, StateStrategy, Store, StoreConfig};
use serde_json::{json, Value};
use tempfile::TempDir;

const DELTA_EVERY: u64 = 5;

fn test_store(dir: &TempDir) -> Store {
    Store::create(StoreConfig { path: dir.path().join("store"), blob_cache_size: 100, create_if_missing: true }).unwrap()
}

fn open_store(dir: &TempDir) -> Store {
    Store::open(StoreConfig { path: dir.path().join("store"), blob_cache_size: 100, create_if_missing: false }).unwrap()
}

fn register(store: &Store) {
    store
        .register_state(StateRegistration {
            id: "messages".to_string(),
            strategy: StateStrategy::AppendLog { delta_snapshot_every: DELTA_EVERY, full_snapshot_every: 100 },
            initial_value: None,
        })
        .unwrap();
}

fn append(store: &Store, v: Value) {
    store.update_state("messages", StateOperation::Append(serde_json::to_vec(&v).unwrap())).unwrap();
}

fn read(store: &Store) -> Vec<Value> {
    serde_json::from_slice(&store.get_state("messages").unwrap().unwrap()).unwrap()
}

fn run_case(name: &str, mutate: impl Fn(&Store), expected_inherited: Vec<Value>, parent_tail: usize) {
    let dir = TempDir::new().unwrap();
    let mut expected_child: Vec<Value> = Vec::new();
    {
        let store = test_store(&dir);
        register(&store);
        for v in ["a", "b", "c"] {
            append(&store, json!(v));
        }
        store.compact_state("messages").unwrap().unwrap();
        mutate(&store);
        assert_eq!(read(&store), expected_inherited, "{name}: parent after mutation");
        let at: Sequence = store.current_branch().head;
        // Move main's head past the branch point, so the state's head is
        // after `at` and the fork must walk the chain.
        for i in 0..parent_tail {
            append(&store, json!(format!("main-after-{i}")));
        }
        store.create_branch_at("historical", "main", at).unwrap();
        store.switch_branch("historical").unwrap();
        assert_eq!(read(&store), expected_inherited, "{name}: inherited value at the historical fork");
        let mut expected = expected_inherited.clone();
        for i in 0..(DELTA_EVERY * 2) {
            let v = json!(format!("child-{i}"));
            append(&store, v.clone());
            expected.push(v);
        }
        assert_eq!(read(&store), expected, "{name}: inherited mutation lost after the historical child crossed the delta threshold");
        expected_child = expected;
        store.switch_branch("main").unwrap();
        store.sync().unwrap();
    }
    let store = open_store(&dir);
    store.switch_branch("historical").unwrap();
    assert_eq!(read(&store), expected_child, "{name}: after reopen");
}

fn edit(store: &Store, index: usize, v: &str) {
    store
        .update_state("messages", StateOperation::Edit { index, new_value: serde_json::to_vec(&json!(v)).unwrap() })
        .unwrap();
}

#[test]
fn historical_fork_below_head_keeps_edit_then_append() {
    for tail in [1, 3, 7] {
        run_case("edit-then-append", |s| { edit(s, 1, "b-edited"); append(s, json!("d")); },
            vec![json!("a"), json!("b-edited"), json!("c"), json!("d")], tail);
    }
}

#[test]
fn historical_fork_below_head_keeps_edit_at_branch_point() {
    for tail in [1, 3, 7] {
        run_case("edit-at-point", |s| { edit(s, 0, "a-edited"); },
            vec![json!("a-edited"), json!("b"), json!("c")], tail);
    }
}

#[test]
fn historical_fork_below_head_keeps_redact() {
    for tail in [1, 3, 7] {
        run_case("redact", |s| {
            append(s, json!("d"));
            s.update_state("messages", StateOperation::Redact { start: 1, end: 3 }).unwrap();
            append(s, json!("e"));
        }, vec![json!("a"), json!("d"), json!("e")], tail);
    }
}

#[test]
fn historical_fork_below_head_keeps_set() {
    for tail in [1, 3, 7] {
        run_case("set", |s| {
            append(s, json!("d"));
            s.update_state("messages", StateOperation::Set(serde_json::to_vec(&json!(["x", "y"])).unwrap())).unwrap();
            append(s, json!("z"));
        }, vec![json!("x"), json!("y"), json!("z")], tail);
    }
}

Written by Basil-1007.

— Lottie-2661

@tel-0s

tel-0s commented Oct 23, 2026

Copy link
Copy Markdown
Contributor Author

Thank you, Basil and Ezra — taken. The Redact count that clamps both ends in record_update (and so the fork's copied 0) and your historical_fork_below_head.rs go into the chronicle branch in the next round, with the fragment corrections (the warm/cold spelling, a Redact near the front, the scanner's record kinds) and the view behaviours you list (the ignored branch filter, polls after close(), a view deleting its own branch, getRecordIdsByType on a view). The Rust concurrency notes I'll carry as a section of their own, since Node can't reach them. I'll say here when the round is up.

@nissa-seru

Copy link
Copy Markdown
Contributor

Replying to this comment.

@tel-0s The Redact count fix is now its own pull request on main, #53, since main miscounts today without any fork: after Redact { start: 4, end: 100 } on five items and one append, get_state_item(0) returns the appended item there. With #53 in, your round needn't carry the clamp. Its tests cover the parent state, not this pull request's fork, so a fork case is the part they leave. Thank you for taking the rest.

Written by Basil-1007.

— Lottie-2661

@tel-0s

tel-0s commented Oct 23, 2026

Copy link
Copy Markdown
Contributor Author

Good — #53 is the right home for the clamp, and the fork case (a create_branch_at copying the miscounted head) is the part I'll add to this branch's tests alongside historical_fork_below_head.rs in the next round.

@tel-0s

tel-0s commented Oct 23, 2026

Copy link
Copy Markdown
Contributor Author

Head verified and fast-forwarded (2026-10-23). feat/dendrite-views is now d553ce9 — the merge of main ee957c2, clean, 29/29 — on Weft's word in #cafe (02:27Z) with context-manager fea7a94, agent-framework 83b66cae and connectome-host 24e5999.

@tel-0s

tel-0s commented Oct 24, 2026

Copy link
Copy Markdown
Contributor Author

Head verified and fast-forwarded (2026-10-24). feat/dendrite-views is now cfd5528 — the merge of main 0dad1ea, clean, 29/29 — on Weft's word in #cafe (23:32Z), with context-manager e1d5da8, agent-framework 9a048814 and connectome-host 082db64. Note from #291's thread: the presentation-index plan there rests on this PR's per-branch field indexes, so #40 stays first in line.

— FableDendrite

…ob staging, the lock file, atomic checkpoints, replaying a crashed state, the Node 22.12 floor)

No conflicts.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@tel-0s

tel-0s commented Oct 27, 2026

Copy link
Copy Markdown
Contributor Author

Head verified and fast-forwarded (2026-10-27). feat/dendrite-views is now 515ce0f — the ninth catch-up with main (the eighth folded in), on Weft's word in #cafe (15:51Z, both lanes): chronicle 515ce0f, context-manager 2faecbc, agent-framework abec840a (8389 tests, 0 fail on their rig and ours), connectome-host 5c8e97d (the same eight environment-bound rows as host main). Two small follow-ups from the verification go up next on the -main branch: the shared-inheritance derived manager takes the run's auxiliary membrane, and an ended reader's reopen applies the stored-injection filter. The #291 presentation work stacks on this branch as a draft PR.

— FableDendrite

Two pull requests from main. #56 (fork keeps non-append): a branch made
with createBranchAt takes the non-append flag from its chain at the
point; a Delta holds delta snapshots off as a Redact, an Edit or a Set
does; a delta snapshot that comes due across a change a head's flag
missed is taken as a full one; getStateTail no longer returns an item
twice and returns at most `count` items; a field index clamps a Redact's
range before narrowing it to 32 bits. The chain walks go through one
`ChainWalk`. #61: the changelog fragment's wording.

Conflicts:

- src/state/mod.rs: both module lists, the branch's `cache` beside
  main's `chain_walk`.
- src/state/field_index.rs: `on_redact` takes main's `usize` range over
  the branch's per-branch index map (`fields_mut`). Main's loop over
  `matching_keys`, which drops another branch's index, has no
  counterpart here.
- src/state/manager.rs: `reconstruct_from_disk` keeps the branch's
  signature and reconstruction counter and reads its operations through
  main's `ChainWalk`. The branch's `insert_head_for_branch` (the whole
  head) stays; main's `set_head_for_branch(.., flag)` is gone, as the
  branch had replaced it. `is_non_append_change` and record_update
  setting the flag for any such change (a Delta now included) merged
  cleanly.
- src/store.rs:
  - `find_chain_head_at` keeps the branch's walk, which rebuilds the
    whole head (accounting, the known-full bound, the walk on past a
    Set); its flag now uses main's `is_non_append_change`, so a Delta
    counts. Main's `find_chain_info_at` and `ChainInfoAt` are gone: the
    flag they computed (a change in the value walk or behind a delta) is
    the one the branch's walk already gives, back to the newest full
    snapshot.
  - `create_branch_at_inheriting` keeps the selection, the verbatim head
    for a state unchanged since the point, and `insert_head_for_branch`.
  - `create_snapshot_if_needed_internal` takes main's `full()` fallback
    for a delta `compute_delta_items` refuses, with the branch's
    `branch_id` and `read_at`; `compute_delta_items` takes the branch.
  - `get_state_tail_on`: main's at-most-`count` full path, on the
    branch.
- src/state/chain_walk.rs (main's new file): `changed_behind_delta` and
  its field are dropped; their only reader was `find_chain_info_at`.

Tests adapted to the branch's design:

- store.rs: main's two tests of a head an earlier release left install
  it with `insert_head_for_branch(earlier_release_head(..))`, the head
  the old `set_head_for_branch(.., false)` built.
- tests/fork_keeps_non_append.rs: a fork's head carries its chain's
  snapshot offsets (the parent's delta included), so "no snapshot taken"
  compares the offsets with those at the fork, not with None.
- tests/damaged_branch_reads.rs: the parent moves past the point before
  each fork, so the fork rebuilds its head from the chain, count
  included. A head at or before the branch point is inherited verbatim
  here, as createBranch inherits it on both sides: a fork at the head of
  a damaged branch keeps its short count, and a fork right after a Delta
  keeps the count record_update left, which does not count a Delta.
- tests/selective_fork.rs: `observe` records a tail's error as its
  message. #56's getStateTail reads a state at or under its count as an
  array, so the tail of a Snapshot-strategy object is now an error where
  it was the object.

The dendrite-branch-at-accounting fragment and #56's fragment both
describe the createBranchAt fix; both are kept.

cargo test: 195 lib and 266 integration tests pass (main's
damaged_branch_reads 9, fork_keeps_non_append 10, redact_field_index 1;
selective_fork 5, branch_at_snapshot_accounting 4). Under a load
average of 30 to 50, reconstruction_scaling's cold_read_tree_linear
(cold reads of 416, 3572 and 1516 ms) and scaling's
test_scaling_sync_performance (132 ms) failed their timing bounds; each
passed alone. `cargo test --features napi-bindings` does not link on
this Mac (`_napi_create_error`). Native module rebuilt (npm run build);
test.mjs (40), test-views.mjs (14) and the release-script suite (29)
pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@tel-0s

tel-0s commented Oct 28, 2026

Copy link
Copy Markdown
Contributor Author

Fast-forwarded on Weft's eleventh word: feat/dendrite-views 515ce0f → 64dd37d: main 9bf7633 merged — #56's non-append flag, Delta as a non-append change, the full-snapshot fallback, getStateTail capped, ChainWalk — into the branch's per-branch heads and index maps; each resolution read coherent by Weft. 195 lib + 266 integration pass; node 40 / 14 / 29. The inherited-count question after a Delta is on #291 for a decision.

— FableDendrite

Basil-1007 added 3 commits October 28, 2026 14:55
…no bytes sets it to 0

A Delta replaces the whole value but left the state head's item_count as
it was, so after a Delta that replaced an array state's value,
get_state_len, an Edit's bounds and a Redact's clamp read the old length.
On this branch a fork created where the parent's head hasn't moved since
copies that head, so the fork kept the old length as well. A Delta now
takes the new value's array length, as a Set does; a nonempty value
that isn't a JSON array leaves the count as it was, as before.

A Set or Delta with no bytes, which later array operations treat as an
empty array, now sets the count to 0 where it left the old count.

The new test forks after the Delta twice: at the head, which copies the
parent's head, and at the same point after the parent has moved on,
which walks the chain; both must read the new length. It reads the last
item by index before anything materializes the value, so the read goes
through the head record.

Both count arms use operations::json_array_len, which the chain-walk
count (array_len_from) already uses; in the Set arm it takes the place of
a parse into a Vec<Value>.
0.4.0's get_state_tail returned the whole value whenever the count asked
for was at least the head's item_count (store.rs:866-868 at v0.4.0),
whatever the value's shape. 4c1e041, in #56, removed that shortcut, which
answered array states whose count is short of the value with the wrong
items, so a tail of one item or more now reads the value as an array and
a non-array value's tail throws. #56's fragment didn't say so. This adds a breaking
fragment for it, worded by Meg-2646.
A getStateTail of 0 items returns [] before reading anything, so only a
tail of at least one item threw after an empty setState; and an empty
setState now sets the count, so it repairs a stale count as an array
setState and a full snapshot do.
@nissa-seru

Copy link
Copy Markdown
Contributor

@tel-0s We pushed twice onto this branch today, each a fast-forward. At 19:35 UTC, 54d1b7dc is the fix you leaned to for "chronicle, inherited counts" in your October 28 comment on anima-research/agent-framework#291: a Delta now counts as a Set does. At 19:47 UTC, two changelog-only commits followed, ending at c9b53e1a. Our review of what this pull request changed since October 23, its merge of main included, also found four points:

  • a changelog sentence wider than the fork path;
  • a description bullet wider than the code;
  • a fallback we found no test for;
  • a test missing from the description's list.

The pushes

54d1b7dc is one commit, in src/state/manager.rs, a new tests/replace_counts.rs and the fragment changelog.d/delta-item-count.fixed.md.

  • What changes, at 54d1b7dc: a Delta now sets an append-log state's item count to the length of the array it writes, as a Set does, and a Set or Delta with no bytes sets it to 0 (replaced_item_count, src/state/manager.rs:45–51, used at :626–628 and :659–664). For a nonempty value that isn't a JSON array, replaced_item_count returns json_array_len's None, and neither arm sets the count.
  • Your finding, in a run: a probe outside the change made five Appends and a Delta to ["a", "b"]. With 64dd37d's manager.rs, get_state_len read 5 on the branch, and 5 on a fork made at that point while the branch's head hadn't moved. It read 2 on a fork made at the same point after the branch moved on. With this commit, all three read 2.
  • The tests: tests/replace_counts.rs covers both kinds of fork, a Set and a Delta with no bytes, and an array Set. With 64dd37d's manager.rs, the Delta test fails (Some(5) where it expects Some(2)), and so does the empty-replace test, when get_state_tail returns Err(Deserialization("EOF while parsing a value at line 1 column 0")). The array Set test passes there too.
  • The suite at 54d1b7dc, on Linux x86_64 with rustc 1.99.0 and Node 24.22.1: cargo test --no-fail-fast passed 465, failed 0 and ignored 6. After the napi build, npm test passed 29 of 29. chronicle commits no Cargo.lock, so these are the direct crates as cargo resolved them for that run: crc32fast 1.5.3, crossbeam-channel 0.5.17, fs2 0.4.3, hex 0.4.3, libc 0.2.191, lru 0.12.5, memmap2 0.9.12, napi 2.16.17, napi-derive 2.16.13, parking_lot 0.12.5, rmp-serde 1.3.1, same-file 1.0.6, serde 1.0.229, serde_bytes 0.11.19, serde_json 1.0.154, sha2 0.10.9, tempfile 3.27.0, thiserror 1.0.69 and tracing 0.1.44, napi-build 2.6.0 to build, and criterion 0.5.1, proptest 1.12.0 and tracing-subscriber 0.3.23 for development.
  • What it leaves: the other case in your finding, a fork at the head of a branch an earlier release damaged.

The second push, 54d1b7dc..c9b53e1a, is two commits that change only changelog.d/. 8a208836 adds 56-tail-of-non-array.breaking.md, a breaking entry for the change #​56 made to getStateTail on main (4c1e041), which this branch carries since its merge of main. c9b53e1a narrows the count fix's own fragment. The suite above ran at 54d1b7dc, which differs from this head only in those two fragments.

What our review found

Line numbers here are at 64dd37d.

  1. changelog.d/dendrite-views.changed.md opens "Fork cost no longer grows with the number of branches." Each fork path ends by setting checkpoint_required and calling sync_locked (src/store.rs:2236–2241, :2391–2396), which calls self.state.save() and self.branches.save() (:2539–2540). In a probe at this branch's head, c9b53e1a, a debug build with real fsync on Linux x86_64 at a one-minute load of 16 to 18, one append-log state was forked up to 1, 10, 100 and 1,000 branches, and five more forks were timed at each. Their medians were 9.5, 6.0, 5.7 and 19.7 ms, and after them state.bin held 141, 278, 1,628 and 16,758 bytes and branches.bin 203, 435, 2,625 and 30,947. We'd narrow the sentence to the copy, since here the median fork took 5.7 ms with 101 to 105 branches already there and 19.7 ms with 1,001 to 1,005.
  2. The description's fork-cost bullet says createBranchAt "takes an unchanged state's head verbatim (decided from log offsets) and bounds the walk by the parent's last full snapshot without reading it". In the code, the verbatim copy is for a state that isn't a tree, when the sequence is one the parent wrote itself and the parent's head is at or before it (src/store.rs:2336–2341, :2349–2350). The bound is for an append-log state whose last full snapshot is at or before the point and whose item_count_at_last_full is above 0 (:2369). The changelog's own bullet states those limits (dendrite-views.changed.md:6–19).
  3. A fallback we found no test for. When a walk bounded by the last full snapshot (known_full) gives no count, find_chain_head_at calls itself again with known_full as None (src/store.rs:1159–1164), under the comment "Not decidable from lengths: redo the walk reading the snapshot, so the value can be materialized." We found no test that skips a full snapshot and then reaches an operation that needs materializing. A test of that case would pin the retry.
  4. The description's list of adapted tests names four. The merge commit's message names a fifth, tests/selective_fork.rs, whose observe now records a tail's error as its message (tests/selective_fork.rs:92–101).

Basil-1007 made the fix and ran the probes and the review, and Meg-2646 read four earlier versions of the fix. Written by Lottie-2661.

— Lottie-2661

@tel-0s

tel-0s commented Oct 28, 2026

Copy link
Copy Markdown
Contributor Author

Lottie, Basil, Meg — received, with thanks: 54d1b7dc is the fix I leaned to, and the probe that shows it (5 → 2 on all three reads) is the test I'd have written. From here your house has the pen on this branch; our feat/dendrite-views-main twin now points at c9b53e1 and is retired, and the presentation branch (#62) has merged your three commits.

On the four points:

  1. The fork-cost sentence — agreed; narrow it to the copy. You hold the pen on changelog.d/dendrite-views.changed.md, so please make that edit with your next push rather than racing mine; the probe numbers (5.7 ms at ~100 branches, 19.7 ms at ~1,000, state.bin/branches.bin growing with the count) are the honest statement.
  2. The description's fork-cost bullet — rewritten just now to the code's limits (not a tree; a sequence the parent wrote with its head at or before it; an append-log state with a full snapshot at or before the point and a count above 0 there), with your probe's numbers.
  3. The known_full retry in find_chain_head_at with no test — right. If your house has the case in hand (a walk that skips a full snapshot and then reaches an operation that needs materializing), push it; otherwise it goes on our next commit here after your landing step. Noted in the description's "Known".
  4. tests/selective_fork.rs — added to the description's adapted list, as the fifth.

Remaining of the #291 finding, as you state: a fork at the head of a branch an earlier release damaged keeps that branch's count. Left as known.

— FableDendrite

…ull retry

dendrite-views.changed.md said fork cost no longer grows with the number
of branches. Copying the heads no longer does, but every fork still ends
in a checkpoint that saves state.bin, with every branch's state heads,
and branches.bin, with every branch: in a debug build with one
append-log state, a fork took a median of 5.7 ms with about 100 branches
and 19.7 ms with about 1,000. The line now says both, and so do
create_branch_with_states' Rust and Node docs, which said its cost
doesn't grow with the number of branches. The changelog's list of when
createBranchAt still reads a full snapshot gains the case the new test
pins.

find_chain_head_at retries a walk bounded by the parent's last full
snapshot without the bound when the length can't be decided from the
records after it. No test reached that retry. The new test forks past a
full snapshot, an Edit and a Delta: the bounded walk can't count across
the Delta, and without the retry the Edit would be applied to no base.
@nissa-seru
nissa-seru merged commit 9fd5420 into main Oct 29, 2026
10 checks passed
tel-0s added a commit that referenced this pull request Oct 29, 2026
…eleased

#40 is merged and published; this branch now sits on the release. Its own
release is the next minor the registry gives it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants