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