31. Package distribution: git, no registry
- Status: Accepted
- Date: 2026-09-05
Context
ADR 0018 built the module system and deferred this
exact question on the spot: "A package manifest / registry. Way beyond M2.
Path-based resolution needs no configuration and is enough to build with; a
package story can come later without changing import's surface." Milestone
5 is that later point — a Korrin codebase should be able to depend on someone
else's code, not just its own files.
The decisions in play:
- Where does a package's code live, and how does a consumer get it onto disk?
- What identifies a package and a version of it?
- What does a manifest look like, and what does it pin?
- Where does a fetched package land locally, and is that shared across projects or private to one?
Constraints that shape the answer: the project has no backend anywhere today
— every deliverable is either a Rust binary or a static site on Cloudflare
Pages (ADR 0030's editors/vscode/ and
korrin-web/ are the two precedents, and neither talks to a server the
project runs). ADR 0013 asks that a new
dependency be justified and that large transitive trees be avoided. Milestone
6 — a native-module system — is the next milestone specifically because
there is no way today to write a Korrin module in Rust and ship it, so a
package in Milestone 5 can only be Korrin source.
Decision
Distribution is git, with no central hosting
A package is a git repository. There is no upload step, no package registry
service, and nothing this project hosts or runs. korrin pkg clones or
fetches a package directly from wherever its manifest says it lives.
The manifest: korrin.toml
[package]
name = "requests"
version = "0.3.0"
[dependencies]
json = { git = "https://github.com/alice/korrin-json", tag = "v1.2.0" }
testkit = { git = "https://github.com/bob/korrin-testkit", branch = "main" }
scratch = { git = "https://github.com/carol/korrin-scratch", rev = "9f2a1c3b" }
[package].nameand.versionare a package's self-declared identity — shown in diagnostics, inkorrin pkg list, and on the community index (ADR 0034). They play no role in resolving a dependent'simportstatements; see the next point.- A
[dependencies]table key is the name a dependentimports it by, independent of what the upstream package calls itself.import "json"means whatever this manifest callsjson, regardless of whatalice/korrin-json's own[package].namesays. This is the same principle ADR 0018 already gave files (import "./a/config" as cfgrenames at the use site) applied to packages, and it avoids a separate rename field. - Each dependency needs a
gitURL and exactly one oftag,branch, orrev. Zero or more than one is an error fromkorrin pkg add/install, not a silently-picked default. - A package's entry file is the fixed path
src/lib.kor— mirroring Cargo'ssrc/lib.rs— not derived from[package].name, since that name can legitimately differ from what a dependent calls the package.
Packages are leaf-only in Milestone 5
If a package's own korrin.toml declares a non-empty [dependencies] table,
korrin pkg add/install refuses it: "json declares dependencies of its
own — packages with dependencies are not supported in Milestone 5." A
package may only depend on nothing.
The lockfile: korrin.lock
# @generated by `korrin pkg` — do not edit by hand.
version = 1
[[package]]
name = "json"
git = "https://github.com/alice/korrin-json"
rev = "a1b2c3d4e5f6..."
path = ".korrin/packages/json"
Every dependency resolves to a pinned commit, even one specified by tag or
branch — a build stays reproducible even if the upstream ref later moves.
korrin pkg update is the only thing that ever moves a pin.
An application (any korrin.toml with a non-empty [dependencies] table
run via korrin run) commits korrin.lock, for the same reproducibility
reason Cargo asks binaries to commit Cargo.lock. A package meant to be
depended on does not — mirroring Cargo's own binary/library split, and
costing nothing to state now even though leaf-only packages give this rule
little practical bite yet.
No versioning solver
Because a dependency is an exact git ref, not a version range, there is
nothing to solve. korrin pkg add pins a commit at add time. Combined with
leaf-only packages, there is no transitive dependency graph to flatten either
— resolution is: read [dependencies], fetch each entry once, write one
lockfile line per entry. No semver crate is added; it would have no
load-bearing job under this model.
Local storage: project-local, no shared cache
A fetched package lands at .korrin/packages/<name>/, a real checkout (not a
symlink), next to the korrin.toml that declared it — gitignored. Manifest
and lockfile discovery walks upward from the entry file's own directory,
not the process's working directory, matching the principle ADR 0018 already
set for ./ resolution.
Consequences
- Nobody has to trust, fund, or operate a registry for Korrin packages to exist. Anyone with a git host (or a bare local repo) can publish one.
- A package author who wants to depend on another package cannot, yet — that is real functionality given up, not an oversight. It is the direct price of landing packaging with zero new hosted infrastructure and no dependency solver; a transitive story is real future work, not blocked by anything chosen here.
- No global package cache means repeated
korrin pkg installacross different projects re-clones the same repository each time. Acceptable at Milestone 5's scale; a shared cache is legitimate future work (see "Alternatives considered"). korrin.lock's commit-or-not convention needs restating, and possibly revisiting, once transitive dependencies exist and a package can plausibly want its own lock for its own dependencies.- A package pinned by
branchsilently becomes stale untilkorrin pkg updateis run — this is the intended tradeoff (an explicit update step, not automatic tracking), matching howrev/tagpins already work.
Alternatives considered
- A hosted registry (crates.io-style: upload, storage, an API). Real functionality — search, download counts, a single canonical source — but needs a backend this project has never had, funded and operated indefinitely. Far more than packaging needs to exist at all.
- Vendoring only (copy a dependency's source into your own repo, no live reference). Simplest possible, and needs nothing this ADR designs — but it doesn't solve "depend on someone else's code" in any lasting way; every update is a manual re-copy with no record of where it came from.
- A shared global cache (
~/.korrin/cache/, à la~/.cargo/registry, reusable across projects viagit worktree addor a maintained bare mirror). More bandwidth- and disk-efficient, and the design Cargo/uv actually use — but it is real, ongoing machinery: locking against concurrentkorrin pkgruns, a staleness/GC policy, and a first cross-platform cache-directory decision. On Windows specifically, the natural cheap sharing mechanism — a symlink — needs Developer Mode or elevated privileges, a concrete wrinkle worth avoiding for a first cut. Legitimate future work once the project-local approach's cost is actually felt. - Transitive dependencies from the start, with a real solver. The standard shape for a package manager, but a dependency-graph solver is substantial, separately-scoped work, and nothing about Milestone 5's actual goal — letting one person depend on another's code at all — needs it yet.