A next-generation version control system built around a snapshot-and-tag model
(Jujutsu-inspired) with per-file permissions, native committed secrets, and
in-memory clones. This document covers the architecture as built — the two
founding wedges and the full collaborative VCS grown around them across 36
vertical-slice phases plus the P37 partial-clone series (37a-37d). The rationale behind each major decision is recorded as an
ADR in docs/adr/, and a compact per-phase appendix closes this doc.
The long-term bet is that Git's two structural limitations — an all-or-nothing access model and a disk-bound working copy — are increasingly expensive in a world of autonomous coding agents. Agents want to fork many short-lived working copies cheaply, and teams want to commit secrets into repo state without bolting on an external vault. Git makes both painful.
The project does not try to replace Git. It began by proving two wedges with the clearest near-term value, and has since grown — one vertical-slice phase at a time — into a full persistent, branchable, content-addressed VCS that builds on and interoperates with Git wherever that saves time. The two founding wedges:
- Phase 1 — In-memory virtual worktrees (the agent wedge). A library + CLI that lets an autonomous agent fork N parallel worktrees of a repo entirely in RAM, run and checkout against each, and tear them down cleanly leaving zero local artifacts. Includes a bounded memory budget with eviction and optional spill so large repos don't exhaust the heap.
- Phase 2 — Native committed secrets. Env vars / keys committed directly into repo state, encrypted at rest and in transit, decrypted only inside an authorized execution context through integrated key management.
Everything in Phase 1 was designed so Phase 2 dropped in without re-architecting:
secrets are just a special object kind in the same content-addressed store. That
composability held across every phase since. All three long-term-thesis pillars
now ship — in-RAM worktrees (P1), committed secrets (P2), and per-file
permissions (P7, encrypted paths; per-branch in P34) — atop a full collaborative
VCS: three-way merge and history editing, packfiles + GC, ssh:// / sc+http(s):// /
bucket (sc+wal://, sc+s3://) / Git network transports, sparse and partial clone
(with lazy fetch, merge and agent sessions on a partial clone), signed commits,
intent-deep provenance via sealed agent-session transcripts, and a read-only
native desktop browser. The phase-by-phase build log is in
ROADMAP.md and CLAUDE.md; the sections below cover
the architecture, with a compact per-phase appendix at the end.
This is systems work with three demands that Rust serves better than the
alternatives. First, a strict memory budget with deterministic eviction is a
core requirement; Rust's lack of a tracing GC means the heap budget we enforce is
the heap we actually use, with no hidden retention or GC pauses skewing the
numbers. Second, the content-addressed store benefits from zero-copy hashing
and slicing over Bytes/Arc<[u8]> without defensive copies. Third, the
crypto wants mature, audited primitives — the RustCrypto ecosystem provides
AEAD (XChaCha20-Poly1305), X25519 ECDH, HKDF-SHA256, and Ed25519 with good
ergonomics, all pure-Rust. Git interop is in-process via gix (pure-Rust
Git), so there is no subprocess boundary or dependency on a system git binary
(the P18 hosted-Git bridge is the one exception, spawning system git only
because gix cannot push).
Go was the main alternative and would scaffold faster with excellent concurrency, but its GC makes a precise heap budget and eviction strategy materially harder to reason about — which is exactly the property Phase 1 is meant to demonstrate.
The codebase is a Cargo workspace of eight library/binary crates plus the
desktop app, with a strict dependency direction: the top-level adapters
{cli, desktop} sit on repo; repo → {vfs, crypto} → core, plus the separate
leaf edges repo → tlsio and repo → objio (tlsio and objio depend on no
other workspace crate, not even core). gitio → core is linked by cli, never
by repo:
src-control/
├── crates/
│ ├── core/ content-addressed store (loose + packfiles), object model, memory
│ │ budget + eviction, streaming pack build/parse
│ ├── vfs/ in-memory copy-on-write worktree engine (fork / edit / checkout / teardown)
│ ├── gitio/ Git interop: import a repo's HEAD + export history back to Git via gix,
│ │ plus a system-git mirror bridge for hosted Git (the only crate linking gix)
│ ├── crypto/ envelope encryption (secrets + protected paths) AND Ed25519 signing +
│ │ v2 dual-key identities (scl-crypto; the only crate linking RustCrypto)
│ ├── tlsio/ rustls/rcgen wrappers for sc+https:// (P32); a leaf, the only crate
│ │ linking rustls
│ ├── objio/ `Bucket` trait + directory and S3 backends for sc+wal:// / sc+s3:// (P36);
│ │ a leaf, the only crate linking the S3 SDK
│ ├── repo/ durable .sc/ repo: refs/branches/working tree, three-way merge, history
│ │ editing (cherry-pick/rebase/amend/undo), remotes + wire transport (ssh://,
│ │ sc+http(s)://, bucket WAL), protection + secret lifecycle, GC, sparse &
│ │ partial clone, signatures & session transcripts, agent workspaces
│ └── cli/ `sc` binary — the full ~43-command surface (`sc --help`; the annotated
│ reference is the sc-cli-reference skill)
├── apps/desktop/ Tauri v2 top-level adapter (`scl-desktop`) + bundled React/TypeScript
│ renderer; native read model only, with no UI dependency in domain crates
├── demo/ 28 end-to-end proof scripts (`run_*.sh`)
├── fuzz/ cargo-fuzz target for the wire decoder (`wire_decode`)
└── ARCHITECTURE.md
core knows nothing about Git, worktrees, or cryptography. gitio is the only
crate that links gix, keeping the Git dependency quarantined behind one
boundary. crypto is the only crate that links the RustCrypto stack, keeping
the cryptographic dependency quarantined behind another; tlsio does the same
for rustls and objio for the S3 SDK. repo owns the .sc/
on-disk layout and all repo orchestration; it is deliberately Git-agnostic
(it never links gitio) — cli links both repo and gitio and passes
imported snapshots down. This matters because the long-term plan is to own the
object format outright; Git is an import/export peer, not a foundation.
The desktop backend is a peer of the CLI, not a layer beneath repo. It calls
the same public Rust APIs and maps them to a narrow immutable read model. Its
WebView never receives a repository root for general use, filesystem or shell
authority, private keys, protected blob bytes, or private-branch internals.
Every piece of repo state is an immutable object identified by the BLAKE3 hash of
its serialized bytes — the ObjectId. BLAKE3 (not SHA-1/SHA-256) because it is
fast, parallel, and tree-structured, which lines up with verified streaming and
future incremental hashing. Identical content anywhere in history is stored once.
There are six object kinds (the leading byte of the canonical encoding tags the kind: blob 0, tree 1, secret 3, snapshot 4, signature 5, transcript 6; tag 2 is a retired pre-P16 snapshot encoding, refused with a clear error):
- Blob — raw file contents.
- Tree — a sorted directory listing mapping a name to
(kind, ObjectId, mode, permissions). The per-entrypermissionsbyte carries thePROTECTEDbit (P7): a protected entry's blob is encrypted ciphertext — convergent for pre-P33 seals, randomized (a companionRANDOMIZEDbit, P33/ADR-0043) for new ones — and the bit is what makes "protected" and "ciphertext" synonymous in every tree. - Snapshot — the Jujutsu-inspired analogue of a commit: a root tree id plus
metadata (parent snapshot ids, author, timestamp, message), a
secretsside-registry (name → secret-object id, so secrets are env vars, not files), and aprotectionregistry (per-prefix recipient rules as last-writer-wins epoch registers, plus the per-blob wrapped-DEK map). The distinction from Git is that snapshots are cheap and implicit — the working copy is a snapshot that gets amended, rather than a staging area that must be explicitly committed. - Secret — an envelope-encrypted object: ciphertext + AEAD nonce + wrapped data-encryption key + recipient ids. Stored and addressed exactly like any other object, so it flows through fork/checkout/clone untouched and stays ciphertext until an authorized context decrypts it.
- Signature (P22) — a bytes-only object over a domain-separated snapshot id
(
Ed25519), indexed on the side (.sc/signatures.v2); the crypto stays quarantined incrates/crypto, socoresees only opaque bytes. - Transcript (P30) — a sealed agent-session record attached to the snapshot
it motivated: the same envelope shape as a
Secret(fresh DEK wrapped per recipient) plus{snapshot, agent, session}metadata, so the plaintext session never enters the store. Indexed on the side (.sc/transcripts), optionally signed under its own domain.
Objects are serialized canonically (length-prefixed, sorted tree entries) so the hash is stable across machines. Every decode length prefix is bounded by a max-object-size / remaining-bytes guard (P28) so a hostile object can't drive an unbounded allocation.
A worktree is a mutable view over an immutable base snapshot. Forking a
worktree allocates only a small overlay — it does not copy file content. Reads
fall through the overlay to the base tree in the store; writes land in the
overlay as copy-on-write entries. Because the base objects are shared via
Arc, forking N agents off the same snapshot is O(N) in overlay size, not in
repo size, and the heavy blob bytes are never duplicated.
Agents interact with worktrees exclusively through the library API and CLI
(read, write, remove, list, checkout). Content lives only in RAM and is
materialized to a real filesystem path only on an explicit checkout to a
caller-chosen directory — which the agent then owns and cleans up. There is no
FUSE mount and no kernel extension: nothing touches disk unless the caller asks
for it, which is what makes "zero residual artifacts" provable rather than
aspirational. (FUSE was considered and rejected: on macOS it needs the macFUSE
kernel extension, is fragile, and makes the zero-residue guarantee harder to
prove than a pure in-RAM model.)
Teardown drops the overlay and releases the Arc references; base objects
survive only as long as some live worktree or the session store references them.
The store enforces a bounded byte budget (configurable, e.g. 512 MiB) over blob content so a large repo or many agents cannot exhaust the heap. Accounting is the sum of resident blob sizes; trees, snapshots, and overlay metadata are small and tracked but rarely dominate.
Eviction is LRU over clean, reconstructible blobs. A blob is evictable only if it is content-addressed (so it can be re-derived) and not pinned by a live worktree overlay write. On a read miss for an evicted blob, the store rehydrates it from the spill backend:
- Default (no spill): eviction is disallowed for objects with no other
source and the store returns a typed
BudgetExceedederror, so the caller fails loudly instead of silently thrashing. - Optional spill: evicted blobs are written to a temporary, content-addressed
spill directory (keyed by
ObjectId, so writes are idempotent and verifiable on read-back) and dropped from RAM. This trades RAM for disk on demand. The spill directory is created lazily, lives under a session-scoped temp root, and is removed entirely on session teardown — so even with spill enabled the zero-residue guarantee holds after the session ends.
The budget is enforced at insert time: inserting a blob that would exceed the budget triggers eviction of the coldest evictable blobs until it fits, or errors if nothing can be freed. Dirty overlay writes are never evicted because they are not yet reconstructible.
gitio imports an existing Git repository in-process via gix. It walks the
commit's root tree, reads blobs and subtrees directly from the Git object
database, and inserts equivalent Blob/Tree/Snapshot objects into our store,
returning the root Snapshot id. Agents then fork in-memory worktrees off that
snapshot. No subprocess, no dependency on an installed git, and the boundary is
a single crate so the rest of the system stays Git-agnostic. Export (writing a
snapshot back out as a Git commit) is the symmetric operation, built in Phase 9;
Phase 10 composes the two into bidirectional sync with a local Git repo as a
first-class remote, and Phase 18 reaches hosted Git (GitHub over https/ssh)
through a spawned-system-git mirror bridge (gix can fetch but not push, so a
lazily-built bare mirror.git beside the marks map is the transport). All of it
keeps gix quarantined in gitio. This is distinct from sc's own network
transport: repo speaks a framed wire protocol over ssh:// (P12), sc+http://
(P26) and sc+https:// (P32) that mirrors its Transport trait, and treats a
bucket as a remote (P36) — no gix involved (see the appendix).
All cryptography is quarantined in crates/crypto; core and repo see only
opaque bytes. The crate owns three surfaces, all built on the RustCrypto stack:
envelope encryption (secrets, below), encryption for protected paths
(randomized DEK/nonce for new content since P33, convergent dual-read for
pre-P33 history — see below), and Ed25519 signing for provenance.
Committed secrets use envelope encryption. Each secret object carries ciphertext encrypted under a fresh per-secret data-encryption key (DEK) using XChaCha20-Poly1305 (AEAD, large random nonce, authenticated). The DEK is then wrapped (encrypted) once per authorized recipient public key (X25519). The secret object stores the ciphertext, the AEAD nonce, and the set of wrapped DEKs keyed by recipient key id.
Secrets are referenced by a side registry on each snapshot (name -> Secret id,
a BTreeMap so canonical encoding is insertion-order-independent), kept separate
from the file tree so checkout never materializes them. An authorized context
decrypts in memory and injects the value into a child process environment; sc secret-demo proves the authorize/deny/grant flow end-to-end with the same
zero-residue teardown as Phase 1. sc run injects through the child's
environment by default; sc run --secrets-fd N (ADR-0052, unix) hands the
values over a pipe on descriptor N instead, which keeps them out of env dumps
but is still not isolation. See ADR-0010.
The consequence is that a clone in an unauthorized context — one whose private key is not among the recipients — receives the secret object intact but cannot unwrap any DEK, so the value stays ciphertext: confidential at rest and in transit by construction, with no separate vault. In an authorized context, the runtime unwraps the DEK with its private key, decrypts the value, and injects it into the execution environment transparently. Authorization is therefore "do you hold a private key listed as a recipient," and granting/revoking access is re-wrapping the DEK for a changed recipient set — a cheap metadata operation that does not require rotating the secret itself. This is the same envelope model used by age and cloud KMS, chosen because it is well-understood and auditable.
Protected paths (P7) encrypt designated file content rather than named
env-var secrets. As of P33 (ADR-0043) all new protected content is sealed
under a fresh random DEK + random nonce (RANDOMIZED perms bit alongside
PROTECTED), so two seals of the same plaintext yield different ciphertext ids
— closing the convergent equality-confirmation oracle for everything sealed
from P33 on. The store is dual-read, randomized-write: pre-P33 convergent
ciphertext (DEK/nonce derived from BLAKE3(plaintext)) still decrypts through
the unchanged decrypt_path, so no snapshot-tag bump and no forced migration —
a pre-P33 store reads byte-for-byte identically. Commit's per-path rule is
format-dispatched on the prior tip entry (convergent → re-encrypt-and-
compare, carry-if-unchanged; randomized → keyed-PRF stat cache under a never-
committed .sc/local-key; new → seal randomized), so unchanged convergent
content stays convergent until an edit or an explicit sc rewrap upgrades it.
An unauthorized clone still gets ciphertext it cannot read; the sc protect CLI
still nudges genuine low-entropy secrets toward sc secret (P28). Per-prefix
recipient rules are last-writer-wins epoch registers so a revoke is durable
across merges (P16). sc grant/revoke manage the recipient set without
touching ciphertext; sc rewrap now eagerly re-seals still-convergent blobs
randomized as it re-wraps the tip — so a rewrap that upgrades content is no
longer tree-identical (a convergent→randomized reseal changes the blob id),
though a second rewrap over an all-randomized tip converges back to policy-only.
See ADR-0014/0026/0043.
Lifecycle (P11/P17): sc secret rotate re-seals a secret under a fresh DEK
(the real cryptographic cutover, distinct from metadata-only revoke); sc escrow
maintains break-glass recovery keys auto-included at seal time; sc rewrap is a
one-commit bulk cutover of every secret and protected blob to the current
recipient/escrow set. See ADR-0019/0027.
Provenance (P22): sc keygen emits a v2 identity — one random seed,
HKDF-derived into both an X25519 encryption key and an Ed25519 signing key,
written as a single scl-id-<hex> file (a pre-P22 v1 scl-sk- identity still
parses and encrypts but cannot sign). Signatures are bytes-only CAS objects over
a domain-separated snapshot id, indexed in .sc/signatures.v2; sc verify walks
history and reports a four-state status (trusted ✓ / untrusted ? / invalid ✗ /
unsigned). Session transcripts (P30) reuse the same signing machinery under a
distinct domain. Signing binds identity to a snapshot id, detecting history
rewrites in clones and remotes. See ADR-0032.
Persistence (Phase 3) adds a durable on-disk store so commits and committed
secrets survive between sc invocations — the foundation every collaborative
phase after it builds on. See ADR-0011.
core::Store gains a Backend::Persistent(PathBuf) variant alongside the
existing Backend::Ephemeral. In persistent mode every put writes the
canonical Object::encode() bytes to .sc/objects/<hex> (idempotent
tmp+rename) before returning, so disk is always at least as fresh as RAM. A
read-miss loads the file, verifies BLAKE3(bytes) == id, and decodes; a
tampered file returns Error::Malformed. Blob eviction drops only the RAM copy
— the durable file remains authoritative — so the existing LRU eviction logic
extends naturally.
The scl-repo crate owns everything .sc/-related. The layout grew with the
phases but the shape is stable:
objects/— content-addressed object storage: sharded/zstd loose files (<aa>/<rest>) plusobjects/pack/<hash>.pack+.idxpackfiles (P8).refs/heads/<branch>— one hex-id-per-line branch tip, updated atomically.refs/remotes/<remote>/<branch>— remote-tracking refs (P6).HEAD— symbolic ref (ref: refs/heads/<branch>), updated atomically.lock— exclusive lock file; acquired onRepo::open/init, removed on drop. Enforces the single-writer invariant.config— remote definitions (sc remote add), TOML.recipients.toml—[recipients](name →scl-pk-<hex>encryption key),[escrow]break-glass keys (P11/P17), and[signing]/[signers](P22).scanner-allowlist.toml— hash-scoped allowlist for the commit-time secret scanner (P5).oplog— append-only operation log; every ref-moving op records before/after sosc undo/redo can invert it (P14). Local-only, like a reflog.signatures.v2— the signature index, one slot per (snapshot, signer) (P22, #257);signaturesis the pre-#257 file, read only by the one-time import (retried on later ingests if it failed) and left alone so an older binary keeps a file it understands.transcripts— snapshot→transcript-object index, one-to-many (P30). All gc-rooted, local-only.transcript-drops— the derived set of dropped transcripts (sc transcript drop), rebuilt from the store on clone;transcript-pending(+transcript-pending-trust) — signed tombstones held but not yet honored, re-judged when the trusted-signer set changes (#230).index.lock— advisory lock around one update of these derived indexes, taken by serve-path ingests and local commands; distinct fromlock(#260).sparse— the local, uncommitted sparse-checkout prefix spec (P24);promisor— a partial clone's fetch-filter + origin (P27);promisor-blob-limit— its blob-size limit and whole-tree-or-prefix scope (ADR-0055).serve-tokens.toml— bearer-token access control forsc serve --http(P29); tokens may carry an expiry and be rotated in place (sc serve token add --expires/rotate).serve-tls/— the auto-mintedsc+https://server identity (P32).promisor-lazy— empty marker: this partial clone may fill gaps by object id (P37a);sparse-prev— the previous sparse spec while asparse setis in flight (P37b).local-key,protected-cache(andws/cache-<i>,ws-<name>/cache-<i>) — the P33 keyed stat cache for protected paths; never committed, never transferred.tmp/— guard-cleaned scratch for streaming pack transfer (P25).ws/— durable agent-session workspaces:session.tomlmanifest + per-agent checkouts (P20);ws-<name>/— the same for a named session (ADR-0056); the manifest also carries thepending_refreshmarker of an unfinishedsc ws refresh.git-remotes/<name>/— thegit_oid ↔ sc_idmarks map and a lazily-built baremirror.gitfor network Git (P10/P18).- Transient in-progress state (removed on completion/abort):
MERGE_HEAD,PICK_HEAD,REBASE_STATE, and their*_DECIDED_ROOTrecords, plusCONFLICT_PLAINTEXT/CONFLICT_SIDECARSlisting what a stopped operation wrote into the working tree — an interrupted merge/pick/rebase resumes or aborts cleanly from these.sparse-widenrecords the files a wideningsparse setis laying out while an operation is stopped (P24, #224). - The
sc+https://client's pin file is user-level, not part of.sc/($SC_HTTPS_KNOWN_HOSTS, else$XDG_CONFIG_HOME/sc/known_hosts, else~/.config/sc/known_hosts; managed withsc tls).
The directory beside .sc/ is the working tree. commit reads every file
(skipping .sc/), builds a content-addressed tree, and advances the current
branch ref. switch materializes the target branch tip into the working tree,
removing tracked files that are absent from the target, and refuses the
operation if tracked files are modified or deleted (to prevent data loss). New,
untracked files are left in place. status diffs the working tree against HEAD.
The write-out paths (switch, merge, rebase, sparse set, clone, ws fork,
private and conflict write-outs) apply the recorded file mode through one
helper (scl_vfs::apply_file_mode, also used by Worktree::checkout), so an
executable file stays executable on unix, a stale execute bit is cleared on a
plain file, and setuid/setgid/sticky are always cleared (#242, #243). sc resolve applies the chosen side's mode after rewriting a deleted conflicted
file. A chmod the filesystem refuses is tolerated with a one-time warning.
materialize treats the disk as the user's wherever it cannot prove otherwise
(on public branches; private-branch merges, forward operations and aborts have
weaker guards, listed in THREAT-MODEL).
A forward operation (merge, switch, cherry-pick, rebase, undo, sparse-spec change)
refuses, before any write, to lay a file or directory onto an untracked or ignored
one. A tracked path is deleted or overwritten only when the disk copy is a regular
file with exactly the tracked bytes, so an edit, a replacement or a symlink
survives (where bytes can be compared: a protected path without a usable identity
and a private-branch forward operation cannot, see THREAT-MODEL); an emptied directory is cleared so a file and a directory can swap at one
path, and every such directory is checked before the first removal, so a refusal
changes nothing. An abort (merge_abort, cherry_pick_abort, rebase_abort)
completes past a kept edit (a directory holding untracked content or a blocked
parent still refuses) and returns an AbortReport: it removes what the stop laid,
overwrites a file only if it is still the stopped tree's bytes or the stop owns it
(recorded plaintext, a conflicted path), and lists what it left in kept (sc
prints left in place (changed since the stop)). See ADR-0034 refinement 8.
Ephemeral mode (Phase 1 agents, sc demo, sc secret-demo) keeps the
zero-residue guarantee unchanged: nothing touches disk except Worktree::checkout
and the optional spill backend, both of which are removed after the session.
Persistent mode (sc init repos) writes to .sc/ by design. .sc/ is
user-owned durable state — the same relationship Git has with .git/. The two
modes compose in exactly one way: a sc work/sc ws agent session (P13/P20) is
a bounded ephemeral session hosted by a persistent repo — temp checkouts are
removed on teardown and the only durable writes go through the same commit path
persistent mode already owns. Otherwise a session is either ephemeral or
persistent, never a mix.
Every ref, loose-object, and pack write goes through one durable atomic-write
helper (scl_core::fsutil): write a per-process temp sibling, fsync it,
rename, fsync the parent directory — Git's crash-durability discipline.
Remote ref updates are compare-and-swap: Transport::update_ref revalidates
the expected old tip under the remote's own lock, so two racing pushes cannot
silently clobber each other. The single-writer lock file records the holder's
PID and is broken automatically when that process is provably dead, so a
SIGKILLed sc doesn't brick the repo. The derived indexes (signatures,
transcripts and their drop state) take a separate, short index.lock, held only
around one update and never while waiting on the repo lock, so a serve-path ingest
and a clone's reindex cannot lose each other's appended lines (#260, #273, #277).
See ADR-0021.
The Phase 1 demo imports a sample repo, forks several agent worktrees in parallel, has each agent independently edit and checkout files, runs the bounded budget under load, then tears everything down. A filesystem snapshot taken before and after the run is diffed to prove zero residual files remain on disk once the session ends. That diff is the headline evidence for the agent wedge.
Phase 8 adds three tightly coupled capabilities to the persistent store:
- Sharded + zstd loose objects. Loose objects moved from
objects/<hex>toobjects/<aa>/<rest>(first two hex chars as a shard prefix) and their payloads are zstd-compressed on disk. The canonical bytes are decompressed and BLAKE3-verified on every read, so the content-addressing invariant is unchanged. Legacy flat/uncompressed objects are still read (read-both, write-new). - Packfile format.
objects/pack/<hash>.pack+.idxbundle many objects into a single file. Each pack record is[id:32][compressed_len:4][zstd(canonical)]; the.idxmapsObjectId → (offset, length). The store gains a pack-aware read path: loose objects are checked first, then pack indexes. Writing is always loose; packing is a batch GC operation. sc gc. Computes the reachable object set by walking from all refs (branch tips, HEAD, allrefs/remotes/*remote-tracking refs, andMERGE_HEADwhen a merge is in progress) through snapshots → trees → blobs/secrets/protected objects. Reachable objects are written into a new packfile; unreachable loose objects older than the grace window (default 24 h,--prune-expire <dur>) are deleted. Packed unreachable objects are dropped immediately (they survived a prior GC cycle). Deletions happen only after the new pack is durably written. GC is persistent-only and runs under the single-writer repo lock.- Bulk-pack transfer.
pushbuilds a single pack of objects the remote lacks and ships it viaput_pack;clone/fetchuseget_pack(wants, haves). The transport read path resolves packed, sharded, and compressed objects from the remote store. This replaces the prior object-at-a-time transfer.
Follow-ons named when this phase shipped have since landed as their own phases: network Git remotes (P18), the HTTP transport (P26), streaming frames (P25), bulk re-wrap and multiple escrow keys (P17), and interactive workspace sessions with auto-merge (P20).
sc export --to <git-repo> [--ref <name>] [--include-encrypted] is the symmetric
peer of the Phase 1 Git import: it maps src-control objects to Git objects and
writes them into a target Git repository via gix, keeping all Git logic
quarantined in gitio.
| src-control | Git |
|---|---|
Blob |
Git blob |
Tree |
Git tree (entries sorted in Git's byte order) |
Snapshot |
Git commit (root tree + parents + author/message) |
The full history of the current branch is walked and written. Identical content maps to identical Git objects, so re-export is idempotent.
Snapshot author strings are parsed as Name <email> (with fallback to name-only
- empty email). The committer is set equal to the author. The timezone is always
+0000. This makes the Git commit hash stable across re-exports of the same src-control history.
Export refuses if the history contains protected paths or registry secrets
unless --include-encrypted is passed:
- Protected files export as their ciphertext blobs — nothing plaintext escapes into the Git repo.
- Registry secrets are dropped — Git has no secrets-registry equivalent, so there is no safe representation; silently omitting them is the least-surprise behaviour.
The target ref is overwritten (mirror semantics). If --to does not exist,
it is created with git init --bare. On a newly-created repo, HEAD is pointed
at the exported ref so tools that resolve HEAD (including git log) work
without additional configuration. Pre-existing repos have their ref force-updated;
HEAD is not touched.
Git trees cannot carry the following src-control metadata; it is dropped on export:
- The secrets registry (
BTreeMap<String, ObjectId>on each snapshot). - The protection policy (wrapped DEKs for encrypted paths).
- The per-entry
permsbyte on tree entries.
These are the documented lossy points (see ADR-0016). Phase 10's bidirectional sync inherits them: the Git side of a git-backed remote never carries the secrets registry, protection policy, or perms byte — a sidecar or extended-attribute convention to preserve them remains out of scope.
Note: the fail-closed scan keys on the per-entry PROTECTED bit, so content
committed as plaintext before a path was protected remains plaintext in history
and is neither flagged nor refused by --include-encrypted. This is the same
forward-looking model as git-crypt; export refusal is not a blanket guarantee of
"no plaintext anywhere in history".
A local Git repository is a first-class remote. sc remote add <name> <git-path> --git registers it; sc fetch <git-remote> imports the full Git
history deterministically and writes a refs/remotes/<name>/<branch>
remote-tracking ref; sc push <git-remote> [--include-encrypted]
synthesizes (or reuses) Git commits for the current branch and
fast-forward-updates the Git ref, reusing Phase 9's export machinery and
confidentiality gate verbatim.
Identity across the two DAGs is carried by a persisted git_oid ↔ sc_id
marks map (.sc/git-remotes/<name>/marks), not by a fatter object model —
the content-addressing invariant is unchanged. The git-remote path dispatches
in cli above the Phase 6 Transport trait rather than implementing it: a
Git remote has a different id space and encoding than Transport assumes, and
routing through cli keeps repo free of any gitio dependency.
Scope is local .git paths on disk; network Git is a later transport swap
onto the same translation core. One accepted MVP limitation: fetching from Git
repo A and pushing a Git-origin commit to a different Git repo B
re-synthesizes with dropped committer/timezone/gpgsig and a different Git oid
than A had — same-remote fetch/push stays clean. See ADR-0018.
sc secret rotate <name> [--value <new>] [--to <names>] [--identity <key>]
re-seals a secret's value under a fresh DEK, composed entirely from the
existing seal/open primitives (crates/crypto is unchanged). With
--value, the new plaintext is sealed directly; without it, the current value
is recovered via --identity and re-sealed. Recipients default to the
secret's current set (reverse recipient_id lookup against
.sc/recipients.toml), overridable with --to.
sc secret rotate rotates secrets. Protected paths could not be rotated before randomized sealing (P33): they used convergent
encryption (DEK = HKDF(BLAKE3(plaintext))), so a recipient who checked the
file out could re-derive the key — "rotating" a convergent path's DEK is either
dedup-breaking or security-meaningless, so their lifecycle stayed on recipient
re-wrap (grant/revoke). sc secret revoke remains metadata-only and now
hints to run rotate for the actual cryptographic cutover. (P33/ADR-0043:
the security-meaningless objection dissolves for randomized content — a
random DEK is independent of the plaintext — so a genuine rotate-for-paths
cutover is coherent and is built as sc rewrap --rotate (ADR-0051): it
re-seals every protected blob under a fresh key, where a plain sc rewrap
only replaces wrap lists and keeps the key.)
sc escrow set <pubkey-or-name> / sc escrow show configure a single
break-glass recipient key ([escrow] in .sc/recipients.toml) that is
auto-appended (deduped) whenever secret add, secret rotate, or protect
seals or wraps — forward-only (existing secrets/paths gain escrow when next
rotated/re-wrapped) and policy, not enforcement.
Rotation ≠ erasure: content-addressed history keeps the old ciphertext object reachable, and anyone who kept the old DEK can still decrypt it. Rotation cuts off future reads through the current registry; real security requires rotating the underlying external credential too. See ADR-0019.
sc clone / fetch / push work against ssh://[user@]host[:port]/path
remotes. The wire protocol mirrors the Transport verbs (eight here; the batched
GetObjects joined in wire v5) over length-prefixed frames with a version
handshake (PROTOCOL_VERSION is 6 today; each bump is noted in the phase that
made it, and a mismatched pair fails closed at the handshake); the server (sc serve --stdio) is
a dispatch loop around LocalTransport, so CAS ref updates and pack
verification apply verbatim server-side. The client spawns the user's ssh
(overridable via SC_SSH, Git's GIT_SSH pattern — the demo and tests drive
the full ssh:// path through a local shim, no sshd needed). Typed errors
(NonFastForward, NotARepo) survive the wire; an interrupted push leaves
at worst unreachable objects, never a torn ref. Confidentiality is unchanged
by construction: objects travel as canonical bytes, ciphertext stays
ciphertext. See ADR-0022.
sc work is the fusion of Phase 1 and Phase 3: the session engine
(crates/repo/src/workspace.rs) forks N vfs worktrees from HEAD inside the
repo's own budget-bounded persistent store, so all forks share one Arc'd
blob cache and eviction never needs a spill backend — .sc/objects is the
reconstruction source. Checkout reuses the P7-aware materialize; harvest
reuses the commit pipeline (snapshot_files, extracted from commit), so
the P5 scanner and .scignore gate agent output exactly like a human
commit. Each changed workspace becomes a flat work-<i> branch (<base>-<i> with
sc work --name <base>; the ref grammar reserves / for remote-tracking refs); merge is the ordinary P4
path. The session holds the single-writer lock end to end, and teardown is
Drop-guarded: zero residue outside .sc/ on success, error, or panic.
Replay is merge: cherry-picking commit C onto tip T, or replaying a branch
commit-by-commit during rebase, is diff3(base = the commit's first parent,
ours = the current tip, theirs = the commit), computed by P4's existing
three_way_files (extracted from three_way) — no second merge engine, no
object mutation, and protected content is refused up front, inheriting P4's
fail-closed guard verbatim → lifted in P15 (ADR-0025). sc cherry-pick resolves like merge: a clean
replay advances the branch with a single-parent snapshot; a conflict writes
P4-style markers plus .sc/PICK_HEAD, and the next sc commit completes it.
sc rebase is atomic instead: a merge commit anywhere in the replayed range
refuses the whole operation before a single commit replays, and the first
conflict aborts wholesale with refs and the working tree untouched — unlike
cherry-pick's per-commit resolve flow. Both follow the same crash discipline
as merge: build the snapshot in the CAS, materialize the working tree,
then move the branch ref — the ref update is the atomic commit point, so a
crash in that window leaves a post-operation working tree paired with a
pre-operation tip (status reads dirty; no ref damage), not a consistently
pre-operation state.
A single append-only .sc/oplog gives every ref-moving operation (commits,
merges, cherry-picks, rebases, switches, secret/protect ops, sc work
sessions) an undo record: HEAD and every touched ref's before/after value.
sc undo restores the last record's before-state and appends its own
inverse record — refs are written first, the oplog record last, so a crash
between the two loses an undo entry but never fabricates one, and a torn
tail from a prior crash is healed (truncated) before the next append. Undo
of undo is redo. sc gc treats oplog-referenced snapshot ids as reachability
roots and trims records past the prune-expire window, always keeping the
newest, so the root set stays bounded. Undoing the repo's initial commit is
refused (there is no working tree to materialize back to) — a deliberate
scope cut rather than a half-built rewind. The oplog is local-only, like a
reflog: it is never copied by clone and never travels over a transport. See
ADR-0024.
Every merge/rebase/cherry-pick used to fail closed on protected content —
lifted in P15 (ADR-0025). The key observation is that P7's path encryption is
convergent (equal plaintext ⇒ equal ciphertext blob id), so most
three-way cases resolve on ciphertext ids alone: unchanged, one-side-changed,
and clean-delete protected paths merge with no identity at all, carrying
ciphertext blobs plus a union of wrapped DEKs. Only a content-divergent
protected path — both sides edited the plaintext — needs an authorized
--identity (→ P33/ADR-0043 adjustment: under randomized sealing, both
sides editing to even identical plaintext no longer id-match, so that case
now also conflicts and needs an identity — accepted cost 4a): the two
plaintexts are decrypted, diff3-merged, and the result
is re-encrypted through the same encrypt_protected/reuse_prior_wraps
helpers commit already used (extracted, single-sourced), so plaintext is
never written to the CAS — conflict markers and sidecars for protected paths
go straight to the identity-holder's working tree instead. Missing identity
raises Error::ProtectedMergeNeedsIdentity; a supplied identity that can't
unwrap raises Error::NotAuthorized; decrypt_with distinguishes ciphertext
corruption from either, so a truncated object never misreports as an
authorization failure. Protection rules merge by union
(union_prefixes/union_wraps, both deterministically sorted for encoding
stability) — narrowing protection is not something a merge can do silently.
This closes an internal-consistency case (I2): a file carried forward as
PLAIN that matches a rule landing at the merge is re-encrypted at
completion, so a path's protection bit and its ciphertext-or-plaintext state
never drift apart in any snapshot.
Conflicted protected merges and picks persist the merge/pick's decided
tree — .sc/MERGE_DECIDED_ROOT / .sc/PICK_DECIDED_ROOT, written
alongside the operation's HEAD marker and cleared with it, gc-rooted only
while that HEAD file exists so crash residue from an abandoned operation
can't be read back and hijack a later, unrelated completion. Completion
unions both parents' (or tip-and-picked) protection rules and wraps, and
carries any protected file absent from the working tree forward from the
decided tree rather than arbitrating by parent order — plain ours-first
carry-forward was found during the build to silently revert content the
other side had already updated.
The secret registry now replays through sc rebase/sc cherry-pick
via the existing merge_secrets helper, with each replayed commit's own
parent as the merge base; a registry conflict aborts the operation
atomically, same as a tree conflict. Replay's Empty check was redefined
to mean the tree delta and the registry delta and the
protection-prefix delta are all empty, so a rules-only or secrets-only
commit now replays instead of being silently dropped (P14's Empty checked
the tree only). A conflicted cherry-pick's completion also merges the
picked commit's registry change, closing a hole where a combined
conflict+registry-change commit lost the registry side. MergeProtected
and ReplayProtected — the P4/P14 fail-closed guards — are retired now that
every path they blocked has a decrypt-on-demand or ciphertext-id
resolution. demo/run_protected_merge_demo.sh proves the keyless disjoint
case, the identity-gated content-divergent case, and registry replay
end-to-end. See ADR-0025.
Protection rules moved from a bare recipient-key list to a per-recipient
last-writer-wins register: RecipientEntry { key, epoch, state: Granted | Revoked }. grant/revoke mint a fresh epoch = max(current) + 1;
merge_prefixes keeps the higher-epoch entry per recipient and resolves an
epoch tie with disagreeing states as Revoked (fail-closed). Commit-time
sealing reads only granted_keys() — Granted entries — so a tombstoned
recipient never seals a fresh DEK again, even when a pre-revoke branch merges
in later. This is the closure of the P15 boundary note: P15's union merge
made revoke non-durable against a merge of a pre-revoke branch, because the
old key-list union simply re-added the revoked recipient.
grant's authorization and decrypt_with are unchanged (they key off wrap
presence, not standing), so a revoked recipient can still read ciphertext
sealed before the revoke — revoke is a standing cutover, not a
cryptographic one; rotation remains the only way to cut off a key that
already saw plaintext. The rules-format tag bumped 2 → 4
(TAG_SNAPSHOT_LEGACY = 2), so a pre-P16 store fails to decode with an
explicit error rather than silently misparsing the new layout. See ADR-0026.
sc rewrap [--identity <key>] [--dry-run] is a one-commit, one-oplog-record
sweep that recovers every secret and unwraps every protected blob's DEK by
wrap presence, then re-seals to exactly the governing rule's current
granted_keys() + escrow — the practical answer to the P16 corollary that a
merged-in pre-revoke branch can re-attach a revoked recipient's old wraps to
the live tip. Convergent DEKs keep ciphertext ids unchanged, so the commit is
policy-only (root tree byte-identical to the parent). Entries the identity
can't open are skipped and named, not silently dropped; the sweep commits
what it could and exits non-zero when anything was skipped.
Escrow grew from a single key to a managed list: sc escrow add/remove/show
join set (kept as replace-with-one sugar), and .sc/recipients.toml [escrow] grows from key = "…" to keys = […] (old singular form still
read on load). Same honesty caveat as rotation: rewrap cuts the live tip
only — old snapshots in history keep their old wraps via content addressing.
See ADR-0027.
Hosted Git (GitHub over https/ssh) becomes reachable because upstream gix
can fetch/clone but not push, so a pure in-process path is impossible today.
Each git-backed network remote gets a lazily-created bare mirror at
.sc/git-remotes/<name>/mirror.git, alongside (not replacing) P10's marks
file — deleting mirror.git is always safe (self-reconstructs), deleting
marks is not (it carries git_oid ↔ sc_id identity). The spawned system
git binary (quarantined to crates/gitio) is transport-only: fetch runs
git fetch --prune into the mirror then P10's unchanged import; push runs
P10's unchanged export into the mirror then git push, reusing the P9/P10
confidentiality gate verbatim. Auth is fully delegated to the spawned git
(ssh-agent, credential helpers, tokens) — sc has no credential surface of
its own.
Clone routing auto-detects unambiguous git URL forms (https://, http://,
scp-style, file://); bare ssh:// stays sc-native unless --git forces
the bridge. SC_GIT overrides the spawned binary, mirroring P12's SC_SSH.
See ADR-0028.
Riding the same P14/P15 replay core with no second merge implementation.
sc amend [-m <msg>] rebuilds the tip from the working tree with the tip's
own parents kept, via a parents_override parameter on the existing
snapshot_files pipeline. Resumable rebase is now the default, revising
P14's atomic-abort: a conflict stops with P4 markers and a persisted
.sc/REBASE_STATE instead of aborting; sc rebase --continue completes the
conflicted commit and resumes the fold, and any number of stops still
collapses into ONE oplog record because the branch ref only moves at final
completion. sc cherry-pick --abort restores the untouched tip with no
oplog record at all — no ref ever moved, so abort is its own inverse. sc cherry-pick <ref> --mainline <N> replays a merge commit relative to its Nth
parent; rebase over a merge-containing range stays refused (a rebase replays
a whole range, so there's no single relative parent a flag could resolve).
A review Critical closed a resumability bug: --continue used to clear
REBASE_STATE before running the resumed fold, so a typed error mid-fold
destroyed retry/abort; state now clears only on the fold's own completion,
and a resolved flag makes a retried --continue idempotent. See ADR-0029.
Agent sessions now outlive a single process. sc ws fork --agents N
materializes N checkouts under .sc/ws/<i>/ and atomically writes
.sc/ws/session.toml (base snapshot, base branch, workspace dirs + status,
author) — the checkout directories plus this manifest are the durable
state, so sc ws list/run/harvest/abandon work across any number of later
invocations, even a different day. sc ws harvest runs each live workspace
through P13's harvest pipeline, then auto-merges the candidate onto the
landing branch through a read-only conflict probe (would_merge_cleanly,
composing P4's three_way + merge_secrets) that guarantees no conflict
markers land unattended: clean merges land immediately and cumulatively (one
oplog record each), anything conflicted falls back to a work-<i> branch
exactly as P13 did. Harvest joins the P19 merge/pick/rebase-in-progress
guard family as a ref-mover.
resolve_and_teardown writes the manifest before removing the workspace
directory, so a crash between the two never strands a live = true entry
pointing at a directory that no longer exists. See ADR-0030.
Named sessions (ADR-0056). sc ws fork --name <n> opens a further session in a
sibling root, .sc/ws-<n>/ (manifest, <i>/ checkouts and cache-<i> inside),
so the unnamed session's layout is untouched and tearing one session down never
touches another. list/run/harvest/abandon take --session <n>; without
it they mean the unnamed session and never guess a named one. Fallback branches
and SC_WORKSPACE are <n>-<i> (work-<i> unnamed). The repo lock serializes
every sc command, so harvests of different sessions run one at a time in
invocation order: the first lands, a later one merges over it or falls back.
sc ws run resolves its workspace and secret environment under the lock, then
releases it before spawning the command (WsRunPlan), so other sessions stay
usable while an agent runs. gc roots every open session's base snapshot.
Refresh (ADR-0056). sc ws refresh moves a session onto the checked-out
branch's tip (or --onto <branch>). Each live workspace's checkout is committed
to a throwaway candidate (harvest's pipeline without the branch write) whose
parent is the session base, and the candidate is replayed onto the tip with
replay_commit, the ADR-0049 engine, which touches neither the host's working
tree nor a ref. A planner then compares the candidate's tree with the replayed
one under the session's recorded view and decides every removal and write for
every workspace; any conflict, running command, scanner hit, view gap, protected
path it cannot decrypt, or user file in the way refuses the whole refresh before
a write. Applying touches only differing paths, each written atomically and
re-checked against what the candidate read right before it is touched; a
pending_refresh marker (target, its branch and, per workspace, the candidate
and result snapshots) is written to the manifest before the first file, and the
manifest with the new base and no marker last. While the marker is set the
manifest's base_snapshot holds the placeholder refresh-unfinished (the real
base is inside the marker), so a binary that predates refresh cannot read the base
and fails closed instead of harvesting a half-moved session. A crash leaves the
marker: harvest and run refuse, and sc ws refresh resumes from the recorded pairs
(accepting at each path the candidate's bytes or the result's, never re-reading
the half-written checkouts) before moving to the requested tip. Session-wide
only.
No new capability axis — a sweep closing the P16–P20 review tail. Every
commit-creating policy op (protect/grant/revoke,
secret add/rotate/grant/revoke) now opens with the same
merge/pick/rebase-in-progress guard the ref-movers already used, closing a
hazard where an unguarded policy op mid-stopped-rebase had its commit
silently discarded by completion. Git marks staleness self-heals at the one
dangerous point of use: GitTarget::has_object verifies a mark-reused git
commit still exists in the target before reuse, re-synthesizing instead of
writing a broken parent chain when git gc pruned it upstream. The three
verbatim conflict-materialization copies (merge, cherry-pick, rebase fold)
collapse into one Repo::materialize_conflict_state helper. sc ws list
gains a landed_tip field so a workspace whose merge actually landed
reports "landed" (or "landed (undone by sc undo)") instead of the
generic "abandoned" a manual ws abandon still shows. See ADR-0031.
Signatures are ordinary CAS objects (TAG_SIGNATURE, bytes-only in
crates/core — no crypto crosses the quarantine) over the domain-separated
snapshot id; a local .sc/signatures.v2 index maps (snapshot, signer) → signature id,
gc-rooted and pruned alongside dead snapshots. sc keygen now emits a v2
identity: one random seed, HKDF-derived into both an X25519 encryption
key and an Ed25519 signing key in a single scl-id-<hex> file (a v1
scl-sk- identity still parses and encrypts, but can't sign). Verification
(sig_status) is strict four-state — Invalid beats Trusted beats
Untrusted beats Unsigned — and sc verify [<ref>] [--require] walks
every parent, not just the mainline. A received signature is verified before
it is indexed and a forged one is dropped (ADR-0038 addendum, #230), so a
transfer cannot make a snapshot Invalid.
Transfer needed zero wire changes: signatures ride the existing pack as
ordinary objects, reindexed by id on receipt. recipients.toml gains
[signing] (name → key) and [signers] trusted = […], mirroring
[recipients]'s shape. Stated plainly: signing defends against history
rewriting and attribution disputes, not against a trusted signer acting
maliciously — and amend/rebase/merge results start unsigned by design,
since a new snapshot id is a new claim. See ADR-0032.
One new abstraction, conflict_versions(path) -> {base, ours, theirs}
(crates/repo/src/conflicts.rs), re-derives all three sides of a conflict
from the DAG for whichever op is active (merge/pick/stopped-rebase) instead
of parsing marker text off disk. sc conflicts [<path>] [--identity] lists
conflicted paths and kinds, or renders a path's base/ours/theirs sections
(protected paths decrypted per-side against that side's own protection
registry, gated on --identity); sc resolve --ours|--theirs <path…>
writes the chosen side and clears the path from the active conflict record.
Resolution only decrypts — it never re-encrypts, so plaintext still never
enters the CAS before the unchanged sc commit/sc rebase --continue
completion re-encrypts through the same helpers commit always used.
sc status --json's "conflicts" field grew from a bare path list to
[{path, kind}] — a strict superset, no existing consumer broke. Whole-file
resolution only; no hunk-level modes. See ADR-0033.
.sc/sparse is a local, uncommitted prefix spec (sc sparse set/show/ disable; empty/absent = full materialization). The whole feature is one
generalized carry predicate: commit's existing absent-path carry (the
P15/ADR-0025 discipline) widens from "absent AND still-protected-and-not-a-
recipient" to "absent AND (that OR outside the sparse set)", so a commit
made while narrowed carries the untouched out-of-sparse subtree forward
byte-identical, while an in-sparse absence stays a genuine deletion.
materialize filters its write and old-root-removal loops by the same spec;
sc ws workspaces inherit the host's sparse view, persisted at fork time in
session.toml so a later sparse set on the host can't reinterpret a
workspace's never-materialized paths as deletions.
A clean merge/pick/rebase change to an out-of-sparse path lands in the CAS
without materializing; a CONFLICT there refuses up front with a "run sc sparse set to include it" hint. While the operation is stopped, sparse set
accepts only a strict widen (the new spec covers the old one): it lays out the
newly in-view files from the stop's decided root, leaves protected ones absent,
and the interrupted write is journaled in .sc/sparse-widen (#224). Sparse is CHECKOUT-only — every object
stays in the CAS regardless of the spec, so sc gc's reachability walk is
unaffected; not fetching out-of-prefix objects at all is deferred to P27.
A stopped operation records a decided root (public branches), and completion
carries an out-of-view or still-protected path from it alone, so a path the other
side deleted cleanly is not resurrected from a parent (#238/#239). A spec change
never counts a case alias of a laid-out path as tracked, since on a
case-insensitive filesystem the newly visible alias would be the laid-out file
itself (#241). See ADR-0034.
push/fetch/clone no longer hold a whole pack in RAM on either side.
crates/core/src/pack.rs gained an incremental PackWriter (appends
objects one at a time to any Write) and a streaming parse_pack_reader
(verifies each record's hash off a Read without holding the whole body).
crates/repo/src/wire.rs frames a pack as ST_PACK_CHUNK/ST_PACK_END
opcodes under the unchanged u32 frame header, CHUNK_SIZE defaulting to 1
MiB (SC_PACK_CHUNK overrides it). PROTOCOL_VERSION bumped 1 → 2, v1
dropped outright — one pack encoding, always chunked, rejected cleanly at
handshake on mismatch. The receiver does a two-pass atomic-after-verify
ingest into a guarded temp file (.sc/tmp/, TempPackGuard) so a corrupt or
truncated pack never partially lands.
A final-review fix closed the client side too: fetch/clone's incoming
pack and push's outgoing pack both now spill through the same guarded
temp-file path instead of destreaming into a Vec, so peak RAM is one
object end to end, not just on the wire and server. See ADR-0035.
A second sc-native transport alongside P12's ssh://: sc+http:// host[:port]/repo (default port 8730). The opening codec is four small pure
Read/Write functions routed through one read_bounded_opening helper
that errors out past MAX_OPENING_BYTES (8 KiB) rather than allocating
unbounded on a hostile/unterminated opening. The client reads and maps an
HTTP-style status line (200/404/other) before the WireClient
handshake begins, so a non-repo server is never mistaken for a HELLO
failure. The server (sc serve --http <addr> <path>) is a
TcpListener, thread-per-connection, each thread opening LocalTransport
fresh — no store or lock shared across threads. Concurrency safety layers
the pre-existing single-writer RepoLock (ref updates) with lock-free
content-addressed object writes, tightened with thread-unique temp sibling
names now that thread-per-connection puts multiple writers in one process
for the first time.
After the 200 status, the raw TcpStream goes straight to wire::serve —
no HTTP framing wraps the P25 chunk stream. Zero new dependencies
(std::net/std::io only). Standing boundaries stated plainly: plaintext
only (no TLS — closed in P32, sc+https://), unauthenticated (closed in
P29), not proxy/CDN-safe (still open). See ADR-0036.
.sc/promisor (local, uncommitted) records a partial clone's fetch-filter
prefixes plus the promisor origin URL; its presence makes a gap expected.
One path-aware walk, reachable_objects_filtered → Reachable { included, gaps }, serves both the server's get_pack filter (GetPack.filter,
PROTOCOL_VERSION 2 → 3) and the client's own gc/sc verify: an
out-of-filter child's id lands in gaps and is never fetched — which is why
absent out-of-filter objects never surface as errors. sc clone --filter <prefix…> writes both .sc/promisor and .sc/sparse to the same prefixes
(partial ⊇ sparse, one filter); sc backfill <prefix…> widens both,
explicitly and offline — no lazy-fetch from inside a read path.
Building a new commit on a partial clone needed real machinery beyond
carry-by-id: worktree::graft_out_of_sparse splices the tip's untouched
out-of-filter subtrees back into a freshly built root by id, never reading
their content, so push's filtered reachability walk (until P37b-i made it
presence-based) still sent only the client's new in-filter objects. commit refuses (Error::GappedPathContent)
rather than silently drop content under an unfetched subtree. sc backfill --all is the genuine full-clone escape hatch: fetch everything, verify the
closure, then remove .sc/promisor. P27 refused merge, replay, sc ws fork/harvest, and sc work outright on a partial clone as a deliberate MVP
coarsening; P37b-P37d lifted those refusals (see below). See ADR-0037.
A dedicated security pass closing exposure introduced by opening the store
to untrusted network peers (P26/P27). One max-object-size cap (scl_core::max_object_size(), ADR-0039 addendum) bounds any
single object's decompressed/decoded size at every read/write boundary that
takes attacker-controlled bytes, closing a decompression-bomb / unbounded-
allocation class that P25's read_frame_inner had already named as a known
gap. Ref names are validated at the write boundary (rejecting path traversal
and control characters) before ever touching .sc/refs/ on disk, since a
ref name had until now been trusted as an internal identifier rather than
treated as attacker-reachable input once a network write path existed. See
ADR-0039.
sc serve --http gains three composed access-control gates, closing P26's
stated "unauthenticated" boundary: a fail-closed bind check and bearer-token
authentication against a .sc/serve-tokens.toml allowlist, both at the
connection opening, and a read-only mode that rejects each mutating request
inside wire::serve_with_policy before any store write. --read-only forces the
read-only gate independent of tokens (a rejected write gets the typed
EC_READONLY wire error), so a public-read mirror needs no token management at
all. The bind and token gates
run before the P25/P26 wire handshake begins, matching P26's own
status-line-before-handshake discipline, so a connection they reject never
reaches LocalTransport; the read-only gate is enforced per request after the
handshake. Tokens later gained an optional expiry (sc serve token add --expires; an expired token gets the same 401 as an unknown one) and sc serve token rotate (ADR-0040 addendum). See ADR-0040.
A sealed provenance record can now attach to a commit. Transcript { snapshot, agent, session, nonce, ciphertext, wrapped_keys } is a bytes-only
TAG_TRANSCRIPT object in crates/core (the crypto quarantine holds) whose
body is always sealed via scl_crypto::seal before it reaches the CAS —
a keyless clone gets ciphertext only. A one-to-many .sc/transcripts index
(snapshot → transcript ids) means history-editing ops start each fresh
snapshot with none attached, so there is no stale-provenance carry-forward
to reason about. Signing is opt-in and reuses P22's machinery outright —
same SignatureObj type, same .sc/signatures.v2 index, no second index —
under a distinct signature domain string.
Because transcripts are ordinary content-addressed CAS objects, transfer
needed zero wire changes: the sender over-sends every indexed transcript
(and its signature, folded into the same over-send query as P22's) covering
a transfer-relevant snapshot, and the receiver reindexes idempotently. sc gc roots live transcript ids into reachability before signature pruning
runs, so a signed transcript survives exactly as long as the transcript
itself does. sc export --to <git-repo> drops transcripts (no Git-native
form exists) and reports a count. A bucket remote (P36) has no index, so its
get_pack rebuilds the same over-send from the packs the manifest chain names
(no format change), at the cost of downloading and decoding every on-chain pack
on each fresh fetch or clone.
sc transcript drop <id> [--identity <key>] removes a transcript from view
without a new object tag or wire change: a drop is a tombstone, a
Transcript object with an empty ciphertext and wrap list that names the
dropped id, recorded in a local .sc/transcript-drops index and treated as a
grow-only set (no undrop; re-attach to restore). Reads, sc log markers and
the over-send filter through the drop set, and sc gc reclaims the body. A
tombstone travels with fetch and push only when it is signed, and a
retained signed tombstone rides a transfer when its snapshot is in it or was
collected by the sender's gc, so a stale peer is told (a push that finds the remote already at
the same branch tip sends nothing, so a drop made afterwards propagates once a
push moves the branch or the remote fetches), and
a receiving peer honors it only if the signature comes from a key in its [signers] trusted list; an unsigned drop is local-only, and an untrusted tombstone stays
inert. Drop is not erasure: a peer that never receives an honored tombstone
keeps the sealed body. A tombstone held before its signer was trusted is
remembered in .sc/transcript-pending (capped at 1024, newest kept) and
honored on the next open or ingest once the signer is trusted (#230). sc push --no-transcripts leaves transcripts and their signatures out of the
pack (saving bandwidth); sc clone|fetch --no-transcripts cannot, because the
server still sends the full pack, so the receiver hash-checks every record and
then discards transcripts and their signatures, writing and indexing none
(saving disk and exposure, not bandwidth). It is per command, not remembered,
and has no effect on git remotes, which carry none. See ADR-0038 and its addendum.
Closes ADR-0036's three named-but-open accepted consequences plus a fourth
gap this phase's own research pass (docs/research/bounded-server-patterns.md)
first named: the aggregate incoming-pack spool was unbounded on both
transports. sc serve --http gains --max-connections (default 32; at the
limit a new connection gets an immediate busy status at the pre-handshake
opening seam and closes — no queuing), --timeout (default 300s; read+write
timeouts now persist for the whole session instead of being cleared after the
opening, connection-fatal on trip since a mid-stream abort desyncs the frame
stream), and --max-pack-size (default 16 GiB, floor = the max object
size, 256 MiB by default; lives in the shared WirePolicy, so it applies to both
--http and --stdio; a counted mid-stream abort replies EC_TOO_LARGE).
Later additions to the same policy: --max-object-size (operator-configurable
per-object cap, #213) and --max-conn-bytes, a per-connection write budget on
both transports that counts every PutObject and each PutPack's larger of
spool and decoded bytes, replies ConnectionBudgetExceeded and closes (#233,
no PROTOCOL_VERSION change; see the ADR-0039 additions).
The read-only pre-drain is capped at 8 MiB (RO_DRAIN_CAP), and the accept
loop gained a hardcoded Go-net/http-shaped exponential backoff around fd
exhaustion (5ms doubling to 1s, reset on success). PROTOCOL_VERSION stays
3 — none of the bounds touch the wire protocol. Zero new dependencies.
Proven by demo/run_limits_demo.sh. See ADR-0041.
A new leaf crate, crates/tlsio (scl-tlsio), is the only crate linking
rustls/rcgen/ring — extending the dependency rule with the separate leaf edge
repo → tlsio while leaving repo → {vfs, crypto} → core and the RustCrypto quarantine in
crates/crypto untouched. Two seam functions grow a TLS wrap and nothing
else changes: the client wraps the TcpStream before the opening write when
the URL is sc+https://; the server wraps before the opening read when
--tls is set. The opening codec, wire.rs, and the P31 bounds are
byte-for-byte unchanged; PROTOCOL_VERSION stays 3.
Trust is accept-new TOFU, the SSH known_hosts shape: the client pins
SHA-256(SPKI DER) per host in the pin file ($SC_HTTPS_KNOWN_HOSTS, else
$XDG_CONFIG_HOME/sc/known_hosts, else ~/.config/sc/known_hosts;
SC_HTTPS_FINGERPRINT pre-pins for CI;
SC_HTTPS_STRICT=1 refuses unknown hosts). A pin mismatch always
hard-fails. The server auto-mints a self-signed identity into
.sc/serve-tls/ (key 0600; owner-only DACL on Windows, #281) unless PEM is supplied; sc serve fingerprint
prints the pin an operator distributes out-of-band. Follow-ons: sc tls list/forget/pin/path manages the pin file (ADR-0053), and a client can opt into
CA validation instead of pinning with SC_HTTPS_CA_FILE=<pem> or
SC_HTTPS_CA=system (ADR-0054; fail-closed, no revocation checking, the pin
file is neither read nor written). Client connect, TLS handshake and opening
are each held to a 30 s inactivity bound, and bracketed IPv6 hosts parse in sc+http(s):// URLs.
Gate change: a
non-loopback bind now requires --read-only, --allow-public, or (--tls
AND ≥1 token) — P29's "tokens alone justify a public bind" is narrowed,
since a token protecting only a plaintext channel was never the guarantee
its wording implied. Proven by demo/run_tls_demo.sh. See ADR-0042.
Closes ADR-0014's convergent equality-confirmation oracle for all new
content: every new protected seal uses a fresh random DEK + nonce, so two
seals of the same plaintext yield different ciphertext ids. The format tag
is a RANDOMIZED perms bit on the tree entry (not a blob-layout change), so
dual-read of pre-P33 convergent ciphertext needs zero read-path code and no
snapshot-tag or protocol bump. Commit's per-path rule is format-dispatched:
a convergent prior keeps re-encrypt-and-compare, a randomized prior consults
a per-checkout keyed-PRF stat cache under a never-committed .sc/local-key
(the keyed tag is authoritative on every lookup — the stat hit is never
trusted alone, closing the git racy-stat class), and a new path seals
randomized.
sc rewrap eagerly upgrades any still-convergent blob to randomized as it
re-wraps the tip, so an upgrading rewrap is no longer tree-identical, while
a second rewrap over an all-randomized tip converges back to policy-only.
Accepted costs landed as predicted: identical independent edits on two
branches now conflict, and identical plaintext at two paths no longer
dedups. The historical caveat stands forever: pre-P33 convergent ciphertext
already in history stays equality-confirmable (rotation ≠ erasure,
ADR-0019). See ADR-0043 and the crypto sections above, which describe the
current sealing model in full.
The last thesis pillar: staging a fix (canonically an embargoed CVE) on a
branch that is fully opaque to non-recipients until a coordinated release.
Where P7/P33 seal blob content, a private branch seals everything the
branch introduces — snapshots, trees (so file paths), and blobs — as
individually-encrypted Object::Sealed bodies (fresh random DEK each,
distinct AAD from path blobs), and the branch ref points at a
BranchManifest rather than a snapshot. The manifest's only plaintext is
structural: a flat sorted list of the sealed-object ids in the branch's
closure, the public fork point, and the KEK wraps — so gc and both
transports get one rule each ("manifest reachable ⇒ every listed sealed id
reachable"; "diff the flat list against the peer's haves") without ever
decrypting. Everything else — the inner id → (sealed id, DEK) map and the
inner tip — lives in a BranchIndex encrypted under a per-branch KEK, itself
wrapped per recipient + escrow with the P2 X25519 envelope.
Sealing is copy-on-write: an inner id absent from the index resolves to a
public object read straight from the store, so a branch forked from main
seals nothing at creation and reseals only what actually changes — an
unchanged subtree keeps its public id. Grant is one KEK wrap (no object
churn); revoke mints a fresh KEK, re-encrypts the index, and rewraps for the
remaining recipients — zero content plaintext, zero sealed-object churn — but
a recipient who already fetched keeps the old manifest (rotation ≠ erasure,
the ADR-0019 boundary). Publish replays the sealed history as plaintext
public snapshots (messages/authors/timestamps preserved, parents remapped,
fork point unchanged) and moves the ref to the published tip; object ids
necessarily change on publish (a sealed id is BLAKE3(ciphertext), a public id
BLAKE3(plaintext) — equal ids would reopen the P33 oracle), so published
commits start unsigned, and the P5 scanner runs over all decrypted content
before any public object is written. The git bridge refuses private branches
unconditionally (there is no git structure to export — it is what's sealed),
integrating a private branch into a public one is refused everywhere except
publish, and filtered clones exclude private branches (sealed trees can't be
path-filtered). Two additive object tags cross the wire, so
PROTOCOL_VERSION bumps 3 → 4; no existing object's format changes. See
ADR-0044.
apps/desktop/ proves the native object model can directly power a product
surface. A Tauri v2 Rust adapter retains the user-selected canonical repository
root and exposes five typed queries: choose repository, select reference,
snapshot details, public file read, and first-parent comparison. Each requested
snapshot must be reachable from a current public ref and each path must be a
canonical repository-relative path. The adapter walks every parent for the DAG,
uses native tree/diff objects, verifies signatures against public trust config,
and reads transcript presence without opening transcript bodies.
The renderer is a bundled React/TypeScript three-panel browser. Local and
remote-tracking branches sit beside the all-parent snapshot graph; provenance is
visible on each snapshot; the inspector shows metadata, a public repository
tree, and first-parent changes. @pierre/trees and @pierre/diffs passed a
Tauri/Vite integration spike and are isolated in renderer components so they
remain replaceable presentation dependencies.
This slice is deliberately keyless and read-only. A protected entry becomes a
fieldless protected_locked DTO before its blob is loaded. A private ref stops
at accepted BranchManifest metadata and becomes private_opaque; authors,
messages, paths, timestamps, and inner topology never cross IPC. The WebView has
no generic dialog, filesystem, URL, process, or object-store command. See
ADR-0045.
A remote that needs no server process. BucketTransport
(crates/repo/src/bucket_transport.rs) implements the same Transport trait as
the local, ssh and http remotes on top of a flat key/value bucket, structured
as a write-ahead log of immutable, content-addressed packs plus one
compare-and-swapped manifest. A push uploads a pack, appends a parent-linked
log/<seq> entry carrying its ref updates, then CASes the manifest's
head_seq forward (Bucket::put_if_tag). A lost CAS refreshes and retries (up
to 16 attempts, with jittered exponential backoff between them, a ceiling that
starts at 4 ms and doubles to a 250 ms cap), so pushes to different branches both land; a push whose target
branch no longer matches its expected old tip fails with NonFastForward,
exactly as on any other transport, and a loser's orphaned log entry is invisible
to readers because they walk parent_seq back from the manifest. There is no
leader, lock file or lease. The new leaf crate objio holds the Bucket trait
and its two backends (DirBucket for sc+wal://<dir>, whose CAS takes an OS advisory file lock (single host, local filesystem; see THREAT-MODEL), S3Bucket for
sc+s3://<bucket>/<prefix>), quarantining the S3 SDK the way gix and
RustCrypto are quarantined.
Reads treat the bucket as untrusted input: WAL metadata (manifest, log
entries, checkpoints, idx) is capped at a fixed 256 MiB, pack records follow the
live max object size (scl_core::max_object_size()), and every WAL record
decodes strictly and fails closed. P36b adds checkpoint folding: once the log tail passes 64
entries a push opportunistically writes checkpoints/<seq> (branch tips plus a
cumulative pack list) and points the manifest at it, so a cold reader seeds from
the checkpoint and walks only the tail. P36c adds sc serve --store <url>: an
sc serve --http/--stdio instance whose .sc/ home supplies tokens, the TLS
identity and scratch space while all served content lives in the bucket, so
instances are disposable. Public content sits in the bucket as plaintext at
rest (the bucket ACL is the perimeter; sealed content stays ciphertext), and
sc clone --filter is refused against a bucket remote. sc remote compact (ADR-0057, #216) merges small packs behind one manifest
CAS, then deletes log entries, checkpoints and unreferenced packs behind a cutoff
that has aged past --prune-expire; readers that find a named object gone
re-read the manifest and restart, and there is no format change. A bucket keeps no signature or transcript
index, so its get_pack rebuilds the over-send from the packs the manifest
chain names; signatures, transcripts and signed drop tombstones therefore
travel through a bucket like any other remote, but each fresh fetch or clone
downloads and decodes every on-chain pack to do it. The bucket cannot judge a
tombstone, so a receiver honors it only for a trusted signer, and until then
may see a transcript the bucket still holds. See ADR-0038's bucket addendum,
ADR-0046 and docs/research/walgit-evaluation.md.
A partial clone marked lazy (.sc/promisor-lazy) can read a path outside its
fetch filter without sc backfill. sc cat <ref>:<path> walks from the ref's
root tree; each absent tree, then the blob, is fetched with one GetObjects
round trip (PROTOCOL_VERSION 5): the client names ids, the server answers
with the objects it can serve plus the ids it will not send, with no haves and
no reachability walk. Store::get stays network-free; the only code that dials
the promisor after clone is Repo::fetch_by_id_from in crates/repo/src/ fill.rs, which verifies the reply against the request before writing. A fill
only adds objects; push, gc and verify were filter-scoped walks when P37a
shipped and became presence-based in P37b-i (below).
Sparse widening over a filled path and the lifted P27 refusals needed
presence-based walks and are P37b-P37d (below). See ADR-0047 and
docs/research/scion-evaluation.md.
The filter records what a clone asked for, not what is present: an ordinary
sc fetch on a partial clone sends no filter and advertises its local tips as
haves, so an object new at an in-filter path can be omitted when it equals an
out-of-filter object reachable from a have. 37b-i therefore makes push, verify
and gc walk what the store holds (reachable_objects_partial) instead of
the fetch filter; push checks every gap id against the destination before
sending, a view gap fails with GapInView before any write (a preflight at the
top of materialize, plus undo and sparse set), and a partial clone fetches
from its origin only. 37b-ii lets a lazy, online clone fill the view's closure
by id (ensure_view) before switch, fast-forward, undo and sparse set;
sparse set may widen past the filter; and a by-id reply is capped at
min(--max-pack-size, 2 GiB) (the 2 GiB ceiling is twice the largest settable
object cap; the client reads at most 4 GiB) and split when refused. sc export and sparse disable stay refused on a partial clone. See ADR-0048.
sc clone --filter blob:limit=<size> (alone or with prefixes) leaves out blobs
of at least <size> bytes that no ref tip's root tree reaches. Only the server
knows a blob's size before sending it (tree entries carry none), so
GetPack gained a trailing optional blob_limit and PROTOCOL_VERSION is 6.
The sender's walk, reachable_objects_limited, walks every want's own tree
first (every ref tip stays complete, so the initial checkout is whole), then
moves over-limit blobs reached only through history from included to gaps,
measuring each distinct blob once with Store::blob_size. The client side adds
nothing to the gap model: absence is the gap (37b). .sc/promisor-blob-limit
records the limit and an explicit AllPaths/Prefixes scope (a size-only clone
is AllPaths, with a full sparse view); PackFilter is the single definition of
a filtered transfer (a prefix or a limit). A lazy size-only clone fills a gapped
revision by id even though its view is full; otherwise the gap error names the
file and sc backfill <path> repairs it. sc fetch from the promisor origin
sends the recorded limit (never the prefixes), so every fetched tip is complete
(bar a blob the clone already gapped) and later history growth is gapped like the clone's; --no-blob-limit sends none
for one fetch. There is no object-count limit: the transfer set is id-ordered, so
"the first N objects" has no stable meaning. See ADR-0055.
merge_trees is a tree-recursive three-way merge that carries a subtree by
id when the sides agree and no protection rule applies, reads only where the
sides differ or a rule intersects (a subtree with the same id on all three sides
is carried unread even under a protection rule), and on a lazy, online clone fills what it
asks for by id outside the store lock (a non-lazy clone gets the gap error with
the sc backfill <path> hint). A carried protected subtree keeps the complete
input wrap union. A conflict inside the view is marked and resolved like on a
full clone (sc conflicts, sc resolve, merge --abort, sc commit; the
out-of-view part is grafted from the decided root); a conflict outside the view,
or a result that must be sealed outside it, is refused before any ref,
merge-state or working-tree change. Cherry-pick and rebase share the engine
through replay_commit, and a stopped pick or rebase keeps its carried subtrees
in the decided root. Content and mode now merge as separate dimensions, so a
mode-only edit merges and a file/directory collision fails before any write on
full clones too. See ADR-0049.
sc ws fork/run/harvest/abandon and sc work work on a partial clone, with the
host's sparse view as the agent's whole world (sc work no longer hands agents
a complete tree there). The view closure is filled (lazy, online) or refused with
the gap error before anything is written; a workspace commit grafts the
out-of-view subtrees from the base without reading them; and harvest lands
through the ADR-0049 merge, with any conflict falling back to a work-<i>
branch as on a full clone. sc backfill --all remains the way to a genuine full
clone. See ADR-0050.