proto: freeze the wire contract at 1.0.0

Schemas for the whole v1 surface: 38 methods, 9 events, 25 named types and the
JSON-RPC envelope, with x-privileged / x-transports / x-deadlineMs / x-errors
annotations that both generators emit as data rather than prose.

Four generators over one IR (contracts/codegen/schema_ir.py), so the C++ structs,
the TypeScript types and the OpenRPC document cannot disagree about what the
contract says:

  gen_cpp.py             -> core/generated/velox_proto.{hpp,cpp}
  gen_ts.py              -> extension/src/shared/protocol/
  gen_openrpc.py         -> contracts/openrpc.json
  gen_cpp_conformance.py -> tests/conformance/cpp/fixture_dispatcher.hpp

Inbound parsing never throws: parse<T>() returns std::expected<T, ParseError> and
nlohmann's throwing ADL from_json is deliberately not emitted. Schema constraints
(minimum, maxLength, pattern, ...) become real runtime checks in both languages —
the daemon does not trust the extension and the extension does not trust the
daemon.

59 golden fixtures: a success case per method, 12 error cases, 9 events. Replayed
by tests/conformance/ against both the generated C++ and a live server over both
transports. tools/mockd serves the same fixtures with unhappy-path flags so the
GUI and EXT lanes never wait for veloxd.

run.sh also proves capture.offer fails open: with a daemon answering slower than
750 ms the client gives up and lets Firefox take the download.

core/generated/ is libveloxproto, a separate target from libveloxcore, which
still never sees JSON — see docs/adr/0009.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012fgjnqFCS5h5L7gZTZo3rV
This commit is contained in:
2026-09-09 19:55:54 +04:00
co-authored by Claude Opus 5
parent a40585f419
commit 53421d6cb8
171 changed files with 29275 additions and 51 deletions
+78
View File
@@ -0,0 +1,78 @@
# ADR 0005 — Protocol v1.0.0 freeze and the versioning rule
**Status:** accepted · **Date:** 2026-09-09 · **Lane:** PROTO
## Context
M0 exists to produce one thing: an interface the CORE, DAEMON, GUI and EXT lanes can build
against in parallel without meeting. Everything after M0 is four lanes working from the
same contract and not talking to each other for weeks. The cost of getting this wrong is
not a bug; it is an M2 integration rewrite.
The failure mode this is designed against is specific and predictable: a lane needs a field
that isn't in the contract, adds it locally "just for now", and nobody finds out until four
implementations disagree at once.
## Decision
`contracts/VERSION` is frozen at **1.0.0**. The surface is 38 methods, 9 events, 25 named
types and the JSON-RPC envelope, exactly as `contracts/README.md` documents it.
**Semantics of a change:**
| Change | Bump | Also needs |
|---|---|---|
| New method, new event, new optional field | minor | fixtures for it |
| New enum value | minor | both generators handle it; check client `default:` arms |
| Rename, remove, retype, change a default | **major** | an ADR with a migration note |
`session.hello` compares **majors only**. A mismatch is refused with `-32001` and a message
the GUI renders as "Velox needs updating"; a differing minor or patch is always accepted.
Failing loudly at connect is the point — the alternative is failing subtly at the tenth
field, three weeks later.
**Process:** changes arrive as a PR touching `contracts/` alone, containing the schema edit,
the fixtures, the regenerated code and the `VERSION` bump. Lanes rebase onto it. This is the
only synchronization point in the project, so it is kept cheap and frequent rather than big
and rare.
## What enforces this rather than hoping for it
* Generated code is **committed**, so no lane is blocked on running Python, and a stale
regeneration is a diff in the PR rather than an invisible skew.
* `tests/conformance/check_contract.py` re-runs all four generators and fails if any
committed output differs. Hand-editing generated code cannot be merged.
* The generators refuse schema constructs they cannot lower, so an unrepresentable schema
stops the build instead of producing subtly wrong code in one language only.
* Every method must have a success fixture, checked mechanically.
* `SettingKey` and `Settings.properties` must name the same keys, checked mechanically —
the GUI cannot invent a settings key that isn't in the contract.
## Consequences
* Adding a field costs a round trip through PROTO. That is the price of the guarantee, and
it is deliberately much cheaper than the M2 rewrite it prevents.
* Four generators must stay in step with `schema_ir.py`. They share one IR precisely so
this is one change, not four.
* The frozen surface has known asymmetries, left in on purpose rather than invented around:
there is no `queue.remove` and no `rules.remove` (rules are deleted through
`rules.upsert`'s `remove` list, queues not at all in v1). These were not added because
`contracts/README.md` does not list them, and quietly widening the surface during the
freeze is the exact habit this ADR exists to prevent. They are minor bumps whenever a lane
actually needs them.
## Alternatives rejected
**Hand-written types per lane.** This is the default and it is how projects like this fail.
Four hand-maintained copies of a type diverge silently; the divergence is discovered at
integration, when all four are load-bearing.
**Protobuf or Cap'n Proto.** Better wire types, but the extension must speak this protocol
from a WebExtension, and JSON-RPC over JSON is what a browser speaks natively. A binary
codec buys efficiency on a control plane that moves a few hundred small messages a second,
and costs a build dependency in every lane plus a much worse debugging story: `nc` and a
browser devtools console can both read this wire.
**Not freezing, and letting the contract evolve continuously.** The whole parallel-lane plan
depends on the interface being still. An unfrozen contract means every lane rebases onto a
moving target, which is the serialized dependency M0 exists to remove.
@@ -0,0 +1,51 @@
# ADR 0009 — `libveloxproto`: generated protocol code is not part of `libveloxcore`
**Status:** accepted · **Date:** 2026-09-09 · **Lane:** PROTO
## Context
Two rules in `CLAUDE.md` appear to collide, and a later agent will notice:
* the layering rule says **`core` → no JSON, no SQL, no Qt, no RPC. Ever.**
* the PROTO brief says `gen_cpp.py` emits **`core/generated/velox_proto.{hpp,cpp}`**, with
`to_json`/`from_json` built on nlohmann.
Read together they say core must not know about JSON, and also that a JSON serialiser goes
in `core/`. Left unresolved, someone eventually "fixes" it by moving the generated code, or
by deleting the layering rule.
## Decision
`core/generated/` is its own CMake target, **`libveloxproto`**, and is **not** part of
`libveloxcore`. The layering rule constrains `libveloxcore` — the engine — which continues
to know nothing about JSON, SQL, Qt or RPC. `libveloxproto` is the wire types, which are by
definition JSON, and it is linked by `veloxd`, the CLI, the GUI and the conformance runner.
The directory is `core/generated/` because the PROTO brief and the roadmap both name that
path, and moving it would break a written interface for a cosmetic gain.
`libveloxcore` must not link `libveloxproto`. The daemon's job is to project the engine's
state into the contract's types; if the engine ever needs to know what a `TaskSummary` is,
the layering has been violated and the fix is in the daemon, not here.
## Consequences
* `add_subdirectory(core)` must produce two targets. Lane PKG/QA owns the root build files;
the conformance runner's `CMakeLists.txt` shows the linkage it expects.
* A grep for `nlohmann` under `core/` is no longer automatically a review failure — but one
under `core/src/` or `core/include/` still is. That is the line, and it is worth stating
because the old grep was a nice bright one.
* The generated code deliberately does **not** emit nlohmann's ADL `from_json`, which
throws. Inbound parsing is `parse<T>() -> std::expected<T, ParseError>`, so a malformed
frame is a value the RPC loop handles rather than an exception unwinding through the
daemon. Only the outbound direction is implicit.
## Alternatives rejected
**Put the generated code in `daemon/`.** It is also needed by the CLI, the GUI and the
conformance runner, and `daemon/` belongs to a different lane than the one that generates
it. A shared artifact owned by one consumer is how ownership disputes start.
**Relax the layering rule to allow JSON in core.** The rule is the reason the engine stays
testable and the reason a GUI bug can never be an engine bug. It is worth more than the
convenience of one directory.