# alknet-blobs: External SQLite Store — Command-Surface Probe **Status:** Probe complete. The external-crate path (no fork) is feasible. The required upstream change is a ~4-line visibility-widening PR with zero logic changes. **Date:** 2026-07-08 **Scope:** Resolves open question #1 from `poc-summary.md` ("FsStore (redb + filesystem) vs SQLite — the two-database question"). Determines whether a sibling `alknet-blobs` crate can implement a SQLite-backed blob store that speaks iroh-blobs' `Command` actor protocol *without forking iroh-blobs*, by checking whether the full public API surface is sufficient. --- ## TL;DR The POC summary framed this as "fork iroh-blobs to use SQLite instead of redb — a big maintenance commitment." That framing is a **false dichotomy**. There is a third path the summary didn't consider: implement a `SqliteStore` actor in an external crate that speaks iroh-blobs' public `Command` protocol, with **zero changes to iroh-blobs core** plus a trivial upstream visibility PR. The probe traced every `Command` variant against the published `iroh-blobs 0.103.0` source. Findings: 1. **There is no `Store` trait.** `Store` is a concrete struct wrapping an `irpc::Client` (an mpsc sender). `MemStore` and `FsStore` are two actor implementations that both call `Store::from_sender(sender)`. A third store is a *third actor*, not a trait impl. 2. **The `Command` enum is `pub`** (generated by the `irpc` `#[rpc_requests]` macro, which propagates the source `pub enum Request`'s visibility). Every request struct, every channel payload type, and every helper type that crosses the actor boundary (`Hash`, `HashAndFormat`, `Tag`, `TagInfo`, `TempTag`, `Bitfield`, `BlobStatus`, `AddProgressItem`, `ExportProgressItem`, `EncodedItem`, `BaoContentItem`, `Leaf`, `Scope`) is `pub`. A downstream actor can match on `Command` and handle every variant using only pub surface. 3. **The only blockers are four `pub(crate)` items** that need widening to `pub`: `Store::from_sender`, `Store::ref_from_sender`, the `ApiClient` type alias, and the `Scope` field. This is a ~4-line, zero-logic upstream PR. Optionally, exposing `TempTags`/`TempTagScope` would enable iroh-native temp-tag tracking, but a self-managed-GC fallback makes this unnecessary. 4. **No `Command` variant is BLOCKED.** The redb-specific types (`EntryState`, `DataLocation`, `OutboardLocation`, the redb `tables` module) never cross the pub API boundary — they're confined to the `pub(crate)` `store/fs/meta` module. A SQLite store has its own schema and never touches them. 5. **GC is reusable, not re-implementable.** `run_gc` (`store/gc.rs:222`) is `pub` and walks the store via the pub `Store` methods (`tags().list()`, `tags().list_temp_tags()`, `blobs().list()`, `blobs().delete()`, `export_bao()`, `sync_db()`). A downstream store that correctly implements those `Command` variants gets iroh's GC for free. 6. **~100 lines of bao-tree orchestration must be re-implemented** (the `ImportBao`/`ExportBao`/`ExportRanges`/`Observe` handlers). This is re-implementation, not reuse — but all of it sits on pub `bao_tree` APIs (`traverse_ranges_validated`, `PreOrderMemOutboard::create`, `BaoTree`, `ChunkRanges`). `MemStore` already demonstrates this pattern: it has its own `BaoFileStorage`/`PartialMemStorage`; `FsStore` has its own; `alknet-blobs` would have its own. One store impl = one copy of the bao glue. **Verdict:** Build `alknet-blobs` as an external crate. Do not fork. Push the 4-line visibility PR upstream. If upstream rejects it, fork — but the fork is ~4 lines different from the external crate, not the "big maintenance commitment" the summary feared. --- ## Background: Why the POC summary's framing was wrong The POC summary (`poc-summary.md` §"Open Unknowns #1") said: > The alternative — forking iroh-blobs to use SQLite instead of redb — is a big maintenance commitment. The POC's write_chunks table proves SQLite can handle chunk-sized inline BLOBs at iroh's granularity, so the swap is mechanically possible. But it should only be done if the two-database coexistence proves problematic, not for aesthetics. This assumed the choice was binary: (a) run redb + SQLite side-by-side, or (b) fork iroh-blobs to swap redb for SQLite. Reading the actual code reveals a third option: **an external crate that implements the `Command` actor protocol against SQLite, with iroh-blobs untouched.** The key architectural fact the summary missed: **iroh-blobs has no `Store` trait.** `Store` (`src/api.rs:213`) is a concrete struct holding an `irpc::Client` — a typed mpsc sender. `MemStore` (`src/store/mem.rs`) and `FsStore` (`src/store/fs.rs`) are not trait implementations; they are actor tasks that hold an `mpsc::Receiver`, handle commands, and produce a `Store` via `Store::from_sender(sender)`. The seam is the `Command` enum, not a trait. A third store is a third actor. This re-frames the risk calculus the summary was conservative about. The "two-database" concern (two WALs, two fsync paths, two crash-recovery stories, silent-corruption risk on the seam) is avoided not by "a big maintenance commitment" but by writing a sibling crate that owns its own single SQLite file — exactly the architecture the POC's path-tree + write-chunks + honker work already validated. --- ## The Command protocol is pub `iroh-blobs 0.103.0`, `src/api/proto.rs`: - `pub enum Request` (line 92), decorated with `#[rpc_requests(message = Command, alias = "Msg", rpc_feature = "rpc")]` (line 90). - The `irpc-derive` macro (`/workspace/irpc/irpc-derive/src/lib.rs:146`) emits `#vis enum #message_enum_name { ... }`, propagating the source enum's `pub`. So **`pub enum Command`** is generated, with one variant per `Request` variant, each wrapping `WithChannels`. - `WithChannels` fields (`inner`, `tx`, `rx`) are `pub` (`irpc/src/lib.rs:1162-1168`). - Per-variant `*Msg` type aliases (e.g. `ImportBaoMsg`, `ExportBaoMsg`) are generated as `pub type` (`irpc-derive/src/lib.rs:328-335`). - `From> for Command` impls are generated and pub (`irpc-derive/src/lib.rs:279-281`). Every request struct (`ImportBaoRequest`, `ExportBaoRequest`, `BlobStatusRequest`, `SetTagRequest`, `ListTagsRequest`, …) is `pub` with `pub` fields. Every channel payload type (`Hash`, `HashAndFormat`, `Tag`, `TagInfo`, `TempTag`, `Bitfield`, `BlobStatus`, `AddProgressItem`, `ExportProgressItem`, `EncodedItem`, `BaoContentItem`, `Leaf`, `Scope`, `BatchResponse`, `ImportByteStreamUpdate`, `ExportRangesItem`) is `pub`. The `Command` enum is the actor's inbox type. A downstream crate that spawns a tokio task holding `mpsc::Receiver` can `match` on every variant and produce a `Store` via `Store::from_sender(sender)` — once that constructor is `pub`. --- ## The four items that must be made `pub` ### Mandatory (the ~4-line PR) | Item | File:line | Current | Needed for | |---|---|---|---| | `ApiClient` type alias | `src/api.rs:33` | `pub(crate) type ApiClient = irpc::Client;` | The external store wrapper holds an `ApiClient` field (as `MemStore`/`FsStore` do). Without it the `from_sender` signature can't be expressed. **Alternative:** make `from_sender` take `irpc::Client` directly and keep `ApiClient` private — fewer surface changes. | | `Store::from_sender` | `src/api.rs:299` | `pub(crate) fn from_sender(client: ApiClient) -> Self` | External actor constructs a `Store` from its `mpsc::Sender`. This is the single load-bearing change. | | `Store::ref_from_sender` | `src/api.rs:303` | `pub(crate) fn ref_from_sender(client: &ApiClient) -> &Self` | `Deref`/`AsRef` for the external store wrapper (matches `MemStore`/`FsStore` pattern). | | `Scope` field | `src/api/proto.rs:693` | `pub struct Scope(pub(crate) u64);` | `Batch` must *construct* a `Scope` to send back on its `oneshot::Sender`; import variants receive a `Scope` they must *destructure*. Only `Scope::GLOBAL` is constructible today. **Minimum:** make the field `pub` (it's a `u64`), or add `Scope::new(u64)`/`Scope::into_inner()`. | ### Optional (enables iroh-native temp-tag tracking, NOT required) | Item | File:line | Current | Why | |---|---|---|---| | `util::temp_tag` module | `src/util.rs:4` | `pub(crate) mod temp_tag;` | `TempTags`, `TempTagScope`, `TagDrop`, `TagCounter` are sealed. Without this, imports can only emit *untracked* temp tags via `TempTag::new(haf, None)` (pub). | | `TempTags` struct + `create`/`create_scope`/`end_scope` | `src/util/temp_tag.rs:173` | `pub(crate) struct TempTags` (methods individually `pub` but in sealed module) | Refcounted, GC-aware temp tags for imports and `CreateTempTag`. | | `TempTagScope` + `TagDrop` | `src/util/temp_tag.rs:211,60` | sealed | `Batch` scope bookkeeping (`scope.on_drop(&msg)`). | | `Bitfield::new_unchecked` | `src/api/proto/bitfield.rs:74` | `pub(crate)` | `ImportBao` fs handler uses it. `Bitfield::new` (pub) is a fine substitute. **Not required.** | **The fallback if `TempTags` stays sealed:** use `TempTag::new(haf, None)` (pub today) for imports. The tag is valid on the wire and protects the blob from GC for the tag's lifetime, but `on_drop` is `None` so the refcount never decrements — effectively a leak until process exit or explicit `DeleteBlobs`. This is acceptable because: 1. alknet-blobs owns its SQLite schema; the path tree (from the POC) already tracks which hashes are live via path rows + tags. Self-managed GC against SQLite is the natural design. 2. `MemStore` and `FsStore` each own their own `TempTags` instance already — there is no shared global temp-tag registry. A third store having its own is the existing pattern, not a deviation. 3. iroh's `run_gc` (see below) walks the store via pub `Store` methods, so it works regardless of how temp tags are tracked internally — as long as `ListTempTags` returns the right set. Under this fallback, the **only strictly-required upstream change is the 4 lines above.** --- ## GC is reusable (a correction to the probe's first pass) The first pass of this probe incorrectly concluded that `run_gc` was `pub(crate)` and that a downstream store would have to write its own GC loop. That is wrong: - `pub async fn run_gc(store: Store, config: GcConfig)` — `src/store/gc.rs:222`. It is `pub`. - `gc_mark_task` (`gc.rs:34`) walks live hashes via: `store.tags().list()`, `store.tags().list_temp_tags()`, `store.export_bao(hash, ChunkRanges::all()).hashes()`. - `gc_sweep_task` (`gc.rs:83`) deletes dead blobs via: `store.blobs().list().stream()`, `store.blobs().delete(batch)`, `store.sync_db()`. Every one of those is a `pub` method on `Store` backed by a `Command` variant (`ListTags`, `ListTempTags`, `ExportBao`, `ListBlobs`, `DeleteBlobs`, `SyncDb`). A downstream `alknet-blobs` store that correctly implements those six variants gets iroh's mark-sweep GC **for free** — it just calls `run_gc(store.clone(), gc_config)` in a spawned task, exactly as `MemStore::new_with_opts` (`mem.rs:143`) and `FsStore::load_with_opts` (`fs.rs:1427`) do. The implication: even under the "self-managed temp tags" fallback, GC is not a thing alknet-blobs has to build. It's a thing it gets by implementing the `Command` variants it would implement anyway. --- ## Per-variant verdict All ~24 `Command` variants traced. Channel payload types are `pub` for every variant. `EntryState`/`DataLocation`/`OutboardLocation` (redb value types) **never appear in pub channel types** — confirmed by reading every `#[rpc(tx=..., rx=...)]` attribute in `proto.rs`. They are confined to the `pub(crate)` redb meta actor. | Variant | Verdict | Notes | |---|---|---| | `ListBlobs` | **PUB-OK** | Stream `Hash` from your DB. | | `Batch` | **NEEDS-PUB** | Must construct `Scope` for the `oneshot` reply — needs `Scope` field pub. The `TempTagScope::on_drop` bookkeeping is sealed; implement your own ~30-line scope tracker or skip batch-scoped cleanup (acceptable for a SQLite store that owns its own GC). | | `DeleteBlobs` | **PUB-OK** | Delete in your DB; protected-set is your own. | | `ImportBao` | **PUB-OK (logic-heavy)** | Re-implement ~60-100 lines of bao-tree chunk assembly (`BaoContentItem::{Parent,Leaf}` → outboard + data + `Bitfield`). Uses pub `bao_tree` APIs. See "Re-implementation cost" below. | | `ExportBao` | **PUB-OK** | Call pub `traverse_ranges_validated` with a 6-line `Sender` impl you write yourself (the iroh `BaoTreeSender` is sealed but trivial). | | `ExportRanges` | **PUB-OK** | ~40 lines of range iteration + `Bitfield` superset checks + `Leaf` emission. Pub `Bitfield`/`Leaf`/`RangeSet2`. | | `Observe` | **PUB-OK** | Implement your own `watch::Sender` → `mpsc::Sender` forwarder (~15 lines). Pub `Bitfield`. | | `BlobStatus` | **PUB-OK** | Return `NotFound`/`Partial`/`Complete` from your DB. | | `ImportBytes` | **NEEDS-PUB** | `Scope` field needed (request carries `scope`). Emit `AddProgressItem::Done(TempTag)` via `TempTag::new(haf, None)` (pub, untracked) or `TempTags::create` (sealed). | | `ImportByteStream` | **NEEDS-PUB** | Same as `ImportBytes`. | | `ImportPath` | **NEEDS-PUB** | Same as `ImportBytes`. | | `ExportPath` | **PUB-OK** | Write your data to target path; emit `ExportProgressItem`. | | `ListTags` | **PUB-OK** | Query your tag table. | | `SetTag` | **PUB-OK** | | | `DeleteTags` | **PUB-OK** | | | `RenameTag` | **PUB-OK** | | | `CreateTag` | **PUB-OK** | `Tag::auto` is pub (`store/util.rs:72`). | | `ListTempTags` | **PUB-OK** | Return your own temp-tag list. **Required for `run_gc` to work** — it calls this. | | `CreateTempTag` | **NEEDS-PUB** | `Scope` field. `TempTag::new(value, None)` (pub) for untracked, or `TempTags::create` (sealed) for tracked. | | `SyncDb` | **PUB-OK** | Flush your SQLite. | | `WaitIdle` | **PUB-OK** | Track your own task set. | | `Shutdown` | **PUB-OK** | | | `ClearProtected` | **PUB-OK** | Your own protected set. | **NEEDS-PUB count:** 6 variants (`Batch`, `ImportBytes`, `ImportByteStream`, `ImportPath`, `CreateTempTag`) — all blocked only by the `Scope` field. One change (`Scope(pub u64)`) unblocks all six. **BLOCKED count:** 0. --- ## Re-implementation cost (the real maintenance burden) The visibility PR is trivial. The actual work — and the honest maintenance cost — is re-implementing the bao-tree orchestration that the `ImportBao`/`ExportBao`/`ExportRanges`/`Observe` handlers do. These handlers don't wire pub pieces together; they *orchestrate* pub `bao_tree` APIs. A downstream store writes its own copy. | Handler | Lines to re-implement | Uses (all pub) | |---|---|---| | `ImportBao` | ~60-100 | `BaoContentItem`, `BaoTree`, `ChunkRanges`, `Bitfield::new`, `PreOrderMemOutboard` (for compute-on-import path), `Leaf`. Re-derive the "size-1 leaf validates size" check (`is_validated`, sealed in `pub(crate)` bitfield module — ~3 lines). | | `ExportBao` | ~6 | `traverse_ranges_validated` + a `Sender for &mut mpsc::Sender` impl (the iroh `BaoTreeSender` is a 6-line `ref_cast` wrapper at `store/util.rs:400-415`). | | `ExportRanges` | ~40 | `Bitfield` superset check, `RangeSet2` iteration, `Leaf` emission. | | `Observe` | ~15 | `tokio::sync::watch` → `mpsc::Sender` forwarder. | **Total: ~120-160 lines of bao-tree glue** to re-implement in alknet-blobs. This is the same glue `MemStore` has its own copy of (`src/store/mem.rs`, the `BaoFileStorage`/`PartialMemStorage`/`import_bao`/`export_bao` functions) and `FsStore` has its own copy of (`src/store/fs/bao_file.rs`, 749 lines). The pattern is "one store impl = one copy of the bao glue." alknet-blobs is a third store impl; it gets a third copy. The copy is smaller than FsStore's because a SQLite store doesn't need the partial-blob-on-filesystem lifecycle (the POC's `write_chunks` table handles partial state in SQLite). This is re-implementation, not reuse — but it is re-implementation against *stable, pub `bao_tree` APIs*, not against iroh internals. The maintenance cost is: track `bao_tree` version bumps. That is a much smaller surface than "track all of iroh-blobs." --- ## alknet-crate reuse for the write/read path The four existing alknet crates (post-POC) reduce some of the work the POC flagged as unknown: - **`alknet-tty`** (`crates/alknet-tty/`) — `TtyHandle` already exposes `tokio::io::AsyncWrite` stdin and `Stream` stdout (`src/backend.rs:139-182`). The `MockStdinSink` (`src/backend.rs:366-403`) is a working `AsyncWrite`-over-`mpsc::Sender` adapter — directly reusable as a pattern for the SFTP write path's chunk bridge into `WriteSession::write_chunk`. The kill-on-Drop contract pattern (`exit_code` future, ADR-056) is the same shape as write-session abort-on-drop. - **`alknet-call`** (`crates/alknet-call/`) — already uses `irpc` (workspace dep `irpc = "0.16"`) for structured RPC. The `EventEnvelope`/length-prefixed JSON framing (`src/protocol/wire.rs`) is a different protocol than iroh-blobs' `Command` actor protocol, but the irpc usage pattern (matching on request variants, channel-based replies) is the same shape alknet-blobs' actor will use. - **`alknet-core`** (`crates/alknet-core/`) — optional `iroh = "0.35"` dep. **Version mismatch:** alknet-core is on `iroh 0.35`/`irpc 0.16`; published `iroh-blobs 0.103` requires `iroh 1.0`/`irpc 0.17`. alknet-blobs will pull iroh-blobs 0.103's irpc 0.17 transitively. This needs resolution at spec time — either alknet-core bumps to iroh 1.0, or alknet-blobs pins an iroh-blobs version compatible with irpc 0.16. **Open question for the spec.** None of the existing alknet crates implement the blob store's bao-tree orchestration — that is genuinely new work. But the async-IO adapter patterns (AsyncWrite-over-mpsc, Stream-of-Bytes, kill-on-drop futures) are already proven in `alknet-tty` and reduce the write-path wiring risk. --- ## Versioning reality Three iroh-blobs generations are in play: | Source | Version | iroh | irpc | Notes | |---|---|---|---|---| | `/workspace/iroh-blobs` (local checkout) | 0.100.0 | 0.98 | 0.14 | Pre-n0-rebrand. Used for the original architecture reading. | | crates.io published (cached) | 0.103.0 | 1.0 | 0.17 | What the POC used; what alknet-blobs would target. | | alknet-core dep | — | 0.35 | 0.16 | Two majors behind iroh-blobs 0.103. | This probe was run against **0.103.0** (the published version). The visibility findings (`Store::from_sender` pub(crate), `Scope(pub(crate) u64)`, `Command` pub via macro, no `Store` trait, `EntryState` confined to `pub(crate)` meta) all hold on 0.103.0. The 4-line upstream PR would target the 0.103+ line. The alknet-core `iroh 0.35` vs iroh-blobs `iroh 1.0` gap is a spec-time decision, not a probe blocker. It affects which iroh QUIC API the distributed-sync layer (POC 3) binds against, but not the local `Command`-actor surface this probe examined. --- ## Recommended path 1. **Build `alknet-blobs` as a workspace member** (`crates/alknet-blobs/`), not a fork. It depends on `iroh-blobs = "0.103"` (published) and implements the `Command` actor against a single SQLite file (schema: the POC's path-tree tables + write_chunks + a blob-metadata table replacing redb's 4 tables + honker tables, all in one .db). 2. **Push the 4-line visibility PR upstream** to n0-computer/iroh-blobs: `Store::from_sender`/`ref_from_sender` + `ApiClient` (or change `from_sender` to take `irpc::Client` directly) + `Scope` field. Zero logic changes. If merged, alknet-blobs is a pure downstream crate. If rejected, alknet-blobs forks those 4 lines — the fork is 4 lines different from the external crate, which is the opposite of "big maintenance commitment." 3. **Use the self-managed-GC fallback** (`TempTag::new(haf, None)` + own SQLite-backed liveness tracking) unless upstream agrees to expose `TempTags`. Reuse iroh's `run_gc` by correctly implementing `ListTempTags`/`ListTags`/`ListBlobs`/`DeleteBlobs`/`ExportBao`/`SyncDb`. This is the same pattern `MemStore`/`FsStore` use (each owns its own `TempTags`); alknet-blobs owns its own, just backed by SQLite. 4. **Re-implement the ~120-160 lines of bao-tree orchestration** (`ImportBao`/`ExportBao`/`ExportRanges`/`Observe`) against pub `bao_tree` APIs. Copy the structure from `MemStore` (the simpler reference; `src/store/mem.rs`), not `FsStore` (which has the partial-blob-on-filesystem complexity alknet-blobs doesn't need). 5. **Reuse `alknet-tty`'s async-IO adapter patterns** (AsyncWrite-over-mpsc, Stream-of-Bytes, kill-on-drop) for the SFTP write-path bridge into `WriteSession`. 6. **Resolve the iroh 0.35 vs 1.0 version gap** at spec time. This is an open question for the architecture doc, not a probe finding. --- ## What this closes and what remains **Closes:** POC open question #1 ("FsStore (redb + filesystem) vs SQLite — the two-database question"). The answer is: neither. Build a third store (`alknet-blobs`) as an external crate with its own single SQLite file. No fork, no two-database coexistence. The upstream cost is a 4-line visibility PR. **Does not close (deferred to spec):** - The iroh 0.35 vs iroh 1.0 version gap (alknet-core dependency alignment). - The exact SQLite schema for the blob-metadata tables (the redb 4-table shape is a reference; a SQLite store may unify inline_data/inline_outboard into BLOB columns or split differently — schema design is a spec task). - Whether to expose `TempTags` upstream or commit to the self-managed-GC fallback (a spec decision; the fallback works, exposing `TempTags` is a nicety). - SFTP wiring (POC open question #3 — unchanged; the `alknet-tty` patterns help but don't close it). - The distributed sync layer's binding to iroh 1.0 vs 0.35 (POC 3 used iroh 1.0; alknet-core is on 0.35). --- ## References - Published source probed: `~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/iroh-blobs-0.103.0/` - `src/api.rs:33,213,299,303` — `ApiClient`, `Store`, `from_sender`/`ref_from_sender` (pub(crate)) - `src/api/proto.rs:693` — `Scope(pub(crate) u64)` - `src/api/proto.rs:90-139` — `pub enum Request` + `#[rpc_requests]` (generates `pub enum Command`) - `src/store/mem.rs` — reference actor (1127 lines, zero redb) - `src/store/fs/meta.rs`, `src/store/fs/meta/tables.rs` — redb confined here (~1100 lines) - `src/store/fs/entry_state.rs` — `EntryState`/`DataLocation`/`OutboardLocation` (pub(crate), never in pub channel types) - `src/store/gc.rs:222` — `pub async fn run_gc` (reusable, walks store via pub methods) - `src/util/temp_tag.rs:85` — `TempTag::new` (pub, untracked fallback); `:173` — `TempTags` (pub(crate)) - irpc derive macro: `/workspace/irpc/irpc-derive/src/lib.rs:146,279,328` — visibility propagation, `From` impls, `*Msg` aliases - POC summary: `docs/research/alknet-filesystem/poc-summary.md` (this doc's parent context) - alknet crates surveyed: `crates/alknet-tty/src/backend.rs` (AsyncWrite/Stream/kill-on-drop patterns), `crates/alknet-call/src/protocol/wire.rs` (irpc usage), `crates/alknet-core/Cargo.toml` (iroh 0.35 version gap)