# T0.7 — `BlobStore` port + `redb` implementation | Field | Value | |---|---| | Phase | P0 — Foundations | | Size | M — 1 to 3 days | | Status | Not started | | Flags | — | | Spec | inlined below | | Blocks | — | ## Goal Content-addressed blob storage, deduplicated **within** a tenant and never across. `delete` ships now, not later. ## Facts (inlined — no spec read needed) ```rust #[async_trait] pub trait BlobStore: Send + Sync { async fn put(&self, tenant: TenantId, content: Bytes) -> Result; async fn get(&self, tenant: TenantId, r: &BlobRef) -> Result>; /// Reduction and tenant deletion both require this. A store that cannot /// delete cannot honour either, and both are obligations. async fn delete(&self, tenant: TenantId, r: &BlobRef) -> Result<()>; } ``` - **Blobs are namespaced per tenant even though they are content-addressed.** Global dedup of prompt and output blobs is tempting — identical system prompts across tenants are common — and it is a leak: a shared blob makes one tenant's storage accounting depend on another's, and a hash becomes an oracle for "does anyone else have this content". - `get` returning `None` is a normal outcome, not an error. After reduction (T8.4) the original body is deleted and the log carries a `Reduced { original, summary }` event explaining what stands in its place. - Content addressing means the ref is a hash of the body. A body must never be rewritten under an existing ref — that makes the ref a lie. ## Steps 1. Define `BlobRef` as a Blake3 hash newtype. Define the trait as above. 2. `redb` table keyed on `Scoped` — tenant in the key is what enforces per-tenant namespacing structurally rather than by convention. 3. `put`: hash the content, insert if absent, return the ref. Insert-if-absent is the dedup, and it is scoped by the key, so no cross-tenant path exists. 4. `get`: point lookup, `Ok(None)` on miss. 5. `delete`: remove the key. Deleting a ref one tenant holds must not touch another tenant's identical content — which follows from the key shape, and the test below is what proves it. 6. Write the conformance suite generic over `impl BlobStore` so T7.2's object-store backend reuses it unchanged. ## Acceptance - Identical content put under two tenants produces two independent blobs. - Deleting tenant A's blob leaves tenant B's readable. ## Verify **Harness:** conformance suite generic over `impl BlobStore`, reused verbatim by T7.2's object store. **Integration test** — `tests/it_blobstore_conformance.rs`: 1. `put` identical bytes under tenant A and tenant B. Assert the returned `BlobRef`s are equal (same content hash) **and** that a raw table scan shows **two** stored entries. 2. `delete` tenant A's ref. Assert `get(A, ref)` is `Ok(None)` and `get(B, ref)` still returns the bytes. 3. `put` the same bytes twice under one tenant; assert one stored entry (dedup within the tenant) and a stable ref. 4. `get` a ref that was never written: `Ok(None)`, not an error. 5. `delete` a ref that does not exist: `Ok(())`, so reduction can run twice. 6. Re-hash every stored body and assert it matches its key. **Command:** `cargo test -p storage --test it_blobstore_conformance` **False pass:** - Step 1 asserting only that both `get`s return the right bytes. A single shared blob passes that. The **raw scan showing two entries** is the isolation proof. - Step 2 passing because `delete` is a no-op stub. Assert the `None` on A explicitly, not just B's survival. ## Traps - A global content-addressed table with tenant tracked in a side index. It dedups across tenants by construction, which is the leak. - Deferring `delete` because nothing calls it yet. Reduction and tenant deletion both need it, and retrofitting a delete path into a store designed without one is a rewrite. --- Background (not required to do this task): [rust-agentic-sys.md](../../../rust-agentic-sys.md) §3, §7, §8.6 · [rust-agentic-task.md](../../../rust-agentic-task.md)