alkblobs 0.2.2

Content-addressed blob storage: multi-hash put/get/verify store with pluggable backends, transport- and protocol-agnostic
Documentation
---
status: reviewed
last_updated: "2026-10-18 (ADR-020 — the write path is one
  derivation-explicit seam: `put_as(algorithm, len: Option<u64>,
  stream)` alone; `put` and `put_git_object` deleted with no canonical
  default and no shim; `put_in_batch` takes the algorithm parameter.
  The unknown-length arm is retained on the facade — the wire is
  claims-only. Verified landed — wave 14, 2026-10-19 (the one-seam
  facade verified by the conformance suite's family legs and the
  wave-14 pinning test))"
notes: "ADR-015 — the local publish's durability and no-replace terms
  registered as invariant rows (3/4); ADR-016 — the reap declaration
  rides the engine-conformance invariant (8); ADR-018 — the derivation
  family (bytes, stat semantic) stands; its family-named put form is
  superseded by ADR-020's one put seam below"
---

# Store API

## What this is

The store core's public surface: the typed facade above the tier
engines that consumers (alkgit, alkfs, the ops module) program against.
It owns typed keys and hashing (ADR-002), size-threshold dispatch
(ADR-003), the put-pinning seam, and the liveness/sweep seam (ADR-005)
— and nothing else: no paths, no manifests, no wire (ADR-004, ADR-001).
The facade is **engine-blind** (ADR-007/008/010): which engines a node
runs changes nothing here except fleet-GC mechanism wiring (ADR-008's
mechanism lives in the engine-state seam, ADR-010 §2).

## Public surface

### Write path

- **`put_as(algorithm, len: Option<u64>, stream)` → `(Key, Pin)`** —
  the one derivation-explicit put seam ([ADR-020]decisions/020-family-explicit-wire-payloads.md
  §Decision 5). The caller names the derivation: the same bytes have
  multiple equally valid addresses, one per registry member, so the
  crate never picks a derivation for its caller — the caller that
  cannot name an algorithm cannot form an address (the invariant).
  Both former put forms folded into it: the canonical-default `put`
  (whose hardcoded blob derivation was the default-privilege defect's
  facade seat) and the family-named `put_git_object` (ADR-018 §6's
  former shape) are deleted outright — no shim (0.x), zero in-repo
  callers, one out-of-repo POC composition. Two arms behind one
  signature:
  - *Known length* (`Some(len)`): one pass, no staging needed for
    hashing. Preamble-carrying members (the blob family) hash
    preamble-then-content as always; identity-family members
    (ADR-018's git-object family) take the preimage verbatim — git's
    loose form, the caller provides `"<type> <len>\0" + content` —
    with `len` as the stored-length check (the derivation itself is
    the plain hash over the preimage; the resulting key is the object's
    git oid, byte-identical, every kind included). Covers git objects,
    pre-staged files (`stat`-derived length), network offers,
    `Content-Length`-bearing uploads.
  - *Unknown length* (`None`): retained on the facade — "stream in,
    get the key back" is a legitimate in-process posture for edge
    producers, and the hard part is the crash-safe staging hygiene
    every outer-layer workaround re-implements (ADR-020 §Decision 3's
    retention rationale). Content buffers in memory up to the dispatch
    threshold; on commit the buffer is the whole stored artifact
    (kv tier) — or, on mid-stream threshold overflow, the buffer
    flushes into a stage file and the stream continues into it
    (large tier — the streaming pass cannot include the preamble, so
    the derivation pass restarts over preamble + staged bytes for
    preamble-carrying members; identity-family members restart over
    the staged bytes — the permitted simple implementation,
    single-pass capable if ever measured worth it). Verification runs
    over the eventual stored artifact as a whole; a stream error on
    either path leaves no entry and no stage file. Eliminates the A6
    dispatch asymmetry by construction: routing is a pure function of
    content length (ADR-003). The large
    tier's stage mechanics are the **staged-put contract** (ADR-010
    §1): the engine hands out a staging cursor (`local`: the stage file;
    pg-lo: chunked `lowrite` statements inside its tx), commit
    publishes, every failure path discards. The derivation-generic
    machinery already exists crate-internally (`put_known_as`-shaped
    routing, the fleet arms' threaded `algorithm` parameter); the
    ADR-020 round deletes the one re-hardcode (the `put_unknown` fleet
    arm ignoring its threaded parameter) and makes the seam public.
  - Error mapping is uniform across both arms and all algorithms:
    length mismatch `Error::Verification` (zero residue), kv-only
    over-threshold rejection, media failure `Error::Io`.
- **Stage-file hygiene invariant** (POC #3 finding A5, codified): every
  failure path converges on stage-discard; every success path converges
  on commit-publish. No early returns that bypass cleanup; this is an
  asserted invariant with dedicated exact-count tests, not review
  hygiene.
- **`Pin` / `Batch`** — RAII guard on the put path (ADR-005): the
  entry is liveness-protected until the guard drops or the caller
  converts it into a consumer-side reference (registered liveness, a
  root tag). **`Batch`** groups multi-put writes under one batch-scoped
  pin (manifest writes are exactly this); a batch's pins drop together
  on batch commit or drop (ADR-005 §Decision). **`put_in_batch` takes
  the algorithm parameter** (ADR-020 §Decision 5) — batch puts name
  their derivation like every other write.

### Read path

- **`get(key) → Option<(len, stream)>`** — small tier returns a
  bounded read; large tier streams (`local`: a pread loop; pg-lo: `lo_get`
  windows). The per-engine shapes are hidden behind the crate-internal
  read cursor the store core drives (ADR-010 §1 — engines construct
  their own cursor bodies; the facade's shape is uniform). Dispatch
  fall-through: a small-tier miss queries the large tier (POC #1
  finding 6 — the general rule, ADR-012 §6.1: every key-addressed op
  — `get`, `stat`, `has`, `delete`, `read_range` — consults the kv
  tier first, then the large tier). A miss on both tiers is a `None`
  (get/stat) or `Err(Missing)` (read_range/delete) — see the error
  model.
- **`stat(key) → Option<EntryMeta>`** — cheap length/type probe
  (phase-0 OQ-BL-04 addendum; gix's header-only read is git's cheapest
  primitive and the packfile-serving surface rides this). `EntryMeta`
  carries `len: u64` and the key's algorithm (the "type" from the
  store's perspective is the hash algorithm — there is no other type at
  this layer). **The `stat` semantic is per-family** ([ADR-018]decisions/018-git-object-derivation-family.md):
  blob entries' `len` is the content length (the preamble lives inside
  the derivation, never the value); git-object family entries' `len` is
  the **stored-preimage length** — git's loose-object form, header
  included — not the content length (the header lives *inside* the
  value, so the value's length is what any lookup can return; the
  entry's `algorithm` distinguishes the cases). Deliberate and
  documented, never defaulted. `stat` rides the `Backend` trait's
  `size` method (ADR-008): an indexed-column probe on kv engines, a
  file stat on `local`, a companion-row probe on pg-lo — never a
  value/content load.
- **`read_range(key, range) → (slice, slice_digest)`** — slice plus an
  out-of-band slice digest: SHA-256 over the slice bytes, fixed by
  convention so callers and ops handlers agree without negotiation
  (per-range verification against the canonical digest is impossible —
  ADR-006). Range reads fall through tiers exactly as `get` does
  (small-tier miss slices the large tier's value). Local range serving
  (packfiles) is the consumer of this.
- **`get_stream` / fanout** — the ops module's fetch handler is a
  broadcast above the store: one reader, store arm + subscriber
  arms (POC #3 finding A3); late joiners degrade to normal verified
  `get` after commit. The store exposes the seam; the fanout policy
  lives in the ops layer (ADR-001).

### Lifecycle

- **`has(key) → bool`** — the pure existence probe (ADR-008's
  `has` discipline; a `size`-probe under the dispatch general rule —
  absent on both tiers is `false`; virgin-store reads are no-ops).
- **`delete(key) → Result<(), Error>`** — falls through tiers per the
  dispatch general rule above. Participates in ADR-005's
  delete windows; direct deletes are permitted but refuse protected
  keys — pinned, re-observed live via registered sources, or named by
  the protect callback (ADR-012 §6.4: the full protection set, not
  just pins — the ADR-005 invariant applied to the direct path) —
  with the typed `GcRefuse` error. Direct deletes are unusual by
  posture; most deletion flows through sweeps. An embedder's
  correction flow deregisters/re-registers around the delete.
- **`list() → stream of keys`** — whole-pool enumeration; complete by
  contract (see backends doc for why list correctness is load-bearing).
- **`register_liveness_source(...)`** — the live-shared GC seam
  (ADR-005, §Decision; the POC's "install implies copy" failure
  (finding 7) is why the verb/name is pinned here). Fleet form (ADR-012
  §6.6): the same verb registers a **named liveness table reference**
  in the shared engine (the embedder's own table per ADR-008 §2 — the
  store core reads it, never owns it); in-process callback sources
  remain the single-node form.
- **`sweep() → Result<SweepReport, Error>`** — explicit mark-and-sweep; embedders own
  the cadence (ADR-005, no ambient timers). **Sweeps are never
  errors: an aborted sweep returns `Ok(SweepReport)` with
  `aborted: Some(GcAbortCause)` — there is no sweep-abort error
  variant** (ADR-012 §1; the previously specced `GcAborted` sweep
  error is retired — see the error model). A sweep with no registered
  liveness sources aborts without deleting — the safe default —
  carrying `GcAbortCause::NoLivenessSources`.
  `SweepReport`'s shape: `candidates_staged`, `deleted`,
  `cancelled_by_arbitration` (u64 counts), `aborted:
  Option<GcAbortCause>`, and `started_at`/`finished_at`. `GcAbortCause`
  is a data enum with exactly three variants — `ProtectFailed`
  (protection-source failure, ADR-005), `SweeperLock` (fleet
  sweeper-lock contention, ADR-008), `NoLivenessSources` (the safe
  default above); callers match non-exhaustively; new causes are new
  ADRs (ADR-012 §1). The direct-delete refusal path is a *different*
  type: the `GcRefuse` error (above), not a sweep outcome.
  Staged-window candidates overdue at sweep time are re-arbitrated
  and executed by that sweep (the executor is the next sweep —
  ADR-012 §6.7; non-SQL engines execute their window inline within
  the same sweep, ADR-012 §5).

  Under a fleet (multiple store instances over one pool —
  requirements.md REQ-2), sweep/put/delete semantics are
  mechanism-extended by ADR-008: pins are DB-backed rows committed
  atomically with entries (the store core holds the joint tx,
  ADR-010 §2; all GC state hosts on the fleet's kv engine —
  ADR-012 §4), one sweeper holds the advisory lock (named exclusive
  sweep lease semantics: one sweeper per pool, atomic test-and-set,
  holdership ends at release/owner death/engine-session loss;
  contention is a report abort, not an error — ADR-012 §1, semantics
  pinned ADR-013 §2), and delete
  arbitration is SQL-level — the facade's invariant text is
  unchanged. The facade owns no reactive machinery: cadence,
  maintenance dispatch, and invalidation wiring are embedder
  composition above it (ADR-005; ADR-013 §1).

## Error model

`thiserror`; no panics in library code; no `unwrap`/`expect` outside
tests (AGENTS convention 2). Distinguished failure families:

- `Missing` — key absent on ops that signal absence via `Err`:
  `read_range` and `delete` (ADR-012 §6.5). `get` and `stat` signal
  absence with `None` — `Missing` is not theirs.
- `Verification` — put/get hash-check failure (content ≠ key)
- `Io(String)` — backend media failure, covering cursor and staged-put
  I/O errors surface through the same trait error family (stringly
  because `std::io::Error` is not stable across versions)
- `GcRefuse` — direct delete refused: the key is protected (pinned,
  registered-source-live, or protect-callback-named) — nothing
  deleted; the delete-window arbitration decides at delete time
  (ADR-005; named `GcRefuse` per ADR-012 §1 — there is no
  sweep-abort error).
- `KeyInvalid` — malformed key bytes at the boundary (truncated,
  unknown algorithm — rejects, never guesses; ADR-002/hashing doc).
- `RegistryFull` — the pin-token registry's admission refusal
  ([ADR-019]decisions/019-token-registry-admission-posture.md):
  a mint at or past the ops registry's admission cap — no token
  issued, no insertion (never an eviction), a custodial guard
  dropped, no custody taken. Ops-layer only (the registry is
  ops-layer state): it never fires at the backend trait boundary or
  through the facade's store verbs.
  One enum, both altitudes: the **facade's** error enum exposes
  `Missing`/`Verification`/`Io`/`GcRefuse`/`KeyInvalid` (the
  store-side families); the
  `Backend` trait's methods return the same family as crate-internal
  variants — `KeyInvalid` never fires at the trait boundary (keys
  arrive opaque there) and `GcRefuse` never fires below the facade
  (arbitration answers flow up as data; the facade decides and owns
  the refusal), so the trait's effective members are
  `Missing`/`Verification`/`Io`/`KeyInvalid`; the facade re-exposes
  them with `GcRefuse` added, and `RegistryFull` rides the same enum
  as the ops-layer member (ADR-012 §6.5's family statement reads
  through this split; "new failure families are new ADRs" —
  ADR-019 is that ADR).

Virgin-store semantics: read paths on a fresh store see absent/empty,
never "table does not exist" errors (POC #1 finding 2 — the redb
lesson generalizes to any kv engine).

## Concurrency posture

- Async I/O throughout; `tokio::sync` for lifecycle correlation;
  `parking_lot` for short-held internal locks (AGENTS convention 3);
  poisoned locks degrade via `unwrap_or_else(|e| e.into_inner())`.
- Blocking file work lives in `spawn_blocking` inside backend impls
  (the alkgit trait-execution pattern); the store never blocks the
  executor.
- The sweep-vs-put window is an architectural mechanism — delete
  windows — specified in ADR-005, not an implementation note.

## Invariants (the test gate)

1. Whole-blob put/get round-trips byte-identical under the canonical
   derivation, both tiers, both put paths, **and every shipped engine
   of both tiers** (kv: sqlite + postgres + mem; large: `local` +
   pg-lo; ADR-007/008/009/011)
   (interop with real git remains the source of truth, per POC #1's
   lesson about hardcoded vectors).
2. Dedup: putting identical content twice (same or different path) is
   one pool entry.
3. Stage hygiene: any failure mid-put leaves zero stage files; any
   success leaves exactly one committed entry. A write failure
   mid-staging also discards the stage (ADR-015 — the staged read
   window never serves hole-plus-shifted content).
4. **Local publish durability and no-replace (ADR-015):** the local
   tier's publish is fsync-durable — file fsync before the
   directory-entry mutation that publishes, directory fsync after —
   so a crash never leaves garbage under a valid key (the crash
   window loses recent in-flight puts, recovered by re-put); and the
   publish is no-replace (hardlink-into-place; `renameat2(NOREPLACE)`
   equivalent) — a committed file is never overwritten. Local reads
   error on a short pread (both platforms agree).
5. Sweep safety: with correct liveness registered, sweep counts are
   exact; with aborting sources, sweep deletes nothing. **Engine-gated
   form (ADR-008):** single-node postures assert this under the
   in-process protocol; fleet postures (shared-pool engines) assert it
   under DB-backed pins + the advisory-locked sweep, with the
   exact-count sweep-outcome shape run against dockerized postgres
   under `--all-features`.
6. `list()` correctness is observable only through GC — list-related
   tests assert through sweep outcomes (POC #1 finding 2's lesson
   codified).
7. `size`/`stat` on any engine returns the entry length without
   fetching content (asserted per engine: kv row probe, `local` stat,
   pg-lo companion-row probe — ADR-008/009).
8. **Engine-conformance uniformity (ADR-010 §1):** every engine's
   cursor/stage/list bodies pass the same contract suite mem
   (the reference engine) passes — exact-count sweep outcomes, stage
   hygiene, virgin-store no-ops, CAS re-put, ranged reads (the POC #7
   gate is the norm; per-engine suite runs in CI, `--all-features` for
   the feature-gated engines). The pg-lo engine additionally observes
   the co-tenancy declaration (ADR-016): `reap_orphans` refuses on an
   engine constructed without `orphan_reap_permitted`.

## Design Decisions

| ADR | Decision | Summary |
|---|---|---|
| [002]decisions/002-canonical-hash-and-key-encoding.md | Hashing & keys | derivation + encoding this surface is built on |
| [003]decisions/003-backend-contract-and-dispatch.md | Dispatch | routing is a pure function of length; pre-threshold buffering |
| [005]decisions/005-pooled-cas-and-gc-mechanism.md | Pools & GC | pins, liveness seams, delete windows, sweep semantics |
| [006]decisions/006-verification-posture-and-transfer-encoding.md | Verification | whole-blob checks; slice digests out-of-band |
| [008]decisions/008-trait-size-probe-fleet-gc-and-large-engines.md | Trait `size` + fleet GC | stat/accounting ride the trait's `size` probe; fleet pins/sweeps are engine-backed |
| [010]decisions/010-backend-io-seams-and-fleet-state-home.md | I/O seams + GC-state home | read cursor / staged put behind the facade; the store core holds the joint entry+pin tx |
| [012]decisions/012-pre-decomposition-consistency-rulings.md | Sweep-abort shape + wire/activation rulings | swept = Ok(report) always, `GcRefuse` for direct-delete refusal; fleet mode is a constructor declaration; GC state hosts on the kv engine |
| [013]decisions/013-family-composition-reactive-boundary-and-feature-graph.md | Sweep lease + composition boundary | the advisory lock's semantics = named exclusive sweep lease (provider unchanged); no reactive machinery/facade hooks in-core |
| [015]decisions/015-local-engine-durability-posture.md | Local durability | fsync-durable publish; no-replace publish; error-on-truncation; write-error-discards-stage |
| [016]decisions/016-one-large-object-consumer-per-database.md | pg-lo reaping constraint | `orphan_reap_permitted` declaration gates `reap_orphans`; one large-object consumer per database |
| [018]decisions/018-git-object-derivation-family.md | The git-object family | registry 0x03/0x04, preimage-identity hashing over the loose form; the per-family `stat` semantic (preimage length); its family-named put form superseded by ADR-020's one seam |
| [020]decisions/020-family-explicit-wire-payloads.md | One derivation-explicit put seam | `put_as(algorithm, len: Option<u64>, stream)` alone; `put`/`put_git_object` deleted, no canonical default; `put_in_batch` takes the algorithm; the unknown-length arm retained on the facade |

## Open Questions

None owned by this document beyond the cross-references above.

## References

- `docs/research/poc-largeblob-findings.md` findings A1/A5/A6,
  benchmark table
- `docs/research/poc-trait-dispatch-findings.md` findings 1-3, 7
- ADR-003 (put-path buffering), ADR-005 (pin/Batch/Pin lifecycle),
  ADR-006 (range-read semantics), ADR-002 (keys), ADR-012 (the
  sweep-abort shape, error-model ruling, fall-through general rule)
- [ops-surface.md]ops-surface.md — the fanout/fetch consumer of the
  read path
- [gc-and-namespaces.md]gc-and-namespaces.md — the lifecycle seam
  details