Skip to content

ADR-0012 — Central package management and SDK pinning

Status: Accepted Date: 2026-09-15

Context

Package versions were declared inline in src/FFOccamMcp.Core/FFOccamMcp.Core.csproj. With one project that is fine. The repository now has seven project files — the host, the L0 gate, three benchmark projects, a docs-audit repro and a test project — and all of them compile against the same OccamMcp.Core assembly. A version bump applied to one and not another produces a mismatch that shows up as a confusing runtime failure in the gate rather than a build error.

Separately, there was no global.json. The cross-platform run surfaced why that matters: the Linux host installed SDK 10.0.302 while macOS and Windows had 10.0.301. Without a pin, that difference is invisible until something behaves differently; with the wrong pin, the build simply refuses to start on one of the three machines.

Decision

Central Package Management. Directory.Packages.props with ManagePackageVersionsCentrally=true and CentralPackageTransitivePinningEnabled=true. Project files reference packages without a Version attribute.

SDK pin with a deliberate roll-forward. global.json pins 10.0.100 with rollForward: latestFeature and allowPrerelease: false:

  • it will not silently build on .NET 11;
  • it accepts any .NET 10 feature band and patch, so 10.0.301 and 10.0.302 both work;
  • it rejects prerelease SDKs, which are a common source of "works on my machine".

Transitive pinning is enabled because this host ships as a single Native AOT binary: an unpinned transitive dependency is an unpinned part of a shipped artefact, and dotnet list package --vulnerable --include-transitive only helps if the resolved graph is stable.

Consequences

Good. One place to bump a version. A transitive CVE can be pinned out without waiting for a direct dependency to update. The SDK pin is permissive enough that provisioning a fresh machine with dotnet-install.sh --channel 10.0 just works, which the cross-platform run confirmed on two hosts.

Costs. Adding a package now means editing two files. A contributor who has only seen inline versions may be briefly confused by a PackageReference with no version — the comment in the project file points at this record.

Verified. Restore, build, test and Native AOT publish all succeed under CPM on macOS arm64, Linux x64 and Windows x64 (docs/testing/RESULTS.md).

Alternatives considered

Leave versions inline. Rejected: the failure mode is a silent version skew across seven projects, and it gets worse as the benchmark projects grow.

rollForward: disable with an exact patch pin. Rejected: it would have blocked the Linux host outright, and pinning a patch version means every contributor must install that exact patch. The value of a pin here is preventing a major drift, not a patch one.

rollForward: latestMajor. Rejected: it defeats the purpose. A .NET 11 SDK building a net10.0 target is exactly the kind of drift a pin should surface.

Directory.Packages.props without transitive pinning. Rejected: the product is a single static binary, so transitive versions are part of what ships.