4.0 KiB
4.0 KiB
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)
#[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".
getreturningNoneis a normal outcome, not an error. After reduction (T8.4) the original body is deleted and the log carries aReduced { 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
- Define
BlobRefas a Blake3 hash newtype. Define the trait as above. redbtable keyed onScoped<BlobRef>— tenant in the key is what enforces per-tenant namespacing structurally rather than by convention.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.get: point lookup,Ok(None)on miss.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.- Write the conformance suite generic over
impl BlobStoreso 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:
putidentical bytes under tenant A and tenant B. Assert the returnedBlobRefs are equal (same content hash) and that a raw table scan shows two stored entries.deletetenant A's ref. Assertget(A, ref)isOk(None)andget(B, ref)still returns the bytes.putthe same bytes twice under one tenant; assert one stored entry (dedup within the tenant) and a stable ref.geta ref that was never written:Ok(None), not an error.deletea ref that does not exist:Ok(()), so reduction can run twice.- 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
gets return the right bytes. A single shared blob passes that. The raw scan showing two entries is the isolation proof. - Step 2 passing because
deleteis a no-op stub. Assert theNoneon 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
deletebecause 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 §3, §7, §8.6 · rust-agentic-task.md