Deprecation policy#
What a @stability level promises, how a promise is withdrawn, and how the
withdrawal is recorded. This is the mechanism; which surfaces sit at which
level is decided in
the 1.0 API freeze proposal and recorded in
the stability report.
MADDENING follows Semantic Versioning.
Read “major bump” below as “2.0 at the earliest”, because 1.0 is where the
STABLE set is first frozen.
The levels#
Every public surface carries
@stability(StabilityLevel.X) from maddening.core.compliance.stability.
An untagged public surface is a decision nobody has made, not an implicit
level; see Untagged surfaces.
Level |
Promise |
Incompatible change allowed in |
Notice required |
|---|---|---|---|
|
The signature and the documented behaviour hold for the life of the major version. |
A major release. |
Two minor releases of |
|
The signature and wire format are settled; additions are expected. |
A minor release. |
One minor release of |
|
Opt-in. May change or disappear in any minor release. |
Any minor release. |
None, but a CHANGELOG entry is mandatory. |
|
Scheduled for removal; the replacement is named in the docstring. |
— |
Already given. |
StabilityLevel has two more members, both DEPRECATED since 0.4.0. Each
shipped in earlier releases (PROVISIONAL since 0.1.0, INTERNAL since
0.3.0), so both stay and keep working, but no surface in src/ carries either,
and nothing new should:
PROVISIONALis a synonym forevolvingand is treated asevolvingeverywhere, the FMU export’sinclude_evolvingincluded. UseEVOLVING.INTERNALmeant “not public, no promise”. Leave such a surface untagged and give it a private, underscored name.
Two rules that are easy to miss:
Lowering a level is itself a breaking change. Moving a surface from
stabletoevolvingwithdraws a promise callers have already relied on, so it needs the same notice as removing it. Raising a level (experimental→evolving→stable) is free and needs only a CHANGELOG entry.A
stableclass promises the whole object, not just its constructor: every public method and property an instance answers to, including the ones it inherits. The exception is a member with a lower tag of its own: it promises that level.GraphManagerisstableandGraphManager.set_param_specisevolving. An override with no tag takes the level of the method it overrides.docs/developer_guide/stable_api.jsonrecords every member except those. Tagging a member lower after it has shipped in astableclass is lowering a level, as above.
What counts as a breaking change#
The guard (below) compares signatures mechanically and cannot tell intent, so it flags every difference. Use this table to decide whether a flagged difference needs a deprecation cycle or is a compatible change the snapshot should simply be told about.
Change |
Breaking? |
|---|---|
Add a keyword-only parameter with a default |
No |
Add a key to a returned |
No |
Widen a parameter type ( |
No |
Annotate a previously unannotated parameter with the type it already accepted |
No |
Rename a parameter |
Yes — callers pass it by keyword |
Reorder positional parameters |
Yes |
Remove a parameter, or make an optional one required |
Yes |
Turn a keyword-only parameter into positional-or-keyword |
Not for callers, yes for subclasses that override; treat as breaking |
Change a default value |
Yes — it changes results silently, which is worse than a failure |
Narrow a parameter type, or tighten validation so a previously accepted value now raises |
Yes |
Remove a key from a returned |
Yes |
Add a new abstract method to a |
Yes — every downstream subclass stops instantiating |
Change which exception type is raised |
Yes if it is documented in the |
A behaviour change that keeps the signature — a different numerical result, a
new warning, a changed convergence default — is breaking at the same level as
a signature change and gets the same notice. Bug fixes that make a documented
behaviour actually work are not breaking; say so in the CHANGELOG under
### Fixed.
Deprecating a stable surface#
Ship the replacement first, at
evolvingor above, in the same release the deprecation is announced. A deprecation with nowhere to go is a bug report, not a policy.Keep the old surface working for the whole notice period. It forwards to the replacement; it does not get a second implementation.
Warn at the call site.
DeprecationWarning,stacklevel=2, naming the replacement and the release that removes it:warnings.warn( "maddening.core.simulation.checkpoint.download_and_load_state moved to " "maddening.cloud.resume; the alias is removed in 1.0", DeprecationWarning, stacklevel=2, )
Use
FutureWarninginstead when the call keeps working but will start returning something different — a changed default, a changed convention — becauseDeprecationWarningis hidden from end users by default and a silent result change must not be.PendingDeprecationWarningis not used in this repository; two minor releases of notice is already the pending stage.Retag it
@stability(StabilityLevel.DEPRECATED)and put the replacement in the docstring’s first line, soscripts/generate_stability_report.pyshows it.Test the warning, not only the forwarding:
pytest.warns(DeprecationWarning, match="..."), plus one test that the alias still produces the same result as the replacement.Record it in the CHANGELOG (below).
Remove it no earlier than two minor releases after the announcement, and only in a major release. Removal is a
### Removedentry and an--update --accept-removalof the signature snapshot in the same commit.
maddening.core.simulation.checkpoint.download_and_load_state is the worked
example in the tree: announced in 0.4.0, forwards to
maddening.cloud.resume.download_and_load_state, removed in 1.0.
evolving and experimental#
evolving differs only in the length of the notice — one minor release
instead of two — and in where removal may land: a minor release, not a major
one. Everything else (replacement first, forwarding alias, warning, retag,
tests, CHANGELOG) is identical. An additive change to an evolving surface
needs no notice at all; that is what the level is for.
experimental needs no warning and no alias. It still needs the CHANGELOG
entry, because “opt-in” means someone opted in. Prefer a one-release
DeprecationWarning anyway when the change is cheap to soften — the level
sets the floor, not the ceiling.
A private surface (an underscored name, no tag) changes freely and is not mentioned in the CHANGELOG.
Recording it in the CHANGELOG#
CHANGELOG.md follows Keep a Changelog.
A deprecation is announced under Deprecated, and the eventual removal under
Removed. A pull request does not edit CHANGELOG.md: it writes the bullet
in its changes fragment, docs/fragments/changes/<branch>.yaml, and the
release assembly puts it in the release’s section (“Recording a change” in
the documentation standards).
Each entry names three things — the surface, the replacement, and the release that removes it:
changelog:
Deprecated:
- "`CouplingGroup.solver=\"fori\"` emits `DeprecationWarning`; removed in the next minor release."
If the deprecation lands together with the replacement, the replacement goes
under Added as usual; do not fold it into the Deprecated bullet.
The CI guard#
scripts/check_stable_signatures.py records the signature of every stable
surface in docs/developer_guide/stable_api.json and compares the tree
against it. It runs as the Check STABLE API signatures step of the
compliance job on every push and pull request — that job has no
changes-gate, so the guard runs even for a documentation-only change.
$ python scripts/check_stable_signatures.py
OK: 17 STABLE surface(s), 242 member(s) (4 of them tagged in their own right) unchanged
A difference fails with the recorded and current signatures side by side. Two kinds:
CHANGED / REMOVED — the promise is broken. Revert, or make the change additive, or land it behind a major version bump.
not in the snapshot — a new
stablesurface, or a newly promoted one. Not a break; the snapshot is simply out of date.
Either way, accepting the current tree is one command:
$ python scripts/check_stable_signatures.py --update
except for a removal. A surface or member that has left the stable set
– deleted, renamed, made private or demoted – withdraws a promise, so
--update refuses to drop it from the snapshot and lists what it would drop.
Once the removal is intended and its notice period is over, say so:
$ python scripts/check_stable_signatures.py --update --accept-removal
The guard also fails when there is no stable surface at all, in the tree or
in the snapshot: a guard with nothing to guard is not a pass.
Run it in the same commit as the change, and say in the commit message why
the change is compatible or which major release carries it. --update is the
only supported way to move the snapshot; hand-editing the JSON is how a break
gets through unnoticed.
What the snapshot does not cover, and therefore what review still has to
catch by reading: behaviour, exceptions, the contents of dict-shaped
arguments and returns (until the PEP 589 phase gives them TypedDicts — see
typing.md), instance attributes assigned in __init__
(SimulationNode.name is one), and module-level constants.
Untagged surfaces#
An export that carries no @stability tag has no level and therefore no
promise — but callers cannot see that, and in practice they treat anything in
an __all__ as public. Anything newly exported from __all__, documented in
a user guide, or used by src/maddening/examples/ gets a tag in the same
commit. docs/developer_guide/api_freeze_proposal.md lists the surfaces that
are public in practice and still untagged today.
scripts/generate_stability_report.py only sees a tag if it imports the
module that carries it; tests/compliance/test_stability.py fails when a
tagged module is missing from its STABILITY_MODULES list, so a new tag
cannot silently drop out of the report.
Checklist#
For a commit that changes a public surface — the long form of section 9 of
.claude/skills/commit-and-push/SKILL.md:
[ ] The surface carries a
@stabilitytag, and the level is still right.[ ]
python scripts/check_stable_signatures.pypasses, or--updateran in this commit with the reason in the commit message.[ ] If the change is breaking and the surface is
stable: it is behind a major version bump, or it is a### Deprecatedannouncement with the replacement shipped, aDeprecationWarningatstacklevel=2, theDEPRECATEDretag, and a test asserting the warning.[ ] If the change is breaking and the surface is
evolving: one minor release of notice, same mechanics.[ ] A changelog bullet in the changes fragment, under the right section, naming the replacement and the removal release.
[ ]
python scripts/generate_stability_report.pyre-run if any tag changed.