Strict PVP (SPVP) v0.0.0.0
This is a versioning system that’s compatible with the Haskell Package Versioning Policy, but tries to prevent more issues with dependencies.
The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in RFC 2119.
- TOC
terminology
-
bump: this aligns with SemVer’s requirements, which are stricter than PVP here. “bumping” a component in a version means incrementing that component while resetting all less-significant components to zero. (PVP doesn’t require resetting the less-significant components, but it’s certainly allowed and generally followed in practice.) NB: Because not all versions may be released, or may be unavailable for some reason, in practice it may appear as if the weaker requirements of PVP were followed (components increased instead of strictly incremented, and less significant components not reset).
-
change: Some versioning documents talk about changing declarations, but any change to a type is an incompatible change, so we can see changes as simply a removal followed by an addition (with a conflicting name).
-
fine-grained API tracking: This is a term I’m coining (maybe there’s prior art) for actually looking at the full transitive API of a module (roughly, the interface file, but with some additions), and analyzing each individual change. It allows you to have narrower version bumps than is implied by dependency versions. FIXME: This needs a specification too (for example, modified definitions aren’t apparent from an interface file, or, if they are, it’s not machine checkable whether they’re breaking changes – there should be a way to annotate these changes with how they affect the API, and if any annotations are missing, they can be inferred to be no more significant than the version bump)
summary of differences from PVP (non-normative)
- requires PVP’s “insensitive to additions to the API” recommendations
- package-qualified imports must be used
- Strict PVP defines a “patch” level, corresponding to the
Dcomponent (PVP references but never defines a “patch” level); - removing any instance or adding an orphan instance requires an
Abump instead of an increase inA.B; - incompatible license changes require an
Abump (PVP has nothing to say on licensing); - adding a module moved from another package only MAY bump the major version (it’s “SHOULD” in PVP);
- deprecation only increments
D, notA.B(compatible, because this is only a “SHOULD” in PVP);
specification
A package that follows Strict PVP SHOULD declare that it does so (FIXME: We need a machine-checkable place to specify this declaration). This allows dependencies of the package to make additional assumptions based on the package version.
A package that follows Strict PVP MAY use fine-grained API tracking to use less significant version bumps than may be implied by simply looking at the source.
fine-grained API tracking
Fine-grained API tracking uses tooling to produce more precise version numbers. For example, you may change a dependency from ^>= {2.0.0.0} to ^>= {2.0.0.0, 3.0.0.0}. This would normally require an A version bump in your next release. However, tooling can indicate that your exposed modules don’t leak any new instances due to the new dependency version, so you can release with only a D version bump.
NB: This may only be a deferral of the A bump. You may make a small bugfix change for your next release, and in doing so, add an import on a module in the dependency you previously widened. This new import may leak new instances, and trigger an A bump.
NB: Actually, any newly added import can trigger this kind of breakage … I’m beginning to think that Strict PVP can only be done with this kind of tooling.
examples
TODO: Change everything to use a single reference dependency graph (and examples will implement the same thing)
There are a large number of examples in this repository, which help ensure that the recommendations are correct. They all use the following package structure:
graph BT
transitive(transitive) --> direct(direct) --> current([current])
below --> beside --> above
transitive --> below
direct --> beside
current --> above
- “current” is the package we’re modifying
- “direct” and “transitive” are the packages we’re impacting
- “above”, “beside”, and “below” are additional packages that help illustrate how multi-package interaction can contribute to failures in dependencies
- If we can create an example where a change causes a failure in a
test-suiteof “direct”, then it’s anA'1 change. - If we can otherwise create an example where a change causes either a compilation failure of “transitive”, then it’s an
Achange. - If we can otherwise create an example where a change causes a compilation failure of “direct”, then it’s a
Bchange. - If the change otherwise affects the interface file2, then it’s a
Cchange. - Otherwise, it’s a
Dchange.
version numbers
Version numbers serve multiple purposes
- distinguishing different releases of the same package from each other (with only this requirement, the number could just as easily be a hash);
- indicating a preference between those releases (higher version numbers are “better”); and
- indicating similarities between releases, so that software developed against different releases can be distributed with a common release.
With this in mind, we want version numbers to communicate compatibility conservatively. If two releases share the same major components, we expect anything developed against the lower of the two will work when compiled against the newer of the two.
This largely follows the Haskell Package Versioning Policy (PVP), but is more strict in some ways.
The package version always has four components, A.B.C.D3. The first three correspond to those required by PVP, while the fourth matches the “patch” component from Semantic Versioning.
transitively breaking changes (bumps A)
There are “leaky” changes in most programming languages, where given a dependency graph like in [the examples section](#examples], a change to “current” can break “transitive” (even if “direct” happens to be unaffected).
SemVer in general doesn’t offer a good way to manage this, but Haskell’s PVP (accidentally) does.
- PVP (like SemVer) allows for more significant bumps than is required (for example, if you just add a bunch of documentation, which would normally be a revision, you’re allowed to release it as a new major version instead); and
- PVP makes no distinction between bumping the
AorBcomponent of a version.
Given these constraints, we can require that making a transitively-leaking change requires bumping the A component. And correspondingly require (as we do below), that adding a new A version to a dependency range forces a bump in your own A component.
breaking changes (bumps B)
non-breaking API changes (bumps C)
The difference between C and D changes can be a bit subtle. C is often seen as “additions” to the API, but it’s perhaps clearer to think of it as non-breaking changes to the API, whereas D doesn’t change the API at all.
other changes (bumps D)
consumer requirements
These apply even if you’re not publishing a library that follows Strict PVP (for example, when you’re developing an application).
PVP recommends that clients follow these import guidelines in order that they may be considered insensitive to additions to the API. However, this isn’t sufficient. Following Strict PVP implies insensitivity to additions to the API, and we strengthen the approach recommended by PVP with the following requirements.
use package-qualified imports everywhere
If your imports are package-qualified, then a dependency adding new modules can’t cause a conflict with modules you already import.
all non-package-local imports must be either qualified or have explicit import lists
TODO: Determine if Prelude really is an exception to this rule – is it true that Prelude is fixed going forward?
restriction of multiple imports with the same qualifier
If multiple imports use the same qualifier, all but one must all use explicit import lists. The remaining one may use either an explicit import list or a hiding clause containing a superset of the explicit imports of the other modules with the same qualifier.
applied changes
Here is a description of which changes to the API require which changes to the version.
FIXME: Be clearer about “adding” and “removing”, etc. being about the API – adding definitions that aren’t exported has no impact on the API.
Each of these cases is covered in the following sections with justifications, but this tries to give a quick rundown.
- “add” and “remove” refers to exports – internal changes generally don’t affect the interface (but be conscious of instances & term behavior)
- “changed” generally means the previous was “removed” and the new one was “added”, so use the more significant of the two columns
- a “persisting” type or class means that the type or class existed in a release prior to the addition, or continues to exist in the release containing the removal
| add | remove | trans? | syntax4 | note | |
|---|---|---|---|---|---|
INLINABLE/INLINE/NOINLINE/UNIQUE pragma | A' | A' | ✔ | only when applied to persisting terms | |
RULES pragma | A' | A' | ✔ | only when all involved terms are persisting, NB: changing is treated specially for rewrite rules | |
class instance | A/C | A | ✔ | only when applied to persisting types & classes, C when it’s part of a new module | |
data/newtype/type instance | A/B | A | ✔ | only when applied to persisting type family, B when non-orphan | |
import | A/C/D | A/D | ✔ | ✔ | C when it’s part of a new module, D when there is persisting import for the same module |
module | C | A/B | ✔ | ✔ | B when the removed module had zero imports |
-Werror | A* | D | ✔ | NB: If you use -Werror, any change to the package is an A change | |
-fpackage-trust | A* | D | ✔ | ||
| constructor | B/C | B | only when applied to persisting type, C when there were previously no exported constructors for the type | ||
| field | B | B | only when applied to persisting type | ||
| method | B/C | B | only when applied to persisting class, C when added with unconstrained default | ||
COMPLETE pragma | C | B | |||
class | C | B | changing type parameter order counts as replacing class | ||
method default | C | B | NB: changing is treated specially for method defaults | ||
pattern | C | B | NB: changing is treated specially for patterns | ||
| term | C | B | NB: changing is treated specially for terms | ||
type (data/newtype/type) | C | B | changing type parameter order counts as replacing type | ||
type family | C | B | changing type parameter order counts as replacing type | ||
| comments | D | D | |||
| dependency | D | D | |||
DEPRECATED pragma | D | D | |||
| Haddock | D | D | |||
NOUNPACK/SCC/SPECIALISE/UNPACK pragma | D | D |
| tighten | weaken | trans? | syntax4 | note | |
|---|---|---|---|---|---|
| constraint | B | A/D | ✔ | D when type defaulting doesn’t come into play | |
| license | A | C | ✔ | ||
| dependency bound | D | A/D | ✔ | ✔ | A when new A or non-strict B version is supported, and for certain other libraries |
| compiler bound | B/D | D | D when “guarded” by a corresponding non-reinstallable dependency tightening | ||
type role | B | D | ? | “inferred” should be treated as between representational and phantom | |
| Safe Haskell mode | D | B | ? | “inferred” should be treated as between Trustworthy and Unsafe |
class instances
Conflicting instances only cause a problem at resolution time, not import time, so “direct” can inherit an orphan instance from “current” and another instance from “beside”, but not exhibit a conflict because the instance is never used.
Orphans also make you sensitive to some dependencies’ APIs, but that only protects you from conflicts with non-orphan instances. The transitively-breaking restriction protects you from conflicts with orphans in other modules.
As described in the PVP spec, removing instances can impact packages that only depend on your package transitively.
Type class instances are imported transitively, and thus changing them can impact packages that only have your package as a transitive dependency.
avoid orphans – TODO: This section is out of date
Because of the transitivity of instances, orphans make you sensitive to your dependencies’ instances. If you have an orphan instance, you are sensitive to the APIs of the packages that define the class and the types of the instance.
suggestion: One way to minimize this sensitivity is to have a separate package (or packages) dedicated to any orphans you have. Those packages can be sensitive to their dependencies’ APIs, while the primary package remains insensitive, relying on the tighter ranges of the orphan packages to constrain the solver.
suggestion: Cross-reference orphans in the Cabal package files. Collect the class names and relevant types for any orphans you define. Add a comment above the relevant dependencies in the Cabal package file listing which classes and types come from each.
NB: Alternatively, adding any instance5 could be considered a transitively-breaking change. Then orphans wouldn’t need to trigger API sensitivity. On the one hand, that seems easier to manage and orphans are often unavoidable. However, it seems odd to penalize definers of non-orphan instances because of orphans, and relegating orphans to their own packages mitigates API sensitivity better than it mitigates transitively-breaking changes.
type instances (data, newtype, and type)
Open type families can have new instances added by other modules. If “above” defines an open type family, and “beside” defines an instance for it,
imports
adding or removing an import (even to an internal module)
This only applies if there isn’t another import for the same module.
This one is very frustrating. Because adding or removing an import can change the set of instances that are exposed by the module that’s importing them. This also applies to non-exposed modules, because they’re imported by exposed-modules, and thus propagate those instances.
modules
Like PVP we recommend a C bump when adding a module. However, unlike in PVP, this is because we recommend that package-qualified imports be used on all imports.
In the rare case that the module had zero imports, removal is a B change (because this also implies that if there were any instances in the module, their types and classes were also self-contained and thus removed).
If there were imports, there may have been instances inherited, and those may now no longer be available to transitive consumers.
constructors
Because patterns are exported along with constructors, these must be invariant – any change is a breaking change. But you can export constructors if there were previously no constructors available for the type, making it only a C change.
methods
Adding a new method is B, because downstream instances of that class will now fail to compile. But if you add an unconstrained default along with it, it’s only a C, because it will allow all existing instances to compile, and no users of the instance will yet be able to reference that method, so it can’t change behavior.
method defaults
Adding one (regardless of constraints) is a C change, because nothing could be depending on it yet. Removing one is a B change, because if it was referenced downstream, that code will break during compilation.
terms
changing the implementation of a term (sometimes)
This is the least-checkable case. If the implementation of a term changes between versions, the conservative option is to assume the behavior changed, which is a breaking change.
However, there are many refactorings, which don’t affect the behavior, but they aren’t easily checkable. The programmer needs to decide for each term whether the change affects the behavior or not. If it doesn’t it should only be a patch.
changing term & pattern definitions (including internal ones)
By default, any change to a term or pattern definition (even an internal one) is considered an A change. This may sound severe, but a change in behavior can change the behavior of downstream referents, and cascade into their downstream consumers. TODO: Can we make this a B change by claiming that consumers are responsible for ensuring that they have sufficient testing to prevent changes in upstream behavior from slipping through their tests unnoticed?
For this reason, we recommend that any non-refactoring definition changes (that is, changes that affect behavior at all) be handled by removing the old identifier and adding a new one in its place. However, we understand that this isn’t always practical or ergonomic, so we make the following concessions.
This is the most subtle aspect of versioning. Let’s break it down into o few cases.
First, types are almost never truly incompatible (type parameters can allow wildly different types to be used in the same context). For this reason, we can’t rely on the type being different to ensure that changed behavior will be communicated sufficiently.
Now, if the change is a refactoring (🤞), that’s a D change.
If there’s an intentional change in behavior, that’s most safely a A change – even for internal definitions, because existing referents’ behavior may consequently change, and then the referents of those referents may change transitively. So, we strongly recommend you instead use a new term or pattern identifier, removing the old (making this a B change).
There are two ways to further restrict the version bump via auditing. They can be used independently or together.
- Analyze how the behavior changed – if it’s a clear bug (for example, correcting
decrement = (+ 1)to(- 1)whenincrement = (+ 1)already exists, so no one is intentionally using the brokendecrementto get around the functionality that isn’t available otherwise), then it’s aDchange- if it improves handling of some cases (for example, it used to throw an exception in one case, but now handles it correctly), it’s a
Dchange - if it re-categorizes things it’s an
Achange (because a consumer may have been relying on that particular grouping of results)
- if it improves handling of some cases (for example, it used to throw an exception in one case, but now handles it correctly), it’s a
- For an internal definition that has been determined to not be a
Dchange, audit all its referents to see how their behaviors have changed, at which point, you can ignore the internal definition’s change significance in favor of the audited definitions’ change significance. NB: this may add new internal functions to the set of changed definitions, so you can iterate on this step to ignore those.
types
changing types
As mentioned in the terminology, changes can be viewed as a removal (a breaking change) followed by an addition, so they’re understandably breaking changes. However, there are some subtle cases that are worth calling out.
changing the order of type parameters
This can happen due to syntactic changes that don’t otherwise affect the API (for example, changing the order of constraints on a function).
suggestion: Enable
ExplicitForAlland addforallto any terms that have multiple type parameters, to insulate you from accidentally running into this.
dependencies
A new release won’t ever prevent the solver from succeeding with an old release, so while adding new dependencies or tightening bounds on existing dependencies might prevent the new release from being solved for, it won’t break downstream consumers.
TODO: Add bit about which Cabal options to use to ensure that solving happens completely.
The one dependency-related change that’s more significant is, counterintuitively, widening the bounds. There are three cases where widening the bounds requires a bump to the A component:
- adding a new
Arange for a dependency (for example, fromyaya ^>= {1.0.0}, toyaya ^>= {0.7.0, 1.0.0}) - adding a new
Brange for a dependency that doesn’t claim to follow Strict PVP (for example, fromtext ^>= {2.2.0}, totext ^>= {2.2.0, 2.3.0}) - adding any new support for a dependency that declares it doesn’t follow PVP (famously,
ghc). These dependencies should use==instead of^>=.
This is what makes tracking transitively-breaking changes useful. If you follow this rule, then your consumers can’t be caught by these breakages, while still allowing you to avoid major version bumps for other breaking changes in your dependencies.
Unfortunately, PVP itself considers transitively-breaking changes to be simply breaking changes, and so unless a dependency declares itself as adhering to “strict PVP”, adding support for any new breaking dependency versions is a transitively-breaking change.
NB: Some libraries (notably ghc) are known to not follow PVP. These shouldn’t use ^>= ranges, and require more explicit versioning. Any change to these dependencies is an A bump.
licenses
If there is no explicit license, the default is “all rights reserved”, which is the most restrictive license possible. So, adding or removing a license is just a special case of weakening or tightening a license, respectively.
Making a license more restrictive may prevent clients from being able to continue using the package. The solver won’t take this into account, and transitive dependencies are responsible for the licensing of all their dependencies.
When weakening a license, you need to provide a way for consumers to say “I can only use it starting from this version” and that’s exactly what C bumps are for.
DEPRECATED pragmas
NB: This case is weaker than PVP (but allowed by it).
PVP says that packages “SHOULD” bump their major version when adding deprecated pragmas.
We disagree with this because packages shouldn’t be publishing with -Werror. The intent of deprecation is to indicate that some API will change. To make that signal a major change itself defeats the purpose. You want people to start seeing that warning as soon as possible. The major change occurs when you actually remove the old API.
Yes, in development, -Werror is often (and should be) used. However, that just helps developers be aware of deprecations more immediately. They can always add -Wwarn=deprecation in some scope if they need to avoid updating it for the time being.
compiler bounds
Adding support for a compiler is a D bump.
removing support for a compiler version
The Cabal solver doesn’t look at compiler versions, so unlike with dependency bounds, we can’t make this a patch change. However, if there’s a corresponding tightening of a non-reinstallable dependency (like the ghc library), then the solver does handle this for us, and it can be D.
NB: Even a minor restriction, like changing from supporting GHC 9.10.1(+) to 9.10.2(+) must be considered a breaking change, because some libraries included with GHC (like the ghc library) may have breaking changes even in a minor version bump. This means if a consumer has a dependency on the ghc library, it may be a breaking change for them to support 9.10.2.
constraints
weakening constraints
Haskell does type resolution independently of constraints. It then sees if the type that was resolved satisfies the constraints. So removing constraints doesn’t affect what type is resolved, therefore it can’t cause a resolution failure.
This is a good example of the difference between “additions to the API” and “non-breaking changes to the API”. This makes a function applicable in more situations, but doesn’t add anything to the API.
FIXME: I think this might not be true with type variable defaulting. For example, if you weaken a constraint from RealFloat to Num, and a consumer is using default (Natural, Double), the switch from resolving Double to resolving Natural can then introduce a runtime failure when they call negate. There are mechanisms to disable defaulting, like default () or requiring -Werror=type-defaults, but those must be applied in the consumer, not the definer.
incompatible extensions
Secure PVP (SPVP is taken …)
This is incompatible with both PVP and SPVP. It requires that a security fix be no more significant than a minor change.
TODO: This section includes some things that are outside the scope of a versioning system, and should be listed as “suggestion”s.
A security fix SHOULD be made without breaking the API. However, if that’s not possible, the breaking change MUST bump C and leave A and B unchanged.
A security fix, even if breaking, MUST not include any other breaking changes. A security fix SHOULD not include any unrelated changes at all. Even trivial changes can impede analysis, and my have some subtle effect that undermines the release.
Whatever mechanisms are available SHOULD be used to deprecate6 the affected releases even before the fix is available. Once a fix is available, affected versions SHOULD be made unavailable.
This helps ensure that security fixes are propagated quickly, even if it means introducing breakages that need to be repaired.
NB: There’s what looks like a catch-22 here, but I think it’s an illusion – if a particular major version has an older unaffected release, then the actual fixed release may introduce an unnecessary breakage. But … if the older version wasn’t affected, then the fix must have been possible without breaking that part of the API. That is, any breaking fix SHOULD only cause breakage to APIs that have already been deprecated.
be careful with dependency changes
While C and D changes won’t break anything downstream, you should be careful about changes that will prevent your new release from being solved for, because it’ll prevent users from getting your security fix.
The easiest way is to make no dependency changes in a security change. Depending on consumer’s settings, even widening dependency resolution can result in an older version of your package being solved for (TODO: Find an example of this counterintuitive behavior). That said, often a security fix involves removing a vulnerable dependency, so it’s not always avoidable.
-
For the purposes of PVP-compatibility,
A'gets folded intoA, but I think behavioral changes (those that can’t be caught at compile time) deserve to be handled specially, and so I make the distinction here, even if it disappears for PVP in particular. In a dynamically-typed language, there isn’t much that can be anAorBchange, so it makes sense that 3-component versions are pretty dominant, but with a good type system, we can distinguish the significance of these changes. ↩ -
There are a few other things that can trigger a
Cchange, such as weakening the license. And I’m not sure if there are some interface-file changes that should be considered onlyDchanges. ↩ -
A mnemonic for the version components in strict PVP:
- bumping
Aaffects All dependencies, - bumping
BBreaks something, - bumping
Cis a Compatible change, and - bumping
Donly changes Documentation (and other non-behavioral things).
- bumping
-
“Syntax-only” means that this can be ignored (that is, it’s always a
Dchange) when using fine-grained API versioning. If it’s a “?”, that means that “inferred” doesn’t apply for fine-grained API versioning, but otherwise the bumps stay the same. ↩ ↩2 -
Adding an instance at the same time as its class or a relevant type would always be a minor change, since there’s no way for an orphan to exist before that point. ↩
-
In this case, “deprecate” refers to something like the Hackage mechanism, where a deprecated release is only used if no other compatible release is available. This means that users will be downgraded where possible before a fix is even available. ↩