Keep names clear when combining modules, and qualify a name when more than one library defines it.
What it is#
Binding identity is (module, name): two modules may define the same top-level name, import "lib" as m is a real namespace (m.kick), and every builtin has a qualified spelling (track.gain, grain.size) beside its flat one. All of it erases in resolve.rs before checking — the IR and engines never learn any of this exists.
Surface#
import "drums" as d -- namespace: everything public, via d.…
import "keys" (pad, ep) -- selective: bare names in song scope
kick = patch { source = kick808, gain = vel } -- the song's own kick
kickT = play d.kick !"c2 ~" -- the library's, qualified — no collision
t = play kick !"c2 c2" |> track.gain 0.8 -- builtin qualified spelling (≡ gainTrack)
- Eager for aliases, lazy for the invisible. An alias-imported module's bindings flatten as
<module-path>.<name>ALWAYS (import "drums" as d⇒drums.kick) — the names never change when unrelated imports come or go. A selectively-imported binding keeps its bare name (the entry claims it); only unclaimed transitive dependencies use collision-lazy naming (bare unless contested — nothing references those names by string). Any program that compiled before namespaces flattens byte-identically. Guard rails: a shred'strigger <synth>target is a real dependency and a renamed target is a hard error; a top-level binding may not share a qualifying module's key (it would collide with kit-instance dot-names); the mixer/group/bus/insert grammars accept dotted names. - Aliases are namespaces, not bare-name dumps.
import "x" as mexposes members only asm.name; a bare reference to a member is out of scope.m.missingandm.privNameget precise errors. User scope shadows an alias (a local binder or own binding namedmkeeps field-projection meaning). privis module-local for real. Private bindings never contest names across modules; colliding private helpers qualify silently.- Bare-scope conflicts still error, with directions: an entry decl vs a selective import of the same name, or the same name selectively imported from two modules, each suggest the
as-alias escape or a rename. - Renaming on import (module-hardening Stage 5):
import "drums" (kick as k808)binds the library'skickask808in this module only. In the song it claims the bare namek808, and the binding flattens under it, so the song may also define its ownkick. Two different bindings may not share one local name in a module. - Re-exports:
pub import "core" (kick, hat as hh)makeskickandhhexports of this module (pubis a soft keyword, meaningful only beforeimport;pub = 1is still a binding). Importers — selectively or through an alias — reach the DEFINING binding, through any number of levels, so a re-exported sound is one binding with one flattened name however many modules pass it on. A re-export may not shadow the module's own binding,privnames stay private, and a plainimportis never an export. - Module identity is the resolved file, not the spelling (C1): two different
tone.voltafiles imported as"tone"from two directories are two modules (the second flattens with the keytone#2), and one file reached as"lib/tone"and"tone"is one module — when the host passes the gatherer's resolution map (resolve_graph; the CLI does). A flatpath -> sourcemap keeps the old by-spelling behaviour. - Builtin origin modules. Every registry builtin carries
(module, member)—builtin_module_of/qualified_builtininbuiltins.rs. Tags:aw fx src pattern mods list form lfo filt env note gran latent io plug spectral track core(mods/gran, notmod/grain— those are common corpus binding names and would be shadowed; a denylist test guards tag choices, and the full map is snapshot-pinned incompiler/tests/snapshots/builtin_modules.txt).coreholdsconst,decide, and the scalar math family (core.sqrt,core.log2, …).awis the vendor tag for the 29 Airwindows ports (AW_BUILTINS—aw.kPlate,aw.srsly,aw.slew…), curated from the registration doc comments and pinned by size inbuiltin_modules.rs. The legacy suffix families get clean qualified members:track.gain≡gainTrack,track.pan≡panTrack,track.eq≡eqTrack,track.crush≡crush,fx.crush≡bitcrush,track.grainSize≡grainSizeT(…all seven-Ts),grain.size≡grainSize. A tag can never equal a registry name (test-enforced — which is whyform/io/filt/plug, sincearrange/midi/filter/pluginare builtins). - The prelude. Every shipped builtin is in it; flat spellings never break. A future builtin registered in
NON_PRELUDEis reachable only qualified — the bare reference errors with the qualified spelling. - The prelude outranks OTHER modules' exports (2026-09-01, production-gaps.md §31 AF3). Scope resolution inside a module is: its own bindings and types → its own imports → the prelude → the "defined in another module but is not imported here" error. User scope still shadows the prelude, as always; what changed is that a name a module never named in an
importno longer collides with some other loaded module's binding of that name.circuits.volta's|> supersaw 7 …is the JP-8000 primitive whether or notpads.voltaalso publishes a patch calledsupersaw, soimport "pads" (choir)andimport "circuits" (nylon)may sit in one song. Deciding it by what else the song imports is exactly the collision(module, name)identity exists to remove. The precise import diagnostic is unchanged for every name that is NOT a builtin. Value and application positions use the same lexical binding. Calling a scalar or patch that shadows a builtin is an error; qualify the intended builtin (src.supersaw,mods.gain,fx.drive). Qualified builtin identity survives flattening and cannot be captured by the flat spelling.
Imported tracks#
An import brings in a library's bindings; which of them become tracks in the song is a separate question, and the answer is: the ones the song actually uses.
``volta norun the reader's ownalice.volta`, not a corpus library import "alice" as alice alice_line = alice.line -- ONE track, named alice_line
A re-binding whose whole value is the imported name and which is the only
thing in the program that names it is a **rename**: the imported binding is
consumed by it, and the IR carries one track under the name the song chose.
Before this, `alice.line` stayed behind as a second, never-started track with
its own copy of every effect — DSP the song paid for and a name every host
listing tracks had to explain. Reachability already dropped a library track
nothing referenced; this closes the case where something *did* reference it,
but only to give it another name.
The gate is deliberately narrow, so nothing a song can still reach disappears:
- it applies only to **imported** bindings — `b = a` inside one file is
ordinary track reuse and still makes two tracks ([[reference/track-reuse]]);
- it applies only to bindings that lower to a **Track** — a patch or a pattern
a song re-binds stays reachable under both names, because a host may still
address `alice.wire` by its qualified name;
- **any other mention keeps it**: a `song { … }`, scene, mixer, `group`, or a
second binding that names `alice.line` means the song wants that track, and
both survive.
The consumed binding is still *lowered*, and still owns its diagnostics: an
error inside the library's track carets into the library, named, exactly as it
did when the copy was emitted.
## Imported strips
A library ships sounds — but the level, placement and routing a library gives
its own tracks are part of those sounds, not part of the song's mix. A
contributing module's `mixer { … }` therefore travels with its tracks as a
**default layer**:
```volta norun two source files shown side by side
-- alice.volta
line = play wire !"c2 e2 g2 e2" |> reverb 0.3
mixer { line = { level = 0.4, pan = -0.25 }
master = { inserts = [limiter (-1dB) 60ms] } }
-- the song
import "alice" as alice
alice_line = alice.line -- level 0.4, pan -0.25, limiter
mixer { alice_line = { level = 0.8 } } -- …except the level, which is ours
- Strips are defaults, overridden field by field. Anything the song's own
mixernames for that track wins; anything it does not name keeps the module's value. The strip lands under the track's final flattened name — including the name a re-binding consumed it under, as above. - A strip is a use of what it reads. A travelled strip may read a binding nothing else in the module does —
kickT = { level = kick }, a plugin synth'smaster = { inserts = [gainTrack output] }, a carried bus'splate 0.5 room. Those bindings flatten with the strip: the surviving strips (their track is reachable),master, and the buses those strips send to are reachability roots inresolve.rs, taken to a fixpoint. A macro only a NON-adopted track's strip reads still stays out. (Before 2026-09-20 such a module failed to import with "namekicknot in scope".) solonever travels. It is a gesture on the song's desk, not a shipped setting: a library that soloed one of its tracks would mute the song.level,pan,width,mute,polarityand routing all do.- Routing carries its buses. A module strip's
sends/presendstravel, and so do thebusdeclarations they name, eagerly qualified (alice.yard). A song that namessendsfor that track replaces the library's routing wholesale rather than stacking on top of it, and a carried bus nothing ends up sending to is dropped again. - A strip for a track the song never adopted is ignored — not an error. Importing one patch out of a twelve-track song must not drag in eleven strips, and a song imported only for its
song { … }value (Play a complete imported song) contributes no strips at all. - The song owns the master. Of a module's
master = { … }, onlyinsertsis even a candidate;level/mono/dim/refLevel/consoleare the song's alone. - A module's master inserts ride a sub-mix minted for that module. The chain is meant for the module's sum, so the compiler declares
<module>.master(Mixing tracks and buses §1.3): every track the song adopted from the module (under the song's names), plus the live voices of every patch of the module the song pulled in, plus the returns of the module's own aux buses that travelled with its strips — so a merged toy sounds as it does alone (its master chain processed its reverb return too), and keys played on its instrument pass its own limiter. The chain is marked inherited (no entry-buffer edit spans). It is an ordinary sub-mix: the song may re-fade it (mixer { acid.master = { level = -6dB } }), replace its chain (inserts = […]),tap "acid.master", or send into it. A track or patch the song placed in asubmixof its own stays there; a module the song adopted no track from mints nothing (a patch borrowed from another song does not drag that song's master limiter in behind it). - Declining it.
mixer { acid.master = { inserts = [] } }declines the imported chain and mints nothing: the members go back to the master sum. A song that re-spelled an imported chain in its ownmaster— the advice the retiredImportedMasterChainwarning gave — now runs both, and awarndiagnostic (DoubledMasterChain) names each repeated insert and this way out.examples/songs/intro-tallyman.voltadeclines: it spells the whole console itself, its ownyardreturn included. - The sub-mix is not the same gain staging. The real master applies its
levelBEFORE its inserts (Mixing tracks and buses § signal flow); a minted sub-mix runs the imported chain at unity and the song's master fader comes after it. A module whose own master had a level other than 0 dB and a nonlinear chain (a clip, a limiter) therefore drives that chain differently imported:spin-06-rotating-hands(master −0.5 dB,clipOnly,limiter) imported with its chain on the sub-mix put the kick's peaks 0.8 dB lower. A film or remix that must sound identical declines the chain and restates it on its own master, as the showcase films do. - The minted name is always spellable. It is the module path with every character an identifier cannot hold written
_(a leading digit gets a_prefix):spin-06-rotating-handsmintsspin_06_rotating_hands.master,acidstill mintsacid.master(lower::module_master_name). A mixer entry written through an import alias whose name differs from the path is rewritten to it (import "acid-house" as ah…ah.master,resolve.rs); an alias the song's own binding shadows is read as that binding. But anasimport qualifies EVERY name of the module (spin-06-rotating-hands.padV), and unset oscillator phases are seeded by patch name, so a song that must sound identical to its source imports by name and uses the identifier spelling. - An imported
@sharedcontrol keeps its module's trajectories. An import names the controlmodule:key(two modules' keys never collide:em-12-a-weather-system-dreams:weather-system-orbit), and the graph carries the key its module declared as itssharedannotation (lower/shared.rs); the engine draws the control's random and simulation state (wander, stepped randoms,orbit/flockworlds) from that key (graph.rs, the control-graph templates), so awanderororbitmoves the same whether its module is the entry or an import. Before 2026-09-26 the draws used the qualified name, and importing a song unchanged changed every voice that read one (engine/tests/imported_strips.rsan_imported_shared_controls_trajectories_match_the_entry). The same key in two imported modules therefore draws the same trajectory. - Groups and unreached tracks do not travel. A module's
groupis not carried, and a track the song reaches through nothing (no scene, strip, group or reference) is not adopted. A film restates the module's groups (group percussion = [hatT, rimT, fillT]and its strip) to sound the same; a track no scene of the module ever plays is silent either way.
Mechanically: resolve.rs renames each contributing module's mix block into the flat namespace and hands it to the checker as Program::module_mixers (plus module_buses, checked against their own source — a library's pipe chains are recorded under its own file, and plate 0.5 |> width 1.2 only reads as a section with that module's pipe_calls in scope); lower/mixer.rs::lower_mixer lays the module strips down before the entry's block, module_master_inserts lowers the chain and lower.rs mints the module's sub-mix around it.
Auto-import quickfix#
An unresolved name that some module in the document's bundle publicly exports gets an import "<module>" (<name>) quickfix — compiler-owned (ide::code_actions, returning CodeAction { title, kind, edits, diagnostic }), so the native workbench and the LSP (textDocument/codeAction) offer the same list. It fires on both producers of the situation: resolve's "X is defined in another module but is not imported here" and the checker's plain unbound name. The edit merges into an existing non-alias import "<module>" (…) list when there is one, otherwise writes a new line under the last import. priv names are never offered, and a name two modules both export offers both actions in module-path order rather than guessing. Exports come from the parse gather_imports already performs (Analysis::module_exports), so the action costs no extra parsing.
One bundle-gatherer#
Import bundles are gathered by the shared volta-sources crate (CLI, volta-studio, LSP, libvolta, plugin target) with one search order: session overrides → importing module's dir → entry dir → extra roots (+ lib/) → workspace (~/Documents/Volta, + lib/) → installed stdlib (VOLTA_STDLIB) → the workspace's bounded recursive search → registry (memoized, sha-256-verified). The flat part of that order is one function, compiler::module_path::candidate_paths, which the editor's find_module_file calls too. Paths are validated and matched case-exactly; a registry pack's own non-@ imports see only the stdlib; every candidate a hit hid is listed in Bundle.shadowed and shown as a volta/shadowed-module note (Reuse sounds and patterns with imports § Notes & limits). The browser's product pages mirror the order over fetch in runtime/src/modules.ts; the browser workbench is handed the shipped stdlib up front (see Reuse sounds and patterns with imports). A song saved under ~/Documents/Volta can import "drums" again.
Registry packs resolve in the browser too (2026-09-16). The wasm compiler is I/O-free — resolve takes a finished path -> source map — so the fetch happens in the preparation worker, before the synchronous compile: gatherModuleSources (runtime/public/studio-module-sources.js) recognises a pack spec, resolves it over GET <apiBase>/registry/<spec> with the browser's session token, verifies the declared sha-256 the way the Rust client does, and memoizes the answer (failures included) for the worker's life. Pack-imports-pack falls out of the existing walk. A supplied session buffer or a corpus file of the same key still shadows a pack, matching the desktop's registry-last order. The origin and token travel in the prepare message (Studio.registryOrigin()), because a module worker has no page globals. rememberShippedImports then pushes what the worker resolved back into the wasm shell's module map, so the editor's own analysis sees it from the next keystroke on. Still missing: a pack import squiggles as cannot find module … until the first Run resolves it — the analysis path (analyze_editor_snapshot) reads only the pushed map and can never fetch. The fix is a small loadPacks() beside loadStdlib() in studio-host.js, using the already-exported source_import_paths.
How it's implemented#
- Compiler —
compiler/src/resolve.rs: per-module scope tables (ModScope), dependency collection byDeclId(collect_refs), reachability, bare-scope claims, final naming, then a scoped rename pass (rename_refs) that rewrites bare refs to renamed bindings, collapsesalias.memberto the member's final name, and rewritesmodule.memberbuiltin accesses to the flat registry name. Prelude enforcement sits inresolve_bare. Module map:builtins.rs(QUALIFIED_OVERRIDES,derive_module,module_tables). - IDE —
compiler/src/ide.rs:builtin_origin_suffix(hover),builtin_module_member_candidates+completion_owner_is_bound(tag-dot completion),code_actions/import_edit/public_exports(auto-import).lsp/src/server.rsmaps the last one ontotextDocument/codeAction. - Tests —
compiler/src/resolve.rs(lazy_qualification_tests),compiler/tests/builtin_modules.rs(totality, injectivity, tag safety, theawcuration, qualified≡flat lowering),ide.rs's hover/completion/ auto-import tests,lsp/src/server.rs's protocol test, andui/tests/prompt.rs's:docorigin assertions, plus the untouched whole-corpus golden IR suite (compiler/tests/golden_ir.rs) as the byte-compat gate. - Gatherer —
volta-sources/(see the crate docs and Names and library imports §5 as-built notes).
Notes & limits#
- Types and constructors have module identity too. Two libraries can each declare
Color; annotations usea.Color, and cases usea.Red. Type aliases, generic payloads and nested annotations retain their defining scope. Selective type/constructor imports reject ambiguous bare names;priv typekeeps its constructors private. - Name a track by its binding; a string is legacy (Language reference §12).
tap drums/duck kit.kickTis an ordinary reference: renamed with every other one when a module flattens, found by the editor. Everything below is about the STRING spelling. - A track name written as a STRING qualifies with its binding (derived from the descriptors). Every port that DECLARES a track-name operand (
TRACK_OR_BUSorTRACK) names a track by string —tap,tapLive,follower,onsets,pitchOf,convLive,specMask,duck,lastPitch,lastVelocity— and so does a shred'srecord … from "drums"(same pass,qualify_macros_stmts); an alias-imported module'stap "drums"is rewritten totap "kit.drums"when that module'sdrumsflattens tokit.drums— the same rulemacro "name"follows, in the same pass (resolve::qualify_macros, whose tablebuiltins::track_name_portsderives from the declarations, so a new reader becomes namespace-aware by declaring its name port and by nothing else). Aduckinsert supplies its receiver when piped and not when it sits in a strip'sinserts, so both argument positions are candidates; the rewrite only ever fires on a string that names a binding of the module being flattened. The identifier spelling (tap drums) was always renamed with every other reference; the literal was not, and resolved to a track that no longer existed, i.e.SourceBus::None— exact silence, with only a warn-levelCausaldiagnostic. The rewrite fires only for a binding of that module whose flattened name actually changed, so a bare-named import stays byte-identical. A module binding shadows an entry binding of the same name here, as it does everywhere else. When the entry's sole re-binding CONSUMES that track (§ Imported tracks), the literal follows it all the way to the re-bound name —keepA = toy.arewrites the library'stap "a"totap "keepA"— so the rack shapealice_line = alice.linecarries the library's own taps, follower and onsets to the track the host starts. Only a string reference written in the SONG keeps the imported copy alive. The one place the two spellings still differ: the library's BINDING at such a seat (tap a) is a mention that keeps the library's own track, so a host's sole re-binding no longer consumes it and the seat reads that (never-started) track, while the string follows the re-binding. No corpus song does this;fmt --modernize, which proves only the file it rewrites, is where it would bite. The visual bridges (vis.onset "kick") declare no name operand, so a library's STRING there is not qualified at all — the binding is the spelling that works. - The same file imported under two different spellings is one module when the host compiles through the gatherer's module graph (the CLI); hosts that pass a flat map (volta-studio's prepare path,
wasm compile, libvolta) still duplicate its DSP (review D11) until they passBundle::graph(). - Shred statement-position names (
set/triggertargets, resolved at lower time) do not all follow a collision rename. These only matter in programs that were errors before namespaces; tracked in Names and library imports §8. - The catalogue (
compiler/assets/docs-data.json) stays module-free: it is the presentation taxonomy. Every presentation surface readsbuiltin_module_ofdirectly instead — the LSP/native hover card (ide::builtin_origin_suffix→ "*builtin* · moduletrack— also writtentrack.gain"), the bench REPL's:doccard (ui/src/prompt.rsbuiltin_origin_line), and the completion of a tag-dot prefix (ide::builtin_module_member_candidates:track.offersgain,grainSize, … inserting the member and naming the flat alias in the detail; any user binding or import alias of the tag's name shadows it). Bare completion is unchanged — flat spellings stay primary. - Entry-owned ambient surfaces (Stage 4): a library's
tempo/meter/key/tuningflattens qualified — a song file imported for its sounds can't hijack the tempo — unless the entry explicitly imports the name (import "kit" (tempo)), which adopts it as before. An imported file'ssong { … }block is value-only; explicitly referenced imported tracks still work. - Located diagnostics (Stage 4): an error inside an imported module renders its caret into that module's source, named (
in moduledrums:), in the CLI, wasmcompile, and volta-studio (LocatedCompileError::render_with_sources); the IDE/LSP and structureddiagnosepaths carried per-file attribution already. Since 2026-09-22 this includes a module's ownbusandmixer { … }blocks: a bad strip in a library carets into the library and squiggles the library's document, in the fail-fast and the recovering checker alike (inference_recovery::a_broken_ambient_block_in_an_imported_library_squiggles_the_librarys_document). A tool that lowers through the plainlower::lower(no source bundle) gets a module-located lowering error re-anchored at the entry call that reached it, its message prefixedin modulex:, so its caret never lands on unrelated entry bytes (lower::tests::a_plain_lower_error_from_a_module_body_points_into_the_entry). - Stdlib adoption (Stage 5):
rhythmexportsrhythmKit(shift: Semitones)(The standard library), the learn course's act IX teaches imports (lessonthe-library), and the auto-import quickfix above closes the discovery loop.