Refactor from per-session sqlite files to one shared shard at ~/.arborist/sessions.db. Three things that didn't work before now do: 1. Queries are first-class members of the tree. nodes_fts (FTS5 over question + answer_text + cited_titles) lets /find <query> walk every prior turn across every session. Cached answers and threads become findable, surface in the REPL as `[<bates>] <audit> <question>` lines. 2. Forking from history works the same as forking from a sibling. parent_bates can cross sids. After /find returns a hit from last week's session, /cd <bates> + ask = your next question lands as a child under that historical turn. The cross-session parent's subtree_hash ripples up its session_root. 3. One global audit chain instead of per-file. audit_events.event_hash = sha256(prev || canonical body), one chain over every state change in the shard. `make session-chain-check` is now a single pass; tampering anywhere in the operator's history breaks the chain. Wire: - arborist/qa/session.py — drop file-per-session SessionStore class; Session becomes a viewport on SessionStore. cited_titles_json + n_cited_sources materialized at insert time so FTS5 doesn't need a join into providence_cache. - arborist/cli.py — `session` subcommand swaps --gc for --find; REPL adds /find. Ancestor-keyword extraction now reads nodes.cited_titles_json directly (no qa.db roundtrip). - Makefile — `make session-find Q="..." [LIMIT=N JSON=1]`; `make session-gc` retired (no per-session files to GC). - docs/sessions.md — rewritten for the single-shard shape. - tests/test_session.py — 17 tests: create, add, fork (incl. cross-session), find (FTS5 + by_cache_key), path_to_root crossing sessions, audit chain (intact + tampered), cited-title extraction, subtree_hash ripple across sessions. Migration: pre-existing per-session dbs at ~/.arborist/sessions/*.db become orphaned. None lost data — test sessions only. Operator can rm -rf ~/.arborist/sessions/ (or rename to sessions-old/) at leisure. 126 session+providence+verify+inspect tests pass.
6.7 KiB
Sessions — single-shard Merkle conversation forest
Multi-turn Q&A with branching. One SQLite shard at
~/.arborist/sessions.db holds every turn from every session a user
has ever started. Sessions are viewports onto a shared forest —
nodes can fork across sessions (yesterday's turn → today's child) via
a single parent_bates pointer.
Privacy: this layer is local-private for now. Gossip-trust sharing across users is future work; the shard's contract today is "private journal of everything the operator asked, plus the answers."
On-disk shape
CREATE TABLE sessions (
sid TEXT PRIMARY KEY,
root_bates TEXT NOT NULL, -- always seq=0, empty question
current_bates TEXT NOT NULL, -- REPL "you are here"
next_seq INTEGER NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
label TEXT
);
CREATE TABLE nodes (
bates TEXT PRIMARY KEY, -- "<sid>-<6-digit>"
sid TEXT NOT NULL, -- session that minted this node
seq INTEGER NOT NULL,
parent_bates TEXT REFERENCES nodes(bates), -- can cross sids
question TEXT NOT NULL,
answer_text TEXT NOT NULL, -- materialized at insert time
cache_key TEXT NOT NULL, -- → providence_cache
audit_mode TEXT NOT NULL,
cited_titles_json TEXT NOT NULL, -- parsed from [E# | <title> | …]
n_cited_sources INTEGER NOT NULL,
created_at TEXT NOT NULL,
label TEXT,
node_hash TEXT NOT NULL, -- content-addressed
subtree_hash TEXT NOT NULL -- Merkle over self + all descendants
);
CREATE VIRTUAL TABLE nodes_fts USING fts5( -- search across all sessions
question, answer_text, cited_titles
);
CREATE TABLE audit_events ( -- one global hash chain
seq INTEGER PRIMARY KEY AUTOINCREMENT,
event_type TEXT NOT NULL,
sid TEXT,
bates TEXT,
prev_event_hash TEXT,
event_hash TEXT NOT NULL, -- sha256(prev || canonical body)
body TEXT NOT NULL,
created_at TEXT NOT NULL
);
Three integrity surfaces:
node_hash— content-addressed, never changes after insert.subtree_hash— recomputed when a node gains a child (anywhere in the forest); ripples up the parent chain regardless of session boundaries.audit_events.event_hash— single global chain; tampering with any state-change record breaks it.
Bates discipline
<sid>-<6-digit-seq>. sid is timestamp+random, never reused; seq
is per-sid monotonic, never reassigned. seq=0 is a synthetic root with
empty question (collapsed in /tree rendering, kept so every real
turn has a parent).
Merkle properties
node_hash = sha256(canonical_json({
parent_node_hash, bates, question, cache_key,
audit_mode, created_at, label,
}))
subtree_hash = HashCombine(
leaf_hash(node_hash),
HashCombine over sorted-by-hash children_subtree_hashes,
)
Children sorted by subtree_hash (content-addressed) keep the combine
order deterministic — two stores that land the same nodes in different
order produce the same root.
Insert path is O(depth). On insert of N under P:
- Compute
N.node_hash. N.subtree_hash = leaf_hash(N.node_hash).- Walk
N → P → … → root(crossing sessions if parent_bates does) recomputing each ancestor'ssubtree_hash. - Append
audit_eventsrow.
Sibling subtrees that didn't change keep their subtree_hash. That's
the property page-refresh caching exploits — a UI client tracks
(bates → subtree_hash, body) and only refetches paths whose hash
differs.
Cross-session forks
Forking is implicit. Any turn from any session is /find-able and
/cd-able. After /cd <bates>, the next non-command question lands
as a child of that node. If the node belongs to a different session,
the new child inherits the original parent's node_hash via Merkle —
that session's subtree_hash ripples up to include the cross-session
child.
In /tree rendering, cross-session children are tagged
[from <sid>]. (With local-only storage that's always self today;
gossip-shared sessions will surface external sids the same way.)
CLI
arborist session # new session, REPL
arborist session --sid <id> # resume
arborist session --list [--json] # all sessions
arborist session --tree --sid <id> # one session's tree
arborist session --find "spider man" # FTS5 across ALL sessions
arborist session --chain-check # verify global audit chain
arborist session --sessions-db PATH # override shard location
REPL commands:
| command | effect |
|---|---|
/tree |
print this session's tree |
/branches |
list branch-point Bates (≥2 children) |
/find <query> |
FTS5 search across ALL sessions |
/cd <bates|seq|label> |
move current pointer (cross-session OK) |
/back |
cd to parent of current |
/label <name> |
name current node |
/show [ref] |
dump stored info for a node |
/sid |
print session id |
/root |
print session_root |
/help |
help |
/quit |
exit (or Ctrl-D) |
| anything else | new question, child of current |
Makefile:
make session [SID=...] # interactive REPL (defaults LLM=qwen)
make session LLM=hermes # use Hermes-3-8B instead
make session-list # list sessions
make session-tree SID=... # one tree
make session-find Q="..." # search [LIMIT=N JSON=1]
make session-chain-check # verify audit chain
Retrieval flow down a branch
When you ask a follow-up turn under parent P, the REPL gathers cited
titles from P and all ancestors (already materialized in
nodes.cited_titles_json at insert time) and passes them to
providence_query via policy["retrieval_keywords"]. The keyword
string augments FTS5 retrieval only — it never enters
cache_key / question_hash / governance_policy_hash. Same
discipline as the legacy --retrieval-keywords flag (#000001).
This makes pronoun follow-ups ("who created him?") surface the prior
turn's primary source. It does not thread prior Q&A into the LLM
prompt — that's a separate enhancement (would fold into
conversation_hash).
Portability
The shard's schema has no FK dependency on the main arborist store. A non-Python consumer reading it needs:
- SQLite3 with FTS5 (for the search index).
- The canonical-JSON convention: sorted keys, no whitespace, UTF-8.
- Three hash primitives:
sha256(bytes) → hexleaf_hash(bytes) = sha256(0x00 || bytes)hash_combine(L, R) = sha256(0x03 || L || R)
Same conventions as proxy.unturf.com Go merkle and the rest of
arborist (see arborist/merkle.py).