OXYGENHUB
◆ v0.4.2 oxygen / docs / spec.md
117.3 KiB · Markdown · last changed author.signer: an agent signs its owner's changes with its own key 1 month ago renderedsource blame history raw

Oxygen (oxy) — Version Control System Specification

StatusDraft 0.8r
Format version1 (changes: 2)
Date2026-09-29
AudienceImplementers, security reviewers, and contributors

The key words MUST, MUST NOT, SHOULD, SHOULD NOT, and MAY are to be interpreted as described in RFC 2119.


Table of contents

  1. Overview
  2. Goals, non-goals, and lineage
  3. Threat model
  4. Terminology
  5. Object model and hashing
  6. The change graph
  7. Files, trees, and atoms
  8. Dependencies and commutation
  9. Conflicts
  10. States, bookmarks, and tags
  11. Checkpoints
  12. Operation log, workspaces, and the working copy
  13. Storage engine
  14. Identity, signatures, and trust policy
  15. Transport
  16. Wire protocol
  17. Partial, sparse, and shallow repositories
  18. Locks for unmergeable files
  19. Repository configuration files
  20. Hooks and server policy
  21. Garbage collection
  22. Client profiles and retro hardware
  23. Git interoperability
  24. Command-line interface
  25. Performance targets
  26. Conformance and test vectors
  27. Open questions

1. Overview

Oxygen (oxy) is an open-source, distributed version control system written in Rust. It is built for multi-gigabyte repositories, monorepos, and high-frequency development loops, and it defines a reduced client profile so that older and low-specification machines, which current version control systems exclude, can still take part.

Its distinguishing design choices are:

  • History is a set of changes, not a sequence of snapshots. A change records what it did to identified lines and tree nodes, and which earlier changes it depends on. Changes that do not depend on each other commute: applying them in any order yields the same result.
  • Every change has two identifiers. A random, stable change ID that survives rewriting, and a content-addressed change hash that makes history tamper-evident. Neither is optional.
  • Conflicts are data, not errors. A conflicted state can be committed, pushed, fetched, and built on. Resolution is itself a change.
  • Every mutation of repository state is logged and undoable through an operation log.
  • Content is stored in a content-defined-chunked, BLAKE3-addressed store, so large files and large histories deduplicate naturally and can be fetched lazily.
  • Every change and every reference update is authenticated with composite Ed25519 + ML-DSA-65 signatures, either on the change itself or on a push-time seal covering it, and verification is driven by an in-repository trust policy.
  • Transport is pluggable. HTTPS is mandatory for every server, Noise is available for peer-to-peer use, and a plaintext transport exists for constrained clients on trusted networks, with integrity provided end to end by the object model rather than by the channel.

2. Goals, non-goals, and lineage

2.1 Goals

  • Correct, deterministic merges of concurrent work without merge commits for independent changes.
  • Stable identifiers for human and tooling use across rebase, amend, and reorder.
  • Tamper-evident history and verifiable authorship, robust against future large quantum computers.
  • Repositories of tens of gigabytes with millions of files, with status and commit latency independent of repository size.
  • Partial clone by path, depth, and blob size as a first-class mode, not an afterthought.
  • Interoperability with Git repositories in both directions.
  • A documented Thin client profile for machines with a 68000-class CPU and 1 MiB of RAM.

2.2 Non-goals

  • Running on machines with less than 512 KiB of RAM.
  • Replacing code review, CI, or issue tracking.
  • Semantic (language-aware) merging. Merging is line-based for text and replace-based for binary.
  • Confidentiality of repository contents at rest in version 1 (see §27).

2.3 Lineage

The change model is the patch theory of Darcs and Pijul. The stable-ID-plus-hash split, the first-class working-copy revision, and the operation log come from Jujutsu. The chunked content store follows bup, restic, and Xet. The signed reference model follows The Update Framework (TUF) and gittuf. Oxygen's contribution is combining these with a partial-fetch-first protocol, composite post-quantum signatures, and a constrained-client profile in a single coherent design.

3. Threat model

3.1 Adversaries

AdversaryCapabilities
Network attackerObserve, modify, drop, delay, and replay traffic between client and server.
Malicious or compromised serverServe arbitrary objects and references, withhold objects, and serve stale references.
Malicious collaboratorHold a valid signing key authorized for some references and attempt to exceed that authorization, forge others' authorship, or introduce colliding identifiers.
Compromised client hostFull control of one contributor's machine, including their private keys.

3.2 Security goals

  • G1 Integrity. A client that has verified a state's heads can detect any modification to any object reachable from them.
  • G2 Authenticity. Every change and every reference update carries a signature verifiable against an identity, and policy decides whether that identity is authorized.
  • G3 Freshness. A client can detect when a server presents a reference older than one it has already seen.
  • G4 Non-downgrade. An attacker cannot strip the post-quantum half of a composite signature and have it verify.
  • G5 Availability under adversarial input. Pathological file contents cannot cause super-linear chunking, hashing, or graph work.

3.3 Explicitly out of scope

  • Confidentiality against a malicious server (repository contents are visible to whoever hosts them).
  • Recovery from a compromised signing key beyond revocation and policy update.
  • Protection against a compromised client host altering its own working copy.

Every mechanism in §§5, 14, 15, and 16 is annotated with the goal it serves.

4. Terminology

TermMeaning
ObjectAn immutable, content-addressed byte string with a type.
ObjectRefThe 32-byte BLAKE3 hash of an object, including its domain-separation header.
ChangeThe unit of history: tree operations, edits, dependencies, and metadata.
ChangeIdA 128-bit random identifier that is stable across rewrites of a change.
ChangeHashThe ObjectRef of a change. Changes with different content have different hashes.
AtomAn identified unit of file content: one line of a text file.
AtomId(ChangeHash of introducing change, u32 index).
NodeA file, directory, or symlink, identified by a 128-bit random NodeId stable across renames.
PlacementThe (parent NodeId, name) pair that positions a node in the tree.
StateA dependency-closed set of changes. Fully determined by its maximal elements (heads), and named by a state object that lists them.
BookmarkA named, mutable pointer to a state. Equivalent to a Git branch.
TagA named, immutable, signed pointer to a state.
CheckpointA materialized snapshot of a state, used as a cache.
OperationA recorded mutation of local repository state (bookmarks, workspaces).
WorkspaceA checkout directory associated with a repository.
AttestationA signed statement about a reference, tag, batch of changes, or key.
IdentityAn object holding a contributor's public keys and metadata.

5. Object model and hashing

5.1 Encoding

All objects are encoded as deterministic CBOR (RFC 8949 §4.2.1): definite lengths, shortest integer encodings, map keys sorted bytewise, no floating-point values, no indefinite-length items. Map keys are small unsigned integers; the tables in this document give the key number in brackets, e.g. deps [2].

Every object begins with the field version [0], an unsigned integer. This document defines version 1. Readers MUST reject objects whose version they do not understand.

5.2 Hashing (G1)

ObjectRef = BLAKE3-256( header || body )
header    = "oxy1 " || type-name || " " || decimal(len(body)) || 0x00

type-name is one of chunk, blob, change, checkpoint, op, attest, identity, policy, state. The header prevents an object of one type from being presented as another. BLAKE3 with a 256-bit output retains 128-bit collision resistance against quantum attackers.

The all-zero ObjectRef is reserved and means "self" inside a change (§7.4).

5.3 Object types

TypePurposeSection
chunkRaw bytes, at most 1 MiB.§13.2
blobOrdered list of chunk refs plus total length; represents file content.§13.2
changeA history entry.§6
checkpointMaterialized state.§11
opOperation log entry.§12
attestA signed statement.§14.4
identityA contributor's keys.§14.2
policyThe repository's trust policy.§14.5
stateA sorted list of heads naming a state.§10.1
receiptWhat a runner ran and its result.§14.6

5.4 Signatures are outside the hash

Signatures never appear inside a hashed object. A signed object is transported and stored as:

Signed = { object [0]: bytes, sigs [1]: [Signature] }

Adding a signature, re-signing, or attaching a seal (§14.4) therefore never changes an ObjectRef.

5.5 Encoding conventions

The field tables in this document give each map key in brackets. Where a structure is a choice between variants, or a compact reference, the following encodings apply:

ItemEncoding
TreeOpMap with key 0 = variant tag: 0 AddNode, 1 RemoveNode, 2 Move, 3 SetMode; key 1 = node id; AddNode adds 2 kind, 3 mode, 4 parent, 5 name; RemoveNode adds 2 placement ref; Move adds 2 old placement ref, 4 parent, 5 name; SetMode adds 2 old mode ref, 3 mode.
EditMap with key 0 = variant tag: 0 Insert, 1 Delete, 2 Replace; key 1 = node id; Insert adds 2 after, 3 before, and either 4 lines (array of byte strings) or, in a version 2 change, 5 text (the text blob's ObjectRef) and 6 count (the number of lines); Delete adds 2 atom ranges; Replace adds 2 old (bytes or null), 3 new (bytes or null).
NodeKindUnsigned integer: 0 file, 1 dir, 2 symlink, 3 subrepo (§7.8).
Subrepo pinThe opaque content of a subrepo node: a CBOR map {0: 1, 1: kind, 2: locator, 3: pin, 4: bookmark} where kind is 0 for an Oxygen repository (locator = repository id bytes(16), pin = a state object's heads as [ChangeHash]) or 1 for a Git repository (locator = null, pin = commit sha as text); bookmark is the branch or bookmark name the pin was taken from.
ModeUnsigned integer bit field: bit 0 executable, bit 1 opaque content mode. Directories carry 0.
OpRef (placement or mode reference)[bytes(32) change hash, uint index]; the all-zero hash means this change.
AtomRef0 for START, 1 for END, otherwise [bytes(32) change hash, uint index] with the all-zero hash meaning this change.
AtomRange[bytes(32) change hash, uint from, uint to], half-open, non-empty.
View (in Op)Key 0 bookmarks: array of [text name, bytes(32) state ref] sorted by name; key 1 workspaces: array of [bytes(16) id, bytes(32) change] sorted by id; key 2 remotes: array of [text remote, [[text bookmark, bytes(32) state ref]]]; key 3 policy ref or null; key 4 tags: array of [text name, bytes(32) state ref]. Repository format 1 stored [name, [heads]]; format 2 stores the state ref.
SignatureMap: 0 key id, 1 alg (1 = Ed25519 + ML-DSA-65), 2 Ed25519 bytes(64), 3 ML-DSA-65 bytes(3309). A signature list is a CBOR array of these.
Secret key fileMap: 0 version, 1 key id, 2 Ed25519 seed bytes(32), 3 ML-DSA-65 seed bytes(32). Kept only in the user's key directory.

Atom indices count the atoms one change introduces for one node, in edit order, across all of that change's Insert edits on the node.

6. The change graph

6.1 Change object

Change {
  version   [0]: 1, 2 or 3
  change_id [1]: bytes(16)            // random, CSPRNG; see §6.3
  deps      [2]: [ChangeHash]         // sorted, unique; see §8
  author    [3]: KeyId                // ObjectRef of an identity object
  hlc       [4]: { wall_ms: u64, logical: u32 }
  message   [5]: text
  tree_ops  [6]: [TreeOp]             // §7.2
  edits     [7]: [Edit]               // §7.4
  kind      [8]: 0 = normal, 1 = resolution, 2 = working-copy
  extra     [9]: map                  // reserved; MUST be empty
}

Writers produce version 3. The versions differ only in how an Insert holds its lines (§7.4): a version 1 change carries every inserted line inline; versions 2 and 3 store each large insert as a text blob beside it, so no change grows with the size of the files it adds, and differ in what the text blob holds. Readers MUST accept all three. A change keeps the version it was made in: rewriting its encoding would change its hash and invalidate its signatures.

6.2 Two identifiers

  • ChangeHash is the integrity handle (G1). All references between objects use it. Dependencies, bookmark heads, tags, and checkpoints reference changes by hash only.
  • ChangeId is the stable handle. It is what users type, what tooling tracks, and what survives amend, rebase, and reorder. It carries no integrity meaning and is never used alone as a reference in any object.

A change may be rewritten: a new change object is created with the same ChangeId and a different hash. Changes that depended on the old hash are orphaned and must be rewritten in turn (§8.4). A repository may hold several changes with the same ChangeId; when more than one is reachable from the same state the ChangeId is divergent, and commands MUST report divergence rather than pick silently.

6.3 ChangeId generation

ChangeIds MUST be produced by a cryptographically secure random generator with 128 bits of entropy on Full and Lite profiles. On the Thin profile they are generated by the proxy (§22). Two changes with equal ChangeId and unequal hash are not an error; they are divergence (§6.2). An implementation MUST NOT infer anything about content from a ChangeId.

6.4 Timestamps

hlc is a hybrid logical clock: wall_ms is the author's wall clock in milliseconds since the Unix epoch, and logical is a counter. On observing a change with a later HLC, a client advances its own clock per the Kulkarni et al. HLC rules. wall_ms is untrusted metadata and MUST NOT be used for ordering, security, or freshness decisions. Sorting for display uses (hlc, ChangeHash).

Drift bound. A change with a far-future wall_ms must not be able to drag every peer's clock forward. On observing a change, a client MUST NOT advance its own HLC more than core.hlc-max-drift (default 5 minutes) ahead of its local wall clock; the change itself is still accepted and stored, so whether a state is well-formed never depends on the observer's clock. A server MAY reject a push containing a change whose wall_ms is more than core.hlc-max-drift ahead of the server clock, since the author is present and can correct their clock. logical is a u32; a client whose counter would overflow MUST refuse to create the change and report clock skew.

7. Files, trees, and atoms

7.1 Tree model

The repository tree is a set of nodes. Each node has a NodeId, a kind (file, dir, symlink, subrepo, §7.8), a mode, and zero or more placements. The mode of a file is its executable bit and its content mode, text or opaque (§7.5); a symlink's content is its target path and its content mode is always opaque; a directory has no mode. The root directory has the fixed NodeId of 16 zero bytes and no placement.

A placement is (parent NodeId, name). Names are byte strings that MUST be valid UTF-8, MUST NOT be empty, ., or .., and MUST NOT contain / or NUL. Placements that a particular file system cannot represent distinctly, such as names differing only by case, are workspace conflicts (§9.7), not errors in the state.

Renames and moves are placement changes, so rename tracking is exact once recorded. A working copy has no way to report a move, so when a snapshot finds a removed file and an added file of the same kind whose contents are at least 50% alike (the share of the larger file's bytes found in lines both have; opaque files must be identical), it records a Move of the existing node rather than a removal and an addition. Beyond 10,000 candidate pairs only identical contents are paired. The same detection applies to Git import (§23).

7.2 Tree operations

Placements are atoms in the sense of §7.3: each is introduced by exactly one change and may be deleted by later changes. Because a placement is defined relative to the parent's NodeId rather than a path, moving a directory moves its whole subtree with a single placement change; no operations are generated for descendants. The cost of this is that two concurrent moves can create a cycle, handled in §9.2.

TreeOp = AddNode  { node: NodeId, kind, mode, place: (parent, name) }   // introduces node + placement
       | RemoveNode { node: NodeId, placement: PlacementRef }            // deletes the placement; node dead when no live placements
       | Move     { node: NodeId, old: PlacementRef, place: (parent, name) } // deletes old placement, introduces new one; implicitly moves all descendants
       | SetMode  { node: NodeId, old: ModeRef, mode }                  // mode is an atom too
PlacementRef = (ChangeHash, u32 index into that change's tree_ops)

A node is live in a state when it has at least one live placement and its parent is live. A node with more than one live placement, or two live nodes with the same placement, is a conflict (§9.2).

7.3 Atoms and the file graph

The content of a text file is a directed graph whose vertices are atoms. Each atom is one line, including its terminator (bytes up to and including \n, or to end of file). Edges express "immediately after". An atom is introduced by exactly one change and has an AtomId = (ChangeHash, index), where index counts atoms introduced by that change for that node, starting at 0.

Every file graph contains two virtual atoms, START and END. An atom is live unless a change in the state deletes it. Deleted atoms remain in the graph as tombstones; they may still be referenced as anchors.

The graph of a file in a state is the union of the atoms and edges introduced by every change in the state, minus nothing: deletion sets a flag, it does not remove vertices. The graph is a function of the set of changes alone and not of the order they were applied. Implementations MUST preserve this invariant; the conformance suite (§26) tests it by applying random permutations.

7.4 Edits

Edit = Insert  { node: NodeId, after: AtomRef, before: AtomRef, lines: [bytes] | text: ObjectRef, count: u32, cbor?: 1 }
     | Delete  { node: NodeId, atoms: [AtomRange] }
     | Replace { node: NodeId, old: ObjectRef | null, new: ObjectRef }     // opaque nodes only
AtomRef   = START | END | AtomId
AtomRange = { change: ChangeHash, from: u32, to: u32 }   // half-open
  • Insert introduces len(lines) new atoms, indexed consecutively, and edges after → first, last → before. after and before MUST be adjacent in the author's view at authoring time; the pair is the insert's anchor.
  • Atoms introduced by the same change are referenced with the all-zero "self" hash. This is how a change can insert a line and then insert another after it without knowing its own hash.
  • Text blobs. In a version 2 or 3 change, an insert whose lines encode as a canonical CBOR array of byte strings to at least 4096 bytes MUST be stored as text and count: text is a blob (§13.2) holding the lines, and count is the number of lines, at least 1. In version 2 the blob's content is exactly that CBOR array. In version 3 it is the lines' bytes, joined, when splitting those bytes by the line rule (every line but the last ends in a newline and holds no other; §7.3) gives the lines back, as every insert a snapshot makes does; otherwise it is the CBOR array and the insert carries cbor: 1 (key 7). A reader rejects a version 3 insert marked cbor whose lines the line rule would split back. The joined form stores a file's text as its bytes, so a text blob shares chunks with any blob of the same content and carries no per-line framing (a tenth of a tree's compressed text). A shorter insert MUST be inline. The rule is deterministic, so a change has one encoding and one hash, and identical inserts share their text blob. A reader rejects an inline insert that should have been stored, a text blob shorter than 4096 bytes, and a text blob whose lines do not number count. A text blob and its chunks belong to their change: they are carried with it by every transfer (§16.3), kept with it by garbage collection (§21), and checked with it by fsck. A change cannot be read without them, so they are never withheld from a partial clone (§13.5).
  • Replace is the only edit for opaque nodes. old is the blob currently expected; two concurrent Replace operations with the same old and different new conflict (§9.3).

7.5 Opaque nodes

A node's content mode is opaque if its content is not line-mergeable. A node MUST be opaque if it matches a binary pattern in .oxyattributes, if it contains a NUL byte in its first 8 KiB, if it exceeds core.max-text-size (default 4 MiB), or if it is a symlink. Symlink targets are stored as small blobs and edited only by Replace, bypassing the line graph. Opaque content is stored as a blob and versioned by Replace.

Content mode is part of the node's mode atom, so a switch is a SetMode. A change switching a node to opaque MUST also Delete every live atom of the node and Replace with old = null; a change switching to text MUST also Replace with new = null and Insert the content between START and END. Concurrent edits made under the other content mode then conflict through the mode atom (§9.2) rather than silently combining.

7.6 Line endings and encoding

Oxygen stores bytes verbatim and never rewrites line endings unless .oxyattributes requests eol=lf or eol=crlf, in which case normalization happens on snapshot and denormalization on checkout, exactly as Git does with text eol=. Implementations MUST NOT guess.

7.7 Checkout safety

Checkout writes only inside the workspace directory, whatever the tree contains.

  • Checkout MUST create a symlink without following it and MUST NOT write any file through a path that contains a symlink, whether that symlink came from the repository or already existed in the workspace. Implementations SHOULD open parent directories with O_NOFOLLOW or the platform equivalent and write relative to that handle.
  • A placement named .oxy under the root, or any placement that resolves to a path inside .oxy/, MUST be refused at snapshot and at checkout.
  • .oxyignore and .oxyattributes files that are symlinks MUST be treated as absent, with a warning from oxy status.
  • On file systems without symlink support, a symlink node is written as a plain file containing the target path, and oxy status reports it; snapshot MUST NOT convert such a file back into a content change.
  • On file systems without executable bits, snapshot keeps each file's recorded executable bit instead of reading it as cleared.
  • Paths that a case-insensitive file system (macOS and Windows by default) cannot hold side by side (equal but for case, including a directory's case), and names Windows forbids (the reserved device names such as CON and AUX with any extension, the characters <>:"\|?* and control characters, a trailing dot or space), are reported by checkout on such a platform, and by oxy doctor (§21.2) on every platform so they can be renamed before anyone meets them.

7.8 Subrepositories

A subrepo node embeds another repository at a path, as Git's submodules do. Its content is a pin: which repository, and which state of it (§5.5, "Subrepo pin"). The pin is stored and versioned exactly like an opaque file, as a blob replaced by Replace edits, so two changes that move the same subrepo to different states produce an ordinary replace conflict (§9.3) that oxy status lists and a resolution settles. A subrepo has no placements of its own inside the parent beyond its one entry; its files belong to the nested repository and are never atoms of the parent.

Two pin kinds exist. An Oxygen pin names a repository id and a state (a sorted set of heads), and is verifiable: the nested repository's changes are fetched from its own remotes and checked under its own policy. A Git pin names a commit hash and is what oxy git import produces for a Git submodule (§23); it is checked out with Git and never converted silently.

The mapping from path to remote lives in .oxymodules at the repository root, a tracked file of [path] sections with url and optional bookmark (Oxygen) or branch (Git) keys, edited by oxy submodule add|remove and readable by hand. A pin without a .oxymodules entry is checked out only when the nested repository is already present.

In the working copy a subrepo is a directory holding its own .oxy/ (or .git/). The snapshot MUST NOT descend into it; it reads the base of the nested repository's working copy (its bookmark's heads unless a parent checked out an older pin; or Git HEAD) as the pin, so committing the parent records whatever the nested repository is at, as in Git. A nested repository that is neither registered in .oxymodules nor tracked as a subrepo node is left alone entirely: not walked, not added. A tracked subrepo whose directory is absent keeps its recorded pin, as a Git gitlink does. oxy checkout of the parent MUST move an existing nested repository to the pinned state (oxy checkout of those heads, or git checkout --detach <sha>), fetching from the recorded remote when objects are missing. It moves the nested repository's bookmark only forward: when the bookmark has changes the pin lacks, the bookmark stays where it is and the checkout says so, as Git leaves a submodule's branch alone. A checkout that carries work (--carry) leaves a subrepo whose pin is part of that work as it is. It MUST leave an absent nested repository absent until oxy submodule update --init clones it, and MUST never delete a nested repository, even when the target state has no node for it. A nested working copy with uncommitted changes is never overwritten: the checkout reports it and continues.

oxy submodule add URL PATH [--bookmark NAME] clones URL into PATH (an Oxygen remote when URL answers as one, otherwise Git), records .oxymodules, and the next snapshot adds the node pinned at the clone's heads. oxy submodule update [--init] [PATH..] brings nested repositories to their pins. oxy submodule status lists each subrepo with its pin and whether the nested repository matches it; one registered but not committed yet is listed as added, with the pin the next commit records (committed: false in --json). oxy submodule remove PATH drops the .oxymodules entry and records that the node is to go with the next snapshot; the directory is left in place and thereafter ignored as unregistered.

8. Dependencies and commutation

8.1 Definition

The scope of a change C is the set of atoms and placements it references: every atom it deletes, every atom used as an anchor, every placement it deletes or moves, and every node it operates on.

deps(C) MUST contain the ChangeHash of the introducing change of every element of scope(C), excluding C itself, START, END, and the root node. It MUST also contain, for every atom C deletes, the introducing change of every atom live directly after it in the author's state, since without it the deletion reads as concurrent with those insertions and materializes as a delete/insert conflict (§9.1); and, for every Replace whose old is a blob, the change(s) whose replace record made that blob current.

Explicit (semantic) dependencies. Changes that share no atoms commute even when they are semantically coupled, for example an API change in one file and the update of its caller in another. To prevent such changes from being separated, deps(C) MAY contain additional hashes that the author or tooling chose to require. oxy commit --depends-on <change> adds one; the argument is resolved to a ChangeHash as in §24, and a divergent ChangeId is an error. Implementations SHOULD support the semantic-deps hook (§20) so that build tools or language servers can inject cross-file dependencies at commit time.

An explicit dependency is a hard dependency with every consequence of §8.2 and §8.4: the change no longer commutes with its target, and rewriting the target orphans it. Tooling that injects dependencies MUST inject the minimal set and MUST be enabled per repository by the user, because injecting dependencies liberally turns history back into a linear sequence and forfeits the model's benefits. deps(C) MUST NOT be transitively reduced by the writer; readers MAY reduce it.

A change with kind = resolution MUST additionally depend on every change that contributed an atom or placement to the conflict it resolves.

8.2 Commutation

Two changes A and B commute if neither is in the transitive dependency closure of the other. By §7.3 applying {A, B} in either order produces the same graph, so no rewriting, re-hashing, or merge change is needed to reorder them. Commutation is a consequence of the model, not a separate rule to implement; the implementer's obligation is the invariant in §7.3.

8.3 Well-formedness

A state S is well-formed iff for every C ∈ S, deps(C) ⊆ S. Every reference to a state (bookmark, tag, checkpoint) names it by its heads, and the state is the dependency closure of the heads. Implementations MUST refuse to construct a state that is not well-formed and MUST refuse to store a change whose dependencies are unavailable unless the repository is shallow (§17).

8.4 Rewriting

Rewriting a change C to C' (amend, rebase, split, squash) produces a new object with the same ChangeId. Amending an older change with the working copy's edits (oxy amend --into C) is allowed when every atom, placement, and node those edits reference exists in C's closure; the edits are appended to C with references to C turned into self references, so C's own atoms keep their indices and dependents are rewritten with an identity correspondence. Splitting a head (oxy split C PATH..) rebuilds it as a first change holding its effect on the named paths and a second holding the rest under the original ChangeId. Any change D with hash(C) ∈ deps(D) is orphaned relative to C'. oxy MUST automatically rewrite orphaned descendants by translating their AtomRefs and PlacementRefs from C to C' where a correspondence exists. Where none exists the reference is reified by re-anchoring: an insert anchored on a vanished atom is anchored instead on the nearest surviving atom of the same insert chain of C (the preceding one for after, the following one for before), or on that chain's own anchors when none survives, so that an order conflict (§9.1) arises exactly when C' placed other content there; a deletion of a vanished atom is dropped; an operation or edit on a node that vanished with C is dropped. Each adjustment MUST be reported to the user. Removing C altogether rewires each dependent to depend on deps(C) and reifies references into C the same way. Changes that do not depend on C are untouched, and a rewritten change by another author is stored without that author's signature, which only they can renew. Rewriting is recorded in the operation log (§12) and is undoable.

8.5 Folding and re-basing

Changes that commute never need re-basing to be combined, so oxy rebase and oxy squash are history-editing tools, not merge tools.

Squash. oxy squash C --into D folds change C into D, where D is a dependency of C and every atom, placement, and mode that C refers to in D is one D introduces or one both already know. The result D' carries D's operations followed by C's, with C's references to D turned into self-references, keeps D's ChangeId, and is signed afresh by its author; C is removed from the state and every change that depended on C or D is rewritten with the correspondence of §8.4. A C that depends on changes D does not depend on cannot be squashed into D; the command names them. oxy amend --into is the special case where C is the working copy.

Rebase. oxy rebase C --onto HEADS re-expresses C against a different base: the state HEADS names is materialized with and without C added to it (commutation makes the union meaningful, and HEADS must contain C's dependencies), the difference is re-derived as edits against the HEADS state, and the new change keeps C's ChangeId with dependencies computed from what it now references (plus HEADS as explicit dependencies). This is the move a Git user makes with rebase --onto: it changes what a change is said to build on. It is refused when HEADS conflicts in any file C touches, since the re-derivation would have no single text to diff against, and when the result would be empty. With --into-working-copy, a rebase that would be conflicted instead merges the HEADS state into the current bookmark, which must hold C: a working copy without changes follows, so the conflict is fixed there and committed as a resolution on top, as after oxy merge (§10.1). Dependents of C are rewritten per §8.4. Because commuting changes already coexist, the common reason to rebase is to drop an accidental dependency, for instance on a change that is to be abandoned.

9. Conflicts

9.1 Text conflicts

The live atoms of a file graph form a partial order. The file is conflicted if that order is not total. Two shapes arise:

  • Order conflict. Two or more chains of live atoms are incomparable (concurrent inserts at the same anchor, or concurrent edits of overlapping regions). The conflict region is the minimal interval of the graph containing all incomparable chains.
  • Delete/insert conflict. An atom x is deleted by change D, and a live atom y is anchored after x by a change I, where D and I commute. The inserted content has no live neighbour to attach to.

9.2 Tree conflicts

  • Name conflict. Two live nodes share a placement.
  • Multi-placement. One node has two live placements (concurrent renames).
  • Orphan. A live node's parent directory is not live (concurrent delete of a directory and modification within it).
  • Cycle. A directory is placed inside its own descendant, which arises when two commuting changes each move a directory under the other. Both placements are live and the tree is conflicted. For materialization the placement whose introducing change has the greater (hlc, ChangeHash) is treated as absent; resolution deletes one of the two placements.
  • Kind conflict. Concurrent AddNode of the same placement with different kinds is a name conflict; concurrent SetMode with different modes is an order conflict on the mode atom.

9.3 Opaque conflicts

Two live Replace edits with the same old and different new are a replace conflict. The state exposes both candidate blobs.

Which blobs are live: a replace record is superseded by a later replacement of its blob, a record whose old is its new and whose change depends on its change (§8.1 makes every replacement depend on the change that made its old current). Replacements that do not know each other both stay live, and a file reverted to an earlier blob keeps it. A replace conflict is resolved by a kind = resolution change with one Replace from each live blob to the chosen one, depending on every change that made a candidate live, so each is superseded and one blob remains. A working-copy snapshot never settles a replace conflict on its own (a checkout writes one candidate at the path); a tool that knows the user's choice, such as a forge's resolver, builds the resolution. A replacement recorded without that dependency (before Draft 0.8i) supersedes every record with its new that does not come after it, the rule until then.

9.4 Properties

  • A conflicted state is well-formed and valid. It MAY be committed, pushed, fetched, tagged, and built upon.
  • Conflicts nest and compose automatically because they are graph shapes, not special objects. A resolution that is itself concurrent with another resolution yields a new order conflict between the two resolutions.
  • Servers MAY refuse conflicted heads on protected bookmarks through policy (§20). Clients MUST be able to push conflicted heads to unprotected bookmarks.

9.5 Materialization in the working copy

Conflicted text files are written with markers that identify the conflict and each side so that tools can parse them unambiguously:

<<<<<<< oxy conflict 1 of 2, side 1: change k7m2p9 "Add retry loop"
... lines ...
======= oxy conflict 1 of 2, side 2: change q3x8w1 "Refactor client"
... lines ...
>>>>>>> oxy conflict 1 of 2 end

The short identifiers are ChangeId prefixes. Delete/insert conflicts render the deleted context as a ------- section. Tree conflicts are materialized by writing each candidate with a suffix .oxy-conflict-<n> and are listed by oxy status. Replace conflicts materialize as <name>.oxy-conflict-<n> files.

oxy status MUST list every conflict, MUST exit with status 2 when any conflict exists in the working-copy state, MUST exit with status 3 when only workspace conflicts (§9.7) exist, and MUST support --json output enumerating conflicts by node, kind, and contributing ChangeIds. The JSON object has bookmark, working_copy (ChangeId), heads, clean, conflicted, paths ([{path, status}]) and conflicts: for a text or replace conflict {kind, path, node, changes: [ChangeId], resolved: true | false | "markers"}, for a tree conflict {kind: name | multi-placement | orphan | cycle | mode, nodes, path?}.

9.6 Resolution

A resolution is a change with kind = resolution whose deletions and insertions leave the affected graphs totally ordered and the tree conflict-free. oxy resolve computes it from the edited working copy by parsing the markers; a resolution MAY also be authored by any tool that can produce a change. Because a resolution depends on all contributing changes (§8.1), any peer that fetches it necessarily has the conflict it resolves.

9.7 Workspace conflicts

A state can be conflict-free and still not be representable on a particular file system. These are workspace conflicts: the state is valid, peers on other file systems see nothing, and no resolution change is involved.

  • Two live placements in one directory whose names are equal under the file system's case folding (Foo.c and foo.c on default Windows and macOS volumes).
  • Two live placements whose names are equal after the file system's Unicode normalization (NFC and NFD forms of the same name on macOS).
  • A name the file system reserves or alters: Windows device names such as CON and NUL, names ending in a dot or a space, and names containing : or other characters the platform forbids.
  • A path longer than the platform limit.

Each colliding node MUST be materialized with a .oxy-conflict-<n> suffix so that no content is lost, and oxy status MUST list the conflict with its kind. A workspace conflict is cleared by any ordinary change that renames one of the nodes; it is never counted by allow_conflicts (§14.5) or by server rules that forbid conflicted heads (§20).

10. States, bookmarks, and tags

10.1 References

References live in a namespace:

bookmarks/<name>                 local bookmarks
remotes/<remote>/bookmarks/<name>  last-seen remote bookmarks
tags/<name>                      tags (immutable once created)
policy                           the trust policy head (§14.5)

<name> is UTF-8, 1–255 bytes, without control characters, leading -, or the sequences .., //, @{.

A bookmark or tag points at a state object:

State { version [0]: 1, heads [1]: [ChangeHash] }   // sorted, unique; may be empty

A repository holds any number of bookmarks. Each workspace has a current bookmark (oxy bookmark switch NAME, recorded per workspace) that its working copy sits on and that commit, log, push, and pull act on by default. Switching refuses uncommitted work unless told what to do with it: --discard drops it, and --carry takes it along when every path it touches is the same in NAME's state as in the working copy's base (so the same edits apply there), refusing and naming the paths otherwise. oxy bookmark create feat && oxy bookmark switch feat --carry moves work started on one bookmark onto a new one. oxy bookmark create NAME [CHANGE..] names a new state (default: the current working copy's base heads); given CHANGE.., it refuses when the current state has changes recorded before the latest of them that they do not contain, since a state holds its heads' dependency closure and not everything recorded before them, unless --upto (the current state's changes up to CHANGE.., as Git ancestry would have it) or --only (exactly CHANGE.. and their dependencies) says which is meant, and bookmark switch reports how many changes the new state lacks of the old; set NAME CHANGE.. moves one, delete NAME removes it (its changes stay until unreachable, §21), list shows all with their heads and the remote-tracking difference, and oxy merge NAME unions the named bookmark's heads into the current one, which is the whole of merging in this model: no merge change, conflicts reified (§9); a working copy without changes follows the merge, whichever state it sat on. oxy checkout NAME materializes a bookmark without switching to it; with uncommitted work it refuses, naming each changed path and how it changed (a removed path is in the working copy's base but not on disk), unless --discard or --carry (as for switching) says what to do with the work. Bookmark names, then tag names, are resolved before change prefixes wherever a change is expected (a bookmark or tag names a change when its state has one head). For builds, oxy describe without -m names the working copy as git describe does: its base state's tag (v1.2), else the nearest tag whose state the base contains with the number of changes it adds and the newest head's ChangeId (v1.2-3-8c5ea12a), else the bookmark and that ChangeId (develop-8c5ea12a), with -dirty when the working copy has uncommitted changes; --json gives the parts.

Every reference, attestation, view, and checkpoint therefore names a state by one ObjectRef however many heads it has. Multiple heads are normal in this model: every independent change is a head until something depends on it, so a busy bookmark may have dozens. That is not a conflict in the §9 sense; oxy log shows the heads as parallel, and a state object keeps the cost of naming them constant for everything that refers to the bookmark. The empty state, State { heads: [] }, is what a new bookmark points at.

10.2 Reference attestations (G2, G3)

Every published bookmark update is accompanied by an attestation (§14.4) of kind ref that binds the repository ID, the reference name, the previous state, the new state, and a per-reference monotonically increasing serial. Servers MUST store the latest attestation per reference and serve it with the reference. Clients MUST verify the attestation against policy before updating a remotes/ reference and MUST reject an attestation whose serial is lower than one they have already recorded for that reference (rollback protection). Clients SHOULD warn when the newest attestation's hlc.wall_ms is older than remote.max-age (default 30 days) and the remote claims no update.

Tags are attestations of kind tag and are never updated; a server MUST reject a second attestation for an existing tag name, and any receiver MUST refuse a tag whose name it already holds with a different state. oxy tag create NAME [CHANGE..] tags the named state (default the bookmark's heads); tags travel with refs and push (§16.3), and a policy rule for tags/<name> governs who may create them.

11. Checkpoints

Materializing a state by replaying every change is linear in history length. Checkpoints make checkout proportional to state size instead.

Checkpoint {
  version [0]: 1
  heads   [1]: [ChangeHash]        // sorted
  count   [2]: u64                 // changes in the state
  payload [3]: ObjectRef           // blob holding the serialized state
}

The payload blob is a deterministic CBOR map {0: version, 1: changes, 2: tree, 3: files, 4: replaces}:

  • changes: for every change in the state, [hash, change id, [deps], message, hlc wall, hlc logical, kind, author], sorted by hash. Operations are not repeated: once applied they live in the graphs, and the store keeps the change objects.
  • tree: the tree graph, {0: nodes [[node, kind, introducer]], 1: placements [[change, index, node, parent, name]], 2: retired placements [[change, index, [deleters]]], 3: modes [[change, index, node, mode]], 4: retired modes [[change, index, [deleters]]]}.
  • files: [[node, file graph]] where a file graph is {0: atoms [[change, index, content or null, [deleters]]], 1: edges [[from, to]]} with vertices encoded as in §5.5 (0 start, 1 end, [hash, index]). Tombstones keep their content so conflicts can show deleted context.
  • replaces: [[node, [[change, old blob or null, new blob or null]]]].

Payload version 2 has the same map with version 2, differing only in files: [[node, graph or ref]], where a file graph whose encoding (as above) is shorter than 2048 bytes appears inline and a longer one is replaced by the ObjectRef (a byte string) of a blob holding exactly that encoding. Graph blobs are content addressed, so a file unchanged between two checkpoints shares its blob, and a reader can load only the graphs it needs. The rule is deterministic, so version 2 checkpoints are as reproducible as version 1. Writers produce version 2; readers MUST accept both. The checkpoint object is unchanged, and whatever carries a checkpoint (garbage-collection roots, the checkpoint fetch of §16.1) carries its graph blobs and their chunks with it. A version 2 payload's graph blobs are part of the checkpoint: one missing makes the checkpoint unusable, and a reader reports it rather than dropping the file.

A checkpoint is valid only if it is bit-for-bit reproducible from its heads. Implementations MUST create one at least every core.checkpoint-interval changes (default 256) along each bookmark and MAY discard any checkpoint at any time; keeping only the newest per bookmark is enough. To load a state from heads H, a client walks from H and stops at any change the checkpoint contains; the checkpoint applies iff every one of its heads was reached, since a state is closed downward, and the walk yields exactly the changes to replay on top. Clients fetching a state SHOULD request the nearest checkpoint and replay only the difference.

12. Operation log, workspaces, and the working copy

12.1 Operations

Every command that mutates local repository state (bookmarks, workspaces, the working-copy change, fetched remote references) records an operation:

Op {
  version [0]: 1
  parents [1]: [ObjectRef]            // usually one; two when merging concurrent operations
  hlc     [2]: HLC
  command [3]: text                   // e.g. "oxy rebase -s k7m2p9 -d main"
  view    [4]: { bookmarks: map<name, state ObjectRef>, remotes: map, tags: map, workspaces: map<WorkspaceId, ChangeHash> }
}

view is the complete state of all references and workspaces after the operation. The operation log is a DAG. The head (.oxy/ops/head) moves by compare-and-swap under a short lock (.oxy/ops/lock), held only while the pointer is written: a process that finds the head is no longer the parent it built on records a merge operation with both as parents, whose view is the three-way merge of the two views against that parent. A reference changed on one side takes that side's value; changed differently on both, a bookmark keeps both sides' heads (it then has several), a change beats a deletion, a tag keeps its first value, remote references and the policy take the later process's value, and a workspace changed by both is an error: the later operation is not recorded, since the files on disk match only one of the two working-copy changes. oxy undo refuses when the head moved while it ran.

oxy undo moves the current operation to its parent's view. oxy redo and oxy op log expose the DAG. Operations are retained for core.op-retention (default 90 days) and then eligible for garbage collection.

Because all mutation goes through the operation log and object writes are idempotent, no long-held repository lock is needed: the operation head's lock covers one pointer write. Each workspace has a working-copy lock (.oxy/workspaces/<id>/lock), held by every command that reads or writes the working copy and replaces its working-copy change (snapshot, checkout, bookmark switch, merge, commit, amend and rewrites, discard, pull), from reading the operation head until its operation is recorded. A snapshot therefore never sees a half-written checkout, and never records one as edits. The lock is re-entrant within a process thread; a waiting command gives up after a minute and says why; a lock whose process is gone is taken over, and where that cannot be told, one older than a minute.

12.2 Workspaces

A repository has one or more workspaces, each with a WorkspaceId (128-bit random) and a checkout directory. Each workspace has exactly one working-copy change at any time. The primary workspace is the directory holding .oxy/; a secondary workspace holds a .oxy file instead, oxydir: <path> and workspace: <id>, pointing at the shared repository, and the repository records each workspace's directory under .oxy/workspaces/<id>/. oxy workspace add PATH [CHANGE..] creates one, list shows them, and forget drops one from the view without touching its files. Workspaces do not nest. A bare repository (oxy init --bare, marker .oxy/bare) has no workspace and no working-copy change; it holds objects, the operation log and references only, for hosting. Working-copy commands fail on it with a clear message; every state-based query works.

12.3 The working-copy change

The working copy is a real change with kind = working-copy. Before running any command that reads repository state, oxy snapshots the working copy: it diffs the file system against the working-copy change's materialized state and rewrites the working-copy change to include the difference. The change therefore always describes the working directory. To prevent operation log bloat, a command that snapshots the working copy MUST NOT record a new Operation (§12.1) if the filesystem diff was empty (i.e., the working-copy change's hash did not change).

  • The working-copy change is unsigned and never pushed while kind = working-copy.
  • oxy commit (-m MSG, or -F FILE with - for standard input) sets the message, sets kind = normal, and starts a fresh working-copy change on top of it. The change is signed immediately if core.sign = "change"; with the default core.sign = "seal" it is left unsigned locally and covered by a seal at push time (§14.4).
  • oxy new, oxy edit <change>, and oxy abandon move which change the workspace edits.
  • There is no staging area and no stash. A partial commit is a split of the working copy: oxy commit PATH.. records only the named paths (and every edit of theirs) and leaves the rest as uncommitted work; oxy commit --interactive offers each hunk (one Insert or Delete edit on one file, or one tree operation) for inclusion, like git add -p; oxy split does the same to an existing change. A hunk that another selected hunk refers to is selected with it, so the committed change is always well-formed (§8.3).

12.4 Snapshot performance

Implementations MUST support a stat-based crawl with a persistent cache keyed by at least (path, size, mtime) and SHOULD support a file-system monitor (Watchman or a native inotify/FSEvents/ReadDirectoryChangesW watcher) for the latency targets in §25.

The reference implementation's cache is the workspace manifest written after every snapshot, commit, and checkout: per file its size, mtime, executable bit, content hash, and, for an opaque file, the ObjectRef of its stored blob. A text file found byte for byte equal to the base state's unconflicted rendering also records its graph key: a hash of the file's node, the checkpoint graph its graph was built from (a blob ref, an inline value, or none), and the set of later changes that edited it. Equal keys mean equal graphs and so equal renderings, since a rendering depends only on the graph and the changes' immutable metadata. On the next snapshot a file whose size and mtime match is not read: an opaque file contributes its recorded blob, and a text file whose recorded graph key equals the key of its base graph now is known equal to the base, so it is neither read nor rendered (a stat-clean file that differs from the base, such as an older uncommitted edit, is always read). Attributes decide how a file compares with the base, so .oxyattributes files are always read, and when any of them is added, removed, edited, or changed in the base, no text file is skipped. A file whose mtime lies within two seconds of the manifest's own write time is never trusted (the racy-stat window), so a modification that preserves size and lands in the same timestamp tick is still seen. An implementation MAY use a file-system monitor instead of, or in addition to, the manifest.

A commit records in the manifest the graph key of every text file of its new base (except files with an eol attribute, whose bytes on disk are not the rendering), from a history-only load that builds no graph, so the first snapshot after it reads and renders no unchanged file. A history-only load reads no stored insert's text blob (§7.4) until it builds that file's graph. oxy status answers from the manifest alone, loading no history, when the manifest records no uncommitted work and no conflict, every entry is older than the racy window, the repository has no subrepos, and every file matches its entry (size, mtime or content, executable bit) with none added or removed; anything else takes a snapshot.

13. Storage engine

13.1 Layout

.oxy/
  format              "oxy repository format 2\n"
  config              repository-local configuration (TOML)
  objects/
    pack-<id>.pack    append-only pack file
    pack-<id>.idx     sorted (ObjectRef → offset, length, type) index
    loose/            objects written since the last pack, as header || body
    sigs/             signature lists keyed by the ObjectRef they cover (§5.4)
  ops/                operation objects and op heads
  refs/               current view cache (derived from ops; rebuildable)
  workspaces/<id>/    snapshot cache, fsmonitor cookie
  policy/             cached policy and identities

Object writes go to a temporary file, are fsynced, and are renamed into place. Pack files are fsynced before their index is written. An interrupted write can never corrupt an existing object. A reader MUST validate an index before trusting it: entries sorted and unique, each offset + length inside the pack, and lengths below an implementation limit (1 GiB), so a damaged index can neither drive out-of-bounds reads nor huge allocations; every object read is re-hashed regardless. The refs cache is derivable from the operation log and is rebuilt if missing.

13.2 Chunking and blobs (G5)

File content is split with FastCDC (Xia et al., 2016) using a 32 KiB minimum, 64 KiB target, and 256 KiB maximum chunk size, with normalized chunking level 2 and cut-point skipping from the minimum size. Files of 32 KiB or less are a single chunk, and the final chunk of a file may be shorter than the minimum. The minimum and maximum bounds guarantee that adversarial content produces at most len / 32 KiB chunks and at least len / 256 KiB chunks, bounding both index growth and dedup loss.

So that every implementation produces identical boundaries, the parameters are fixed:

  • Gear table. GEAR[i] for i in 0..256 is the little-endian u64 at bytes 8i..8i+8 of the BLAKE3 extendable output of the ASCII string oxy1 fastcdc gear.
  • Rolling hash. h = (h << 1) + GEAR[byte] in 64-bit wrapping arithmetic, starting from zero at the minimum size.
  • Masks. A mask with k one-bits has them at bit positions 63 - floor(i * 64 / k) for i in 0..k. The small mask has 18 one-bits (used until the target size) and the large mask has 14 (used until the maximum). A cut occurs at the first position where h & mask == 0.
Blob { version [0]: 1, length [1]: u64, chunks [2]: [ObjectRef] }

Chunks and blobs are the objects that carry content. Other objects are small in practice; a change holds its tree operations and inserts of under 4096 bytes, with larger inserts stored as text blobs (§7.4). No object may be larger than 1 GiB after encoding: a writer MUST refuse to store one and nothing of it is written.

13.3 Compression

Each object in a pack is individually compressed with zstd (level 3 by default). Objects whose compressed size is not smaller are stored raw, with a flag. Chunks matching a nocompress attribute pattern (already compressed media) are stored raw.

13.4 Packing

An automatic trigger when the loose object count exceeds 4096 writes loose objects into a new pack and merges packs when there are more than 16; oxy gc repacks every surviving object into one pack, compressed harder (zstd level 9, against level 3 for everyday writes) and on every core, as git gc repacks. Writers SHOULD batch: a snapshot, a fetched bundle, a Git import, or a checkpoint is one write batch, and a batch that grows beyond a small threshold (256 objects or 8 MiB) is written as a single pack with one fsync rather than as loose objects, each with its own; smaller batches stay loose so ordinary commits do not multiply packs. Objects of an open batch MUST be readable by the writer before the batch completes. Pack indexes MUST support lookup by ObjectRef prefix and MUST be memory-mappable. A repository with two million objects MUST be openable with under 64 MiB of resident index memory on the Full profile.

The pack and index formats are fixed so that tools can read them without the implementation:

pack:  "OXYPACK1" || entry*
entry: type u8 || flags u8 || stored_len u32le || raw_len u32le || ref bytes(32) || body[stored_len]
index: "OXYIDX01" || count u32le || ( ref bytes(32) || offset u64le || stored_len u32le || raw_len u32le || type u8 || flags u8 )*

type indexes the object types in the order of §5.3 (chunk 0, blob 1, change 2, checkpoint 3, op 4, attest 5, identity 6, policy 7, state 8). Flag bit 0 means the body is zstd-compressed and raw_len is its decompressed size; otherwise the body is stored raw and the two lengths are equal. offset is the position of the body, not of the entry. Index entries are sorted by ref. A reader MUST re-hash every object it reads from a pack and reject a mismatch.

13.5 Lazy objects

A blob or chunk may be absent in a partial repository (§17). After a fetch with a blob filter, the repository records every blob its new changes reference but did not receive, together with the remote that withheld it (.oxy/lazy/<ref>), and fetches it through objects on first read, dropping the record once stored. Changes and checkpoints are never lazy: a repository that has a change has all of its inline data.

13.6 Durability

Every update that must survive a crash replaces a file atomically: write a uniquely named temporary file in the same directory, fsync it, rename it over the target, and fsync the directory. Loose objects and packs are written the same way, a pack before its index. The operation head (.oxy/ops/head) is the commit point of every command: before it moves, every object the new operation names MUST be durable, including objects still held by an open write batch (§13.4), which is flushed first. A crash therefore leaves the repository at the operation before or after the interrupted command, never between; stray temporary files may remain and are ignored. The reference implementation's tests stop commit and push at each durable step and require both repositories to open, pass oxy fsck (§21.1) and accept the same command again.

14. Identity, signatures, and trust policy

14.1 Algorithms

PurposeAlgorithm
HashBLAKE3-256
Signature (composite)Ed25519 and ML-DSA-65 (FIPS 204)
Transport AEADChaCha20-Poly1305
Transport key agreementX25519, with ML-KEM-768 hybrid where the Noise pattern supports it
RandomOS CSPRNG

ML-DSA-65 is chosen over ML-DSA-44 for NIST security category 3, at the cost of signatures of 3309 bytes.

14.2 Identity object

Identity {
  version    [0]: 1
  ed25519_pk [1]: bytes(32)
  mldsa65_pk [2]: bytes(1952)
  name       [3]: text
  email      [4]: text
  created    [5]: u64 (wall ms)
  expires    [6]: u64 | null
  previous   [7]: KeyId | null        // rotation chain
  delegates  [8]: [Delegate]          // keys allowed to act on this identity's behalf
}
Delegate {
  key     [0]: KeyId
  scope   [1]: ["changes" | "refs" | "seal"]   // what it may do, in that order
  expires [2]: u64 | null                       // wall ms; null for as long as it is listed
}
KeyId = ObjectRef of the Identity

The two public keys live in one object, so a KeyId names the pair. delegates lists keys that may act for this identity, each within a scope: changes lets the key sign changes whose author is this identity, seal lets it seal them, and refs lets it attest references and tags as this identity would (§14.5). A proxy acting for a Thin client (§22) or a host that commits browser edits for a user gets changes,refs; an AI agent gets changes alone, so its signature authenticates the changes it makes but it can never move a reference, whatever its owner may do. A delegation with expires authenticates nothing whose hlc.wall_ms is later than that, so an agent can be let in for a session without changing the owner's own expiry. Delegation is one level deep and is not transitive. A bare KeyId in delegates, as identities carried before scopes existed, means every scope with no expiry. Rotation is a new identity with previous set, plus an attestation of kind rotate signed by the previous key. Revocation is an attestation of kind revoke signed by the revoked key, by the identity that delegated to it, or by a policy root key (§14.5); the delegator's revocation marks the changes the key already signed as valid but revoked, and the delegator then re-issues their identity without it.

Lifecycle rules. Because the KeyId is the hash of the whole object, any change to an identity, including its delegates or expires, is a rotation: a successor object with previous set. A successor MAY keep the same key pair (oxy identity renew, oxy identity delegate add|remove) or carry a fresh one (oxy identity rotate); either way earlier signatures remain verifiable under the earlier object, which stays stored. A rotation supersedes the previous KeyId: a policy that named it MUST be updated to name the successor (§14.5), and a rule key covers every predecessor in its previous chain that is not revoked, so changes signed before a rotation remain authorized. Revocation is for compromised or abandoned keys: a revoked key authorizes nothing, whatever its chain. expires bounds the changes a key can authenticate: a signature or seal over a change whose hlc.wall_ms is later than the identity's expires is invalid. A client records the revocations it makes or verifies under .oxy/revoked/ and reports signatures by such keys as valid but revoked.

14.3 Composite signature (G2, G4)

Signature {
  key  [0]: KeyId
  alg  [1]: 1                          // ed25519+mldsa65
  ed   [2]: bytes(64)
  ml   [3]: bytes(3309)
}
message = "oxy1 sig " || domain || 0x00 || KeyId || ObjectRef-being-signed
domain  = "change" | "attest" | "policy"

Both signatures are computed over the same message, which includes the KeyId of the composite pair. Verification MUST require both signatures to verify with the keys of the identity named by key. A signature with either half missing, empty, or failing is invalid. This is the same construction as the IETF LAMPS composite signature drafts and prevents stripping the post-quantum half.

Profiles that cannot compute ML-DSA (§22) do not sign; they produce changes that a proxy signs.

Challenges. A host that must know a client holds a key (linking a key to an account, signing in to a server) sends at least 16 random bytes; the client returns a composite signature in domain challenge over BLAKE3-256("oxy1 challenge " || bytes), which can never equal a stored object's reference, so a challenge signature authenticates nothing but the challenge. oxy identity sign-challenge HEX prints it.

14.4 Attestations

Attest {
  version [0]: 1
  kind    [1]: "ref" | "tag" | "seal" | "rotate" | "revoke" | "lock" | "receipt"
  repo    [2]: bytes(16)               // repository ID, chosen at init
  hlc     [3]: HLC
  body    [4]: map                     // per kind
}
ref:    { name, old_state: ObjectRef, new_state: ObjectRef, serial: u64 }
tag:    { name, state: ObjectRef, message }
seal:   { hashes: [ChangeHash] }        // sorted, unique, 1..4096 entries
rotate: { new: KeyId }
revoke: { key: KeyId, reason }
lock:   { node: NodeId, path, acquire: bool, expires }
receipt: { receipt: ObjectRef, state: ObjectRef, run: text }   // §14.6

A seal is one signature over a batch of change hashes. A composite signature is 3373 bytes, which is larger than a typical change, so signing every change individually roughly doubles the size of history in high-frequency workflows. A seal replaces that with one signature per push.

Cadence. With the default core.sign = "seal", changes are left unsigned locally and oxy push creates one seal covering every change in the push that carries no individual signature, immediately before the reference attestation. Seals are created only at push: a seal made earlier would be invalidated by any amend, since rewriting produces a new hash. With core.sign = "change", every change is signed at commit and no seal is needed. Policy (§14.5) decides which the receiving repository accepts.

Verification. A sealed change C is authenticated iff a seal S exists such that hash(C) ∈ S.body.hashes, S carries a valid signature from key K, and K equals C.author or is listed in C.author's identity as a delegate with the seal scope, not expired at C.hlc.wall_ms (§14.2). The same author-or-delegate rule applies to individual change signatures, with the changes scope. This rule is what stops a collaborator from sealing a change whose author field names someone else (§3.1). A change whose author is the zero KeyId names nobody (history imported from Git, §21, or made before an identity existed), so a seal from any key authenticates it: the sealer adopts it, and policy authorizes the sealer for the reference as usual. A push seals the pusher's own unauthenticated changes and adopts anonymous ones in the same seal. A seal's signer must additionally be authorized by policy for the reference being pushed.

Handling. A seal is stored and transported with the changes it covers: fetch (§16.1) streams the seals covering every change it returns, push (§16.4) MUST include a seal or signature for every change, and a seal covering any reachable change is itself reachable (§21). Seals from different keys MAY overlap; a change needs at least one valid authenticator, and policy thresholds count distinct authenticating keys.

14.5 Trust policy (G2)

The policy reference points to a signed Policy object:

Policy {
  version   [0]: 1
  repo      [1]: bytes(16)
  serial    [2]: u64
  roots     [3]: { keys: [KeyId], threshold: u32 }
  rules     [4]: [ { pattern: glob over reference names, keys: [KeyId], threshold: u32,
                     require: "signed" | "sealed" | "any" /* default */, allow_conflicts: bool } ]
  identities[5]: [Identity]           // embedded so verification needs no fetch
  revoked   [6]: [KeyId]
  expires   [7]: u64
}
  • The initial policy is signed by the root keys; every update MUST be signed by roots.threshold root keys of the policy it replaces and have a higher serial. An update that a single holder cannot complete is a proposal: the signed policy ([policy bytes, [Signature]], §16.3) is passed to other root holders, each of whom adds a signature with oxy policy sign; whoever completes the threshold applies it. Rotating a root key (§14.2) substitutes the successor in the roots and rules; such a policy is signed by both the previous and the successor key so a fresh clone can bootstrap from it.
  • A change's signer is authorized by a rule if the rule lists it, or lists a successor of it, or lists the change's author (or a successor) when the signer is that author's delegate.
  • A reference attestation's signer is authorized by a rule if the rule lists it, or lists an identity whose delegates name it with the refs scope, not expired at the attestation's hlc.wall_ms (§14.2). Delegation is the identity's own signed statement and the policy names that identity, so a delegate with refs moves references exactly as far as its delegator may; revoking either key, or the delegation expiring, ends it. Hosts use this to land and to commit browser edits on a user's behalf with their own key. A delegate without refs (an agent) is never authorized for a reference or tag, even when its delegator is.
  • A change or attestation is authorized for reference r if its signer is listed in the first rule whose pattern matches r, and the rule's threshold of distinct authorized signers is met across the attestation and each change in the closure of new_state not previously in the closure of old_state. require selects which authenticators count for changes: individual signatures only, seals only, or either. Every change MUST additionally satisfy the author-or-delegate rule of §14.4 regardless of require.
  • Clients MUST verify policy on fetch, MUST refuse to update a remotes/ reference whose attestation is unauthorized, and MUST warn loudly on an expired policy. Servers MUST enforce the same rules on push.
  • A repository without a policy reference is unverified; every command that talks to a remote MUST print a one-line warning until one is created. oxy policy init creates one with the author's key as the sole root; oxy policy grant and oxy policy revoke produce successors.
  • Propagation. The refs response carries the current signed policy together with the earlier policies the server still holds, and a push MAY carry a newer policy with the intermediate ones in its bundle, applied before any bookmark update. A receiver behind by several serials verifies each step against the one before it. A receiver accepts a policy only if it is for the same repository, its serial exceeds the current one, and it is signed by the current policy's root threshold; the first policy a repository ever sees vouches for itself, which means at least one valid signature by one of its own roots (trust on first use).
  • Downgrade protection. A client records the highest policy serial each remote has presented. A remote that later presents none, or a lower serial, is refused. A remote that has never presented a policy is unverified: the client fetches with a warning and cannot verify what it received. Policy signatures use the domain policy (§14.3).
  • Existing history. A policy governs changes added after it. The receiver of a push checks the changes the new state adds beyond the state it already had, so history that predates the policy is not re-verified. A fetching client verifies the bookmark's attestation, which binds the whole state through its head hashes, and does not re-verify individual changes; it therefore trusts what an authorized attester vouched for, which is the point of the policy.

14.6 Execution receipts

A runner (OxygenFlow, or any CI) records each job it ran as a receipt object:

Receipt {
  version         [0]: 1
  job_name        [1]: text
  pipeline_hash   [2]: ObjectRef      // the pipeline definition the job came from
  scope_tree_hash [3]: ObjectRef      // BLAKE3 over the sorted (path, content) of the files the job's scope matches; the whole tree without a scope
  inputs          [4]: [ObjectRef]    // artifacts and other inputs beyond the tree
  environment     [5]: ObjectRef      // what it ran in: isolation tier, rootfs, argv, environment
  outputs         [6]: [ObjectRef]    // blobs the job produced
  exit_code       [7]: u32            // 0 passed
  duration_ms     [8]: u64
}

A receipt names no state: the same job on the same scope in the same environment gives the same receipt, which is what lets a runner reuse it for a later state whose scope is unchanged. An attestation of kind receipt (§14.4) binds a receipt to the state it ran on and names the run (usually its URL). The runner signs that attestation with its own identity (§14.2), an ordinary composite key, in domain attest.

Signed receipt. A runner hands a receipt over as [receipt bytes, attest bytes, [Signature], identity bytes], so it verifies with nothing else: the attestation's receipt is the receipt's ObjectRef, and a signature by the included identity verifies over the attestation. Whether a runner is trusted is the reader's decision (a host lists its runners' keys); a revoked runner key is reported.

Storage. A repository keeps receipts beside the state they name: the receipt, the attestation with its signatures, and the runner's identity, indexed by state ObjectRef. Receipts live as long as the changes of their state, and keep their outputs and inputs alive (§21). oxy receipt list|show|export|verify|import reads and exchanges them; a receipt for another repository is refused.

15. Transport

Every transport carries the same wire protocol (§16). The transport provides the channel; integrity and authenticity of history come from the object model regardless of channel.

15.1 HTTPS (mandatory)

Every server MUST offer HTTPS with TLS 1.3 at https://<host>/<repo>/oxy/v1/. Authentication is by bearer token, mutual TLS, or HTTP signature over a server-issued challenge with the client's identity key. HTTPS is required because it traverses corporate proxies, load balancers, and CDNs; it is the interoperability baseline. A reference server MAY speak plain HTTP behind a TLS-terminating proxy; it MUST NOT be exposed without TLS except on a loopback or otherwise trusted network. Reads MAY be open; a push MUST be authenticated when any credential is configured.

15.2 Noise (optional)

Peer-to-peer and self-hosted deployments MAY use Noise over TCP or QUIC.

  • Pattern Noise_XXhfs_25519+kyber768_ChaChaPoly_BLAKE2s (hybrid post-quantum handshake) for first contact, or IKhfs when the server's static key is known.
  • The client's Noise static key MUST be the X25519 key derived from its Ed25519 identity key (RFC 8032 key clamping via the standard birational map), so the transport identity and the signing identity are the same KeyId.
  • Servers publish their static key fingerprint out of band. Clients pin it on first use and MUST refuse a changed key without explicit oxy remote trust action.

15.3 Zero-Crypto Transport (ZCT, restricted)

ZCT is a plaintext, length-prefixed framing of the wire protocol over TCP or serial, for Thin clients that cannot run ChaCha20 or X25519 at usable speed.

  • ZCT is disabled by default on servers and on clients. A remote MUST be explicitly configured with transport = "zct" and clients MUST print a warning on every use.
  • ZCT provides no confidentiality. Over ZCT the client MUST verify every change signature or seal, every reference attestation, and every ObjectRef it receives; a client incapable of verification (Thin profile without Ed25519) MUST only use ZCT through a trusted proxy on a network it trusts, and this MUST be a documented deployment decision.
  • Push over ZCT is permitted only with a reference attestation signed by an authorized key; there is no other authentication. Servers MAY refuse push over ZCT entirely.
  • ZCT MUST NOT be offered on a public network interface by default.

15.4 Proxy

A proxy is a Full-profile oxy server that fronts one or more remotes for Thin clients, performing verification, chunking, delta computation, and signing on their behalf. Its security properties are those of the Thin client's trust in it.

16. Wire protocol

Messages are deterministic CBOR. Over HTTPS each message is a request body with Content-Type: application/oxy-cbor and responses are CBOR sequences (RFC 8742) with Content-Encoding: zstd when supported. Over Noise and ZCT, messages are framed as u32 big-endian length || bytes.

16.1 Endpoints

EndpointRequestResponse
GET capabilities—{ versions: [1], features }; feature names are seal, and, when offered, lock; max_frame and transports are reserved for framed transports. The repository id is all zero for an unbound hosted repository, one created empty that adopts the id of the first repository pushed to it; a client MUST NOT clone or fetch from one and MAY push to one, sending its id in the Oxy-Repo-Id header, which the server binds before validating the push
GET refs{ patterns }[ { name, state: ObjectRef, heads, attestation: Signed<Attest> } ] plus the current Signed<Policy>
POST negotiate{ have: [ChangeHash], want: [ChangeHash], filter }stream of ChangeHash the client lacks, in dependency order
POST fetch{ changes: [ChangeHash], include_blobs: bool, filter }stream of Signed<Change>, then every Signed<Attest kind=seal> covering them, then referenced blob/chunk objects
POST objects{ refs: [ObjectRef] }stream of objects
GET checkpoint{ heads, filter }nearest Checkpoint and its objects, and the list of changes to replay
POST pushstream of Signed<Change>, objects, seals, then Signed<Attest kind=ref> per reference{ results: [ { name, ok, reason } ] }
POST lockSigned<Attest kind=lock>{ ok, holder, expires }

16.2 Negotiation

The client sends the heads it has for the requested references and the heads it wants. Because dependencies form a DAG and every state is its heads' closure, the server computes closure(want) \ closure(have) by a reverse traversal, stopping at any hash in have. For very large histories a server MAY ask the client for the Merkle root of its known-hash set in 2^16-hash-prefix buckets and skip buckets whose roots match. The response is ordered so that no change precedes any of its dependencies.

16.3 Message encodings

Every message is a deterministic CBOR map. Signatures use the encoding of §5.5; a change or attestation travels as its canonical bytes so the receiver hashes exactly what was signed.

MessageEncoding
Signed attestation[attest bytes, [Signature]]
Bundle{0: [[change bytes, [Signature]]], 1: [signed attestation], 2: [[type name, object bytes]], 3: [signed policy]}; changes in dependency order; objects are chunks, blobs, identities, and states only, and include the text blobs of every change in the bundle and their chunks, so a bundle can be read on its own; key 3 (optional) holds the policies between the receiver's serial and the one sent with a push, ascending
capabilities response{0: [versions], 1: repo id bytes(16), 2: [feature names]}
Signed policy[policy bytes, [Signature]]
refs response{0: [{0: name, 1: state ref, 2: [heads], 3: signed attestation or null, 4: last serial or null}], 1: repo id, 2: signed policy or null, 3: [tag], 4: [signed policy]}; key 4 (optional) lists earlier policies ascending by serial; key 5 (optional, text or null) names the default bookmark, which a clone checks out; the attestation is present only when it describes the current state; a tag is {0: name, 1: state ref, 2: [heads], 3: signed attestation or null}
negotiate request{0: [have], 1: [want]}; response {0: [hashes]} in dependency order
fetch request{0: [hashes], 1: max blob size or null}; response a Bundle without blobs above the size
objects request{0: [refs]}; response a Bundle carrying only objects, a blob accompanied by its chunks
checkpoint request{0: [heads]}; response a Bundle of objects: the checkpoint of exactly those heads, its payload blob, and the blob's chunks
shallow request{0: [heads], 1: depth}; response {0: [hashes]}, the changes within depth steps of the heads, dependencies first
push request{0: Bundle, 1: [{0: name, 1: old state, 2: new state, 3: force, 4: signed attestation or null}], 2: signed policy or null, 3: [tag]}; response {0: [{0: name, 1: ok, 2: reason}]}, where a result named policy reports the policy update and tags/<name> each tag

The HTTP content type is application/oxy-cbor.

16.4 Push validation

On push, the server MUST, in order: verify that every change has a valid signature or seal from a key embedded in policy and satisfies the author-or-delegate rule (§14.4), verify the attestation for each reference, check that old_state matches its current state (compare-and-swap), check policy rules for the reference including allow_conflicts, check that every dependency of every change is either included or already stored, and only then update references atomically. A failure rejects the whole push. A refusal's reason names the reference and, for a failure of the server's own storage, the step that failed; the reference server also logs every refusal and every failed request with its repository and operation, and answers a storage failure with 500 rather than 400.

Deleting a reference. An update whose new_state is the zero ObjectRef deletes the bookmark (oxy push REMOTE --delete NAME). The bookmark MUST exist at old_state. A server MUST refuse to delete its default bookmark: in a bare repository that is the host's to name (the reference server's is the repository's configured bookmark, a forge's its default-bookmark setting), and in a non-bare repository it is the checked-out bookmark. The update is attested like any other: under a policy the attestation is required and its key MUST be authorized for the reference. The deletion's attestation stays the reference's latest, so an older attestation cannot be replayed to bring the bookmark back. The first attestation of a reference the remote does not list takes the client's clock in milliseconds as its serial, which is past the serial of any deleted namesake; later ones increment it. Changes of a deleted bookmark stay until unreachable (§21).

17. Partial, sparse, and shallow repositories

A repository records a filter in .oxy/config:

[filter]
paths    = ["src/", "docs/"]      # sparse: only these subtrees are materialized and blobs fetched
max-blob = "1MiB"                 # blobs larger than this are lazy
depth    = 100                    # shallow: history beyond this many changes from heads is not stored
  • Sparse repositories hold the full change graph but only materialize the listed paths: the working copy contains nothing else, files outside the filter are neither reported nor removed, and changes touching other paths are stored and applied to the graph. A sparse clone sets max-blob to zero unless a size is given, so blobs outside the filter are fetched only if the filter later widens (§13.5). oxy sparse PATH.. changes the filter of a clean working copy and re-checks it out.
  • Lazy blobs are fetched via objects on first read. oxy status and oxy commit MUST NOT need them.
  • Shallow repositories hold a checkpoint of their bookmark's heads (obtained with the checkpoint message) and the changes within depth dependency steps of those heads (the shallow message lists them). The shallow boundary is the set of dependencies of stored changes that are not stored (.oxy/shallow). Dependencies into the boundary are accepted as well-formed, negotiation stops at the boundary (the remote is assumed to hold it), and the log shows every change from the checkpoint's metadata. oxy deepen N fetches N more levels below the boundary; when nothing remains the repository is no longer shallow.
  • Thin clients (§22) always use depth = 0: a checkpoint plus zero history.

Servers MUST honour filter on negotiate, fetch, and checkpoint.

18. Locks for unmergeable files

Opaque files (models, textures, binary documents) cannot be merged. Paths matching a lockable attribute in .oxyattributes are read-only in the working copy until locked.

  • oxy lock <path> sends a lock attestation to the remote configured as lock-server. The server grants it if unlocked or expired, and records the holder.
  • A push that replaces a lockable node's blob MUST be rejected by the server unless the pushing key holds the lock.
  • Locks expire (default 7 days) and can be broken by a policy root key.
  • Locks are advisory in repositories with no lock-server.

19. Repository configuration files

FilePurpose
.oxyignoreIgnore rules. Gitignore syntax and semantics, per directory, plus .oxy/info/ignore and the global ignore file. A directory's .gitignore and .ignore files are read too, with lower precedence than its .oxyignore (a ! rule there re-includes what they exclude), so a project coming from Git leaves out what Git did. Rules apply to untracked paths only: a tracked path stays tracked, and its edits are seen, until it is deleted or removed from the state.
.oxyattributesPer-path attributes: binary (force opaque), text (text unless NUL bytes), `eol=lf
.oxy/configRepository-local settings, TOML.
~/.config/oxy/config.tomlUser settings, the same keys as .oxy/config: author.name, author.email, author.key, author.signer (the key that signs and seals in place of author.key, which must list it as a delegate: an agent's own key; also $OXY_SIGNER), core.sign, and remote.<name>.token; also auth.helper. Overridden by $OXY_HOME/config.toml when $OXY_HOME is set.
~/.config/oxy/credentials.tomlTokens from oxy auth login, one [scheme://host[:port]] section per hub with token, account and ca. Written owner-only; ignored when others can read it. With auth.helper set, the token is kept by that helper (Git's credential-helper protocol, user name oxy-token) and the section holds the rest.

Settings resolve in order: the repository file overrides the user file, which overrides environment variables (OXY_AUTHOR_NAME, OXY_AUTHOR_EMAIL, OXY_TOKEN) and built-in defaults. A remote's token and CA without remote.<name>.token or .ca come from OXY_TOKEN and OXY_CA, then from credentials.toml for the remote's hub, then from a remote on the same hub in an enclosing repository's .oxy/config. A client MUST NOT send a token over plain http except to a loopback address, and MUST NOT forward it across a redirect. oxy config writes the repository file; oxy config --user writes the user file. Secret keys live beside the user file in keys/, one <KeyId>.secret and one <KeyId>.identity per key, so any repository the user works in can store the identity object when it first signs.

Filters (filter=<name>) are clean/smudge programs configured in user or repo config, as in Git. Implementations MUST refuse to run filters defined in .oxy/config of a freshly cloned repository until the user approves them.

20. Hooks and server policy

Client hooks live in .oxy/hooks/ and are never cloned. Events: pre-commit, semantic-deps, post-commit, pre-push, post-fetch, post-checkout. A hook is an executable named after its event, run in the workspace root with OXY_EVENT and OXY_ROOT set; it receives one JSON object on stdin and blocks the operation by non-zero exit for pre-* events, whose stderr becomes the error message. Payloads: pre-commit {event, message, paths: [{path, status}]}; post-commit {event, change_id, hash, message}; pre-push {event, remote, bookmark, heads}; post-fetch {event, remote, bookmarks: [{name, heads}]}; post-checkout {event, heads, written, removed}. Hashes and ids are hex strings.

The semantic-deps hook runs during oxy commit after the working copy is snapshotted and before the change is finalized. It receives { change_id, touched_paths, candidate_deps: [ { change_id, hash, paths } ] }, where candidate_deps are the changes the new change already references plus the heads it sits on, each with the paths it touched, and it returns { deps: [hash hex] } to add to deps(C); hashes not in the base state are ignored. It runs only when hooks.semantic-deps = true is set in .oxy/config, which is never set by clone.

Server policy is the trust policy (§14.5) plus server configuration for per-reference rules: protected patterns that additionally require CI status, forbid conflicted heads, forbid multiple heads, or forbid force (non-fast-forward) updates. A force update is a ref attestation whose old_state heads are not all in the closure of new_state.

21. Garbage collection

An object is reachable if it is referenced, directly or transitively, from any bookmark, tag, policy, the current operation heads, any operation newer than core.op-retention, any workspace's working-copy change, or any checkpoint of a reachable state. oxy gc deletes unreachable objects that are older than core.gc-grace (default 14 days, to protect concurrent writers) and repacks. Tombstone atoms are never collected independently of their change. Divergent changes are reachable if any of their variants is. A change's text blobs and their chunks are reachable with it (§7.4). A seal is reachable if any change it covers is reachable, and reference attestations are reachable while they are the latest for their reference or are within core.op-retention.

21.1 Integrity check

oxy fsck reads everything and changes nothing. It checks (1) every stored object: header, length, ObjectRef, and that the body decodes as its type; (2) the operation log: every operation reachable from the head through all parents is present and decodes, and no operation's clock is earlier than its parent's (a warning); (3) the current view's bookmarks, tags, remote bookmarks, workspaces and policy, and the dependency closure of every change they name, with each change's author identity, text blobs, blobs and chunks, and each committed change's signature or seal (§14.4); (4) that every bookmark forms a valid state, that every checkpoint restores exactly the state of the changes it names (§11), and that reference and tag attestations carry valid signatures. A change beyond the shallow boundary (§17) may be absent; a blob absent from a partial clone (filter.max-blob or a sparse filter) is a warning, not an error. Each problem names the object it concerns. The exit status is 1 when any error is found and 0 otherwise; --json gives the counts and problems.

21.2 Doctor

oxy doctor checks what commonly stands between a user and a working repository and says, for each problem, what to do next: the operation log opens and no lock was left by a dead process; the signing identity exists, its secret key file is present, and it has not expired (a warning within 30 days); the author name and email are not the defaults; and for each remote, that plain http:// is only used on loopback, a token is set, the CA file exists, and (unless --offline) the remote answers and is this repository or an unbound one. Failures of remote requests anywhere in oxy carry the same next step (an untrusted certificate, a rejected or insufficient token, no repository at the URL, nothing listening, an unknown host, a timeout). The exit status is 1 when a check fails; --json gives the checks.

22. Client profiles and retro hardware

FullLiteThin
Minimum hardware1 GiB RAM, current desktop or server16 MiB RAM (486, 68030, ARMv5, RISC-V RV32)68000 @ 8 MHz, 1 MiB RAM, block storage (Amiga 500+, Atari ST, Macintosh Plus/SE, early 386 DOS)
Local historyFull or partialPartial (shallow + lazy)None: one checkpoint
Change graph operationsYesYesNo
BLAKE3 verificationYesYesYes (chunks and objects received; ~150 KiB/s on 68000)
Ed25519 verificationYesYesMAY (~2 s per signature on 68000; used for attestations only)
ML-DSA-65Sign + verifyVerify; sign MAYNo
Chunking, delta computation, signingLocalLocalProxy
TransportsHTTPS, Noise, ZCTHTTPS, Noise, ZCTZCT to proxy; serial or TCP
Working copyFullSparseSparse subtree, whole-file edits

A Thin client fetches a checkpoint for a sparse subtree, verifies each object's ObjectRef, and writes files. To commit, it sends the full content of modified files with their previous blob refs to the proxy; the proxy computes edits, assigns ChangeIds, signs or seals with its own key, which the user has listed in delegates on their identity (§14.2), and pushes. The threat model for Thin clients is therefore "the proxy is trusted", and oxy documentation MUST state this without qualification.

Machines under 512 KiB of RAM are out of scope; a serial terminal to a Lite or Full machine is the recommended path.

23. Git interoperability

oxy git import <repo> and oxy git export <repo> provide a lossless-enough bridge; oxy git fetch/push operate against a Git remote directly.

  • Import. Each Git commit becomes one change whose edits are the diff between the union of its parents' states and its tree, so a merge commit becomes a resolution exactly when the parents conflict in the change model, and an ordinary change otherwise. The commit's author and hash are recorded as Git-Author and Git-Commit trailers; the author key is the zero KeyId, since nobody can sign on the original author's behalf. Submodule entries are skipped. Renames are detected on import exactly as for a snapshot (§7.1, similarity ≥ 50%), then tracked exactly. The Git commit's Change-Id trailer, if present and 128 bits, becomes the ChangeId; otherwise one is derived as BLAKE3-128 of the Git commit hash, so re-import is idempotent. New files' and directories' NodeIds are derived too, as BLAKE3-128 of the commit hash, the node kind and the path, so importing the same history twice, anywhere, gives the same changes. Note that a change's own closure is what it depends on, not the Git snapshot: the imported bookmark's state equals the Git tree, but checking out an individual imported change yields only the files it and its dependencies touch.
  • Directories. Git gives a directory no identity: its path is all it is. An import therefore never makes a second node for a directory path: when a commit needs a directory where the parent's state has none, and the parent's history has a directory last placed at that (parent, name) and now placed nowhere, the change brings that node back with a Move from its last placement (the newest by operation key when there are several). Otherwise lines of history that placed files in the directory before it was deleted and after it came back would place them in two different directories, a conflict Git never had. Likewise a node the commit keeps is in exactly the one place its tree names: any other live placement it has in the parents' union (one left under a directory another line removed is out of sight, and no diff of the tree would reach it) is retired by the commit's change, and so is every live placement of a node the union cannot show at all (an orphan, whose only placements are under removed directories): Git's tree has no path for it.
  • Export. Each state along a bookmark becomes a Git commit whose tree is the materialized state. The ChangeId is written as a Change-Id: trailer and the ChangeHash as Oxy-Hash:. Conflicted states are exported with §9.5 markers and an Oxy-Conflicted: true trailer. Signatures are not exported; Git commits MAY be additionally signed with the Ed25519 key via SSH signature format.
  • Fetch and push against a Git remote. oxy git fetch URL keeps a bare mirror under .oxy/git/ for the remote, fetches the branch into it, and imports the commits the bookmark does not have yet, seeding the import with the commits already known: an imported commit maps to its change, and a commit this repository exported maps to the bookmark state it was made from (so a Git commit on top of exported work is based on everything that work contained, including changes Git never saw). The bookmark moves to the union of its heads and the new changes, like a pull; the working copy follows when it was clean. A fetched state whose previous state was itself a Git tree is remembered as equal to the fetched tip. oxy git push URL refuses until the remote's tip is known, exports the bookmark's later states as commits chained onto that tip, and pushes the branch, so the push is always a fast-forward.
  • Submodules. A Git gitlink entry (mode 160000) imports as a subrepo node with a Git pin (§7.8) and the matching .gitmodules entry becomes a .oxymodules entry with branch set to the submodule's configured branch when there is one. Export writes a Git pin back as a gitlink, keeps a tracked .gitmodules as is, and otherwise derives .gitmodules from .oxymodules; an Oxygen pin has no Git form and is omitted from the exported tree, which the export report counts.
  • Merges of opaque files. When the union of a merge commit's parents holds several live blobs for a file or submodule, the merge's change replaces each of them with the commit's blob and is a resolution: Git's merge is the authority, unlike a working copy, where a candidate on disk is not yet a decision.
  • Round trip. Import followed by export MUST reproduce every tree hash. Export followed by import MUST reproduce every ChangeId. oxy git verify GIT_DIR checks an import commit by commit: for each commit, the state formed by the changes imported for it and all its ancestors must materialize to exactly that commit's tree (content, executable bits, symlink targets, submodule pins; .oxymodules is allowed beside submodules). --last N rebuilds only the last N commits, following the others, to look again at where an import went wrong.

24. Command-line interface

Commands mirror Jujutsu where semantics match, so that muscle memory transfers.

oxy init [PATH] [--bare]            oxy clone URL [--sparse PATH].. [--depth N] [--max-blob SIZE]
oxy status [--json]                 oxy diff                     oxy prompt [--format F]
oxy commit -m MSG [--depends-on CHANGE]..
oxy log [-r REVSET] [-n N] [-v] [-L S,E:PATH]   oxy show CHANGE | -r REVSET
oxy describe [CHANGE] [-m MSG]      oxy amend [-m MSG] [--into CHANGE]
oxy split CHANGE [PATH]..           oxy abandon CHANGE | -r REVSET [--yes]   oxy resolve
oxy checkout [CHANGE].. [--discard|--carry] oxy undo                     oxy op log
oxy tag create NAME [CHANGE].. [-m MSG]   oxy tag list
oxy bookmark create|set|delete|list|switch  oxy merge BOOKMARK
oxy commit [PATH].. [--interactive]         oxy squash CHANGE --into DEST   oxy rebase CHANGE --onto HEADS.. [--into-working-copy]
oxy submodule add|update|status|remove      oxy blame PATH               oxy bisect start|good|bad|run|reset
oxy workspace add|list|forget       oxy sparse [PATH]..          oxy deepen [N]
oxy fetch [REMOTE]                  oxy pull [REMOTE]            oxy push [REMOTE] [--force]
oxy remote add|remove|list          oxy serve                    oxy lock|unlock PATH
oxy identity create|show|export|rotate|renew|revoke|delegate|sign-challenge
oxy policy init|show|grant|revoke|root|sign                      oxy verify [CHANGE | -r REVSET]
oxy seal [CHANGE].. | --proposal URL  seal your unauthenticated and anonymous changes (§14.4), or a hub proposal's changes as your approval
oxy proposal show|land URL          hub proposals from a client (hub spec §7)
oxy git import|export|fetch|push|verify   (fetch and push go through a bare mirror under .oxy/git/)
oxy checkpoint                      oxy gc [--now]               oxy config [--user] KEY VALUE
oxy root                            oxy cat PATH [-r CHANGE]     oxy version
oxy fsck [--json]                   check integrity (§21.1)
oxy doctor [--offline] [--json]     check identity, config and remotes (§21.2)
oxy completions SHELL               shell completion script     oxy man [--dir DIR]   man pages
oxy hunks                           oxy discard PATH.. | --hunks N,M

Machine interface. status, log (with --path), show, diff, hunks, blame, resolve, op log, bookmark list, remote list, fetch, pull, push, submodule status, bisect status, verify, policy show, root and version take --json and then print exactly one JSON document on stdout, with ids as hex strings and times in milliseconds since the epoch; errors go to stderr with a non-zero status. commit --hunks N,M and discard --hunks N,M take hunk numbers as hunks prints them. Editors and other tools MUST use these rather than reading .oxy/.

Change arguments accept a ChangeId prefix, a ChangeHash prefix (with #), a bookmark or tag name, and @ for the working-copy change. Ambiguous prefixes are an error.

Reserved for a later version, with these names: oxy new, oxy edit, oxy redo, revsets (log -r), and oxy remote trust (with the Noise transport, §15).

25. Performance targets

Measured on the Full profile, warm cache, NVMe storage, 8-core x86-64, with a 20 GiB repository of 2 million files and 500 000 changes.

OperationTarget
oxy status (no changes, fsmonitor)< 50 ms
oxy status (no changes, stat crawl)< 2 s
oxy commit touching 10 files< 100 ms
oxy log -r main first page< 100 ms
Checkout between adjacent checkpointsproportional to changed bytes, ≥ 500 MiB/s
Full clone over 1 Gbit/sline rate; chunk verification ≥ 1 GiB/s per core
Sparse clone of a 50 MiB subtree< 5 s
Rebase of 100 commuting changes< 200 ms, zero object rewrites
Resident memory, status< 200 MiB

Implementations SHOULD ship a benchmark harness that reproduces this table.

26. Conformance and test vectors

A conforming implementation MUST pass the oxy-conformance suite, which includes:

  • Encoding vectors: canonical CBOR and ObjectRef for every object type.
  • Signature vectors: composite signatures, including stripped-half and wrong-KeyId negatives.
  • Graph invariance: for random change sets, all permutations of application yield identical graphs and checkpoints.
  • Conflict shapes: each shape in §9 with expected materialization and expected status --json.
  • Rewrite propagation: amend of a change with dependents, with and without correspondence.
  • Protocol: negotiate/fetch/push against a reference server, including rollback and unauthorized-attestation rejection.
  • Adversarial chunking: inputs with no gear boundaries and inputs with boundaries at every byte.
  • Git round trip on a corpus of public repositories.

26.1 Reference implementation status

The reference implementation (crates/oxy-core, crates/oxy-server, the oxy binary) as of this draft:

AreaStatus
Objects, hashing, deterministic CBOR (§5)Implemented; format 2 repositories, migration from format 1
Change graph, commutation, conflicts and resolution (§6–§9)Implemented, including order, delete/insert, tree, replace, and mode conflicts; workspace conflicts (§9.7) and exit status 3 are not detected
Rewrites (§8.4, §8.5)Implemented: describe, amend (also into older changes), split (any change), abandon, squash, rebase; orphaned references re-anchored
Bookmarks, per-workspace current bookmark, merge by union (§10.1)Implemented
Subrepositories with Oxygen and Git pins, .oxymodules, Git gitlink import and export (§7.8, §23)Implemented
Partial and interactive commits (§12.3)Implemented
Blame and bisect (§27)Implemented
Operation log, undo (§12.1)Implemented; redo reserved
Snapshots and the stat cache (§12.3, §12.4)Implemented; no file-system monitor yet
Storage: loose objects, packs, GC, checkpoints, batching (§11, §13, §21)Implemented; pack indexes are read fully rather than mapped
Identity, composite signatures, seals, attestations, policy, lifecycle (§14)Implemented; lock attestations reserved
HTTPS transport and endpoints (§16)Implemented, HTTP only; Noise and ZCT (§15) reserved; negotiation is exact (no Merkle buckets); refs takes no patterns
Partial, sparse, shallow (§17)Implemented
Locks (§18)Local advisory locks only; server locks and the lock endpoint reserved
Configuration, attributes, hooks (§19, §20)Implemented (.oxyattributes: binary, text, eol, nocompress, lockable)
Thin profile and proxy (§22)Reserved
Git interoperability (§23)Implemented: import, export, fetch, push, rename detection
Conformance suite (§26)Partial: encoding, signature, permutation, conflict, rewrite, protocol, chunking and Git tests exist as unit and integration tests in the crates; a standalone vector suite is not published yet

27. Inspection: blame and bisect

Blame. Every line of a file in a state is an atom identified by the change that introduced it (§7.3), so oxy blame PATH is a lookup, not a heuristic: for each line it prints the introducing change's ChangeId, author, and date, and --json gives the ChangeHash and atom index. A line inside a conflict region is attributed to the side that introduced it. Blame follows renames for free, since the node survives them, and it MUST cost no more than materializing the file.

Line history. For the same reason the history of a range of lines, such as a function, is exact: oxy log -L START,END:PATH lists every change that inserted or deleted an atom on some path in the file graph from the range's first line to its last (the lines' own atoms and those deleted from between them), newest first, with lines numbered as in the working copy's base. Changes elsewhere in the file are not in it, and edits within the range stay in it however the lines moved around them. Hosts use the same query for a function's history (tree-sitter gives the range).

Bisect. A regression is a change, and the candidates lie between a known-good state and a known-bad one: candidates = closure(bad) \ closure(good). oxy bisect start, then oxy bisect good CHANGE.. and oxy bisect bad CHANGE.. (either may name a bookmark), orders the candidates so that every prefix is dependency-closed and checks out good ∪ prefix at the midpoint. Each further good/bad halves the remaining range, and the search ends by naming the first change whose inclusion makes the state bad; oxy bisect run CMD automates it with the command's exit status (0 good, 125 skip, anything else bad). oxy bisect reset restores the working copy from before the search. Because prefixes are closed under dependencies, every tested state is well-formed; a change that cannot be tested in isolation because it commutes with the culprit is simply ordered after it. Bisect state lives under .oxy/bisect/ and is not versioned.

28. Open questions

  1. Encryption at rest. Per-repository XChaCha20-Poly1305 with a key hierarchy is reserved for format version 2; object headers already leave room. Convergent encryption is rejected because it leaks equality.
  2. Word- or character-granularity atoms for prose and minified files. Line atoms are fixed for v1.
  3. Set reconciliation at the scale of tens of millions of changes may need an IBLT-based negotiator; the Merkle bucket scheme in §16.2 is the v1 answer, and the reference implementation's exact negotiation is sufficient below that scale.
  4. Workspace conflicts (§9.7) on case-insensitive file systems are specified but not yet detected by the reference implementation.
  5. Server-side locks (§18) need the lock endpoint and attestation to be exercised by an implementation before their wording is final.

Changelog. Draft 0.8r (2026-09-29): change format version 3, text blobs as the lines' bytes (§6.1, §7.4); oxy gc repacks into one pack at a higher compression level (§13.4); an import brings a deleted directory back rather than making a second node for its path, and retires a kept node's other placements (§23); graph keys at commit, lazy text in history-only loads, and a clean status from the manifest (§12.4); a parent checkout never moves a subrepo's bookmark backwards, and the pin is read from the nested working copy (§7.8); bookmark create --upto/--only (§10.1); the working-copy lock covers every command that writes the working copy; a workspace changed by two concurrent operations is an error (§12.1). Draft 0.8q (2026-09-29): change format version 2 stores large inserts as text blobs, so a change does not grow with the files it adds (§6.1, §7.4); no object may exceed 1 GiB (§13.2). Draft 0.8p (2026-09-29): Git import derives new NodeIds from the commit, making imports deterministic (§23). Draft 0.8o (2026-09-29): execution receipts, the receipt object and attestation (§14.6). Draft 0.8n (2026-09-29): line history, oxy log -L (§27). Draft 0.8m (2026-09-29): resolving replace conflicts (§9.3). Draft 0.8l (2026-09-28): deleting a reference on a remote; first serials from the clock (§16.4). Draft 0.8k (2026-09-28): the manifest records graph keys, so unchanged files are neither read nor rendered (§12.4). Draft 0.8j (2026-09-28): checkpoint payload version 2, file graphs by reference (§11).

Draft 0.8i (2026-09-28): portability rules for exec bits and path names (§7.7); found by verifying a real Git import commit by commit: deletions depend on the inserters of the lines right after them, and replacements on the change that set their old blob (§8.1); replace records are superseded causally, so a reverted binary keeps its blob (§9.3); merges resolve opaque conflicts to the commit's blob, and oxy git verify (§23). Draft 0.8h (2026-09-28): oxy fsck (§21.1); durability rules and the operation head as commit point (§13.6); the operation head moves by compare-and-swap and concurrent operations are merged (§12.1); oxy doctor (§21.2). Draft 0.8g (2026-09-27): delegates of a rule's identities may sign reference attestations (§14.5); local-path pushes bind unbound repositories like HTTP ones. Draft 0.8f (2026-09-27): challenge signatures (§14.3) for hosts. Draft 0.8e (2026-09-27): anonymous changes (zero author) are authenticated by a seal from any key, so imported and pre-identity history can be pushed under a policy (§14.4). Draft 0.8d (2026-09-27): unbound hosted repositories adopt the first pusher's id (§16.1). Draft 0.8c (2026-09-27): bare repositories (oxy init --bare, §12.2: no workspace, no working copy; hosting) and the reference server as an embeddable library (§15.1). Draft 0.8b (2026-09-27): the machine interface for editors (§24): root, cat, version, hunks, discard, and --json on the reporting commands.

Draft 0.8a (2026-09-27): every Draft 0.8 addition is implemented in the reference implementation (§26.1). Clarifications from doing so: an unregistered nested repository is left alone and a tracked subrepo whose directory is absent keeps its pin (§7.8); Oxygen pins are omitted from Git export (§23).

Draft 0.8 (2026-09-27): subrepositories with Oxygen and Git pins (§7.8, §23); squash and rebase defined as history edits (§8.5); multiple bookmarks per repository with a per-workspace current bookmark and oxy merge (§10.1); partial and interactive commits (§12.3); blame and bisect (§27); open questions moved to §28.

Draft 0.7 (2026-09-27): review pass. oxy status --json shape (§9.5); stat crawl required and file-system monitor recommended (§12.4); capabilities aligned with the implementation (§16.1); §24 lists the implemented command line and names the reserved commands; new §26.1 reference implementation status; §28 records the open workspace-conflict and lock items.

Draft 0.6s (2026-09-27): the manifest as a stat cache with a racy-stat window (§12.4).

Draft 0.6r (2026-09-27): batched object writes (§13.4).

Draft 0.6q (2026-09-27): pack index validation rules (§13.1).

Draft 0.6p (2026-09-27): oxy git fetch and oxy git push against a Git remote through a mirror, with incremental import seeded by imported and exported commits (§23).

Draft 0.6o (2026-09-27): rename detection on snapshot and Git import records moves of the existing node (§7.1, §23).

Draft 0.6n (2026-09-27): identity lifecycle (§14.2): every identity edit is a rotation that supersedes rather than revokes, expiry bounds authenticated changes, revocations recorded locally; policy proposals for multi-signer updates, chain-aware authorization, bootstrap rule, and policy history on refs and in push bundles (§14.5, §16.3).

Draft 0.6m (2026-09-27): orphaned references on rewrite are reified by re-anchoring rather than refused (§8.4).

Draft 0.6l (2026-09-27): amend into older changes and split (§8.4).

Draft 0.6k (2026-09-27): hook execution model and JSON payloads (§20).

Draft 0.6j (2026-09-27): .oxyattributes semantics (§19).

Draft 0.6i (2026-09-27): secondary workspaces and their .oxy file (§12.2).

Draft 0.6h (2026-09-27): tags in views, on the wire, and under policy (§5.5, §10.2, §16.3).

Draft 0.6g (2026-09-27): shallow repositories as checkpoint plus history window, the checkpoint and shallow messages, deepening (§16.3, §17).

Draft 0.6f (2026-09-27): sparse checkout semantics (§17).

Draft 0.6e (2026-09-27): lazy blobs: the fetch filter, the objects message, and the absent-blob record (§13.5, §16.3).

Draft 0.6d (2026-09-27): §11 checkpoint format as implemented, with the loading rule.

Draft 0.6c (2026-09-26): §23 import defines the base as the union of parent states, records author and commit trailers, skips submodules, and states the closure-versus-snapshot difference.

Draft 0.6b (2026-09-26): §8.4 allows refusing a rewrite whose orphaned references have no specified conflict shape, defines removal, and notes the signature consequence.

Draft 0.6a (2026-09-26): §13.4 fixes the pack and index file formats.

Draft 0.6 (2026-09-26): trust policy propagation over the wire, the policy signing domain, per-remote downgrade protection, the bootstrap rule for unverified remotes, and the rule that policies govern only changes added after them.

Draft 0.5b (2026-09-26): §19 defines the user configuration file's keys, the precedence of repository over user over environment, and the key directory layout.

Draft 0.5a (2026-09-26): §15.1 allows a reference server behind TLS termination and requires authenticated pushes when credentials exist; §16.3 defines the CBOR message encodings (push validation moves to §16.4).

Draft 0.5 (2026-09-26): bookmarks and tags point at a state object naming the heads, so attestations, views, and references carry one ObjectRef per bookmark however many heads it has; repository format 2 records this, and format-1 repositories migrate on open.

Draft 0.4b (2026-09-26): §5.5 records the encoding conventions for variants, references, views, signatures, and secret key files; §13.1 adds the signature directory.

Draft 0.4a (2026-09-26): §13.2 pins the FastCDC gear table, rolling hash, and masks so boundaries are identical across implementations.

Draft 0.4 (2026-09-26): overview no longer describes the constrained profile by word size; HLC drift bound replaces timestamp-based rejection; node kinds reduced to file, dir, symlink with a separate content mode and defined mode switching; symlinks always opaque; checkout safety rules (§7.7); workspace conflicts (§9.7) replace the case-folding tree conflict and cover normalization, reserved names, and path length; operation log entries suppressed for empty snapshots.

Draft 0.3 (2026-09-26): recursive directory moves with cycle handling; explicit semantic dependencies with the semantic-deps hook; seals redefined as push-time batch signatures carrying their hash list, with an author-or-delegate authentication rule and delegates on identities; core.sign setting; fetch, push, policy, and garbage collection updated for seals.

Draft 0.2 (2026-09-26): rewrote from the 0.1 outline (the original spec.txt, since removed). Added threat model, two-identifier model, atom coordinate system, tree operations as atoms, conflict taxonomy and materialization, operation log, checkpoints, storage layout, composite signature construction, trust policy, reference attestations, HTTPS-mandatory transport, ZCT restrictions, wire protocol, partial repositories, locks, client profiles with a named hardware floor, Git bridge, CLI, performance targets, and conformance suite.