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
11 KiB
CORE → PROTO — requests before the VERSION freeze
Status: open. Raised by lane CORE against contracts/ at 1.0.0-draft.
This is a request list, not a decision record. PROTO owns every item here. Once PROTO
answers D1, that one becomes a three-way ADR (docs/adr/) per contracts/README.md rule 4.
Ranking is by contracts/README.md rule 4, not by intuition:
new method or new optional field = minor, can land anytime;
rename / remove / retype / default change = major + ADR.
Freeze-blockers — get these into 1.0.0 (major if we guess wrong)
B1. A frozen wire enum for TaskSummary.error.code
TaskSummary.error.code is currently a bare integer. CORE cannot import a protocol
header (layering rule — core/ never sees JSON/RPC), so CORE has its own failure
taxonomy regardless of what PROTO picks; DAEMON owns the projection from one to the
other. The ask is not "who owns the enum" — it is: freeze a wire enum with enough
cardinality that DAEMON's mapping from the CORE taxonomy below is lossless.
If the wire enum is coarser than this table, DAEMON has to collapse distinct engine
failures into one code and the GUI can't tell "server file changed" from "checksum
mismatch". Retyping error.code later is a major bump.
CORE failure taxonomy (this is vdm::Error in core/include/vdm/util/error.hpp):
CORE Error |
Meaning | retryable |
carries httpStatus |
|---|---|---|---|
canceled |
user/daemon cancelled | no | no |
resolve_failed |
DNS resolution failed | yes | no |
connect_failed |
TCP connect failed | yes | no |
tls_failed |
TLS handshake / cert rejected | no | no |
connection_reset |
peer reset mid-transfer | yes | no |
timeout |
connect or idle/stall timeout | yes | no |
too_many_redirects |
redirect limit hit | no | no |
http_client_error |
4xx not otherwise classified | no | yes |
http_server_error |
5xx | yes | yes |
auth_required |
401 / 407 — task pauses, not terminal | no | yes |
forbidden |
403 after the referrer-retry (docs/04 §7) |
no | yes |
not_found |
404 | no | yes |
range_not_satisfiable |
416 — triggers re-probe/re-split | yes | yes |
gone |
410 / expired signed URL, no mirror left | no | yes |
server_file_changed |
200 where 206 expected, or If-Range / ETag drift (docs/04 §5) |
no | yes |
content_length_mismatch |
bytes received ≠ declared length | yes | no |
checksum_mismatch |
post-download hash ≠ expected | no | no |
disk_full |
ENOSPC | no | no |
io_error |
pwrite / fallocate / fdatasync failed |
no | no |
path_rejected |
outside allowed roots or not writable | no | no |
permission_denied |
EACCES on the destination | no | no |
meta_corrupt |
.veloxpart.meta failed CRC / bad magic |
no | no |
meta_version_unsupported |
meta file from a newer engine | no | no |
probe_failed |
probe could not determine file info | maybe | yes |
unsupported_url_scheme |
not http/https | no | no |
max_retries_exhausted |
backoff loop gave up (data names the last underlying Error) |
no | maybe |
internal |
invariant violation / bug | no | no |
Concretely: a string enum on error.code mirroring these names is simplest to keep
lossless; if it stays an integer, please reserve grouped ranges (e.g. 1xx network,
2xx http, 3xx local-io, 4xx metadata, 5xx internal) with room to grow inside each.
B2. Freeze the meaning of TaskSummary.segments — major, retype-of-meaning
docs/04 §4/§3 require the effective value (after the per-host cap and the
non-resumable→1 demotion) to reach the UI. TaskSummary.segments currently has no stated
meaning. Please freeze it as "effective segment count in use right now", not the
requested value. This is the only genuinely freeze-blocking half of the buffer/segment
story — pinning a field's meaning after 1.0.0 is a major bump under rule 4. It stands
alone; do not defer it together with B2a below.
B2a. A readable effective buffer size — minor, but note the trap
Split out from B2 on purpose so PROTO can't use the cheap half to defer the major half.
bufferBytes is write-only: accepted by download.add and download.update.patch,
readable on no type. docs/04 §4 says the clamped / "silently reduced to fit" value
is "reported back to the UI", so CORE will expose an effective value and PROTO needs a
frozen field to carry it. Adding an optional field is minor under rule 4 — except
that TaskSummary is additionalProperties: false, so a daemon cannot tack it on as an
extension; it has to be in the schema or it's dropped by every conformance replay. Name
and placement are PROTO's call (effectiveBufferBytes on TaskSummary / TaskDetail,
or a dedicated readback). Land it whenever — it just can't be improvised later.
Fold in two more, all discovered via B4 (buffer-sizing.md):
maxTotalBufferBytesis absent from the contract entirely. CORE implements the clamp against it; the Options dialog can neither show nor set it. AddSettings.schema.json connection.maxTotalBufferBytes(recommended default134217728= 128 MiB).maxActiveSegmentsis new — the global concurrent-segment cap that actually bounds "20 active downloads" for the RSS budget. AddSettings.schema.json connection.maxActiveSegments(recommended default32). DAEMON's scheduler reads it.
B3. Freeze Segment.schema.json field names
Segment is already on PROTO's type list (contracts/README.md) and TaskDetail
"includes segments[]", so this is mostly field-name matching — but do it before
freeze or each fix is a rename = major.
CORE will emit per segment:
| CORE field | Type | Note |
|---|---|---|
index |
int ≥ 0 | event.task.progress currently says i. Pick one name for both. |
startByte |
int ≥ 0 | absolute byte offset, inclusive |
endByte |
int | absolute byte offset, inclusive — range is [startByte, endByte] (see below) |
completed |
int ≥ 0 | bytes written in this range so far |
speedBps |
int ≥ 0 | current per-segment rate |
state |
enum | connecting | downloading | stalled | complete | failed |
Resolved by PROTO at freeze: endByte is inclusive (matches HTTP Range
semantics — Range: bytes=start-end is inclusive — and removes an off-by-one at the curl
boundary). CORE designs stage 6 (segmenter/stealer) against inclusive. The earlier
[start, end) ask is withdrawn.
Still open: (a) reconcile i vs index; (b) confirm the segment state enum values;
(c) empty-segment representation — see B3a.
B3a. Zero-length segment must be representable — PROTO is fixing; CORE's requirement
With endByte inclusive and minimum: 0, a zero-length segment (endByte = startByte - 1) at offset 0 is endByte = -1, which the schema forbids. The one case CORE actually
needs: a whole-file zero-length download (Content-Length: 0) — one segment, length
0. It is a valid HTTP response and the daemon/GUI must be able to hold it.
CORE will not produce empty segments mid-download: the min_segment_bytes floor
(1 MiB, docs/04 §3) means the segmenter never splits below 1 MiB and the stealer only
takes a half-range if it is ≥ that floor. So B3a is purely about the degenerate
whole-file case.
Preference: encode segments as startByte + length (+ completed) rather than an
inclusive endByte with a startByte - 1 sentinel — length: 0 is then the natural
representation and there is no negative value to allow. If endByte inclusive stays,
then a 0-byte task needs an explicit encoding (an empty/length field, or permitting
endByte = startByte - 1 with minimum: -1) — any of those work for CORE as long as
total length 0 round-trips. Flag back if the chosen fix needs anything else from CORE.
B4. bufferBytes range is wrong in the frozen schema — see buffer-sizing.md
contracts/ froze bufferBytes at 4 KiB – 8 MiB in four schema files (DownloadSpec,
TaskDetail, download.update patch, and Settings.schema.json connection.bufferBytes);
docs/04 §4 says 64 KiB – 64 MiB default 4 MiB; the RSS DoD (docs/04 §8 line 125) holds
with neither. CORE's recommendation — 64 KiB – 16 MiB, default 1 MiB, plus a new
maxActiveSegments (32) global concurrent-segment cap and maxTotalBufferBytes
lowered to 128 MiB and added to the contract, with line 125 kept at 60 MB "given
maxActiveSegments = 32" (or explicitly raised to 120 MB) — and the per-segment RSS
arithmetic are in core/docs/buffer-sizing.md. PROTO to land the four
schema edits + docs/04 §4 + §8 + ADR together.
Do not hold the freeze for these
All minor under rule 4 (new methods / new events / a pattern on an existing optional
string). contracts/README.md rule 5 wants contract changes "cheap and frequent" — raise
these now, land them as small follow-up PRs, but do not gate 1.0.0 on them. Each is
needed within M1.
F1. A "needs user decision" carrier — new event + method, minor
docs/04 §5 mandates a "File on server has changed — restart download?" prompt, and §7
has 416/stale-metadata. The event surface has only event.notify + task states. Either:
- confirm
state: paused+ anevent.notify {level:"warn", …}is the intended carrier (CORE reportsserver_file_changed, DAEMON pauses + notifies, GUI offers restart), or - add
event.task.decision {taskId, kind, options[]}+download.decide {taskId, choice}.
CORE needs to know which, but a string comparison on error.code covers the engine side
either way, so this doesn't block freeze.
F2. A credential return path for 401/407 — new method, minor, M1-needed
event.auth.required exists; there is no method to hand credentials back to a paused
task. session logins / Secret Service is M3, but "401 handled" is in CORE's M1 DoD.
Requesting download.provideAuth {taskId, username, password, save?} →
{ok}. Secrets go to the Secret Service, never SQLite/logs (CLAUDE.md §4) — save
just tells DAEMON whether to persist.
F3. checksum string format — pattern on an existing optional field, borderline
download.add {checksum?} has no shape. CORE's verifier will accept "<algo>:<hex>"
with algo ∈ {md5, sha1, sha256} (appendix lists MD5/SHA-256). Adding a pattern to an
existing optional string is close to free; PROTO's call whether it's worth a fixture now
or a doc note.
Three-way ADR material (after PROTO answers)
D1. State-machine ownership split — CORE / DAEMON / PROTO
docs/04 §1 hands CORE the lifecycle, but queued and every scheduler transition are
DAEMON's. Proposed split, to be recorded as an ADR once all three agree:
- CORE owns:
probing → connecting → downloading ⇄ paused → retry_wait → assembling → verifying → complete | failed, pluscancelledfrom any state. - DAEMON owns:
new,queued, and pause-for-schedule (enters CORE'spaused). - Shared:
pausedis reachable from either side; both must be idempotent about it.
This needs an ADR regardless of the answer (contracts/README.md rule 4: the lifecycle
enum is a frozen type and any change to who drives it is a documented decision).