# ADR 0014 — 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` → `HandlerResult`) 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 `Result` → `HandlerResult` 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`).