Files
vdm/docs/adr/0015-generated-binding-changes-and-versioning.md
T
samiandClaude Sonnet 5 dd1676bc0a proto: renumber ADR 0015 (0014 collided with PKG's), fix stale README versions
PKG landed docs/adr/0014-conformance-runs-through-ctest.md (dad88fc) and this
lane's 0014-generated-binding-changes-and-versioning.md landed in the same
integration round, both as 0014. Renumbered this one to 0015 — second merger
renumbers. Updated the two cross-references (contracts/README.md rule 4,
proto-answers-daemon-m1.md P1) and the in-file header.

Also while in contracts/README.md: the file-tree comment and the "Method
surface" heading still said v1.2.0; both now v1.4.0 to match VERSION.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012fgjnqFCS5h5L7gZTZo3rV
2026-09-10 15:31:01 +04:00

4.6 KiB

ADR 0015 — Versioning a generated-binding break with an unchanged wire

Status: accepted · Date: 2026-09-10 · Lane: PROTO Prompted by: the 1.4.0 bump (DAEMON's P1 — HandlerResult / HandlerError on the generated C++ Dispatcher), recorded here as a rule rather than left as a one-off.

Context

contracts/README.md rule 4 says: optional field or new method → minor; rename, remove, retype, or default change → major + an ADR. Read literally, the 1.4.0 change retyped every Dispatcher::on_* method (Result<T>HandlerResult<T>) and so should have been major with an ADR.

It was shipped as a minor bump with a migration note and no ADR. That call was right, but the reasoning only lived in a commit message and a proto-answers-* doc. The next generated-binding change — and there will be one; the C++ and TypeScript emitters are young — would get argued from rule 4's literal text again, and rule 4 would read as though PROTO quietly exempts itself whenever it's inconvenient.

Decision

VERSION tracks the wire protocol, not any binding's API or ABI. Two clients at the same VERSION speak the same JSON-RPC: same methods, same params and result shapes, same event payloads, same error codes on the wire. That is the whole promise session.hello enforces (major-only), and it is the only thing VERSION is allowed to mean.

From that, the versioning rule for a change that leaves the wire byte-identical but alters the generated binding's source-level API (a return type, a struct name, a signature — anything that makes a regenerated core/generated/ or extension/src/shared/protocol/ stop compiling against last version's hand-written code):

  • Minor bump. Not major. A major bump makes session.hello reject a client that is wire-compatible — actively worse than the problem, since the wire didn't change.
  • A migration note in the same PR: what to regenerate, and the mechanical edit each consumer makes (e.g. "swap ResultHandlerResult on every on_* override"). The proto-answers-* doc for the requesting lane is the normal home; contracts/README.md's version banner carries the one-line summary.
  • An ADR only when the change encodes a design decision — a new type's shape, a new contract, a semantic split. A purely mechanical shape change (this one: the error-channel type DAEMON proposed, applied as sketched) does not need one; it needs the migration note. "One lane consumes it today" is not a reason to skip the ADR when the change is a design decision — GUI already links velox::proto, and the next such change will have more than one consumer from day one.

Rule 4's "retype → major" is about the wire. Retyping a field on TaskError, TaskSummary, a method's params or result — anything a client parses off the socket — is major, because a peer at the old version mis-parses it. Retyping a generated C++ virtual's return type is not that: no byte on the wire moved, and the compiler catches every call site at build time. The two are different failure modes and get different rules.

A regeneration hazard worth naming

nlohmann::json brace-initialization from nullptr produces the array [null], not JSON null. HandlerError's data member is written nlohmann::json data = nullptr; (copy-init) for exactly this reason; a member written data{nullptr} compiles, passes a casual read, and makes every defaulted HandlerError{} emit "data":[null] in the error response. It was caught here only by an end-to-end assertion on dispatch()'s output.

Anyone hand-editing contracts/codegen/gen_cpp.py around json defaults or literals: prefer = nullptr / = {} / json::object() / json::array() over brace-init with a single element, and keep the dispatch()-output assertions that would catch a regression.

Consequences

  • The 1.4.0 bump stands as a minor with a migration note; this ADR is its written backing.
  • A future generated-binding retype cites this ADR instead of relitigating rule 4.
  • contracts/README.md rule 4 gets a pointer here so the "major + ADR" line isn't read in isolation.

Alternatives rejected

Make it major, per rule 4's literal text. Rejected: session.hello compares majors and would refuse a client whose wire behaviour is unchanged. The rule's purpose is to stop incompatible peers connecting; applying it here would block compatible ones.

Leave the reasoning in commit messages and proto-answers-* docs, as done for 1.4.0. Rejected: those are per-instance and get lost. The rule needs one durable home, which is what docs/adr/ is for (ADR 0001).