Files
vdm/contracts/README.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

11 KiB

contracts/ — the wire contract

This directory is the interface between every lane. Owner: agent PROTO. Nobody else commits here. Everybody else generates from here.

Status: v1.4.0 (frozen at v1.0.0 on 2026-09-09; minor bumps since)

v1.0.0 froze 38 methods, 9 events, 26 named types. v1.1.0 widened bufferBytes bounds and added the segment-budget settings. v1.2.0 added download.provideAuth (F2). v1.3.0 widened when error is populated on a state change to cover a daemon-initiated paused (for docs/adr/0013-...). v1.4.0 (current) is a C++-binding-only change: the generated Dispatcher gains a HandlerError / HandlerResult<T> error channel so a handler can return -32010 / -32011 / -32013 with their data payloads instead of collapsing to -32603. The wire is byte-identical — no schema or fixture change — but any Dispatcher implementer must swap ResultHandlerResult on regen. Answered in contracts/proto-answers-daemon-m1.md. See also docs/adr/0005-... for the versioning rule and docs/adr/0010-... for the failure taxonomy and segment ranges.

Lane requests are answered in writing: contracts/proto-answers-m1.md responds to core/docs/proto-requests-m1.md point by point.

What each lane can rely on, starting now:

You need It is here
C++ types, parsing, dispatch core/generated/velox_proto.{hpp,cpp} (target libveloxproto)
TypeScript types, typed client, runtime validators extension/src/shared/protocol/
The API document to read contracts/openrpc.json
A daemon to build against today tools/mockd — both transports, 4 Hz progress, unhappy-path flags
Proof you have not drifted ./tests/conformance/run.sh

Changing this is a PR to contracts/ alone. Optional field or new method → minor. Rename, remove or retype → major, plus an ADR. File a request; do not add a field locally.

contracts/
├── VERSION                     # protocol semver — v1.2.0, minor-bumped from the v1.0.0 freeze
├── openrpc.json                # human-readable API doc (generated from schema/)
├── schema/
│   ├── envelope.schema.json    # JSON-RPC 2.0 envelope + our error codes
│   ├── types/                  # Task, Segment, Category, Queue, Settings, CaptureRules…
│   ├── methods/                # one file per method: params + result
│   └── events/                 # one file per server→client notification
├── fixtures/                   # golden request/response pairs, replayed by conformance
└── codegen/
    ├── schema_ir.py            # the one loader/IR all generators share
    ├── gen_cpp.py              # → core/generated/  (structs + to_json + parse + dispatch)
    ├── gen_ts.py               # → extension/src/shared/protocol/ (types, client, validators)
    ├── gen_openrpc.py          # → contracts/openrpc.json
    └── gen_cpp_conformance.py  # → tests/conformance/cpp/fixture_dispatcher.hpp

Each subdirectory has its own README: codegen/ documents the supported JSON Schema subset, fixtures/ documents the fixture shape and the placeholder rules.

Rules

  1. Generated code is committed. No lane may be blocked because it can't run Python.
  2. Hand-editing generated files is a merge blocker. Fix the schema and regenerate.
  3. Every method needs at least one fixture — a success case and, where meaningful, an error case. A method with no fixture is not done.
  4. Versioning: adding an optional field or a new method → minor bump. Removing, renaming, retyping, or changing a default → major bump and a written migration note in docs/adr/. session.hello rejects a major mismatch with error -32001 and a message the GUI renders as "Velox needs updating". "Retype → major" is about the wire — a field a client parses off the socket. 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 — see docs/adr/0014-generated-binding-changes-and-versioning.md.
  5. Changes arrive as a PR to contracts/ alone, containing: schema edit + fixtures + regenerated code + VERSION bump. Lanes rebase onto it. This is the only synchronization point in the whole project — keep it cheap and frequent rather than big and rare.

Per-method annotations

Every method schema carries these, and both generators emit them as data the code can act on rather than as prose a reader has to honour:

Key Meaning
x-privileged refused over the WebSocket transport with -32003
x-transports which listeners serve it (uds, ws)
x-deadlineMs how long a client waits before giving up
x-errors the error codes this method is documented to return
x-wsRestrictions extra limits when the call arrives from the extension

20 of the 39 methods are privileged: everything that reconfigures the daemon, destroys user data, or names an arbitrary destination path. The extension may request a download; it may not choose where the bytes land.

Transport framing

Client Transport Framing
GUI, CLI $XDG_RUNTIME_DIR/velox/velox.sock newline-delimited JSON (NDJSON)
nmhost ← Firefox stdio 4-byte little-endian length prefix (Firefox's format)
nmhost → daemon same Unix socket NDJSON
Extension (fallback) ws://127.0.0.1:520xx one JSON message per WS text frame

All four carry the same JSON-RPC 2.0 payloads. The framing differences stop at the transport layer; no method behaves differently depending on how it arrived — except that methods marked "privileged": true in the schema are refused over the WebSocket transport.

Method surface (v1.2.0 — expand only via PR)

Session

Method Params → Result
session.hello {clientType, clientName, protocolVersion, token?}{daemonVersion, protocolVersion, capabilities[], sessionId}
session.pair {clientName, extensionId}{token, expiresAt} (WS only; triggers user prompt)
session.subscribe {events[]}{ok}

Downloads

Method Params → Result
download.probe {url, headers?, cookies?, referrer?, userAgent?}{filename, sizeBytes?, mime, resumable, effectiveUrl, suggestedCategoryId}
download.add {url, headers?, cookies?, referrer?, userAgent?, filename?, saveDir?, categoryId?, segments?, bufferBytes?, startMode:"now"|"later"|"queue", queueId?, description?, checksum?}{taskId, state}
download.addBatch {items[], defaults}{taskIds[]}
download.list {filter?, sort?, offset?, limit?}{total, items: TaskSummary[]}
download.get {taskId}TaskDetail (includes segments[])
download.start | .pause | .resume | .cancel {taskIds[]}{updated[]}
download.remove {taskIds[], deleteFile:bool}{removed[]}
download.update {taskId, patch:{filename?, saveDir?, categoryId?, queueId?, description?, segments?, bufferBytes?}}TaskSummary
download.refreshUrl {taskId, url, headers?}{ok} (IDM's "Refresh Download Address")
download.provideAuth {taskId, username, password, save?}{ok} — answers event.auth.required. UDS only; privileged. Credentials go to the Secret Service, never SQLite, never logs

Organisation

category.list · category.upsert · category.remove · queue.list · queue.upsert · queue.start · queue.stop · queue.reorder · rules.list · rules.upsert · schedule.get · schedule.set

Settings & limits

settings.get {keys?} · settings.set {values} · limiter.get · limiter.set {globalBps?, enabled}

Browser integration

Method Notes
capture.offer {url, method, headers, cookies, contentType?, contentLength?, contentDisposition?, tabUrl, filename?}{action:"take"|"ignore", taskId?, reason?}must answer within 750 ms; the extension gives up and lets Firefox handle it otherwise
capture.getRules Extension mirrors the daemon's monitored types so the two never disagree
media.listVariants {manifestUrl, headers}{variants:[{id,resolution,bitrate,codec,sizeEstimate}]}
media.addVariant {manifestUrl, variantId, ...addParams}{taskId}

Grabber

grabber.start {startUrl, depth, includePatterns[], excludePatterns[], fileTypes[]}{jobId}; grabber.status {jobId}; grabber.harvest {jobId, select[]}{taskIds[]}

Events (server → client notifications)

Event Payload
event.task.added / .removed {taskId, summary?}
event.task.state {taskId, state, error?}
event.task.progress Batched array, emitted at ≤4 Hz: [{taskId, downloaded, speedBps, etaSec, segments:[{i,completed,speedBps}]}]
event.speed.global {downBps, activeCount}
event.auth.required {taskId, host, realm, scheme}
event.notify {level, title, body, taskId?}
event.settings.changed {keys[]}
event.grabber.progress {jobId, found, crawled, done}

Two error spaces, and why they are not the same

This trips people up, so it is stated once, loudly:

ErrorCode TaskErrorCode
Says why a call failed why a download failed
Space JSON-RPC integers (-32xxx) strings ("server_file_changed")
Lives in the JSON-RPC envelope's error TaskError.code, on a task
Example -32602 — your params were malformed checksum_mismatch — the bytes arrived and were wrong

A download fails while every RPC involved succeeds. That is the normal case. Never put a -32xxx into a TaskError, and never invent a JSON-RPC code for a transfer failure.

TaskErrorCode's 27 values mirror vdm::Error in core/include/vdm/util/error.hpp by name, so DAEMON's projection from the engine taxonomy is lossless and a new engine failure that has no wire spelling is a visible hole rather than a silent collapse to internal.

Segment ranges are inclusive

Segment.startByte and Segment.endByte describe a closed range [startByte, endByte]: endByte is the last byte, not one past it, and the segment covers endByte - startByte + 1 bytes. The two fields are copied verbatim into Range: bytes=<startByte>-<endByte>, which RFC 9110 defines as inclusive, so there is no arithmetic between the wire and the socket and nowhere for an off-by-one to hide. Conformance enforces contiguity and full coverage; a fixture written half-open fails.

Requested is not effective

DownloadSpec.segments is what a client asked for. TaskSummary.segments is what is in use right now, after the per-host cap and after the demotion to 1 for a non-resumable source. They are routinely different and the GUI must render the effective one.

Error codes

Code Meaning
-32600/-32601/-32602/-32603 Standard JSON-RPC
-32001 Protocol major version mismatch
-32002 Not paired / invalid token
-32003 Method not permitted on this transport
-32010 Task not found
-32011 Invalid destination path (outside allowed roots, or not writable)
-32012 Disk full
-32013 Probe failed (with data.httpStatus)
-32014 Rate limited (pairing brute-force lockout)