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. |