Files
vdm/contracts
samiandClaude Sonnet 5 d3ab00d67c proto: fix daemon/engine attribution in two buffer-clamp descriptions
DAEMON's rebase audit caught it: TaskDetail.effectiveBufferBytes said "the
daemon reduces every live segment's buffer" to fit maxTotalBufferBytes, but
ADR 0011's ownership table (line 55) assigns bufferBytes/maxTotalBufferBytes
to CORE in bytes-units -- DAEMON counts tasks, CORE counts segments and
bytes. "The engine" is correct.

Same error, same root cause, in DownloadSpec.segments: "the daemon lowers it
to the per-host cap" attributes the per-host *segment* cap to DAEMON, but
that's CORE's (ADR 0011 line 54, "CORE enforces per-host segment caps -- it
owns the connections and is the only place segments are counted"). DAEMON's
own per-host cap is a *task*-level admission cap (line 50), a different
thing entirely -- conflating the two in the schema's own prose is exactly
how the clamp ends up implemented twice, once in each lane, disagreeing.

Description-only, no version bump: the JSON Schema shape is untouched, only
which component the prose names as doing the reducing. Regenerated code
diffs are comment-only (doc comments in the generated header and TS types).

Checked every other buffer/segment-clamp description for the same mistake;
the rest either already said "CORE"/"the engine" or used passive voice that
doesn't misattribute (Settings.connection.maxTotalBufferBytes,
Settings.connection.bufferBytes, docs/04, ADR 0012).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012fgjnqFCS5h5L7gZTZo3rV
2026-09-10 00:07:05 +04:00
..

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.3.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 (docs/adr/0012-...). v1.2.0 added download.provideAuth, F2's credential return path. v1.3.0 (current) widens when event.task.state.error / TaskSummary.error are populated to also cover a paused the daemon entered unilaterally (auth_required, server_file_changed, disk full), not just failed/retry_wait — the wire shape is unchanged (error was already TaskError | null), only the description of when it's set. Landed for DAEMON's docs/adr/0013-task-state-machine-ownership.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".
  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)