| 1 |
# Oxygen Anywhere: stores |
| 2 |
|
| 3 |
Status: built 2026-10-03/04 (oxygen#63): the `Store` trait, directory |
| 4 |
and in-memory stores, the bucket layout and the store remote, the S3, |
| 5 |
GCS and Azure stores, the CLI (`remote add` settings, `remote check`, |
| 6 |
`gc --remote`), and the user guide `docs/strata.md`. This document is |
| 7 |
the design and the record of what was decided; the guide is for users. |
| 8 |
|
| 9 |
## 1. What and why |
| 10 |
|
| 11 |
Today an Oxygen repository lives in `.oxy/` on a disk, and a remote is |
| 12 |
either another directory or an OxygenHub. This feature makes storage |
| 13 |
pluggable. Any **store** that can keep write-once objects and update a |
| 14 |
small reference record by compare-and-swap can hold a complete |
| 15 |
repository, with no server in between. |
| 16 |
|
| 17 |
The first stores are **Amazon S3** and **Google Cloud Storage**, built |
| 18 |
side by side so the code stays portable. Azure Blob and a Git repository |
| 19 |
used as a plain store follow on the same interface. |
| 20 |
|
| 21 |
Why Oxygen can do this safely when Git cannot: |
| 22 |
|
| 23 |
- **Objects never change.** Changes, states, blobs, attestations and |
| 24 |
receipts are named by their BLAKE3 hash and written once. |
| 25 |
- **Only references are mutable.** Bookmarks, tags and the operation |
| 26 |
head are small, and object stores now offer compare-and-swap on a |
| 27 |
single object. |
| 28 |
- **Every read is verified.** Objects are re-hashed on read and changes |
| 29 |
are signed, so a bucket is untrusted storage. Tampering is detected, |
| 30 |
and nobody can forge history, not even the bucket's owner. |
| 31 |
|
| 32 |
Performance is "good enough" for one engineer today. This work is about |
| 33 |
where a repository can live, not about speed. |
| 34 |
|
| 35 |
## 2. What users see |
| 36 |
|
| 37 |
Storage must be transparent: a bucket behaves like any other remote, with |
| 38 |
no new concepts to learn. |
| 39 |
|
| 40 |
```sh |
| 41 |
oxy remote add backup s3://my-bucket/repos/oxygen |
| 42 |
oxy push backup |
| 43 |
oxy clone gs://team-bucket/metalgear |
| 44 |
oxy pull |
| 45 |
``` |
| 46 |
|
| 47 |
- **URLs:** `s3://BUCKET/PREFIX` and `gs://BUCKET/PREFIX`. The prefix is |
| 48 |
optional, so one bucket can hold many repositories. |
| 49 |
- **Credentials come from the standard places**, with no oxy-specific |
| 50 |
configuration: |
| 51 |
- **AWS:** environment variables, `~/.aws` profiles (`AWS_PROFILE`), |
| 52 |
SSO, and instance or container roles; |
| 53 |
- **Google Cloud:** Application Default Credentials (`gcloud auth |
| 54 |
application-default login`), service account files, and workload |
| 55 |
identity. |
| 56 |
- **Overrides:** optional per-remote settings in the repository's |
| 57 |
config (`profile`, `region`, `endpoint`), for example |
| 58 |
`oxy remote add backup s3://b/p --profile work`. |
| 59 |
- **Errors are written for people.** Each one says what to do next, for |
| 60 |
example: "no AWS credentials found: run `aws sso login` or set |
| 61 |
AWS_PROFILE", or "the bucket exists but this account cannot write to |
| 62 |
it (s3:PutObject denied on my-bucket/repos/oxygen)". |
| 63 |
- **`oxy init --remote s3://…`** creates an empty repository in the |
| 64 |
bucket, and `oxy remote check NAME` reports access, latency and |
| 65 |
what is stored there. |
| 66 |
- **Progress, colour and glyphs** follow the CLI style of every other |
| 67 |
command. |
| 68 |
- **Several writers are safe.** Two people pushing to the same bucket at |
| 69 |
once is handled the way the operation log handles two processes today |
| 70 |
(§5). |
| 71 |
|
| 72 |
## 3. Architecture |
| 73 |
|
| 74 |
- **The `Store` trait** (`oxy_core::strata`, built 2026-10-03). It has |
| 75 |
two halves: |
| 76 |
- **objects**: `has`, `get`, `get_range`, `put_new` (create only), |
| 77 |
`list` by prefix, and `delete`; |
| 78 |
- **references**: `read_ref() -> (bytes, version)` and |
| 79 |
`write_ref(bytes, expected_version) -> Ok | Conflict`. A version is |
| 80 |
opaque and compared for equality only. Writing identical bytes may |
| 81 |
keep the version, as S3's content-based ETag does; compare-and-swap |
| 82 |
guards against losing a *different* record. |
| 83 |
- Keys are plain relative paths (`[A-Za-z0-9._-]` segments, no `.` or |
| 84 |
`..`), the same rule on every store, so a key can never escape a |
| 85 |
directory store. |
| 86 |
- Implementations: `LocalStore` (a directory: create-only through a |
| 87 |
hard link, references under a lock file beside them, so processes on |
| 88 |
one machine can share it), `MemoryStore` (tests), then S3 and GCS in |
| 89 |
a crate of their own. |
| 90 |
- `strata::conformance(store, prefix)` exercises any store under a |
| 91 |
probe prefix and leaves it empty: every implementation runs it in its |
| 92 |
tests, and `oxy remote check` runs it against a remote. |
| 93 |
- Since 2026-10-04 (oxygen#68, B1) the repository's own object store |
| 94 |
is behind the trait too: `.oxy/objects` is a `LocalStore` with the |
| 95 |
same keys a bucket would hold (`loose/`, `pack-*`, `nocompress/`, |
| 96 |
`sig/`, `seal/`, `receipt/`), and the pack code reads a packed |
| 97 |
object with one ranged read. A directory store is as fast as the |
| 98 |
files were: the same stats, reads and syncs, with a batch of loose |
| 99 |
objects synced together. What must be on this disk, a pack being |
| 100 |
streamed out and the derived `metas` cache, lives in a scratch |
| 101 |
directory, which for a directory store is the store itself. |
| 102 |
- **The `object_store` crate** (Apache Arrow) for S3 and GCS: one API |
| 103 |
for both, mature credential chains, multipart uploads, and conditional |
| 104 |
puts. Azure is in the same crate, so it comes nearly free later. |
| 105 |
- **Push and pull talk to the bucket directly** through the same |
| 106 |
negotiation the hub protocol uses: what does the remote have, and what |
| 107 |
is missing? The client sends packs, never loose objects. |
| 108 |
- **A local cache** of fetched objects, which is always correct because |
| 109 |
objects never change. |
| 110 |
- **The working repository stays local for now.** A bucket is a remote. |
| 111 |
Running a repository *from* a bucket is plan B (oxygen#68): a `Repo` |
| 112 |
opened over a bucket store with a local cache in front, so the hub |
| 113 |
holds no copy of its own and the data lives where its owner chose. |
| 114 |
B1 (the store over the trait) is done; B2 is the caching store and |
| 115 |
the references by compare-and-swap. |
| 116 |
- **The operation head on a store** (B2, 2026-10-05): a repository whose |
| 117 |
objects are on any store but this disk's directory keeps its |
| 118 |
operation head in that store's `ops/head` reference record. Recording |
| 119 |
an operation reads the record and its version, and moves it only if |
| 120 |
the version is unchanged. When another process moved it first, the |
| 121 |
two operations are merged by the same rules as on disk, and the write |
| 122 |
is tried again (eight times at most). A repository on this disk keeps |
| 123 |
`.oxy/ops/head` under its lock file, as before, so older oxy builds |
| 124 |
share it safely. |
| 125 |
- **The caching store** (B2, 2026-10-05): `strata::CachingStore` puts a |
| 126 |
directory store in front of any other. Reads go through it: a missing |
| 127 |
object is fetched from the remote and kept. Writes go to the remote |
| 128 |
first and then the cache, so the remote always holds everything and |
| 129 |
the cache can be deleted at any time. The cache has a size limit; past |
| 130 |
it the objects used least recently are removed, down to nine tenths of |
| 131 |
the limit. An object larger than an eighth of the limit is not kept: |
| 132 |
ranges of it are read from the remote each time. References are never |
| 133 |
cached, so compare-and-swap is always the remote's. |
| 134 |
- **A repository on a store** (B2, 2026-10-05): `oxy init --store URL` |
| 135 |
(or `storage.url` in `.oxy/config`, set at init) keeps the objects and |
| 136 |
the operation head on that store, behind a cache in `.oxy/cache` of |
| 137 |
`storage.cache` bytes (2 GiB by default). `.oxy/objects` then holds |
| 138 |
only scratch: packs being written and derived caches. The cache can be |
| 139 |
deleted at any time. A second repository cannot start on a store that |
| 140 |
already holds one. Measured from the workstation against the real |
| 141 |
services with five small commits (`a_repository_runs_on_every_configured_store`): |
| 142 |
|
| 143 |
| Store | init | each commit | open with no cache, read the files | |
| 144 |
|---|---|---|---| |
| 145 |
| S3 | 441 ms | 703 ms | 481 ms | |
| 146 |
| GCS | 769 ms | 857 ms | 570 ms | |
| 147 |
| Azure | 664 ms | 511 ms | 639 ms | |
| 148 |
|
| 149 |
A commit writes its objects one request each and moves the head, so |
| 150 |
its cost is round trips, not bytes; batching a commit's objects into |
| 151 |
one pack is the next step for speed. |
| 152 |
|
| 153 |
## 4. Layout in the bucket (as built, 2026-10-03) |
| 154 |
|
| 155 |
``` |
| 156 |
PREFIX/ |
| 157 |
oxygen.toml format version and repository id, written once by the first push |
| 158 |
bundles/<seq>-<hash>.bundle one per push: changes, signatures, seals, objects, policies |
| 159 |
(the wire protocol's bundle encoding), written once, named by content |
| 160 |
refs every bookmark with its heads and attestation, every tag, the policy |
| 161 |
and its history (the protocol's refs response), moved by compare-and-swap |
| 162 |
``` |
| 163 |
|
| 164 |
- **Pushing** uploads one bundle, then updates `refs` from the version |
| 165 |
it read. A bundle named in `refs` is complete, because the bundle is |
| 166 |
written before `refs` moves. |
| 167 |
- **One `refs` record** so that a push moving several bookmarks is |
| 168 |
atomic. Per-reference records would scale to more writers but lose |
| 169 |
atomic pushes; one engineer and small teams do not need them. |
| 170 |
- **Bundles are whole-file transfers.** Each push is one bundle, so a |
| 171 |
pull downloads only the bundles it has not seen. Packs with indexes |
| 172 |
for range reads, as first planned, are an optimisation for later |
| 173 |
(`oxy gc` against a remote merges bundles, and could write a pack). |
| 174 |
- **The client does what a hub does.** A store cannot run code, so each |
| 175 |
client keeps a **mirror**, a bare repository of its own under |
| 176 |
`.oxy/remotes/<name>/strata/`, brought level with the store before |
| 177 |
every use: the record read, the bundles it has not seen imported (each |
| 178 |
object re-hashed), the view set from the record. A push runs the hub's |
| 179 |
own checks (`receive_push`: policy, attestations, fast-forward) on the |
| 180 |
mirror, uploads the bundle, then moves `refs`. A fetch verifies each |
| 181 |
bookmark's attestation under the policy, as for any remote. The |
| 182 |
mirror costs a second copy of the objects on disk; sharing them with |
| 183 |
the repository's own store is a later optimisation. |
| 184 |
- **The record moved meanwhile:** the mirror is put back to what the |
| 185 |
store says and the push is reported as a hub reports a stale push: |
| 186 |
pull, then push again. Merging two pushes to different bookmarks |
| 187 |
without a round trip is ST5's work. |
| 188 |
- **A directory is a store too:** `oxy remote add usb dir:///media/usb/repo` |
| 189 |
(`dir://`, since a plain path or `file://` is another repository). |
| 190 |
Tests run the whole flow through it with no network. |
| 191 |
|
| 192 |
## 5. Concurrency: compare-and-swap on `refs` |
| 193 |
|
| 194 |
|
| 195 |
|
| 196 |
| S3 | `PutObject` with `If-None-Match: *` | `PutObject` with `If-Match: <ETag>` | |
| 197 |
| GCS | `ifGenerationMatch=0` | `ifGenerationMatch=<generation>` | |
| 198 |
|
| 199 |
When a push finds `refs` has moved, it re-reads `refs` and merges by the |
| 200 |
same rules as the operation log's merge: |
| 201 |
- a bookmark moved by only one side takes that side's value; |
| 202 |
- moved differently by both, it keeps both sides' heads, as several |
| 203 |
heads; |
| 204 |
- a tag keeps its first value. |
| 205 |
|
| 206 |
It then retries the write. Uploading packs needs no locking: two writers |
| 207 |
uploading the same objects is harmless. |
| 208 |
|
| 209 |
**As built (2026-10-04):** the `object_store` crate (0.13.1: 0.14 brought in |
| 210 |
`aws-lc-rs`, a C build, and `crc-fast`, whose `cdylib` the macOS cross |
| 211 |
build cannot link) carries both |
| 212 |
writes as `PutMode::Create` and `PutMode::Update(UpdateVersion { e_tag, |
| 213 |
version })`; S3 needs `S3ConditionalPut::ETagMatch` switched on, which |
| 214 |
sends `If-None-Match: *` and `If-Match: <ETag>`; GCS uses the generation |
| 215 |
(`ifGenerationMatch`); Azure the ETag. A store's `Version` carries both |
| 216 |
the ETag and the generation, so the same code serves all three. |
| 217 |
Checked against the real services on 2026-10-03/04: every conditional |
| 218 |
write refuses as required on S3, GCS and Azure. |
| 219 |
|
| 220 |
## 6. Safety |
| 221 |
|
| 222 |
- **The bucket is untrusted.** Every object is re-hashed on read, as |
| 223 |
local reads are now. Signatures and repository policy are checked on |
| 224 |
fetch exactly as for a hub remote. |
| 225 |
- **Bound to one repository.** `oxygen.toml` names the repository id. A |
| 226 |
push to a bucket holding a different repository is refused, and so is |
| 227 |
a clone that finds packs naming another id. |
| 228 |
- **Size limits.** The existing 1 GiB object cap, plus limits on index |
| 229 |
and `refs` sizes, so a hostile bucket cannot drive huge allocations. |
| 230 |
- **Garbage collection never deletes in place.** `oxy gc` on a remote |
| 231 |
writes the merged pack and moves `refs` first. Packs no longer named |
| 232 |
are deleted only after a grace period (default 7 days), so a slow |
| 233 |
concurrent reader is never left with a missing pack. |
| 234 |
- **Least privilege.** The documented IAM and GCS policies need only |
| 235 |
Get, Put, List and Delete under the prefix. A read-only key gives a |
| 236 |
working clone and pull. |
| 237 |
- **No secrets in the repository.** Credentials stay in the providers' |
| 238 |
own chains or the OS keychain, never in `.oxy/config`. |
| 239 |
|
| 240 |
## 7. Testing |
| 241 |
|
| 242 |
- **One conformance suite** that every store must pass, run against |
| 243 |
local, S3 and GCS. It covers object round trips, range reads, |
| 244 |
create-only refusals, compare-and-swap conflicts, concurrent pushers |
| 245 |
merging, an interrupted push leaving the remote valid, gc with a |
| 246 |
concurrent reader, and a repository-id mismatch being refused. |
| 247 |
- **In CI** (Flow jobs have no network): the in-memory and directory |
| 248 |
stores run the conformance check and the whole push/clone/pull flow. |
| 249 |
- **Real buckets** for the cloud stores, run by hand from a machine that |
| 250 |
holds restricted credentials: `crates/oxy-strata/tests/cloud.rs` reads |
| 251 |
`OXY_TEST_S3`, `OXY_TEST_GCS` and `OXY_TEST_AZURE` (each a store URL |
| 252 |
under a test prefix, with `OXY_TEST_S3_PROFILE`, |
| 253 |
`OXY_TEST_GCS_CREDENTIALS` and `OXY_TEST_AZURE_CREDENTIALS` naming the |
| 254 |
identity), runs the conformance check and a repository round trip under |
| 255 |
a prefix of its own, and deletes what it wrote. Unset, the tests say so |
| 256 |
and pass. |
| 257 |
- **Emulators** were tried and dropped: MinIO's images are no longer |
| 258 |
published publicly, and `fake-gcs-server` rejects the client's XML |
| 259 |
uploads. The real services are the test. |
| 260 |
|
| 261 |
## 8. Order of work (done) |
| 262 |
|
| 263 |
1. ✔ **The `Store` trait in oxy-core**, with a directory store and an |
| 264 |
in-memory one; the working repository's own store left as it was, |
| 265 |
then (B1, 2026-10-04) put behind the trait with the same layout. |
| 266 |
2. ✔ **The bucket layout and the store remote**, tested with no network. |
| 267 |
3. ✔ **S3, GCS and Azure stores**, in `crates/oxy-strata`, verified |
| 268 |
against the real services. |
| 269 |
4. ✔ **`oxy remote add`, `push`, `pull`, `clone`** for store URLs, with |
| 270 |
the settings on the URL, `remote check`, and friendly errors. |
| 271 |
(`init --remote` was dropped: `remote add` and `push` bind a store.) |
| 272 |
5. ✔ **`oxy gc --remote`**, with the grace period, and merge-and-retry |
| 273 |
for concurrent pushes. |
| 274 |
6. ✔ **Documentation:** `docs/strata.md`, the README section, and this |
| 275 |
record. |
| 276 |
|
| 277 |
Later, on the same trait: |
| 278 |
- Azure Blob storage; |
| 279 |
- a Git repository used as a plain store, so `oxy push` works to |
| 280 |
GitHub or GitLab; |
| 281 |
- the hub attaching a repository from a customer's bucket ("bring your |
| 282 |
own storage", see oxygenhub `docs/scaling.md`); |
| 283 |
- couriers: signed, encrypted bundles shared by email, Slack, Matrix |
| 284 |
and the like (a separate design); |
| 285 |
- a virtual working copy mounted with FUSE (from OxygenFlow's |
| 286 |
`flow-casfs`). |
| 287 |
|
| 288 |
## 9. Open questions |
| 289 |
|
| 290 |
- Should `oxy clone s3://…` set the bucket as `origin`, or prompt for a |
| 291 |
name? Proposed: `origin`, as for any URL. |
| 292 |
- Does a bucket remote keep its own operation log, or only `refs`? |
| 293 |
Proposed: only `refs` at first. The local operation log already |
| 294 |
records what each push did. |
| 295 |
- Should packs be encrypted on the client, so the provider only ever |
| 296 |
stores ciphertext, beyond the provider's own encryption? Proposed: |
| 297 |
later, as an option. It does not change the layout. |