7 Commits
Author SHA1 Message Date
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
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
samiandClaude Sonnet 5 6db304a0ae proto: widen error-on-paused for ADR 0013's auto-pause signal (1.3.0)
DAEMON's docs/adr/0013-task-state-machine-ownership.md needs a wire signal
for the difference between a paused task the daemon entered unilaterally
(auth_required, server_file_changed, disk_full) and one that was requested
(user, schedule, queue stop, admission reconcile) -- without it, DAEMON's §3
resume rule ("resume only when the reason matches the event that justifies
resuming") has nothing correctness-preserving to key on, and would have to
guess from timing. CORE has already accepted the ADR; this was the sole
remaining blocker per DAEMON's own status line on it.

No retype, no new field -- error was already TaskError | null on both
event.task.state and TaskSummary, exactly as DAEMON characterized the ask.
Only the *description* of when it is populated widens: previously "failed or
retry_wait", now also "paused, when the daemon entered it on its own
initiative". A deliberate pause still carries error: null. TaskError's own
top-level description gets the same widening, since it previously also said
"failed or retry_wait" and would otherwise contradict the field that embeds
it.

New fixture (event.task.state.auto-paused.json) exercises the case directly:
an auth_required pause with error populated, contrasted in its own
description against download.pause.json's error: null for a requested pause.
The existing event.task.state.json fixture's first assertion was stale
("error is present exactly when failed or retry_wait") and is corrected.

Minor bump, 1.2.0 -> 1.3.0: a description widening on an already-nullable,
already-optional field changes no JSON Schema shape, but it is a real
behavioral commitment change worth a version bump so downstream regenerates
and notices, per the same reasoning ADR 0010 applied to TaskErrorCode.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012fgjnqFCS5h5L7gZTZo3rV
2026-09-10 00:04:35 +04:00
samiandClaude Sonnet 5 2d36e9fef0 proto: land F2 — download.provideAuth (1.2.0)
The last contract gap blocking an M1 definition-of-done item: CORE's "401
handled" has no return path without it, and B2a's sibling F2 was accepted in
proto-answers-m1.md but never actually landed.

download.provideAuth {taskId, username, password, save?} -> {ok}, exactly as
proposed there. Privileged and Unix-socket-only: a credential-bearing method
must never be reachable from the browser, which is the other half of the
promise event.auth.required's own description already makes ("never back
through this event, never into a log"). It answers the challenge; it does not
itself resume the task -- the daemon retries with the credential attached and
the ordinary event.task.state reports the task leaving retry_wait, the same
as any other state change.

save only tells the daemon whether to persist the credential in the Secret
Service for next time, or use it for this attempt alone -- it never touches
SQLite or a log either way, in keeping with CLAUDE.md's secrets rule.

Three fixtures: the success path, -32010 for a task that no longer exists
(credentials submitted for it are simply discarded), and -32003 confirming
the extension has no path to this method under any transport.

mockd gets a real handler rather than falling through to the generic fixture
responder: it validates the taskId exists (so the -32010 fixture is
replayable) and actually transitions the task out of retry_wait.

Minor bump, 1.1.0 -> 1.2.0: additive method, no existing type touched.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012fgjnqFCS5h5L7gZTZo3rV
2026-09-10 00:01:58 +04:00
samiandClaude Sonnet 5 60363a7142 proto: land B4 and B2a — buffer bounds, budget knobs, effective readback (1.1.0)
Minor bump on 1.0.0, per core/docs/buffer-sizing.md.

B4 — bufferBytes bounds corrected in all four locations (DownloadSpec,
TaskDetail, download.update's patch, Settings.connection.bufferBytes): was
4 KiB-8 MiB with no stated default, now 64 KiB-16 MiB with a 1 MiB default.
64 KiB because 4 KiB is smaller than one libcurl HTTP/2 write-callback delivery;
16 MiB because throughput from write size is flat past ~1-4 MiB and past 16 MiB
there is stall-cover left to buy but no memory left to spend it on; 1 MiB
default because it is the only candidate for which docs/04's 60 MB RSS target
actually holds once buffers are counted per segment, not per download.

Two new settings keys: connection.maxTotalBufferBytes (128 MiB default) and
connection.maxActiveSegments (32 default). Without them CORE's clamp — reduce
every live segment's buffer to fit the global cap — has no wire configuration
surface, and "20 active downloads" has no meaning distinct from 160 live TLS
connections.

B2a — TaskDetail.effectiveBufferBytes: what a segment is actually using right
now, after the clamp. Placed on TaskDetail next to bufferBytes, following the
requested/effective pattern ADR 0010 already established for segments. The
download.get fixture now demonstrates a real clamp (16 MiB requested, 4 MiB
effective) rather than a case where the cap happens not to bind.

docs/04-engine-design.md §4 and §8 updated in the same change per CORE's
request and CLAUDE.md rule 5: the RSS target is now stated as conditional on
maxActiveSegments = 32, and the old 4 MiB/64 MiB/256 MiB numbers are corrected
to match the schema. ADR 0012 records the reasoning and explicitly keeps the
60 MB target over CORE's offered 120 MB alternative, with the arithmetic that
makes 60 MB achievable with margin.

Numbered 0012 rather than 0011: DAEMON is independently drafting ADR 0011
(admission control / segment budget split) in a peer session at time of
writing, so 0011 was reserved to avoid a collision.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012fgjnqFCS5h5L7gZTZo3rV
2026-09-09 23:20:58 +04:00
samiandClaude Opus 5 2c8f5e5d7d proto: answer CORE's freeze-blockers before 1.0.0 lands
Three corrections into 1.0.0, all of which would be major bumps once the
contract has landed. It has not: main still carries 1.0.0-draft, so these are
corrections to an unpublished version rather than changes to a released one.
ADR 0010 records that and the reasoning behind each.

B1 — TaskError.code was a bare integer, and the integer space in the contract is
JSON-RPC's, which is a different thing; TaskError's own description said so while
typing its code as one. Freeze TaskErrorCode: a string enum mirroring vdm::Error
by name and in order, all 27 failure values, verified against
core/include/vdm/util/error.hpp mechanically. ErrorCode says why a call failed;
TaskErrorCode says why a download failed, and a download fails while every RPC
succeeds. Adds TaskError.cause so max_retries_exhausted names what kept failing.

B2 — TaskSummary.segments is now explicitly the effective count in use right now,
after the per-host cap and the non-resumable demotion to 1. DownloadSpec.segments
and download.update's patch say they are the requested value.

B3 — Segment.endByte's "minimum: 0" contradicted the description's own empty-range
encoding of startByte - 1, which is -1 for the first segment of every download.
Empty ranges are no longer representable and are not needed. The range stays
CLOSED and INCLUSIVE, matching the HTTP Range header the two fields are copied
into verbatim, and that is now stated in the schema, the README, an ADR, a fixture
assertion and a conformance check. CORE asked for half-open and gets a written
notice rather than a silent schema edit. Segment state spells 'downloading' as
CORE asked, not 'receiving'.

check_contract.py now enforces segment contiguity, coverage of exactly
[0, sizeBytes-1], downloadedBytes within the range size, and the entry count
matching TaskSummary.segments. The download.get fixture claimed 8 segments while
carrying 2; it now carries 8 contiguous ones covering the whole file.

contracts/proto-answers-m1.md answers every item in core/docs/proto-requests-m1.md,
including the ones not being landed now: B2a and F2 accepted as follow-ups, F1
answered with the notify path for M1, F3 already frozen as a Checksum object
rather than a string, and D1 left for DAEMON to draft as the three-way ADR it is.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012fgjnqFCS5h5L7gZTZo3rV
2026-09-09 20:01:47 +04:00
samiandClaude Opus 5 53421d6cb8 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
2026-09-09 19:55:54 +04:00