Files
phantomptrandClaude Opus 5.5 38f992f78e helper: ended uploads leave the job table on a live session (#475)
The Files screen uploads a pick of files one AVA1 job per file, all on the
engine's one shared session. The console's job table holds 32 jobs, and an
upload that ended stayed listed (with its job thread and workers) until its
session ended: only parked jobs were ever reaped, and a session is parked only
when it ends. So the 33rd file of a pick got ERR_BUSY "too many jobs, or this
job is still closing" for 12 retries and failed, and every later file did the
same until the helper was replaced (a new session). Reproduced on the Phat
(FW 13.60): file 24 of the Switch-like set failed busy, and the table never
drained while the session lived.

- A finished upload or download (not a copy, op or console-to-console send)
  stamps concluded_ms once its JobDone is out (or the peer's came in).
- The reaper lets such a job go 10 s after that, attached or not, unless it
  still has files settling (unswept), as SPEC §11.7 intended.
- A JobOpen that finds the table full takes the slot of the concluded
  transfer that ended longest ago and nobody else holds, instead of BUSY.

Tests: C unit test of the eviction and reap rules (wire_upload), and a host
test of 40 single-file jobs in a row on one session (failed at file 32 before).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-18 13:46:29 -07:00

1271 lines
101 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# AVA1 wire protocol, version 1 (normative)
AVA1 = Adaptive Verified Assembly. Design rationale: ps5upload-docs
`superpowers/specs/2026-09-30-ava1-transfer-protocol-design.md`. Implementations:
`engine/crates/ava1` (Rust), `payload/ava1` (C). Both must pass `protocol/ava1/vectors/`.
## 1. Transport
TCP, port 9120. A session is one control connection (lane 0) plus up to 8 data
connections (lanes 1..=8). Integers are little-endian.
## 2. Frame header
16 bytes: `"A1"` (0x41 0x31), type u8, flags u8, channel u32, body_len u32,
CRC32C (Castagnoli, reflected, init/xorout 0xFFFFFFFF) of bytes 0..11 as u32.
body_len ≤ 16 MiB (receivers may enforce a lower cap: 64 KiB on control
connections). Bad magic or CRC: close the connection. Flags: bit 0 SEALED (the
body is AEAD ciphertext + 16-byte MAC, §4.4), bit 1 IGNORABLE (a receiver that
does not know the type skips the frame).
## 3. Message encoding
Messages are defined in `schema/ava1.toml` and generated for each language by
`ava1-gen`; never hand-encode. A body is the message's fixed fields in schema
order — u8/u16/u32/u64 little-endian; b16/b32 raw; bytes = u32 length + bytes;
str = u16 length + UTF-8 — followed by an extension block: u16 count, then per
extension u16 tag, u32 value length, value (encoded as a field of its type).
Encoders write extensions in ascending tag order. Decoders skip unknown tags,
reject a repeated known tag, reject invalid UTF-8, and reject trailing bytes
(both in the body and inside an extension value). The canonical encodings in
`vectors/messages.txt` must round-trip byte for byte.
Decoders accept a superset of what encoders write — inside a records item, an
extension may be unknown or out of order, and the decoder drops it — so a
re-encoder is only bound to canonical input, which is all an encoder ever
produces: it must reproduce canonical bytes exactly. For a message it accepted but
would not itself have written, a re-encoder may reproduce the captured item bytes
verbatim (the C decoder holds them) or re-encode each item canonically (the Rust
one does); the two agree wherever the input is canonical, and nothing in the
protocol re-encodes a peer's bytes.
**Records.** A field of type `records` holds a list of one struct — an *item* is that
struct's encoding per this section (its fields, then its extension block), and the
length written before it is exactly that. It is encoded as a
`bytes` field whose content is, for each item in order, `u32le(item length) ‖ item`.
An empty list is `00000000`. A decoder validates every item (a bad item makes the whole
message malformed). Records are not allowed as extensions. C decoders keep a pointer to
the encoded list and its item count; items are read with the generated `ava1_<struct>_next`.
## 4. Keys and sealing
4.1 Identity: a static X25519 key pair per node.
4.2 Handshake: `Noise_XX_25519_ChaChaPoly_BLAKE2b` (Noise revision 34), prologue
"AVA1 v1"; the client is the initiator. Implementations must reproduce
`vectors/noise_xx.json` (from the cacophony set). After message 3, Split() gives
c2s (initiator → responder) and s2c; `h` is the handshake hash. Low-order keys:
an implementation must abort the handshake when a DH result is all zero —
equivalently, when the peer's ephemeral or static key is a low-order point (the C
side checks each DH output; the Rust side checks each received key, since its Noise
library does not). A handshake step that fails poisons the state: no later message
is read or written and no keys are derived from it. Key material is wiped after use.
4.3 Lane keys: lane_key(dir, n, cn, sn) = BLAKE2b-256(key = dir, "AVA1 lane" ‖
u16le(n) ‖ cn ‖ sn), where cn and sn are the 16-byte client and server nonces of
the lane's join (§9). Both are fresh random per join, so a re-join of lane n — or a
replayed Join, even one the server no longer remembers — never gets a key already
used with counters restarted at 0. The control connection is lane 0, keyed once per
handshake, with cn = sn = 16 zero bytes. `vectors/keys.txt` pins these derivations.
4.4 Sealed frames: body = ChaCha20-Poly1305(lane key of this direction, nonce =
4 zero bytes ‖ u64le(counter), AD = header bytes 0..11) followed by the 16-byte
MAC; the counter is per lane and direction from 0, advances by one on every sealed
frame of any type (Ping and ignorable frames included), and is never reset or rewound
while a key is in use. A frame resent on another lane after a lane death is sealed again
under that lane's own key and counter. A counter that reaches 2^64 - 2 may not seal or
open another frame: the sender refuses and the receiver closes the connection, so a
nonce can never repeat under one key. A frame that fails to open closes the connection.
4.5 Join proofs: BLAKE2b-128(key = BLAKE2b-256(key = dir, "AVA1 join"), m):
the Join tag uses dir = c2s and m = "join" ‖ session_id ‖ u16le(lane) ‖ cn; the
JoinAck tag uses dir = s2c and m = "join-ack" ‖ session_id ‖ u16le(lane) ‖ cn ‖ sn.
4.6 Pairing PAKE. Pairing proves, to the console, that the person at the app has read the
console's screen. The console draws a random six-digit code from its CSPRNG for each
unconfirmed session and shows it in its notification ("enter 123456 in the app"); the code is
never derived from the transcript and never sent. It is the password of a CPace-style PAKE run
inside the encrypted session (messages `PairPakeClient`, `PairPakeServer`, `PairConfirm`,
`PairResult`, §5.5). With h the Noise handshake hash (64 bytes) and `code` as six ASCII digits:
* `d = BLAKE2b-256("AVA1 CPace" ‖ h ‖ code)`; `G = Elligator2(d)`, the u coordinate of
Curve25519, by the map of Monocypher's `crypto_elligator_map` (RFC 9380
`map_to_curve_elligator2_curve25519`, Z = 2; the top two bits of `d` are ignored).
* each side draws a fresh 32-byte scalar x and sends `Y = X25519(x, G)` (X25519 clamps x); a
result of all zero bytes is refused.
* `K = BLAKE2b-256("AVA1 CPace K" ‖ h ‖ X25519(x, Y_peer) ‖ Y_client ‖ Y_server)`; an all-zero
shared secret (a low-order `Y_peer`) is refused.
* key confirmation: `MAC_client = BLAKE2b-256(key = K, "client" ‖ h)`, sent in `PairConfirm`;
`MAC_server = BLAKE2b-256(key = K, "server" ‖ h)`, returned in `PairResult` only when the
client's verified. Every comparison is constant time.
`vectors/cpace.txt` pins G, both public values, K and both MACs; the Rust and C implementations
both reproduce it, and a differential test compares the two Elligator2 implementations on
thousands of inputs.
Threat model. The code is a secret that exists only on the console's screen. A host on the LAN
can complete Noise with a throwaway key and be welcomed (it sees h, both nonces, every frame),
but none of that depends on the code, so it cannot compute `G`, hence not `K`, hence not a
valid `MAC_client`: each attempt is one online guess at one in 10^6, and there is nothing on the
wire to test a guess against offline (`Y = x·G` hides G behind a discrete logarithm). A man in
the middle holds two handshakes with different h, and the code is shown by the console, not by
the app; what the app sends is bound to the first leg's h and the console's code, so it
verifies on neither leg without the code.
Remaining limits, stated plainly:
* A fake console (an impostor at the console's address) gets one guess per client attempt: the
app's `MAC_client` is checked against the impostor's own key, which the impostor can test
offline for a guess at the code. A correct guess (probability 10^-6 per attempt) reveals the
code, and the impostor can then answer with a valid `MAC_server`. The real console never
sees these attempts, so it cannot count them; the client is the only place they could be
limited, and a person retyping codes is not a fast guesser.
* Someone who can see the console's screen, or the user's typing, knows the code. Out of scope.
* A user who types the code of a pairing they did not start has paired the other party.
* Denial of service on a LAN, and what bounds it. Only a real guess (the PAKE was exchanged and
the proof was wrong) counts as a failure: a throwaway session that never completes the
PAKE, sends a `PairConfirm` with nothing behind it, or opens with a malformed or low-order
value reveals nothing and costs no budget. Each source address has 5 wrong guesses per
window, and all addresses together 20 (about 2·10^-5 of the space); the 20th closes the
window until a paired device reopens it or the node restarts, so closing it takes guesses
from at least four addresses. Sessions that guess nothing are bounded by `MAX_UNPAIRED`
(2) and by 6 new pairing sessions per address per 10 s (`ERR_BUSY` beyond that). The
console shows every welcomed session's own code on its screen (an identical request, same
address and same key within 10 s, is not shown twice; the global notice rate below): a stranger cannot hide the user's
code, only crowd the two unconfirmed places for up to 60 s each. The guess is taken from
the budgets under one lock before the proof is looked at, so two sessions confirming at once
cannot spend the last guess twice, and a session whose address has no guess left is refused
without its proof being compared. A budget is never evicted from a full table: a node
tracking many addresses refuses new pairing sessions (`ERR_BUSY`) from unseen addresses
while every slot holds guesses made in this window (C: 32 slots), instead of handing a
cycling attacker a fresh budget; IPv4-mapped IPv6 addresses count as their IPv4 form.
* Residuals of the per-address budget. An IPv6 host with privacy (temporary) addresses, or a
host with several aliased addresses, appears as several addresses and so has several
per-address budgets; the global cap of 20 guesses per window still bounds everything
together, and the 20th closes the window. Spoofed or many hosts can likewise spend the global
cap (a denial of service, recovered by a paired device's `pairing.open` or a restart), but
cannot raise the number of guesses beyond it.
* Notification bounds. Besides the per-session code and the identical-repeat rule, all
addresses together may show a burst of 3 pairing notifications and then one per second.
Past that, the newest session's code waits for credit (at most about a second) and any
older session still waiting is dropped, so the code on the screen is always the latest
attempt's, and the screen cannot be flooded faster than one notification per second.
The `pair_commit`, `nonce_c` and `nonce_s` fields of §5 are retained for wire stability; no code
is derived from them any more.
## 5. Handshake and pairing
1. Client → `Hs1{noise}` (unsealed): Noise message 1, payload `HelloInfo`
(version range, caps 0).
2. Server: no common version → `Error(ERR_UNSUPPORTED_VERSION)` unsealed, close.
Else → `Hs2{noise}`: message 2, payload `ServerInfo` (version, caps, random
session_id, pair_commit (§4.6), name). `caps` bit 0 is `CAP_DATA_PLANE` (1): the node hosts the jobs
of §11–§16. A client sends no data-plane frame and no method 16–19 request to a
node that did not advertise it.
Bit 1 is `CAP_MGMT` (2): the node serves the management methods of §7.3 (numbers 4 and
up other than 16–19). A client routes management calls by this bit instead of probing
for `ERR_UNKNOWN_METHOD`; a node that does not advertise it answers
`ERR_UNKNOWN_METHOD` to them. The payload advertises it when its management table is
installed (`mgmt_rpc_installed()`); the Rust `Session::has_mgmt()` reads it.
Bit 2 is `CAP_SHARED_JOBS` (4): any paired device may read and cancel a transfer and take over a
parked upload (§18.5); only a node with the data plane advertises it.
3. Client → `Hs3{noise}`: message 3, payload `ClientInfo` (nonce_c (§4.6), name). Both sides
now key lane 0 (§4.3) and every further frame is sealed. A client that expects
a particular device (it knows the key it paired with at this address) compares
the server's static key from message 2 and, if it differs, closes without
sending `Hs3` — the wrong device never learns the client's key or name.
4. The server learned the client's key in message 3. Unknown key and pairing
closed → sealed `Error(ERR_PAIRING_CLOSED)`, close. A ClientInfo without
nonce_c → sealed `Error(ERR_PROTOCOL)`, close. Else → sealed
`Welcome{knows_you, nonce_s}`. The client checks BLAKE2b-256(nonce_s) against the
pair_commit of message 2 before it shows any code or trusts anything else in the
Welcome; a mismatch, or a ServerInfo or Welcome without its field, closes the
connection (after a best-effort `Error(ERR_PROTOCOL)`).
5. Pairing (§5.5, PAKE over the Noise session; §4.6): while the console does not know the
client it shows the session's random code on its screen, and the user types it into the
app. A client whose server sent knows_you = 0 sends `PairPakeClient{y}` (channel = request
id) and the server answers `PairPakeServer{y}` on that channel (once per session; a second
`PairPakeClient`, or one while the window is closed, ends the session). The client then
sends `PairConfirm{mac}` and the server answers `PairResult{accepted, mac}`. The server
accepts only while its pairing window is open and the client's `mac` verifies (and its
owner, where there is a hook, does not veto), then stores the client's key (a key that
cannot be stored is not accepted) and returns its own `mac`; the client stores the
server's key only if `accepted` is set and the server's `mac` verifies. Otherwise
`accepted = 0` with a zero `mac`, and the session ends: one attempt per session. A
`PairConfirm` before the PAKE, one that does not decode (the old empty body), or a
malformed or low-order `PairPakeClient` is refused and ends the session, but guessed nothing
and so is not counted. A wrong `mac` after the PAKE ran is a guess: it is logged per source
address and counted against that address's budget (5 per window) and the global one (20
per window); the 20th closes the window until a paired device reopens it or the node
restarts, and an address that spent its budget is refused at `PairPakeClient` until then.
A failure makes that address's next welcome show a new notification at once. New pairing
sessions are also rated: at most 6 per address per 10 s (`ERR_BUSY` beyond that), and
`MAX_UNPAIRED` (2) at a time. Every welcomed session shows its own code on the
console; only an identical repeat (same address and key) within 10 s is not shown again.
The client cannot tell a wrong code from a typo, only that the console
refused; its next try is a new handshake with a new code on the screen. A client that cannot
be told apart from a paired one (a trusted reconnect, §5.1, or a launch proof, §5.2) never
sends these messages and needs no code. Until accepted, RPCs answer
`ERR_NOT_PAIRED` and lanes are refused. A client sends nothing but the pairing messages (no RPC, no Join) while
either side is unconfirmed. A data-plane frame (§11–§16) on a
control connection whose pairing is not accepted is a protocol error: the server answers a sealed
`Error(ERR_NOT_PAIRED)` and closes the connection.
6. Pairing window: opens by itself for 5 minutes after start only while the node
has no paired peer; otherwise `pairing.open` (method 2, body `PairingOpen`,
≤ 600 s) from a paired session opens it. Either kind of window closes as soon
as one pairing succeeds. A session welcomed with knows_you = 0 that has not
been accepted ends — sealed `Error(ERR_PAIRING_CLOSED)`, close — when the
window closes or 60 s after its Welcome, whichever is first. At most 2 such
sessions exist at a time; a third unknown client gets `Error(ERR_BUSY)` in
place of Welcome. A node shows each welcomed session's own code (an identical
request, same address and key, not twice within 10 s), and only for a client it has sent
Welcome to: a client gone before its Welcome uses none.
7. Peer stores: `<64 hex key> <unix seconds> <name>` per line, ≤ 32 peers (oldest
dropped), written atomically (a temp file no other writer shares + rename in the same
directory). The reference side shares the file, the identity and the launch tokens between processes:
each read-modify-write holds an advisory lock on `<file>.lock` and starts from the file as it is then. A
missing file is an empty store. A file that exists but cannot be read is not:
the node runs, knows no peers, logs the failure, never opens its automatic
window, accepts no pairing, and never writes the file.
5.1 Trust slot: the payload ELF carries a 64-byte array — "AVA1TRUST" (9 bytes),
state (0 empty, 1 key, 2 key + launch token), 6 zero bytes, 32-byte X25519 key,
and a 16-byte launch token in state 2 (§5.2), zero otherwise. An engine sending
the ELF writes its key into the single slot; the payload adds that key to its
peers at startup. Exactly one slot must exist. A slot counts as a slot in states
0 and 1 only while its last 16 bytes are zero, so data that happens to start
"AVA1TRUST" is not one; state 2 is identified by its state byte.
5.2 Launch token: whoever stamps the ELF may also write a fresh random 16-byte
token into it (state 2) and keep it for itself — the reference side stores them in
`<data dir>/ava/launch_tokens`, one `<32 hex token> <unix seconds>` per line, mode
0600, written atomically, at most 32 kept (oldest dropped), each good for 10 minutes and for one proof.
The payload keeps the token in memory only. When the handshake's client static key
equals the slot's key, and the client is one the server knows — a helper whose
peers file could not be written knows nobody and sends no proof — the server adds an
ignorable extension field `launch_proof` to `Welcome`: the first 16 bytes of
BLAKE2b-256(key = the token followed by 16 zero bytes, "AVA1 launch" ‖ h), h
being the handshake hash. No other client gets a
proof, a proof differs every handshake, and the token itself never travels.
`vectors/launch.txt` pins the derivation.
A client that recognises the proof — one of its unexpired tokens, on this
handshake — spends that token (removes it and saves the removal before it trusts
anything; if the removal cannot be saved, the proof is not accepted) and stores the server's key and treats the session as paired with no
pairing code. A proof counts only from a server it does not already know and
whose `Welcome` says it knows the client (`knows_you` ≠ 0); a server that does not
know the client was never given a proof, so one presenting a proof anyway is not
trusted. A client that does not recognise it (another token, expired, an old proof
replayed under a new h, a token already spent, or a `knows_you` of 0) pairs with the code as usual
(§5.5: the user types the code the console shows). The token proves "this console is the helper I
launched" to the side that sent it. The ELF is sent unauthenticated, so a sniffer on the path holds
the token too and could answer as the console to the launching engine: that is why a token is
single-use and short-lived. The first proof wins, and the real helper normally connects within
seconds of the launch, so a replay after that finds the token gone; an attacker who answers first
(before the real helper, within 10 minutes) gets the one silent pairing and nothing more. A proof is
needed only from a server not yet known, so nothing uses a token twice.
## 6. Liveness
Every connection sends `Ping{seq, t_us}` every 2 s (default) on channel 0, also
while it is in the middle of reading a large frame; the receiver answers `Pong`
with the same values; the sender's RTT is now − t_us. t_us is taken when the Ping is
written, not when it is queued, so time spent behind other frames is not counted as
round trip. A sender may skip a Ping or Pong while other frames are queued or being
written: they are proof of life too.
Liveness counts bytes, not frames: a connection is dead after 12 s (default,
`dead_after`; the ping stays at 2 s, so six pings go unanswered first) with no byte received, so a 16 MiB frame on a slow link is never
mistaken for silence. Silence is judged by what can be read: a reader that was busy
elsewhere (writing, waiting to write) past `dead_after` checks the socket first and
carries on if bytes are waiting; a process that was not running (a late timer tick)
may put off the verdict for at most 2 ticks in a row. Each frame must also move at no less than a rate floor
(default 8 KiB/s) after a `dead_after` grace — its deadline is dead_after +
body_len / floor — so a peer cannot drip one frame forever. Writes obey the same
two limits: a peer that takes no bytes for `dead_after` (it stopped reading), or
takes one frame slower than the floor, has its connection closed. A reader never
waits on a socket write: replies are queued to the connection's writer (bounded),
and a peer whose replies back up is disconnected.
Clocks are monotonic. A server closes a connection whose handshake (first byte
to Welcome) does not finish within the handshake timeout (10 s default). `Bye`
ends a session; `Error` reports why and ends the connection.
## 7. RPC
`RpcRequest{method, body}` on the control connection, channel = request id
(chosen by the client, unique among its outstanding requests). The server
answers `RpcResponse{status, body}` on the same channel; a request that does not
decode is answered `Error(ERR_PROTOCOL)` and closes the connection. status 0 (`STATUS_OK`) = OK;
error statuses are the `ERR_*` constants, and an error response's body is the cause as
UTF-8 text (not an encoded message). An unpaired session's RPCs answer `ERR_NOT_PAIRED`; a
session has at most 8 requests in flight (§7.4) and the next one answers `ERR_BUSY`. Only
`pairing.open` is answered on the reader; every other method runs on a worker, so a slow method never
delays liveness.
7.1 Methods:
| # | name | request body | response body (status 0) |
|---|------|--------------|--------------------------|
| 1 | `node.info` | empty | `NodeInfo{version, platform, name}`, ext `firmware` |
| 2 | `pairing.open` | `PairingOpen{seconds}` (≤ 600) | empty (§5.6) |
| 3 | `crypto.bench` | `CryptoBench{mib}` | `CryptoBenchResult{bytes, micros}`, ext `open_micros`, `backend` (a diagnostic) |
| 16 | `job.copy` | `JobCopy{job_id, src, dest, flags}` | `Status` (§16.9), ext `state` |
| 17 | `job.status` | `JobRef{job_id}` | `Status`, ext `state` |
| 18 | `job.cancel` | `JobRef{job_id}` | empty |
| 19 | `disk.calibrate` | `DiskCalibrate{dir, files, size}` | `DiskCalibrateResult` (§16.10) |
| 22 | `c2c.allow` | `C2cAllow{job_id, key, root}` | `C2cTicket{token}` (§18) |
| 23 | `c2c.send` | `C2cSend{job_id, host, port, key, token, src, dest, flags}` | `Status`, ext `state` (§18) |
| 4–141 | management methods | see §7.3 and `MGMT_METHODS.md` | see §7.3 |
`Status.state` is 0 while the job runs, 1 when it finished OK and 2 when it failed (the cause is
in ext `current`, the `ERR_*` code in ext `code`; a finished `job.run` job's output is in ext `result`). Methods 16–19 are the version 1 data-plane RPCs and exist only on a node that
advertises `CAP_DATA_PLANE`; the management methods are §7.3 and exist on a node that advertises `CAP_MGMT`. The behaviour of 16–18 is §15.5; of
19, §16.10.
7.1.1 `job.run` and `job.list` (long management operations). `job.run{job_id, op, args}` starts operation
`op` (`JOB_OP_*`: DELETE 1, CHMOD_R 2, HASH 3, CRC32 4, FSCK 5, BACKUP_SNAPSHOT 6, BACKUP_RESTORE 7,
CLEANUP 8, SDK_SCAN 9) on its own worker thread (the 512 KiB management stack) and answers at once with a
`Status` (state 0). `args` is the operation's request body, the same legacy JSON the FTX2 frame carried
(at most 60 KiB). The job is an entry of the job table (counted against the 32-job limit, owned by the
peer that started it, no session, so a reconnect does not matter), at most 8 operations run at once
(`ERR_BUSY` for a ninth) and an unknown `op` is `ERR_PROTOCOL` (`unknown_op`).
* `job.status` returns the progress (`files_done/total`, `bytes_durable/total`: files are non-directories,
bytes the regular files' sizes; both totals are 0 while unknown), the current step in ext `current`, and,
once finished, `state` 1 with ext `result` (the operation's reply body, at most 128 KiB) or `state` 2 with
ext `code` (the `ERR_*`) and the cause token in `current`. A repeat of `job.run` with the same id and the
same owner, op and args answers the job's status whatever state it is in; other parameters are
`ERR_PROTOCOL`, another owner `ERR_UNKNOWN_JOB`. A finished operation whose repeat gives the same outcome
(every op but BACKUP_SNAPSHOT and BACKUP_RESTORE) stays listed for a grace of 10 s after the first reply
that carried its terminal status, so a reply lost on the wire is answered from the stored job and nothing
runs twice inside the grace; it is then released by the reaper or by the next `job.status`/`job.run` read
after the grace, and a full job table releases the one delivered longest ago at once, so a loop of hashes
never fills the 32 slots. After the release a repeat of `job.run` runs the operation again (harmless for
delete, chmod, hash and crc32). A backup is kept for the park age instead, because a re-run would take a
second snapshot.
* `job.cancel` raises the job's cancel flag and returns at once (it runs on a reader or RPC worker and
never waits for the worker). A delete, chmod, hash, crc32 or backup stops at the next directory entry or
read block and the poller then sees `state 2` with `ERR_CANCELLED`; fsck, cleanup and sdk.scan are one
system call and only honour a cancel that arrives before they start, so a cancelled fsck holds its job
slot (and its worker) until the system call returns. Unlike a copy, a cancelled operation stays listed
(finished) so a poller reads how it ended; it is collected like any finished job.
* An operation that wraps an FTX2 handler (fsck, backup, cleanup, sdk.scan) keeps the handler's
`{"ok":false,...}` body as its result: the operation ran and the body is the answer. Only an ERROR
frame is a failed job. DELETE refuses a path outside the writable roots and a mount point (a path on
another device than its parent): `ERR_PATH`, `fs_delete_path_not_allowed` / `fs_delete_path_is_mount_point`.
* `job.list` returns the peer's jobs of every kind as `JobListResult` (`JobEntry.kind` 1 upload, 2
download, 3 copy, 4 operation).
7.2 Error codes. The numbers below are generated from `schema/ava1.toml`, whose constants are the
normative table; the second column names the constant in the generated code.
| code | name | sent when |
|------|------|-----------|
| 1 | `ERR_NOT_PAIRED` | an RPC or a lane join from a session whose pairing is not accepted (§5) |
| 2 | `ERR_PAIRING_CLOSED` | an unknown client while the pairing window is closed; an unconfirmed session whose window or 60 s ended |
| 3 | `ERR_UNSUPPORTED_VERSION` | the version ranges of the two peers do not overlap (unsealed, §5) |
| 4 | `ERR_PROTOCOL` | a frame or body that does not decode, a frame type not allowed where it arrived, a `JobOpen` with an unknown kind, policy or flags, a `Chunk` for a small file or a `BundleRecord` for a large one (§12.2), a window that cannot hold one group (§12.4) |
| 5 | `ERR_BAD_JOIN` | an unknown session, lane id outside 1..=8, wrong tag or replayed nonce (§9) |
| 6 | `ERR_UNKNOWN_METHOD` | `RpcResponse` status for a method the node does not implement, including methods 16–19 on a node without the data plane |
| 7 | `ERR_INTERNAL` | the node could not do what the peer asked for a reason that is neither the peer's nor the disk's: out of memory, a thread that would not start |
| 8 | `ERR_BUSY` | a limit of §8 or §11.7: connections, sessions, an unconfirmed-session slot, in-flight RPCs (8 per session, §7.4), jobs, a destination another job is writing, no buffer budget left for another job |
| 9 | `ERR_PATH` | a manifest or RPC path that breaks §11.2, a root the node's write or read policy refuses, a source that cannot be stat'd or a staging parent that is not a directory |
| 10 | `ERR_NO_SPACE` | the destination drive is full (`ENOSPC` while writing) |
| 11 | `ERR_UNKNOWN_JOB` | `Resume`, `job.status` or `job.cancel` for a job the node does not list, or lists for another peer key (the two are not told apart; with `CAP_SHARED_JOBS` a transfer answers any paired key, §18.5) |
| 12 | `ERR_IO` | a disk or filesystem failure on the node's side: write, fsync, rename, journal append, reading a source, a failed `disk.calibrate` |
| 13 | `ERR_VERIFY` | a file or copy whose bytes do not match their root and that a `FileRetry` cannot fix |
| 14 | `ERR_EXISTS` | a destination that is already there and may not be replaced: the root without `JF_OVERWRITE`, a staging root that appeared meanwhile, a file where one must go in a merge |
| 15 | `ERR_CANCELLED` | the job was cancelled (`job.cancel`, or a `JobCancel` carrying this reason) |
| 16 | `ERR_CROSS_DEVICE` | a staged or part-file rename whose two sides are on different devices (`st_dev`); never attempted, because a cross-device `rename` panics the console's kernel |
| 17 | `ERR_CREDIT` | a lane frame larger than the credit the receiver granted (§12.4) |
| 18 | `ERR_STALLED` | the receiver ended a job whose sender sent no file data for the progress deadline while heartbeating (§12.8) |
| 19 | `ERR_PAIRING_CODE` | a client-side refusal of the pairing: the console refused the typed code, or could not prove it knows it (§5.5) |
7.3 Management methods (the console operations FTX2 carried on :9114). Numbers are assigned by
block; the tracked list, one row per method with its payload handler and engine caller, is
`MGMT_METHODS.md`. The generated constants `METHOD_*` in `schema/ava1.toml` are normative.
| numbers | block | bodies |
|---------|-------|--------|
| 4–11 | node and diagnostics: `node.status`, `node.shutdown`, `node.cleanup`, `log.klog`, `log.syslog`, `net.interfaces`, `net.reach`, `net.speedtest` | `node.status` replies `NodeStatus`; the rest `MgmtText` |
| 20–21 | `job.run`, `job.list` | `JobRun{job_id, op, args}` → `Status` (ext `state`, `result`, `code`); `job.list` → `JobListResult` |
| 32–44 | filesystem: `fs.volumes`, `fs.list`, `fs.stat`, `fs.mkdir`, `fs.rename`, `fs.chmod`, `fs.read`, `fs.write`, `fs.mount`, `fs.unmount`, `fs.mount_pkg`, `fs.mount_lwfs`, `fs.freespace` (44) | `fs.list`, `fs.stat`, `fs.freespace` (`FsPath` → `FsFreeSpace`: usable bytes = free less the working margin, 1/64th of the drive at most 1 GiB; the nearest existing ancestor of the path is asked), `fs.mkdir`, `fs.rename`, `fs.chmod`, `fs.read`, `fs.write` are typed (`FsList` → `FsListResult`, `FsPath` → `FsStat`, `FsMkdir`, `FsRename`, `FsChmod`, `FsRead` → `FsReadResult`, `FsWrite`); the others `MgmtText` |
| 48–61 | apps, launch, install queries, processes | `MgmtText`; `app.list` pages with `offset`/`limit` and `more` (§7.4, the only text method that does not fit one reply) |
| 64–70 | saves, screenshots, videos, search index | `MgmtText` |
| 72–87 | hardware, power, time, peripherals, `shell.exec` | `MgmtText` |
| 88–100 | profiles, users, backups (97 and 99 are unassigned: backup snapshot and restore run as `job.run` ops) | `MgmtText` |
| 104–128 | cheats, SMP metadata, SDK changer, TMDB, FTP, firmware spoof, notifications, activity | `MgmtText` |
| 136–141 | Remote Play | `MgmtText` |
A `MgmtText` body is the payload handler's existing request or reply (UTF-8 text, in practice
JSON), carried unchanged in `MgmtText.body`; `more = 1` on a reply means the method is paged and
the caller asks again with the next `offset`. Typing the text methods is deferred (§10): the text
bodies are stable and tested, and the cutover does not need them typed.
`log.klog` and `log.syslog` are clamped tails, not paged reads: when the console's text is longer than
`RPC_TEXT_MAX` the reply is its newest `RPC_TEXT_MAX` bytes, starting at a line boundary (else a
UTF-8 boundary), with `more = 1` meaning "older text was left out"; a text that fits is returned whole
with `more` absent or 0. `net.reach` is a probe: its negative answer (`{"ok":false,"timed_out":..,
"errno":..,"ms":..}`) is the measurement and travels as an ordinary OK reply; only a malformed request
(`bad_request`, `bad_address`) is `ERR_PROTOCOL`. A console writes a human-readable job event log at
`/data/ps5upload/ava/events.log` (one line per job open, resume, done and fail with status, bytes,
files and lanes; 1 MiB, rolled to `events.log.old`), read with `fs.read` like the other log files.
Encoding overhead. A `MgmtText` is `u32 length + text + u16 ext count` (6 bytes), plus 7 bytes when
`more` is present (tag u16, length u32, value u8). It is the `RpcResponse` body, so the largest text
a handler may return is `RPC_REPLY_MAX - 16 = 262,128` bytes (`RPC_TEXT_MAX`, with 3 bytes to spare);
a text request is bounded the same way by 56 KiB. Typed bodies carry their own overhead
(`FsReadResult` is `data + 7`).
Errors: the response status is an `ERR_*` code (§7.2) and the body is the cause as UTF-8. A ported
handler's cause is its legacy token (`fs_move_cross_mount`, `cleanup_path_denied`, ...), so the
engine can build the same `payload rejected <LABEL>: <cause>` text FTX2 callers produced. No new
error codes were added for management methods. `fs.rename` answers `ERR_CROSS_DEVICE` when the
source and the destination's parent are on different devices (`st_dev`); it never calls `rename(2)`
across devices. A device that cannot be read (unknown) is refused too, with `ERR_IO` (`fs_move_device_unknown`):
only a definite "same" reaches `rename(2)`. Every `st_dev` guard (§11.6, §12.6, `fs.rename`, FTP, shell `mv`) fails closed.
Legacy failure bodies. Many FTX2 handlers answered a failure as a *successful* frame with a
`{"ok":false,"err":"..."}` body (`handle_fs_write_bytes`, `handle_net_reach`, `handle_toast_send`,
the TMDB, SDK and cheats handlers, ...). A ported handler never does that: it answers an `ERR_*`
status (the closest of §7.2; `ERR_INTERNAL` when none fits) with the legacy token as the cause, and
`STATUS_OK` only when the operation succeeded. A body that still contains `"ok":false` under
`STATUS_OK` is a porting bug. Where the legacy body also carried data on failure (a partial list,
a detail object), the cause is that body's `err` token and the data is dropped.
Truncation. A ported handler must detect truncation and fail loudly. Every `snprintf` into a
reply buffer is checked (`n < 0` or `n >= cap` is an error, never clamped to `cap - 1` and sent), a
clamped read (`klog`, `syslog`, `fs.read`) reports a short read as a short read (`eof`, `more`), and
a buffer that cannot hold the whole answer answers `ERR_INTERNAL` with the cause `reply truncated`.
The payload helper is `ava1_rpc_text(out, cap, &out_len, fmt, ...)` (`ava1_data.h`): it returns
`STATUS_OK`, or `ERR_INTERNAL` with that cause, so a handler returns it directly. The harness pins
it (`ava1_rpc_text_answers_ok_when_it_fits_and_internal_when_truncated`) and the server answers
`ERR_INTERNAL` ("reply exceeds the 256 KiB RPC cap") for a handler that claims more than the cap.
Threads. The payload runs a management RPC (method 4 and up, except the data plane's 16-19) on a worker
with a 512 KiB stack (`AVA1_MGMT_STACK`, the FTX2 management thread's size); every other RPC keeps the
256 KiB `AVA1_THREAD_STACK`. Before each handler the worker re-applies the credential elevation and sets the
in-flight frame marker to the handler's legacy FTX2 frame number (the crash breadcrumb), and clears it
after. A ported handler still keeps stack buffers small: **no stack array of 16 KiB or more reachable from
a table handler**, enforced by `payload/tools/mgmt_audit.py stack` (run by the `ava1-ctest` test
`c_mgmt_handlers_never_read_the_socket_and_keep_small_stacks`), and the handler must not read `client_fd`
(it is called with -1 behind a capture sink, `payload/src/mgmt_rpc.c`). `mgmt_audit.py report` lists every
array of 2 KiB or more per handler (it is a tripwire, not a proof: it reads C text, follows calls and function-pointer arguments by name, sizes struct elements as a lower bound and unknown element types at 8 bytes, and cannot see calls through tables or dlsym; `mgmt_audit.py selftest` pins the shapes it must catch); Tasks 5 and 7 must heap-allocate the 64 KiB buffers it shows in
`handle_crc32_file`, `handle_shell_builtin` and `copy_file` before routing those handlers.
7.4 RPC limits. A session has at most **8** requests in flight; the ninth answers `ERR_BUSY`
(earlier drafts said 4). A request body is at most **56 KiB** and a reply body at most **256 KiB**,
both enforced by the server (`RPC_REQUEST_MAX`, `RPC_REPLY_MAX`): a larger request answers
`ERR_PROTOCOL` with the cause `request exceeds the 56 KiB RPC cap` and the session continues; a
handler that returns more than 256 KiB is answered `ERR_INTERNAL` with the cause `reply exceeds the
256 KiB RPC cap` rather than clipped. The control connection's frame cap is 64 KiB while a session
is being set up; once the handshake is done the client accepts replies up to
`RPC_REPLY_MAX + RPC_FRAME_SLACK` (1 KiB for status, length, extension count and the AEAD tag). The
worst case per session is 8 × 256 KiB = 2 MiB of reply buffers on the node (heap, per call). A
method whose reply can exceed the cap takes `offset` and `limit` and sets `more`; no management
method may return a larger body. `MGMT_METHODS.md` lists today's largest reply of every method and
says which fit and which page.
The engine side (a Task 4 requirement, not current behaviour): the engine's gate holds 6 permits per
console and reserves the other 2 for `node.status`, `job.status` and `job.cancel`, so a flood of
slow calls never hides a cancel or liveness; it retries `ERR_BUSY` with backoff (3 tries) and never
reports it as "payload failed".
7.5 Chunked and bounded filesystem calls.
`fs.read` (`FsRead{path, offset, len, flags}` -> `FsReadResult{data, eof}`): `len` is at most
`FS_READ_MAX = RPC_REPLY_MAX - 16 = 262,128` bytes (the reply is `data + 7`). A shorter reply with
`eof = 1` means the end of the file; `eof = 0` with fewer bytes than asked means the node chose a
short read, and the caller continues at `offset + data.len()`. A caller that needs more than
`FS_READ_MAX` (FTX2 allowed 2 MiB per call) loops until `eof` or the byte count it wanted, and the
core wrapper `fs_read_with_timeout` does that for every caller. Callers that can ask for more than
the cap: `ps5upload-engine/src/lib.rs:3974`, `ps5upload-engine/src/fakelibs_api.rs:423`,
`ps5upload-core/src/fs_ops.rs:1703`, `ps5upload-core/src/smp_image_rw.rs:174`,
`ps5upload-core/src/smp_checkout.rs:185`. Existence tests by 1-byte `FsRead` that become `fs.stat`
(Task 4): `lib.rs:4033`, `smp_checkout.rs:456`, `smp_image_rw.rs:229`, `fakelibs_api.rs:390` and
`fakelibs_api.rs:594`.
`fs.write` (`FsWrite{path, offset, flags, data}`, ext `mode`): FTX2 wrote up to 256 KiB atomically,
and the request cap is 56 KiB, so a larger file is written in chunks of at most
`FSW_CHUNK_MAX = 48 KiB` (49,152) of `data` (the rest of the request is the path, the header and
the extension). Flags: `FSW_APPEND` (write at the end of the temporary file, `offset` ignored),
`FSW_AT_OFFSET` (write at `offset`; a missing temporary file is created empty), `FSW_COMMIT`
(after this chunk: fsync the temporary file, then `rename` it over `path`), `FSW_CREATE` (at commit
fail with `ERR_EXISTS` when `path` exists) and `FSW_OVERWRITE` (replace it; the default when neither
is set; both set is `ERR_PROTOCOL`). Neither `FSW_APPEND` nor `FSW_AT_OFFSET` means "the whole file in
one call": `offset` must be 0, the file is written to the temporary file and committed in the same
call, exactly FTX2's atomic small write (`COMMIT` is implied). Chunked protocol: the temporary file
is `<path>.ps5upload.tmp` in the same directory as `path` (so the commit rename never crosses a
device, with the `st_dev` guard of `fs.rename`); the caller sends chunks with `FSW_AT_OFFSET`
(or `FSW_APPEND`) in order, the last one also carrying `FSW_COMMIT`. A caller that gives up
deletes the temporary file (`fs.rename` is not needed; `job.run` DELETE removes it). A chunk with `FSW_AT_OFFSET` at offset 0 truncates an abandoned temporary file first, so a retry
starts clean. (`FSW_APPEND` never truncates: it has no offset to say "first", so an `FSW_APPEND` writer starts from a temporary file that does not exist; the engine uses `FSW_AT_OFFSET`.) `mode` (ext 1) is accepted
but ignored: the file is committed as 0777, like everything else the helper writes (see
`umask(0)` in `main.c`; CE-107750-0 otherwise). Callers that need chunking because
they write more than 48 KiB: `ps5upload-core/src/cheats.rs:701`, `ps5upload-core/src/profile.rs:664`,
`ps5upload-core/src/smp_image_rw.rs:158` (the others, `smp_checkout.rs` and `smp_image_rw.rs:305/339`,
write small state files). The core wrapper `fs_write_bytes` chunks transparently.
Other filesystem methods, as built (`payload/src/mgmt_fs.c`, host-tested): `fs.list` pages by `offset`/`limit`
(`limit` 0 = 256, at most 256; `more` = entries remain; names that are not valid UTF-8 are listed with `?` for
their high bytes; `total_scanned` counts what the walk passed). `fs.stat` takes any absolute path without a `..`
component (the policy of `fs.list`, not of `fs.read`; see "Scope of `fs.stat` and `fs.list`" below), follows a link (`kind` is `link` only for a dangling
one), and answers `ERR_IO` with `fs_stat_failed_errno_<n>` for an absent path. `fs.mkdir` ignores `mode`
(the new directory and any intermediate ones are 0777, like every file and folder the helper creates) and honours `parents` (0: a missing
parent is `fs_mkdir_failed`); an existing directory succeeds, an existing non-directory is `ERR_EXISTS`.
`fs.rename` with `overwrite = 0` refuses an existing destination (`ERR_EXISTS`, `fs_move_exists`); with
`overwrite = 1` it replaces, as FTX2's move did. `fs.read` reads at most `FS_READ_MAX` and loops internally
until the ask or the end, so a reply shorter than the ask always has `eof = 1`. `log.klog` sets `more` when the
read filled the whole ask; `log.syslog` returns the newest `RPC_TEXT_MAX` bytes (or `max_bytes`) and sets `more`
when older text was cut. A handler whose failure carries data the caller reads (`net.reach`, `fs.mount_pkg`,
`fs.mount_lwfs`) answers an error status whose cause is the whole `{"ok":false,...}` body (up to 1 KiB), which
the engine's `call_legacy_ok` hands back as the reply it parses.
Scope of `fs.stat` and `fs.list` (review 006, checklist D; decided: not narrowed). Both answer for any
absolute, `..`-free path, as the FTX2 handlers did, because the product needs it: the Volumes and file
browsers list `/`, `/mnt/*`, `/user` and `/system_data`, and existence probes (installed titles, SMP
overlays, backport libraries) ask about paths outside every writable root. What a paired peer learns is
metadata only: names, kind, size, mtime, mode, device. Never contents (`fs.read` and the data plane keep
their own read policy), and the trust store's contents are refused by the read and write policies (S2), so no key
material or peer list is reachable (its name and size are visible, like any other file's). A peer that is paired can already upload, delete, rename, launch and
read through `fs.read`'s allowlist, so metadata of the rest adds no capability. Narrowing these two
would break the browsers for no gain; the decision is to revisit it only if a read-only or guest pairing
tier is ever added (then `fs.list`/`fs.stat` would take the read policy).
Typed bodies decoded by the adapters: `NodeStatus.ucred_elevated` is a `u8` on the wire; the engine
adapter restores the JSON boolean the client reads (`true`/`false`) and rebuilds the legacy
`/api/ps5/status` object (Tasks 3 and 4). `NodeStatus.prior_instance` is one of `clean`,
`killed_externally`, `wedged`, `stale` or `replaced` (the values of `instance_verdict_name`).
`FsEntry.kind` and `FsStat.kind` are `ENTRY_FILE` (0), `ENTRY_DIR` (1), `ENTRY_LINK` (2, a symbolic
link, not followed), `ENTRY_OTHER` (3, a device, socket or fifo) or `ENTRY_UNKNOWN` (4, the node could
not stat the entry; FTX2 said `"other"`). `FsListResult` carries no `path` and no returned-entry
count (FTX2's reply had both); the adapter reconstructs `path` from the request and the count from
`entries.len()`.
## 8. Limits
A server accepts at most 64 connections, 12 from one source address, and 16
sessions (2 of them unconfirmed, §5); past any of these it sends
`Error(ERR_BUSY)` and closes. The accept loop never stops on an accept error.
Job admission is bounded as well: a receiver has at most 32 jobs open (the console's job table;
the engine's host counts per session). A `JobOpen` past it is answered `JobOpenAck{ERR_BUSY}`
and a `Resume` `JobMap{ERR_BUSY}`; the session and the admitted jobs are untouched and the
sender retries (review 006, checklist T).
One session per device: when a client completes a handshake (message 3 proves its
key) while that key still has a session, the older session ends at once — its
control connection and lanes are closed and their per-address counts given back
before the new session's limits are checked. A client reconnecting after its link
died silently is therefore never refused for its own dead connections.
The key is the identity, not the process: two engines (two processes, two computers, a
desktop app and a Docker engine) that share one identity file are one device to the
console, and each new handshake ends the other's session and its jobs' lanes. They keep
evicting each other, and each sees its session end for no visible reason. Every engine
therefore needs its own identity (its own data directory). A client can recognise the
condition but not prove it: the session simply ends. The engine logs a warning naming
this cause when a console's session ends on its own 3 times within 120 s (at most once
per window per console).
## 9. Data lanes
A client opens lane n (1..=8) by connecting and sending, unsealed,
`Join{session_id, lane_id, client_nonce, tag}` with a fresh random client_nonce
and the Join tag of §4.5. The server refuses (`Error(ERR_BAD_JOIN)`) an unknown
session, a lane id outside 1..=8, a wrong tag, or a client_nonce it has seen in
this session's last 64 joins; and `ERR_NOT_PAIRED` while the session is not
paired. Otherwise it draws a fresh random server_nonce and sends, unsealed,
`JoinAck{lane_id, server_nonce, tag}` (JoinAck tag, §4.5); the client checks the
tag. Both sides then seal everything after with lane_key(c2s|s2c, n, client_nonce,
server_nonce) (§4.3), counters from 0. The client sends a sealed Ping on the lane
as soon as it has checked the JoinAck. A join of a lane id that is still live
supersedes the older connection, but only once the new connection's first sealed
frame has opened under the new lane key (within the handshake timeout): a replayed
Join cannot prove the key and leaves the live lane alone. Lanes end with their
session.
A lane carries heartbeats and, on a node with `CAP_DATA_PLANE`, the lane data frames `Chunk`
and `Bundle` (§12) and `Error`; any other frame without the IGNORABLE flag is answered
`Error(ERR_PROTOCOL)` and closes the lane.
Lane sockets (informative): both ends ask for 4 MiB `SO_RCVBUF` and `SO_SNDBUF` on every lane, the
client before it connects and the console on accept, before the first read, and take whatever the
kernel grants. A lane thread that is busy opening a 15 MiB frame for 10-20 ms then does not close
the sender's TCP window. A receiver may keep a small pool of frame buffers for chunks whose size is a class (1, 4, 8, 15 or 16 MiB; every other size is allocated exactly, so memory in use never exceeds what the credit window counted, and idle pooled memory never exceeds the admit budget) so a lane does not allocate and fault in a fresh 15 MiB block per frame; this is not visible on the wire.
## 10. Version 1 scope
Version 1 is what this document specifies; the sections below say what that is and what it is
not. Unknown frame types on a control connection are a protocol error; new frame types require a
version bump or a negotiated `caps` bit.
In version 1:
- Project 1 (§1–§9): framing, codecs, keys, handshake, pairing, the trust slot and launch
token, heartbeats, RPC `node.info` (and `pairing.open`, `crypto.bench`).
- Project 2 (§11–§16): jobs and manifests, chunks and bundles on lanes, credit, verification
groups and outboards, journals, resume, staging, apply and the governor; uploads
(folders, single files, file lists, zip archives read as sources, local and NAS sources),
downloads (to a folder or to a zip), console-local copy and move, and PS5 → PS5 through
an engine relay (the engine downloads from one console while it uploads to the other,
with a bounded in-memory hand-off); the data RPCs `job.copy`, `job.status`, `job.cancel`
and `disk.calibrate`.
Not in version 1, each with its reason:
- Direct PS5 → PS5 (tickets, Ed25519 signing, cross-network encryption): the relay is the only
PS5 → PS5 path; direct transfer needs a trust model between two consoles. PS5 → PS5 has
engine support but no UI wiring (project 3).
- Engine ↔ engine sharing: `host::FolderHost` exists as the receiving half, the sharing
policy and the feature are deferred.
- Zstd bundles and small-file deduplication: ruled out of project 2.
- Full re-verification of durable groups on resume: §13.4 re-hashes only the last durable
batch of each partial file and trusts older groups to the journal.
- Sources of unknown length: every file's size must be known when the manifest is built.
- Auto-tuning of the small/large cutoff (the design spec's 64 KiB–4 MiB range): the cutoff
is the protocol constant `LARGE_CUTOFF`, §12.2.
- Resuming a Deflate zip download: a deflate stream cannot be continued, so the optional
Deflate archive restarts with a fresh job per attempt (progress stays monotonic) and says
so. The default archive is Stored and does resume (below).
- Resuming a download whose remote manifest changed: the engine restarts that job.
### 10.1 Zip downloads resume
A download into a `.zip` writes Stored (uncompressed) entries and resumes mid-entry on a
reconnect, within one engine run (the job id is reused, as for a download to a folder). No
wire change and no journal record: the receiver is `JF_ORDERED` and its journal already holds the
durable files and the durable prefix of the file in flight.
- Layout: every entry is `local header ‖ data ‖ data descriptor` (zip64 throughout), in manifest
order, then the empty files, then the central directory and the zip64 end records. Because the
manifest carries every size, entry `i` starts at the sum of the entries before it: offsets are
recomputed on resume, never recorded.
- Resume: after the §13.4 re-check and before the map is sent, the receiver passes the journal's
state (finished files, partial prefix) to the sink (`Sink::position`). The sink verifies each
finished entry's header and data descriptor (the descriptor holds the CRC-32), rebuilds the
in-flight entry's CRC-32 by reading its durable bytes back, truncates the archive to
`data offset + durable bytes` and continues. The sender, told by the map, sends the rest.
- The sink's fsync runs inside the receiver's batch (data sync, journal append, Durable), so the
journal is never ahead of the archive. A sink that cannot honour the journal (a missing or
altered archive) makes the receiver drop the journal and start the job over.
- A file the receiver asks to have sent again (a verify mismatch) still restarts the archive.
- Deflate remains available as an option (`ZipCompression::Deflate`); it cannot resume.
- A same-drive `fs.move` over AVA1 is `fs.rename` (§7.3), with the `st_dev` guard; a cross-mount
move (copy, verify, delete) is an AVA1 job.
- Typed bodies for the management text methods (§7.3): the filesystem, node and job methods are
typed; the rest carry `MgmtText` and are typed method by method after the cutover.
## 11. Jobs and manifest
11.1 Every transfer is a job with a 16-byte `job_id`, chosen by the node that opens it
(the engine uses the HTTP API's `tx_id`). A job is bound to the static key of the peer
that opened it; only that key may resume or cancel it. Every data-plane message
(types 0x20–0x3F) has `job_id` as its first field, so a router reads it from body[0..16].
11.2 Paths in a manifest are relative to the job root: UTF-8, '/'-separated, at most
1024 bytes, no empty, "." or ".." component, no NUL, no leading '/'. A receiver refuses
a manifest with any other path (`ERR_PATH`) before touching the filesystem.
11.3 Opening (`kind` as seen by the node that receives `JobOpen`):
- `JOB_UPLOAD`: opener → `JobOpen`; receiver → `JobOpenAck{credit, staged}`; opener →
`ManifestPage`* → `ManifestEnd`; receiver → `JobMap`; then data.
- `JOB_DOWNLOAD`: opener → `JobOpen{root = source, ext credit}`; the other node becomes
the sender: `JobOpenAck`, `ManifestPage`* → `ManifestEnd`; opener → `JobMap`; then data.
- `JOB_COPY` runs on one node (`job.copy` RPC, §7).
Entries are numbered 0.. in manifest order (`file_id`); directories are entries with
`kind = ENTRY_DIR`. `manifest_hash` = BLAKE3 over, for every entry in order,
`u32le(len) ‖ encoding of the entry without its ext`.
11.4 Policies: `replace` (send everything not in the map), `skip-existing` (the receiver
marks a file done when a file of the same size and mtime seconds exists), `verify` (the
sender puts each file's root in `ext root`; the receiver marks a file done when an
existing file of the same size hashes to it).
A sender that wants "skip files the console already has" picks the policy from its source:
when every file reports a real mtime (local disk; SMB, FTP and SFTP servers) it uses
`skip-existing`; when any file's mtime is unknown (`mtime = 0` in the manifest — a backend
that reports none) it uses `verify` for the whole job, computing each file's root before
the manifest is sent. `verify` costs one read of the source plus a hash of each existing
console file of the right size, but is correct without an mtime; the sender never guesses
an mtime. The engine's "safe" resume mode always uses `verify`. The known weakness of
`skip-existing`: a file whose content changed but whose size and mtime did not is skipped.
A directory's mtime is always 0 and is ignored by both policies.
11.5 A `JobOpen` for a job the receiver already knows is a resume: the receiver matches
the new manifest against its stored one by path, keeps the progress of entries whose
size and mtime are unchanged, and restarts the others (never splicing old and new bytes).
`Resume{job_id, manifest_hash}` is the fast path when the sender still holds the same
manifest: the receiver answers `JobMap`, or `JobMap{status = ERR_UNKNOWN_JOB}` and the
sender falls back to `JobOpen`. A map larger than one control frame is sent as several
`JobMap` pages; `last = 1` marks the final one. `Durable` is never paged: each is complete.
Engines reopen with `JobOpen` after any interruption; `Resume` is optional for senders that
keep their manifest and credit state. A receiver answers `Resume` with the `JobMap` of a parked job
of the same peer key whose stored manifest has that hash, else `JobMap{status = ERR_UNKNOWN_JOB}` —
never silence.
The final `JobMap` page may carry the extension `held`: the bytes the receiver's drive already
holds for the job's unfinished large files (the allocated blocks of their part files, at most
each file's size; a part file is preallocated whole, so its undurable tail is on the drive
already). It is advisory and only ever a credit: a sender that checks free space before
sending subtracts it, with the files in place and the durable ranges, from what the job still
needs. Absent means none is claimed, and a sender never credits more than the receiver said.
Credit restarts after any interruption and nothing outstanding carries across a reconnect: the
grant in a `JobOpenAck` is an absolute number that sets the sender's window (a `Credit` that
arrives later adds to it), and a `Resume` restarts the window the same way — the receiver resets
the job's outstanding-credit count to its current grant and re-sends that grant as `Credit`. (Conformance: the console receiver sends the grant minus what the job still holds, the engine host its full
grant; the sender's window is exactly that number either way. Tests: `wire_upload.rs`
`a_resume_after_a_dropped_session_sends_credit_and_the_job_completes`, `data_rust.rs` the Resume test.)
11.6 Staging: when the job root does not exist, the receiver writes the whole tree under
`<root>.ava-part/` and, after the last file, renames it to `<root>` (same parent, `st_dev`
checked; a device that cannot be read is refused with `ERR_IO`, never taken as the same). When the root exists, files are written in place; large files through
`<name>.ava-part` and a same-directory rename. `JF_SINGLE_FILE` writes `<root>.ava-part`. A staging
receiver takes `<root>` with `mkdir` before it journals the job (an existing `<root>` then
refuses it, `ERR_EXISTS`) and records that in `JnlOpen.staged` bit 1, so on resume the empty
`<root>` is its own; the final rename replaces only that empty folder (not empty: `ERR_EXISTS`).
11.7 Limits and lifetime. A node lists at most 32 jobs; past that, or when it has no buffer budget
left for another job (§12.4), a `JobOpen` is answered `ERR_BUSY`. A manifest has at most 4,000,000
entries (a receiver refuses growth past that), and its pages are sized to fit a control frame (the
console writes at most 60 KiB per page). At most one running job writes a destination: a `JobOpen`
or `job.copy` whose root is, or lies inside or around, the root of another job that has not ended
(and, for a move, its source) is answered `ERR_BUSY`; parked jobs count, because they can resume.
When a session ends its jobs are parked, not ended: they stay listed, detached, for 10 minutes
and then leave the table; their journal stays on disk (§14.3), so a later `JobOpen` resumes them.
A finished upload or download leaves the table 10 s after it ended (its `JobDone` went out or came in), whether
or not its session goes on: a session runs job after job, so the jobs it ended must not hold the table until it
ends. A `JobOpen` that finds the table full takes the slot of the ended upload or download (with nothing left
to settle) that ended longest ago instead of answering `ERR_BUSY` (a reopen of an upload replays its journal; a download starts over). A finished local job
(`job.copy`) stays listed for the full park age so `job.status` can still answer. Either peer ends a
job with `JobCancel{job_id, reason}`, where `reason` is the `ERR_*` code the ender wants reported
(`ERR_CANCELLED` for a user's cancel, `ERR_IO` or `ERR_VERIFY` when a sender's source fails); the
receiver then ends the job with that status in `JobDone` and keeps the journal. A `job.cancel`
on the console ends the job behind its peer's back, so the console tells the session the job is
attached to before it stops it: `JobDone{ERR_CANCELLED}` for a job it receives, `JobCancel{ERR_CANCELLED}`
for one it sends (a send to another console is ended by its own session, §18.2). A sender whose
receiver says nothing at all about the job for 10 minutes on a live session (no `Status`, §16.9, nor
any other frame) sends `JobCancel{ERR_STALLED}` and fails the job; that also bounds the wait for the map.
11.8 Job flags (`JobOpen.flags`, `JobCopy.flags`, `JnlOpen.flags`). A receiver answers
`ERR_PROTOCOL` to a flag it does not know.
| flag | value | meaning |
|------|-------|---------|
| `JF_SINGLE_FILE` | 1 | the root is a file path and the manifest has one file entry; the part file is `<root>.ava-part` (§11.6). On `job.copy` the node derives it from the source, and passing it for a directory source is `ERR_PROTOCOL` |
| `JF_ORDERED` | 2 | the receiver consumes the files in manifest order (a download written into a zip, a relay); the sender then reads with one reader |
| `JF_UNSAFE_READ` | 4 | a sender may read outside the roots its read policy allows (system files); the engine sets it only for a download the user marked unsafe |
| `JF_MOVE` | 8 | `job.copy`: delete each source file after its destination is durable (§12.6, §15.5) |
| `JF_OVERWRITE` | 16 | `job.copy`: replace destination files that already exist; unset, an existing destination root is refused with `ERR_EXISTS`. Not valid on `JobOpen` |
11.9 Messages. All are data-plane messages (§11.1). "Sender" and "receiver" are the roles of the two
peers for the job (§11.3), not who opened it.
| type | message | direction | where |
|------|---------|-----------|-------|
| 0x20 | `JobOpen` | opener → peer | control |
| 0x21 | `JobOpenAck{status, credit, staged, workers}` | answerer → opener | control |
| 0x22, 0x23 | `ManifestPage`, `ManifestEnd{files, bytes, manifest_hash}` | sender → receiver | control |
| 0x24 | `JobMap` | receiver → sender | control |
| 0x25 | `Resume` | sender → receiver | control |
| 0x26, 0x27 | `Chunk`, `Bundle` | sender → receiver | a lane |
| 0x28 | `Received{lane, seq}` | receiver → sender | control |
| 0x29 | `Credit` | receiver → sender | control |
| 0x2A | `Durable` | receiver → sender | control |
| 0x2B | `FileRoot` | sender → receiver | control |
| 0x2C | `FileRetry{file_id, reason}` | receiver → sender | control |
| 0x2D | `Status` | receiver → sender (IGNORABLE) | control |
| 0x2E | `JobDone{status, files, bytes}` | receiver → sender | control |
| 0x2F | `JobCancel` | either | control |
| 0x30 | `SkipProgress{bytes_skipped}` | sender → receiver | control |
`FileRetry.reason` is `RETRY_VERIFY` (1, the root did not match), `RETRY_IO` (2, the receiver lost
the part file or outboard) or `RETRY_CHANGED` (3, the record's length disagrees with the manifest).
A sender queues a file at most once until it has re-read it: a repeated `FileRetry` for a file
still waiting is ignored, and a `file_id` past the manifest closes the session.
## 12. Data frames and credit
12.1 `Chunk` and `Bundle` travel on lanes; everything else on the control connection.
The header `channel` of a lane data frame is the sender's per-job sequence number.
12.2 `Chunk.offset` is a multiple of 1 MiB (one verification group, §13); its length is
a multiple of 1 MiB unless the chunk ends the file. A file is a *large* file when its
size is at least `LARGE_CUTOFF` (256 KiB), a protocol constant both sides use (`JobOpen`
carries no cutoff); smaller files travel whole, as `BundleRecord`s. A receiver ends the job
with `ERR_PROTOCOL` on a `Chunk` for a small file or a `BundleRecord` for a large one. A piece is
never larger than the credit the receiver granted (§12.4): the sender caps a piece at the smaller of
the chunk size and the granted window, floored to whole verification groups, and a grant below
one group fails the job loudly instead of stalling.
12.3 `Received{lane, seq}` is sent as soon as the receiver has a lane frame in memory,
before any disk work. A sender requeues, on any lane, the frames of a lane that closed
before they were `Received`. Applying a frame twice is harmless. A lane's death releases
window credit only for frames that provably never left the sender: frames the lane's writer
never dequeued (still queued when the writer is confirmed dead) are dropped and their bytes
returned to the window. A frame the writer may have put on the wire stays charged until the
receiver accounts for it (its `Credit` after the apply) or the job ends — releasing those
would let the sender spend the same window twice, and the receiver's `ERR_CREDIT` (12.4)
would fail a healthy job.
12.4 Credit: `JobOpenAck.credit` (uploads) or `JobOpen.ext credit` (downloads) is the
number of lane-frame body bytes the sender may have outstanding — the window counts the whole
body, so a `Chunk` costs its data plus 34 bytes of framing; `Credit{bytes}` returns space as the
receiver frees buffers. A version 1 receiver grants 64 MiB, and never less than 8 MiB: a node whose
global buffer budget cannot cover 8 MiB refuses the job with `ERR_BUSY`. A receiver that sees a
lane frame exceed the credit still outstanding sends a sealed `Error{ERR_CREDIT}` on the offending
lane and closes that lane only: the session, the job and its other lanes go on. Nothing of the frame is
buffered or acknowledged, and the sender observes ERR_CREDIT on that lane; it requeues the frames the
lane never had `Received`, as for any dead lane (§12.3). A sender never sends a piece larger than the credit already granted: pieces are sized at
read time to fit the window (whole verification groups, one group minimum; a file's final piece keeps
the whole-file rule), and a window that cannot hold one group — or whose smallest queued frame fits
no lane for 10 s with nothing sent, received or credited — ends the job (`ERR_PROTOCOL`) instead of
stalling. The 10 s applies while the receiver holds no window bytes (a window that can never fit the
frame). While it still holds some — a drive in a long flush has not yet returned its `Credit` — the
receiver is slow, not dead, and the sender waits (a dead one is caught by §6 liveness), failing only
after 10 minutes without progress. A lane's death does not refund window credit that the receiver has not accounted for
(§12.3): its un-received frames are requeued with their bytes still charged, and the charge is
released only when the receiver accounts for them (its `Credit` after the apply) or the job ends.
12.5 Per lane, the sender keeps at most `max(chunk size, lane rate × 2 s)` bytes sent
and not yet `Received`.
12.6 `Durable{files, ranges}` follows the order: data synced → journal appended and
synced → `Durable`. What "data synced" means for a small file is the receiver's choice, but what
the journal proves is not: a file reported durable can be reproduced by the receiver alone, either
because its bytes and name are on stable storage in place, or (durable-by-log, §15.7) because a
log record that holds them is, and a durable journal record names it. `JobDone` follows the last durable commit (and the staging rename).
A failure after every byte is durable (`ERR_EXISTS`, `ERR_CROSS_DEVICE` on the final
rename) is reported in `JobDone` and never causes a resend. A rename is durable only once its
directory is synced: the receiver fsyncs the parent directory after every commit or staging
rename, before it journals that commit. Likewise for new names: before a batch is journaled, every directory
that gained a file in it is synced once, after the file data. Where fsync does not reach stable
storage (macOS), a receiver flushes the drive's cache once per batch after the per-file fsyncs.
12.7 Download pipeline (receiver on the engine, informative; §12.6 is what binds). The pipeline is
tuned so a download is bound by the wire, not by the receiver: (a) a job with nothing to resume sends
its (empty) `JobMap` before the journal and the destination folders are made durable, so the sender's
turnaround to its first data frame overlaps that setup; (b) the files of one `Bundle` are root-checked
and written on a blocking task of their own, several bundles at once, while the receiver goes on
draining the inbox; a bundle's `Credit` is returned when its files are written, not when its frame
arrived; (c) the sync batch is due every 250 ms and at once when every file of the job has been
written (waiting for the next tick there only adds latency). None of this weakens §12.6: a file is
reported durable, and `JobDone` sent, only after its data, its names and its journal record are
synced. A sender starts its reader and writers when the map arrives, not at the next 25 ms tick, and
its walk stats each path once (`lstat`; `stat` only for a symlink).
A transient `fsync` error does not fail the job at once. A receiver retries a failed `fsync` (file
data, directory, journal append, final commit sync) up to four more times, waiting 20, 60, 200 and
600 ms, when the error is one a drive can recover from: `EINTR`, `EAGAIN`, `EBUSY`, `ETIMEDOUT`,
`ENOENT`, `ENXIO`, `ENODEV`, in Sony's `0x8002xxxx` form too (the console reports a USB drive's
hiccup as `0x80020002`). `EIO` is never retried: it is the kernel saying the data did not reach the
drive, and an fsync that "succeeds" afterwards proves nothing (the kernel may have dropped the dirty
pages that failed). A retry that finally succeeds is not trusted alone either: the receiver reads
back what that fsync covered — each small file is re-read and its BLAKE3 compared with the root it
arrived with, each large-file range of the batch is re-read and its group chaining values compared
with the outboard's — and a mismatch ends the job with `ERR_IO` before anything is journaled or
acknowledged. Nothing is acknowledged while a retry is pending; every retry is logged
(`[ava1] fsync failed (errno 0x80020002), retry 1 of 4`). Exhausted retries end the job with
`ERR_IO` ("fsync failed"), as before.
The console-local copy and move (§15.5) use the same standard in memory: a copy is a receiver job
fed by an in-process reader, with no read-back of the destination; a move deletes a source file only
after every destination group of that file is verified in memory, the file and its directory are
fsynced and the destination's `Done` is journaled; a copy that fails deletes nothing.
12.8 Progress watchdog (review 006 #2). A Ping is a byte, so §6 cannot see a sender that heartbeats
while its data pump is wedged (a source read stuck on a network share): such a job would stay open
with no byte of file data moving. A receiver therefore ends a job that has made no progress for
the progress deadline while the sender still owes bytes: a file is neither written nor in a sync
batch, the receiver has nothing of its own in flight, and the sender is attached. Progress is a
lane data frame admitted, a `FileRoot`, a finished bundle write, or a sync batch that made
something durable. The deadline is 3 x `dead_after` (36 s at the default 12 s); a job that resumed
whose journal held finished or partial files when it opened (decided once, at open, or at a reattach that finds durable work) gets 15 minutes,
since its sender may hash or skip what is durable for a long time without producing a frame. The clock runs only while the sender is attached
(a lane is up on the engine, a session on the console), and any finished sync batch that had work counts as progress. A slow drive never counts: any queued or running write, or a
running sync batch, holds the clock. The engine's receiver sends `JobCancel{reason: ERR_STALLED}`
and fails the job (the engine's session retry resumes it); the console ends it with `JobDone`
status `ERR_STALLED` and keeps the journal, as for every console-side failure. The sender
observes the code like any receiver-ended job and may resume.
`SkipProgress{job_id, bytes_skipped}` (0x30) is job-scoped liveness. A sender that reads past
data the receiver already holds (a solid archive decoded from its start on resume, §17.2, or the
durable stretches of a partly held large file) sends no data frame while it does; it sends a
`SkipProgress` at least every 10 s instead (the engine: every 5 s while its skip counter moves).
`bytes_skipped` is the running total the sender has read past for this job (informational). A
receiver counts it as progress for that job, only from the session the job is attached to, and
answers nothing. Receivers that predate it ignore it as an unknown data-plane type.
## 13. Verification
13.1 A file's root is its standard BLAKE3 hash. Files are hashed in groups of 1 MiB
(`GROUP_SHIFT` = 20): group i covers bytes [i·2^20, (i+1)·2^20). For a file of two or more
groups, each group's chaining value (BLAKE3 `finalize_non_root` of that subtree) is
computed where its bytes are, and the root is merged from the group CVs along BLAKE3's
tree (left subtree = the largest power of two of groups strictly less than the count);
every merge is a non-root parent compression except the last, whose compression carries
BLAKE3's ROOT flag. A file of zero or one group: root = BLAKE3(bytes).
13.2 Senders compute the root while reading. Small files carry it in their
`BundleRecord`; large files send `FileRoot` once their last group is read (or, on a
resume, once all group CVs are known from the sender's outboard).
13.3 Receivers compute each group's CV from the bytes they write and store it in an
outboard (32 bytes per group, in the job directory). At commit the root merged from the
outboard must equal the sender's root; otherwise the file is reset and `FileRetry` sent.
13.4 Resume verification: before answering a resumed job's map, the receiver re-hashes the groups
of each partial file that its last durable batch covers and compares them with the outboard; a
mismatch drops those ranges from the map. The console re-hashes only that batch and trusts older
durable groups to the journal — version 1 never re-verifies every durable group of a console
partial file. A receiver may check more: the engine's receiver re-hashes every durable group of
every partial file and resets a file on any mismatch. The verify policy re-hashes whole files.
## 14. Journal and resume
14.1 The journal: `<job dir>/journal` = magic `AVA1JNL1` (8 bytes), then records
`u32le(len) ‖ u8 kind ‖ body ‖ u32le(crc32c(kind ‖ body))`, `len` = 1 + body length. Kinds:
1 `JnlOpen`, 2 `JnlBatch`, 3 `JnlReset`, 4 `JnlSnapshot`, 5 `JnlDone`, 6 `JnlSweep` (bodies are the
generated structs). Replay stops at the first record whose length runs past the file, whose CRC fails, or
that the visitor refuses; the file is truncated there before the next append. Every append is
followed by `fsync`. When the file passes 1 MiB it is compacted: `journal.tmp` =
magic + `JnlOpen` + `JnlSnapshot(current state)`, `fsync`, `rename` over `journal` (same
directory), `fsync` of the directory. The job's manifest is stored once as `<job dir>/manifest`:
exactly the content of a `records(ManifestEntry)` field (written as `manifest.tmp` → `fsync` →
rename).
14.2 State: a `JnlBatch` marks files done (dropping their ranges), adds durable ranges and
records roots; `JnlReset` forgets one file; `JnlSnapshot` replaces the whole state; `JnlDone`
records the job's final status. A `JnlBatch` carrying the pack extension (§15.7) also marks its
files *unswept*; a `JnlSweep` clears that for the files it lists; a `JnlSnapshot` carries the
unswept files (`unswept`: the `FileRun` item stream) and the pack ranges that still hold them
(`segments`: the `PackRef` item stream, one per segment, spanning its unswept records). Replay's
unswept set is "done, minus swept". The map answered for a resume is the replayed state (done files;
durable ranges of the others), after the check in §13.4.
14.3 Location: the console keeps job directories under `/data/ps5upload/ava/jobs/`, an engine
under `<data dir>/ava/jobs/`. Each holds `journal`, `manifest` and one `<file_id>.ob` outboard
per large file. A node removes a directory 7 days after its last write (directory or journal mtime), on
start and then once a day; the engine also sweeps `<data dir>/ava/send` (sender outboards) and never
touches the directory of a job that is running in the process. A job whose session ended is parked for 10 minutes first (§11.7).
## 15. Apply (receivers)
15.1 Directories first: every directory entry of the manifest is created before any file data
is applied, and the directories a batch created are fsynced before that batch is journaled. A
file whose parent is not a manifest entry still gets its parents created when it is opened.
15.2 Small files (a `BundleRecord` chunk): `open(O_WRONLY|O_CREAT|O_TRUNC|O_NOFOLLOW)` → write →
mode → mtime. The descriptor stays open until a sync batch covers the file; a duplicate record
for a file that is already durable or already pending is dropped without truncating it. A
record whose length disagrees with the manifest is answered with `FileRetry` reason
`RETRY_CHANGED`, and one whose BLAKE3 disagrees with the root it carries with `RETRY_VERIFY`;
the record is not applied and the job continues.
A receiver may instead take the durable-by-log path of §15.7 for these records.
15.3 Large files: `pwrite` at the chunk's offset into the part file (preallocated when new),
group CVs into the outboard. Bytes are durable after a sync batch. When every byte is durable
and the root merged from the outboard equals the sender's `FileRoot`, the file commits: mode →
truncate to size → mtime → fsync → a same-device check (§12.6) → a rename in the same directory
→ an fsync of that directory. The part file of a single-file job is `<root>.ava-part`; in a
merge it is `<path>.ava-part` beside the final file; in a staged tree (§15.5) it is the file's
final relative path inside the staging tree, so the file's own commit renames nothing. A part file or
outboard that is missing at commit time is a reset (`FileRetry` `RETRY_IO`), never a new empty
file.
A receiver opens and preallocates a new part file (so that ENOSPC is found first, and so that a
sparse file cannot collapse the drive's write rate under dirty-buffer throttling) without
holding the job's lock, and runs a commit on a worker rather than on the thread that batches
fsyncs. Journal appends from several commits and a batch are serialised, so their records land in
some order; each is independent, since a commit's record names one file and only follows that
file's rename and directory fsync. A journal compaction waits until no commit is between its
rename and its record. Where a drive's fsync of a batch takes longer than the credit window
holds data at the intake rate measured between batches, a receiver may fsync each chunk right
after writing it (the batch fsync then finds little to flush); this changes no ordering.
15.4 Sync batches: every 250 ms, or after `batch_max` small files (tuned 16–512, starting 256:
halved when a batch takes over 1.5 s, doubled when under 0.5 s) or 64 MiB of large-file bytes.
Data fsyncs run in parallel on the workers, then the new directories are fsynced (also in
parallel on the workers: each is an independent descriptor, and a directory is synced once per
batch however many files it gained), then the batch (`JnlBatch`) is appended and the durable
ranges are reported. A stop in the middle of a sync journals and acknowledges nothing.
(A logged batch, §15.7, replaces the small files' data and directory syncs with one fsync of the pack
log; the invariant below then applies to the sweep, which is where those directories are synced.)
The directory-sync invariant (the journal never runs ahead of a name): a `JnlBatch` naming a
file is appended only after every directory that gained an entry in that batch has returned from
`fsync`, and a prepare's new directories are likewise all synced before the map is sent. The
syncs are never deferred past the record; a crash with some of them still to do therefore leaves
no record naming those files, and the resumed job resends them. A receiver may run the syncs in
any order and on any threads, but not after the append.
15.5 Staging and merge: a new destination is staged — the tree is written to `<root>.ava-part`,
a sibling of the destination, while `<root>` itself is created as an empty lock folder; at the
end the finished tree is renamed over that empty folder (never over content). If the
destination appeared in the meantime the job ends `ERR_EXISTS` and the files stay in
`<root>.ava-part`. A merge into an existing folder refuses a manifest directory that is a
symbolic link or not a directory (`ERR_PATH`), and a file already where one must go ends the
job with `ERR_EXISTS`.
Version 1 has two walk modes and they are deliberately different. The sender/download mode
follows directory symlinks (the engine walks the same tree the same way); a symlink cycle is
an error, never a spin, and a dangling link — the link's target cannot be stat'd — is an error
in both modes. The console-local copy/move mode skips directory symlinks: the copy writes into
a namespace it must not be able to be led out of by the source tree. Both are contracts, not
bugs; the sender's descend behaviour is not normative for the copy path.
A copy (`job.copy`) is a receiver job whose sender is the in-process reader, so this section
applies to it unchanged. The job is owned by the peer key that issued it: `job.status` and
`job.cancel` from another key answer `ERR_UNKNOWN_JOB`. A `job.copy` for an id the node already
lists, from the same owner with the same parameters, answers with that job's `Status` instead of
starting a second one — which makes re-issuing after a lost connection safe; a failed job is retired
and restarted by a re-issue. `job.cancel` stops a job and unlists it (the journal stays), so a
caller never cancels a job whose terminal status it still needs. A finished local job stays listed for the park age, so `job.status`
keeps answering for it.
`job.copy` uses the source and destination paths, a stable job id, and flags including
`JF_OVERWRITE` and `JF_MOVE`. Without `JF_OVERWRITE`, an existing destination root is refused
with `ERR_EXISTS`; with it, colliding files are replaced and destination-only files remain.
A move deletes source entries only after the destination rename, parent directory sync and
successful Done journal append. It checks each source file against the manifest before unlinking
and reports any paths left behind as a failed job; `job.status` stays running (`state` 0, `current` =
"deleting source") during deletion, so `state` 1 is the only point at which a move is done.
15.6 Open files. The number of descriptors a process may hold open is a resource, and on the
console it is smaller than `RLIMIT_NOFILE` says: the measured ceiling on firmware 13.60 is about
619 open files while the limit reads about 13,952. At data-plane start a node therefore probes
(it opens `/dev/null` until the system refuses, bounded at 4096), and its open-file budget is the
smaller of the raised limit and the probed count, each less 128 descriptors held back for sockets
and the other services. Half of the budget is the share of pending small-file descriptors (§15.2) all
jobs together may hold, never fewer than 4; a worker that finds the share used up runs queued sync
work or waits, and the job thread syncs early so that the batch frees slots. A single job holds at
most 512 pending small files regardless of the budget. `disk.calibrate` (§16.10) works within the same
budget instead of opening every file at once.
15.7 Durable-by-log (small files; optional for a receiver, the console and the engine implement it).
A batch of N small files costs two fsyncs (the log, the journal) instead of N + D + 1; files are made
durable in place later, off the transfer's critical path.
Pack log. `<job dir>/pack.<n>` (n from 0, never reused within a job) = magic `AVA1PCK1` (8 bytes), then
records `u32le(len) ‖ u8 kind ‖ body ‖ u32le(crc32c(kind ‖ body))`, `len` = 1 + body length, appended
sequentially; kind 1 `PackFile` = a `BundleRecord` as received. A reader stops at the first record that runs
past the extent or fails its CRC. A record never spans segments; a segment is closed when the next record would
pass `PACK_SEGMENT` (64 MiB) and a new one starts. Segments are not preallocated (the log is fsynced every
batch, so dirty data never piles up the way it does under a part file). A new segment's directory entry is
synced when it is created, so a journal record may name it.
Receiver steps for a small file: validate as in 15.2; append the record to the log (the offset is taken and the
record written under one lock, so every record below a written one is written); create the file, write, mode,
mtime, close, with no fsync and no descriptor kept. A batch then (1) fsyncs the log segments written since the last
batch (a retried fsync is followed by reading the batch's records back and checking each CRC and root), (2) fsyncs
large-file data as today, (3) appends one `JnlBatch` per segment its records sit in, carrying the files as runs and
the pack extension (`pack_segment`, `pack_offset`, `pack_len`: the byte range of its records), fsynced, (4) marks the
files done and sends `Durable`. No directory is synced here.
Invariants. I1 A file reported `Durable` can be reproduced by the receiver alone: its bytes are in the file (swept)
or in a log record named by a durable journal record. I2 `Durable` and `JobDone` are never sent before the journal
record that proves I1 is fsynced. I3 A segment is deleted only after every file whose record it holds is swept and
the sweep is journaled durably. I4 Large files are unchanged. I5 Recovery is idempotent: re-making a file that exists
with the right bytes changes nothing, one with wrong bytes is rewritten.
Sweep. A worker with no other work (or one that would push the log past the cap) takes up to 64 done, unswept
files whose batch is at least `SWEEP_AGE` (3 s; every one when the job has ended) old: each is opened, re-made from its
log record if it is missing or the wrong size, and fsynced (a retried fsync re-makes it and syncs again); then each
distinct parent directory is fsynced; then `JnlSweep{files}` is appended and fsynced; only then does a segment whose
files are all swept get deleted. The sweep is the only place the directories of logged small files are synced.
Backpressure. The pack bytes of files not yet swept (pending ones included) are capped at `UNSWEPT_MAX` (256 MiB): a
worker that would pass it sweeps (or runs queued sync stripes) instead of writing, so the disk holds at most one cap
of duplicated bytes and throughput falls back to the per-file path's, never below it.
Recovery. On `JobOpen` of a known job, and for every job directory at helper start, the receiver replays the journal;
for each file done but unswept it locates the record in the pack ranges the replay kept, checks the record's root,
and re-makes the file when it is missing or its size or BLAKE3 differs, then sweeps. A file whose record cannot be found
or fails its CRC (a missing segment, a torn tail) is not done: a `JnlReset` forgets it and the sender resends it. A
successful recovery leaves no segment behind. Cost is bounded by the cap.
End of job and reporting. `JobDone` is sent once the final batch is journaled (I2), with ext `settling` = 1 while
files are still unswept; `Status` ext `unswept` carries the count (absent = 0) and the job stays listed, its sweep
running, until it reaches 0 (a session that ends does not stop it). A staged tree settles fully before its final rename
(the sweep addresses files by path), so `JobDone` carries no flag for it; so does a console copy or move, which must
not delete its source before what it copied is durable in place; merges and single files settle behind `JobDone`. A
sender that sees `settling` may keep the job open, reading `Status`, until `unswept` is 0 (an engine does, for up to 30 s,
to show "finishing on the console"); the report is true either way. A power cut inside the sweep's lag leaves the last files to be re-made by the next recovery.
Failure. A sweep that fails loses nothing: its files go back to the front of the queue and are tried again after a
backoff (100 ms doubling to 3.2 s). After five failures in a row the error is sticky: `Status` carries `code` =
`ERR_IO` and the reason in `current` until a sweep succeeds, and a job that is still running fails with it. A sender
that waits for `unswept` = 0 fails the upload on that `code` (the console cannot make its files durable); on its own
timeout or a cancel it ends the wait and reports the job with a warning, never as a clean success, because the
bytes are safe in the log. A drain (a staged tree's rename, a copy's end, a manifest change) retries a few times and
then fails; a drain cut by a stop writes nothing (no terminal `Done`). A manifest change is refused with `ERR_IO` when
the unswept files cannot be settled first (the sweep queue names ids, which the new manifest would renumber).
Nobody holding the job. A job directory that holds a pack log is never garbage-collected. A settling job that is
reaped (after the park age) is destroyed with its log on disk; the receiver's housekeeping takes such directories (and
those a crash left) through the same recovery as `JobOpen`, a bounded number per pass, every few seconds and at start.
Beside the per-job `UNSWEPT_MAX` there is a cap across jobs (512 MiB); it gates a job only past its own share (4 MiB),
and the bytes of a job in a sticky sweep error, or of a recovery pass, do not count against the others. Recovery runs on a
thread of its own (the reaper is never held up), takes a bounded number of directories per pass and moves on from one it
cannot settle, so a stuck directory never starves the rest; while it holds a job id a `JobOpen` for it is answered
`BUSY` (the sender retries with a bounded jittered backoff, honouring cancel, and then resumes; the engine does this for uploads, downloads and relays, and fails with reason `ava1_busy` once the bound, 12 tries by default, is spent). A log nobody could recover is garbage-collected a week after the normal
age, with a log line.
## 16. Governor
The sender and the receiver each keep one small control loop; both are pure functions of the
numbers they are fed, so both are tested against models rather than sockets.
- Start: 2 lanes, a 4 MiB chunk and a 1 MiB bundle target; receiver workers start at 4.
- Lanes: add one while the bottleneck is the network and the last addition raised throughput by
≥ 10 % (≥ 5 % while the rate is below 90 % of the best rate seen in the job); otherwise revert
it and hold for 30 s. A tick with a lane death or requeue drops one
lane (min 1) and halves the chunk. At most 8 lanes: on a link that scales past that the count
simply stops growing.
- Chunk: 1–15 MiB in whole groups; halved on a stall, doubled after 10 stable ticks; never more
than half a second of one lane's throughput (min 1 MiB). Lanes before chunk: while the network
is the bottleneck, fewer than 4 lanes are open and no lane probe has failed yet, the chunk is
not doubled past 4 MiB (a bigger frame lengthens every decrypt stall on the receiver); once a
probe fails or 4 lanes are open, growth resumes. The sender can switch this policy off
(`PS5UPLOAD_AVA1_LANES_FIRST=0`) to A/B it; it is on by default. 15 MiB, not 16: the frame cap (§2)
counts the header and the MAC, which a 16 MiB body would not fit under.
- Benchmark pins (sender-local, never on the wire): `PS5UPLOAD_AVA1_LANES=n` holds the lane count
at n (1-8) and `PS5UPLOAD_AVA1_CHUNK=m` holds the chunk at m MiB (1-15); a pinned value ignores
stalls and the link. Out-of-range values are ignored. For measurement only.
- Bundle target: a quarter second of one lane's throughput, clamped to 256 KiB–15 MiB. It moves
with that rate; the effect is that it grows while the network is the limit and shrinks when
the rate falls (a receiver whose workers wait shows up as a receiver-reported bottleneck, §16.9
— the target itself is not fed by worker pressure).
- In-flight cap per lane: `max(chunk, lane rate × 2 s)` — the same bound §12.5 states.
- Mixing check: once per job — after 3 warm-up ticks, if both classes still have work queued,
the sender probes 5 s mixed, 5 s stream-only, 5 s bundle-only, then picks sequential if its
estimated finish time is < 90 % of mixed. Sequential runs bundles first. The choice and reason
go into `Status.sequential`. The probe never runs twice.
- Priority: beyond the bundle floor, the class with the longer estimated remaining time is
preferred. The small/large cutoff is the protocol constant `LARGE_CUTOFF` (§12.2); it is not
governed in project 2 — the design spec's 64 KiB–4 MiB auto-tuning is deferred.
- Receiver workers: every 2 s; add one while work is queued and the last addition raised
files/s by ≥ 10 %; revert and hold 30 s otherwise; release one after 3 idle steps; range
2–16, start 4.
- Bottleneck: source starved → `BN_SOURCE`; credit starved → the receiver's reported bottleneck
(`BN_DISK`/`BN_WORKERS`; `BN_NETWORK` when its queue ran dry, the credit being slow in transit),
else `BN_CREDIT`; otherwise `BN_NETWORK`.
16.9 Status: the receiver sends `Status` (IGNORABLE) every 250 ms while a job is open: files
and bytes done, durable bytes, its own bottleneck (`BN_DISK` when workers are at their maximum
or adding one did not help, `BN_WORKERS` while it is still adding, `BN_NETWORK` when its queue
ran dry), workers, lanes, and whether it runs sequential. The engine shows the sender's
bottleneck, which already folds in the receiver's.
16.10 disk.calibrate: method 19 accepts `DiskCalibrate` and returns `DiskCalibrateResult` with
measurements at 1, 2, 4, 8 and 16 workers. The request allows at most 20,000 files of at most
1 MiB each, and `dir` must pass the node's write policy. The answer is a hint for the engine's
starting worker count, never a contract. The node deletes every file and directory it created.
## 17. Sequential sources (sender-local, no wire change)
A sender may read a source that can only be read forward (a 7z folder, a solid RAR): a
`SeqSource` (`engine/crates/ava1/src/seq.rs`). The receiver cannot tell: it sees ordinary
`Bundle`, `Chunk` and `FileRoot` frames, and the manifest (sorted, §11.3) is built from the
archive's headers before the job opens.
17.1 One decode thread replaces the random readers. It calls `SeqSource::pass`, which visits
entries in *decode* order, asks `want(path, size)` for each (`Keep::Skip`, `Keep::All` or
`Keep::Ranges(lacking)`) and feeds the wanted ones to an `EntrySink` (`begin`/`data`/`end`).
The thread maps the entry's path to its manifest id (the archive's order is unrelated to the
manifest's), cuts files below `LARGE_CUTOFF` into records and the rest into group-aligned
chunks exactly as the random readers do (§12.2, §13), and queues a `FileRoot` after a large
file's last chunk. It takes the same read-ahead permits, so a slow lane parks the decoder
and its memory stays bounded.
17.2 Resume (§14). Files the receiver reports done are `Skip`. The pass starts at the minimum
`restart_for(id)` over the unfinished files (7z: the folder's first entry; RAR non-solid: the
entry), so everything the receiver already has before that point is not decoded. A partly
durable large file is decoded from its start (a decoder cannot seek inside an entry); only the
groups the receiver lacks are sent, durable groups are hashed only when no persisted outboard
CV exists, and a file whose every CV is known needs no decoding at all. There are no decoder
checkpoints: a resume costs a decode of at most the restart folder's prefix.
17.3 `FileRetry` (§13) queues the file for a further pass over only the retried files
(`Keep::All`, their outboards dropped). A job makes at most 3 decoding passes (the first plus two retry passes; a pass that
has nothing to decode is not counted); a further retry request fails it. An entry that a pass never delivers, delivers twice, or delivers with a different size
than the manifest fails the job (the archive changed between listing and sending).
17.4 Cancellation. `pass` receives a flag raised by the job's cancel *or* by any other way the job ends (a lane,
protocol or receiver failure) and must poll it at least every 1 MiB of input, including while
skipping, and every `EntrySink` call fails
once the job is ending; `SeqSource::close` is called at teardown before the decode thread is
joined. The sender attributes the bottleneck itself: it is `BN_SOURCE` when lanes find nothing queued, except
in a tick in which the decode thread parked on its read-ahead budget (the budget is held by frames in flight,
so the lanes, not the source, are the limit then).
17.5 Entry metadata. The manifest carries each entry's own last-modified time as `mtime` (it is content, not
container metadata) where the format exposes it: 7z (the entry's FILETIME), zip (its DOS time, read as UTC) and
RAR (UnRAR's DOS time in the host's zone and 2 s resolution; a stamp in the future is read as absent, and
non-Unix hosts carry none). 0 means the archive has none. Directory mtimes are 0, and modes stay `0644` for files
and `0755` for directories: archives' Unix permission bits are not carried (zip's `unix_mode` excepted).
17.6 Refusals. A duplicate name, a path that is both a file and a directory (and, for RAR, two names that differ
only in case) fail the job terminally (`ava1_7z_unsupported`, `ava1_rar_unsupported`), and so does an
unsupported 7z coder method (`7z_unsupported`). A RAR's listing order is compared with its extraction order only when a
non-solid resume skips entries by position; any other pass binds entries by path.
## 18. Console to console
One console can send a job straight to another, without the bytes passing through the device
that asked (`engine/crates/ps5upload-ava1/src/c2c.rs`). Neither console is paired with the other;
the device paired with both (the engine) introduces them for one job.
18.1 `c2c.allow` (22), on the receiving console B: `C2cAllow{job_id, key, root}`, where `key` is the
sending console A's identity. B answers `C2cTicket{token}`: 16 random bytes. A ticket admits that
one key, showing that token, for that one job into that one `root`, acting for the device that
called `c2c.allow` (the job on B is that device's own, so it reads its `job.status` and can resume
it over any route). A ticket lasts 10 minutes from its last use; the same key and job again
replaces it. A node holds at most 8.
18.2 `c2c.send` (23), on A: `C2cSend{job_id, host, port, key, token, src, dest, flags}`. A dials
`host:port` (IPv4), runs the handshake as the client and refuses unless the static key the server
proves in message 2 is `key`. Its `ClientInfo` carries the ticket in ext tag 2 `token`. B admits a
key it does not know only with a live ticket for it: it answers `Welcome{knows_you = 1}` and marks
the session restricted. A live ticket decides even for a key B has paired: that session is the
ticket's restricted one too, never a full paired session. A joins up to 4 lanes (§9), sends `JobOpen{job_id, JOB_UPLOAD,
POLICY_REPLACE, JF_SINGLE_FILE when src is a file, root = dest}` and then runs the job as the
sender (§11–§13), exactly like a download's sender except that its window is the grant in B's
`JobOpenAck` and the job ends with B's `JobDone`. The reply is the sending job's `Status` once it
runs; a dial, handshake or ticket failure is `ERR_IO` with the reason as text. A source the read
policy denies is `ERR_PATH` whether or not it exists. A's job is listed under `job_id` and its
`job.status` / `job.cancel` answer the device that called `c2c.send` (and, on a node with `CAP_SHARED_JOBS`, any paired device: §18.5). A `c2c.send` from that device
again under the same `job_id` replaces its earlier send; any other job listed under that id is
left alone and the call is `ERR_BUSY`. When the session drops before the job ends, A fails the job
(`ERR_IO`); a cancel on A sends B a `JobCancel`, unless the job on A was replaced under the same id
(the relay took it over), which only hangs up. B acts on a `JobCancel` only for a job that is parked
or attached to the session it came on: a late one from an earlier session never stops the job
another session (the relay) now drives. Either way B keeps its journal, so a later `c2c.send` or upload with the same
`job_id` resumes.
18.3 A restricted session may send no RPC (`ERR_NOT_PAIRED`), and only data frames whose
`job_id` is the ticket's: a `JobOpen` only of kind `JOB_UPLOAD` without `src` into the ticket's
`root`. Any other frame closes it. A ticket's session replaces (§8) only an earlier session for
the same job from the same key, so one console's sends of different jobs run side by side.
18.4 The session A dialled is held to the same rule from A's side: B may send no RPC, nothing
on the lanes, and on the control connection only a receiver's frames (`JobOpenAck`, `JobMap`,
`Received`, `Credit`, `FileRetry`, `Durable`, `Status`, `JobDone`, `JobCancel`) for the one job;
anything else closes it. No lane may join it, and no handshake to A replaces it.
18.5 Sharing a transfer among paired devices (`CAP_SHARED_JOBS`, bit 2 = 4; #444). A node that
advertises it relaxes ownership for transfers only: `job.status` and `job.cancel` answer any fully
paired session for a job of kind `JOB_UPLOAD` or `JOB_DOWNLOAD` (an upload, a download, a send to
another console and its receiving half), so another device can watch a send or end it to take it
over. Operations (`job.run`) and local copies (`job.copy`) stay their owner's: another key still gets
`ERR_UNKNOWN_JOB`, and `job.list` (21) still lists only the caller's own jobs. A `JobOpen` of
`JOB_UPLOAD` for a listed upload of another key moves the job to the new key when no session is
attached to it (parked: its device paused it, lost the link or went away): the node retires it
from memory and reopens it from its journal, which is keyed by the job alone, under the new owner
(the same root, kind and flags rule as any reopen). One attached to a session is still refused with
`ERR_UNKNOWN_JOB` ("this job belongs to another device"); one still settling its files, or held for
a moment by another thread, is `ERR_BUSY` (try again). A `Resume` for another key's job is answered
`JobMap{status = ERR_UNKNOWN_JOB}` as before (§11.5), and the `JobOpen` the sender then sends is the
one that moves the job. A node without the bit keeps every job its owner's; an engine decides by the
bit, never by version, whether it may watch or take over a send another device runs.
18.6 The shared send record (engine convention, not wire). The device that drives a send keeps
`/data/ps5upload/c2c/<job id hex>.json` on the receiving console with the management fs write:
both consoles (address and key), paths, route, the driving device's key and name, a lease (45 s,
refreshed every 15 s), state (`running`, `paused`, `failed`) and progress; it is removed when the
send finishes and records not updated for 7 days are removed when listed. A device that takes a
send over moves the lease to itself, ends both halves (`job.cancel` on both consoles) and sends again
under the same `job_id`, which resumes from the receiving console's journal; the device that had it
reads the lease before anything that would end a half or fall back to the relay, and stops driving
the send without ending anything once the lease is another device's. A pause ends both halves, so a
paused send resumes from any device with any helper.