magus v0.4.3 is out. See what's new
¶ View markdown source · ✎ Suggest an edit
7 min read

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

  1. 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`
    
  2. 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.

  3. magus.yaml declares 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"
    
  4. magus.lock pins each declared spell's digest. It sits beside magus.yaml with the same stem, as go.sum sits beside go.mod and Cargo.lock beside Cargo.toml, is written only by magus, and is YAML to match its manifest, with sorted keys for stable diffs. It is committed.

  5. Only the update charm resolves a tag. magus run <target>:update, on the target that declares magus.lock as 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.

  6. 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.buzz for a spell, the layout every published spell carries). The parser never sees a URL.

  7. An override is declared in magus.yaml, like Go's replace. The import string never changes: code keeps saying import "magus/spell/go"; or import "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 one
    

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

  8. Misconfiguration is an error, each with its own code and doc page: a dotted import with no magus.yaml entry; a declared spell with no lock entry, or whose lock entry was written for a different tag; an override whose path holds 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 update charm produces.
  • Signing (who published a digest, not just which bytes) attaches to the same magus.yaml entries 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 bare go). 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 existing magus/spell/... import for no gain.
  • Implicit override by file presence (a workspace spells/go silently replaces the embedded go). 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's replace, Cargo's [patch].
  • The same path for built-in and remote (magus/spell/go served 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.lock is spell-lock, declared in the root magusfile.buzz with magus.lock as its output. It is a thin wrapper over magus spell lock, which never loads the workspace: plain, it checks the lock against magus.yaml and verifies every pinned digest; under the update charm it runs magus 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 --update is the escape and every error says so.
  • An override points at a workspace path only, 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 a replace: key without changing anything decided here.
adrdecisionspellsociimportsbuzzlockfilesupply-chain
Last updated (139df068)
Glossary

Workspace

The magus root directory that owns a set of projects and shared config; the unit magus operates over. See workspace.

Magusfile

The magusfile.buzz that declares a project's targets (as export funs) and binds its spells. See targets.

Target

A named operation (build, test, ...) you invoke with magus run <target>; it may compose a spell's tool-native operations and depend on other targets. See targets.

Op

A single tool-native command a target composes (long form: operation); the middle of the work hierarchy (Spell to Op to Target). See operations.

Spell

A language/runtime adapter (e.g. go, md) that maps generic targets onto a toolchain's real commands. See spells.

Charm

An execution modifier attached with : (lint:rw) that changes how a target runs, not which one; the built-in rw flips a check-only target to mutate in place, and ci always strips it. See charms.

Module

A magus stdlib namespace a magusfile imports for host capabilities: filesystem, exec, vcs, and more. See the module reference.

Buzz

The language magusfiles are written in (the .buzz engine). See engines.

Engine

The interpreter a magusfile runs on; magus embeds the Buzz engine. See engines.

Cache

The content-addressed store magus consults before running a target, so unchanged work is skipped. See cache.

CI

An ordinary magusfile-defined target you compose yourself with magus\needs - magus does not hardcode its stages. Magus.RunCI treats it specially only in that it strips the rw charm, it is the anchor magus affected ci keys off, and a selected scope with no project declaring it is a load error rather than a silent no-op. See targets.

Session

An agent host's conversation, by the id the host delivers to its hooks. magus never mints one: a record with no session is unattributed, and the OS user it carries says whose account ran it.

Job

The unit of delegated work, and one row of the job store: what an orchestrating agent handed out, with its goal, the checkpoint it was cut against, the paths it may write or must not touch, and the one check it runs. A job's holder is either a session, for work an orchestrator handed out, or the server, for its own maintenance. The store records; the agent guard is what reads those facts back when grading a write. See doctrine.

A job is not a run. magus run build web is a run, and no job exists for it. A job causes runs: its check executes as one, and a server job records the invocation of its last one. Jobs are listed with magus ls jobs and in the console's Jobs view; runs are listed in the Runs view.

Run

One target executing under one magus invocation, such as magus run test web or magus affected ci. A run keeps its captured output behind an output reference. Every magus run is a run whether or not any job asked for it; see Job for how the two relate.

Lease

The grant a holder takes on a job: the write and read paths that job declared, enforced in the checkout that took it with magus job exec. A job is the piece of work; a lease is permission over it.

Conventions

Placeholders

Angle brackets mark a value you replace with your own - never type the brackets:

magus run <target>
magus completion <shell>    # e.g. bash, zsh, fish

<target>, <path>, <shell>, <name> and the like are stand-ins, not literal text.