# Wire format specification: TCP v2 and secured v4

Normative description of the bytes exchanged between `scribe` and
`scribe-server`, and of the auth protocol SAU2 that storage servers, gateways,
and clients use to reach `scribe-auth`. Reference implementation:
`crates/scribe-protocol-tcp` (`frame.rs`, `wire/`, `auth.rs`, `client.rs`,
`server/`) and `crates/scribe-auth/src/wire.rs`. Logical semantics live in
`crates/scribe-api`; another protocol may encode the same operations
differently without changing storage or operation identity. Scribe is unreleased;
protocol layouts may change between development revisions. Use matching client
and server revisions.

## 1. Transport

TCP, either plaintext or inside TLS 1.3. One control connection per client;
up to four data connections bound to the same session. Exactly one request
is in flight per connection; the response carries the request id back. No
multiplexing. Default listen `tcp://127.0.0.1:7447`; non-loopback binding
in plaintext mode requires explicit configuration.

An authenticated server (see [auth.md](/docs/auth.md)) accepts only TLS and only
protocol 4: the same frames, codecs, and opcodes as protocol 2, with an
extended hello (section 4). It never negotiates plaintext or protocol 2.
Plaintext servers speak only protocol 2.

Every TLS endpoint (storage, gateway, auth, and trust discovery) uses TLS 1.3
only and offers and accepts only the hybrid post-quantum key exchange
`X25519MLKEM768`; a peer without it cannot connect. Callers prove their
identity with a TLS client certificate: a storage server or gateway accepts
only *access certificates* issued by auth's access CA, and auth accepts only
*credential certificates* issued by its credential CA. TLS 1.3
CertificateVerify binds the proof to the connection, so no bearer secret
crosses any Scribe wire. A client without a certificate may still connect
anonymously. See
[identity, device keys, and certificates](/docs/auth.md#identity-device-keys-and-certificates).

Timeouts: the server waits 10 s for the first frame (handshake) and then
applies an inactivity timeout (default 300 s) between frames; there is no
total limit on a long transfer. The timeout covers a whole session: a control
connection and the data connections bound to it are closed for silence only
once none of them has had a request in progress for that long, so a control
connection waiting while its data connections transfer stays open. The client
applies a 300 s read/write timeout and a 10 s connect timeout. A client that
keeps an idle connection for later requests, as a gateway does with its
upstream connections, replaces it once it has been silent for half the
inactivity timeout the server announced in its handshake reply, before sending
anything on it.

## 2. Frame

```
offset size field
0      4    magic "SCR1"
4      2    opcode                u16 LE
6      2    flags                 u16 LE   bit0 STREAM: object data follows the payload
                                           bit1 ERROR: response payload is an error
8      8    request id            u64 LE   client-assigned; echoed unchanged
16     4    payload length        u32 LE   0 ..= 67,108,864
20     4    reserved              must be 0
24     n    payload
```

The header is always little-endian, whatever the negotiated codec. A receiver
that sees bad magic, unknown flag bits, nonzero reserved bytes, or a length
over the maximum closes the connection: its position in the stream is unknown.

## 3. Codecs and primitives

Payload fields are encoded by the negotiated codec. Codec 1 is little-endian
and is the only production codec. Codec 2 encodes the same layouts big-endian;
it exists so contract tests prove semantics and operation identity are
independent of byte order, and servers accept it only when built for tests
(`allow_test_codec`), never through configuration.

| primitive | encoding |
|---|---|
| u8, bool | one byte; bool is 0 or 1, anything else is malformed |
| u16, u32, u64 | codec byte order |
| str16 / bytes16 | u16 length, then bytes; strings must be UTF-8 |
| str32 / bytes32 | u32 length, then bytes |
| opt_str16 | u8 present, then str16 if 1 |
| digest | 32 raw bytes |
| id16 | 16 raw bytes |
| object id | u8 kind (1 chunk, 2 manifest, 3 change set, 4 commit), digest |
| path | str16 whose bytes must form a canonical path (else `invalid_path`) |
| prefix | str16, empty or a canonical path |
| file state | digest manifest, digest content, u64 size, u8 mode, u64 change |
| opt file state | u8 present, then file state |
| semantic error | u16 code, str16 detail (≤ 4096 bytes) |
| lock info | path, u64 generation, id16 workspace, id16 branch, str16 author, u64 acquired ms |
| submit outcome | id16 operation, u8 ok; if ok: u64 change, digest commit id; else semantic error; then u64 file flushes, u64 flush nanos |

Every count is validated against its limit and against the remaining payload
before allocation. Trailing bytes after a payload are malformed.

## 4. Handshake

Request opcode 1 `hello`, payload always little-endian:

```
u16 protocol version (2, or 4 over TLS) | u8 codec (1 or 2) | u32 capabilities (0 = raw object transfers, 1 = LZ4 object transfers) | u64 bind session (0 = new) | str16 client string (≤ 256)
```

Protocol 4 appends a caller envelope and a binding to the same hello:

```
u8 caller | [bytes16 delegated certificate, only for caller 2] | [32] binding
```

| caller | meaning | identity |
|---|---|---|
| 0 | anonymous | none; any TLS client certificate is ignored and only public repositories are readable |
| 1 | direct | the verified TLS client certificate, which must be an access certificate |
| 2 | delegated | the TLS client certificate is a gateway's access certificate; the user's access certificate follows as DER, 1..16384 bytes |

The hello carries no credential. For caller 2 the server verifies the
forwarded certificate against auth's access CAs itself and refuses one whose
trust domain differs from the gateway's. A storage server's own identity
cannot call storage. Other caller tags, an empty or oversized delegated
certificate, and trailing bytes are `malformed`.

The binding is zero for a control connection and, for a data connection, the
256-bit secret the server returned when the control session was established.
A data connection must present the same caller mode, the same subject and
delegate credentials certified for the same keys, and a live control session.
The maximum handshake payload is 16,692 bytes (see
[request-frame memory admission](/docs/wire-format.md#request-frame-memory-admission)). Anonymous
TLS sessions receive the same random binding protection as authenticated
sessions.

The server resolves the caller from the certificate first, then confirms it
with auth: the credential must be live, the certificate's key must be the key
auth registered for it, and its account must be enabled. For a delegated
caller the gateway's service account must also hold a delegate grant for the
server's namespace. The decision is cached for at most 30 seconds (the
authorization lease), so a revocation takes effect within that time whatever
the certificate's lifetime. See
[gateways and delegation](/docs/auth.md#gateways-and-delegation).

Response opcode 1: `u16 protocol version | u8 codec | u64 session id | u32 inactivity timeout seconds | u8 compression (0 none, 1 LZ4)`.
A client refuses an inactivity timeout of 0.
A protocol-4 control session additionally receives its 32-byte binding secret.

Errors during the handshake are little-endian error frames:
`unsupported_version` for a protocol or codec the server does not accept
(there is no fallback to an older protocol; an older client is simply
refused), `unauthenticated` for a missing, invalid, or revoked certificate or
credential (for example `an access certificate is required`, `invalid
delegated access certificate`), `busy` when the identity already holds its
connection limit, `auth_unavailable` when auth cannot be reached or answers
from a revision older than one the server already saw, `malformed`
otherwise, then the connection closes. A data connection passes the control
session's id in `bind session` and receives the same id back.

## 5. Errors

A response with the ERROR flag carries a semantic error. Codes are the stable
values from `scribe_core::ErrorCode`, also persisted in operation records:

| range | codes |
|---|---|
| 1..99 request | 1 malformed, 2 unsupported_version, 3 limit_exceeded, 4 unauthorized_network, 5 busy, 6 shutting_down, 7 cancelled, 8 unauthenticated, 9 forbidden, 10 auth_unavailable |
| 100..199 repository | 100 repository_not_found, 101 repository_exists, 102 branch_not_found, 103 branch_not_main, 104 workspace_not_found, 105 change_not_found, 106 path_not_found, 107 invalid_path, 108 incarnation_mismatch, 109 workspace_retired |
| 200..299 submission | 200 base_mismatch, 201 already_exists, 202 not_present, 203 locked_by_other, 204 path_collision, 205 object_missing, 206 object_invalid, 207 operation_intent_mismatch, 208 empty_submission, 209 lock_required, 210 invalid_repository_config, 211 merge_incomplete, 212 note_revision_stale |
| 300..399 locks | 300 lock_held, 301 lock_not_held, 302 lock_generation_stale |
| 500..599 storage | 500 storage_failure, 501 integrity_failure, 599 internal |

Codes 1–4, 8–9, and 100–499 are client outcomes and are not server errors.
`forbidden` is returned for any operation the caller's role does not allow,
and repositories the user cannot read are absent from `repo_list`.

## 6. Messages

Request payload → response payload. `repo`, `branch`, `workspace`,
`operation` are id16; `change` fields are u64.

`discover` reports the logical deployment visible through the connected endpoint.
It is available to anonymous and authenticated sessions after the normal hello;
TLS trust and all existing connection, frame, and query admission limits apply.
The request has no payload or stream. The reply is `u8 role | bool authentication
| u32 storage count | str16 namespace × count`: one entry per configured storage
server, in configuration order, naming the auth namespace that server serves.
Role and boolean tags are strict; unknown tags, truncated or trailing bytes, and
invalid counts are malformed. A direct storage endpoint reports one storage
server. A gateway reports its configured 1..64 storage servers, including
offline nodes; this is configuration, not a health sample. `authentication`
describes configured native authentication, not whether this caller logged in.

On authenticated endpoints every label is a namespace (1..64 ASCII letters,
digits, `-`, `_` or `.`); on plaintext endpoints every label is empty. The
distinct labels are the namespaces reachable through the endpoint, and the
repeats say how many servers back each. When a gateway serves several
namespaces, repositories are addressed by name as `<namespace>/<name>` (see
`repo_create` and `repo_info`) and `repo_list` returns names in that form.

A direct endpoint cannot enumerate gateways above it or unrelated storage
servers. Discovery performs no inventory scan and exposes no endpoints,
identities, repositories, paths, or usage. The existing session establishment
requirements still apply, including gateway backend availability. Errors are
returned, never replaced with inferred topology.

Canonical little-endian reply for an authenticated two-storage gateway whose
instances serve `studio` and `publishing`: `02 01 02 00 00 00 06 00 "studio"
0a 00 "publishing"`; unauthenticated direct storage: `01 00 01 00 00 00 00 00`.

Repository names on the wire are at most 193 bytes: a 128-byte storage name,
optionally prefixed by a 64-byte namespace and `/`. Storage names never contain
`/`; the prefix is understood by gateways only. A gateway with one namespace or
a configured default resolves bare names in it; otherwise a bare name is
malformed and the error names the accepted form.

The table below lists the core opcodes. Opcodes 20–36 are specified in their own
sections: 20–25 in section 9 (Branch and merge opcodes), 26–28 in Gateway
inventory and authorization preflights, 29 in Repository lock policy, 30 in
Public repository reads, 31 in Versioned repository mounts, 32–33 in Selective
integration, and 34–36 in Workspace lifecycle.

| op | name | request | response |
|---|---|---|---|
| 37 | discover | (empty) | u8 role (1 storage, 2 gateway), bool authentication configured, u32 configured storage servers, str16 namespace × servers (one per server; empty on plaintext) |
| 2 | server_info | (empty) | u16 protocol, str16 server version, u64 session id, u32 max batch objects, u64 max batch bytes, u32 max page entries, u64 max submit entries |
| 3 | repo_create | str16 name (≤193; `<namespace>/<name>` through a gateway), u8 storage compression (0 none, 1 LZ4; permanent) | repo info |
| 4 | repo_list | u8 has_cursor, optional [16] opaque cursor | u8 has_next, optional [16] cursor, u32 count (≤128), repo info × count |
| 5 | repo_info | u8 1 + id16, or u8 2 + str16 name (≤193) | repo info |
| 6 | workspace_register | repo, workspace, branch, u16 view count, prefix × count, str16 label, u16 tag count (≤ 16), str16 tag (≤ 64 bytes) × count | (empty) |
| 42 | pin_objects | workspace id, intent id, repo, u32 count (≤ 8192), object id × count | u32 count, bool × count |
| 43 | tree_proof | bytes32 tree_page request, selector, u32 path count, str16 paths | content root[32], revision root[32], u32 node count, bytes32 portable revision node × count |
| 7 | presence | repo, u32 count (≤ 8192), object id × count | u32 count, bool × count |
| 8 | put_objects | repo, u32 count (≤ 1024), (u8 kind, digest, u64 length) × count; STREAM flag set; then the concatenated payloads in order (total ≤ 256 MiB) | u32 count, per object: u8 1 stored / 2 already present / 3 rejected + semantic error; then u64 file flushes, u64 dir flushes, u64 flush nanos |
| 9 | submit | repo, id16 expected incarnation, branch, workspace, operation, digest change set, str16 author (≤ 256), str32 message (≤ 65536), bool keep locks | submit outcome |
| 10 | operation_outcome | repo, operation | u8 present, then submit outcome |
| 11 | head | repo, branch | u64 head, id16 incarnation |
| 12 | tree_page | repo, branch, u64 change, prefix, opt_str16 after, u32 limit | u32 count, (path, file state) × count, bool more, content root[32], revision root[32] |
| 13 | changed_paths | repo, branch, u64 from, u64 to (target), prefix, u8 token present (+ bytes32 token), u32 limit | u32 count, (u64 change, path, opt file state as of `to`, bytes32 token) × count, bool more |
| 14 | get_objects | repo, u32 count (≤ 1024), object id × count | STREAM flag set; payload `u32 count`; then per requested id in order: u8 present, u64 original length (zero if absent), followed by object data when present |
| 15 | log | repo, branch, bool follow merges, bool all branches, bool snapshot present (+ u64), opt_str16 path, u8 before present (+ u64), opt_str16 after path, u32 limit | u32 count, per entry: bool ancestry present (+ bool in ancestry), bool history snapshot present (+ u64), u64 change, id16 branch, str16 branch name (≤ 128 bytes), digest commit id, u64 parent, bool merge parent present (+ id16 source branch, u64 source change), bool cherry-pick present (+ id16 source branch, u64 source change), str16 author, str32 message, u64 timestamp ms, u32 entry count, id16 workspace, id16 operation, u8 action present (+ u8 action), opt file state, opt_str16 historical path, bool move present (+ str16 source path, u64 source change) |
| 16 | lock | repo, path, workspace, branch, str16 author | lock info |
| 17 | unlock | repo, path, workspace, u64 generation, bool force | (empty) |
| 18 | locks | repo, prefix, opt_str16 after, u32 limit | u32 count, lock info × count, bool more |
| 19 | ping | (empty) | (empty) |
| 38 | repo_usage | repo | u64 apparent bytes |
| 39 | describe_change | repo, u64 change, u32 expected revision, str16 author (≤ 256), str32 text (≤ 65536) | change note |
| 40 | change_notes | repo, u32 count (≤ 4096), u64 change × count | u32 count, (bool present, change note if present) × count, positional |
| 41 | change_note_history | repo, u64 change | u32 count (≤ 1024), change note × count, oldest first |

Repo info = id16 id, str16 name, id16 main branch, u64 head, id16 incarnation,
u64 created ms, u8 storage compression. Change note = u64 change, u32 revision,
str16 author (≤ 256), u64 edited ms, str32 text (≤ 65536). Page limits are clamped to 1..=4096 entries. `after` and
`token` are opaque continuation values taken from the previous page's last
entry.

Log `follow merges` requires an exact path; otherwise the server returns
`malformed`. With it set, path history follows both branch origins and merge
sources up to their recorded revisions. Without it, only native and inherited
first-parent path history is returned. Both modes follow explicit move sources,
sort descending by change then ascending by historical path, and deduplicate
by `(change, path)`. Without `after path`, `before` is exclusive. With it, the
same change's lexicographically later paths are included as well as older changes.
`after path` requires both a path query and `before`, otherwise `malformed`.
Pass the last row's change and historical path together to continue without
skipping tied rows. Pagination filters results, not newer merge or move edges
needed to discover older revisions.
Branch names are current display names; branch IDs identify where revisions
were accepted. Merge parents describe ancestry, not per-file resolution choices.
History exceeding its 100,000 metadata-visit or 32 MiB lineage-path budget
returns `limit_exceeded`.

All-branch log requests require a path and `follow merges = true`. An optional
snapshot ceiling is allowed only in this mode; it must be within the selected
branch's lifetime and no later than the repository head. Otherwise the server
returns `malformed`. Without a ceiling, the server pins the repository head in
the query's single metadata read transaction. Every returned row carries that
ceiling and a commit-ancestry membership boolean; ordinary logs carry neither.
The same `(before, after path)` cursor applies; clients repeat the snapshot
ceiling to keep file selection and membership stable across pages. Branch names
remain current display metadata. Empty results need no continuation.

This mode selects the latest stored revision (including deletion) at the query
path and snapshot, then traverses the `file_lineage` adjacency index in both
directions. Ordinary adds start new incarnations; names and content hashes are
not identity links. Fork snapshots retain original revisions. Per-file merge
entries connect their predecessors, while ancestry-only merges affect membership
without inventing file identity links. Traversal excludes nodes beyond the
snapshot, independently of the pagination cursor, and fails explicitly on its
read/path budget rather than returning an incomplete page.

## 7. Streaming rules

The client selects transfer compression in Hello; the server echoes the
accepted choice in HelloOk. Unknown capability bits are rejected and the
client rejects a different echoed choice. Control frames and other metadata
are not compressed. Bound data connections negotiate their own
transfer policy (the client carries its existing preference forward).

With compression 0, object data is the original byte stream. With compression
1, each object is a sequence of bounded blocks with the same layout specified
in the object-envelope section of `on-disk-format.md`, without the stored
object's `u64 original length` prefix. Announced object lengths and admission
budgets always count original bytes. Block headers are little endian even
with the big-endian test metadata codec. Decoder allocation is bounded to
256 KiB per block; malformed blocks close the connection. Empty objects have
no blocks. Receivers drain unfinished objects before reading the next item.

Transfer compression is independent of repository storage. Stored compressed
objects are decoded before being passed to the adapter; the
adapter recompresses them when transfer compression is enabled.
Raw storage and raw transfers retain the direct file-to-socket path.

Per-operation rules:

- `put_objects`: the sender announces every length up front and then writes
  object data that decodes to exactly those lengths. The server hashes the original bytes while receiving, validates manifests
  (all chunks present, in the store or earlier in the same batch) and change
  sets (all manifests present), publishes the batch durably, and only then
  answers. If a request-level failure occurs before all announced bytes were
  consumed, the server sends the error and closes the connection; the client
  reconnects and retries (uploads are idempotent).
- `get_objects`: the response header commits to a count; each item is
  self-describing so absent objects are reported inline. A receiver that stops
  consuming an item must drain the remaining bytes to stay in sync.
- Resume is at chunk granularity: an uploading client asks `pin_objects` again and re-sends
  only absent chunks.
- Byte accounting: the harness reports "protocol bytes" as header + payload +
  stream bytes as seen by the client (`TcpRemote::bytes_sent/received`).

## 8. Semantics every adapter must preserve

- Every request naming a branch is checked against the repository's branch
  table before any mutation; `branch_not_found` names an unknown id.
- A mutating response is sent only after the durable commit. A lost response
  leaves the outcome unknown; `operation_outcome` resolves it.
- The operation fingerprint is computed from the semantic request, so a
  submit replayed through another adapter or codec resolves to the same
  accepted change; a replay with different intent is `operation_intent_mismatch`.
  Attribution is not intent: the author is excluded, because an authenticated
  server replaces it with the principal's display name before publication.
- Submit (including merge submit) carries the expected repository incarnation,
  included in its semantic fingerprint immediately after the repository ID.
  A new operation from an older incarnation is refused with
  `incarnation_mismatch`, even if its numeric expected bases match reused change
  numbers after restore. An existing operation still resolves to its original
  result through `operation_outcome`.
- Locks, budgets, and publication gates are shared server state, identical
  across concurrently connected adapters.


## 9. Branch and merge opcodes

Protocol 2 includes the branch and merge operations below. Protocol 1 is
unsupported: its hello receives `unsupported_version`, with no client fallback.

| Opcode | Operation |
|---|---|
| 20 | branch list |
| 21 | branch create/rename with persisted operation id |
| 22 | branch operation outcome |
| 23 | pinned merge parents and merge-base discovery |
| 24 | submit with expected target head, pinned source parent, and pinned merge base |

Opcode 21 is `[16] repository ‖ [16] incarnation ‖ [16] operation ‖
[16] branch ‖ str16 name ‖ u8 action`. Create (1) appends `[16] source
branch ‖ u64 source change`; rename (2) appends `str16 expected name`.
The incarnation is included in the semantic fingerprint after the repository ID.
A new intent from an old incarnation receives `incarnation_mismatch` before any
branch reservation or outcome is written. Exact recorded outcomes still replay;
an exact snapshot intent retained in the backup can finish because its pinned
source ancestry is also retained. Changed intent with a reused operation ID is
always refused, including a changed incarnation.

Opcode 24 is `bytes32 submit request ‖ u64 target head ‖ [16] source branch ‖
u64 source head ‖ [16] base branch ‖ u64 base`. The base is the merge base the
client planned against; it is part of the semantic fingerprint. Before the
publication gate the server recomputes the merge base from its own history,
refuses a base that disagrees with it (`base_mismatch`), and verifies
that the change set covers every path the source changed since that base,
returning `merge_incomplete` otherwise (see `docs/branches.md`). Neither
rejection is recorded as an operation outcome. `cherry_pick_submit` carries no
base: the picked commit's parent is derived server-side.

The canonical codecs are in `scribe-protocol-tcp/src/branches.rs`.
Names, counts, input lengths, and trailing bytes are checked. A merge submission
uses the existing accepted/rejected outcome encoding and `operation_outcome`
for retries. Data connections negotiate the same version as their bound session.


Opcode 13 (`changed_paths`) returns each selected path at most once, ordered
by exact canonical path. Its change is the last native event in `(min(from, to), max(from, to)]`,
and its state is pinned at `to` (absent when the target has no file). Reverse
ranges are supported; the event change can exceed `to`. Fork-inherited events
are excluded. Continuation tokens are opaque; repeat the same branch, interval,
and prefix on subsequent pages. The field encodings remain unchanged.

Opcode 25 (`MergeRead`) is read-only and requires repository schema 3.
Its request begins with RepositoryId and a u8 query tag:

- 1: u64 left revision, u64 right revision, optional str16 path cursor. A page
  traverses the larger first-parent revision until the chains meet, at most
  128 commits or 1,024 changed paths. Reply: tag, updated left/right revisions,
  optional cursor, u32 path count, str16 paths. Equal revisions signal completion.
- 2: BranchId, u64 revision, u32 count, str16 canonical paths (maximum 1,024).
  Reply: tag, u32 count, optional FileStateAt for each input in the same order.
- 3: BranchId, u64 revision, str16 canonical path, optional str16 folded cursor.
  Reply: tag, optional next cursor, u32 count, (str16 path, FileStateAt) entries.
  The first page includes exact/ancestor matches. Each page examines at most
  1,024 historical descendant paths, including deleted paths. An empty page can
  still carry a continuation cursor. Replies are bounded to 5,120 entries,
  allowing the ancestors of a maximum-length canonical path.
- 4: BranchId, u64 revision, base BranchId, u64 base revision, u32 count, str16
  canonical paths (maximum 1,024). Reply: tag, u32 count, one origin per input:
  u8 0 (no recorded base file), u8 1 + str16 base path (unique origin), or u8 2
  (multiple base-file origins; automatic matching is unsafe). Both snapshots
  must lie within their branches' lifetimes. The entire batch shares the
  100,000-visit / 32 MiB lineage-path budget and fails rather than truncating.
  The query follows selected-side predecessors. Forward edges are allowed only
  within the pinned base's commit ancestry, to recognize bases reached by merges
  that kept different bytes or names. Other branches and later descendants do
  not establish a merge origin. Results use recorded revision relationships,
  never content equality or filename similarity.

- 5: BranchId, u64 from revision, u64 to revision, u32 count, str16 canonical
  paths (maximum 1,024). Reply: tag and boolean. True means the earlier revision
  is in the later target ancestry and none of these paths, their folded aliases,
  ancestors or descendants changed between the revisions. Tombstones count as
  changes. This uses snapshot change summaries, without scanning unrelated
  commits. Both revisions must be within the branch lifetime. Publication still
  checks the dependencies again under the repository acceptance rules.

Optional strings are a boolean presence byte followed by str16 when present.
Numeric fields follow the negotiated codec; the deployed codec is little endian.
Malformed, oversized and trailing payloads are rejected. All reads are
repository-scoped and enforce branch/revision bounds.

## 10. Upload admission under protocol 4

On an authenticated connection `put_objects` is answered with an admission
response before the client sends the streamed payload, so a reader cannot
force the server to consume an upload it will reject. Object payloads pass
through TLS; the zero-copy `sendfile` path is used only on plaintext
connections.

## Gateway inventory and authorization preflights (opcodes 26–28)

These additive operations use the existing TCP v2/plaintext and v4/TLS handshake,
framing and negotiated codec. Existing operation payloads and canonical object
bytes are unchanged. A storage server remains usable without a gateway.

`InstanceStatus` (26) request: `u8 has_after` (0 or 1), followed by an exact
16-byte repository ID when present. Response, in order:

1. Exact 16-byte data-root incarnation.
2. `u64 total_repositories`, `u64 used_bytes` (apparent whole-root bytes, populated
   on the first page only).
3. `u8 has_available_bytes` (0 or 1), optional `u64 available_bytes`.
4. `u32 repository_count`, at most 128; each entry is the existing `RepoInfo`
   encoding followed by `u64 apparent_repository_bytes`.
5. `u8 has_next` (0 or 1), optional exact 16-byte exclusive repository-ID cursor.

Entries are ordered by repository ID. The gateway retries inventory if the
identity or total count changes between pages. On authenticated storage the
operation requires a namespace reader or namespace administrator grant for the
server's namespace; a gateway polls it with its own access certificate as a
direct caller. It is read-only and
performs metadata work in the existing bounded blocking storage pool. Gateways
use it for placement and discovery, not to acknowledge repository mutations.

`AuthorizeUpload` (27) request: exact 16-byte repository ID. Successful response:
empty payload. Storage checks the same writer authorization as upload admission.
A gateway uses this before granting its own v4 upload admission. The storage
server repeats admission for the actual upload; this preflight grants no lease.

`AuthorizeRepoCreate` (28) request: `u8 has_namespace`, then `str16 namespace`
when set. Successful response: empty payload. No namespace means the endpoint's
default; an explicit namespace must be one the endpoint serves (storage refuses
any other with `malformed`, a gateway refuses unknown labels the same way and
uses the label to pick the placement pool). Authenticated storage requires
namespace-administrator authorization; plaintext storage permits it and accepts
no namespace. A gateway checks this at the chosen pool before persisting a new
placement. Creation itself repeats authorization at storage.

The inventory byte fixture is in
[`wire/tests.rs`](https://github.com/Flying-Rat/scribe/blob/main/crates/scribe-protocol-tcp/src/wire/tests.rs). Gateways forward all existing repository
operations and stream object bodies; failed upstream transports close the client
connection so unknown mutation outcomes are not presented as definitive failures.


## Repository lock policy (opcode 29)

`LockPolicy` is read-only and requires Reader permission. Request: exact `id16`
repository ID. Response: `u64 config change`, `str32 UTF-8 TOML configuration text` (at most
65,536 bytes). The response uses the negotiated numeric codec. The change is the
last accepted revision of `.scribe/config.toml` on `main`; when absent, change is
zero and text is exactly `version = 1\n`. Existing configs must be valid TOML;
there is no empty-text fallback. The response contains the complete config,
including profiles, although only its locks section governs locking. Profile-only
edits also advance this config-file revision.
No workspace branch, local policy text, or client-supplied matching result can
change this authority. Gate/admission routing treats this as a control read;
object reads and rule compilation run on admitted blocking workers.

An edit fetches this snapshot and uses existing `Lock` requests for matching
files. The server independently evaluates the current policy for every submit,
including merge submissions, and validates ownership in the acceptance
transaction. Missing mandatory ownership returns a durably recorded
`lock_required` outcome; invalid submitted configuration returns
`invalid_repository_config`. Changing the decoded ordered `locks.rules` on main requires Maintainer permission,
including through merges, section removal, or file moves/deletion. Profile-only
and formatting changes that preserve those values require Writer permission.
The shared schema is in [repository configuration](/docs/repository-config.md); the
updated UTF-8 snapshot golden fixture is in `scribe-protocol-tcp/src/wire/tests.rs`.

### Request-frame memory admission

Every request opcode has an exact payload bound, checked against the frame
header before its body is allocated or received. The bound is the largest
encoding the opcode's decoder accepts, derived from its field layout and the
limits in `scribe_core::limits` and `scribe_api::limits`; the reference table
lives in `server/admission.rs` and is verified against maximal encodings in
both codecs. A header announcing more than the bound closes the connection
without reading the body. The general 64 MiB frame ceiling is only a header
sanity bound; no request opcode admits a payload that large.

| opcode | bound (bytes) | derivation |
|---|---|---|
| `hello` (first frame only) | 16,692 | hello fields (273) + `u8 caller` + `bytes16` delegated access certificate (16,384) + 32-byte binding |
| `ping`, `server_info`, `discover` | 0 | empty |
| `authorize_repo_create` | 67 | `u8` + `str16` namespace (64) |
| `head`, `operation_outcome`, `BranchOutcome`, `workspace_remove_outcome` | 32 | two `id16` |
| `branch_list`, `authorize_upload`, `lock_policy`, `repo_usage` | 16 | `id16` |
| `change_note_history` | 24 | `id16` + `u64` |
| `repo_list`, `instance_status` | 17 | `u8` + optional `id16` |
| `repo_create` | 196 | `str16` name (193) + `u8` compression |
| `repo_info` | 196 | `u8` kind + `str16` name (193) |
| `workspace_list` | 38 | `id16` + two `u8` + optional `id16` + `u32` |
| `repository_mounts` | 40 | two `id16` + `u64` |
| `cherry_pick_info` | 56 | three `id16` + `u64` |
| `workspace_remove` | 64 | four `id16` |
| `MergeInfo` | 66 | three `id16` + two optional `u64` |
| `BranchMutate` | 581 | four `id16` + `str16` name (256) + `u8` + `str16` expected name (256) |
| `lock` | 8,500 | `id16` + path (2 + 8,192) + two `id16` + `str16` author (256) |
| `unlock` | 8,235 | `id16` + path + `id16` + `u64` + `u8` |
| `tree_page` | 16,433 | two `id16` + `u64` + prefix + optional after + `u32` |
| `tree_proof` | 1,048,576 | bounded aggregate tree request, selector and optional point paths |
| `locks` | 16,409 | `id16` + prefix + optional after + `u32` |
| `log` | 16,446 | two `id16` + three flags + optional `u64` + optional path + optional `u64` + optional path + `u32` |
| `changed_paths` | 16,507 | two `id16` + two `u64` + prefix + optional `bytes32` token (8,256) + `u32` |
| `change_notes` | 32,788 | `id16` + `u32` + 4,096 × `u64` |
| `get_objects` | 33,812 | `id16` + `u32` + 1,024 × (`u8` + digest) |
| `put_objects` | 42,004 | `id16` + `u32` + 1,024 × (`u8` + digest + `u64`); bodies are streamed, never framed |
| `describe_change` | 65,826 | `id16` + `u64` + `u32` + `str16` author (256) + `str32` text (65,536) |
| `submit` | 65,910 | five `id16` + digest + `str16` author (256) + `str32` message (65,536) |
| `MergeSubmit` | 65,970 | `bytes32` submit + `u64` + `id16` + `u64` + `id16` + `u64` |
| `cherry_pick_submit` | 65,946 | `bytes32` submit + `u64` + `id16` + `u64` |
| `get_chunks` | 65,556 | `id16` + `u32` + 1,024 × two digests |
| `pin_objects` | 270,388 | workspace `id16` + intent `id16` + repo `id16` + `u32` + 8,192 × (`u8` + digest) |
| `presence` | 270,356 | `id16` + `u32` + 8,192 × (`u8` + digest) |
| `workspace_register` | 2,099,030 | three `id16` + `u16` + 256 × path + `str16` label (256) + `u16` + 16 × `str16` tag (64) |
| `MergeRead` | 8,390,725 | `id16` + `u8` + origins query: two `id16` + two `u64` + `u32` + 1,024 × path |

All incoming request bodies share a 128 MiB budget per listener, including
handshakes, incomplete bodies, queued requests, and requests executing on
workers; anonymous TLS connections together hold at most 64 MiB of it. The byte
reservation is acquired before allocation and released when the request
finishes or its read is cancelled. One request is in flight per connection, so a
connection holds at most one payload, and that payload is capped at an eighth of
the shared budget (16 MiB; a quarter of the anonymous budget). Every opcode
bound is below this cap, so no legal request is refused by it, and at least
eight connections (four anonymous) must stall bodies simultaneously to exhaust
the budget for everyone else. All connections of one principal share a quarter
of the listener budget (32 MiB), so at least four accounts must stall bodies at
once to starve everyone else. The principal is the user named by the access
certificate; for a gateway's delegated traffic it is the user, not the gateway.

If a reservation cannot be acquired immediately, the server answers the frame
with a `busy` error carrying its request id, then closes the connection because
the body was never read. The client may reconnect and retry. This budget is
separate from object-transfer, storage-worker, query-worker, and handshake
admission; no started durable commit is cancelled. These rules apply to both
plaintext and TLS connections.

### Handshake admission

Handshake authentication may make a network round trip to the auth service or,
on a gateway, to a backend. It runs on a dedicated bounded handshake pool
(`handshake_workers`, default 8), never on the query or storage pools, so a peer
that presents a syntactically valid certificate cannot occupy request workers
before it is authenticated. Anonymous TLS handshakes additionally take an
anonymous-request permit before authentication work begins.

Cheaper limits are decided before that work, from the TCP source and the
certificate alone:

- A connection is *pending* until it has completed its handshake and delivered
  one complete request frame. One source address (an IPv6 /64 counts as one
  source) may hold at most `max_pending_per_source` pending connections
  (default 16). When the connection table is full, the oldest pending
  connection is closed to admit a newcomer, so unauthenticated peers can delay
  a legitimate client by at most one handshake deadline. A connection is
  refused at accept when its source already holds its share, or when every slot
  carries a request in progress.
- One principal may hold at most `max_connections_per_principal` connections
  (default 64); the next hello is answered `busy` (`too many concurrent
  connections for this identity`) before any auth round trip.

Auth, storage, and gateway listeners share the same pending-connection
bookkeeping (`scribe_core::admission`).


## Public repository reads

Opcode 30 `get_chunks` requests `RepositoryId | u32 count (≤1024) | ([32]
ChunkId | [32] ManifestId) × count`. The streamed response is identical to
`get_objects`, in request order. The secured adapter requires accepted manifest
membership and a matching chunk reference; an invalid witness returns an absent
object. Public-only `get_objects` and `presence` expose accepted manifests,
never arbitrary staged chunks or changesets. Explicit repository grants retain
existing raw-object access.

`repo_list` uses bounded candidate pages. A secured cursor is opaque and scoped to
the connection; finish pagination without interleaving other operations. Empty
pages can still carry continuations. Private and absent repository names produce
the same access error. The gateway applies these rules through backend authorization.

## Auth protocol SAU2

Storage servers, gateways, clients, and the administration commands reach
`scribe-auth` over SAU2, a separate TLS 1.3 protocol (same key-exchange rule as
section 1). It does not change storage TCP framing or canonical objects.
Reference: `crates/scribe-auth/src/wire.rs`. All integers are little-endian.

```
frame   = "SAU2" | u32 payload length (≤ 1 MiB) | payload
request = [16] operation | u16 argc | argc × str16 | u8 has_secret | [str16 secret]
reply   = u16 0 | [16] incarnation | u64 revision | u16 rows | rows × (u16 n | n × str16)
        | u16 code | str16 message (≤ 2048 bytes)
```

The first request string is the verb; the rest are its arguments. An argument
vector or row holds at most 130 strings of at most 4,096 bytes each
(certificates travel as PEM text), and a reply holds at most 256 rows. Only
`login`, `login-outcome`, `admin-login` and `admin-login-outcome` (the
password, 1..1024 bytes) and `users-password` (the new password, 12..1024
bytes) carry a secret; any other verb with a
secret, or one of these without it, is `malformed`. A secret is never part of
an intent digest, an outcome, or an audit row. Error codes are the storage
codes of section 5.

Every successful reply names the auth *incarnation* (random 128-bit, new for
each `init`, `migrate`, and `restore`) and *revision* (u64, advanced by every
applied administrative change and every logout). A storage server refuses an
answer from a lower revision of the incarnation it last saw; see
[rollback protection](/docs/auth.md#rollback-protection).

The caller is identified only by the TLS client certificate, which must be a
credential certificate from auth's credential CA; no credential travels in a
request. A connection without a certificate may call only `trust-bundle`,
`login`, `login-outcome`, `admin-login`, and `admin-login-outcome`. A connection carries any number of sequential
requests. The first must arrive within 3 seconds of accepting the connection
(one deadline for the TLS handshake and the first frame); afterwards the
service closes a connection idle for 60 seconds. An idle connection counts as
pending again until its next complete request, so it can be evicted when the
service is full (see [handshake admission](/docs/wire-format.md#handshake-admission)). Clients pool up to four
connections, reuse one for at most 50 seconds, and retry a request once on a
fresh connection when the service closed a pooled one.

| verb | caller | arguments after the verb → rows |
|---|---|---|
| `trust-bundle` | anyone | none → `[trust domain]`, then one `[credential or access, CA certificate PEM]` row per CA |
| `login`, `login-outcome` | anonymous, with the password | `username, credential id, public key (SPKI hex), label` → `[ok, expires ms, credential certificate PEM]`; `login-outcome` of an unknown operation → no rows |
| `admin-login`, `admin-login-outcome` | anonymous, with the password of an enabled person auth administrator | as `login`; registers an administrator credential (kind `user`, bound to the password generation) that expires after the auth-configured 1–24 hours; a person who is no administrator → `forbidden` after the password check; `admin-login-outcome` of an unknown operation → no rows |
| `access-certificate` | any account credential (not a storage server) | none → `[access certificate PEM, lifetime ms]` for the caller's own key; issuing writes nothing |
| `identity` | a session or automation credential | none → `[user id, username]` |
| `logout`, `session-outcome` | the session or automation credential being revoked | none → `[ok]`; an already revoked, expired, or pruned credential answers `ok` without writing; `session-outcome` of a live credential → no rows |
| `automation-create`, `automation-outcome` | a password session | `credential id, public key, label, expires ms, role` → `[ok, expires ms, credential certificate PEM]` |
| `automation-derive`, `derive-outcome` | an automation credential that is not itself derived | `credential id, public key, label` → `[ok, expires ms, credential certificate PEM]` |
| `authorize` | a storage server credential | `credential, key id, repository, actor credential, actor key id` → `[principal id, name, role, namespace admin, lease ms, public read, namespace reader]` |
| `authorize-batch` | a storage server credential | `credential, key id, actor credential, actor key id, repository…` (1..125 repositories) → `[can read, lease ms]` per repository |
| `public-policy` | a storage server credential | `repository` → `[public or private, visibility revision, lease ms]` |
| administrative verbs | an administrator credential | listed below |

The client generates the device key and the random credential id; a request
carries only the public key as hexadecimal SubjectPublicKeyInfo DER, and auth
records its key id (the BLAKE3 digest of that DER). A public key is registered
at most once. `login`, `admin-login`, `automation-create`, and
`automation-derive` need a nonzero persisted operation id; their intent is the
BLAKE3 digest of the canonical encoding of a zero operation id and the
arguments after the verb (for `admin-login`, the string `admin-login` and then
those arguments, so an id used for one sign-in is refused for the other), so
the matching `-outcome` verb replays the recorded certificate for the same
intent and a reused operation id with a different intent is
`operation_intent_mismatch`. Replay never extends an expiry.

- `automation-create` roles `reader`, `writer`, `maintainer`, and
  `namespace-admin` create credential kinds `automation`, `automation-write`,
  `automation-maintain`, and `automation-admin`. The expiry must be within one
  year. Only a password session may create one, over its own connection.
- `automation-derive` issues a child for another key (a runner's) with the
  parent's kind, account, role ceiling, and expiry; revoking or expiring the
  parent revokes the child. A derived credential cannot derive another.
- An access certificate lives one hour and never beyond its credential's
  expiry. Clients renew it at half its lifetime measured by local elapsed time.

`authorize`, `authorize-batch`, and `public-policy` refuse repositories outside
the calling server's namespace. The caller columns identify the subject of an
access certificate the server verified: its credential id and key id, and for a
delegated connection the gateway's credential id and key id (empty when the
caller connected directly). Auth checks that each credential is live, that the
key id is the registered key, and that its account is enabled; a delegate must
be a live `service-reader` or `service-writer` credential whose account holds a
delegate grant for the server's namespace, otherwise the answer is
`delegate may not act for users in this namespace`. In `authorize-batch` all
four caller columns empty ask for an anonymous caller. In `authorize` an empty
repository asks only for the identity. The role is empty when the caller has
none; it is the lesser of the credential kind's ceiling and the account's
current grants, and only `user`, `session`, and `automation-admin` credentials
keep namespace administration. The lease is at most 30 seconds and never
beyond the caller's, the delegate's, or the server credential's expiry.

Administrative verbs need a credential of kind `user`, held by an enabled
person who is an auth administrator:

```
users-add name id administrator kind       users-password name generation (+ secret)
users-disable name                          users-enable name
users-list cursor                           servers-add name namespace id
servers-disable name                        servers-enable name
servers-list cursor                         credentials-add kind subject credential public-key expires-ms label
credentials-certificate credential          credentials-revoke credential
credentials-list cursor [username]          grants-set | grants-revoke | grants-list
namespace-admins-set | -revoke | -list      namespace-readers-set | -revoke | -list
delegates-set user namespace                delegates-revoke user namespace
delegates-list cursor [namespace]           visibility-get | visibility-set
audit-list cursor                           operation-outcome id
```

`credentials-add` kinds are `user`, `server`, `service-reader`, and
`service-writer`; it and `credentials-certificate` answer
`[credential certificate PEM]`, so a lost answer is recovered by asking again.
A mutation needs a nonzero persisted operation id; the same id with another
actor or intent is `operation_intent_mismatch`, and another administrator's
operation id answers like an unknown one. `users-password` must name a new
password generation. Every administrative request, applied or rejected, is
audited. See [credentials and roles](/docs/auth.md#credentials-and-roles).

## Versioned repository mounts (opcode 31)

`RepositoryMounts` is a read-only operation available through the same native
protocol and gateway. Request: exact `id16 repository`, `id16 branch`, `u64 change`
(40 bytes). Response: `u32 count` (at most 64), then each declaration in mount-name
order as `str16 name` (64 bytes maximum), `str16 path` (canonical path bound),
`str16 repository` (32 lowercase hex digits), `str16 branch` (32 lowercase hex
digits), `str16 revision` (at most 20 digits, canonical u64 decimal). Counts and
lengths use the negotiated codec; production remains little-endian. Duplicate
names, malformed identities/pins, excess counts, truncation and trailing bytes
are rejected. Golden bytes and both numeric codecs are tested.

Only the parent repository's Reader permission is checked. The operation reads
its accepted config at the exact selected branch revision and never contacts
children. Missing configuration returns an empty declaration list. Target
resolution uses separate normal repository/branch/tree reads under the caller’s
credential; it grants no inherited access. No structured-object or commit format
changes are introduced by mount declarations, which remain ordinary TOML bytes.


## Selective integration (opcodes 32 and 33)

`cherry_pick_info` (32) takes `id16 repo, id16 target, id16 source, u64 change`
(56 bytes). It returns two branch-info records (target with live pinned head,
source with selected change), `id16 parent_branch, u64 parent, bool already_applied`.
Only ordinary source commits on the selected branch's first-parent history qualify.
Reader permission is required. Provenance is not merge ancestry.

`cherry_pick_submit` (33) takes `bytes32 ordinary_submit_request, u64 target_head,
id16 source, u64 source_change`. It returns the ordinary SubmitOutcome. Writer,
workspace ownership, lock policy and configuration authorization match submit.
The operation fingerprint is BLAKE3 of `"scribe:cherry-pick-operation:v1\0"`, the
32-byte ordinary submit fingerprint, LE target head, source branch bytes and LE
source change. Changing any of those fields cannot reuse an operation ID.
Target advancement rejects only when integration path dependencies changed;
unrelated commits preserve the planned result. Competing duplicate picks still
produce recorded rejections.

The log result carries optional cherry-pick provenance immediately after its
optional merge parent. There is no wire fallback for a log layout without it, so
native binaries and the SDK/API are deployed together. Object formats remain explicitly versioned to retain accepted history.

## Repository usage (opcode 38)

`RepoUsage` (38) request: exact 16-byte repository ID. Response: `u64` apparent
bytes — the sum of file sizes under the repository's storage directory, sampled
from metadata without reading object contents or following links, the same
measure `InstanceStatus` reports per entry. Unlike `InstanceStatus` it is a
single-repository read for anyone who can read that repository (Reader on
authenticated storage, public-read for anonymous callers, everyone on plaintext
storage); an unknown ID is `repository_not_found`. It runs in the bounded
blocking storage pool and is measured on request, so a gateway forwards it to
the owning node and never answers from its own inventory sample. `discover`
still exposes no usage: it is public and describes configuration, not
repositories. The byte fixture is in
[`wire/tests.rs`](https://github.com/Flying-Rat/scribe/blob/main/crates/scribe-protocol-tcp/src/wire/tests.rs).

## Change descriptions (opcodes 39–41)

A commit's message is part of its identity and never changes. A *change
note* overlays it: the server keeps an append-only journal of descriptions per
accepted change, clients display the latest revision in place of the message,
and every earlier revision stays readable. `log` (15) is unchanged and keeps
returning the submitted message, so a client that predates these opcodes sees
exactly what it always did; a client that knows them fetches the overlay for
the page it displays with one `change_notes` request.

`describe_change` (39) appends a revision. `expected revision` is the revision
the caller last displayed, zero for a change that has none; when the journal
is already past it the server answers `note_revision_stale` (212) and records
nothing, which is also what makes a retry after a lost reply safe — a
duplicate revision is never written. An empty or blank text is `malformed`; a
change that does not exist is `change_not_found`; the per-change journal is
bounded by `scribe_core::limits::MAX_CHANGE_NOTE_REVISIONS` (1,024), after
which the server answers `limit_exceeded`. On authenticated storage the author
field is ignored and the revision is attributed to the principal; a Writer may
describe changes whose commit author is that principal, anyone else's need a
Maintainer, and the response is the recorded note. The request runs in the
storage pool; its write transaction is one bounded read of the change's last
revision and one insert.

`change_notes` (40) returns the current note of each requested change in
request order, `present = 0` where a change was never described, from one read
snapshot. `change_note_history` (41) returns one change's revisions oldest
first. Both need only a Reader and run in the query pool. Byte fixtures are in
[`wire/tests.rs`](https://github.com/Flying-Rat/scribe/blob/main/crates/scribe-protocol-tcp/src/wire/tests.rs).

## Workspace lifecycle (opcodes 34–36)

All production integers use the negotiated little-endian codec. These operations
use the ordinary bounded blocking admission and work through gateway forwarding.
Storage enforces authorization through `AuthProvider`.

| Opcode | Request | Response |
|---|---|---|
| 34 `workspace_list` | `[16] repo, u8 everyone, u8 has-after, [16] after?, u32 limit` | `u32 count`, registration rows, `u8 more` |
| 35 `workspace_remove` | `[16] repo, [16] incarnation, [16] workspace, [16] operation` | removal outcome |
| 36 `workspace_remove_outcome` | `[16] repo, [16] operation` | empty when unknown, otherwise removal outcome |

A registration row is `[16] workspace, [16] branch, u8 has-owner, [16] owner?,
str16 label, u16 tag count, str16 tag × count, u64 created-ms, u8 has-locks`.
At most 128 rows are returned; label length is at most 256 UTF-8 bytes; at
most 16 tags of at most 64 bytes each. A tag is `name` or `name=value`: the
name is lowercase ASCII letters, digits, `-`, `_` or `.` starting with a
letter or digit, the value printable ASCII without spaces, names unique
within one registration (`scribe_core::workspace_tags`). Tags are fixed by
the first registration of a workspace; a re-registration naming different
ones is refused with `malformed`. The cursor is an exclusive workspace ID within
the requested repository and owner selection. Authenticated own listings seek
only that principal's index; `everyone` requires Maintainer. Anonymous clients
cannot list or remove registrations. Unauthenticated storage treats all callers
as trusted and lists all registrations.

A removal outcome is `[16] operation, u16 error-code`, followed by `u64 retired-ms`
when the code is zero or `str16 detail` (at most 4096 bytes) otherwise. Removal
fingerprints hash `scribe:workspace-remove:v1\0`, repository ID, incarnation, and
workspace ID in that order. Operation IDs share the mutation namespace with
submits and branch operations. Authenticated outcome reads are restricted to the
principal that issued the removal. The new semantic code 109, `workspace_retired`,
refuses reuse of a retired identity. Request payload bounds are 38, 64, and 32 bytes
respectively. The server records successful and rejected removals durably before
replying; held locks produce a recorded `lock_held` rejection.


## Durable upload pins (opcode 42)

`pin_objects` writes workspace-and-intent-scoped pins durably before returning object
presence bits. Both present and absent identities are pinned. Uploading clients
call it before `put_objects` and before reusing existing chunks/manifests/change
sets. Repeating it is idempotent. Raw `put_objects` alone creates unleased,
unaccepted objects eligible for explicit offline collection; it does not extend
an upload lease implicitly. Accepted history is always retained independently.

The shared client combines chunk, manifest, and change-set identities into
mixed-kind requests of at most 8,192 identities. All pin requests finish before
any object upload begins; missing objects are then sent in dependency order
(chunks, manifests, change sets). A small submit uses one pin request. Presence
responses must contain exactly one bit per requested identity.

Pins remain active until that submission intent has a final outcome, workspace
retirement, or explicit offline operator abandonment. The client persists the
intent ID before sending it. A finalized ID or an ID belonging to another
workspace is refused. Finalizing one intent does not release another’s pins.
There is no clock expiry or renewal interval. Retired workspaces are refused.
Authenticated adapters require Writer permission and ownership of the workspace;
no-auth adapters use the same durable metadata without an auth dependency.
The request uses storage admission, not reserved read/query capacity. `presence`
remains read-only. The shared client and all adapters must be upgraded together;
there is no fallback that silently uploads without a pin.


## Portable tree pages and proofs (opcode 43)

`tree_page` responses append the full tree’s content root[32] and revision
root[32] after the existing `more` byte. Roots describe the requested historical
snapshot, regardless of prefix or pagination. They are bound into accepted v5
commit envelopes; roots returned by the server alone are not a trust anchor.

`tree_proof` takes bytes32 of the tree-page request, a selector u8, then u32
path count and that many str16 canonical paths, followed by a continuation bool.
When true it carries bytes32 edge prefix (1–8,192 bytes) and u32 remaining
selected values (1 through original limit + 1). The prefix is raw radix bytes
and may end inside UTF-8; it is not a canonical path string. Selectors are 0 (live range),
1 (changed range, followed by lower change u64), 2 (exact path), and 3 (explicit
paths). A changed range proves all revisions newer than the lower change at the
requested snapshot, including tombstones. Exact requires a nonempty prefix,
limit 1 and no cursor. Explicit paths require sorted unique paths, an empty
prefix, no cursor and a limit equal to their count. Other selectors carry zero
explicit paths. Requests are capped at 1 MiB and 4,096 paths. Unknown selectors,
truncation, trailing bytes and oversized counts are refused in both codecs.

The response is content root[32], revision root[32], u32 node count, followed by
bytes32 canonical portable revision nodes in depth-first query visitation order.
Each response contains at most 8,192 nodes and 8 MiB of canonical node bytes.
Payload framing adds 68 bytes plus 4 bytes per node. The query worker pool is
shared with tree pages. Node-internal integers are always little endian.

A logical proof spans as many bounded fragments as needed. The initial fragment
starts at the root. The client maintains its authenticated depth-first frontier;
when the response ends, it derives the next edge prefix and remaining result
count (including one lookahead value) itself. The next request retains the exact
repository, branch, revision, selector, paths and page arguments. The producer
seeks that node from the historical root, reconstructs ancestor iterators, and
continues through later siblings. It stops at a response budget, traversal end,
or the remaining selected-value count. There is no server session,
client-supplied local page ID, or server-provided completion flag. A seek follows
at most 8,193 ancestor nodes; producing a fragment visits at most 8,193 nodes
including the one that would overflow the response. A valid node fits alone.

The verifier checks every returned node against its retained parent commitment
and prefix. The frontier stores one prefix and lazy child iterator per ancestor;
strictly growing prefixes bound depth by the 8,192-byte path limit, independently
of total repository size. Frontier memory depends on depth and fanout, and can
exceed a single response budget. Fragment bytes are released as they are consumed.
An empty, replayed, omitted, reordered or surplus fragment cannot establish
completion. The client returns page values only after verifying the complete
page and lookahead. A transport failure discards that in-memory page; retry starts
its proof again without advancing the sync watermark. Oversized point requests
still split to fit the 1-MiB request budget, reserving room for a continuation.
No supported tree requires a larger response budget or an unverified fallback.

The shared Rust `scribe_client::tree::verified_page` checks the proof against
caller-supplied trusted roots, reconstructs results and `more`, and rejects
missing/extra nodes, changed digests, invalid summaries, and invalid ordering.
Authenticated child counts and prefix intervals justify skipped subtrees. A
limit-plus-one live witness proves continuation. Empty and all-deleted trees
have explicit verifiable representations. Permission is repository Reader,
including public-read/no-auth configurations, matching tree enumeration. Proofs
can reveal ancestor values and sibling metadata within that repository; a future
path-scoped authorization model must restrict this operation accordingly.

Normal workspace sync uses these proofs against persisted per-branch history
anchors, including backward deltas and deferred point states. It binds applied
state atomically to journal completion. First contact trusts the connected
server; later heads must reach the pinned commit through authenticated parents.
This detects rollback and inconsistency against that client's anchor, but cannot
prove global freshness or detect a server maintaining separate consistent forks
for different clients. Restores require explicit re-anchoring. Update shared
clients, server and gateways together for this unreleased request change.

Path-scoped access is not supported by this proof format. Introducing it requires
reviewing ancestor/sibling disclosure, direct object downloads, historical objects
and mount repositories together; filtering returned entries alone is insufficient.

### Submit lock disposition

Ordinary, merge, and cherry-pick submissions release the submitting workspace's
locks on their submitted paths at durable acceptance, including submitted keep
entries and move endpoints. Locks on other paths or owned by other workspaces
are untouched. `keep locks = true` retains acquisitions; retained moves may
acquire a free destination from an owned source lock. Rejections retain locks.
This flag is part of the operation fingerprint. Replaying a recorded operation
returns its original result and never changes a newer acquisition.
