OXYGENHUB
# 7026bd7dc5b8 oxygen / docs / stores.md
15.3 KiB · Markdown · last changed A repository kept on a store: oxy init --store 1 month ago renderedsource blame history raw
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
| Provider | Create only | Replace only if unchanged |
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.