Files
vdm/contracts/proto-answers-daemon-m1.md
T
samiandClaude Sonnet 5 768f4f8e53 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
2026-09-10 15:24:53 +04:00

4.4 KiB

PROTO → DAEMON — answers to daemon/docs/proto-requests-m1.md

Status: answered. Against contracts/ at 1.4.0 (lane/proto). Raised by DAEMON while building rpc/ against 1.3.0.


P1 — the generated Dispatcher has no error channel below -32603 · landed in 1.4.0

Done, essentially as sketched. The generated C++ binding now has two error channels, kept deliberately separate:

Channel Type Raised by Always
parse Result<T> = expected<T, ParseError> dispatch() turning the wire into typed params -32602, structural, data.path a JSON pointer
handler HandlerResult<T> = expected<T, HandlerError> a Dispatcher::on_* method any contract code + free-form data
struct HandlerError {
    ErrorCode code{ErrorCode::InternalError};   // default: a bare HandlerError{} is a valid -32603
    std::string message;
    nlohmann::json data = nullptr;              // forwarded straight into the JSON-RPC error's data
};
template <class T> using HandlerResult = std::expected<T, HandlerError>;

Every Dispatcher::on_* now returns HandlerResult<T>. dispatch()'s handler-error branch went from a hard-coded InternalError to:

if (!r) return make_error(id, r.error().code, r.error().message, r.error().data);

So the three in-handler fixtures are now satisfiable by a conformant server:

Fixture return std::unexpected(HandlerError{ ... })
download.get.not-found ErrorCode::TaskNotFound, "no such task", {{"taskId", id}}
download.add.invalid-path ErrorCode::InvalidPath, "outside allowed roots", {{"path", p}}
download.probe.probe-failed ErrorCode::ProbeFailed, "HTTP 403", {{"httpStatus", 403}}

session.pair.rate-limited (-32014) is a handler result too if you want it there — nothing stops a handler returning HandlerError{ErrorCode::RateLimited, ..., {{"retryAfterSec", 60}}}. -32001/-32002/-32003 stay yours to raise in the server layer around dispatch(), as you're already doing; they're decided before or without reference to method params, and HandlerError's own doc comment says so.

Verified end to end: a handler returning each of the above through the real dispatch() path produces the right code with the data payload intact, and a bare HandlerError{} still yields a clean -32603 with no data field. (Watch the nlohmann brace-init trap: HandlerError{code, msg, {{"k", v}}} gives an object, but a lone {nullptr} would give the array [null] — the struct's member initializer is = nullptr for exactly that reason.)

FixtureDispatcher and conformance_main.cpp: the generated dispatcher swapped ResultHandlerResult automatically; conformance_main.cpp only ever inspects dispatch()'s JSON output and needed no change.

Version: minor, 1.3.0 → 1.4.0. The wire is byte-identical — no schema, fixture, or OpenRPC change — but every implementer of Dispatcher must swap ResultHandlerResult on their on_* overrides or they won't compile, and a version bump is how lanes are told to regenerate and adapt. Minor, not major: a major would make session.hello refuse a client whose wire behaviour is unchanged. The rule — a generated-binding API break with an unchanged wire is minor + migration note, because VERSION is the protocol version, not the C++ ABI — is written up as docs/adr/0014-generated-binding-changes-and-versioning.md (this instance is mechanical; the ADR records the rule for the next one, which GUI will also consume since it already links velox::proto). kProtocolVersion moves to "1.4.0" with it.

P2 — clarifications

session.hello.version-mismatch data.expected. You're right that "1.0.0" in the fixture is stale. Fixed: it's now $any. Echo kProtocolVersion ("1.4.0") there — the conformance compare on an error fixture is on code only, structural elsewhere, so the live version string is fine and can't be pinned in a golden file that outlives version bumps anyway. actual stays the concrete bad version the fake client sent ("2.0.0").

SessionHelloResult.transport always populated. No change requested, noted. The field's own description already invites it ("Lets a client know up front which privileged methods will be refused"), so always setting "uds" / "ws" is using it as intended. std::optional stays because a hand-rolled or older server may legitimately omit it and a client must tolerate that.