# Arbor Handles: External Reference System for Conversation Trees

**Status**: Implemented
**Date**: 2025-12-19
**Author**: Claude Opus 4.5
**Affects**: Arbor, Cone, ClaudeCode activations

## TL;DR

**Handles are pointers.** Instead of storing messages directly in tree nodes, Arbor stores lightweight Handle references that point to data in external systems.

```rust
Handle {
    source: "cone",           // Who owns this data
    source_version: "1.0.0",  // Schema version
    identifier: "msg-abc:user:chatbot",  // How to find it
}
```

**Flow**: Activation stores message in its DB → creates Handle → creates Arbor node with Handle → later resolves Handle back to message when building context.

**Why**: Keeps trees lightweight, lets each activation own its data format, enables ephemeral branches without data duplication.

---

## Context as Handles

LLM conversations are fundamentally **trees of messages**. Each user prompt can branch into multiple assistant responses; each response can be followed by different user messages. Arbor manages this tree structure - tracking parent-child relationships, navigating paths, and maintaining the "current head" position.

But **Arbor doesn't care what the messages contain**. It only needs to know:
- How messages relate to each other (tree topology)
- Where to find the actual content (handles)

This is the core insight: **context is just a path through a tree of handles**. When an activation needs to call an LLM, it:

1. Asks Arbor for the path from root to current head
2. Gets back a list of nodes, each containing a Handle
3. Resolves each Handle back to its original message
4. Assembles the messages into LLM context

The Handle acts as a **deferred lookup** - Arbor can traverse trees quickly without loading message payloads. The actual content is only fetched when needed for an LLM call.

This also means different activations can store messages differently. Cone stores simple chat messages. ClaudeCode stores messages with tool calls, token counts, and cost tracking. Each defines its own Handle format and resolution logic, while Arbor treats them uniformly as "external data at this location."

---

## Overview

The Arbor Handle system provides a flexible external reference mechanism for storing conversation tree nodes. Instead of storing large message payloads directly in the tree structure, Arbor uses Handles as lightweight pointers to data stored in external systems.

This pattern enables **separation of concerns**:
- **Arbor** manages tree topology and relationships
- **Specialized storage systems** (Cone, ClaudeCode) manage the actual message data

## Why Handles?

### The Problem

A naive approach would store all message content directly in Arbor nodes:

```
Tree
└── Node (id=1, content="Hello, how can I help?")
    └── Node (id=2, content="Can you write a function...")
        └── Node (id=3, content="Here's a Python function... [10KB of code]")
```

Problems with this approach:
1. **Data duplication**: Same message appears in both the activation's database and Arbor's tree
2. **Schema coupling**: Arbor must understand every activation's message format
3. **Bloated trees**: Large messages make tree operations expensive
4. **Lifecycle complexity**: Message data and tree nodes have different lifecycle requirements

### The Solution: Handles as Pointers

Handles decouple tree structure from data storage:

```
Tree (Arbor)                          External Storage (Cone/ClaudeCode)
└── Node (handle → cone:msg-abc)  →   Message (id=abc, content="Hello...")
    └── Node (handle → cone:msg-def)  →   Message (id=def, content="Can you...")
        └── Node (handle → cone:msg-ghi)  →   Message (id=ghi, content="Here's...")
```

Benefits:
- **Single source of truth**: Messages live only in their activation's database
- **Lightweight trees**: Nodes store ~100 bytes instead of potentially kilobytes
- **Flexible schemas**: Each activation defines its own message format
- **Independent lifecycle**: Nodes can be deleted without affecting messages (and vice versa)

## Handle Structure

### Definition (src/activations/arbor/types.rs:109-124)

```rust
/// Handle pointing to external data with versioning
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct Handle {
    /// Source system identifier (e.g., "cone", "claudecode", "postgres")
    pub source: String,

    /// Source system version (semantic version: "MAJOR.MINOR.PATCH")
    pub source_version: String,

    /// Identifier within that source system
    pub identifier: String,

    /// Optional metadata for the handle
    #[serde(skip_serializing_if = "Option::is_none")]
    pub metadata: Option<serde_json::Value>,
}
```

### Fields Explained

| Field | Purpose | Example |
|-------|---------|---------|
| `source` | Identifies which system owns this data | `"cone"`, `"claudecode"`, `"s3"` |
| `source_version` | Semantic version for schema evolution | `"1.0.0"` |
| `identifier` | Unique locator within the source system | `"msg-550e8400-...:user:chatbot"` |
| `metadata` | Optional context (content-type, size, etc.) | `{"content_type": "text/markdown"}` |

### JSON Representation

```json
{
  "source": "cone",
  "source_version": "1.0.0",
  "identifier": "msg-550e8400-e29b-41d4-a716-446655440000:user:my-chatbot"
}
```

## Node Types

Arbor nodes come in two variants:

### Text Nodes (Built-in Storage)

For lightweight, transient data that doesn't need external storage:

```rust
NodeType::Text { content: String }
```

Created via `arbor.node_create_text(tree_id, parent, content, metadata)`.

Use cases:
- System prompts
- Metadata markers
- Inline annotations

### External Nodes (Handle Reference)

For data managed by external systems:

```rust
NodeType::External { handle: Handle }
```

Created via `arbor.node_create_external(tree_id, parent, handle, metadata)`.

Use cases:
- User messages
- Assistant responses
- Tool call results
- File attachments

## Creating Handles

### Pattern: Factory Method

Each activation defines a static factory method that creates handles with consistent formatting:

#### Cone (src/activations/cone/storage.rs:486-495)

```rust
/// Create a handle for a message
/// Format: "msg-{id}:{role}:{name}"
pub fn message_to_handle(message: &Message, name: &str) -> Handle {
    Handle {
        source: "cone".to_string(),
        source_version: "1.0.0".to_string(),
        identifier: format!("msg-{}:{}:{}", message.id, message.role.as_str(), name),
        metadata: None,
    }
}
```

#### ClaudeCode (src/activations/claudecode/storage.rs:520-529)

```rust
/// Create a handle for a message
/// Format: "msg-{id}:{role}:{name}"
pub fn message_to_handle(message: &Message, name: &str) -> Handle {
    Handle {
        source: "claudecode".to_string(),
        source_version: "1.0.0".to_string(),
        identifier: format!("msg-{}:{}:{}", message.id, message.role.as_str(), name),
        metadata: None,
    }
}
```

### Identifier Format Convention

The identifier encodes enough information for resolution:

```
msg-{uuid}:{role}:{name}
 │    │      │     └── Context identifier (cone name, session name)
 │    │      └── Message role (user, assistant, system)
 │    └── Message UUID in the source database
 └── Prefix indicating resource type
```

Examples:
- `msg-550e8400-e29b-41d4-a716-446655440000:user:my-chatbot`
- `msg-7c9e6679-7425-40de-944b-e07fc1f90ae7:assistant:code-review`

## Resolving Handles

### Pattern: Source-Based Dispatch

When reading conversation context, the activation checks the handle's `source` field and dispatches to the appropriate resolver:

```rust
// src/activations/cone/activation.rs (simplified)
for node in context_path {
    match &node.data {
        NodeType::External { handle } => {
            match handle.source.as_str() {
                "cone" => {
                    let msg = storage.resolve_message_handle(&handle.identifier).await?;
                    messages.push(msg.into());
                }
                _ => {
                    // Unknown source - skip or error
                }
            }
        }
        NodeType::Text { content } => {
            // Handle inline text nodes
        }
    }
}
```

### Resolution Implementation (src/activations/cone/storage.rs:467-484)

```rust
/// Resolve a message handle identifier to a Message
/// Handle format: "msg-{message_id}:{role}:{name}"
pub async fn resolve_message_handle(&self, identifier: &str) -> Result<Message, ConeError> {
    // Parse identifier: "msg-{uuid}:{role}:{name}"
    let parts: Vec<&str> = identifier.splitn(3, ':').collect();
    if parts.len() < 2 {
        return Err(format!("Invalid message handle format: {}", identifier).into());
    }

    let msg_part = parts[0];
    if !msg_part.starts_with("msg-") {
        return Err(format!("Invalid message handle format: {}", identifier).into());
    }

    let message_id_str = &msg_part[4..]; // Strip "msg-" prefix
    let message_id = Uuid::parse_str(message_id_str)
        .map_err(|e| format!("Invalid message ID in handle: {}", e))?;

    self.message_get(&message_id).await
}
```

## Complete Usage Flow

### Creating a Message (Chat Flow)

```
1. User sends: "Hello!"
   │
   ▼
2. Cone creates Message in its database
   │  INSERT INTO messages (id, cone_id, role, content, created_at)
   │  VALUES ('abc-123', 'my-cone', 'user', 'Hello!', 1703001234)
   │
   ▼
3. Cone creates Handle
   │  Handle {
   │    source: "cone",
   │    source_version: "1.0.0",
   │    identifier: "msg-abc-123:user:my-cone"
   │  }
   │
   ▼
4. Cone creates External Node in Arbor
   │  arbor.node_create_external(tree_id, parent_node_id, handle, None)
   │
   ▼
5. Arbor inserts node record
   │  INSERT INTO nodes (id, tree_id, parent_id, node_type,
   │    handle_source, handle_source_version, handle_identifier, ...)
   │  VALUES ('node-xyz', 'tree-001', 'parent-node', 'external',
   │    'cone', '1.0.0', 'msg-abc-123:user:my-cone', ...)
   │
   ▼
6. Cone updates its head position
      UPDATE cones SET canonical_head = 'node-xyz' WHERE id = 'my-cone'
```

### Reading Conversation Context

```
1. Cone needs context for LLM call
   │
   ▼
2. Query Arbor for path from root to current head
   │  arbor.context_get_path(current_head_node_id)
   │  Returns: [node-001, node-002, ..., node-xyz]
   │
   ▼
3. For each node, check type
   │  node.data match {
   │    External { handle } => resolve handle
   │    Text { content } => use content directly
   │  }
   │
   ▼
4. Resolve each handle by source
   │  handle.source == "cone"
   │    → cone_storage.resolve_message_handle(handle.identifier)
   │    → Returns Message { role, content, ... }
   │
   ▼
5. Convert messages to LLM format
   │  Message { role: User, content: "Hello!" }
   │    → cllient::Message::user("Hello!")
   │
   ▼
6. Send to LLM with full context
```

## Database Schema

### Arbor Nodes Table (src/activations/arbor/storage.rs:89-106)

```sql
CREATE TABLE IF NOT EXISTS nodes (
    id TEXT PRIMARY KEY,
    tree_id TEXT NOT NULL,
    parent_id TEXT,
    ref_count INTEGER NOT NULL DEFAULT 1,
    state TEXT NOT NULL DEFAULT 'active',
    scheduled_deletion_at INTEGER,
    archived_at INTEGER,

    -- Node type discriminator
    node_type TEXT NOT NULL,  -- 'text' or 'external'

    -- For Text nodes
    content TEXT,

    -- For External nodes (Handle fields)
    handle_source TEXT,
    handle_source_version TEXT,
    handle_identifier TEXT,
    handle_metadata TEXT,

    created_at INTEGER NOT NULL,
    metadata TEXT,

    FOREIGN KEY (tree_id) REFERENCES trees(id) ON DELETE CASCADE
);
```

### Query Example: Load External Node

```sql
SELECT
    id, parent_id, node_type, content,
    handle_source, handle_source_version, handle_identifier, handle_metadata
FROM nodes
WHERE id = ?;
```

Result reconstruction:
```rust
if node_type == "external" {
    NodeType::External {
        handle: Handle {
            source: handle_source,
            source_version: handle_source_version,
            identifier: handle_identifier,
            metadata: handle_metadata.map(|s| serde_json::from_str(&s).unwrap()),
        }
    }
} else {
    NodeType::Text { content: content.unwrap() }
}
```

## Ephemeral Nodes

### Use Case

Ephemeral nodes support "try before you commit" workflows:
- Test a prompt without advancing session state
- Explore alternative responses
- Temporary tool executions

### Implementation (src/activations/arbor/storage.rs:774-811)

```rust
pub async fn node_create_external_ephemeral(
    &self,
    tree_id: &TreeId,
    parent: Option<NodeId>,
    handle: Handle,
    metadata: Option<Value>,
) -> Result<NodeId, ArborError> {
    // ... setup ...

    sqlx::query(
        "INSERT INTO nodes (..., ref_count, state, scheduled_deletion_at, ...)
         VALUES (..., 0, 'scheduled_delete', ?, ...)",  // Note: ref_count=0, state=scheduled_delete
    )
    .bind(now)  // scheduled_deletion_at = creation time
    // ...
}
```

Key differences from regular nodes:
- `ref_count = 0` (no references holding it)
- `state = 'scheduled_delete'` (marked for cleanup)
- `scheduled_deletion_at = now` (eligible for cleanup immediately)

### Cleanup Lifecycle

```
Day 0: Ephemeral node created (state=scheduled_delete)
        │
        ▼
Day 7: cleanup_scheduled_trees() runs
        │  - Moves state from 'scheduled_delete' to 'archived'
        │  - Sets archived_at timestamp
        │
        ▼
Day 37: cleanup_archived_trees() runs
         - Permanently deletes archived nodes
```

## Implementing a New Handle Source

To add a new external data source:

### 1. Define Your Storage

```rust
pub struct MyStorage {
    pool: SqlitePool,
    arbor: Arc<ArborStorage>,
}
```

### 2. Create Handle Factory

```rust
impl MyStorage {
    pub fn item_to_handle(item: &MyItem) -> Handle {
        Handle {
            source: "mysource".to_string(),
            source_version: "1.0.0".to_string(),
            identifier: format!("item-{}", item.id),
            metadata: None,
        }
    }
}
```

### 3. Implement Resolution

```rust
impl MyStorage {
    pub async fn resolve_handle(&self, identifier: &str) -> Result<MyItem, MyError> {
        // Parse identifier
        let id = identifier.strip_prefix("item-")
            .ok_or("Invalid item handle")?;
        let uuid = Uuid::parse_str(id)?;

        // Query your database
        self.get_item(&uuid).await
    }
}
```

### 4. Use in Your Activation

```rust
// Creating
let item = storage.create_item(data).await?;
let handle = MyStorage::item_to_handle(&item);
let node_id = storage.arbor().node_create_external(tree_id, parent, handle, None).await?;

// Resolving
for node in path {
    if let NodeType::External { handle } = &node.data {
        if handle.source == "mysource" {
            let item = storage.resolve_handle(&handle.identifier).await?;
            // Use item...
        }
    }
}
```

## Version Migration

The `source_version` field enables schema evolution:

### Scenario: Identifier Format Change

```rust
// v1.0.0: "msg-{uuid}"
// v2.0.0: "msg-{uuid}:{role}:{name}"

pub async fn resolve_message_handle(&self, identifier: &str, version: &str) -> Result<Message, Error> {
    match version {
        "1.0.0" => {
            // Old format: just UUID
            let uuid = identifier.strip_prefix("msg-").unwrap();
            self.message_get(&Uuid::parse_str(uuid)?).await
        }
        "2.0.0" => {
            // New format: UUID:role:name
            let parts: Vec<&str> = identifier.splitn(3, ':').collect();
            let uuid = parts[0].strip_prefix("msg-").unwrap();
            self.message_get(&Uuid::parse_str(uuid)?).await
        }
        _ => Err("Unknown version".into())
    }
}
```

## Best Practices

### DO

1. **Use semantic versioning** for `source_version`
2. **Include enough context in identifiers** for debugging (role, name)
3. **Validate handles early** - fail fast on invalid formats
4. **Keep handles immutable** - never modify a handle after creation
5. **Use ephemeral nodes** for exploratory operations

### DON'T

1. **Store large data in handles** - the metadata field is for small hints only
2. **Assume handle validity** - the referenced data may have been deleted
3. **Parse handles manually** - use the factory/resolver pattern
4. **Mix handle sources** - each activation should only resolve its own handles

## Related Documents

- `16680562178783729663_session-improvements.md` - Ephemeral message support
- `16680686512957503487_stream-first-hub.md` - Overall architecture
- `16680684630903157503_stream-first-macro.md` - Activation macro system

## Summary

The Arbor Handle system provides:

1. **Decoupled storage**: Tree structure separate from message data
2. **Lightweight nodes**: Handles are ~100 bytes vs kilobytes for full messages
3. **Flexible ownership**: Each activation manages its own data format
4. **Version safety**: Schema evolution through `source_version`
5. **Lifecycle control**: Ephemeral nodes for temporary operations

The pattern is used consistently across Cone and ClaudeCode activations, and can be extended to any new activation that needs to store data externally while participating in Arbor's conversation trees.
