Architecture
Scrollcase turns a declarative recipe into a box: a portable, locked, self-contained Python environment for one operating system and accelerator, packed so it runs somewhere other than where it was built, signed so a consumer can prove what they received, and accompanied by a dependency licence inventory.
This page explains how, and — more usefully — why each step is where it is.
The substrate
One substrate, and only one: pixi + conda-pack + conda-forge.
flowchart LR
A["pixi.toml"] -->|scrollcase lock| B["pixi.lock"]
B -->|pixi install --frozen| C["conda prefix"]
C -->|conda-pack| D["relocatable tarball"]
D -->|extract| E["box payload: venv/"]pixi solves a committed pixi.lock against conda-forge, conda-pack relocates the resulting prefix, and the tree is extracted into the box as venv/. There is deliberately no second dependency backend — the reasoning is in Why Pixi & Conda-Forge.
The pipeline
flowchart TD
R["recipe.json + pixi.lock"] --> V["1. validate<br/>identity, target, host, lock, git state"]
V --> P["2. install from lock, pack, relocate"]
P --> A["3. stage assets<br/>download + verify, local files, archives"]
A --> PR["4. prune what is not needed at run time"]
PR --> L["5. licence audit vs the reviewed copy"]
L --> S["6. self-test with the box's OWN interpreter"]
S --> PA["7. parity gate (optional)"]
PA --> N["8. normalise timestamps and ordering"]
N --> Z["9. deterministic zip"]
Z --> SG["10. sign release, then channel pointer"]
SG --> O["11. content-addressed staging tree"]Each step earns its position:
Validate first. The recipe's declared identity must match its directory, its entry point must match the target's layout, the host must match the target, pixi.lock must exist, the tree must be a git checkout, and it must be clean unless --allow-dirty is passed. All of this happens before anything is installed, because the cheapest failure is the earliest one.
Install, never resolve. pixi install --frozen materialises exactly the locked packages without touching or re-checking the lock, so what ships is byte-for-byte what was reviewed. Resolution is a separate, human-initiated step (lock).
Relocate. See below.
Stage assets. Every declared asset is size- and hash-checked before it enters the payload. Downloads are resumable and re-runnable, and a partial file is renamed into place only after its hash matches.
Prune, then check. Pruning keeps the box to what it needs at run time; selfTest.files is what stops an over-aggressive prune from shipping a broken box.
Audit before self-testing. The licence inventory is derived from the lock and compared to the reviewed copy. A licence problem is a legal problem, and it is cheaper to hit it before the expensive checks.
Self-test with the box's own interpreter. The step that earns the box its name: the same check a consumer repeats after installing, so an environment that unpacks but cannot import its dependencies fails on the builder's machine rather than on a user's.
Parity after the self-test, on the same payload. There is no point comparing accelerators in a box that cannot import its dependencies in the first place.
Normalise, then archive. Timestamps are stamped to a fixed instant and files enumerated in one stable order, which is what makes the ZIP deterministic.
Sign last, stage after. The release commits to the archive by hash; the channel commits to the release document by its hash. The staging tree is then laid out exactly as a bucket would be, so whatever publishes it uses the keys the manifests already point to.
The guarantees
Everything above exists to hold six promises. They are the product; features that would weaken one are refused.
Locked
The environment is a pure function of a committed pixi.lock. build installs; it never resolves. A missing lock is a hard error rather than an invitation to resolve on the fly.
Deterministic
Rebuilding the same commit produces a byte-identical archive. Three things make that true:
- timestamps are normalised to a fixed instant (
2000-01-01T00:00:00Z) and files are enumerated in one stable order; - the build time comes from the HEAD commit, not the clock — outside a git checkout it falls back to a constant, because a wall-clock fallback would reintroduce exactly the nondeterminism this avoids;
- the rollout cohort salt is derived from
boxIdandversionrather than randomly, so a rebuild does not reshuffle which users receive the release.
A test asserts this directly. Anything that varies per run — a clock read, a random value, an unsorted directory listing — breaks it.
Relocatable
conda-pack already replaces the build prefix with a neutral placeholder, and a conda-forge prefix imports and runs from any location with no activation environment. So the box needs no relocation step at install time, and the embedded conda-unpack fixer is deliberately never run: doing so would stamp the build machine's absolute paths into dozens of files that then ship to users — measured on a probe environment, zero files carried the build prefix before running it and thirty-six after — leaking a developer's directory layout while still being wrong at the user's install location.
Instead, three repairs happen at build time:
- The few service files that carry the build prefix are removed (
conda-meta/pixi_env_prefix,bin/conda-unpack, and friends). - Every symlink is dereferenced, so the payload contains only regular files and directories — a link that dangles or escapes the prefix is dropped rather than pulling host files into the box.
- Generated console scripts, whose shebangs embed the build interpreter's absolute path, are rewritten to resolve Python next to themselves.
Signed
Every release and channel document travels in one envelope, with the payload as exact base64-encoded JSON so that verifying a signature means hashing the bytes as transmitted. A local ed25519 key works out of the box; an external signer plugs in through --signer-command and is not trusted on its word — it must echo back the exact payload it was given, and its signature is verified locally before the build continues. See Signing & Key Custody.
Verified
verify mirrors what an installing client does: signature, archive size and hash, safe entry names, box.json agreeing field-for-field with the signed release, the declared interpreter present, and — with --self-test — a real extraction whose own interpreter imports the declared modules. A box that would fail on a user's machine fails on the builder's first.
Honest about provenance
A box records the commit it was built from and whether that tree was dirty. Building outside a git checkout fails rather than inventing a revision; a dirty tree requires --allow-dirty and is recorded as sourceTreeDirty: true in the box itself. A build that cannot be reproduced from its recorded revision says so.
The code
src/
├── contract/ the box format itself — the source of truth
│ ├── targets.mjs target model, identity rule, per-target adapters
│ ├── documents.mjs signed-document envelope, namespacing
│ ├── schema/ seven JSON Schemas
│ └── fixtures/ golden fixtures other implementations prove themselves against
├── build/ solving, packing, staging, auditing, verifying
│ ├── pixi.mjs tool discovery, argument vectors, install + pack + relocate
│ ├── launchers.mjs the console-script repair
│ ├── archive.mjs deterministic zip, defensive extraction
│ ├── filesystem.mjs stable ordering, fixed timestamps, path safety
│ ├── assets.mjs verified downloads, local files, archive expansion
│ ├── licenses.mjs the lock-derived licence inventory
│ ├── workspace.mjs project path resolution
│ ├── recipe.mjs recipe reading and provenance
│ ├── box.mjs the build core
│ ├── verify.mjs the consumer's checks, run locally
│ ├── audit.mjs the licence audit verb
│ ├── project.mjs init and doctor
│ └── parity.mjs the accelerator parity gate
├── sign/ key generation, local signing, external dispatch, verification
└── cli.mjs argument parsing and dispatch — thin, logic lives in the modulessrc/contract/ is the source of truth for the format. Other languages mirror it and prove the mirror against fixtures/target-id-contract.json; they do not import it. That is how a Rust client, a Worker, and this builder stay in agreement without sharing a runtime.
Boundaries
Two boundaries hold everything else up.
Paths come from the project, not from Scrollcase. A scrollcase.config.json declares where recipes live and where artefacts go, discovered by walking up from the working directory, overridable per invocation. A tool that derives its paths from its own location on disk only works while it lives inside the project it serves; making the layout the project's declaration is what lets Scrollcase run from anywhere against any project.
The document namespace belongs to the publishing project. Document kinds are <namespace>.release / .channel / .revocations, defaulting to scrollcase.box. A project with boxes already installed in the field keeps emitting the namespace its clients recognise, and Scrollcase carries nobody's brand.
What is deliberately outside
Publishing to object storage, serving or promoting a channel, revoking a release, allocating CI runners, and model-specific scientific validation. Scrollcase stops at a signed, verified box on disk — see Distributing Boxes for what to build on top, and Design Decisions for why each was left out.