# contracts/ — the wire contract **This directory is the interface between every lane.** Owner: agent **PROTO**. Nobody else commits here. Everybody else *generates from* here. ``` contracts/ ├── VERSION # protocol semver, e.g. 1.0.0 ├── 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, CaptureOffer… │ ├── methods/ # one file per method: params + result │ └── events/ # one file per server→client notification ├── fixtures/ # golden request/response pairs, replayed by conformance └── codegen/ ├── gen_cpp.py # → core/generated/ (structs + to_json/from_json) └── gen_ts.py # → extension/src/shared/protocol/ (types + client) ``` ## 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. ## 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.0.0 target — 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")* | ### 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}` | ## 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) |