Files
vdm/contracts/README.md
T
samiandClaude Sonnet 5 5e3e21543a proto: give the generated C++ Dispatcher a real error channel (P1, 1.4.0)
DAEMON's daemon/docs/proto-requests-m1.md P1: velox::proto::Dispatcher's
on_* methods returned Result<T> = expected<T, ParseError>, and dispatch()
mapped every handler error to -32603 InternalError. A handler had no way to
return -32010 (download.get not-found), -32011 (download.add invalid-path)
or -32013 (probe-failed) with their data payloads -- three error fixtures a
conformant server must satisfy were unreachable, blocking DAEMON's
"conformance as a server" M1 DoD.

Two error channels now, kept separate on purpose:
  - parse: Result<T> / ParseError -- dispatch() failing to turn the wire into
    typed params. Always -32602, always structural.
  - handler: HandlerResult<T> / HandlerError -- a handler deciding the request
    can't be fulfilled. Carries any ErrorCode + message + free-form data.

    struct HandlerError {
        ErrorCode code{ErrorCode::InternalError};  // bare {} is a valid -32603
        std::string message;
        nlohmann::json data = nullptr;             // straight into the error's data
    };
    template <class T> using HandlerResult = std::expected<T, HandlerError>;

dispatch()'s handler branch is now
  make_error(id, r.error().code, r.error().message, r.error().data)
instead of a hard-coded InternalError. -32001/-32002/-32003 stay the server
layer's to raise around dispatch(), as DAEMON already does.

Verified end to end against the real dispatch() path: a handler returning
TaskNotFound/InvalidPath/ProbeFailed produces -32010/-32011/-32013 with the
data object intact, and a bare HandlerError{} still yields a clean -32603
with no data field. The `= nullptr` on the member (not `{nullptr}`) matters:
brace-init of nlohmann::json from nullptr is the array [null], not JSON null.

FixtureDispatcher regenerated to HandlerResult; conformance_main.cpp only
inspects dispatch()'s JSON and needed no change. TS side is untouched beyond
the version string -- no server Dispatcher is generated there.

P2 also handled: session.hello.version-mismatch's data.expected was a stale
"1.0.0"; now $any, with a note that the error-fixture compare is on `code`
only so a server echoing kProtocolVersion there is fine.

Version: minor, 1.3.0 -> 1.4.0. Wire is byte-identical (no schema, fixture,
or OpenRPC change) but every Dispatcher implementer must swap Result ->
HandlerResult on regen, and the bump is how lanes are told to. Not an ADR:
one lane consumes this binding, it's the one that asked, and the shape is
the one they proposed. Answered in contracts/proto-answers-daemon-m1.md.

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

203 lines
11 KiB
Markdown

# 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 `Result` → `HandlerResult` 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".
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) |