Package Manager Development: Versions, Manifests, Dependency Resolution, and Lockfiles¶
Part 1 of 5 of the Package Manager Development reference (plugin
package-manager-development), covering §0–§4. Sibling skills:package-manager-registries-and-installation(§5–§7),package-manager-supply-chain-and-workspaces(§8–§10),package-manager-ux-ecosystems-and-governance(§11–§14),package-manager-reference(§15–§20). Section numbers are shared across the set; a reference written as §N →skillpoints into that sibling skill.Currency: Verified August 2026. See §17 →
package-manager-referencefor the currency snapshot and what goes stale first.How to read this. Reference, not tutorial. Sections are independent. Three markers: - [DURABLE] — computer science, or a lesson every ecosystem has independently learned. Does not expire. - [ECOSYSTEM] — specific to npm, PyPI, crates.io, Go, etc. Verify against that ecosystem's current docs. - [CONTESTED] — the ecosystems have made genuinely different, defensible choices, or the practitioners disagree. Both cases given.
⚠️ GOTCHA boxes mark the design mistakes that produce unreproducible builds, wedged caches, exponential resolutions, or supply-chain compromise.
The framing that organizes everything below: a package manager is a distributed systems problem wearing a CLI. It is a cache-coherency problem, a constraint-solving problem, a trust-and-identity problem, and a compatibility-contract problem, all of which happen to be invoked by typing
install. Every serious difficulty in this document is one of those four, and treating it as "just downloading files" is the root of most bad designs.
§0. Routing¶
0.1 The anatomy — every package manager has these parts¶
┌──────────────────────────────────────────────────────────────────┐
│ MANIFEST what the human wrote: deps + constraints │ package.json, pyproject.toml,
│ (+ metadata, scripts, workspaces) │ Cargo.toml, go.mod
├──────────────────────────────────────────────────────────────────┤
│ RESOLVER constraints + registry metadata → exact versions│ §3
│ This is the hard part. It is NP-complete. │
├──────────────────────────────────────────────────────────────────┤
│ LOCKFILE the resolution, frozen, with integrity hashes │ §4
├──────────────────────────────────────────────────────────────────┤
│ FETCHER registry API → tarballs; retries, mirrors, auth │ §5, §7
├──────────────────────────────────────────────────────────────────┤
│ VERIFIER hashes, signatures, attestations, policy │ §8
├──────────────────────────────────────────────────────────────────┤
│ CACHE content-addressed store; global, shared │ §6
├──────────────────────────────────────────────────────────────────┤
│ LINKER cache → project layout (copy/hardlink/symlink) │ §6
├──────────────────────────────────────────────────────────────────┤
│ BUILDER compile/build native code; run lifecycle scripts│ §9 ← the danger zone
├──────────────────────────────────────────────────────────────────┤
│ RUNTIME RESOLUTION how the language finds a module at run time │ §6.4
└──────────────────────────────────────────────────────────────────┘
[DURABLE] These are separable, and separating them is the single best structural
decision you can make. Resolution should be a pure function of (manifest, registry
metadata) with no I/O side effects, so it is testable, cacheable, and can run offline
against a snapshot. Installation should be a pure function of (lockfile, cache). Package
managers that entangle resolution with fetching and installation are the ones that can't
do --dry-run, can't do offline installs, can't produce deterministic lockfiles, and
can't be tested without a network.
0.2 The question router¶
| Asked about... | Go to |
|---|---|
| Version schemes, semver, constraint syntax, ranges | §1 |
| Manifest design, metadata, what to put in it | §2 |
| Resolution algorithms, SAT, PubGrub, MVS, error messages | §3 |
| Lockfiles, reproducibility, integrity hashes | §4 |
| Registry design, APIs, immutability, yanking, mirrors | §5 → package-manager-registries-and-installation |
| Caching, install layouts, hardlinks, hoisting, PnP | §6 → package-manager-registries-and-installation |
| Publishing, authentication, tokens, trusted publishing | §7 → package-manager-registries-and-installation |
| Supply-chain attacks, provenance, Sigstore, SLSA, policy | §8 → package-manager-supply-chain-and-workspaces |
| Lifecycle scripts, native builds, sandboxing | §9 → package-manager-supply-chain-and-workspaces |
| Workspaces, monorepos, path/git deps, overrides | §10 → package-manager-supply-chain-and-workspaces |
| Performance: parallelism, metadata, network | §11 → package-manager-ux-ecosystems-and-governance |
| UX: error messages, output, CLI design | §12 → package-manager-ux-ecosystems-and-governance |
| Ecosystem comparison table | §13 → package-manager-ux-ecosystems-and-governance |
| Governance, funding, deprecation, name squatting | §14 → package-manager-ux-ecosystems-and-governance |
| "Don't do this" | §15 → package-manager-reference |
| "Which approach is better?" | §16 → package-manager-reference (contested) |
| "Is this still current?" | §17 → package-manager-reference |
| Books, papers, people | §18 → package-manager-reference |
§1. Versions and Constraints¶
1.1 Semantic versioning — what it promises and what it delivers¶
SemVer (MAJOR.MINOR.PATCH[-prerelease][+build]): MAJOR for breaking changes, MINOR
for backward-compatible features, PATCH for backward-compatible fixes.
[CONTESTED, with unusually good empirical evidence.] SemVer is a social contract enforced by nothing, and the research consistently finds it is widely violated: - A large Maven study found ~20% of non-major upgrades contained breaking changes, though only ~8% of client programs were actually affected. - Another Java study found a majority of libraries had at least one syntactically breaking patch upgrade. - In Rust, a study of yanked crates.io releases found "breaking SemVer" was the leading reason for yanking, at ~43% — maintainers discovering after publishing that they'd broken the contract. - Hyrum's Law is the underlying reason: with a sufficient number of users of an API, all observable behaviours of your system will be depended on by somebody — so even a bug fix is a breaking change for someone.
The strongest critique (Hynek Schlawack, and the Software Engineering at Google argument) is that SemVer over-predicts breakage — consumers use a small fraction of any API, so a "major" bump is usually harmless to any given caller — while simultaneously under-predicting it, because "patch" changes break people via Hyrum's Law. Treating the version number as a machine-checkable compatibility guarantee is the error; treating it as a statement of the author's intent is correct and useful.
Design implication for a package manager author: don't build a system whose safety depends on SemVer being honoured. Build one where (a) the lockfile is the source of truth, (b) upgrades are an explicit, reviewable action, and (c) you have a mechanism (yank, audit, cooldown) for when the contract is broken anyway.
1.2 The version scheme decision¶
| Scheme | Example | Used by | Notes |
|---|---|---|---|
| SemVer | 2.4.1-rc.1+build |
npm, Cargo, Go, most modern | Well-defined precedence; prerelease sorts before release |
| PEP 440 | 2!1.4.1rc1.post2.dev3 |
Python | Epochs, .postN, .devN, local versions (+local). More expressive, harder to implement |
| Debian | 1:2.4.1-3ubuntu2~20.04 |
apt | Epoch, upstream version, revision. Comparison rules are genuinely subtle (~ sorts before empty) |
| RPM | 1:2.4.1-3.el9 |
dnf/yum | Epoch:Version-Release; rpmvercmp |
| Maven | 1.4.1.RELEASE, 1.4-SNAPSHOT |
Maven | Loose; qualifier ordering is famously surprising |
| CalVer | 2026.8.1 |
Ubuntu, pip, some libs | No compatibility claim at all — arguably more honest |
⚠️ GOTCHA — prerelease precedence is where implementations disagree.
1.0.0-alpha<1.0.0-alpha.1<1.0.0-alpha.beta<1.0.0-beta<1.0.0-rc.1<1.0.0. And prereleases must not satisfy a normal range unless explicitly opted into — if^1.0.0matches2.0.0-alpha.1, you will ship an alpha to everyone. npm, Cargo, and pip all handle this specially, and every naive implementation gets it wrong.⚠️ GOTCHA — version equality vs. normalization. Is
1.0the same as1.0.0? Is1.0.0+build1the same as1.0.0+build2? (SemVer says build metadata is ignored in precedence — so they compare equal, which means a registry must not allow both.) Is1.0.0-RC1the same as1.0.0-rc1? Decide, normalize on ingest, and store the normalized form. Ambiguity here becomes a cache-poisoning vector.
1.3 Constraint syntax¶
^1.2.3 caret >=1.2.3 <2.0.0 (npm, Cargo) ⚠️ For 0.x, ^0.2.3 means >=0.2.3 <0.3.0
~1.2.3 tilde >=1.2.3 <1.3.0 (npm) ⚠️ npm ~ and Cargo ~ differ subtly
~=1.2.3 compatible >=1.2.3 <1.3.0 (PEP 440)
1.2.* wildcard
>=1.2,<2 range
=1.2.3 exact/pin
1.2.3 "1.2.3" ← MEANS DIFFERENT THINGS: exact in npm, ^1.2.3 in Cargo, minimum in Go
* any ⚠️ crates.io rejects wildcard deps on publish
[DURABLE] Design the constraint language for the reader, not the writer. Every ecosystem that invented clever operators regrets it. The two decisions that matter: 1. What does a bare version mean? Exact, caret, or minimum. This is the highest-traffic syntax in your entire system and there is no consensus across ecosystems — pick, document loudly, and never change it. 2. Are ranges allowed at all? Go says no (§3.4). That single choice removes most of the resolver's complexity and most of its usefulness.
§2. The Manifest¶
2.1 What belongs in it¶
| Field group | Contents | Notes |
|---|---|---|
| Identity | name, version, (namespace/scope) | Name rules are a security surface (§8.3 → package-manager-supply-chain-and-workspaces) |
| Dependencies | runtime, dev/test, build, optional, peer | The categories are the design |
| Metadata | description, license (SPDX!), authors, repository, homepage | repository powers provenance (§8.2 → package-manager-supply-chain-and-workspaces) |
| Constraints | language/runtime version, platform, architecture | |
| Entry points | main/exports/bin/scripts | |
| Build config | build system, features/extras, scripts | |
| Workspace | members, shared constraints | §10 → package-manager-supply-chain-and-workspaces |
Dependency categories — get these right, they're hard to change:
- Runtime — needed to run. Installed for consumers.
- Dev — needed to develop/test. Not installed transitively for consumers. Every
ecosystem has these; the important rule is that a consumer must never pull a dependency's
dev deps.
- Build-time — needed to compile (Cargo's [build-dependencies], Python's
[build-system] requires). Distinct from runtime because they may target a different
platform in cross-compilation.
- Optional / extras / features — conditionally enabled. This is where resolution
complexity explodes, because it turns one package into 2^N virtual packages.
- Peer dependencies — "I need the host to provide this, at this version, and we must
share the same instance." Essential for plugin systems (a React component and the app
must share one React). Notoriously confusing; npm auto-installed them in v3–v6, stopped,
then resumed in v7.
⚠️ GOTCHA — the manifest is a public API. Once published, the file format's semantics are frozen for every version anyone might install. Add fields; never repurpose them. Include a format-version field from version one. Every ecosystem that didn't has a painful compatibility story (
package.json'sexportsfield rollout,setup.py→pyproject.toml).
2.2 Manifest vs. lockfile — the distinction that must never blur¶
MANIFEST LOCKFILE
written by humans generated by the tool
expresses INTENT ("^1.2") expresses FACT ("1.4.7, sha256:...")
loose constraints exact versions + hashes + resolved URLs
hand-editable hand-editing is always a bug
committed committed for APPLICATIONS; see §4.3 for libraries
requirements.txt usage, where people pinned
exact versions in the manifest because there was no lockfile) force users to choose
between reproducibility and expressible intent. Give them both.
§3. Dependency Resolution — the hard part¶
3.1 The problem, formally¶
Given a root package's constraints and a universe of package versions each with their own constraints, find an assignment of exactly one version per selected package such that every constraint is satisfied and no unreachable package is selected.
[DURABLE] This is NP-complete. Di Cosmo et al. proved it in 2005 (the EDOS work) by encoding 3SAT into Debian and RPM dependency constraints; Russ Cox's "Version SAT" restates it, and it has been re-proven for other formulations since. The reduction is easy to see: disjunction is encoded in the changing dependencies across versions of a package.
Why it's tractable in practice anyway: real dependency graphs are nothing like the worst case. They're shallow-ish, mostly consistent, and heavily biased toward recent versions. Every production resolver is an algorithm with terrible worst-case behaviour and good typical behaviour — plus heuristics and a timeout.
The canonical hard case, the diamond:
The resolver must find a D that satisfies both, prove none exists, or use an escape hatch (§3.5).3.2 The algorithm families¶
| Family | How it works | Used by | Trade-off |
|---|---|---|---|
| Backtracking / DFS | Try highest version; on conflict, back up and try the next | pip (2020 resolver), Cargo, Swift PM | Simple; exponential worst case; historically terrible error messages |
| Backtracking + forward checking / backjumping | Prune early, jump past irrelevant choices | Molinillo (Bundler, CocoaPods — Bundler has since moved to PubGrub) | Better pruning, still heuristic |
| Full SAT / CDCL | Encode as boolean satisfiability, use a real solver | Composer, libsolv (dnf, Conda via libmamba), 0install | Complete and fast; explanations are hard; encoding is fiddly |
| PubGrub (CDCL specialized for versions) | Conflict-driven clause learning over version ranges, with derivation tracking | Dart pub, uv, Poetry (Mixology), Bundler, and the designated replacement for Cargo's solver | Fast and explains failures. The modern default |
| ASP (Answer Set Programming) | Declarative, with optimization objectives | Spack (via Clingo) | Handles multi-objective optimization (compilers, variants, targets) |
| Pseudo-Boolean optimization | SAT + an objective function | Research; some distro tooling | "Best" solution, not just any solution |
| MVS (Minimal Version Selection) | No search at all — take the max of the required minimums | Go modules | O(graph); trivially reproducible; no ranges |
| Avoid the problem | Allow multiple versions to coexist | npm (nesting), Nix/Guix (content-addressed store) | Turns resolution into a layout problem |
3.3 PubGrub — why it won¶
Natalie Weizenbaum designed PubGrub for Dart's pub in 2018. Its two contributions:
- CDCL over version ranges. Instead of assigning boolean variables, it works with incompatibilities — sets of terms that cannot all be true. On conflict it derives a new incompatibility (clause learning), which prunes an entire region of the search space rather than just backing up one step.
- The derivation graph is the error message. Because every incompatibility records why it was derived, a failure produces a human-readable proof: [DURABLE] This is the single biggest UX advance in package management in fifteen years. Every prior resolver's failure mode was "could not find a compatible set," which is useless. If you are building a resolver in 2026 and you do not produce an explanation, you are shipping a known-solved problem as a known bug.
The Rust pubgrub crate is generic over package type (including virtual packages),
version format, and version sets, and lets the caller control prioritization (highest-
versus lowest-version solving) and error rendering. uv extends it with "forking" —
splitting the resolution when environment markers (Python version, OS, architecture)
partition the solution space, producing a universal lockfile valid across platforms
rather than one lockfile per machine. That extension is the interesting part for anyone
building a cross-platform resolver.
3.4 MVS — the radical simplification¶
Russ Cox's argument for Go modules: most version selection algorithms are overcomplicated. MVS: - Modules declare minimum required versions, not ranges. - The build list is: for each module in the graph, the maximum of all the minimums required by anything in the graph. - One pass. No backtracking. No SAT. No solver. Deterministic by construction.
[CONTESTED] MVS's trade-off is the whole argument.
- For: builds are high-fidelity — adding a dependency cannot silently upgrade an
unrelated one, and the result is identical today and in five years without a lockfile.
It's a dozen lines of pseudocode. There is no resolution failure mode to debug.
- Against: you do not get security patches automatically. If your dependency requires
logrus v1.2.0 and v1.2.1 fixes a CVE, MVS keeps you on v1.2.0 until someone explicitly
bumps it. Proponents call that a feature (upgrades are deliberate); critics call it a
security liability at ecosystem scale.
- Note the naming confusion, which appears even in reputable sources: MVS selects the
maximum of the required minimums, which people describe both as "the minimum version
that satisfies all requirements" and "the highest required version." Both descriptions
are of the same operation, and the ambiguity causes real misunderstanding.
Go's ecosystem also learned an important lesson the hard way: the original design computed
MVS from a root go.mod listing only direct dependencies, which meant the file said
v1.2.3 while the build used v1.5.0. Users were endlessly confused and security scanners
couldn't read it statically. go mod tidy and go get were changed to write the full
resolved requirement list into the root go.mod. Lesson: if the manifest doesn't state
the actual answer, tooling and humans will both get it wrong.
3.5 Escape hatches — what to do when there's no solution¶
| Strategy | Mechanism | Used by |
|---|---|---|
| Allow multiple versions | Install both, isolate them | npm (nested node_modules), Cargo (multiple semver-major versions coexist), Nix |
| Version mediation | Pick one by a rule (nearest-wins, first-declared) | Maven — "nearest definition wins," which is deterministic but often surprising |
| Overrides / resolutions | User forcibly pins a transitive dep | npm overrides, Yarn resolutions, pnpm overrides, Cargo [patch] |
| Fail loudly | Report the conflict, make the human decide | pip, Cargo (for same-major conflicts), Bundler |
[DURABLE] "Allow multiple versions" is not free. It works for pure, self-contained libraries. It breaks catastrophically for anything with a singleton: a global registry, a type identity that must be shared, a native library that can only be loaded once, a database connection pool. That's exactly what peer dependencies exist to express, and why Rust distinguishes semver-compatible (unified) from semver-incompatible (coexisting) — and still hits "two versions of the same type, and they're not the same type" errors.
3.6 Making resolution fast¶
The dominant cost is almost never the solver — it's fetching metadata. Design for this:
- Publish dependency metadata separately from the artifact. PyPI's historic failure to provide dependency metadata via a plain API meant Poetry and pip had to download and unpack sdists just to learn their dependencies. This is the single largest reason Python resolution was slow for a decade. (PEP 658/714 metadata-only fetches fixed much of it; uv's speed comes substantially from exploiting them.)
- Serve a queryable index, not one file per package that must be fetched serially. Cargo's move from a git index to a sparse HTTP index was a large real-world win.
- Cache metadata aggressively and immutably (§5.2 →
package-manager-registries-and-installationimmutability makes this safe). - Prefetch speculatively — start downloading the versions you're likely to pick while still solving.
- Bound the search: prioritize by (fewest versions remaining) then (highest version), deprioritize packages with huge version counts, and have a hard timeout with a useful message rather than an infinite spin.
⚠️ GOTCHA — the "backtracking forever" experience. pip's resolver visibly downloading dozens of versions of one package is the canonical user-facing symptom of an unbounded backtracking search with expensive metadata access. If your resolver can do this, your users will experience it as a hang. Detect it, report which package is thrashing, and suggest a narrower constraint.
§4. Lockfiles¶
4.1 What a lockfile must contain¶
For each resolved package:
name, exact version
INTEGRITY HASH of the artifact ← the security property
resolved source (registry URL / git rev / path)
its dependencies (the resolved edges, not the constraints)
platform/marker applicability ← for universal lockfiles
Plus:
lockfile format version
a hash of the MANIFEST it was derived from ← detects "manifest changed, lock is stale"
[DURABLE] The integrity hash is the point. Without it, a lockfile pins versions but not bytes, and a registry that serves different content for the same version (or a man-in-the-middle, or a compromised mirror) defeats it entirely. Hash the artifact, and prefer hashing the content rather than the archive where you can, so recompression doesn't break it.
4.2 Design properties that matter¶
- Deterministic serialization. Sorted keys, stable ordering, consistent formatting.
A lockfile that produces spurious diffs will not be reviewed, and an unreviewed lockfile
is a supply-chain hole. (npm's
package-lock.jsonreaching 50,000+ lines for large projects with noisy diffs is the widely-cited failure of this property.) - Merge-friendliness. Lockfiles conflict constantly in team workflows. Either make the format merge cleanly (flat, per-package, sorted) or ship a merge driver. Most ecosystems did the second, late, after years of pain.
- Cross-platform validity. A lockfile generated on macOS must install correctly on
Linux CI. This requires recording platform-conditional entries rather than only what the
generating machine needed. uv's universal resolution and Cargo's approach do this;
pip freezenever did, which is why it isn't a lockfile. - Verifiability offline.
npm ci,pip install -r pylock.toml,cargo build --locked,pnpm install --frozen-lockfile— every ecosystem eventually adds a mode that says install exactly this, fail if the manifest and lock disagree, never resolve. Build this mode from day one; it is what CI should always use.
4.3 Should libraries commit lockfiles?¶
[CONTESTED, and the consensus is more nuanced than the folklore.]
- The classic rule: applications commit lockfiles; libraries don't, because a library's
lockfile is ignored by consumers and pinning it hides the fact that your declared ranges
are broken.
- The counter-position, now common: commit it anyway, because it makes your own CI
reproducible and lets you bisect. Then add a separate CI job that resolves fresh
(and ideally one that resolves minimum versions) to catch range breakage.
- Cargo shipped the modern answer: commit Cargo.lock for everything, and it is simply
ignored when the crate is consumed as a dependency. That removes the trade-off.
4.4 The Python standardization story — a case study in lockfile politics¶
Worth knowing because it illustrates how hard "one lockfile format" is:
- The quest began in 2019. PEP 665 was rejected for being too restrictive (it
excluded sdists). PEP 751 went through three complete rewrites and 1,800+ forum posts
before acceptance in March/April 2025, defining pylock.toml.
- Adoption moved fast on the export side: pip ≥25.1 (pip lock, April 2025), PDM ≥2.24,
uv ≥0.6.15 all write it. pip 26.1 (April 2026) added experimental pip install -r
pylock.toml on the install side.
- But the flagship tool declined to adopt it as its native format. uv's author stated
plainly that pylock.toml files "are not sufficient to replace uv.lock" — the key
limitation being that pylock records a fixed marker per package rather than a graph of
dependencies, so it can't express installing an arbitrary subset of the graph. Poetry had
not shipped support as of April 2026.
- The durable lesson: tools with a native lockfile treat a standard format as an
export target, not a replacement, because per-tool lockfiles capture information the
standard doesn't. Standardizing an interchange format is achievable; standardizing the
canonical format when competing formats are already entrenched is much harder.
⚠️ GOTCHA —
pip freezeandrequirements.txtare not lockfiles. No hashes by default, no platform markers, no distinction between direct and transitive, no manifest linkage.pip install --require-hashesgets you partway. Do not design a new system with this shape.
Plugin: package-manager-development · View SKILL.md on GitHub