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
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
Accept as written — no amendments. Answers to the four open items:
1. tasks_starved / starved_tasks() count ONLY tasks in {connecting,
downloading} with segments_active == 0 (the allocator owes a slot to a
task that is asking). retry_wait and paused (either-initiated) are
outside that state set, so they are excluded by construction, not by a
special case. Design commitment; the accessor is stage 6/8.
2. pause() is idempotent: no-op success on an already-paused or terminal
task; resume() no-op success on a non-paused task; only task_not_found
errors. No state-change event for a no-op.
3. PROTO's item — CORE confirms its half: auto-pause reports the
transition with ErrorInfo populated (auth_required / server_file_changed
/ disk_full / path_rejected), already in util/error.hpp. Ready once
PROTO permits error on state=="paused".
4. CORE adopts "auto-pause"; the wire/API discriminator stays
state==paused + presence of the Error code.
Notes back: pause during verifying re-hashes from scratch on resume;
pause during assembling is M4; restart handling in §5 agreed —
start(TaskId) re-derives resume position from .veloxpart.meta + If-Range.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01HPPSGhiArbvQgwC2DNiURS
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
net/content_disposition — total parser for the mojibake-prone header:
RFC 6266 filename (quoted/token), RFC 5987 filename* ext-values
(charset'lang'pct-encoded, incl. RFC 2231 continuations), legacy RFC 2047
encoded-words (=?UTF-8?B?..?= / ?Q?), and raw Latin-1 bytes; prefers
filename* over filename; strips path components AFTER decoding (a base64
payload can hold '/'). 22-case test table.
net/text_codec (internal) — percent-decode, UTF-8 validation, Latin-1->
UTF-8, base64, RFC 2047 — shared by the CD parser and the URL splitter.
net/url — a small total URL splitter (scheme/userinfo/host/port/path/
query/fragment, http(s) validity) and url_filename() for the last path
segment; used for the filename fallback.
net/probe — HEAD then a ranged GET bytes=0-0 that PROVES resumability
(206 + matching Content-Range + a validator), rather than trusting
Accept-Ranges which servers lie about; the ranged GET is also the HEAD-
refused (403/405/501) fallback. 401/407 -> success result with
requires_auth, not an error. Runs on its own pool (max_concurrent,
default 4) outside the segment budget per ADR 0011 §5. suggest_filename()
does the resolution order (explicit -> disposition -> URL -> download.bin)
with a light strip; rules/ (stage 9) owns the authoritative sanitize.
tools/fuzz — libFuzzer targets for the CD parser and the URL splitter,
compiling the parser sources directly so they're fully instrumented;
self-guards on VELOX_BUILD_FUZZ + Clang (the top-level CMake adds every
tools/* unconditionally). Seed corpora included.
Fixed on the way: a p -> Transfer -> State -> cbs -> p reference cycle in
Prober that leaked every probe (drop the stored Transfer; the worker
keeps State alive). Tests green under ASan/UBSan and TSan.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01HPPSGhiArbvQgwC2DNiURS
One HttpClient owns a small pool of workers, each with its own CURLM; an
easy handle lives on one worker for its life. Public start/pause/resume/
cancel enqueue a command + curl_multi_wakeup(); callbacks (on_head /
on_data / on_finished) run on the worker thread and return a DataAction
(proceed / pause / abort). Covers redirects (final-response head only),
ranges (inclusive ByteRange -> CURLOPT_RANGE), proxy/SOCKS5, basic/digest
auth, cookies, verbatim headers, stall detection, a coarse recv-rate cap,
and a curl_share DNS/TLS cache across workers. CURLcode + HTTP status ->
vdm::Error in net/curl_error. A probe is on_head returning abort: it
finishes successfully (head_complete), not canceled.
Tests drive tools/testserver: full GET, ranged 206, redirect chain, 404
-> not_found, connection refused -> connect_failed, HEAD probe + ranged
0-0 probe (no body), cancel mid-transfer, pause/resume completes. Skip
cleanly if testserver isn't in the tree.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01HPPSGhiArbvQgwC2DNiURS
Accept the decision (each ceiling enforced once by its unit owner; DAEMON
counts tasks, CORE counts segments; the single min() clamp; per-host caps
split by unit). daemon/src/sched/ is unblocked.
Answers to the five open questions:
1. Min-1 is implementable in the allocator without inversion: guarantee
pass (zero-slot tasks, priority order) before growth pass; a released
slot always re-enters allocation from the top, never handed back
locally.
2. Probe pool size 4, outside the segment budget — confirmed.
3. set_max_active_segments is drain-not-kill; in-flight segments run to
their boundary.
4. Priority = an ordered TaskId list pushed on change, not an integer,
not per tick — tie-breaking is DAEMON policy.
5. on_budget_changed coalesced at 4 Hz, immediate on the tasks_starved
zero-crossing.
Amendments: (A1) add "yield" to §3.3 as the non-neutral slot-transfer op
that satisfies min-1 when the budget is full — "steal" stays slot-neutral;
(A2) "admission implies progress" is bounded-delay not immediate — bounded
by an incumbent's next segment boundary, capped by the stall timeout, so
§3.6's 2 s assertion window is too tight; (A3) add starved_tasks() /
starved_since() and pin the segments_active / tasks_starved definitions
(a connecting segment counts as a held slot and is not starvation).
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01HPPSGhiArbvQgwC2DNiURS
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
First pass counted one buffer per download; it is one per segment. 20
active downloads at the default 8 segments = 160 buffers, so at 20 tasks
the binding constraint is the global cap, not the per-segment default —
256 MiB and the "<=60 MB RSS / 20 downloads" target (line 125) cannot both
hold whatever the default is.
Floor (64 KiB) and ceiling (16 MiB) unchanged — the 256/64 unreachability
argument is stronger under per-segment accounting. Changes:
- default 1 MiB (was 2): with the cap below, 32 live segments x 1 MiB =
32 MiB buffers -> ~45-50 MiB RSS, line 125 holds with margin.
- NEW maxActiveSegments (default 32): a global concurrent-segment cap is
the actual mechanism that bounds "20 active downloads"; docs/01 §2
implies it, docs/04 never states it. Without it no buffer policy hits
60 MB.
- maxTotalBufferBytes 128 MiB (was 256) and it must be ADDED to the
contract — currently absent, so the clamp CORE implements has no wire
representation and Options can't show/set it. Folded into B2a.
- line 125: keep 60 MB "given maxActiveSegments=32 and default buffers",
or explicitly raise to 120 MB — ADR records which. Flagged that
changing it is a defensible outcome CORE owns, not a number that
quietly loses.
- bufferBytes bounds are in FOUR schema files, not three:
Settings.schema.json connection.bufferBytes also has 4096-8388608.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01HPPSGhiArbvQgwC2DNiURS
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
buffer-sizing.md: the frozen 4 KiB–8 MiB and docs/04's 64 KiB–64 MiB both
miss. Recommend 64 KiB – 16 MiB, default 2 MiB, max_total_buffer_bytes
unchanged at 256 MiB:
- 4 KiB floor is smaller than one libcurl write callback -> a syscall per
chunk; 64 KiB is the smallest floor that coalesces.
- throughput vs write size is flat past ~8 MiB on NVMe; 8–16 MiB is
disk-stall absorption headroom for the fast-pipe/slow-disk case; 64 MiB
is cache pressure for zero gain.
- 32 segments x 64 MiB = 2 GiB vs the 256 MiB cap means the docs/04 max is
unreachable past 4 total active segments — a misleading Options value.
16 MiB is reachable for single-/light-multitask and clamps to 8 MiB
under heavy parallelism, which is correct.
- default 4 MiB x 20 downloads = 80 MiB, busting the "<=60 MB RSS / 20
downloads" DoD; 2 MiB fits. Filed as request B4.
proto-requests-m1.md: B3 endByte accepted as inclusive (HTTP Range
semantics, no curl-boundary off-by-one); [start,end) ask withdrawn; stage
6 designed against inclusive. New B3a: the Content-Length: 0 whole-file
case needs a representable zero-length segment — min_segment_bytes means
CORE never makes empty segments mid-download, so it's only the degenerate
case; mild preference for startByte+length over an endByte=startByte-1
sentinel.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01HPPSGhiArbvQgwC2DNiURS
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
Pure formatting, no behaviour change. PKG landed .clang-format (Google
base, 4-space indent, 100 cols); this brings util/ and the test harness
into conformance so `clang-format --dry-run -Werror` is clean. Build and
all six test binaries unchanged and green.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01HPPSGhiArbvQgwC2DNiURS
util/ carries no wire surface, so it lands before the contract freeze.
- error: enum class Error, the engine-wide failure taxonomy; is_retryable
enumerates every value (no default:) so -Wswitch forces the retry
decision on each future addition. ErrorInfo carries context/http_status.
- result: Result<T> over std::expected<T, ErrorInfo>, Result<void>,
VDM_TRY / VDM_TRY_ASSIGN. Errors returned, never thrown, on the
transfer path.
- bytes: span aliases, LE load_le/store_le (debug-asserted precondition,
not input validation), and a bounds-checked latching ByteReader for the
.veloxpart.meta reader.
- event_bus: typed thread-safe pub/sub; header states plainly that
unsubscribe is not a quiesce point and download_task will need its own
drain.
- thread_pool: std::jthread pool; dtor joins in the body before members
die (fixed a use-after-destruction on cv_/mu_). Header notes shutdown is
drain-only and DAEMON will need a cancel mode.
- log: sink interface (core does no I/O); DAEMON installs one.
Tested: -Werror clean, 6 binaries green under plain / ASan+UBSan / TSan.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01HPPSGhiArbvQgwC2DNiURS
Self-contained core/CMakeLists.txt (veloxcore STATIC + velox::core alias),
warnings at target scope so the sanitizer presets' CMAKE_CXX_FLAGS override
doesn't drop -Werror. vtest: ~150-line header-only harness (VT_TEST /
VT_CHECK / VT_REQUIRE / VT_CHECK_EQ) behind a one-function vdm_add_test(),
so the swap to a real framework once PKG picks one is mechanical.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01HPPSGhiArbvQgwC2DNiURS
Lays out Velox Download Manager (IDM-class download manager for Ubuntu
26.04) as a monorepo ready for parallel lane development. No implementation
code by design.
- docs/: architecture, roadmap M0-M7, IDM-parity GUI spec, engine design,
Firefox extension spec, risks/spikes, packaging
- contracts/: wire-contract skeleton (JSON Schema + fixture templates) —
the single synchronization point between lanes
- docs/agents/: one brief per lane (PROTO, CORE, DAEMON, GUI, EXT, PKG/QA)
with owned directories, build order and definition of done
- CLAUDE.md: rules of engagement — lane ownership, layering, non-negotiables
- CMake scaffolding with dev/tsan/release/ci presets
Two environment findings shape the design: Firefox here is the Mozilla snap
(native-messaging risk, so the extension carries a loopback-WebSocket
fallback), and Wayland forbids passive clipboard monitoring (so clipboard
capture is explicit-action-first).
Co-Authored-By: Claude Opus 5 <[email protected]>