Cross-cutting governance spec: morph's semantic-versioning commitment, the
public/detail stable-surface split, the deprecation window, and how the C++
API version and the wire protocol version are independent axes. This is a
process document, not a description of a code type or subsystem — read it
before deciding whether a change is additive (minor), a fix (patch), or
breaking (major), and before removing or renaming anything in the stable
surface.
Related specs: ARCHITECTURE.md (the per-topic
public/detail namespace split this formalises into a compatibility
promise), wire.md (the Envelope/kind contract; its
"Protocol version negotiation" and "Action-evolution policy" sections are the
wire's own versioning and deprecation-window discipline — the independent
axis described below), docs/spec/pinned_facts.toml and
CONTRIBUTING.md (the pinned-facts
drift guard this policy's still-manual public-surface check could grow into
once it gains a symbol roster — see "Known limitation" below), CLAUDE.md
(the "specs are the authoritative contract" rule this policy's major/minor/
patch classification relies on).
morph is 0.1.0 (CMakeLists.txt's project(morph VERSION 0.1.0 ...)),
mirrored in code by include/morph/version.hpp:
| Symbol | Meaning |
|---|---|
MORPH_VERSION_MAJOR / MORPH_VERSION_MINOR / MORPH_VERSION_PATCH |
Preprocessor version components. |
MORPH_MAKE_VERSION(major, minor, patch) |
Packs three components into one comparable integer, for #if MORPH_VERSION >= MORPH_MAKE_VERSION(1, 2, 0)-style feature checks. |
MORPH_VERSION |
MORPH_MAKE_VERSION(MORPH_VERSION_MAJOR, MORPH_VERSION_MINOR, MORPH_VERSION_PATCH) for the running release. |
morph::version::kMajor / kMinor / kPatch |
constexpr int mirrors of the macros, for code that prefers a typed constant. |
morph::version::kString |
constexpr std::string_view dotted version, e.g. "0.1.0". |
tests/test_version.cpp cross-checks these constants against the
PROJECT_VERSION_MAJOR/MINOR/PATCH variables CMake derives from
CMakeLists.txt's project(VERSION ...), so the header and the build system
cannot silently disagree.
morph follows Semantic Versioning 2.0.0. Per semver's
own rule for major version 0, morph has not yet made a 1.0 compatibility
promise: any 0.x release, including a patch release, may change anything
without a major bump — 0.1.0 is still initial development. The rest of this
document states the promise morph commits to starting at 1.0.0, published
now so it is public and reviewable before it takes effect, rather than
invented after the fact.
The stable surface is every non-detail symbol in morph's per-topic public
namespaces, exactly as ARCHITECTURE.md's namespace map
defines them: morph::log, morph::exec, morph::async, morph::model
(traits and Loggable), morph::backend (LocalBackend/RemoteServer/
SimulatedRemoteBackend), morph::bridge, morph::offline, morph::session,
morph::journal, morph::math, morph::units, morph::time, morph::forms,
morph::version, morph::qt — plus the registration macros
(BRIDGE_REGISTER_MODEL, BRIDGE_REGISTER_ACTION, BRIDGE_REGISTER_VALIDATOR),
the MORPH_VERSION* macros above, and the wire Envelope/kind contract
(wire.md).
Every nested detail namespace is explicitly not part of the stable
surface. A detail symbol can still appear in a public signature — e.g.
Bridge's constructor takes unique_ptr<backend::detail::IBackend>
(ARCHITECTURE.md) — but a caller never names a
detail type directly (construct the concrete class and let the conversion
happen implicitly). A caller who names a detail symbol anyway is outside
this policy's promise: that symbol's shape may change in any release,
including a patch.
morph is header-only (ARCHITECTURE.md): there is no
shared-object ABI to preserve, and none is promised — every consumer
recompiles against the headers on every release. What semantic versioning
governs here is source compatibility of the stable surface — whether a
consumer's existing #includes and call sites still compile and behave the
same:
- Major (
X.0.0): a breaking source change to the stable surface — removing or renaming a public symbol, an incompatible signature change, or a behavior change that breaks a contract documented indocs/spec/. A change is classified against the stable surface and its documenteddocs/spec/behavior together: perCLAUDE.md, the spec is the contract, so a behavior change that contradicts a spec is a major even when the C++ signature is unchanged. - Minor (
x.Y.0): additive and source-compatible — a new public symbol, a new optional parameter with a default, new opt-in behavior. Every item in todo.md's roadmap is deliberately "opt-in or backward compatible by default," so the entire planned roadmap fits inside 1.x. - Patch (
x.y.Z): bug fixes and doc corrections that touch neither the stable surface nor its documented behavior.
Before a stable symbol is removed, or its documented behavior changed, in a major release:
-
It is marked, in this exact shape, naming both the target removal version and the replacement:
[[deprecated("removed in <major>.<minor>.<patch>; use <replacement> instead")]]e.g.
[[deprecated("removed in 2.0.0; use morph::bridge::NewThing instead")]].scripts/check_deprecated_markers.shenforces this exact shape in CI (thedeprecation-lintjob) — see "What CI enforces today" below. -
It keeps working, unchanged, for at least one full minor release.
-
Its impending removal is recorded in the affected spec's Status/Limitations section.
-
It is removed only at the next major version.
This mirrors the deprecation-window discipline wire.md's
"Action-evolution policy" already applies to the wire's own action/result
evolution — mark a field deprecated, keep it working for at least one full
release, remove only at a kProtocolVersion bump: one discipline, applied to
the C++ source surface here and to the wire there.
The C++ API version (this document) and the wire protocol version
(wire.md's kProtocolVersion) move independently:
- A 1.x release of the C++ library may speak the same wire protocol version
throughout its whole 1.x line; a wire-breaking change bumps
kProtocolVersionon its own schedule, unrelated to the library's major/minor/patch. wire.md's"hello"handshake (RemoteServer::setSupportedVersionRange) lets a server widen its accepted range to keep serving pre-bump clients through their own deprecation window, entirely independent of what the C++ library's own version is doing. - A consumer therefore reasons about two separate promises: "will my code still compile against this morph release?" (this document) and "will my client still talk to that server?" (wire.md, "Protocol version negotiation"). Neither promise implies the other, and neither document restates the other.
- The declared version is internally consistent.
tests/test_version.cppstatic_assertsmorph::version::kMajor/kMinor/kPatch(include/morph/version.hpp) against thePROJECT_VERSION_MAJOR/MINOR/PATCHvaluestests/CMakeLists.txtforwards fromCMakeLists.txt'sproject(morph VERSION ...), so the header and the build system cannot silently drift apart. - Deprecation markers are well-formed. The
deprecation-lintjob (.github/workflows/ci.yml) runsscripts/check_deprecated_markers.shagainstinclude/morph, failing the build if any[[deprecated("...")]]message does not name both a target removal version and a replacement in the shape given above. - Every public symbol is documented. The existing Doxygen job (
CLAUDE.md,.github/workflows/docs.yml) already runs withWARN_AS_ERROR = FAIL_ON_WARNINGS, failing the build if any public symbol — including every symbol on the stable surface — lacks complete@param/@tparam/@returndocs. This policy piggybacks on that gate rather than duplicating it: an "everything public is documented" precondition is a prerequisite for reasoning about the stable surface at all. - Individual mechanical facts cannot silently drift.
docs/spec/pinned_facts.tomlpins specific mechanical facts that recur across specs — key constants, enum cardinalities, canonical error/reply strings — and two checks enforce them:tests/test_pinned_facts.cpp(real code vs. the manifest) andscripts/check_spec_citations.sh(the spec prose vs. the manifest). This is a narrower, already-shipped relative of the gap described next: it pins individual facts a human names in the manifest, not an enumerated roster of every symbol on the stable surface.
There is currently no CI check that fails a pull request for removing or
renaming a stable public symbol without a major-version bump —
classifying a change as major/minor/patch is manual (author and reviewer
judgement) today. The pinned-facts drift guard described above
(docs/spec/pinned_facts.toml, tests/test_pinned_facts.cpp,
scripts/check_spec_citations.sh) has landed since this policy was first
drafted, and its manifest-plus-generated-header pattern is a plausible
foundation to build on: it already proves out "a human-pinned fact, checked
against the real code, at CI time." But its manifest format is flat
KEY = value scalar facts named one at a time, not an enumerated inventory
of every symbol on the stable surface — growing it into a public-symbol
diff (one entry per stable symbol, a CI job that fails on an unlisted
removal or rename) is new work that has not been done, not something that
falls out of the existing manifest for free. Until that work lands, this one
piece of the policy is enforced by review discipline alone.
- No ABI stability promise. See "Semantic versioning applies to source compatibility, not ABI" above.
- Not a promise about
detail. See "The stable surface" above. - Not a feature freeze. The policy governs how the surface evolves (additive in minors, breaking only in majors with a deprecation window), not whether it evolves — the entire todo.md roadmap is 1.x-compatible by its own "opt-in or backward compatible by default" rule.
- No existing behavior changes. This policy is governance plus tooling — a
version header, a policy document,
[[deprecated]]discipline, and CI enforcement — not a change to any existing public symbol's behavior. - Does not restate the wire policy. Wire compatibility is wire.md's concern (its "Protocol version negotiation" and "Action-evolution policy" sections); this document states only that the two axes are independent, not the wire policy's own rules.