arborist/docs/sessions.md
russell@unturf.com 45a348b4f0
session: single-shard forest + FTS5 search + cross-session forks
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.
2026-06-01 17:32:44 -04:00

185 lines
6.7 KiB
Markdown

# 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
```sql
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:
1. Compute `N.node_hash`.
2. `N.subtree_hash = leaf_hash(N.node_hash)`.
3. Walk `N → P → … → root` (crossing sessions if parent_bates does)
recomputing each ancestor's `subtree_hash`.
4. Append `audit_events` row.
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:
1. SQLite3 with FTS5 (for the search index).
2. The canonical-JSON convention: sorted keys, no whitespace, UTF-8.
3. Three hash primitives:
- `sha256(bytes) → hex`
- `leaf_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`).