OXYGENHUB
# 3a4b3e018326 oxygen / docs / stores.md
13.0 KiB · Markdown · last changed The object store runs on any Store: a directory today, a bucket next 1 month ago renderedsource blame history raw
a70dce53 Andy McCall ↶ 1
# Oxygen Anywhere: stores
2
8e119486 Andy McCall ↶ 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.
a70dce53 Andy McCall ↶ 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
a5f76c79 Andy McCall ↶ 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.
64818938 Andy McCall ↶ 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.
a70dce53 Andy McCall ↶ 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.
64818938 Andy McCall ↶ 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.
a70dce53 Andy McCall ↶ 116
9f9e72b9 Andy McCall ↶ 117
## 4. Layout in the bucket (as built, 2026-10-03)
a70dce53 Andy McCall ↶ 118
119
```
120
PREFIX/
9f9e72b9 Andy McCall ↶ 121
  oxygen.toml                      format version and repository id, written once by the first push
122
  bundles/<seq>-<hash>.bundle      one per push: changes, signatures, seals, objects, policies
123
                                   (the wire protocol's bundle encoding), written once, named by content
124
  refs                             every bookmark with its heads and attestation, every tag, the policy
125
                                   and its history (the protocol's refs response), moved by compare-and-swap
a70dce53 Andy McCall ↶ 126
```
127
9f9e72b9 Andy McCall ↶ 128
- **Pushing** uploads one bundle, then updates `refs` from the version
129
  it read. A bundle named in `refs` is complete, because the bundle is
130
  written before `refs` moves.
131
- **One `refs` record** so that a push moving several bookmarks is
132
  atomic. Per-reference records would scale to more writers but lose
a70dce53 Andy McCall ↶ 133
  atomic pushes; one engineer and small teams do not need them.
9f9e72b9 Andy McCall ↶ 134
- **Bundles are whole-file transfers.** Each push is one bundle, so a
135
  pull downloads only the bundles it has not seen. Packs with indexes
136
  for range reads, as first planned, are an optimisation for later
137
  (`oxy gc` against a remote merges bundles, and could write a pack).
138
- **The client does what a hub does.** A store cannot run code, so each
139
  client keeps a **mirror**, a bare repository of its own under
140
  `.oxy/remotes/<name>/strata/`, brought level with the store before
141
  every use: the record read, the bundles it has not seen imported (each
142
  object re-hashed), the view set from the record. A push runs the hub's
143
  own checks (`receive_push`: policy, attestations, fast-forward) on the
144
  mirror, uploads the bundle, then moves `refs`. A fetch verifies each
145
  bookmark's attestation under the policy, as for any remote. The
146
  mirror costs a second copy of the objects on disk; sharing them with
147
  the repository's own store is a later optimisation.
148
- **The record moved meanwhile:** the mirror is put back to what the
149
  store says and the push is reported as a hub reports a stale push:
150
  pull, then push again. Merging two pushes to different bookmarks
151
  without a round trip is ST5's work.
152
- **A directory is a store too:** `oxy remote add usb dir:///media/usb/repo`
153
  (`dir://`, since a plain path or `file://` is another repository).
154
  Tests run the whole flow through it with no network.
a70dce53 Andy McCall ↶ 155
156
## 5. Concurrency: compare-and-swap on `refs`
157
158
| Provider | Create only | Replace only if unchanged |
159
|---|---|---|
160
| S3 | `PutObject` with `If-None-Match: *` | `PutObject` with `If-Match: <ETag>` |
161
| GCS | `ifGenerationMatch=0` | `ifGenerationMatch=<generation>` |
162
163
When a push finds `refs` has moved, it re-reads `refs` and merges by the
164
same rules as the operation log's merge:
165
- a bookmark moved by only one side takes that side's value;
166
- moved differently by both, it keeps both sides' heads, as several
167
  heads;
168
- a tag keeps its first value.
169
170
It then retries the write. Uploading packs needs no locking: two writers
171
uploading the same objects is harmless.
172
8d803563 Andy McCall ↶ 173
**As built (2026-10-04):** the `object_store` crate 0.14.2 carries both
174
writes as `PutMode::Create` and `PutMode::Update(UpdateVersion { e_tag,
175
version })`; S3 needs `S3ConditionalPut::ETagMatch` switched on, which
176
sends `If-None-Match: *` and `If-Match: <ETag>`; GCS uses the generation
177
(`ifGenerationMatch`); Azure the ETag. A store's `Version` carries both
178
the ETag and the generation, so the same code serves all three.
179
Checked against the real services on 2026-10-03/04: every conditional
180
write refuses as required on S3, GCS and Azure.
a70dce53 Andy McCall ↶ 181
182
## 6. Safety
183
184
- **The bucket is untrusted.** Every object is re-hashed on read, as
185
  local reads are now. Signatures and repository policy are checked on
186
  fetch exactly as for a hub remote.
187
- **Bound to one repository.** `oxygen.toml` names the repository id. A
188
  push to a bucket holding a different repository is refused, and so is
189
  a clone that finds packs naming another id.
190
- **Size limits.** The existing 1 GiB object cap, plus limits on index
191
  and `refs` sizes, so a hostile bucket cannot drive huge allocations.
192
- **Garbage collection never deletes in place.** `oxy gc` on a remote
193
  writes the merged pack and moves `refs` first. Packs no longer named
194
  are deleted only after a grace period (default 7 days), so a slow
195
  concurrent reader is never left with a missing pack.
196
- **Least privilege.** The documented IAM and GCS policies need only
197
  Get, Put, List and Delete under the prefix. A read-only key gives a
198
  working clone and pull.
199
- **No secrets in the repository.** Credentials stay in the providers'
200
  own chains or the OS keychain, never in `.oxy/config`.
201
202
## 7. Testing
203
204
- **One conformance suite** that every store must pass, run against
205
  local, S3 and GCS. It covers object round trips, range reads,
206
  create-only refusals, compare-and-swap conflicts, concurrent pushers
207
  merging, an interrupted push leaving the remote valid, gc with a
208
  concurrent reader, and a repository-id mismatch being refused.
8d803563 Andy McCall ↶ 209
- **In CI** (Flow jobs have no network): the in-memory and directory
210
  stores run the conformance check and the whole push/clone/pull flow.
211
- **Real buckets** for the cloud stores, run by hand from a machine that
212
  holds restricted credentials: `crates/oxy-strata/tests/cloud.rs` reads
213
  `OXY_TEST_S3`, `OXY_TEST_GCS` and `OXY_TEST_AZURE` (each a store URL
214
  under a test prefix, with `OXY_TEST_S3_PROFILE`,
215
  `OXY_TEST_GCS_CREDENTIALS` and `OXY_TEST_AZURE_CREDENTIALS` naming the
216
  identity), runs the conformance check and a repository round trip under
217
  a prefix of its own, and deletes what it wrote. Unset, the tests say so
218
  and pass.
219
- **Emulators** were tried and dropped: MinIO's images are no longer
220
  published publicly, and `fake-gcs-server` rejects the client's XML
221
  uploads. The real services are the test.
a70dce53 Andy McCall ↶ 222
8e119486 Andy McCall ↶ 223
## 8. Order of work (done)
a70dce53 Andy McCall ↶ 224
8e119486 Andy McCall ↶ 225
1. ✔ **The `Store` trait in oxy-core**, with a directory store and an
64818938 Andy McCall ↶ 226
   in-memory one; the working repository's own store left as it was,
227
   then (B1, 2026-10-04) put behind the trait with the same layout.
8e119486 Andy McCall ↶ 228
2. ✔ **The bucket layout and the store remote**, tested with no network.
229
3. ✔ **S3, GCS and Azure stores**, in `crates/oxy-strata`, verified
230
   against the real services.
231
4. ✔ **`oxy remote add`, `push`, `pull`, `clone`** for store URLs, with
232
   the settings on the URL, `remote check`, and friendly errors.
233
   (`init --remote` was dropped: `remote add` and `push` bind a store.)
234
5. ✔ **`oxy gc --remote`**, with the grace period, and merge-and-retry
235
   for concurrent pushes.
236
6. ✔ **Documentation:** `docs/strata.md`, the README section, and this
237
   record.
a70dce53 Andy McCall ↶ 238
239
Later, on the same trait:
240
- Azure Blob storage;
241
- a Git repository used as a plain store, so `oxy push` works to
242
  GitHub or GitLab;
243
- the hub attaching a repository from a customer's bucket ("bring your
244
  own storage", see oxygenhub `docs/scaling.md`);
245
- couriers: signed, encrypted bundles shared by email, Slack, Matrix
246
  and the like (a separate design);
247
- a virtual working copy mounted with FUSE (from OxygenFlow's
248
  `flow-casfs`).
249
250
## 9. Open questions
251
252
- Should `oxy clone s3://…` set the bucket as `origin`, or prompt for a
253
  name? Proposed: `origin`, as for any URL.
254
- Does a bucket remote keep its own operation log, or only `refs`?
255
  Proposed: only `refs` at first. The local operation log already
256
  records what each push did.
257
- Should packs be encrypted on the client, so the provider only ever
258
  stores ciphertext, beyond the provider's own encryption? Proposed:
259
  later, as an option. It does not change the layout.