proto: ADR 0014 — versioning a generated-binding break with an unchanged wire

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
This commit is contained in:
2026-09-10 15:24:53 +04:00
co-authored by Claude Sonnet 5
parent 5e3e21543a
commit 768f4f8e53
3 changed files with 92 additions and 6 deletions
@@ -0,0 +1,78 @@
# 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.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`).