Records the rule 1.4.0 applied, so the next generated-binding retype cites it
instead of relitigating README rule 4's "retype → major + ADR" from scratch.
The rule: VERSION tracks the wire protocol, not any binding's API or ABI. A
change that leaves the wire byte-identical but breaks a generated binding's
source API (a C++ virtual's return type, a struct name) is a minor bump plus
a migration note — major would make session.hello refuse a client whose wire
behaviour is unchanged, which is worse than the problem. An ADR is still
required when the change encodes a design decision; "only one lane consumes
it" is not a reason to skip that, since GUI already links velox::proto and
the next such change starts with more than one consumer.
Rule 4 in contracts/README.md now points here so its "major + ADR" line
isn't read in isolation. proto-answers-daemon-m1.md's P1 writeup references
it as the durable home for the reasoning that was otherwise only in a commit
message.
Also names the nlohmann brace-init hazard the 1.4.0 work hit: json{nullptr}
is the array [null], not JSON null, so HandlerError::data is `= nullptr`. The
kind of thing a regeneration reintroduces; caught here only by an end-to-end
assertion on dispatch() output, which is called out to keep.
Docs only — no schema, VERSION, or generated-code change.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012fgjnqFCS5h5L7gZTZo3rV
4.6 KiB
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<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.helloreject 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→HandlerResulton everyon_*override"). Theproto-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.mdrule 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).