asz.pull
asz.pull is how one asz copies conversations from another over HTTP. A client lists the
conversations a server holds, asks it to freeze one conversation’s head as a snapshot, and fetches the
snapshot’s files as one archive, in ranges. The protocol is version 1, and every JSON answer but a
problem carries "schema": "pull/1". asz defines and owns it, as it owns Session Data,
Session Flow and asz.view, and a server or a client written in
another language follows this page. Pull from Another asz says how to set
up both ends, and Storage Root where each keeps what it holds.
The files travel as they are stored, byte for byte: the rounds through the head, the landed files and
skeletons through the head’s through_seq, and every published entry of the session’s provider
store. view.hide does not apply to them, since a masked file would no
longer match the digests its rounds carry.
Routes
| Method | Path | Purpose |
|---|---|---|
GET |
/pull/v1/conversations |
one page of the conversations the server holds |
GET |
/pull/v1/conversations/{conversation}/manifest |
start a freeze of the head, or poll one, until it answers the snapshot’s manifest and a lease |
POST |
/pull/v1/conversations/{conversation}/snapshots/{tag}/archives |
ask for an archive of some of the snapshot’s files |
GET |
/pull/v1/conversations/{conversation}/snapshots/{tag}/archives/{id} |
the archive’s state |
GET |
/pull/v1/conversations/{conversation}/snapshots/{tag}/archives/{id}/data |
one range of the archive’s bytes |
POST |
/pull/v1/conversations/{conversation}/snapshots/{tag}/done |
release a lease |
Below, ... stands for /pull/v1/conversations/{conversation}. Each part of a path is
percent-decoded once, then checked: {conversation} is a name, {tag} a
tag, {id} 64 lower-case hexadecimal characters, and a part that fails is 400.
An unknown path under /pull/ is 404. Another method, HEAD included, is 405 with Allow naming the
route’s one method. A query on any route but the listing is 400. asz’s client adds the routes to the
path of the remote’s url, so a proxy can serve them under a prefix.
A whole pull
Every request carries Authorization: Bearer <token>. Bodies are cut short with ….
GET /pull/v1/conversations?since=24h&limit=1
200 {"schema":"pull/1","rows":[{"id":"0438c73b","session":"0438c73b","runtime":"claude-code/1",
"title":"Check otel-rules support","round":4,"digest":"2e77…","talks":3,"steps":41,"streams":2,
"segments":1,"unresolved":0,"session_through_time":"2026-10-26T07:58:01.5Z"}],"skipped":[],"next":"eyJ2…"}
GET /pull/v1/conversations/0438c73b/manifest
202 {"schema":"pull/1","conversation":"0438c73b","freeze":"9c41…","state":"building"}
GET /pull/v1/conversations/0438c73b/manifest Asz-Pull-Freeze: 9c41…
200 {"schema":"pull/1","conversation":"0438c73b","session":"0438c73b","round":{…},"generation":0,
"formats":{"sd":"sd/1","sf":"sf/1"},"files":[…],"snapshot":"20261026T081630.123456789Z","lease":"5b0e…"}
POST /pull/v1/conversations/0438c73b/snapshots/20261026T081630.123456789Z/archives
{"lease":"5b0e…","files":["0438c73b/streams/main/transcript-20261026T075801.500000000Z-000004.sd",…]}
202 {"schema":"pull/1","snapshot":"20261026T081630.123456789Z","archive":"7d2e…","state":"building"}
GET …/snapshots/20261026T081630.123456789Z/archives/7d2e… Asz-Pull-Lease: 5b0e…
200 {"schema":"pull/1","snapshot":"2026…","archive":"7d2e…","state":"ready","bytes":9437184,"digest":"a3f1…"}
ETag: "a3f1…"
GET …/archives/7d2e…/data Range: bytes=0-8388607 If-Match: "a3f1…" Asz-Pull-Lease: 5b0e…
206 Content-Range: bytes 0-8388607/9437184
GET …/archives/7d2e…/data Range: bytes=8388608-9437183 If-Match: "a3f1…" Asz-Pull-Lease: 5b0e…
206 Content-Range: bytes 8388608-9437183/9437184
POST …/snapshots/20261026T081630.123456789Z/done {"lease":"5b0e…"}
204
Authentication
A server serves pull routes only when remote_server.tokens_file names a tokens file. Then every
request to the address needs a token: the page, /api and the pull routes alike. Without a tokens
file the address serves no pull routes, and the page is open as before.
- The header. Exactly one
Authorizationheader:Bearerin any letter case, one space, and the token. The server compares the SHA-256 of the token’s text, exactly as sent, with each line of the file in constant time. Any text whose hash is listed is taken.asz token createmakes one ofasz_and 43 characters of unpadded base64url. No header, another form, an unknown token, or a tokens file that cannot be read is 401, withWWW-Authenticate: Bearer realm="asz", before the body is read. - The tokens file. At most 4 MiB, one line per token: a name, one space, and the token’s SHA-256
in lower-case hexadecimal. Blank lines and lines that start with
#are passed over, and a line may end in a carriage return. A name is a name of onlyAtoZ,atoz,0to9,-,_and.. Any other line, or a name or a hash given twice, refuses the whole file. A request that comes five seconds or more after the server last read the file reads it again. New content that does not read is reported, and the last good set kept. A file that cannot be read, or that names no token, refuses every request. - Every answer on the address carries
Cache-Control: no-store, refusals included, withoutExpiresorPragma. A token runs at mostremote_server.max_concurrent_per_tokenrequests at once, 2 by default, and one more is 429. A request keeps its place until its answer is sent whole. An authenticatedOPTIONS *is 405 withAllow: GET, POST. The page and/apianswerGETonly.
A pull request is checked in this order: the token (401), the route (404, or 400 for a part of the
path), the method (405), a place within the token’s limit (429), and whether this process serves the
root’s pulls (503 owner-wait, or 503 while it stops, each with Retry-After: 1). Then the query
(400), then the route’s own checks.
Reading an answer
Every JSON answer and every request body follows these rules. A reader refuses a whole answer that breaks one, and asz’s client then stops reading that server.
- One JSON value, with only white space after it. A server ends each answer with a newline.
- Valid UTF-8. A
\uescape of half a surrogate pair without its other half is refused. - A member name appears once in an object, at every level, unknown members included. Two spellings
of one name, such as
"id"and"\u0069d", are the same name. Names are case sensitive. - A known member is never
null, required or optional. An optional member with no value is absent. - An answer may carry members this page does not list, and a reader passes over them. A request body may not. There an unknown member is 400.
- The numbers this page lists are decimal integers, with no sign, fraction or exponent. Counts,
sizes, sequences and round numbers are unsigned 64-bit. A member it does not list is passed over
whatever its value.
generationis at most 9,223,372,036,854,775,807. - A digest is a full SHA-256 as 64 lower-case hexadecimal characters, as are a lease, a freeze identifier and an archive identifier.
- A tag is a UTC time to the nanosecond, written
YYYYMMDDTHHMMSS.NNNNNNNNNZwith exactly nine fractional digits, such as20261026T081630.123456789Z. Any other spelling of the same time is refused. Tags sort by their bytes in time order. - Bounds, checked while the text is read: at most 16 nested objects and arrays, 4,096 members in
one object, and 65,536 bytes in one decoded string or member name. A manifest lists at most
1,000,000 files. A listing’s
limitis 1 to 500, and its cursor at most 4,096 bytes once decoded.
Names
A conversation, a session, an owner and each part of a file path is a name any machine can hold. It
is valid UTF-8 that JSON encoding and decoding give back unchanged, 1 to 255 bytes, and not . or
... It holds no character from U+0000 to U+001F or U+007F to U+009F, and none of
< > : " | ? * \ /. It does not end in a dot or a space, or start with .tmp- in any letter case.
It is not a device name in any letter case, with or without an extension, spaces before the
extension ignored: CON, PRN, AUX, NUL, CONIN$, CONOUT$, COM1 to COM9, LPT1 to
LPT9, or COM or LPT followed by ¹, ² or ³. A file path is such names joined by /, at
most 1,024 bytes, with no / at either end.
Problems
Every refusal is one JSON object, sent as application/problem+json. asz’s client reads a problem
only with that content type. An answer of 408, 429, 500, 502, 503 or 504 without it, such as a
proxy’s, is a status alone, tried again. Any other refusal without a problem that reads, such as a
proxy’s HTML 404, stops the server.
| Member | Type | Required | Meaning |
|---|---|---|---|
type |
string | yes | about:blank, or one of the typed problems below |
title |
string | yes | the text of the HTTP status |
detail |
string | yes | what went wrong, for a person |
status |
number | yes | 400 to 599. A reader refuses a problem whose status is not the status it came with |
conversation, freeze |
string | on freeze-build-failed |
the conversation and the freeze polled. Absent on any other type |
snapshot, archive |
string | on archive-build-failed |
the tag and the archive asked for. Absent on any other type |
attempt |
number | on both | the attempt that failed: 3 or more on freeze-build-failed, 1 or more on archive-build-failed |
asz’s client stops the server on a freeze-build-failed or an archive-build-failed that does not
name what it asked for, or whose attempt is out of those ranges.
type |
Status | When |
|---|---|---|
urn:asz:pull:owner-wait |
503 | another process serves this root’s pulls. Retry-After: 1 |
urn:asz:pull:storage-state |
500 | the server’s own state could not be read or written |
urn:asz:pull:source-read |
503 | a freeze could not read the evidence, and nothing showed it invalid |
urn:asz:pull:source-changing |
503 | the evidence changed under each of a freeze’s four reads |
urn:asz:pull:source-invalid |
422 | the evidence is not valid while it holds still, or a freeze found more than 1,000,000 files |
urn:asz:pull:freeze-build-failed |
500 | a freeze’s worker stopped without finishing, and the next attempt would be 3. attempt is 3 or more |
urn:asz:pull:archive-build-failed |
500 | an archive’s build failed, or its worker stopped and the next attempt would be 3 or later |
A problem of one of these types under any other status breaks this page’s rules. A conversation whose
rounds directory holds more entries than the bound is refused before a freeze starts, with
storage-state, since the server cannot list it.
A failed freeze answers the problem it failed with: a source problem, freeze-build-failed,
storage-state, or a plain 404, 409 or 410. Every poll of that freeze gives the same answer. Only a
GET without Asz-Pull-Freeze starts a new freeze.
The listing
GET /pull/v1/conversations answers one page. Each parameter comes at most once. An unknown
parameter, one given twice, or a value out of range is 400.
| Parameter | Value |
|---|---|
since, until |
a positive duration counted back from the server’s clock, made of numbers and units from ns, us, ms, s, m and h, such as 24h or 1h30m, or an RFC 3339 time. A conversation is kept when its last activity, the head round’s session_through_time, is at or after since and before until. With either one, a conversation with no activity time is left out. since after until is 400 |
runtime |
the session’s dialect, such as claude-code/1, compared byte for byte |
match |
text the title holds, compared under Unicode simple case folding, with no normalization |
min_talks |
at least this many talks: digits only, with no leading zero but in 0 |
limit |
rows on a page, 1 to 500, 50 when absent |
after |
the next of the page before |
| Member | Type | Required | Meaning |
|---|---|---|---|
schema |
string | yes | pull/1 |
rows |
array of rows | yes | newest activity first, then by id in byte order. Rows with no activity time come last |
skipped |
array | yes | each conversation this scan could not read, as id and reason, both strings and required. Empty when none |
next |
string | no | the after of the next page. Absent on the last page, and never empty |
| Row member | Type | Required | Meaning |
|---|---|---|---|
id, session |
name | yes | the conversation, and the session its rounds were assembled from |
runtime |
string | yes | the dialect of the session’s first landed transcript, else of the first landed file that names one, else empty |
title |
string | yes | the head round’s title, empty when it has none |
round, digest |
number, digest | yes | the head round, from 1, and its commit digest |
talks, steps, streams, segments, unresolved |
number | yes | the head round’s counts, 0 when its header leaves one out |
session_from_time, session_through_time |
string | no | RFC 3339 times from the head round’s header: when the session began, and its last activity |
changes, lines_added, lines_removed, llm_calls, subagents, bash_runs |
number | no | the head round’s counts, absent when its header does not carry them |
A row is read from the head round’s header and commit, and from landed files’ headers, never by
folding, so it promises nothing about its rounds’ integrity. A skipped id is display text, never a
name to ask for, each byte that is not valid UTF-8 written \xHH. Every page carries all the skipped
entries its scan found, sorted by id, and they count toward no limit. A conversation with no rounds, a retired
session, or one a removal is taking is left out without a word. A root that cannot be listed is 500
storage-state.
The cursor. A client treats next as opaque, and sends it back with exactly the parameters it
gave for the first page, or the answer is 400. asz writes unpadded base64url of a JSON object of six
members. v is 1. filters holds the six filters as given, "" for an absent text filter,
min_talks 0 and limit 50. since and until are the bounds the first page resolved, RFC 3339 in
UTC, or "", so a duration is resolved once. time and id are the last row’s. A cursor that is not
canonical, lacks a member or holds another, or decodes to more than 4,096 bytes is 400, and so are
filters too large for a cursor. A head that moves between pages can show a conversation twice or pass
one over.
The manifest
GET .../manifest freezes the conversation’s head before it answers. The first GET carries no
Asz-Pull-Freeze. It joins the freeze this conversation has building, or starts one, and answers 202
with the freeze’s identifier. A poll is the same GET with exactly one Asz-Pull-Freeze header naming
that freeze. It answers 202 while the freeze builds, then the freeze’s result. A freeze whose evidence
has the same file-list digest as the conversation’s newest snapshot answers that snapshot.
| Status | When | asz’s client |
|---|---|---|
| 202 | the freeze is building | polls again after one second |
| 200 | the freeze is ready. Each 200 grants a new lease | reads the manifest |
| 400 | a query, an Asz-Pull-Freeze that is not one value of 64 lower-case hexadecimal characters, or a freeze of another conversation |
stops the conversation |
| 403 | the token was taken out of the tokens file before the lease was granted | stops the server |
| 404 | the conversation has no rounds, on a first GET or as a freeze’s failure | stops the conversation |
| 409 | no such freeze, a ready freeze whose snapshot is gone or expired, or a freeze that failed because a removal of the session’s files was in progress | drops the freeze and sends a first GET, at most three times for one conversation. Outside an archive fetch, that GET has a fresh budget |
| 410 | the freeze failed because the session was retired | stops the conversation |
| 422 | source-invalid |
stops the conversation |
| 429 | a poll found the freeze with no worker and no free place to run one | tries again |
| 500 | storage-state |
stops the conversation |
| 500 | freeze-build-failed |
checks conversation, freeze and attempt, then sends a first GET |
| 503 | owner-wait |
tries again |
| 503 | source-read or source-changing |
tries again. To a poll it is the freeze’s saved failure, so the next try is a first GET |
The server forgets a failed freeze remote_server.snapshot_ttl after it failed, and a ready one once
its snapshot is gone. A poll of either is then 409.
| 202 member | Type | Required | Meaning |
|---|---|---|---|
schema |
string | yes | pull/1 |
conversation |
name | yes | the conversation asked for. A reader refuses any other |
freeze |
string | yes | the freeze’s identifier, 64 lower-case hexadecimal characters. A poll’s answer names the freeze polled |
state |
string | yes | building |
The 200 answer, for the snapshot of the worked example:
{"schema":"pull/1","conversation":"0438c73b","session":"0438c73b",
"round":{"round":1,"digest":"fbd9789139ecf53daf3c4baea3da2a5deeb5a883f520b4c2a3198dd1273afc54","through_seq":1},
"generation":0,"formats":{"sd":"sd/1","sf":"sf/1"},
"files":[{"file":"0438c73b/streams/main/transcript-20261020T081630.000000000Z-000001.sd","kind":"landed",
"bytes":1834,"digest":"d38c7d7f3dc559ff67f30e29404a65716bbb3fbe99ae34b7e7e4132e7464f901","seq":1},
{"file":"_conversations/0438c73b/rounds/r000001-fbd9789139ec.sf","kind":"round",
"bytes":2210,"digest":"da52fe0c9181a81d7288352f43b0dcb3c6947d28fe6e0f9fd825e6cd78e996f5","round":1}],
"snapshot":"20261026T081630.123456789Z",
"lease":"5b0e6a2f9d1c4e8b7a3f2d6c1e9b8a7f6e5d4c3b2a1f0e9d8c7b6a5f4e3d2c1b"}
| 200 member | Type | Required | Meaning |
|---|---|---|---|
schema |
string | yes | pull/1 |
conversation |
name | yes | the conversation asked for |
session |
name | yes | its session, which never starts with _ |
round |
object | yes | the head, with three required members: round, its number from 1, digest, its commit digest, and through_seq, the last landed sequence it reaches, which can be 0 |
generation |
number | yes | the session’s removal generation when the server read it. It grows when retention removes files, and is not part of the digest |
formats |
object | yes | sd and sf, both required, sd/1 and sf/1. A reader refuses a version it does not read |
files |
array | yes | 1 to 1,000,000 file entries, in strictly ascending byte order of file |
snapshot |
string | yes | the snapshot’s tag |
lease |
string | yes | the lease this answer granted, 64 lower-case hexadecimal characters |
The saved manifest is the same object without snapshot and lease. Each snapshot keeps it as
its _manifest, at either end. A reader of a saved manifest passes over those two members.
| Entry member | Type | Kinds | Meaning |
|---|---|---|---|
file |
path | all | the path within the snapshot, with / between names |
kind |
string | all | round, landed, skeleton or store |
bytes |
number | all | the stored file’s size, from 1 to 9,223,372,036,854,775,807. Empty evidence is invalid |
digest |
digest | all | the SHA-256 of the stored bytes |
round |
number | round |
the round’s number, from 1 |
seq |
number | landed, skeleton |
the sequence, from 1 |
landed_digest |
digest | skeleton |
the SHA-256 of the landed file the skeleton replaced |
content |
digest | store |
the SHA-256 of the entry’s bytes once decompressed |
Every member is required for the kinds it lists. A member of another kind, or an unknown kind, refuses the manifest.
| Kind | Path |
|---|---|
round |
_conversations/<conversation>/rounds/r<number>-<prefix>.sf. <number> is six or more digits and equals round. <prefix> is the first twelve characters of the round’s commit digest |
landed |
<session>/streams/<owner>/<name>.sd, <session>/runs/<owner>/<name>.sd or <session>/provider_body/<name>.sd. <name> is <kind>-<tag>-<sequence>, as Landed files names it. <kind> is transcript, meta, journal, manifest, script, changes, execution or provider_body, and <sequence> is six or more digits equal to seq |
skeleton |
the same places and names, ending in .sk. The file is one gzip member holding the skeleton |
store |
<session>/provider/tips/<generation>/<hex>.gz or <session>/provider/pieces/<content>.gz, an entry of the session’s provider store. <generation> is a whole number from 1 with no leading zero, and <hex> 64 lower-case hexadecimal characters. The file is one gzip member |
A reader also checks the whole list. Every entry but a round is under <session>/. No path starts
with _copy, _manifest, _root, _leases or _work in any letter case, the names a snapshot
keeps for itself. No two paths, and no two directories on them, are equal under Unicode simple case
folding, so s/streams/Main/a.sd and s/streams/main/b.sd together are refused. The rounds are 1 to
the head’s round, each once, and the head round’s file is named for the head’s digest. The
landed files and skeletons hold the sequences 1 to through_seq, each once.
The file-list digest
The manifest’s digest is the SHA-256 of its canonical file list. Two manifests list the same
evidence exactly when their digests are equal. The list is one line per entry, sorted ascending by
the bytes of file. A line holds file, kind, seq, round, bytes, digest, landed_digest
and content, in this order, separated by a tab (byte 9), and ends with a newline (byte 10), the last
line too. Numbers are decimal with no leading zero. A member the entry’s kind does not have is a
single -. Text is its exact UTF-8 bytes, with no quotes or escapes, and nothing else is written. The
order of the entries in the answer, unknown members, generation, snapshot and lease change
nothing. The two entries above give these lines, \t standing for the tab and \n for the newline.
The landed file comes first, since 0 (byte 48) sorts before _ (byte 95):
0438c73b/streams/main/transcript-20261020T081630.000000000Z-000001.sd\tlanded\t1\t-\t1834\td38c7d7f3dc559ff67f30e29404a65716bbb3fbe99ae34b7e7e4132e7464f901\t-\t-\n
_conversations/0438c73b/rounds/r000001-fbd9789139ec.sf\tround\t-\t1\t2210\tda52fe0c9181a81d7288352f43b0dcb3c6947d28fe6e0f9fd825e6cd78e996f5\t-\t-\n
Their SHA-256, the manifest’s digest, is 3b71076bf358aef2572af4a1f4f4af93ee561c7d14b95f024c3ad68ce701c209.
Archives
POST .../snapshots/{tag}/archives asks for an archive of some of the snapshot’s files. Its body:
| Member | Type | Required | Meaning |
|---|---|---|---|
lease |
string | yes | the lease the manifest granted |
files |
array of paths | yes | the files wanted, each a file of the manifest, each once, in any order. At least one, and no more than the manifest lists |
restart |
number | no | the failed attempt to start again after, from 1 |
The identifier. An archive is named by its set of files. Sort the paths ascending by their
bytes, write each followed by a newline (byte 10), the last too, and take the SHA-256 of those bytes
and nothing else. The two files of the worked example give
33dbf51fc530f07d28c2e42f54ef6449c03a3a4f37cec1ee2514b0a66d734804. One set in one snapshot is one
archive, so asking again joins it or answers its state. A client computes the identifier itself, and
refuses an answer that names another.
GET .../snapshots/{tag}/archives/{id} answers an archive’s state, and carries exactly one
Asz-Pull-Lease header, the lease. Both routes answer:
| Status | When |
|---|---|
| 202 | the archive is building, queued or running |
| 200 | the archive is ready. ETag is its digest, quoted |
| 400 | a body or a header that breaks the rules, or a restart that does not apply |
| 403 | the lease is missing, another token’s or released, or the token was taken out of the tokens file |
| 404 | a name the manifest does not list, to a POST. No such archive, to a GET |
| 410 | the snapshot is gone |
| 429 | a GET found the archive building with no worker and no free place to run one |
| 500 | archive-build-failed, the saved failure of the current attempt, or storage-state |
| 503 | another request is recording the same archive at this moment, to a POST. Retry-After: 1 |
| Member | Type | Required | Meaning |
|---|---|---|---|
schema |
string | yes | pull/1 |
snapshot |
string | yes | the tag asked for. A reader refuses any other |
archive |
string | yes | the identifier asked for. A reader refuses any other |
state |
string | yes | building with 202, ready with 200 |
bytes |
number | with 200 | the archive’s size, from 1 to 9,223,372,036,854,775,807 |
digest |
digest | with 200 | the SHA-256 of the archive’s bytes. A client refuses a ready answer whose ETag is not this, quoted |
A failed archive answers its failure to every POST and GET until a POST restarts it. Attempts
count from 1. A worker that stops is followed by the next attempt, and when that would be attempt 3
or later, the archive fails with archive-build-failed instead. A POST with restart: N:
- When the current attempt N failed, keeps that failure, starts attempt N + 1 and answers 202.
- When the current attempt is past N and the failure of N was kept, answers the current state. So the same restart can be sent again.
- When the current attempt is N and did not fail, is 500
storage-state. - With no archive yet, or in any other case, is 400.
asz’s client checks snapshot, archive and attempt of an archive-build-failed, then sends the
same POST with restart set to its attempt.
The archive’s bytes
An archive is one POSIX tar inside one gzip member. Its entries are the files asked for, in
ascending byte order of their paths. Each is a regular file named by its file, of its bytes,
holding the stored bytes exactly. A name or a size a plain tar header cannot hold goes in a PAX
extended header. asz writes mode 0444, owner and group 0, no owner names, and the Unix epoch as the
time. The archive’s digest is the SHA-256 of all its bytes, the tar’s end and the gzip trailer
included, and bytes is their count. A reader checks the whole download’s size and digest first,
then reads the archive under these rules, which the server also applies to what it built:
- One gzip member, read to its checksum and trailer, with nothing after it, not even an empty member.
- The decompressed bytes stay within the sizes of the files asked for, plus 4,096 bytes for each file and 1,024 bytes for the end. More is an error.
- Only regular files. Each is a path asked for, once, of the size and the SHA-256 the manifest lists, in any order, and every path asked for is there. A link, a directory or another type is refused.
- Mode, owner and times are ignored. No header decides where a file goes. The manifest does.
- Bytes after the tar’s end are read within the bound and never extracted.
Before a client uses the files, it checks the evidence itself: every landed file and skeleton read
to its end, its header bound to its path and the manifest, every store entry decompressed to its
content, and the rounds verified as one chain over the files, as Reading it
says. Every provider body record of a landed file is rebuilt, in landing order, from its own
representation against the snapshot’s store, each occurrence of a repeated record too, unless the
body holds or cites a piece too large to land, which no reader can rebuild and the format allows. A
skeleton’s records are checked for their structure only. A tip the store holds must be the body of
the record it is named for, when that record is in the snapshot. Matching file digests alone do not
accept a snapshot.
Ranges
GET .../snapshots/{tag}/archives/{id}/data answers one range of a ready archive. It carries
exactly one of each header:
| Header | Value |
|---|---|
Asz-Pull-Lease |
the lease |
Range |
bytes=<first>-<last>: one closed range, decimal with no sign and no leading zero, <last> not before <first>, at most 8,388,608 bytes. No list, open end, suffix or space |
If-Match |
"<digest>", the archive’s digest as one quoted strong entity tag. No *, weak tag or list |
The server checks in this order:
Range, thenIf-Match, thenAsz-Pull-Lease. A missing or malformed one is 400.- The snapshot, which is 410 when gone, then the lease, which is 403.
- The archive: 404 when there is none, 202 with its state while it builds, 429 when it builds with no worker and no free place to run one, or its problem when it failed.
If-Matchagainst the ready archive’s digest. Another digest is 412, even for a range past the end of the archive.- The range against the archive’s size. A
<last>at or past the size is 416, withContent-Range: bytes */<size>.
Otherwise the answer is 206 with Content-Type: application/gzip, Content-Length of <last> -
<first> + 1, Content-Range: bytes <first>-<last>/<size>, Accept-Ranges: bytes and
ETag: "<digest>", and no Content-Encoding. Its body is exactly bytes <first> to <last>.
asz’s client sends Accept-Encoding: identity. It asks for ranges in order from byte 0, each of at
most 8,388,608 bytes, and keeps only a contiguous prefix. A 206 whose ETag, Content-Range or
Content-Length is not what it asked for, any Content-Encoding, a 200, or a body longer than
Content-Length stops the server. A body that ends early, or sends no byte for sixty seconds, is
cut: the client keeps what came, and the next range starts at the first byte it lacks and ends where
the cut one was to end. An error the connection reports with the last bytes or after them, such as
a length its frames do not match, fails the range: its bytes are dropped, and it is asked again
whole. Any other answer is read
within two minutes. After a 412 with its problem, a 202 that names the snapshot and archive asked
for, or a 500 archive-build-failed, it reads the state, and a changed size or digest drops the
prefix. A 500 storage-state stops the conversation. Once every byte is there, a size or a digest
that does not match stops the server.
Leases and lifetime
A lease belongs to one snapshot and to the token that took it, and each 200 of the manifest route
grants a new one. A snapshot route checks the snapshot first, which is 410 when it was never
published, was removed or expired. It then checks the lease, which is 403 when it is missing, another
token’s or released, or when its token was taken out of the tokens file. done alone answers 204 for
a lease this token released.
Each request that passes records a use of the snapshot, and a request still being answered records
one again from time to time. A snapshot expires remote_server.snapshot_ttl after its last use, and
no answer says when. An open lease does not keep it from expiring, and work already admitted may
finish after. A newer snapshot of the conversation supersedes the older ones, and each of those is
removed after its last open lease is released, or when it expires. The newest stays until it expires.
After a 410 while it fetches an archive, asz’s client sends a first GET for the manifest again. With the same file-list digest and the same head, it carries on with the new snapshot and lease, asking for the archive there from byte 0. With anything else, it builds its copy again from the new manifest, with the conversation still held, and with what is left of the archive fetch’s budget. A conversation takes at most three fresh manifests after its first.
Releasing a lease
POST .../snapshots/{tag}/done releases a lease. Its body is exactly {"lease": "<lease>"}.
| Status | When |
|---|---|
| 204 | the lease is released, now or before by this token. No body |
| 400 | a body that breaks the rules |
| 403 | no such lease, or another token’s |
| 410 | the snapshot is gone. asz’s client counts the lease as released |
Releasing again is safe. The first release records a use of the snapshot, and a repeated one records nothing. asz’s client releases a lease once it stops using its manifest, whether it published a copy, found the copy it holds the same, or stopped. A lease it could not release is left to expiry.
Retries and transport
What asz’s client does with an answer, beyond what each route’s table says:
- A failed connection, a cut answer, 408, 429, 500, 502, 503 and 504 are tried again, after about half
a second, then one second, then two seconds for each later try, each varied at random. A
Retry-Afterin seconds or as an HTTP date replaces that wait, up to 60 seconds. A longer one stops the server. - 401 and 403 stop the server, as do a 410 to a listing, an answer that breaks this page’s rules, an archive that fails its checks, a file in it that is not the one the manifest lists, and a digest that does not match. A certificate that does not verify, and a TLS alert from the server, such as for a missing client certificate, stop the server at the first request. Any other refusal stops the listing or the conversation. A retryable answer with a problem is read as a problem, and one that does not read stops the server.
- A failure of the client’s own storage, such as a full disk while an archive arrives, stops the conversation and is never tried again.
- Failures count against a budget of four. A listing page, a manifest with its polls, and a release each have one. An archive fetch has one, shared by every request it makes, a fresh manifest included, and a copy built again from a changed list goes on with what is left of it. A 412 counts, and each failed attempt of a freeze or an archive counts once. The fourth failure stops the server. A poll, and a range that succeeds, count nothing.
- No redirect is followed, since a token could go with it to another host. At most two requests run at once for one server address and token.
HTTP/1.1 is served, and HTTP/2 when the address serves TLS and both ends support it. TLS is 1.2 or
later. With remote_server.tls.client_ca_file, the server requires a client certificate that
authority signed. No request waits open for a freeze or a build: a client polls. A server gives a
request body 30 seconds, and each write of at most 32 KiB of an answer 60 seconds. asz’s client gives
a JSON request two minutes in all, and goes direct or through an http:// proxy, never https://.