Files
poimen/tasks/T0.7-blobstore-port-redb-implementation.md
T

98 lines
4.0 KiB
Markdown

# 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<BlobRef>;
async fn get(&self, tenant: TenantId, r: &BlobRef) -> Result<Option<Bytes>>;
/// 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<BlobRef>` — 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)