ADR-066: Longitudinal Compatibility History and Project-Defined Versioning Policy¶
Date: 2026-09-05
Status: Proposed — S1 and S2 implemented. Design record for the
vision's "history and versioning" decisions (vision.md); this document
itself introduced no code, schema, config key, or default change, but
S1/S2's dated amendments below record real, landed implementation
(abicheck/workflows/history.py, the versioning: --policy namespace,
abicheck/policy/versioning_policy.py) sequenced by
plans/vision-api-abi-evolution.md
(workstream "Longitudinal history and versioning policy"). S3/S4 remain
unimplemented. This ADR does not reauthorize the baseline registry ADR-043
D4 retired, and it does not introduce a hosted service.
Decision maker: maintainer (product decision recorded in vision.md);
technical sign-off pending review of this document.
Context¶
Every abicheck entry point is two-sided: compare, scan --against, the
release fan-out, deps compare, and aggregate (which folds many reports
of one run). The release recommendation (abicheck/semver.py,
recommend_release) reads one DiffResult and maps verdict and change
kinds to a hard-coded strict-SemVer table (MAJOR/MINOR/PATCH/NONE, a
SONAME action, and an actionable/review/unavailable state). There is no
versioning: configuration key, no pre-1.0 rule, no calendar or
compatibility-line scheme, and no notion of a support or deprecation
window. Deprecation is already a persisted per-declaration fact
(deprecated/deprecated_fact on functions, variables, records, and
enums, snapshot schema v40) with per-pair transition kinds
(func/var/type/enum/field_deprecated_added|removed, header-AST only)
derived from it; what is missing is any lifecycle read of that fact
across more than two releases.
Baselines are identified by a channel × target × profile tuple whose
project_ref is an opaque label (a release tag or a git SHA) compared for
exact equality only (abicheck/buildsource/baseline_set.py); exactly one
baseline per tuple exists, with no ordering and no "previous N releases".
AbiSnapshot carries version, git_commit, git_tag, created_at, and
a dump_provenance block that stamps depth and frontend, not a content
digest or tool version; storage v2's PackageManifest/VariantRef
(ADR-062) carry declared-versus-captured variant coordinates but no
release label or predecessor pointer. Occurrence-preserving identity
(EntityId/OccurrenceId, canonical_finding_id) exists and is what a
history must key on; EntityId.key is not yet proven stable across
releases. A suppression has an expiry date but no version window.
So the questions the vision asks — when did an API first appear, when was it deprecated or removed, which prior releases does a candidate still promise support to, and does a removal satisfy the project's own rules — have no owner, and the only versioning advice assumes a promise many projects do not make.
Decision¶
D1 — History references immutable artifacts; it is not a second store¶
A history is an index over snapshots the project already keeps, not a
copy of their facts. Each history entry references one immutable snapshot
(or ProjectSnapshot package, ADR-062) by content digest and records:
project/component identity, release label, branch/channel/compatibility
line, variant/profile coordinates, evidence coverage, extraction/tool/
schema versions, the contract/config revision in force, and provenance
(who produced it, from what source ref). Entries are ordered by an explicit predecessor relation the project
supplies, or, when none is supplied, by an order derived once from the
declared version scheme (D4) at index-build time. Either way the
resulting release order/predecessor graph is persisted with the index
as input provenance and frozen: lifecycle events are computed from that
persisted order, and a later policy re-evaluation (D6) reads it back and
never re-derives it. A different scheme therefore cannot reorder the
same entries or change an observed first_observed/removed/
reintroduced event; changing the order is an explicit rebuild of the
index that produces a new history with its own provenance, not a
re-evaluation. Lexical tag order and upload time are never authoritative.
The first implementation is offline: a user supplies N existing snapshots (two suffice), and the tool produces machine-readable lifecycle events and coverage. No service, no network, no automatic fetching. CI publication and resolution of history come later through the existing baseline/artifact channels (ADR-047/059), and a baseline refresh is storage activity, never an approval.
D2 — Lifecycle events are per occurrence, per scope, and honest about gaps¶
Events (first_observed, changed, deprecated, removed, reintroduced)
are keyed by the existing entity identity (EntityId), with occurrence
disambiguation carried through an explicit cross-release correspondence
step rather than by using today's OccurrenceId directly: the
multi-TU normalizer builds an occurrence's disambiguator from the TU name
and source location (abicheck/extract/manifest_semantic_ir.py), so a
rebased or relocated but otherwise unchanged source tree would yield a new
key and read as removal-plus-reintroduction. The correspondence step
matches occurrences across entries on a normalized, persistent occurrence
key built only from change-stable material: an identity projection of
EntityId (kind, scope path, name — deliberately omitting EntityId.extra,
which for a function carries the mangled name or the normalized signature
discriminator, abicheck/model/identity.py's entity_id_for_function, and
so changes with a parameter change), plus TU-relative and root-relative
paths and declaration anchors. Never mutable semantic payload —
CanonicalEntity.canonical_spelling, a signature, a mangled name, an
underlying type, a constant value — since those change exactly when a
changed event should be emitted and would otherwise read as
removal-plus-reintroduction; payload is compared only after
correspondence is established. Overloads, which the projection no longer
separates, are disambiguated inside the correspondence step by their
signature discriminators: a one-to-one signature match is continuity;
anything else — including a changed signature with a single remaining
candidate, since old f(int)/new f(double) look identical whether one
declaration changed or f(int) was removed while an unrelated overload
was added — is a possible correspondence only, never an asserted
changed event. A possible correspondence becomes changed only when
stable provenance corroborates it (the same declaration anchor in the
same TU-relative location, or a project-supplied explicit rename/
signature-change mapping); otherwise history records a removed and a
first_observed pair with the correspondence attached as a hint, so
deprecation or first-observed history is never carried between distinct
APIs. It reports an ambiguous match as
a possible correspondence, and never asserts continuity it cannot prove.
Defining that key is S1's first deliverable, gated by the rebased-path
test below. Distinct overloads, occurrences, ABI aliases, and template
instances are never merged because display names match; a rename with
uncertain identity is a possible correspondence, not continuity. Events are scoped to the variant/profile
and contract they were observed under — one platform's addition is not a
release-wide addition.
Terminology is bounded by coverage: first_observed_in is not
introduced_in unless every earlier entry in the relevant line was
captured with sufficient evidence; an unobserved deprecation is unknown,
not absent; a missing intermediate release yields an unknown_interval.
Branching histories and backports are kept as branches, never flattened.
Clarification (2026-09): negative evidence across releases reuses
ADR-065's absence semantics, not a separate interpretation. "Not present
in snapshot B" must never become "removed in release B" purely because a
later comparison against snapshot B fails to find the entity — the same
completeness question ADR-065 D2 answers for a two-sided comparison applies
identically to a history built by walking N releases: B's own snapshot must
carry evidence sufficient to conclude absence — a proven-complete header
surface or exports inventory for the relevant contract/variant, not merely
"the AST walk over whatever headers were captured didn't happen to mention
it." A snapshot captured under a narrower variant, an incomplete header set,
or missing evidence for the entity's contract records unknown
(this section's own terminology above), never removed, exactly the way
ADR-065 D2 requires proven completeness before an unmatched member becomes
a removal finding in an ordinary two-sided comparison. History is not a
second, independently-invented notion of absence; it is the same
completeness/evidence-adequacy machinery (ADR-065's D1/D2, the evidence-
adequacy workstream amending ADR-028/049) applied across more than two
points instead of two.
D3 — Adjacent pairs do not prove a window¶
Compatibility against a support window is evaluated against the baselines the project actually promises (D4), not inferred transitively from adjacent pass results — especially across differing contracts, profiles, or evidence coverage. Cached pairwise results may be reused only under a complete key (both digests, config fingerprint, tool version, identity scheme) and each conclusion keeps the evidence it rests on.
Clarification (2026-09): the cross-release correspondence step (D2) is
itself part of that complete key. D2's correspondence algorithm — the
identity projection, the signature-discriminator disambiguation, and the
provenance corroboration rule that promotes a possible correspondence to an
asserted changed event — is exactly the kind of thing this ADR expects to
be revised as real multi-release corpora surface new edge cases (D2 already
names overloads and rebased paths as cases it had to get right on the first
attempt). A history index computed under one correspondence-algorithm
version and a later one computed under a revised algorithm are not
comparable, and reusing a cached lifecycle event across that boundary would
silently mix two different notions of "the same declaration." So the
correspondence algorithm carries its own version, folded into D3's "complete
key" the same way a config fingerprint or identity scheme already is: a
history index entry, and any cached comparison feeding it, records which
correspondence-algorithm version produced it, and a version bump requires
either a full history recompute or an explicit, reported
"history predates algorithm vN" marker — never a silent mix of two
algorithm generations in one timeline.
D4 — Versioning policy is a small, separable model¶
The project's versioning policy is resolved by the existing configuration
owner, ADR-049 D7's complete precedence as abicheck/
compatibility_evaluation_resolver.py implements it: explicit CLI/API
request > legacy alias > run recipe > run profile > project config >
built-in default. Versioning controls are semantic fields, not execution
fields, so the run-profile tier is not eligible for them (the resolver
rejects a run-profile candidate for any field that has not opted in as an
execution field); a run recipe may supply them. The policy consists of
independent controls:
| Control | Meaning | Examples |
|---|---|---|
scheme |
How version labels parse and order | strict SemVer; SemVer with maintainer-defined relaxations; calendar; opaque labels with an explicit order; a named compatibility line |
promise |
What compatibility the project claims between two ordered versions | "binary-compatible within a major"; "source-compatible within a minor"; "no promise" |
support_window |
Which prior releases/lines a candidate must remain compatible with | last two minors; every release on the 5.x line; an explicit list |
deprecation_window |
The minimum observed deprecation before a removal is policy-conforming | one minor; one release on the line; none |
enforcement |
What a policy deviation does | warn or block, per control |
Pre-1.0 behavior and branch-specific promises are stated explicitly in the policy, never assumed. There is no built-in "weak SemVer": a relaxed policy is whatever the project declares. The built-in default is the current behavior (strict SemVer advice, no windows, advisory only), so no existing run changes.
D5 — Policy changes acceptance; it never changes facts¶
Whether a release is accepted under its versioning policy is a distinct
decision from whether a change is compatible. A major-version bump can
make a known break policy-conforming; the break stays BREAKING, stays in
the report, and still drives the SONAME advice. A relaxed policy may warn
instead of block; it cannot remove findings, inflate evidence, or alter a
verdict. Changing enforcement or scheme cannot change the raw finding
set, any evidence status, or — because the release order is frozen
provenance (D1) — any recorded lifecycle event; an executable invariant,
not prose.
Advice distinguishes three things: an observed recommendation (what the
delta implies), an unmet release policy (what the project's own rules
say), and insufficient evidence for advice (the existing unavailable
state). SONAME advice remains conditional on platform and contract.
D6 — History integrates with acknowledgment; it does not invent approval¶
A history entry may link to an acknowledgment record (ADR-067) by its id; history never carries a separate approval mechanism. Re-evaluating a recorded transition under a later policy is a new result that references the original run's decisions and config fingerprint; it never rewrites the original acceptance.
D7 — Reporting through the canonical document¶
Timelines and lifecycle projections are sections of the canonical report document (ADR-036/061), derived from recorded events; a diagram is a view of those events, not evidence. Optional bounded retention (how many entries a project keeps) is a storage setting, not a semantic one.
Consequences¶
- No pairwise run changes: with no history and no policy, the release recommendation is exactly today's.
abicheck/semver.pybecomes the observed recommendation half of a two-part answer; the policy evaluation is a newpolicy/owner that reads the resolved versioning policy and the (optional) history.versioning:is a new.abicheck.ymlnamespace registered with the config reference generator and the docs topic registry in the same slice that implements it — not before.- Suppressions gain nothing here; version-window scoping of an acknowledgment is ADR-067's field, referenced by history, not duplicated.
Relationship to existing decisions¶
Extends ADR-015/059/062 (snapshot identity, compression, storage v2),
ADR-047 (baseline lifecycle and publication channels), ADR-049 (config
precedence, contract revision), ADR-055 (typed requests), ADR-063
(identity, RunOutcome), ADR-064 (exit precedence: enforcement: block
folds through the existing gate/policy axis, never a new scheme). Replaces
nothing. ADR-022's registry stays retired; a history index is a file a
project owns, not a service abicheck runs.
Implementation slices¶
S0: this ADR, model trade-offs on real fixtures, migration/retention design. S1: offline history over user-supplied snapshots, emitting machine-readable events and coverage. S2: the versioning policy model, support/deprecation evaluation, and integration with the existing SemVer/SONAME advice. S3: CI publication/resolution through the existing artifact channels. S4: report projections, bounded retention, and reproducible comparison reuse.
Clarification (2026-09): S4's rendering is explicitly the last slice, not
an accident of numbering. Do not start implementation with a rendered
timeline view. The reproducible unit this ADR defines is the lifecycle
event stream (D2's first_observed/changed/deprecated/removed/
reintroduced events, each with its own coverage/unknown honesty per the
terminology above) plus the coverage record that says which intervals are
unknown_interval rather than confirmed-unchanged. A timeline (S4) is a
projection of that event stream for display — the same fact/formatting
split this codebase already applies everywhere else (ADR-061's
ReportDocument "compute vs. render" split; see that ADR's own
distinction for the precedent) — never a second, independently-computed
representation of history. Building a timeline view before the event
stream and its honesty-about-gaps machinery (S0/S1) exist and are correct
would recreate exactly the failure mode ADR-022's retired registry avoided
by not existing: a display artifact whose correctness nobody can check
against the facts it claims to summarize.
Mandatory tests (contract)¶
Three-release add/deprecate/remove sequences; strict versus relaxed policy on the same raw delta (identical findings and evidence, different acceptance); non-SemVer labels; backports and release branches; a missing intermediate release; lost debug or header evidence in one entry; a symbol removed and reintroduced; an API present in one variant only; an unknown deprecation date; rebased source paths that must not reset identity; ambiguous renames that must not be asserted; re-evaluation under a new policy without overwriting the original result. Memory/time budgets are set from measured fixtures in S1, not promised here.
Amendment (2026-09-06, S0 design validated on real fixtures; S1 landed). S0's design pass was run against snapshots built from real declarations (
examples/workflows/compare-release/{v1,v2}/mathutils.h'sadd/subtract/multiplysurface, extended with a synthetic third and fourth release exercising deprecate/remove/reintroduce) rather than a from-nothing sketch — seetests/test_workflows_history.pyfor the resulting fixture shapes, which now double as this slice's regression suite. Trade-offs recorded:
- History is an index over stored snapshots, resolved offline.
abicheck.workflows.history.run_history_requesttakes N storedAbiSnapshotpaths (ADR-059 storage envelope, any compression), never a copy of their facts, matching D1's "index, not a second store."- Ordering is explicit, not inferred. D1 describes an explicit predecessor relation or an order derived from a declared version scheme (D4). S1 implements only the former: the caller's own snapshot order (oldest first) IS the release order, full stop. D4's scheme-driven ordering is deferred to S2, once
versioning: scheme:exists to derive one from.- Correspondence key is deliberately narrower than D2's full algorithm. S1 tracks each entity by its existing, already-resolved
EntityId(or a(kind, symbol)fallback when no header-AST identity was resolved for that pair) — never a bespoke identity scheme. D2's full correspondence step (overload signature-discriminator disambiguation with TU-relative provenance corroboration for a "possible correspondence") is not implemented. This is a conservative narrowing, not a violation: per D2's own fallback rule, anything short of a proven one-to-one correspondence must read asremoved+introduced/first_observedrather than an assertedchanged— which is exactly what omitting the corroboration step produces, just for a slightly larger set of cases (S1 never attempts the correspondence hint at all, so it never reaches the "corroborated" branch either). Consequence: a function whose signature changes reads as one entity removed and a different one introduced in S1's output, never achangedevent — D2's sixth vocabulary word (changed) is not emitted by this slice at all. Implementing full correspondence (signature-discriminator disambiguation, TU-relative declaration-anchor corroboration, the D3 correspondence-algorithm version this ADR requires once that logic exists) is real, separate engineering work, tracked as an open S1+ follow-up rather than attempted in this pass.- Absence honesty is a bounded proxy, not ADR-065's full ledger. A
removedlifecycle event carriesevidence_uncertain: truewhenever the backing pairwiseDiffResult.confidenceis notHIGH— cheap to compute from datacompare()already returns, and directionally correct (low confidence usually does mean thinner evidence), but it is a proxy, not ADR-065's real per-provider completeness ledger. Wiring that ledger through per-entity absence claims is deferred; until then, aevidence_uncertain: falseremoval is not a formal proof of absence, only "nothing about this comparison's own confidence flagged a gap."- Coverage-gap detection is a bounded SemVer heuristic, not D4's real scheme. With no declared
versioning: scheme:yet, "a release is missing" can only be guessed from label shape: two adjacent labels that both parse asmajor.minor.patchand are not one ordinary SemVer increment apart are flaggedunknown_interval. A label pair that doesn't parse as SemVer (calendar versions, opaque codenames, git SHAs) reports no gap verdict at all — never a false "contiguous", per D2's terminology section. Retention/pruning: not implemented in S1 — every supplied snapshot is loaded and held for the run's lifetime, with no bounded-retention summarization; that is explicitly S4 scope (D7: "optional bounded retention... is a storage setting, not a semantic one") and depends on S3's CI publication channel existing first, since there is nothing to prune from an offline, one-shot run over N explicitly-supplied files.S1 landed, offline only:
abicheck/workflows/history.py(run_history_request/build_longitudinal_history, plusLifecycleEvent/CoverageGap/LongitudinalHistoryResult) and one CLI surface,abicheck project history SNAPSHOTS... [--version LABEL]... [--policy NAME] --format {json,text}(abicheck/cli_project.py), per ADR-054's admission bar — aprojectsubcommand, not a new root command. Noversioning:config key, no CI publication, and no report/timeline projection exist yet; S2-S4 remain as scoped above.Amendment (2026-09-07, S2 landed): the versioning policy model, deprecation evaluation, and SemVer/SONAME integration. (
support_windowis declared and type-validated but not yet evaluated for conformance -- see the D4 entry below.)
- D4's model landed as
abicheck/policy/versioning_policy.py.VersioningPolicycomposes the five independent controls verbatim (scheme,promise,support_window,deprecation_window,enforcement);built_in_default_versioning_policy()is exactly today's behavior (strict SemVer, no windows, advisory-only), so a run that never statesversioning:is bit-for-bit unchanged. Resolved through the existing ADR-049 D7 precedence resolver (compatibility_evaluation_resolver.resolve_field) as one whole-object field (versioning.policy) — see the scope note below for why this is one field rather than five independently-resolvable ones in this slice. Registered onCompatibilityEvaluationConfig.versioningand wired end to end throughcompatibility_evaluation_wiring. resolve_versioning_policy/versioning_policy_candidateandcompatibility_evaluation_frontend.resolve_compatibility_evaluation_config, the same patternsurface.internal_namespacesalready established.policy_file.py's--policyYAML documents gained aversioning:namespace (PolicyFile.versioning/versioning_stated) — the one real front end this resolves against today, per D4's "resolved by the existing configuration owner."- D5's release acceptance axis landed as an orthogonal, additive fact.
evaluate_release_acceptancereads only aDiffResult's verdict (never its finding set) and aVersioningPolicy, and answers whether this release is acceptable under the declaredpromise/enforcement—promise: none(the default) always accepts a break (no promise to violate); a stated promise plusenforcement: warnstill accepts, with the deviation noted;enforcement: blockdoes not.abicheck.semver.recommend_releasegained an optionalversioning_policyparameter and an additiveReleaseRecommendation.policy_acceptancefield (PolicyAcceptance | None) — proven, as an executable Hypothesis property test over the whole(Verdict, promise, enforcement)input space (tests/test_versioning_policy.py:: test_policy_never_changes_observed_recommendation), to never changebump/soname/state/rationale: D5's "policy changes acceptance; it never changes facts" is checked by property, not only asserted in prose. Omitted (the default),recommend_release's behavior is bit-for-bit identical to a build with no versioning-policy support.- D4's
deprecation_windowevaluated over an S1 history, as a history-report-only fact.evaluate_deprecation_compliancewalks aLongitudinalHistoryResult's already-computedeventsand, for eachremovedevent, finds the most recentdeprecatedevent for the same entity key and classifies the observed release-count distance againstdeprecation_window.min_releasesasconforming/non_conforming/unknown(an unobserved deprecation isunknown, never asserted as a violation, per D2's terminology).build_longitudinal_history/run_history_requestgained an optionalversioning_policyparameter that populates the newLongitudinalHistoryResult.deprecation_compliancefield — never mutatingevents/gaps/pairwise, and never read by any pairwisecompare()verdict, per ADR-066's own orthogonal-axis requirement. A lifecycle restart (introduced/reintroduced/first_observed) discards a stale deprecation record from a prior presence cycle, so a reintroduced-then-removed entity is not scored against a deprecation that belonged to a different cycle.- Deliberate S2 narrowing, following the S0/S1 precedent of bounding D2's full algorithm rather than reinventing it:
support_windowis declared and type-validated but not evaluated for conformance — doing so requires resolving which real prior baselines a named window refers to (D3), which depends on S3's CI publication/resolution channel existing at all.deprecation_windowis evaluated only in the release-count terms S1's history already provides (LifecycleEvent.index), not D4's real version scheme. A singleVersioningPolicyobject resolves as one D7 field (versioning.policy) rather than five independently-precedenced fields: no concrete CLI/run-recipe input mechanism exists yet for any individual control (only--policy-file's wholeversioning:block does), so per-field D7 granularity is deferred until such an input exists — seeabicheck/policy/versioning_policy.py's own module docstring for the full account. D2's full correspondence algorithm remains untouched, exactly as S1 left it.- Tests:
tests/test_versioning_policy.py(the model, both evaluators, the mandated "strict vs. relaxed policy, identical delta, different acceptance" property, and the history compliance fact — including reintroduction/restart edge cases) andtests/test_versioning_policy_wiring.py(thepolicy_file.pyYAML parser, the D7 resolver wiring, and the whole-object frontend resolver). S3 (CI publication/resolution) and S4 (report/timeline projection, retention, cached-comparison reuse) remain unimplemented.