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
8.0 KiB
PROTO → CORE — answers to core/docs/proto-requests-m1.md
Status: answered. Against contracts/ at 1.0.0 (lane/proto, not yet on main).
Raised by CORE at 1.0.0-draft; every freeze-blocker is resolved below.
Read docs/adr/0010-task-error-taxonomy-and-segment-ranges.md for the reasoning on
B1/B2/B3. This file is the index and the parts CORE has to act on.
Freeze-blockers — all three are in 1.0.0
B1 — a frozen wire enum for the task failure code · done, as a string enum
TaskError.code was a bare integer. It is now
TaskErrorCode: a string enum with your 27
failure values, mirrored by name and in your order, verified against
core/include/vdm/util/error.hpp mechanically rather than by eye. ok has no wire
spelling — a TaskError only exists when something failed.
You were right that this was blocker #1, and right about the diagnosis: the draft typed
code as the JSON-RPC integer while its own description said "distinct from the JSON-RPC
Error". Those are two code spaces. ErrorCode says why a call failed; TaskErrorCode
says why a download failed, and a download fails while every RPC succeeds.
Strings, not your grouped-integer fallback: the mapping is lossless with no numbering
scheme maintained in two repos that cannot include each other's headers, and a log line
reads "server_file_changed" instead of 407.
Two details you should design against:
retryablestays a per-occurrence boolean, not a property of the code — because your own table hasprobe_failedas "maybe". Emit it per failure.causeis new onTaskErrorand carries aTaskErrorCode. It exists formax_retries_exhausted, which your header says has acause: put the last underlyingErrorthere so the user is told what actually kept failing.
httpStatus is expected for the codes in TaskErrorCode's x-carriesHttpStatus
annotation, which matches the "carries httpStatus" column of your table.
B2 — the meaning of TaskSummary.segments · done, frozen as effective
"The EFFECTIVE connection count in use right now — not the number that was requested. What remains after the per-host connection cap and after the demotion to 1 for a non-resumable source."
The requested value stays in DownloadSpec.segments, which now says so on its own
description, as does download.update's patch.segments. TaskDetail.segmentDetail
carries exactly TaskSummary.segments entries, and conformance checks that.
B3 — Segment field names, and the range convention · done, but read this
(a) index vs i — settled as index, everywhere. There is no i spelling in the
contract; event.task.progress's per-segment entries use index too. Nothing to
reconcile, it was already consistent.
(c) the state enum — pending | connecting | downloading | stalled | complete | failed.
Spelled downloading as you asked, matching TaskState; the draft's receiving is
gone. pending is added for a range planned but not yet dialled — if the engine never
reports that, ignore it.
(b) the range convention — this is the one that will bite you if you skim.
Ranges are CLOSED and INCLUSIVE:
[startByte, endByte].
endByteis the index of the last byte, not one past it. The segment coversendByte - startByte + 1bytes.
You asked for half-open [start, end). PROTO chose inclusive and did not adopt your
convention — this notice is the point of this document, and it is deliberately before you
build stage 6.
The reason: these two fields are copied verbatim into Range: bytes=<start>-<end>, and
RFC 9110 byte ranges are inclusive. Inclusive means no arithmetic at all between the wire
and the socket. Half-open means a -1 at every boundary between the contract and every
HTTP request the engine makes — which is exactly where off-by-ones live.
Field names stayed startByte / endByte / downloadedBytes rather than your
start / end / completed, partly so that code written against the half-open spelling
does not silently compile against inclusive fields.
While fixing this we found a real contradiction in the draft: it encoded an empty segment
as endByte == startByte - 1, which is -1 at offset 0 — and every download's first
segment starts at 0, so the schema's own minimum: 0 rejected it. Empty ranges are no
longer representable and are not needed. endByte >= startByte always holds; a
zero-length download carries an empty segmentDetail; a segment that donates its remainder
to a steal keeps the bytes it already wrote. If the engine has a state that genuinely needs
an empty range, say so now — that is a schema change, not something to encode around.
tests/conformance/check_contract.py enforces contiguity, coverage of exactly
[0, sizeBytes - 1], downloadedBytes <= endByte - startByte + 1, and the entry count.
A fixture flipped to half-open fails it.
Not gating the freeze — the follow-up queue
Agreed with your ranking: these are minor under rule 4 and land as small PRs to
contracts/ alone. They are not in 1.0.0. Ranked by when M1 needs them.
| # | Item | Verdict | Shape |
|---|---|---|---|
| B2a | readable effective buffer size | accepted | effectiveBufferBytes on TaskSummary, next to the effective segment count, so the requested/effective split reads the same way for both. You are right about the additionalProperties: false trap — no daemon can tack it on, so it needs a schema PR either way. |
| F2 | credential return path for 401/407 | accepted as proposed | download.provideAuth {taskId, username, password, save?} → {ok}. Unix socket only, privileged: a credential-bearing method must never be reachable from the browser. Secrets go to the Secret Service; save only tells DAEMON whether to persist. |
| F1 | "needs user decision" carrier | the simple option | state: paused + event.notify is the intended carrier for M1: CORE reports server_file_changed, DAEMON pauses and notifies, GUI offers restart. A dedicated event.task.decision + download.decide is a real design with a state machine attached, and it should not be invented in a hurry — raise it again in M3 if the notify path proves too thin. A string comparison on error.code covers the engine side either way, which is now a TaskErrorCode comparison rather than a magic number. |
| F3 | checksum string format |
already frozen, differently | download.add {checksum} is not a string. It is a Checksum object: {algorithm: "md5"|"sha1"|"sha256"|"sha512", value: "<hex>"}, with value patterned ^[0-9a-fA-F]{32,128}$. Parse your "<algo>:<hex>" form at the CLI or GUI edge, not on the wire. Note sha512 is accepted by the contract even though the appendix lists MD5/SHA-256 — reject it in the engine if you do not implement it, rather than the contract forbidding it. |
Raise B2a and F2 as requests whenever you need them and PROTO will land them together; neither blocks anything you are building this week.
D1 — state-machine ownership
Your proposed split is right and PROTO does not dispute any of it: CORE owns
probing → connecting → downloading ⇄ paused → retry_wait → assembling → verifying → complete | failed plus cancelled from anywhere; DAEMON owns new, queued and
pause-for-schedule; paused is shared and both sides must be idempotent about it.
PROTO will not write that ADR alone. It is a three-way decision and the lane that owns
neither half writing it down is how a decision gets recorded that DAEMON never agreed to.
DAEMON should draft it, CORE and PROTO review. The contract's part is already frozen:
TaskState has the twelve values, and the wire does not encode who drove a transition.
One thing that is PROTO's and worth stating: event.task.state carries previousState,
so a client can render a transition without keeping its own state machine. Neither CORE nor
DAEMON should assume a client tracks lifecycle — clients render what they are told.