RFD 0030 — Package dependencies ([dependencies], path deps v1)
- State: committed
- Opened: 2026-06-12
- Decides: how an Argon package declares a dependency on another package and consumes
its
pubsurface — the mechanism that makes vocabulary publishing real. A vocabulary team ships a package (e.g.argufo); a modeler in a separate package declares[dependencies] argufo = { path = "../argufo" }, writesuse argufo::kind;, and usesargufo’spub metatype kindas a declaration keyword in its own catalog. Built as the Wave-A arc7-packaging work of the v0.2.1 program; closes register blocker R-B1 and majors R-M1 (use-import diagnostics) and R-M12 (manifest honesty). Builds D1 of RFD 0022 (which reserved “a dependency package name ([dependencies])” as a resolution root) and the §3.3/§3.4 manifest/resolution surface. Relates to Modules and Build.
This RFD records, as built, the dependency mechanism the book described aspirationally (§3.3
[dependencies], §3.4 dependency-package roots, §16 the ox orchestrator’s package graph). v1 is
path dependencies only; the registry/version/git surface is recognized and refused loudly,
reserved for a later RFD.
Question
Until now ox.toml parsed only [package]/[project]/[schema], and the resolver registered
only the embedded stdlib (std::*) plus intra-package paths. The two-package vocabulary journey —
the central use case for an ontology-modeling language — was impossible: every vocabulary and
every model that used it had to live as sibling modules in one package. The book documented the
opposite in detail, “as if real.”
- How does a package name and resolve a dependency? What
[dependencies]shape, what resolution semantics, what happens across the package boundary foruse, qualified paths, and — the acceptance test — dependency-provided introducers (pub metatype/pub metarel)? - What about the registry/version surface the book showed?
ufo = "1.0"is a version requirement against a registry that does not exist. Accept it silently (the old failure mode), or refuse it? - What is honest about an
ox.tomlthe compiler does not fully consume — unknown sections,[lattice].max_tier?
Context
The substrate already composes a multi-file package into one artifact: the driver’s
pre-elaboration sweep builds a WorkspaceSymbols table over every reachable module, the §3.4
introducer gate (resolve_metatype_introducer/resolve_metarel_introducer) resolves a declaration
keyword against the pub metatype/pub metarel declarations visible in the workspace, and the
resolver (oxc-resolver) resolves use/qualified paths over the workspace’s file set. The
embedded stdlib is folded into that same file set via synthetic <stdlib>/std/<pkg>/root.ar paths
(oxc_instantiate::stdlib_source_files). A dependency package is the same shape as the stdlib:
a set of pub-surfaced modules folded into the consumer’s workspace.
So the mechanism is not a new subsystem. It is: parse the dependency, load its modules, give them a
package-qualified namespace, fold them into the workspace exactly like the stdlib, and teach the
resolver to treat the dependency package name as a path root. Everything downstream — the
introducer gate, use, qualified paths, module extraction, the .oxbin — composes for free,
because all of those already operate over the workspace.
Decision
D1 — Manifest: [dependencies], path deps only
[dependencies]
argufo = { path = "../argufo" }
- The key is the dependency name the consumer uses as the path root (
argufo::Person,use argufo::kind;). It must equal the dependency package’s own[package].name— a mismatch is refused with OE1241, naming both the declared key and the found package name. (A rename-on-import surface is a possible later extension; v1 keeps the package’s published name authoritative, as Cargo does by default.) - The only supported source is
path = "<relative path to a package directory>". The path is resolved relative to the consumer package directory; it must point at a directory containing anox.toml. - A shorthand string requirement (
argufo = "1.0") or any of the registry/VCS keys (version,git,branch,tag,rev,registry) is recognized and refused with OE1240, naming the feature (“registry/version dependencies are not yet supported; cite RFD 0030”) rather than silently ignored. This is the loud-refusal discipline: a documented input is never silently dropped.
D2 — Resolution: a dependency is a package-qualified namespace folded into the workspace
For each declared path dependency, the workspace loader:
- Loads the dependency package (its own
ox.toml+ entry + reachable module closure), exactly as it loads the consumer package. - Re-homes the dependency’s modules under synthetic VFS paths
<dep>/<name>/…and assigns each a package-qualified module path rooted at the dependency name: the dependency’srootmodule becomes moduleargufo, its submoduleendurantbecomesargufo::endurant, and so on. - Folds the dependency’s source files into the consumer’s
Workspacefile set and its modules into the package’s reachable-module list — the same fold the stdlib already gets.
The resolver (oxc-resolver) recognizes a leading path segment equal to a registered dependency
name as a root (§3.4): argufo::Person and use argufo::kind; resolve into the dependency’s
<dep>/argufo/… namespace, the way std::core::rel resolves into <stdlib>/std/core/…. A
dependency root is detected from the synthetic-path pattern — a leading segment argufo is a
dependency root iff the dependency’s root file <dep>/argufo/root.ar is in the workspace file set —
so no separate dependency-name registry is threaded onto the Workspace input (the same way the
stdlib is recognized by <stdlib>/std/<pkg>/root.ar). Inside a dependency, pkg:: anchors at that
dependency’s root, so a multi-file dependency’s internal package-absolute paths resolve within its
own subtree. Only the dependency’s pub
(and pub use-re-exported) surface is visible to the consumer; module visibility rules (§3.1) are
unchanged across the boundary.
Because the dependency’s pub metatype/pub metarel declarations are in the workspace symbol
table, the §3.4 introducer gate resolves them transparently: after use argufo::kind;, a consumer
declaration pub kind Person { … } resolves kind to argufo::kind and elaborates as a concept
under that metatype — the acceptance property. A dependency-shipped catalog check (a
pub check over iof/meta/specializes) runs on the consumer’s catalog at the consumer’s
ox check, because the dependency’s rules and the consumer’s individuals share one elaborated event
stream. That is what makes a vocabulary package real, and it is tested (see Consequences).
Transitive dependencies resolve recursively (a dependency’s own [dependencies] are loaded into
the same workspace). Cycles are refused loudly with OE1242, naming the cycle path. Two
declared dependencies resolving to the same canonical directory are one package (de-duplicated,
not an error). The same dependency name resolving to different canonical directories is
refused with OE1243, naming both paths.
D3 — Artifact: dependency modules embed into the consumer’s .oxbin (monolithic, v1)
Dependency modules are elaborated into the same event stream as the consumer’s modules and embed
into the consumer’s single .oxbin, exactly as the stdlib embeds today (§16.3). v1 is one
monolithic artifact per ox build; the per-package .oxc cache + workspace merge (§16.1) remains
the reserved v0.2+ shape. Because the dependency’s elaborated content participates in the event
stream, module/content hashes (§16.5) incorporate it: a change to a dependency changes the
consumer’s artifact_hash.
D4 — No lockfile for path dependencies
Path dependencies are unlocked (Cargo precedent — a path dep is whatever is at that path now).
There is no ox.lock for a path-only dependency graph. ox.lock is reserved here for the
registry/git story (D1’s refused surface): a versioned/VCS dependency graph needs a lockfile to pin
resolved versions; a path graph does not.
D5 — Use-import diagnostics (R-M1): loud, never silent
A use that does not resolve is refused loudly:
use a::b::C;where the path does not resolve emits OE0103 (UnresolvedUseImport) at theuseitself, with a did-you-mean suggestion over the names visible in the target namespace (the dependency’spubsurface, a sibling module, an intra-package path). Previously a brokenusewas accepted clean and the only error was a misleading downstream OE0605 far from the cause.- A glob
use pkg::*;/use argufo::*;resolves correctly (it already does, via the re-export-awaremodule_exports) — it brings in the target’spubsurface. A glob whose prefix does not resolve is the same OE0103 as a named import. A glob is never a silent no-op.
D6 — Manifest honesty (R-M12)
- An unknown
ox.tomlsection or key (anything outside the recognized[package]/[project]/[schema]/[dependencies]/[lattice]/[standpoints]surface, or an unrecognized key within them) emits a Cargo-style OW1240 (UnusedManifestKey) warning naming the section/key — it no longer parses withserdesilently dropping it. [lattice].max_tieris wired to the §10 tier classifier: it sets the artifact’s tier ceiling, and a declaration whose classified tier exceeds the ceiling is refused atox check/ox buildwith OE1230 (TierCapExceeded, previously reserved). The ceiling string is validated against the seven-tier ladder names; an unknown tier name is OW1240.
Rationale
- A dependency is the stdlib shape. The most robust, lowest-surface-area design reuses the
fold-into-workspace machinery the stdlib already proves. The introducer gate,
useresolution, qualified paths, module extraction, and the.oxbinall already operate over the workspace; a dependency that lives in the workspace inherits all of them with no new code path. This is why the acceptance property (imported introducers + imported checks fire on the consumer’s catalog) falls out rather than being special-cased. - Path-first, registry-refused. Path deps are the v1 vocabulary-authoring need (a Sharpe team’s packages live in one tree). A registry is a distribution-infrastructure project (provenance, resolution, lockfiles) orthogonal to the language. Recognizing-and-refusing the registry keys keeps the manifest forward-compatible and honest: the book can show the shape, and a user who writes it gets a feature-named refusal, not a silent no-op.
- No lockfile for path deps matches Cargo and the “no ceremony you don’t need” instinct: a path graph is fully determined by the filesystem.
- Loud manifest, loud imports. Both R-M1 and R-M12 are instances of the program’s silent-accept blocker class. A mistyped import or a typo’d manifest key that the compiler silently swallows is a trap; a warning/refusal that names the thing is debuggable.
Alternatives
- A separate dependency resolver pass that produces per-package
.oxcand a workspace merge (the full §16.1 orchestrator). Deferred: it is the right end state for incremental builds and a registry, but it is a large build-system project; folding into one workspace is correct for v1 and keeps the artifact monolithic (§16.3’s stated v0.x shape). - Allow
version/gitto parse and warn, resolving via path anyway. Rejected: a version requirement that silently resolves to a path is exactly the silent-wrong the program forbids — the user thinks they pinned a version. - Rename-on-import (
argufo = { path = "…", package = "ufo_foundational" }). Deferred: a useful ergonomic, but v1 keeps the published[package].nameauthoritative (mismatch = OE1241), matching Cargo’s default. - A lockfile for path deps. Rejected (Cargo precedent): a path graph has nothing to lock.
Consequences
ox.tomlnow has a[dependencies]table and recognizes[lattice]/[standpoints]; unknown keys warn (OW1240).[lattice].max_tieris enforced (OE1230, no longer reserved).- No change to the
Workspacesalsa input: dependency roots are detected from the synthetic<dep>/<name>/root.arpath pattern in the file set (mirroring stdlib’s<stdlib>/std/<pkg>/…), soWorkspace::new’s signature is unchanged. - New diagnostics (allocated in
grammar.toml, generated viacargo xtask gen): OE0103UnresolvedUseImport, OE0104GlobImportUnsupported(reserved — glob currently resolves; held for a future feature-named refusal of an unsupported glob shape), OE1240DependencyVersionUnsupported, OE1241DependencyNameMismatch, OE1242DependencyCycle, OE1243DuplicateDependencyName, OE1244DependencyPackageLoadFailed, OW1240UnusedManifestKey. OE1230TierCapExceededis wired (was reserved). - The two-package journey is the living proof:
examples/vocab_pkg_v0(a UFO-shaped vocabulary package withpub metatype kind/category, apub metarel, and apub checkcatalog rule) andexamples/vocab_consumer_v0(a separate package,[dependencies] vocab_pkg_v0 = { path = ".." }, declaringpub kind Personvia the dependency’skind, with the dependency’s catalog check firing on the consumer’s catalog). Both are corpus-pinned and run in the examples harness; GO-journey (a) of the v0.2.1 release gate.
Open questions
- When the registry/git story lands (its own RFD),
ox.lockand the per-package.oxc+ workspace merge (§16.1) become live; the path-dep fold described here stays as the unlocked fast path. - Should a future rename-on-import key (
package = "…") be added once a registry makes the published name vs. local name distinction load-bearing? - Should
pub(pkg)become a true visibility cut at the dependency boundary (RFD 0022 open question) — i.e. should a dependency’spub(pkg)items be invisible to a consumer (todaypub(pkg)==pub)? v1 keeps the RFD 0022 simplification.