ADR 0002: remote spells are imported by registry path
- Status: Accepted
- Date: 2026-09-22
- Supersedes: the
import "oci://<registry>/<repository>@sha256:<digest>" as x;form drafted in the remote spells change, which never shipped.
Context
Spells are compiled into the binary or read from the workspace. Publishing a spell to an OCI registry decouples its versions from magus's, and lets any workspace use a spell another team maintains. The engine for that exists: a digest-pinned OCI client, one resolver every spell consumer goes through, and a deterministic layer built from tracked files. What this records is how a magusfile NAMES such a spell, because that name becomes part of every magusfile that uses it and changing it later breaks them.
The Buzz resolver, as it behaves today
An import path is a plain string. The resolver substitutes the whole path for ? in each
search template (?.buzz, ?/main.buzz, ?/src/main.buzz, ?/src/?.buzz, under each
search root), and binds the module under the path's LAST segment unless the import is
aliased (libs/gopherbuzz/session.go, resolveImport and expandSearchPath, matching
upstream Buzz's import guide). Dots and slashes are ordinary path characters. Upstream
reserves one scheme, buzz:, for its own stdlib (import "buzz:os").
Precedents
Every dependency system surveyed separates three things: a manifest declares a name and a version, a tool-written lock beside it pins the exact bytes, and code imports a NAME. None puts a URL in the import.
| System | Declared in | Locked in | Code refers to |
|---|---|---|---|
| Go | go.mod |
go.sum |
the module path, which is the repository location |
| Dagger | dagger.toml (formerly dagger.json) |
its lock | a generated name, dag.hello() |
| Cargo | Cargo.toml |
Cargo.lock |
the crate name |
| Helm | Chart.yaml (OCI repositories allowed) |
Chart.lock |
the chart name or alias |
| Nix | flake.nix inputs |
flake.lock |
the input name |
| Deno | deno.json imports |
deno.lock |
a bare specifier |
Go also supplies the rule that tells a reader, without looking anything up, whether an
import is local: a path whose first element contains a dot (github.com/...) is a module
fetched from its host, and one without (fmt, net/http) is the standard library.
Decision
-
A remote spell is imported by its registry path without a scheme, the way a Go import path is its repository:
import "ghcr.io/egladman/magus/spells/go"; // binds `go` -
Three kinds of import, each readable from the path alone:
- a dot in the first element (
ghcr.io/...) is a remote spell; - the
magus/prefix (magus/spell/go) is embedded, provided by the binary, and keeps its current spelling; - anything else (
spells/harness/cursor,./tools/drift) is a workspace path.
Go reserves dotless paths for its standard library because all other Go code is imported by a dotted module path. A magusfile also imports workspace files by dotless paths, so magus needs the
magus/prefix to do the job the dot rule does for Go. - a dot in the first element (
-
magus.yamldeclares each remote spell, keyed by that same path, with the tag it tracks and, later, the trust it requires:spells: ghcr.io/egladman/magus/spells/go: tag: "1.4" -
magus.lockpins each declared spell's digest. It sits besidemagus.yamlwith the same stem, asgo.sumsits besidego.modandCargo.lockbesideCargo.toml, is written only by magus, and is YAML to match its manifest, with sorted keys for stable diffs. It is committed. -
Only the
updatecharm resolves a tag.magus run <target>:update, on the target that declaresmagus.lockas its output, asks the registry what each declared tag points to, rewrites the lock, and pulls and verifies the new artifacts. Every other run reads the locked digest only: deterministic, offline-capable once cached, and never contacting a registry to learn what a tag means. -
A verified artifact is materialized under a cache root laid out by path, and that root joins the magusfile search roots, so the ordinary templates resolve the import (
?/spell.buzzfor a spell, the layout every published spell carries). The parser never sees a URL. -
An override is declared in
magus.yaml, like Go'sreplace. The import string never changes: code keeps sayingimport "magus/spell/go";orimport "ghcr.io/team/spells/lint";, and one entry redirects it to a workspace copy.spells: magus/spell/go: path: spells/go # the workspace copy replaces the embedded spell ghcr.io/team/spells/lint: path: vendor/lint # or a local copy replaces a remote oneThe declaration IS the acknowledgment. A workspace spell that would shadow an embedded or declared one without such an entry is an error, the rule MGS1002 already applies to a nested spell shadowed higher in the tree. An override is never inferred from a file existing, because a stray directory would then change what runs with nothing in any import or manifest diff to show it.
-
Misconfiguration is an error, each with its own code and doc page: a dotted import with no
magus.yamlentry; a declared spell with no lock entry, or whose lock entry was written for a different tag; an override whosepathholds no spell; an undeclared shadow of an embedded or declared spell.
Consequences
- An import names exactly one repository, so moving a spell to another registry is a visible change to every importer, as moving a Go module is. That is the price of an unambiguous name, and it is paid on purpose.
- Code that uses a spell does not change when the spell becomes remote: the import string changes and the bound name does not.
- The lock is the supply-chain record. Reviewing an upgrade means reviewing the lock diff
the
updatecharm produces. - Signing (who published a digest, not just which bytes) attaches to the same
magus.yamlentries and is decided separately.
Alternatives rejected
- A scheme in the import (
import "oci://...@sha256:..."). Puts:and//into what becomes a filesystem path, puts a digest into every magusfile that uses the spell so every upgrade touches all of them, and invents syntax inside the language that upstream Buzz does not have. - Bare names (
import "go"). Ambiguous between a built-in, a workspace module and a remote spell, and two registries publishing the same short name would collide. - Dropping the
magus/prefix from built-ins (spell/go, or barego). With declared overrides and an error on any undeclared collision (decision 7), nothing would silently resolve to the wrong spell, so the prefix is not needed for correctness. It stays for STABILITY: workspaces already import their own modules by bare names (coverage,badge), so without a namespace magus owns, each new built-in added in a release could collide with a workspace module named first and turn a magus upgrade into a load error. Go gets that guarantee from its dot rule, since user code is always dotted; magus gets it from the prefix. Dropping it would also rename every existingmagus/spell/...import for no gain. - Implicit override by file presence (a workspace
spells/gosilently replaces the embeddedgo). It keeps the call site identical, which is right, but makes the override invisible: nothing in an import or a manifest says the embedded code no longer runs. The declared override in decision 7 keeps the call site identical and makes the replacement reviewable. Precedent: Go'sreplace, Cargo's[patch]. - The same path for built-in and remote (
magus/spell/goserved by a registry). Hides where the bytes come from, which is the one fact a reader of a supply-chain boundary needs. - Declarations in the magusfile. Remote spells must resolve before any Buzz loads, so declaring them in Buzz is a bootstrap loop.
- A tag resolved at run time. Makes two runs of the same commit able to execute different bytes, which is what the lock exists to prevent.
Resolved questions
- The target that owns
magus.lockisspell-lock, declared in the rootmagusfile.buzzwithmagus.lockas its output. It is a thin wrapper overmagus spell lock, which never loads the workspace: plain, it checks the lock againstmagus.yamland verifies every pinned digest; under theupdatecharm it runsmagus spell lock --update, the only code path that resolves a tag. The name is this repository's convention; the lock names no target, so another workspace may call its own anything. A stale pin fails the import that names it, not the workspace load, so the target still loads to repair it; when the lock-owning magusfile imports the stale spell itself,magus spell lock --updateis the escape and every error says so. - An override points at a workspace
pathonly, never at another registry path. A fork is served today by publishing it under its own path and changing the import, which is the visible move decision 1 already pays for on purpose. A registry-to-registry replace would put a second name in the lock for one import, a second pin to review per upgrade, and a resolution chain for a case nobody has asked for; it can be added later as areplace:key without changing anything decided here.