Files
alkstore/docs/architecture/decisions/017-contract-versioning.md
T

341 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ADR-017: Contract versioning discipline — the core crate's semver *is* the contract's semver
## Status
Accepted (2026-10-06, Phase 1 — OQ-10's resolution; governs the
versioning of the surface [core-contract.md](../core-contract.md)
carries; discharges the version-coordination duty
[ADR-001](001-crate-split.md) records as its negative consequence)
## Context
The crate split makes the core crate's trait surface a contract the
engine crates must track — ADR-001's one structural negative
consequence ("version bumps in core must be adopted by engines in
lockstep when the contract changes"). OQ-10 framed three candidate
mechanisms for how: semver-bump-only-when-contract-changes, engines
pinning core ranges, and a contract-compatibility test suite the
engines run against the core's trait definitions — plus the direct
question: what happens to a *released* engine crate when core makes a
contract breaking change?
Much has been decided since the OQ was framed, and it narrows and
feeds the answer:
- **The contract's content is no longer just v1.** ADR-009 and
ADR-010 added the first *post-v1 extensions* (the scheduler
collapse surface; the `QueueOpts` depth) — ADR-009's negative
consequence explicitly deferred their versioning discipline to
OQ-10, and ADR-010's depth rode the same extension path
([ADR-008](008-contract-v1-pinning.md)'s framing). ADR-014 and
ADR-015 created
the other precedent class: *amend-in-place, pre-implementation* —
both framed "no versioning event, OQ-10 untouched." The discipline
must codify both frames and draw the line between them.
- **The fold ([ADR-013](013-fold-substrate-into-sqlite.md)) removed a
would-be versioning surface.** The forked substrate is a module
subtree of `alkstore-sqlite` — unpublished, path-invisible, with no
crate identity of its own. It has no version number to track
anything with; cherry-picks ride the engine crate's own semver and
OQ-11's provenance procedure. The pairing the discipline must track
is the *engine/core* pairing, full stop.
- **The capability surface is gone**
([ADR-016](016-deployment-honesty.md)): no descriptor struct that
contracts would have to keep honest across versions — one less
surface class for this discipline to govern.
- **No artifact is released.** No crate exists; the pinned text
(ADR-008 through ADR-016, and the specs it carries) is entirely
pre-implementation. The discipline is therefore forward policy —
every claim it makes about *released* engines is governable from
day one, and nothing needs retrofitting.
- **One structural fact shapes the whole answer**: the core crate is
a contract artifact and nothing else (`alkstore` — "the unified
trait surface, types, error model, and the contract documentation.
No driver dependencies," [ADR-001](001-crate-split.md) §1 as
annotated by ADR-016). A core release cannot be motivated by
anything *but* a contract change: the crate has no other behavior
to ship.
## Decision
### 1. The core crate's semver is the contract version — no parallel versioning surface
There is **no separate contract-version register, no version
descriptor API, no compatibility-matrix document**: the core crate's
version number *is* the contract version, because the crate is
nothing but contract. This is the one-normative-owner rule
([ADR-012](012-forked-substrate-design.md) §2) applied to the version
question — a second ledger would be a second normative home for
"which contract is this," exactly the drift surface ADR-016 rejected
in the runtime-descriptor case. A dependency edge *states* the
contract pairing; it cannot drift from the truth it states.
Concretely:
- The core crate's **changelog is the version ledger**: every
contract change entry cites its ADR (the "no silent contract
change" rule, §2 below makes the citation mandatory).
- [core-contract.md](../core-contract.md) is the *content* of record
for the current contract text; the ADR table there is the change
history. Neither duplicates the version number as a normative
field.
- The coupling binds fully at the core crate's 1.0 (contract major
⇔ crate major). **The initial release is 1.0.0** — contract v1's
content (ADR-008 through ADR-017's text, the class-1 additions
included) *is* crate major 1, so the identity holds at the moment
it binds rather than being reached by accumulation. Pre-1.0 (the
contingency of interim dev releases, not the planned path) follows
Cargo's 0.x semantics: a contract breaking change bumps `0.y`;
additions bump `0.y.z` — at 1.0 the count re-bases: contract v1's
breaking history is exactly the majors 1.x accumulates from. The
pinned text's name for the initial surface — **contract v1**
([ADR-008](008-contract-v1-pinning.md)) — is a content
designation, not yet a semver claim.
### 2. The event taxonomy: four classes of contract change
Every change to the contract text is exactly one of these, and
classes 1–3 name their ADR in the core crate's changelog entry
(**no silent contract changes** — a contract change without an ADR
and a changelog citation is a process defect, the guard this
discipline most depends on because semantic breaks are not
compiler-visible, §5; class 4's doc clarifications carry no ADR
duty — that is what makes them non-events).
1. **Pre-implementation amend-in-place** (the ADR-014/015 precedent)
— completing a pinned-but-under-pinned surface, before the core
crate first publishes. No versioning event, because there is no
versioned artifact for the change to be an event *on*. **This
class terminates permanently at the core crate's first release**:
once any consumer can pin the contract text by a version number,
"the contract changed under the same number" stops being
available. ADR-009/ADR-010, though framed as *post-v1
extensions*, fall in this class in fact — nothing was released
when they were made, so their additions ship inside the initial
contract v1 text (the initial release carries ADR-008 through
ADR-017 whole).
2. **Additive extension** (the ADR-009/010 *shape*, applied
post-release) — a new trait method, a new type, a new error
variant, a new field on a consumer-read value type (§3's
`#[non_exhaustive]` class), a new guarantee row, or a new
contract-suite row pinning new
behavior. Core bumps its minor (pre-1.0: patch). Existing
correct consumers are untouched; §5 carries the engine-side
consequences. (Opts-struct field additions do *not* ride this
class — §3 exempts those structs, pricing their field additions
class 3.)
3. **Breaking change** — any signature change, removal, or rename
(cutting a pinned v1 method name was already ruled a contract-
breaking event, [ADR-015](015-streams-depth.md) §1's option-(c)
reasoning), **and any semantics change a correct consumer's
behavior could depend on**: weakening a guarantee row, changing a
pinned contract constant (the 64-cap, the 1-hour backoff cap, the
8000-byte pg payload limit), changing a variant's meaning or
production conditions. *Compile-compatible is not the test* —
the act-differently measure is (ADR-008 §5's rule, generalized
exactly as ADR-016 §2 generalized it to surface): if consumer
code that was correct before the change behaves differently
after, the change is breaking regardless of whether it compiles
clean. Core bumps its major (pre-1.0: its `0.y`).
4. **Non-events** — engine-internal changes (engine opts, derived
names, substrate cherry-picks per OQ-11's procedure —
[ADR-018](018-provenance-register-and-cherry-picks.md), resolved:
contract-blind by ADR-012 §2, so they cannot be contract events
by construction); and doc clarifications *only* where a correct
implementation could not have behaved differently before them
(the conservative inverse of the act-differently measure: if it
could have, it is class 3, not a clarification).
Extensions and breaking changes additionally obey the **row-first
discipline** both ADR-009 §2 and ADR-015 §1 applied: a consumer-
inventory row naming the need precedes the extension (breaking
changes have their ADR-008 §5-rule justification instead — the
change exists because the pinned text was *wrong*, which no row
names).
### 3. `#[non_exhaustive]` on the consumer-read contract types — opts structs exempt
Class 2 is only *mechanically* additive if Rust's rules agree, and
Rust's rules split the value types by who constructs them. Pinned:
`#[non_exhaustive]` on the types consumers **read but never
construct** — the top-level `Error`, `StreamEvent`, `Job`,
`Schedule`, and `Wake` — so field and variant additions there are
semver-minor under Cargo's rules, not major. On `Error` this also
makes the taxonomy's fallback posture contractual: consumers
matching `Error` *cannot* match exhaustively — the opaque
`Database`-carrying catch-all the act-differently rule designed is
the required shape, mechanically enforced. (`Job`'s dead-only
`last_error`/`died_at` fields — the ADR-010 §1 addition — are this
class's demonstrated growth surface, now made mechanically minor.)
The **opts structs (`EnqueueOpts`, `QueueOpts`, `ScheduleOpts`) are
deliberately *not* `#[non_exhaustive]`**: consumers construct them
(the pinned v1 field sets, ADR-008 §1, carried whole), and
`#[non_exhaustive]` bans struct-literal construction of the type
from outside the defining crate — functional-update syntax included
(E0639). Pinning it would silently remove the pinned construction
posture for every `enqueue`/`queue`/`schedule` call — itself a
semantics change by this ADR's own class-3 measure. The honest cost,
stated: an opts *field* addition post-release is a **class 3
breaking change** (major) — heavier than a minor for "one more
field," and priced that way deliberately; the row-first gate keeps
such additions rare (the v1 field sets are complete, and ADR-010's
depth is in them; a consumer row must name the need, and the
field must earn it).
Trait methods remain the additive class Rust's rules cannot paper
over (adding a method breaks every impl; `#[non_exhaustive]` does
not cover trait items). That cost is carried openly, not hidden:
the lockstep duty, §5.
### 4. The pairing: how an engine crate declares what it implements
The engine/core contract pairing is tracked by **three carriers of
one fact** (the ADR-016 pattern — statements at the right altitude,
not a runtime descriptor):
1. **The manifest pin**: each engine crate declares
`alkstore = "1.y"` (caret-within-major; pre-1.0, `0.y`)
— the dependency edge stating the contract version the engine's
code implements. Cargo's resolution makes cross-major mixing a
compile error rather than a runtime surprise.
2. **The contract suite**: the verification backlog
([core-contract.md](../core-contract.md) §Verification backlog)
matures into the contract suite — the *compatibility instrument*
(the OQ's option (c)), not a dev convenience. An engine release
claiming contract vN runs the suite's vN rows; **each row is
version-stamped with the contract change that added it** — the
same no-silent-change discipline (§2's ADR-citation duty is what
the stamp records), which is what makes the suite auditable
against the changelog. The suite's layout —
shared crate, workspace test target, or per-engine module — is a
test-side convenience decided at implementation
([ADR-001](001-crate-split.md) §4's posture for test artifacts).
3. **Engine-crate docs**: each engine's docs state the contract
version(s) it implements — the same standing-statement altitude
as its deployment posture (ADR-016 §3's carrier 2).
The engine crate's own semver stays independent — it versions the
engine's own surface (constructors, opts). A contract adoption
release is a normal engine release; nothing in the engine's version
number *must* encode the contract version (its manifest pin does,
§4.1).
### 5. Lockstep duties — and what a breaking change does to released engines
- **Contract minor (class 2)**: **both engine crates adopt by
release before-or-with the core minor's announcement** —
mandatory for new trait methods (their impls must exist or
downstream builds of the engine against the new core fail), and
the suite's new rows do not go green without the adoption either
way. A consumer feels class 2 as *available* engine updates; the
consumer adopts when it wants the new surface. An engine not yet
adopting stays a coherent crate against its last-verified core
minor, but note the mechanics: a consumer's *unpinned* caret
requirement resolves to the newest compatible core minor — against
which a method-method-addition non-adopter fails to compile. That
compile failure *is* the adoption duty's enforcement, not a
consumption guarantee; the grace window holds for consumers whose
graphs deliberately resolve the previous minor (a pinned
requirement or a lockfile), and for additions that are not trait
methods (new opts never ride trait additions; new suite rows test
what the engine already shipped).
- **Contract major (class 3)**: **nothing is forced on released
engines.** A released engine crate keeps its pinned core major
range; consumers keep working; the engine's docs record the
contract majors it serves. The new major gets new engine majors
(implementing it, per §4's carriers), and consumer migration is a
deliberate two-line dependency bump — the single-driver design
(ADR-001) makes the whole move visible in one place. This is the
OQ's direct question, answered: *the released engine serves its
pinned contract majors until archived; "what happens" is that the
ecosystem gains a new parallel line, and no released artifact has
anything pulled out from under it.*
- **The lockstep duty's edge is stated, not smoothed**
(ADR-001's "no trait-default drift" line), on both sides the
mechanics actually allow: **core** must not ship class-2
additions as default-bodied trait methods (a failing default
would make the addition optional for engines to implement and
silently non-additive for consumers — the exact drift the lockstep
duty exists to prevent; additions are required methods), and
**engines** must not stub required surface with
error-returning impls to fake adoption.
## Consequences
**Positive**
- The versioning machinery is *nothing new*: no descriptor, no
register, no compatibility API — the discipline rides the artifact
graph (crate version, manifest edge, suite, changelog), the same
compile-time-honesty posture as ADR-016. A dependency edge *is*
the pairing statement.
- The two precedent classes are codified with their boundary:
amend-in-place has a termination line (first release); the
extension shape has a mandatory-adoption rule. ADR-009/010 are
correctly understood as pre-implementation events; ADR-014/015's
"OQ-10 untouched" framing is vindicated and bounded.
- `#[non_exhaustive]` makes the read-type additive-minor rule
mechanically true and mechanically enforces the catch-all matching
posture the taxonomy already mandated in prose; opts-field
additions are honestly priced as breaking (class 3) rather than
smuggled in as minor.
- The suite graduates from backlog to compatibility instrument with
a version discipline — the thing that makes "engines still
implement the contract" an auditable claim rather than an
intention.
**Negative**
- Contract minor bumps carry a real (if small) maintenance cost:
both engines must publish adoption releases — mandatory for trait
methods (non-adopter engines fail to compile against the new
core), and suite-row readiness effectively demands it for the
rest. This is
the ADR-001 negative consequence made concrete, not removed.
- Semantic breaks ride semver only through this discipline's honesty
rule — the compiler cannot see a weakened guarantee row. The
guards are review, the no-silent-contract-change rule, and the
suite's version-stamped rows; a dishonestly-classified change is
a process defect, not a caught one.
- Pre-1.0, the coupling is looser (0.y bumps for breaks) — the
usual pre-release latitude, bounded at 1.0.
## References
- OQ-10 (`docs/architecture/open-questions.md`) — this ADR's
resolution; the three mechanisms it framed compose here (semver
discipline → §1/§2, range pins → §4.1, the suite → §4.2), the
house pattern of options-are-one-posture (ADR-016 §1).
- [ADR-001](001-crate-split.md) — the crate split and its
version-coordination negative consequence this ADR discharges;
§1's core-crate contents list (contract artifact, and nothing
else); §4's test-artifact posture the suite's layout defers by.
- [ADR-008](008-contract-v1-pinning.md) — contract v1 (the content
designation §1 keeps); §5's act-differently rule, generalized
twice (surface by ADR-016 §2, change classification by this
ADR's class 3).
- [ADR-009](009-scheduler-collapse.md) — the first extension shape
(its negative consequence named this discipline "still open");
§2's row-first extension path.
- [ADR-010](010-queue-semantics-depth.md) — the `QueueOpts`-depth
extension; its stamped-fields pattern is the opts addition §3's
`#[non_exhaustive]` rule anticipates.
- [ADR-012](012-forked-substrate-design.md) — §2's one-normative-
owner rule (§1) and the contract-blind boundary that makes
substrate changes structurally non-events (class 4).
- [ADR-013](013-fold-substrate-into-sqlite.md) — the fold that
removed the substrate's versioning surface entirely.
- [ADR-014](014-outbox-tx-enqueue.md) §4 /
[ADR-015](015-streams-depth.md) §2 — the amend-in-place
precedents ("no versioning event, pre-implementation") this ADR
codifies and terminates at first release.
- [ADR-016](016-deployment-honesty.md) — the capability-surface
rejection that narrowed this discipline's scope (no descriptor to
version); its three-carriers pattern (§4).
- OQ-11 — the fork-scaffold residue whose cherry-pick procedure
governs the substrate-side non-events (class 4) — resolved by
[ADR-018](018-provenance-register-and-cherry-picks.md) (2026-10-06).
- `docs/research/consumer-inventory.md` — the row-first gate on
extensions (class 2/§2's closing rule).