OXYGENHUB

Design: Oxygen Anywhere, pluggable stores starting with S3 and GCS (refs #63) a70dce536476

Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-03 10:46 UTC
Signature
✔ signed by Andy McCall
Hash
a70dce536476320af18e51c7cc862d118ba24521f235fe2a1c34664f9f1dd5db
Depends on
ff0af1ee299d
Depended on by
d069701a4997 454236d1108d 648189388f1a f874dc8659c2 8e1194860efa d8d9367878a6 8d80356371c5 9f9e72b93e4e a5f76c79666c ac4af91530fc 82504d09aefe 4f22a8fd0ed4
In bookmarks
agents-in-readme bare-hashes carried-not-removed carry-identical doctor-trust identical-sides keyless-tags main merge-remote-bookmark only-help-103 paths-for-resolutions-and-amend push-notes show-stat subrepo-pin-state switch-says
In tags
v0.10.0 v0.11.0 v0.4.0 v0.4.1 v0.4.2 v0.4.3 v0.4.4 v0.4.5 v0.4.6 v0.5.0 v0.5.1 v0.6.0 v0.7.0 v0.8.0 v0.9.0
Edits
1 atom, 1 tree operation, 1 file

Browse the tree at this change

added docs/stores.md +196 −0
1 +
# Oxygen Anywhere: stores
2 +
3 +
Status: design, 2026-10-03. Nothing here is built yet. Next to implement.
4 +
5 +
## 1. What and why
6 +
7 +
Today an Oxygen repository lives in `.oxy/` on a disk, and a remote is
8 +
either another directory or an OxygenHub. This feature makes storage
9 +
pluggable. Any **store** that can keep write-once objects and update a
10 +
small reference record by compare-and-swap can hold a complete
11 +
repository, with no server in between.
12 +
13 +
The first stores are **Amazon S3** and **Google Cloud Storage**, built
14 +
side by side so the code stays portable. Azure Blob and a Git repository
15 +
used as a plain store follow on the same interface.
16 +
17 +
Why Oxygen can do this safely when Git cannot:
18 +
19 +
- **Objects never change.** Changes, states, blobs, attestations and
20 +
  receipts are named by their BLAKE3 hash and written once.
21 +
- **Only references are mutable.** Bookmarks, tags and the operation
22 +
  head are small, and object stores now offer compare-and-swap on a
23 +
  single object.
24 +
- **Every read is verified.** Objects are re-hashed on read and changes
25 +
  are signed, so a bucket is untrusted storage. Tampering is detected,
26 +
  and nobody can forge history, not even the bucket's owner.
27 +
28 +
Performance is "good enough" for one engineer today. This work is about
29 +
where a repository can live, not about speed.
30 +
31 +
## 2. What users see
32 +
33 +
Storage must be transparent: a bucket behaves like any other remote, with
34 +
no new concepts to learn.
35 +
36 +
```sh
37 +
oxy remote add backup s3://my-bucket/repos/oxygen
38 +
oxy push backup
39 +
oxy clone gs://team-bucket/metalgear
40 +
oxy pull
41 +
```
42 +
43 +
- **URLs:** `s3://BUCKET/PREFIX` and `gs://BUCKET/PREFIX`. The prefix is
44 +
  optional, so one bucket can hold many repositories.
45 +
- **Credentials come from the standard places**, with no oxy-specific
46 +
  configuration:
47 +
  - **AWS:** environment variables, `~/.aws` profiles (`AWS_PROFILE`),
48 +
    SSO, and instance or container roles;
49 +
  - **Google Cloud:** Application Default Credentials (`gcloud auth
50 +
    application-default login`), service account files, and workload
51 +
    identity.
52 +
  - **Overrides:** optional per-remote settings in the repository's
53 +
    config (`profile`, `region`, `endpoint`), for example
54 +
    `oxy remote add backup s3://b/p --profile work`.
55 +
- **Errors are written for people.** Each one says what to do next, for
56 +
  example: "no AWS credentials found: run `aws sso login` or set
57 +
  AWS_PROFILE", or "the bucket exists but this account cannot write to
58 +
  it (s3:PutObject denied on my-bucket/repos/oxygen)".
59 +
- **`oxy init --remote s3://…`** creates an empty repository in the
60 +
  bucket, and `oxy remote check NAME` reports access, latency and
61 +
  what is stored there.
62 +
- **Progress, colour and glyphs** follow the CLI style of every other
63 +
  command.
64 +
- **Several writers are safe.** Two people pushing to the same bucket at
65 +
  once is handled the way the operation log handles two processes today
66 +
  (§5).
67 +
68 +
## 3. Architecture
69 +
70 +
- **A `Store` trait in oxy-core**, with three implementations: the
71 +
  current local `.oxy/` store, S3, and GCS. It has two halves:
72 +
  - **objects**: `has`, `get`, `get_range`, `put` (create only), and
73 +
    `list` by prefix;
74 +
  - **references**: `read() -> (bytes, version)` and
75 +
    `write(bytes, expected_version) -> Ok | Conflict`.
76 +
- **The `object_store` crate** (Apache Arrow) for S3 and GCS: one API
77 +
  for both, mature credential chains, multipart uploads, and conditional
78 +
  puts. Azure is in the same crate, so it comes nearly free later.
79 +
- **Push and pull talk to the bucket directly** through the same
80 +
  negotiation the hub protocol uses: what does the remote have, and what
81 +
  is missing? The client sends packs, never loose objects.
82 +
- **A local cache** of fetched objects, which is always correct because
83 +
  objects never change.
84 +
- **The working repository stays local.** A bucket is a remote. Running
85 +
  a repository *from* a bucket (no local `.oxy/`) is out of scope here;
86 +
  it is what the virtual working copy (§8) is for.
87 +
88 +
## 4. Layout in the bucket
89 +
90 +
```
91 +
PREFIX/
92 +
  oxygen.toml          format version and repository id, written once
93 +
  packs/<id>.pack      immutable, zstd-compressed objects
94 +
  packs/<id>.idx       sorted index of the pack (read with range GETs)
95 +
  refs                 bookmarks, tags and the remote's operation head: one object, updated by compare-and-swap
96 +
  receipts/, attest/   only if they are kept outside packs
97 +
```
98 +
99 +
- **Pushing** uploads one pack and its index, then updates `refs`.
100 +
- **Packs never change.** A pack named in `refs` is complete, because
101 +
  the pack is written before `refs` moves.
102 +
- **One `refs` object** so that a push moving several bookmarks is
103 +
  atomic. Per-reference objects would scale to more writers but lose
104 +
  atomic pushes; one engineer and small teams do not need them.
105 +
- **Many small packs from many pushes** are merged by `oxy gc` against
106 +
  the remote, as `merge_packs` does locally.
107 +
108 +
## 5. Concurrency: compare-and-swap on `refs`
109 +
110 +
| Provider | Create only | Replace only if unchanged |
111 +
|---|---|---|
112 +
| S3 | `PutObject` with `If-None-Match: *` | `PutObject` with `If-Match: <ETag>` |
113 +
| GCS | `ifGenerationMatch=0` | `ifGenerationMatch=<generation>` |
114 +
115 +
When a push finds `refs` has moved, it re-reads `refs` and merges by the
116 +
same rules as the operation log's merge:
117 +
- a bookmark moved by only one side takes that side's value;
118 +
- moved differently by both, it keeps both sides' heads, as several
119 +
  heads;
120 +
- a tag keeps its first value.
121 +
122 +
It then retries the write. Uploading packs needs no locking: two writers
123 +
uploading the same objects is harmless.
124 +
125 +
Before building on it, check each provider's current documentation for
126 +
conditional writes: S3 added `If-Match` on `PutObject` in 2024, and this
127 +
design depends on it.
128 +
129 +
## 6. Safety
130 +
131 +
- **The bucket is untrusted.** Every object is re-hashed on read, as
132 +
  local reads are now. Signatures and repository policy are checked on
133 +
  fetch exactly as for a hub remote.
134 +
- **Bound to one repository.** `oxygen.toml` names the repository id. A
135 +
  push to a bucket holding a different repository is refused, and so is
136 +
  a clone that finds packs naming another id.
137 +
- **Size limits.** The existing 1 GiB object cap, plus limits on index
138 +
  and `refs` sizes, so a hostile bucket cannot drive huge allocations.
139 +
- **Garbage collection never deletes in place.** `oxy gc` on a remote
140 +
  writes the merged pack and moves `refs` first. Packs no longer named
141 +
  are deleted only after a grace period (default 7 days), so a slow
142 +
  concurrent reader is never left with a missing pack.
143 +
- **Least privilege.** The documented IAM and GCS policies need only
144 +
  Get, Put, List and Delete under the prefix. A read-only key gives a
145 +
  working clone and pull.
146 +
- **No secrets in the repository.** Credentials stay in the providers'
147 +
  own chains or the OS keychain, never in `.oxy/config`.
148 +
149 +
## 7. Testing
150 +
151 +
- **One conformance suite** that every store must pass, run against
152 +
  local, S3 and GCS. It covers object round trips, range reads,
153 +
  create-only refusals, compare-and-swap conflicts, concurrent pushers
154 +
  merging, an interrupted push leaving the remote valid, gc with a
155 +
  concurrent reader, and a repository-id mismatch being refused.
156 +
- **Emulators in CI** (Flow jobs have no network):
157 +
  - MinIO for S3, if its conditional writes match S3's, otherwise
158 +
    LocalStack;
159 +
  - `fake-gcs-server` for GCS.
160 +
- **Real buckets** (Andy's AWS and GCP accounts) for a nightly or
161 +
  manual test run. It needs network access, so it runs outside the
162 +
  isolated tier, with credentials scoped to a test prefix.
163 +
164 +
## 8. Order of work
165 +
166 +
1. **The `Store` trait in oxy-core**, with the local store moved behind
167 +
   it and no change in behaviour, so every existing test passes.
168 +
2. **S3 and GCS stores together**, with the conformance suite and the
169 +
   emulators.
170 +
3. **`oxy remote add`, `push`, `pull`, `clone` and `init --remote`**
171 +
   for bucket URLs, with the friendly errors and `remote check`.
172 +
4. **`oxy gc` against a bucket**, with the grace period.
173 +
5. **Documentation:** a user guide page, sample IAM and GCS policies,
174 +
   and a README section.
175 +
176 +
Later, on the same trait:
177 +
- Azure Blob storage;
178 +
- a Git repository used as a plain store, so `oxy push` works to
179 +
  GitHub or GitLab;
180 +
- the hub attaching a repository from a customer's bucket ("bring your
181 +
  own storage", see oxygenhub `docs/scaling.md`);
182 +
- couriers: signed, encrypted bundles shared by email, Slack, Matrix
183 +
  and the like (a separate design);
184 +
- a virtual working copy mounted with FUSE (from OxygenFlow's
185 +
  `flow-casfs`).
186 +
187 +
## 9. Open questions
188 +
189 +
- Should `oxy clone s3://…` set the bucket as `origin`, or prompt for a
190 +
  name? Proposed: `origin`, as for any URL.
191 +
- Does a bucket remote keep its own operation log, or only `refs`?
192 +
  Proposed: only `refs` at first. The local operation log already
193 +
  records what each push did.
194 +
- Should packs be encrypted on the client, so the provider only ever
195 +
  stores ciphertext, beyond the provider's own encryption? Proposed:
196 +
  later, as an option. It does not change the layout.