SPEC-TRANSPORT-CONNECT.md
In docs/specs/
Preview
Text · 130.9 KiBspec: SPEC-TRANSPORT-CONNECT
version: 2
status: draft-normative
audience: implementers of mkit.transport.v1 Connect servers and clients (reference Worker, mkit-server, native CLI transport)
SPEC-TRANSPORT-CONNECT — mkit.transport.v1, the canonical Connect remote protocol
Status: Draft, version 2. Version 1 defined a single-repository
service: one deployment served one repository, and a valid auth v2
signature was enough to write. Version 2 is a semantic revision of the
same wire package. It adds multi-repository addressing (§7.4), namespace
and write policy (§7.5), GetServerInfo (§2.1), upload tickets and
resumable parts (§7.6), ref deletion (§7.8), read consistency and
paging (§7.9), admission challenges (§5.1),
and the per-RPC upload lifecycle (§7.7). mkit.transport.v1 evolves
additively: v2 adds RPCs and fields and never renumbers or removes one,
so buf breaking stays green. The breaks are semantic only. mkit is
pre-production (CONTRIBUTING, "Pre-production compatibility policy"), so
v2 has no v1 compatibility machinery: no v1 reader, no negotiation, and
no fallback mode. The proto messages v2 names land with the M1
implementation, and the admission messages with the M3 implementation
(§8); until then the implementations described under
"Reference implementation" below implement the v1 surface only. §9
records the version history.
Scope: the mkit.transport.v1.TransportService Connect service — its
proto shape, verb-to-trait mapping, CAS semantics, error-code mapping,
pack-transfer streaming design, repository addressing, write
authorization policy, upload tickets, admission challenges, and read
consistency — and how
the deployment targets (reference Worker, mkit-server, native CLI
client) consume one generated codebase. It does not cover S3 multipart,
the WatchRefs live-feed migration, pricing or payment verification,
or any server/client implementation; those are separate changes (§8).
Write and read grants, epochs, signed reads, and private repositories
are SPEC-WRITE-GRANTS's.
Supersedes: SPEC-TRANSPORT §5 ("HTTP transport") as
the ACTIVE implementation behind mkit+https:///mkit+http:// in
mkit-cli — mkit-transport-connect is what remote_dispatch now
constructs for those schemes. SPEC-TRANSPORT §5 is not yet deleted: the
mkit-transport-http crate remains in the tree (unused by mkit-cli's
dispatch) because its sparse-checkout and pack-shards extensions
(SPEC-TRANSPORT §5.6 and the pack-shards cargo feature) have no
mkit.transport.v1 equivalent yet — full retirement waits on that gap
being resolved, not just core-verb parity.
Reference implementation: mkit-transport-connect (the native CLI
client, §7.3) against mkit-transport-connect/tests/roundtrip.rs's
in-process server — real HTTP, real protobuf framing, real Connect
streaming, backed by mkit-transport-memory rather than R2/a Durable
Object. The mkit-server library and its conformance host (§7.2) drive the same
client end to end; they replaced mkit serve --http, which the CLI no longer has.
apps/vcs-worker (mkit#699)
implements the unary and client-streaming RPCs
(ListRefs/ReadRef/UpdateRef/AdvanceRefs/PackExists/UploadPack)
against this proto over R2 and a Durable Object; DownloadPack
(server-streaming) conforms to the wire shape but whole-pack-buffers
rather than incrementally streaming — see its README "Known
limitations" — so §6.3's owned-mpsc-channel bridge and its unresolved
end-to-end delivery risk remain unverified by that implementation. Every
RPC has been manually verified against a real local wrangler dev
instance (real R2/DO emulation, real Ed25519-signed envelopes) — see
apps/vcs-worker/README.md "Known limitations" for the trial writeup,
including a SECOND pass driving the real mkit CLI (push/clone/
pull) end to end against this exact server through ConnectTransport's
new envelope-signing auth mode (§7.3). ConnectTransport now supports
BOTH the bearer-token scheme (SPEC-TRANSPORT §5.2, unchanged, used by
bearer-mode mkit-server deployments) and this server's Ed25519 write envelope (§7.1) as
independent, additive auth modes — see §7.3. No AUTOMATED test drives
this client/server pair yet (the wrangler dev verification above is
manual, matching apps/vcs-worker's existing testing posture for
wasm-only glue) — that remains the open item on the testing axis.
apps/repo-worker
remains the closest OTHER existing analog (a Connect service on
Cloudflare Workers) but implements the unrelated mkit.repo.v1.RepoService
anonymous-demo contract, not this one; this document borrows its proven
patterns (§1, §7) without sharing its proto.
The proto lives at
proto/mkit/transport/v1/transport.proto.
1. Buf module and package layout
buf.yaml (repo root, v2, three-module workspace)
├── rust/crates/mkit-rpc/proto → mkit.rpc.v1 (+ .signer, .ssh, .verify)
├── apps/repo-worker/proto → mkit.repo.v1
└── proto/mkit/transport/v1/transport.proto → mkit.transport.v1This module shares the repo-root buf.yaml workspace (mkit#677, "buf
workspace + proto path restructure") alongside rust/crates/mkit-rpc/proto
(mkit.rpc.v1) and apps/repo-worker/proto (mkit.repo.v1). buf
lint / buf breaking run from the repo root against the whole
workspace — see CONTRIBUTING.md's "Protobuf schemas (buf)" section.
buf breaking is configured with breaking.use: [FILE] from this
module's first commit onward, so every subsequent change to
transport.proto is checked against the immediately prior version —
there is no grace period after this document merges.
A follow-up (tracked as mkit#679) extracts RefExpectation and
RefEntry into a shared mkit/common/v1/refs.proto imported by
mkit.rpc.v1.ssh, mkit.repo.v1, and this package — the buf
workspace (§1) now makes that cross-module import resolvable. Until
that extraction lands, mkit.transport.v1.RefExpectation and
RefEntry are byte-for-byte duplicates of the mkit.rpc.v1.ssh
originals. mkit.rpc.v1.ssh and mkit.repo.v1 have already completed
this extraction and both import the shared mkit/common/v1/refs.proto
definitions rather than duplicating them (see the comment at
apps/repo-worker/proto/mkit/repo/v1/repo.proto:20-23); mkit.transport.v1
is the one package still pending the same move.
2. Verb-to-RPC mapping
TransportService maps one-to-one onto the verbs of the
Transport trait — the
same trait mkit-transport-http/-s3/-ssh/-enc implement today.
Transport trait method | RPC | Shape |
|---|---|---|
| (none; lists namespace repositories) | ListRepos (§7.10) | unary |
list_refs(prefix) | ListRefs | unary |
read_ref(name) | ReadRef | unary |
update_ref(name, condition, hash) | UpdateRef | unary |
write_ref(name, hash) (default impl: update_ref(.., Any, ..)) | (none — client calls UpdateRef with expectation = REF_EXPECTATION_ANY) | — |
advance_refs(..) | AdvanceRefs | unary |
pack_exists(key) | PackExists | unary |
upload_pack(bytes, key) | UploadPack | client-streaming |
download_pack(key) | DownloadPack | server-streaming |
upload_blob(bytes, key) (default impl: delegates to upload_pack) | (none — client calls UploadPack) | — |
download_blob(key) (default impl: delegates to download_pack) | (none — client calls DownloadPack) | — |
| (none — deployment discovery, M1) | GetServerInfo (§2.1) | unary |
| (none — retained storage receipt, M5) | GetReceipt (§2.2) | unary |
| (none — upload ticket, M1) | BeginUpload (§7.6) | unary |
| (none — one part of a ticketed upload, M1) | UploadPart (§7.6) | client-streaming |
| (none — completes a multipart upload, M1) | CompleteUpload (§7.6) | unary |
| (none — reads a namespace authority generation) | GetAuthorityGeneration (SPEC-SERVER §6.2.1) | unary |
| (none — raises a namespace authority generation) | SetAuthorityGeneration (SPEC-SERVER §6.2.1) | unary |
| (none — reads a namespace's grant epoch, M2) | GetGrantEpoch (SPEC-WRITE-GRANTS §5.3) | unary |
| (none — raises a namespace's grant epoch, M2) | SetGrantEpoch (SPEC-WRITE-GRANTS §5.3) | unary |
| (none — sets a repository's visibility, M2) | SetRepoVisibility (SPEC-WRITE-GRANTS §9.1) | unary |
| (none — mints a signed URL token, M2) | IssueObjectUrl (SPEC-WRITE-GRANTS §9.4) | unary |
write_ref and the blob verbs are Transport-trait-level default
methods that delegate to another trait method before any transport
implementation runs (see protocol.rs's doc comments on each). The
wire therefore never distinguishes "pack" from "auxiliary blob," or
"unconditional write" from "CAS write with expectation = ANY" — a
Connect server implementing the seven wire RPCs above (§2's table)
gets every Transport trait verb for free through the client-side
default impls, exactly as every other transport already does.
The four rows marked "M1" are v2 additions. They have no Transport
trait verb: they are deployment discovery and the ticketed upload
protocol, which a client drives around the seven verb RPCs. Their proto
messages land with the M1 implementation (§8), additively. The four rows
marked "M2" are SPEC-WRITE-GRANTS additions for grant epochs, repository
visibility, and URL tokens; their proto messages land with the M2
implementation, additively.
Endpoints follow the standard Connect convention:
POST /mkit.transport.v1.TransportService/<Method>.
2.1 GetServerInfo
Protocol requirement.
GetServerInfo tells a client what a deployment supports before the
client relies on it. The request is empty. It MAY carry X-Repository
(§7.4). The response MUST NOT depend on whether that repository exists,
so it is never an existence oracle (§7.4, isolation).
The call is unauthenticated: a server MUST answer it without auth v2
headers or a bearer token. The response MAY be cached with
Cache-Control: private, max-age=<n> where n is at most 60 seconds.
GetServerInfoResponse carries these fields:
| Field | Meaning |
|---|---|
protocol | The wire package, mkit.transport.v1. |
spec_version | This document's version, 2. |
max_pack_bytes | The largest pack the deployment accepts. A deployment MAY advertise a lower value in indexed mode than in opaque mode, for example on a runtime with tight CPU limits. A deployment whose storage cannot accept multipart uploads MUST advertise max_pack_bytes <= part_size. |
part_size | The part size for resumable uploads (§7.6): a power of two, at least 8 MiB. |
max_parts | The largest number of parts in one upload (§7.6). |
max_list_refs_page_size | The largest number of refs one ListRefs page returns (§7.9). |
begin_upload_threshold_bytes | Packs smaller than this MAY skip BeginUpload (§7.6). It is 0 on every multi-repository deployment, whenever admission is true, and with any inspector configured (SPEC-SERVER §11.1). On a single-repository deployment without admission or inspection, the value is deployment-defined. |
atomic_advance | Whether AdvanceRefs commits the head and packmap atomically (§4). |
indexed_mode | Whether the deployment decodes and verifies pushed packs before refs move. |
admission | Whether the deployment runs an admission step that can challenge a request (§5.1). When it is true, begin_upload_threshold_bytes is 0. |
receipt_public_key, receipt_key_id | Raw 32-byte current receipt-and-notice signing public key and its 64-lowercase-hex BLAKE3 key id (SPEC-SERVER §15.5). Both empty only when that role key is not configured. Historical keys are in the well-known list, not this response. |
grant_schemes | The owner signature schemes the deployment accepts on grants and epoch statements (SPEC-WRITE-GRANTS §4). Empty on a deployment that accepts no grants. |
namespace_policy | allowlist, any, or single-repository (§7.5). single-repository is advertised, never configured. |
index_fanout | The deployment's repository-index fan-out advertisement (§7.9). This field does not require a particular index layout. |
max_delta_chain_depth | The advertised delta-chain depth cap (SPEC-SERVER §9.8); 0 when indexed mode is off. |
leases | Whether the deployment enforces storage leases under SPEC-SERVER §12. |
async_inspection | Whether any asynchronous inspector is configured (SPEC-SERVER §10–§11). Writers MUST sign reads to see their own pending content that is not held. Held content is hidden from every caller, including when this field is false and a synchronous inspector holds it. |
inspection_max_objects | Maximum distinct objects in one advance's complete inspected set (SPEC-SERVER §11.1). Present only when inspectors are configured; absence imposes no inspection-specific object-count limit. A client exceeding the advertised limit MUST replan into smaller advances; the server rejects the oversized advance before calling inspectors or accepting changes. |
A client MUST NOT assume atomic advance without atomic_advance = true
from this call. atomic_advance replaces the client-side opt-in of v1
(§7.3): a client reads it here instead of from local configuration.
Reference-server behavior. The repository index uses fan-out 16 (the top four object-id bits), and indexed mode defaults to delta-chain depth 50. The synchronous inspection cap is configurable from 1 to 10,000 objects. The reference server conservatively checks all added-pack entry counts before enumeration, so duplicates and structural entries can cause refusal below the distinct-set cap. Its inspected set contains added packs' Blob and ChunkedBlob entries, including surplus entries; each inspector receives one complete set.
2.2 GetReceipt
GetReceipt is a signed read (STC §7.1). The server MUST first validate
the invalid_argument cases below, then perform the
SPEC-WRITE-GRANTS §9.3
read check (including its receipt-only write-grantee exception), then
require the SPEC-SERVER §10.1 writer
view. A write-only grantee establishes that view through its signed grant
and MAY retrieve advance receipts only for refs inside that grant's
ref scopes. For this check, a paired packmap maps to its branch. A
caller with read or read,write capability, an owner, or an authority
is not subject to this grant-scope limit. A failure at either access
check or this scope check MUST be not_found. Every not_found from
this RPC MUST have byte-identical Connect code, message, details, and response headers,
whether the repository is nonexistent, private and unauthorized, the
caller lacks writer view, the ref lies outside a write-only grant, or
the receipt is missing. The lease selector
MUST remain available to an otherwise authorized writer for that
scope while the repository is suspended or deleted; it reveals receipt
history, not live content. The write-only grant-scope check is part of authorization and runs
before §12.2. The advance selector MUST then apply §12.2: under a
suspended or deleted effective state of the repository or ref it MUST
return permission_denied with public message lease suspended.
GetReceiptRequest MUST select exactly one oneof arm:
| Selector | Fields | Meaning |
|---|---|---|
advance = 1 | ref (full branch head or other ref name), advance_sequence (u64) | Retrieve that advance's storage receipt; 0 selects the latest committed advance sequence for the ref, including one whose signature is pending. The branch selector is refs/heads/<x> even if the requested write advanced its paired refs/mkit/packmap/<x>. |
lease = 2 | ref (full ref or empty), lease_version (u64), namespace_scope (bool) | Retrieve that lease storage receipt. With namespace_scope = true, an empty ref selects the namespace of the signed repository identity. Otherwise an empty ref selects repository scope. 0 selects the latest committed lease_version for that scope, including one whose signature is pending. |
An absent selector, an empty advance ref, an invalid nonempty ref,
a packmap ref used as the selector, or namespace_scope = true with a
nonempty lease ref MUST receive invalid_argument. The
advance_sequence starts at 1; 0 is reserved for latest. The
branch head and paired packmap share that sequence (SPEC-SERVER §10.2), which MUST
NOT reset after deletion, recreation, or repository-level deletion.
GetReceiptResponse.receipt = 1 contains the complete DSSE envelope
JSON as bytes, identical to the bytes returned at issue or replay.
If the selected committed receipt's signature is pending, the call
MUST return retryable unavailable; 0 MUST NOT fall back to an older
signed sequence or version. If receipts are disabled or the selected version was
never issued or has passed the deployment's receipt_retention, the
call MUST return not_found. The server MUST retain the latest
receipt per ref, including a deletion, and per lease scope as
SPEC-SERVER §15.6 requires. This read allocates no write replay entry and does not expose the reader's published-view
state. Advance receipts expose previous and the paired packmap of a
committed write on that ref to every caller entitled to the receipt. SSH and enc clients receive
no storage receipts in M5; their wire schemas do not change.
3. CAS semantics — UpdateRef
Identical in spirit to SPEC-TRANSPORT §4.2.1
and mkit.repo.v1.RepoService.UpdateRef, expressed as a Connect
unary call instead of an SshFrame or a JSON body:
RefWriteCondition | RefExpectation | expected_id | Semantics |
|---|---|---|---|
Any | REF_EXPECTATION_ANY | empty | Last-writer-wins. |
Missing | REF_EXPECTATION_MISSING | empty | Create-only; the ref MUST NOT already exist. |
Match(h) | REF_EXPECTATION_MATCH | 32-byte digest h | Current ref value MUST equal h. |
A conforming server MUST reject REF_EXPECTATION_UNSPECIFIED (the
proto zero value) with Connect code invalid_argument — mkit is
alpha (pre-1.0); there is no back-compat surface for a client that
omits expectation.
Unlike the SSH wire's Error.details (an opaque, not-client-consumed
carrier for the current ref value, per SPEC-TRANSPORT §4.2.1) and
mkit.repo.v1.UpdateRefResponse.current_id (which is
client-consumed), UpdateRefResponse on this service carries no
current-value field: a CAS failure is a Connect error
(failed_precondition), full stop. This is a deliberate
simplification, not an oversight — SPEC-TRANSPORT §7 already requires
a caller that gets RefConflict to call read_ref before reporting a
conflict, because the retry ladder can re-issue a write whose first
attempt landed. A second value-carrying channel on the CAS-conflict path
would add a field no conforming client is allowed to trust as
authoritative on its own. ReadRef is the one source of truth for "what
is the ref's current value," called explicitly, every time.
On a committed update, UpdateRefResponse.receipt = 1 contains the
complete §15 storage-receipt DSSE envelope JSON bytes. It is empty
when receipts are disabled. A CAS or validation failure returns a
Connect error, so no receipt is issued. The field attests the live
advance, including deletion, and says nothing about publication.
This document reuses SPEC-TRANSPORT §7's caller obligations unchanged,
for UpdateRef and for AdvanceRefs' conflict outcomes (§4). A
deployment with the auth v2 replay ledger (§7.1) answers a re-issue
that carries the same nonce with the stored result, so a re-issue within
one call's retry ladder reports the true outcome there (§7.3); the
read_ref obligation still applies to any other deployment and to a
caller that invokes the operation again.
4. Atomic two-ref advance — AdvanceRefs
Protocol requirement.
Transport::advance_refs updates a branch's head ref and its packmap
ref together, each under its own CAS precondition, so the
delta-transfer invariant ("if head_ref resolves to T, the packmap
reconstructs closure(T)") never has a window where the two refs
disagree. AdvanceRefsRequest carries both preconditions in one
message so a transactional server backend can commit both writes
atomically in one round-trip:
message AdvanceRefsRequest {
string head_ref = 1;
RefExpectation head_expectation = 2;
bytes head_expected_id = 3;
bytes head_new_id = 4;
string packmap_ref = 5;
RefExpectation packmap_expectation = 6;
bytes packmap_expected_id = 7;
bytes packmap_new_id = 8;
}AdvanceRefsResponse.outcome mirrors
AdvanceOutcome exactly:
AdvanceOutcome | AdvanceOutcome (proto) | Meaning |
|---|---|---|
Committed | ADVANCE_OUTCOME_COMMITTED | Both refs updated. |
HeadConflict | ADVANCE_OUTCOME_HEAD_CONFLICT | The head precondition failed; branch moved under the caller. |
PackmapConflict | ADVANCE_OUTCOME_PACKMAP_CONFLICT | The packmap precondition failed; a concurrent pusher advanced the chain first. |
An atomic-capable client publishing a force push MUST compare the packmap in
that same AdvanceRefs, including a head-only or idempotent write (set the
packmap to its existing value under MATCH). A plan depending on the observed
head's closure MUST also compare that head: separate reads do not establish an
atomic pair, and a reset can restore an identical packmap digest. A full,
self-contained append MAY use ANY for the head. On a precondition miss, the
client MUST re-read the packmap and re-plan if it no longer extends the chain
captured by the plan. Re-planning and publication attempts MUST share the
existing bounded retry budget. These requirements use the existing fields;
UpdateRef alone cannot carry the packmap dependency.
If capability discovery failed or was invalid, the client MUST refuse a force
push before uploading or writing refs. It cannot infer that other clients are
unable to reset the packmap from its own discovery failure. A valid discovery
response explicitly disabling atomic advance retains the non-atomic path.
A conflict is carried as a successful RPC with outcome !=
ADVANCE_OUTCOME_COMMITTED, not a Connect error — unlike UpdateRef,
advance_refs's Rust signature already returns a typed enum rather
than a boolean success/CAS-conflict split (see
protocol.rs's AdvanceOutcome), so the wire follows the same shape
instead of forcing a three-way outcome through a two-way (success /
error) channel.
AdvanceRefsResponse.receipt = 2 contains the complete §15
storage-receipt DSSE envelope JSON bytes on a committed outcome. It
MUST be empty on either conflict and when receipts are disabled. The
receipt attests the live committed pair; it reveals no inspection or
publication state. On authenticated replay of a committed operation,
the server returns the identical receipt envelope bytes.
A server offering atomic advance MUST commit both writes atomically and MUST
advertise atomic_advance = true through GetServerInfo (§2.1). A server that
does not offer atomic advance MUST NOT advertise it; its fallback writes the
packmap before the head. That fallback is available only to writes outside the
signed replay-protected write contract. Every accepted signed ref write MUST
satisfy atomic acceptance under SPEC-SERVER §3.1, including both sides of an
AdvanceRefs or paired deletion.
A server MAY require packmap_ref to be refs/mkit/packmap/<x> when
head_ref is refs/heads/<x> (SPEC-REFS §2), and answer any other pairing with
invalid_argument; every mkit client already sends that pairing.
Reference-server behavior. The reference server limits
max_tickets_per_advance to seven and rejects a larger ticket_ids list with
invalid_argument before acceptance. This limit bounds its receipt envelope
under SPEC-SERVER §15.2; it is not a required limit for other servers.
4.1 Create a branch from a published source
Protocol requirement.
The additive AdvanceRefsRequest.source_ref = 11 form selects a published
source branch in the same repository. head_ref = 1 names the destination
branch; the server derives its matching packmap ref and applies MISSING
to both. expected_tip = 12 optionally contains a 32-byte published-head
CAS. All explicit update fields except head_ref MUST be absent.
ticket_ids MUST be empty, and delete MUST be absent, including an
explicit false. The server MUST reject a mixed form or
expected_tip without source_ref with invalid_argument.
This form requires atomic metadata; a server without it MUST return
failed_precondition. It reads one authoritative published pair and
retains ordinary ticketless advance conflict outcomes, replay, admission,
quota, receipt and outcome semantics. SPEC-SERVER §9.3 specifies uniform
source absence, optional expected-tip errors, and the final source fence before
destination acceptance. A source move after that fence may precede the
destination commit; success still copies exactly the captured pair.
No upload, membership addition or stored-byte charge is introduced.
The immediate path copies the source's coverage as it stands; configured
publication verification still checks the destination (SPEC-SERVER §10.2).
5. Error taxonomy — TransportError to Connect code
Protocol requirement.
Connect carries structured errors natively (a code plus a message),
so — unlike the SSH wire's hand-rolled mkit.rpc.v1.Error message,
needed because raw stdio framing has no ambient error channel — this
service defines no custom error message type. Every
TransportError variant
maps onto a standard Connect code:
TransportError | Connect code | Raised by |
|---|---|---|
PackNotFound | not_found | DownloadPack before any chunk is sent; PackExists never raises this (it returns exists = false instead). |
AccessDenied | permission_denied; a client also maps unauthenticated to AccessDenied. | Any RPC, when the deployment's write or namespace policy (§7.5) rejects an authenticated caller, or a ticket does not bind to the request (§7.6). |
RefConflict | failed_precondition | UpdateRef on a CAS mismatch, including deletion of an absent ref (§7.8). AdvanceRefs reports its conflicts as typed outcomes (§4), never as this error. |
InvalidRef | invalid_argument | Any RPC taking a ref name that fails SPEC-REFS §3, or (ReadRef, UpdateRef, either name of AdvanceRefs) a name outside refs/, which a server does not serve (SPEC-REFS §2); that message starts ref name must start with refs/. |
ConnectionFailed | (not server-raised — client-observed transport failure, for example TCP reset, deadline exceeded) | — |
ServerError{status} | unavailable (5xx-equivalent), resource_exhausted (429-equivalent), or aborted (a client maps it to ServerError{status: 503}) | Deployment-specific overload / backend failure; aborted also answers a retry of an operation that is still in flight (§7.1). |
InvalidResponse | (not server-raised — client-observed: malformed frame, wrong message on a streamed oneof, digest mismatch on DownloadPack) | — |
ProtocolError | invalid_argument | A client-streaming call whose header is missing, arrives after a chunk, or whose declared/received byte counts disagree (§6). |
PayloadTooLarge | resource_exhausted | UploadPack header total_bytes (or the observed stream length) exceeds the server's cap. |
AdmissionRequired{challenges, description} | permission_denied, sent with HTTP status 402 and exactly one AdmissionChallenge detail (§5.1). A client also maps any HTTP 402 response to this variant. | A unary RPC that the deployment's admission step challenges (§5.1). Never retried. |
InsecureScheme | (not applicable — URL-scheme concern, handled client-side before any RPC is made; see SPEC-TRANSPORT §3) | — |
RemoteError(String) | unknown (server-raised, deployment-specific advisory failure with no more specific code applies) — also the client-side default target for any Connect code this table does not otherwise list (internal, data_loss, …), matching the variant's existing "catch-all" contract in protocol.rs. | A deployment-specific backend failure that does not fit any row above. |
A conforming client's Connect-to-TransportError mapping is the
mechanical inverse of this table, with RemoteError(String) as the
fallback arm for any Connect code not otherwise listed — the mapping
is total in both directions, never a partial match. Before it applies
the table, a client checks for admission: an HTTP 402 response, or a
permission_denied error that carries an AdmissionChallenge detail,
maps to AdmissionRequired, never to AccessDenied (§5.1).
Before the generic failed_precondition mapping, a client MUST check
whether AdvanceRefs returned failed_precondition with exact public
message delta base not available in this repository. That answer is
not a ticket failure: the client re-plans once as a self-contained pack
(§7.6, Errors). On BeginUpload, failed_precondition with exact
public message too many open upload tickets is also not a ticket
failure. A client MUST also check for unavailable with a
PendingVerification detail and follow §7.6's Pending verification
polling rule, without its normal backoff ladder.
is_retryable (SPEC-TRANSPORT §7) continues to apply once translated:
unavailable, resource_exhausted, and aborted are retryable, and
everything else is not. AdmissionRequired is never retryable.
Authentication, authorization, addressing, ticket, and storage
failures map to these codes. The Condition column is what the server
observed. Subject to the checks above, a v2 client maps
failed_precondition on BeginUpload, UploadPart, CompleteUpload,
a ticketed UploadPack, or an AdvanceRefs carrying ticket_ids to a
ticket failure, which it resolves by calling BeginUpload again, never
to RefConflict, except for the two cases the paragraph after the table
excludes.
| Condition | Connect code |
|---|---|
On an RPC that requires auth v2: a missing, malformed, or expired envelope, a bad signature, a missing X-Repository, or an X-Repository that differs from the signed <repository> (§7.4) | unauthenticated |
| An authenticated principal that the namespace or write policy does not authorize (§7.5) | permission_denied |
A ticket whose audience, repository, signer, pack_id, or byte count differs from the request (§7.6) | permission_denied |
A malformed repository identity, or a missing X-Repository on an unsigned RPC to a multi-repository deployment (§7.4) | invalid_argument |
| A read RPC on a repository that does not exist (§7.4) | not_found |
| A signed read whose auth v2 envelope fails verification (SPEC-WRITE-GRANTS §9.2) | unauthenticated |
A grant that does not authorize a write, including an epoch mismatch found at apply (SPEC-WRITE-GRANTS §11) | permission_denied |
| Any unauthorized read of a private repository (SPEC-WRITE-GRANTS §9.3), indistinguishable from a missing repository | not_found |
| An expired, unknown, or missing ticket, or a ticket presented to an advance of a ref it does not name (§7.6) | failed_precondition |
| Too many open upload tickets for the ref or the signer (§7.6) | failed_precondition, with public message too many open upload tickets |
| An unresolved delta base after the relay-lag bound, in indexed mode (SPEC-SERVER §9.4) | failed_precondition, with public message delta base not available in this repository |
| A part whose subtree hash or length differs from its commitment, or a completion whose merged root or total differs from the ticket (§7.6) | invalid_argument |
| An invalid part receipt (malformed, unknown key id, bad MAC, or bound to another ticket; §7.6) | invalid_argument |
| A pack still under verification in indexed mode (§7.6) | unavailable with exactly one PendingVerification detail |
| A membership-dependent miss within the relay-lag bound, in indexed mode (SPEC-SERVER §9.4) | unavailable, public message repository membership not yet visible, with exactly one empty MembershipNotVisible detail and no Retry-After; a retry with the same nonce is safe. A client classifies by the detail first and MAY fall back to the exact message for servers that predate it |
| Publication verification ran out of its execution allowance or retained-evidence capacity, or the publication state changed between verification and commit (SPEC-SERVER §10.2). Nothing commits. | unavailable with no detail, never PendingVerification; the ladder retries it, though a deterministic capacity limit is not resolved by retrying |
| A decoded pushed file object is globally blocklisted (SPEC-SERVER §14.2) | permission_denied, exact message object blocked, with a repository-empty writer RedactionNotice detail whose rewrites are empty |
An AdvanceRefs closure contains a repository tombstone (SPEC-SERVER §14.5) | invalid_argument, exact message open closure, with applicable writer RedactionNotice details; the ordinary membership-lag interval does not apply |
| A delta base is tombstoned in this repository (SPEC-SERVER §14.5) | failed_precondition, exact message delta base not available in this repository, with applicable writer RedactionNotice details and no lag interval; this is the existing self-contained replan case |
DownloadPack names a superseded pack (SPEC-SERVER §14.5) | not_found with the RedactionNotice details of the caller's view whose rewrites name the requested pack, before streaming; otherwise plain not_found; PackExists returns false |
| A writer is denied by a repository- or namespace-level takedown (SPEC-SERVER §14.1) | permission_denied, exact existing message lease suspended, with applicable writer RedactionNotice details; readers keep §12.2's byte-identical absent answer |
| A missed acceptance deadline, authoritative-state capacity exhaustion, or pending-delivery backpressure. Nothing commits, and a retry with the same nonce is safe. | unavailable, never resource_exhausted |
| Receipt signing fails after a successful apply (SPEC-SERVER §15.1). The advance remains committed; a same-nonce retry returns that result with its signed receipt. | Retryable unavailable |
| A signed nonce already recorded with a different operation fingerprint (§7.1) | invalid_argument |
A signed nonce whose operation is still in_flight (§7.1). The request never reaches admission. | aborted (retryable) |
| A new operation that the admission step challenges (§5.1) | permission_denied with HTTP status 402 and one AdmissionChallenge detail |
| A new operation that the admission step denies outright (§5.1) | permission_denied, with no AdmissionChallenge detail, with its default HTTP status 403, never 402 |
failed_precondition is a CAS conflict only on UpdateRef. Except
for the indexed delta-base case and the BeginUpload open-ticket cap
case (too many open upload tickets) above, it is a ticket failure on the
other RPCs listed here and on an UploadPack that needed a ticket and
carried none (§7.6). On the cap case, the client fails the operation
with a user-visible error and does not call BeginUpload again.
A client MUST NOT treat a ticket failure as a ref
conflict. No ticket failure is resource_exhausted,
because clients retry that code on the backoff ladder. For the same
reason, no admission challenge or denial is resource_exhausted.
5.1 Admission challenges
Protocol requirement.
A server MAY refuse a request until the caller does something outside
this protocol. Examples are a payment, a quota top-up, or verifying an
email address. A plain resource_exhausted cannot express this,
because clients retry it on a backoff ladder. A plain
permission_denied cannot express it either, because it gives the
caller nothing to act on. An admission challenge is a
permission_denied error that tells the caller what to do.
mkit registers no challenge schemes and interprets none. Payment
protocols such as MPP (the Machine Payments Protocol) and x402 define
their own challenges, credentials, and receipts. This section defines
only how they travel through mkit.transport.v1 and which headers a
client may attach in reply. Pricing, payment verification, and
settlement belong to the deployment.
Error. The server answers a challenged request with HTTP status
402 and a Connect error body whose code is permission_denied. The
body carries exactly one error detail of type
mkit.transport.v1.AdmissionChallenge:
message AdmissionChallenge {
repeated Challenge challenges = 1; // 1 to 8 entries, in the server's order of preference
string description = 2; // text for a person; at most 512 bytes of UTF-8
}
message Challenge {
string scheme = 1; // lowercase token: [a-z0-9][a-z0-9.-]{0,63}
string value = 2; // opaque to mkit; at most 8,192 bytes
}The proto messages land additively with the M3 implementation (§8).
Each Challenge is opaque: scheme names the external protocol that
interprets value, and a challenge defined by another protocol can
travel unchanged in value (informative: a deployment can mirror each
WWW-Authenticate: Payment challenge it sends as one entry). A client
treats a detail that breaks these bounds, or a second
AdmissionChallenge detail, as InvalidResponse. No RPC response
message carries an admission result; a challenge is only ever this
error.
A client that predates this section maps the error to AccessDenied
and fails at once instead of retrying, because the code is
permission_denied (§5).
Header pass-through. The 402 response MAY also carry the raw
challenge headers of the payment protocols the deployment supports:
one or more WWW-Authenticate: Payment … fields (MPP) and a
PAYMENT-REQUIRED field (x402). The server passes them through as
the deployment's business layer produced them. mkit does not
interpret them. A server SHOULD send each challenge-carrying pass-through
field as its own field line. A platform that combines repeated
WWW-Authenticate fields into one comma-separated value under
RFC 9110 §5.3
is conforming when challenge order is preserved. Clients and admission
helpers MUST parse these values as a challenge list under
RFC 9110 §11.6.1,
including quoted commas in auth-params, rather than treating each field
line as one challenge. PAYMENT-REQUIRED is unaffected.
Unary RPCs only. A challenge applies only to unary RPCs, because a
client-streaming RPC reports its status after the client has sent the
whole stream. GetServerInfo is never challenged (§2.1), and neither
is the part path (UploadPart, CompleteUpload, and a ticketed
UploadPack, §7.6), whose admission happened at BeginUpload. When
the deployment runs admission, GetServerInfo advertises
admission = true and begin_upload_threshold_bytes = 0, so
BeginUpload is mandatory for every upload (§7.6). §7.7 states which
RPC of an upload is admitted. Paid bulk downloads are served over
plain HTTP under SPEC-HTTP-OBJECTS,
where a 402 is an ordinary response. That specification fixes HTTP read
authorization, admission, cache headers, and CORS.
Ordering. For an operation the deployment authenticates, the server authenticates before it challenges. For a signed write it checks the replay ledger and authorizes before admission, in the order §7.1 fixes, so a retry of a committed operation returns its stored result and never meets a new challenge. An anonymous operation, such as a paid public read, MAY be challenged directly.
No state on challenge. A challenged request MUST NOT change any state. The server creates no namespace or repository, reserves no quota, allocates no ticket, and does not consume the replay nonce. It never stores a challenge as a replay result (§7.1). A retry with the same nonce is therefore evaluated as a new operation. A denial allocates nothing either (§7.5).
Admission input. The admission step decides from the operation, which carries (informative):
- the audience, repository, procedure, and the verified signer, or "anonymous";
- the namespace owner and the write authorization used (§7.5);
creates_namespaceandcreates_repo;- for
BeginUpload, thepack_id, the declared byte count, and the bytes new to the repository (known only from membership); - the idempotency key.
Two rules about this input are normative. The server MUST give the admission
step creates_namespace and creates_repo (§7.5). It MUST NOT give
the admission step the bytes new to the whole store, because a price
that depends on them would reveal whether other repositories hold the
content (§7.4, isolation). Those bytes are reported only in the
Committed outcome (§7.7).
Reference-server behavior. The reference server cannot atomically account for quota scopes spanning its namespace storage domains. An embedding server chooses an accounting mechanism suited to its declared atomic domain; storage colocation alone does not establish an atomicity guarantee.
Protocol requirement.
Binding (informative). Binding a challenge to one request, for
example an HMAC over the MPP challenge parameters, an opaque value,
or a digest over the unary BeginUpload body, is the deployment's
job. mkit supplies the fingerprint to bind: the repository, the
signer, the pack_id, and the byte count. A retry sends a
byte-identical signed body (§7.1), so a digest binding holds across
it.
Bearer deployments. A deployment that authenticates callers with
Authorization: Bearer MUST advertise header="Payment-Authorization"
in every MPP challenge it sends, so the payment credential never
displaces the bearer token.
Caching. A 402 response carries Cache-Control: no-store. A
response that carries a Payment-Receipt field (MPP) or a
PAYMENT-RESPONSE field (x402) passes that field through to the client
and carries Cache-Control: private.
CORS. A deployment that serves browsers exposes the challenge and
receipt headers (Access-Control-Expose-Headers: WWW-Authenticate,
Payment-Receipt, PAYMENT-REQUIRED, PAYMENT-RESPONSE) and allows the
credential request headers (Payment-Authorization,
PAYMENT-SIGNATURE, Authorization, and Accept-Payment). A preflight OPTIONS
request never requires payment.
Redaction. Servers and clients MUST keep payment credentials and
receipts out of logs, traces, error messages, and analytics. This
covers every header a client attaches from its admission helper,
Payment-Receipt, PAYMENT-RESPONSE, and the helper's output.
Client behavior
A client builds AdmissionRequired{challenges, description} from the
AdmissionChallenge detail. A 402 response without that detail, for
example one an intermediary produced, yields an AdmissionRequired
with no challenges and an empty description. In both cases the client
also keeps the response's WWW-Authenticate and PAYMENT-REQUIRED
fields for the helper. The client never parses a problem+json or
other non-Connect body, and never retries AdmissionRequired
automatically.
A client implementation must keep the HTTP status of an error
response, so that it still recognizes a raw 402 that carries no
Connect detail (informative; an M3 implementation requirement). The
connectrpc 0.9 client drops that status when it builds its error
(client/mod.rs, lines 2173–2255). Without a helper it fails the operation and shows the
description and the challenge schemes to the user.
Admission helper. A client MAY run a user-configured admission
helper, named by the user-scoped admission_helper key:
- Repository configuration MUST NOT set
admission_helper. SPEC-CONFIG-SECURITY classifies it as UNSAFE. - The client runs the helper only for a remote whose endpoint equals
the user-scoped
trusted_remote_endpoint(SPEC-CONFIG-SECURITY §3.4). - The client runs the helper at most once per logical operation.
The client writes one JSON object to the helper's standard input and closes it:
{
"origin": "https://vcs.example",
"repository": "ed25519-<64 hex>/name",
"procedure": "/mkit.transport.v1.TransportService/BeginUpload",
"description": "text from the detail, or empty",
"challenges": [{ "scheme": "…", "value": "…" }],
"headers": {
"www-authenticate": ["Payment id=\"…\", …"],
"payment-required": ["…"]
}
}origin is the canonical origin of §7.1. challenges holds the
detail's entries, in order, and is empty when the 402 had no detail.
headers maps each lowercase header name to the list of that field's
values, in the order received; it holds only www-authenticate and
payment-required, and omits a name the response did not carry.
The helper answers with exit status 0 and one JSON object on standard output that maps header names to string values:
{ "Payment-Authorization": "Payment eyJ…" }For an MPP challenge the helper returns Authorization: Payment …,
or Payment-Authorization: Payment … when the challenge sets
header="Payment-Authorization". For an x402 challenge it returns
PAYMENT-SIGNATURE (informative). Any other exit status, output that
is not such an object, an empty object, a header name that is not an
HTTP token, two names that differ only in case, or a value that
contains a control character other than horizontal tab aborts the
operation. A header value longer than 8,192 bytes also aborts it.
Header allowlist. The client attaches a helper header only if its name is on the remote's allowlist. Names compare case-insensitively.
- The default allowlist is
Payment-Authorization,PAYMENT-SIGNATURE, andAuthorization.Authorizationis on it only when the client does not already sendAuthorizationto that remote, that is, when noMKIT_API_TOKENbearer token (§7.3) is configured for it. - The user-scoped
remote.<name>.admission_headerskey adds header names to the allowlist of the remote<name>(SPEC-CONFIG-SECURITY). - A helper header that is not on the allowlist, or that names a header the request already carries, fails the operation. The error names that header.
Hard-reserved headers. These names are never allowed, whatever the
configuration says. An admission_headers entry that names one has no
effect, and the client warns naming it:
- the auth v2 envelope headers
X-Public-Key,X-Signature,X-Digest,X-Created-At,X-Expires-At,X-Envelope-Version,X-Audience,X-Repository,X-Content-Commitment; the write-grant headerX-Write-Grant;X-Mkit-Ref(§7.9); every header beginningX-Mkit-; everyX-Forwarded-*header; Host, everyContent-*header,Transfer-Encoding, everyConnect-*header,Cookie,Idempotency-Key, andAuthorizationwhen the client already sends it to that remote (§7.3);- the hop-by-hop headers
Connection,Keep-Alive, everyProxy-*header,TE,Trailer, andUpgrade.
A later mkit specification that defines a request header MUST name it
X-Mkit-* or add it to this list.
mkit config SHOULD refuse to write a reserved name into
remote.<name>.admission_headers. When a client loads such an entry,
it SHOULD warn with this fixed wording, where <header> is the entry
and <name> the remote:
warning: ignoring reserved header `<header>` in remote.<name>.admission_headers (see SPEC-TRANSPORT-CONNECT §5.1)Retry. With the allowed headers attached, the client sends the same logical operation once more. While the auth v2 envelope is still valid (at most 300 seconds, §7.1), it reuses the nonce, the timestamps, and the signed body. After the envelope has expired, it signs a new operation over the same request body. The helper headers are not part of the auth v2 canonical string. They stay attached to every transport retry of that attempt on the backoff ladder. A second challenge for the same operation fails it; the client does not run the helper again.
Receipts. A client keeps a Payment-Receipt or PAYMENT-RESPONSE
field it receives out of logs and traces, and MAY report it to the
user.
6. Pack transfer streaming
UploadPack (client-streaming) and DownloadPack (server-streaming)
carry PackChunk,
which duplicates
mkit.rpc.v1.ssh.PackChunk's
field layout exactly (pack_id, offset, data, last — same
numbers, same types). This is a deliberate wire-identical duplication,
not a new chunking format: the bytes a client streams over Connect are
parseable by anything that already speaks the SSH/enc PackChunk
shape.
6.1 UploadPack (client-streaming)
UploadPackRequest is a oneof of header (UploadPackHeader{
pack_id, total_bytes }) and chunk (PackChunk). The client MUST
send exactly one header message first, then zero or more chunk
messages in ascending contiguous offset order, ending with a
chunk.last = true message (an empty pack still sends one last =
true chunk with empty data, matching the SSH wire's convention).
The server MUST reject the stream — before returning
UploadPackResponse, and MUST NOT create or overwrite the destination
pack on rejection — if:
- the first message is not
header; - any
chunk.pack_iddoes not matchheader.pack_id; - any
chunk.offsetdoes not equal the running received-byte count; - the stream ends without a
chunk.last = truemessage; - the received byte count does not equal
header.total_bytes; or BLAKE3(received bytes)does not equalheader.pack_id.
These are the same checks SPEC-TRANSPORT §4.2 already requires of the
SSH server's UploadPack handling, restated for a Connect stream
instead of an SshFrame sequence.
6.2 DownloadPack (server-streaming)
DownloadPackResponse is the receive-side mirror: a oneof of
header (DownloadPackHeader{ total_bytes }) and chunk
(PackChunk). The server sends exactly one header message first,
then a sequence of chunk messages ending with chunk.last = true.
If the requested pack_id is absent, the server returns
not_found before sending any message (never a zero-chunk stream).
6.3 Streaming on Cloudflare Workers — design answer and open risk
Reference-server behavior.
The reference Worker (§7.1) is the one deployment target where
Connect streaming has a known, previously-hit failure mode:
apps/repo-worker/README.md §"WatchRefs / streaming (issue #705, building on the #697 spike)"
documents that worker::WebSocket::events() returns a borrowed
EventStream<'ws>, which cannot satisfy a generated server-streaming
trait method's 'static + Send ServiceStream<T> bound — that
constraint is why mkit.repo.v1.RepoService.WatchRefs is served over
a hand-rolled WebSocket instead of Connect server-streaming today.
DownloadPack faces the identical shape of problem (server-to-client
streaming on Workers), and the M2 Task 0 spike (mkit#697) prototyped
the design answer this document specifies: bridge the source of
events (there, a Durable Object /watch WebSocket; for DownloadPack,
a chunked read from R2/filesystem) into an owned
futures_channel::mpsc channel, drained by a
wasm_bindgen_futures::spawn_local task, so the channel's Receiver
— not the borrowed source stream — is what gets boxed into the
generated trait's ServiceStream<PackChunk>. Because the channel is
owned (no borrowed lifetime), it satisfies 'static + Send with zero
unsafe code. A conforming reference-Worker implementation of
DownloadPack MUST use this bridge pattern (or an equivalent owned
intermediate channel) rather than attempting to box a borrowed stream
directly.
The spike also identified a companion requirement: a Worker's fetch
handler that buffers the entire response body before returning it
(http_resp.into_body().collect().await.to_bytes(), the pattern
apps/repo-worker's unary path uses today) defeats streaming
end-to-end regardless of how the RPC handler itself produces chunks —
the response construction MUST use a true streaming response
(Response::from_stream or equivalent) instead of collect-then-return.
Known risk, not yet resolved: the spike verified the bridge
mechanically (the DO WebSocket opens, drains real events, and
translates them correctly inside wasm_bindgen_futures::spawn_local)
and verified cargo check --target wasm32-unknown-unknown compiles
clean, but did not verify client-visible delivery end-to-end over
HTTP — a test client received zero bytes even after the bridge
processed a real event, against a local wrangler dev run, with the
root cause (a wrangler dev limitation vs. a remaining adapter bug)
not isolated. This document specifies the bridge as the correct
design, informed by the mechanical proof; it does not claim the
design is proven to deliver bytes to a real client yet. The reference
Worker issue (mkit#699) and the streaming pack transfer issue
(mkit#702) MUST re-verify end-to-end delivery (ideally against a real
Cloudflare deployment, not only wrangler dev) before either is
considered done — a proto/spec review is not a substitute for that
runtime verification.
mkit-server (§7.2) and the native CLI client (§7.3) run outside
Workers (axum/hyper and Tokio respectively) and are not subject to the
non-'static WebSocket::events() constraint at all — the bridge
above is a Workers-specific workaround, not a general requirement of
this protocol.
7. Deployment targets
One proto, one generated codebase, three consumers — no per-target dialect:
7.1 Reference Worker
Reference-server behavior.
A connectrpc and workers-rs service
(apps/vcs-worker, mkit#699), reusing
apps/repo-worker's proven patterns: vendored generated/ staged by
build.rs (MKIT_TRANSPORT_CODEGEN=1 to regenerate via
connectrpc-build against the canonical
proto/mkit/transport/v1/transport.proto, no protoc dependency on the
default build path — Cloudflare Workers Builds and CI images lack a
protoc new enough for edition = "2023"), R2 for pack storage, and a
single global Durable Object for ref CAS. That is a single-repository
deployment in §7.4's terms. A multi-repository deployment shards its
state instead: one namespace coordinator per namespace, one strongly
consistent ref shard per (repository, ref), and eventually consistent
repository index shards (§7.9). Unlike repo-worker's
open-write demo, all mutating procedures require the versioned signed-write
contract below. This verifies the writer's identity. Write authorization,
which decides whether that identity may write to the repository, is §7.5.
The deployment config supplies AUTH_AUDIENCE (exact canonical
HTTP(S) origin) and, on a single-repository deployment, AUTH_REPOSITORY
(the configured repository identity, §7.4). Repo Worker instead obtains
the repository identity from the decoded room; Keys Worker uses keys.
Protocol requirement. Host or forwarded request headers MUST NOT establish the server's
trusted audience.
Auth v2 contract
Protocol requirement.
All producers and verifiers MUST use the following eight newline-separated UTF-8 fields, with no final newline:
mkit-write:v2
<audience>
<repository>
<full procedure>
<content commitment>
<created epoch milliseconds>
<expiry epoch milliseconds>
<nonce>The signature is strict Ed25519 over the 32-byte BLAKE3 of those bytes. The
origin is the URL's lowercase ASCII HTTP(S) origin with no userinfo, path,
query, fragment, trailing dot, or default port. <repository> is the full
repository identity of §7.4, exactly as X-Repository carries it.
Audience, repository, and procedure MUST be nonempty ASCII byte strings
containing only bytes 0x21 through 0x7e. Their byte lengths MUST NOT exceed
512, 255, and 512 respectively. Procedure MUST begin with /. The audience
additionally obeys the canonical-origin rules above. No normalization is
performed before comparison or signature verification.
Hexadecimal fields in the signed commitment, nonce, public key, and signature
MUST use lowercase hexadecimal with their exact widths: 64 characters for a
nonce, public key, commitment digest, pack id, ticket id, or subtree hash,
and 128 for a signature. Unary X-Digest MUST equal the body commitment's
digest. A pack or part stream MAY omit X-Digest; if supplied once, its value
is unused and MUST NOT affect envelope verification. The stream's content
commitment remains binding. Every decimal field MUST contain only ASCII
0 through 9, with no sign, whitespace, separator, or leading zero unless
the field is exactly 0. Creation and expiry MUST each be in
0..=9223372036854775807. Pack byte counts MUST be in
0..=18446744073709551615; part indexes in 0..=4294967295; part lengths in
1..=18446744073709551615. Overflow, extra commitment fields, and noncanonical spellings
MUST be rejected, independently of the implementation language.
A unary commitment is body:<64 lowercase hex BLAKE3 of exact request bytes>.
An UploadPack commitment is pack:<64 lowercase hex pack id>:<decimal byte count>.
An UploadPart commitment is part:<ticket>:<index>:<subtree-hash>:<len> (§7.6).
The streaming handler MUST compare both fields with the first UploadPack
header before reserving quota or reading chunks, and verify the actual byte
count and BLAKE3 before publishing the immutable object.
Required headers are X-Envelope-Version: 2, X-Audience, X-Repository,
X-Content-Commitment, X-Created-At, X-Expires-At, Idempotency-Key,
X-Public-Key, and X-Signature; unary requests additionally carry X-Digest
matching the body commitment. Nonces are 32 cryptographically random bytes
encoded as 64 lowercase hexadecimal characters, generated once per logical
operation and retained with timestamps across every transport retry.
Here, "any auth v2 header" means a signature-triggering envelope header,
other than the addressing header X-Repository (§7.4). The trigger set is
X-Public-Key, X-Signature, X-Digest, X-Created-At, X-Expires-At,
X-Envelope-Version, X-Audience, and X-Content-Commitment.
X-Repository and Idempotency-Key alone do not indicate a signed request.
If any trigger occurs, the complete envelope, including its required signed
repository and nonce, MUST verify; malformed or partial signed metadata MUST
NOT fall back to anonymous access. Repeated envelope fields MUST be rejected.
A request MAY also carry X-Write-Grant, a grant that authorizes the
signer (SPEC-WRITE-GRANTS §4.2).
It is not a signed header: it is not part of the canonical string above,
and the signature does not bind it.
Signed reads. The same contract signs read RPCs
(SPEC-WRITE-GRANTS §9.2):
ListRepos, ListRefs, ReadRef, PackExists, DownloadPack, IssueObjectUrl,
and GetReceipt,
each with a body: commitment over the exact request body. A client
that has a signer for a remote MUST sign every read RPC to it. A request that carries any auth v2 header MUST verify in full, or it
is unauthenticated. A signed read is idempotent: the server checks the
validity window only, and records and looks up no replay entry, so the
replay rules below apply to writes only. GetServerInfo,
GetGrantEpoch and SetGrantEpoch stay unsigned.
GetAuthorityGeneration { namespace } -> { generation } and
SetAuthorityGeneration { signed_statement } -> { generation } also carry no
auth-v2 envelope or repository selector. They are disabled by default;
SPEC-SERVER §6.2.1 defines deployment-authority statement verification, monotonic
targets, and the bounded completion barrier. Pending completion is unavailable
with Retry-After in delay-seconds. The generation is independent of grant epochs.
When a pending SetGrantEpoch or SetRepoVisibility call returns unavailable, its retry hint is a Retry-After response header in delay-seconds (SPEC-WRITE-GRANTS §5.3, §9.1).
The validity interval MUST be positive and at most 300,000 ms; sender clocks may lead the server by at most 30,000 ms. At ingress, server time MUST be nonnegative, creation MUST be at most server time plus 30,000 ms, and server time MUST be at most expiry. Both boundaries are inclusive. Expired requests MUST be rejected, including resumed requests and requests whose results remain cached; a replay never extends envelope validity. Missing or unsupported auth versions MUST fail closed.
Reference-server behavior. A signed write authenticated within that window retains the existing absolute commit-deadline cap of signed expiry plus 30,000 ms, further bounded by its configured apply window and applicable lease or ticket deadlines. Arithmetic saturates at the host time representation's maximum rather than wrapping. This commit margin does not permit ingress, resume authentication, or committed-result lookup after signed expiry.
Protocol requirement.
A valid signature alone is insufficient replay protection. Each service MUST
persist, for every signed write except the replay-exempt uploads of §7.6
(the part path: UploadPart, CompleteUpload, and a ticketed
UploadPack), a nonce record scoped to
audience/repository/signer, together with the full authenticated operation
fingerprint. Reusing a nonce for a different operation MUST fail.
Same-operation retries MUST return the saved result and MUST NOT repeat
mutable effects or charge quota again. A signed ref write MUST atomically commit
its replay result, applicable quota, and reference changes (including both sides
of a paired advance or deletion), as SPEC-SERVER §3.1 requires. A failed
acceptance guard commits none of that unit's effects. Configured notifications
occur only after durable commit. Replay records MUST remain until
the signed expiry has passed.
A server processes a signed write in this order:
- Authenticate. Verify the signature and the validity window. This step writes no state.
Look up the replay record for (audience, repository, signer, nonce):
- a stored fingerprint that differs from the request's is
invalid_argument; - a
committedoperation returns its stored result, except that a committed write awaiting its §15 receipt MUST finish signing with the key selected at apply, persist the signed envelope, and then return that same result with the receipt. The server also MUST finish durable pending signatures independently of retries (SPEC-SERVER §15.1). If signing still fails, it returns retryableunavailablewithout applying again; - an
in_flightoperation returns a retryableaborted, without reaching admission. A single-repository deployment MAY resume an un-ticketedUploadPackwith the same signed nonce by re-reading its stream instead.
Only a new operation continues beyond this step, apart from the scoped upload resume above. So a retry never presents a spent payment credential again, and one signer's result is never served to another.
- a stored fingerprint that differs from the request's is
- Authorize the write (§7.5), then run admission (§5.1)
(for
BeginUpload, after the live-ticket check of §7.7). A challenge or a denial allocates nothing. Because the lookup precedes admission, a retry stays answerable after the caller has exhausted its budget. - Reserve. Insert the
in_flightreplay record. - Apply the operation's effects.
- Commit the stored result.
Concurrent duplicates of a new operation that both complete step 2 before either reserves may each reach admission; an admission hook MUST treat payment credentials as single-use (e.g. by challenge id) so at most one is charged. For unary writes, reservation, acceptance effects, and saved result form one atomic unit with no externally observable in-flight window; holding admission does not create one.
For a unary write, steps 4 to 6 commit together in the atomic unit
required above. §7.7 states each RPC's durable effects. A challenge,
a PendingVerification answer (§7.6), a membership-lag unavailable
(SPEC-SERVER §9.4), and a synchronous inspector's fail_closed
unavailable (SPEC-SERVER §11.2) MUST NOT be stored as replay results: the
server leaves no record for the attempt, and removes any in_flight record it
inserted, so a retry with the same nonce is evaluated again from step 2.
Only permanent refusals with codes invalid_argument, not_found,
already_exists, permission_denied, failed_precondition, out_of_range,
or unimplemented MAY be stored as terminal rejection results. Admission
challenges MUST NOT be stored, regardless of their code; rejection details
MUST NOT be stored in the replay result. Every other code, including an unknown
future code, MUST NOT be stored as a terminal rejection. Committed successes
and these permanent refusals are final; uncommitted pending or transient attempts are
re-evaluated on retry. A stored fingerprint mismatch is still checked before
returning any terminal result or resuming in-flight work.
When receipt signing is enabled, that unit also fixes the statement bytes and signing key and durably records responsibility for the pending signature (SPEC-SERVER §15.1). A post-apply signing failure is the committed-result exception above; it MUST NOT discard the replay record or create a second commit.
Signed reads skip the replay ledger and this order's steps 2 and 4 to 6 ("Signed reads" above): a read is idempotent, so the server checks only the signature and the validity window.
A ticketed upload resumes through its ticket and part receipts (§7.6),
under the per-RPC lifecycle of §7.7. The single-repository un-ticketed
UploadPack exception above may resume through its replay record. An
unreachable ledger or failed quota read fails closed.
Reference-server behavior. AUTH_AUDIENCE is explicitly configured for
every deployment and local development origin.
7.2 mkit-server (library and adapters)
Reference-server behavior.
The same generated TransportService binding (mkit-server's connect
module over its pipeline) is served on Workers by the mkit-server-worker
adapter (§7.1) and in process by the conformance host
(mkit-server-conformance), one handler implementation over different
storage backends (R2 and Durable Objects on Workers; the .mkit on-disk
layout, per SPEC-WORKTREE/SPEC-CONCURRENCY,
in process). There is no standalone self-hosted server binary: the former
mkit-server serve deployment was removed.
mkit serve <path> (the CLI) is only the mkit+ssh:// forced-command
server, speaking the SSH-frame protocol on stdin/stdout. Its former HTTP
mode (--http, mkit#700) and encrypted listener (--listen-enc) are gone,
and with them the axum-hosted TransportServer of
mkit-transport-connect's former server feature.
7.3 Native CLI Connect client
Reference-server behavior.
mkit-client owns the runtime-independent protocol implementation: canonical
messages, signed envelopes, Connect framing, and response decoding.
mkit-sync::ClientSource drives native repository operations over its per-RPC
calls. The CLI routes mkit+https and mkit+http push, fetch, pull, and clone
through that source.
mkit-transport-connect retains the synchronous Transport API as a native
host adapter. It supplies TLS, credentials, grants, admission recovery,
scheduling, and progress callbacks. Its public epoch and visibility helpers
also delegate to the protocol client. Native HTTP uses a current-thread runtime,
HTTP/1.1, gzip or identity, and bounded streaming; the
adapter guide
records differences from the previous native driver. These are implementation
choices and do not change the normative wire contract.
The native CLI accepts additional trusted PEM certificates from
http.sslCAInfo; MKIT_SSL_CA_FILE takes precedence over that configuration
key. The selected file augments the Mozilla roots for all Connect HTTPS RPCs,
including streaming uploads and downloads. Certificate-chain and hostname
verification remain enabled. A missing, unreadable, empty, or malformed
selected file is a hard error. The CA file contains certificates only, not
private keys. These settings cover Connect remotes only; the self-updater is
excluded by design and S3 remotes are not yet covered. Wasm/browser clients
retain browser-managed certificate trust; these native settings do not affect
them. This changes no wire contract.
This crate is now the implementation mkit-cli's remote_dispatch
constructs for the mkit+https:// scheme (and loopback-only
mkit+http://), replacing mkit-transport-http there — see
rust/crates/mkit-cli/src/remote_dispatch/mod.rs. mkit-transport-http
itself is NOT deleted (its sparse-checkout/pack-shards extensions
have no mkit.transport.v1 equivalent yet, §8), so SPEC-TRANSPORT §5 is
marked superseded rather than removed.
Auth modes (mkit#699 follow-up, closing the gap this document
originally flagged in "Reference implementation" above):
ConnectTransport supports two independent, additive auth modes —
a deployment can require either, both, or neither:
- Bearer token (unchanged, #700/#701):
MKIT_API_TOKEN, read from the environment atconnect()time, sent asAuthorization: Bearer <token>on every call. This ismkit-transport-http's scheme (SPEC-TRANSPORT §5.2) and is what bearer-modemkit-serverdeployments (§7.2) expect. - Ed25519 envelope:
mkit-clientsigns the auth v2 contract in §7.1 for reads and writes, with an exact request body commitment for reads and unary writes, including the framedDownloadPackrequest, and the declared pack id and length for streamingUploadPackwrites.GetServerInfoand the grant-epoch RPCs remain unsigned.transport_auth = envelopeis user-scoped and repository-forbidden. The CLI requires exact user-scopedtrusted_remote_endpointapproval before resolving the commit-signing Ed25519 identity, independently of bearer-token presence. Domain separation alone does not grant a repository permission to invoke ambient signing.
Verified live: real mkit push/clone/pull (envelope auth) against a
local wrangler dev instance of apps/vcs-worker — see
apps/vcs-worker/README.md "Known limitations".
ConnectTransport::supports_atomic_advance() lazily reads atomic_advance
from GetServerInfo (§2.1) and caches discovery for the transport instance's
lifetime. It reports true only when a validated v2 advertisement explicitly
sets atomic_advance = true. Servers that return unimplemented, failed
discovery after the retry ladder, and invalid advertisements retain the
conservative false default. There is no local opt-in. Transactional SQLite
and Durable Object deployments can therefore enable the existing packmap
re-baseline gate automatically; the gate still requires advertised atomicity
before resetting a chain (mkit#521).
Every Transport method ConnectTransport implements is driven through
the same mkit_core::protocol::retrying/BackoffIterator ladder
mkit-transport-http/-ssh/-enc share (mkit#703, mkit#790): a
transient ConnectionFailed or the Connect codes §5 maps onto a
5xx/429-equivalent (unavailable, resource_exhausted), or aborted
(§5), is retried per
SPEC-TRANSPORT §7's is_retryable classification, read off the
TransportError §5's client-side mapping produces rather than directly
off an HTTP status. Each retry re-issues the whole RPC from scratch
(including, for DownloadPack, a fresh stream) — nothing from a failed
prior attempt is reused, except the signed-write identity: update_ref and
advance_refs allocate one nonce and validity window per logical call
(RetryIdentity) and send them on every attempt. The ladder re-issues
UpdateRef and AdvanceRefs after a retryable failure. A re-issue never
applies a write twice: a landed first attempt falsifies the precondition
the re-issue carries, and a server with the replay ledger returns the
stored result for the reused nonce (§7.1). A CAS conflict itself is never
retried here (is_retryable excludes TransportError::RefConflict, and
AdvanceRefs reports conflicts as successful responses, §4); that stays
caller-level policy. Against a deployment without the replay ledger
(mkit-server's Open, Bearer and TransportIdentity modes), a
re-issue can report a conflict for a write that landed, so the caller
follows SPEC-TRANSPORT §7's read_ref rule.
The regression tests
(rust/crates/mkit-transport-connect/tests/roundtrip.rs,
tests/retry.rs) drive every Transport verb — including multi-chunk
UploadPack/DownloadPack streaming, all three AdvanceOutcome
variants, and the retry ladder itself (a flaky in-process server that
fails the first N calls with a retryable error class, and a
non-retryable-error/ladder-exhaustion pair) — through a real in-process
TransportService server (memory-backed, not R2/DO), per this issue's
testing decision: a real server, not a mock standing in for one.
7.4 Repository addressing
A deployment serves one repository or many. Every repository RPC names its repository, so one client and one proto serve both kinds of deployment.
Grammar. A repository identity is ASCII text with this grammar (ABNF, RFC 5234):
repository = namespace "/" name / name ; bare name: single-repository deployments only
namespace = ed25519-ns / address-ns
ed25519-ns = "ed25519-" 64HEXLC ; owner: that Ed25519 public key
address-ns = "0x" 40HEXLC ; owner: a key whose derived address matches
name = lead *99tail
lead = %x61-7A / DIGIT ; a-z 0-9
tail = lead / "." / "_" / "-"
HEXLC = DIGIT / %x61-66 ; 0-9 a-fIn the default self_certifying namespace mode, namespaces are self-certifying:
the namespace itself names its owner,
so a verifier needs no registry to find the owner. The ed25519- form
names an Ed25519 public key. The 0x form names a 20-byte address;
SPEC-WRITE-GRANTS §4.1
defines how an owner key derives it.
A self-certifying deployment MUST NOT resolve other forms through a registry.
A deployment MAY instead opt in to namespace_mode = authority under
SPEC-SERVER §6.2.2.
That mode accepts opaque namespaces matching [a-z0-9][a-z0-9._-]{0,71},
including lowercase UUIDv7 names, and MUST reject root and the ed25519-
and 0x prefixes. The two deployment grammars are disjoint. The deployment
is the trust root; the name cannot verify ownership. Multi addressing,
Authority-hook authorization, authority fencing, and namespace_policy = any
are required. The mode MUST be validated at startup and MUST NOT be selected
by a request. Owner-signed grants, grant epochs, and visibility statements
are refused with the uniform §6.2.2 error; signed visibility envelopes and
deployment URL tokens retain their separate authorization rules.
A deployment under any MUST NOT claim complete deployment-wide takedown.
The auth-v2 wire envelope and RPC schema do not change. A client MUST accept
the union of the two namespace grammars when it parses a remote URL: the
self-certifying grammar above and the authority-mode grammar of
SPEC-SERVER §6.2.2.
The grammars are disjoint, because an authority-mode namespace is never root
and never starts with ed25519- or 0x, so the union is unambiguous; a URL
does not tell the client which mode the server runs. The server still enforces
only its own mode's grammar and answers invalid_argument for the other.
Grants, grant epochs, and visibility statements keep the self-certifying
grammar, and a client MUST NOT build one for an authority-mode namespace.
Identities are lowercase only, so two spellings never name one
repository. A server MUST reject an identity that does not match the
grammar, including one with uppercase hexadecimal digits, with
invalid_argument. The longest identity is 173 bytes (ed25519-, 64
hexadecimal digits, /, and a 100-byte name). That fits within the
255 bytes the reference auth v2 validator allows for <repository>
(§7.1).
Carriage. A client MUST send the X-Repository header on every
repository RPC, read or write. The exceptions are GetServerInfo
(§2.1), which MAY carry the header, and the namespace RPCs
GetGrantEpoch and SetGrantEpoch, which carry none
(SPEC-WRITE-GRANTS §5.3). A server treats a request without
the header as the deployment kind below requires. On a signed request,
X-Repository MUST equal the signed <repository> field byte for
byte. Envelope verification
detects a mismatch, so a mismatch is unauthenticated. For Connect RPCs,
Host, path, and forwarded headers MUST NOT select the repository. Plain HTTP
object serving instead selects the repository by path and MUST ignore
X-Repository, as SPEC-HTTP-OBJECTS requires.
Single-repository deployments. The deployment configures exactly
one repository identity. It MAY be a bare name. A request without
X-Repository resolves to that identity on unsigned RPCs; a signed
request without X-Repository is unauthenticated (§7.1). A request that carries any
other well-formed identity is not_found. The §7.1 reference Worker is
a single-repository deployment.
Multi-repository deployments. The deployment routes each RPC by
X-Repository. A missing header on an unsigned RPC and a bare name
are both invalid_argument, because only the namespace "/" name form is
valid here. A read RPC on a repository that does not exist is
not_found, the same as a missing pack or ref.
Isolation. Refs, pack membership, packmap chains, and replay
records are per repository. Quota and admission scope are set by the
deployment. They are not required to be per repository. A server MUST
NOT let an RPC on one repository read or change another repository's
state. A server MAY store identical immutable bytes once across
repositories, but PackExists and DownloadPack MUST answer only for
packs that are members of the named repository. Otherwise a caller
could learn another repository's contents. A server MUST NOT answer any
RPC by consulting another repository's membership: no response may act
as an existence oracle for content held elsewhere.
Creation. A repository comes into existence with its first authorized write (§7.5). This service has no create-repository RPC.
Client. The path of the remote URL is the repository identity:
mkit+https://host/<namespace>/<name> names <namespace>/<name>. The
client trims leading and trailing / from the path. An empty path
names the bare identity default, so a single-repository deployment
reachable with an empty path configures default. A client MUST send
the same identity in X-Repository on every repository RPC, reads
included, and in the signed <repository> field on writes.
ssh and enc (informative). The ssh and enc transports carry no
X-Repository; the listener, not the wire, chooses the repository. For
ssh the addressing input is the path argument of the forced command.
Plain mkit serve <path> serves the one repository under that path, as
it always has. mkit serve --root <DIR> is the multi-repository form:
the path names a <NAMESPACE>/<NAME> resolved to the directory
<DIR>/<NAMESPACE>/<NAME>, one repository per process, and writes run
the ed25519- owner rule of §7.5 against the --principal key the
sshd configuration asserts (SPEC-TRANSPORT §4.1). For enc (deprecated),
a listener under multi-repository addressing binds every session it
accepts to one configured repository (SPEC-TRANSPORT-ENC §6). Both carry the
same implicit-membership rule in place of §7.6's upload tickets: packs
uploaded and verified in a session (at most seven before a packmap
write) may be consumed into membership by that session's packmap write
— never by a later session's. The packmap check only refuses: the
node's prev is absent or the packmap value the write replaces, the
node and every pack it lists — at most 1,024 — are pending in this
session or already members (a pending packlist listed as a pack is
refused: a packlist is a node, not a pack), or the write is refused. The on-disk layout
under the repository's path is unchanged,
and the frozen mkit.rpc.v1.ssh protocol is untouched.
7.5 Namespace and write policy
A deployment applies a namespace policy and a write policy to every
signed write except the part path (§7.6). Together they decide whether an authenticated principal
may write to the repository in X-Repository.
Namespace policy. namespace_policy is one of:
allowlist: the deployment lists the owner namespaces it serves. A write to any other namespace ispermission_denied. This is the default for a stock multi-repository deployment.any: every self-certifying namespace is served. This is an explicit opt-in. Every new key is a new namespace, and each new namespace starts with a fresh default quota. A deployment underanyMUST therefore configure a non-default admission step (§5.1). Without one it MUST refuse to start, unless the operator passes an explicit unsafe override (for example, an "unsafe open namespaces" setting; the exact spelling is up to the implementation).
A single-repository deployment advertises single-repository in
GetServerInfo (§2.1). single-repository is advertised, never
configured; it tells a client that bare names and a missing
X-Repository are accepted.
Write policy. write_policy is one of:
open: any valid auth v2 signer may write. Only a single-repository deployment MAY runopen. A stock multi-repository deployment MUST NOT runopen.owner: a write needs authorization for the repository inX-Repository.
Under owner, a write to <ns>/<name> is authorized when any of
these holds:
nshas theed25519-form and the auth v2 signer is that key.- A valid grant authorizes the signer (SPEC-WRITE-GRANTS §6 and §7). The Connect server enforces this path for configured multi-repository deployments. Informative: the §8.2 ref-scope rules and grant-epoch RPCs are implemented for configured multi-repository deployments.
- A deployment-defined authority source authorizes the signer for the repository. An example is a ledger's delegated-key record, checked against verified state. The deployment MUST document the source and MUST fail closed when it cannot read that source.
An 0x namespace owner cannot sign auth v2, which is Ed25519-only. So
an 0x namespace takes writes only through grants or through a
deployment-defined authority source (informative). Reads of a private
repository are authorized by the same three rules with the read
capability, and an unauthorized one is not_found
(SPEC-WRITE-GRANTS §9).
Order. The server authorizes a write after authentication and after the replay-record and saved-reply check §7.1 requires, and before it allocates any quota, reservation, or replay record. A rejection allocates nothing.
Creation signals. The authorization and admission steps see
whether the write creates a namespace and whether it creates a
repository (creates_namespace and creates_repo). §5.1 requires the
server to give both to its admission step.
7.6 Upload tickets and resumable parts
Protocol requirement.
A ticket reserves an upload before the client sends any pack bytes. It binds the ref whose accepted advance may consume it (§7.9). Parts let a client upload a large pack in pieces and resume after a failure, without the server ever holding a whole part in memory.
BeginUpload. BeginUpload(repository, ref, pack_id, bytes) is a
unary, signed write with a body: commitment (§7.1). It returns one
of:
AlreadyPresent: the pack is already a member of this repository.Ticket{id, part_size, expires, token}: a reservation and an upload session.
For the same signer, ref, and pack before an advance consumes the
ticket, BeginUpload returns the existing ticket: the same id and the
same upload session, never AlreadyPresent. Membership is eventually
consistent (§7.9), so a server MAY return a ticket for a pack that is
already a member. That costs only a re-upload.
AlreadyPresent and a returned live ticket run no admission.
When inspection hides a pack because it contains a flagged or held id,
BeginUpload MUST NOT return AlreadyPresent for it and MUST answer as
if the pack were absent (SPEC-SERVER §10.3).
Ticket token. token is an opaque, server-authenticated value that
binds the ticket id, audience, repository, signer, pack_id, bytes,
part_size, expires, and the id of the key that authenticates it.
Token authenticity is verified without consulting strongly consistent metadata.
When namespace authority fencing is enabled, the token also binds the authority
generation and the server MUST check it against the current namespace generation
before ticket-only staging and completion (SPEC-SERVER §6.2.1). A client treats the token as opaque. How the server
authenticates tokens is deployment-defined (informative: a message
authentication code under a deployment secret, rotated by key id).
Parts. A client uploads a pack of more than part_size bytes in
parts. UploadPart is a client-streaming, signed RPC, like
UploadPack:
- The first message is a header
{ticket_token, index}. - The following messages carry the part's bytes in order.
- The response is a part receipt.
The part's commitment is:
part:<ticket>:<index>:<subtree-hash>:<len><ticket> is the ticket id. A ticket id is 64 lowercase hexadecimal
digits. <index> is the zero-based part index in
decimal. <subtree-hash> is the part's BLAKE3 subtree chaining value
as 64 lowercase hexadecimal digits. <len> is the part's byte count in
decimal. The signed headers commit to the whole part before the client
sends any byte of it.
Every part except the last is exactly part_size bytes, a power of two
of at least 8 MiB. The last part holds the rest and is not empty. An
upload has at most max_parts parts (§2.1). Part i covers pack bytes
from i × part_size. Its subtree hash is the BLAKE3 chaining value of
those bytes as a non-root subtree at that offset, so the server can
merge the part hashes into the pack's root hash. Parts merge by
BLAKE3's left-balanced tree rule.
For UploadPart, the header's token ticket id and index MUST equal the
commitment's <ticket> and <index>, and <len> MUST equal
part_size for every part but the last.
Golden vectors (informative;
SPEC-CONVENTIONS §5).
Vector inputs are not stored: input byte i is i mod 251.
rust/tests/golden/uploads/subtree-merge.jsonpins, per vector, each part's index, offset, length and subtree chaining value, and the merged root, which equals the BLAKE3 hash of the whole input. The 8 MiB vectors cover 2, 3, 5 and 8 parts, with short last parts of 1, 1023, 1024, 1025 and8 MiB − 1bytes and one upload of only full parts. Vectors marked"test_geometry": trueuse 1 KiB and 4 KiB parts, below the minimum part size; they are test-only geometry for deeper, unbalanced trees (2 to 17 parts).rust/tests/golden/uploads/MANIFEST.txtpins the file's BLAKE3.rust/tests/golden/auth-v2/part.jsonpins a signedUploadPartenvelope: itspart:commitment (the second part of the 8 MiB two-part vector), canonical string, signing digest and Ed25519 signature.
scripts/golden/blake3_subtree_ref.py checks both files against an
independent pure-Python BLAKE3.
The server verifies the ticket token and the commitment before it reads
any data. It hashes the data as it streams it to storage and MUST NOT
need to hold a whole part in memory. A subtree hash or length that
differs from the commitment is invalid_argument. On success the
server returns a part receipt: an opaque, server-authenticated value
that binds the ticket id, index, subtree hash, length, and the storage
backend's tag for the part. A part needs no admission decision, because
admission happened at BeginUpload. Sending a part index again is
idempotent. A receipt bound to another ticket is an invalid receipt
(invalid_argument), indistinguishable from a forged one, so receipts reveal
nothing across tickets or repositories.
A deployment MUST retain a rotated key id in the receipt verification set for at least seven days after rotation, the maximum ticket lifetime. Only then may it retire that id. An invalid receipt within this window indicates forgery, rather than ordinary key retirement.
Part path. UploadPart, CompleteUpload and a ticketed UploadPack
form the part path. They record no replay entry. They are idempotent by content, so
the server checks only the validity window, the ticket token, and the
commitment. The part path does not run the Authorizer; authority is
checked at BeginUpload and again inside the AdvanceRefs apply, so a
revocation between them leaves only unreferenced bytes, reclaimed at
ticket expiry.
Completion. CompleteUpload{ticket_token, receipts[]} is a unary,
signed write with a body: commitment. The server verifies every
receipt and merges the subtree hashes into a root. It makes the pack
visible in storage only if the root equals pack_id and the lengths sum
to bytes. Otherwise it returns invalid_argument and MAY abort the
storage session. Reclamation follows the backend lifecycle rule and ticket expiry.
Completion does not make the pack a member of the
repository. Completing the same ticket again is idempotent: it returns the
same result and changes nothing.
Resume. Part receipts are the durable record of the parts a server
received, and the client keeps them. A client that lost receipts sends
the missing parts again. A client that calls BeginUpload again gets
the same ticket. The server offers no listing of received parts.
Single-part packs. A pack of at most part_size bytes uses
UploadPack with the ticket token in its header and the usual pack:
commitment. It sends no part: commitment and no CompleteUpload.
Threshold. A multi-repository deployment MUST advertise
begin_upload_threshold_bytes = 0: every upload needs a ticket, so
upload completion alone never grants repository membership. On a single-repository
deployment, packs under the threshold MAY skip BeginUpload; a stored
pack is a member. When the deployment runs admission or configures an
inspector
(SPEC-SERVER §11.1), the threshold is 0. An upload that needs a ticket
and carries none is failed_precondition.
With a threshold of 0, even an empty pack needs a ticket; BeginUpload
refuses zero bytes. Clients never upload empty packs.
Membership. On a multi-repository deployment, a pack becomes a
member of the repository only at the AdvanceRefs apply that consumes
its ticket. AdvanceRefs gains a repeated ticket_ids field
naming the tickets it consumes. A ticket can be consumed only by an
advance of the ref it names; any other advance is failed_precondition.
A head-only UpdateRef consumes no tickets. Packlist nodes (MKPL) are
uploads like any other pack, and need tickets too.
Reference-server behavior. An advance names at most seven tickets (§4;
SPEC-SERVER §15.2).
Protocol requirement.
Binding. The ticket's audience, repository, and signer MUST equal
the request's, and the ticket's pack_id and bytes MUST equal the
request's commitment. Otherwise the request is permission_denied.
An AdvanceRefs consuming tickets MUST pair refs/heads/<x> with
refs/mkit/packmap/<x>; any other pairing is invalid_argument.
Errors. An expired or unknown ticket, including a token that fails
verification, is failed_precondition. A ticket, signer, or
commitment mismatch is permission_denied. A bad part hash or length
is invalid_argument. No ticket failure is resource_exhausted,
because clients retry that code on the backoff ladder (§5).
On AdvanceRefs, failed_precondition with public message
delta base not available in this repository (SPEC-SERVER §9.4) means
the client MUST re-plan the upload once as a self-contained pack with
no external delta bases and retry in a new signed operation (new
nonce) with a new ticket. Changing ticket_ids changes the operation
fingerprint (§7.1).
Informative: on invalid_argument with public message
packlist lists a pack that is not in this repository, the client
rebuilds its packlist from the packs the remote actually holds, then
retries in a new operation.
Expiry. A ticket expires less than 7 days after BeginUpload. A
ticket that expires before an advance consumes it produces an Expired
outcome for its reservation, and its pack becomes eligible for garbage
collection. The server best-effort aborts the ticket's upload session when
it closes the expired ticket; backend lifecycle rules reclaim sessions
left by an abort failure or interrupted cleanup.
Retries. A client that retries a signed request reuses its nonce and timestamps while the envelope is valid (at most 300 seconds, §7.1). After that it signs a new operation.
Pending verification. In indexed mode, an AdvanceRefs that
consumes a pack still under verification MUST fail with unavailable
and exactly one PendingVerification detail (SPEC-SERVER §9.5).
Before returning this answer, a server MAY wait for verification on a
ticketed call. Waiting MUST remain within the request deadline and the signed
envelope's validity window with sufficient margin to enforce the final
acceptance deadline. It MUST NOT hold leases,
reservations, or guarded state. If verification becomes usable, the server
MUST run the full normal advance path with fresh authorization, admission,
denial, replay, CAS, and final guards. If waiting times out, it MUST return
the original pending answer, including its typed hint. This option adds no
wire fields and does not change lost-reply or replay behavior.
Its retry_after_ms field is the server's suggested poll interval.
The server SHOULD send at least 1,000 milliseconds, except for bounded
tiny-pack work expected to finish sooner (SPEC-SERVER §9.5). The client MUST
poll, waiting retry_after_ms clamped to 1,000–60,000 milliseconds
between attempts; a missing or zero value means 1,000 milliseconds.
It polls until the ticket expires and MUST NOT use its normal backoff
ladder for this answer. The client MUST reuse the nonce while the
envelope is valid and sign a new operation after it lapses (§7.1,
at most 300 seconds). A server MUST NOT store this answer as a replay
result (§7.1).
Reference-server behavior. Pending-verification waits default to zero and are configurable up to 2,000 milliseconds. Waiting also respects the reference server's execution allowance.
Protocol requirement.
Storage visibility (informative). On every backend, the storage commit is the point at which a pack becomes visible. On an object store with multipart uploads, the server completes the multipart upload only after the merged root verifies. Presigned direct-to-storage part uploads are not allowed, because they would bypass verification before completion.
7.7 Lifecycle per RPC
Protocol requirement.
This section fixes, for each RPC of a ticketed upload, whether admission (§5.1) runs and which effects become durable.
Atomic acceptance, replay immunity, declared durability, and recovery follow SPEC-SERVER §3.1–§3.2. These requirements apply to any conforming server, independently of its storage implementation.
Every RPC carries its own nonce: BeginUpload, each UploadPart,
CompleteUpload, a ticketed UploadPack, and AdvanceRefs are
separate signed operations.
The server also runs admission on every other signed unary write:
UpdateRef, including deletion (§7.8), and an AdvanceRefs that
consumes no ticket. Allowing, challenging or denying it is deployment
policy. A configured non-default admission also runs on ticketed
AdvanceRefs and the same-session implicit consuming UpdateRef (§7.4).
That admission covers ref effects with zero bytes; uploads keep their
independent byte reservations.
| RPC | Admission | Durable effects |
|---|---|---|
BeginUpload (unary; names its target ref) | Yes | The replay result, reservation responsibility, and ticket in one atomic unit. |
UploadPart, CompleteUpload, and a ticketed UploadPack (the part path, §7.6) | No | Pack or part bytes, authorized by the ticket token; no replay or repository-membership effects. The ticket's audience, repository, and signer MUST equal the request's, and for a ticketed UploadPack its pack_id and byte count MUST equal the request's pack: commitment; otherwise the request is permission_denied (§7.6). |
AdvanceRefs | Yes when ref admission is configured | In one atomic acceptance unit: the head and packmap effects, replay result, applicable quota effects, consumed-ticket state, added membership, one terminal Committed outcome per consumed-ticket reservation, and the separately granted zero-byte ref reservation's outcome. Responsibility for propagating any deferred membership projection is durable in that unit; read indexes may lag under §7.9. |
- Membership. A pack becomes a member of the repository only at the
acceptance of the
AdvanceRefsthat consumes its ticket, or after the guarded acceptance of a server-side fork into an empty repository (SPEC-SERVER §9.9). A fork MUST NOT expose destination membership or accept destination writes before that acceptance. Until membership is established,BeginUploadfor the same signer, ref, and pack returns the existing ticket, neverAlreadyPresent(§7.6). ABeginUploadthat finds such a live ticket returns it after authorization and before admission: it runs no admission, creates no reservation, and is never challenged. - One outcome per reservation. Every reservation that admission
granted gets exactly one outcome:
Committed,Aborted, orExpired.Committedreports the bytes stored, the bytes new to the repository, the bytes new to the store, and the refs advanced. - Aborted. If the apply of an admitted RPC definitely fails after
an admission allowance with a reservation, for example an
UpdateRefcompare-and-swap loss, an epoch mismatch, or a replay race, the server recordsAbortedindependently of the rejected acceptance unit. An uncertain result MUST NOT be reported as a definite abort; recovery uses the original identity (SPEC-SERVER §3.2). A missing ticket pack is also a defensive abort case; conforming server GC retains every unexpired ticket pack as a root (SPEC-SERVER §13), so it cannot normally collect that pack before the advance consumes the ticket. - Expired. A ticket that expires before an
AdvanceRefsconsumes it producesExpired(§7.6). - Conflicts. An
AdvanceRefsthat ends in a typed conflict (§4) consumes no ticket. Its tickets stay usable until they expire. A lost compare-and-swap onAdvanceRefsis such a conflict, not a ticket abort. A separate ref admission reservation settles asAbortedon that conflict. An unconditional update whose presence changes after admission MUST abort rather than apply a different ref effect (SPEC-SERVER §6.3).
A deployment settles a payment on Committed and releases it on
Aborted or Expired, so an aborted upload settles nothing
(informative). Outcome delivery is at least once with a stable reservation
identity for deduplication, as SPEC-SERVER specifies. Repeated
delivery does not create another terminal outcome or settlement.
Reference-server behavior. The built-in default admission in mkit-server retains its skipped paths and quota accounting: ticket consumption does not invoke it again. Non-default admission also covers the zero-byte ref effects. A transactional outbox retains pending outcome-delivery responsibility.
7.8 Ref deletion
UpdateRef and AdvanceRefs gain an additive delete field.
- On
UpdateRef,deleteis valid only withREF_EXPECTATION_MATCHand an emptynew_id. The server removes the ref if its current value equalsexpected_id. - On
AdvanceRefs,deleteremoves the branch head and its packmap together, under the same rules for each: both expectations areMATCH, and both new ids are empty. A conflict is a typed outcome (§4), as for any advance. - A
deletewith any other expectation or a nonempty new id isinvalid_argument. - A deletion with
ticket_idsisinvalid_argument; deletion consumes no tickets. - Deleting an absent ref is a CAS conflict:
failed_preconditiononUpdateRef.
The write policy authorizes deletion (§7.5). A grant authorizes
deletion only through its delete flag
(SPEC-WRITE-GRANTS §8).
A deployment MAY refuse deletion entirely with permission_denied.
This document requires no client command for deletion (informative: a
push --delete command is a separate change).
7.9 Consistency and paging
Protocol requirement.
This section states the consistency guarantees a client can rely on, independently of the server's storage layout.
Strong. ReadRef of a specific ref is strongly consistent, and so
is every write. Push compare-and-swap MUST use ReadRef. A deployment
MUST NOT serve ReadRef from a snapshot to a client that may push. A
client that may push signs its reads (§7.1), so a deployment MAY serve an
unsigned ReadRef from a snapshot and MUST serve a signed one from the
ref's strongly consistent state
(SPEC-WRITE-GRANTS §9.2).
Eventual. ListRefs and pack membership (PackExists,
DownloadPack, and AlreadyPresent from BeginUpload) are eventually
consistent. This permits eventual visibility; it does not require lag. A server
with a relay-lag bound of zero never returns MembershipNotVisible, and
conformance MUST accept immediate membership visibility. They may otherwise lag
a write by seconds. A lag MUST only cause one
of these:
- a re-upload, because
AlreadyPresentwas not returned; - a retryable
unavailable("not yet visible", carrying theMembershipNotVisibledetail); - an older listing;
not_found(orexists = false) for a pack when the request carried noX-Mkit-Ref; or- in indexed mode, once the consuming ticket is older than the deployment's relay-lag bound, the uniform permanent error of the membership-dependent check that missed (SPEC-SERVER §9.4). It is byte-identical whether the object or pack exists in another repository or nowhere.
A lag never exposes another repository's data and never acts as an existence oracle (§7.4). A listing need not reflect a single instant; a branch head and its packmap may appear at different ages.
Stale listings. A client that fetches every listed branch MUST
tolerate a listing that names a branch deleted since the listing. When
the ReadRef of a listed branch's packmap finds no ref, the client MUST
re-read refs/heads/<name> strongly. If that head is also absent, the
listing was stale: the client MUST skip the branch and MUST NOT create or
update its tracking ref. If the head is present, the remote is corrupt (a
paired head and packmap effects are atomic) and the client
MUST fail. A transport error on either read MUST fail the fetch and MUST
NOT be treated as a stale listing.
Read-your-writes for packs. PackExists and DownloadPack MAY
carry an optional header naming a ref of the same repository whose
packmap listed the pack:
X-Mkit-Ref: <refname>The server then also resolves membership against the authoritative
added membership recorded by that ref's accepted advances. So a pusher sees its own advance at once, despite
index lag. The answer is always subject to the caller's view. A caller
without write access gets the published view, so a pack that an
advance still in quarantine added stays invisible through the header,
exactly as without it. The header never reveals another repository's
packs, or whether a ref exists in another repository. An unknown or
malformed ref name makes the header a no-op: the answer falls back to
the index and is never an error, which would act as an existence
oracle. A client SHOULD send the header when it fetches packs listed by
a packmap it just read. X-Mkit-Ref is not part of the auth v2
canonical string: changing it can only change which of the caller's
own permitted answers comes back, never widen the caller's view.
Paging. ListRefs is paginated through additive fields: the
request carries page_size and page_token, and the response carries
next_page_token. An empty next_page_token ends the listing. The
server MAY return fewer refs than page_size, and caps it at
max_list_refs_page_size (§2.1). An absent or zero page_size requests
max_list_refs_page_size. A malformed or foreign page token is
invalid_argument. Every encoded response page MUST be
at most 2 MiB, so a page always fits under the common 4 MiB default
client message limit. The pages concatenate to a listing in ref-name
order (SPEC-REFS §4.1).
ReadRefResponse.redaction_notices = 3 attaches active signed notices
for the caller's view only when the returned ref exists in that view.
ListRefsResponse.ref_redactions = 3 attaches (ref, notice) pairs for
visible refs on that page, including their bytes in the 2 MiB bound;
the ref is the full name. RefEntry retains its existing wire shape.
The detail is mkit.transport.v1.RedactionNotice { bytes envelope = 1; }
and its envelope and verification follow SPEC-SERVER §14.6. Hidden
refs and repositories MUST NOT leak a notice through these fields.
SSH and enc callers receive the same plain error codes and messages
without a notice detail; their frozen protocols do not gain notice
fields.
Advertised values. GetServerInfo advertises index_fanout and
the ListRefs page bound (§2.1). Neither field requires a particular
internal index layout.
7.10 Namespace repository listing
Protocol requirement.
ListRepos is an additive unary read RPC. ListReposRequest carries
namespace = 1, an opaque page_token = 2, page_size = 3, and optional
name_prefix = 4. Zero or absent page size means 100; a value above 100 is
invalid_argument. The prefix is at most 255 bytes of printable ASCII without
whitespace and matches raw name bytes. Empty matches every repository.
ListReposResponse carries repos = 1 and next_page_token = 2. Each new
RepoEntry carries its complete namespace-local name = 1 and effective
visibility = 2, using the existing RepoVisibility enum. Names MUST be sorted
by ascending byte order and MUST NOT be duplicated. No existing field changes
meaning. Entries carry no head or last-update value. A page MUST contain at most 100 entries and its encoded protobuf
response MUST fit in 64 KiB. An empty continuation ends the listing.
Addressing and authentication retain §7.1 and §7.4: X-Repository is an
ordinary full repository identity in Multi mode, and its namespace MUST equal
request.namespace. A mismatch is invalid_argument. The header's repository
name is only a namespace selector for this RPC: it need not exist and does not
limit or confer listing rights. Signed auth v2 commits the exact request bytes
and full procedure. All supplied auth v2 markers MUST verify; malformed signed
requests never become anonymous. Listing is idempotent, with no replay writes
or admission. Under Single addressing, the namespace is the deployment's
configured namespace (normally root), ordinary Single header rules apply,
and the sole configured public repository is returned if its name matches.
Multi listing rights are deliberately narrower than repository read rights:
- Anonymous and non-owner signed callers receive only the public listing.
- A signed namespace owner receives the full registered repository listing, subject to a configured authorizer's check.
- In
authoritymode, non-owner callers receive the public view by default. A deployment MAY explicitly enable full authority listings (disabled by default) only for an authority that understands namespace-wide listing. With this opt-in, a signed non-owner caller without a grant receives the full registry only if the hook Allow also returnswriter_view = true. A repository-scoped read Allow alone MUST NOT confer namespace listing rights. Authority hooks MUST treat this procedure as namespace-wide, not as permission on the arbitrary header selector repository. Apermission_deniedornot_foundhook denial selects the public view for both owners and non-owners; hook/storage failures fail closed. - Presenting
X-Write-Grantselects the public view even for an owner or authority caller. Grants do not add repository listing rights, including grants withreadcapability. The normal §9.3 private read rights remain unchanged. An unsigned grant header remainsunauthenticated.
A configured check authorizer MUST check every namespace listing, including
anonymous, non-owner and grant-bearing public views, once before storage scans.
Its denial or failure MUST propagate; an allowance cannot widen the public view.
Like authority hooks, this check concerns the entire requested namespace rather
than the selector repository name.
Private repository registrations MUST NOT affect public listing results or continuations. Public listing eligibility MUST change atomically with repository creation or a visibility change, so a concurrent listing cannot expose a repository through stale visibility. Visibility on an uncreated repository MUST NOT make it appear in a listing. Explicit visibility settings take precedence over the deployment default. A default-visibility change includes or excludes repositories inheriting that default without changing explicitly configured visibility.
Reference-server behavior. Public listings do not scan or filter private
repository registration records. The reference server maintains separate sorted
public-name indexes for explicitly public and default-visibility repositories.
It merges those indexes for public listings and reads effective visibility for
full listings. Its repository-object indexes do not supply repository names.
Source documentation for pipeline::list_repos describes the layout and the
exact registration and visibility guards; other servers need not use it.
Protocol requirement.
The token MUST be integrity-protected and bound to the namespace, name prefix,
caller, listing view, deployment audience, and default visibility. It resumes
strictly after the last returned name. Foreign, malformed, tampered, or
binding-mismatched tokens are invalid_argument, checked before listing work.
A token gives no authorization: owner/authority classification is repeated on
every page. Tokens are opaque to callers; no internal scan
cursor or private filtering position is returned in the public view.
There is no snapshot across pages. Concurrent creation or visibility changes
may add or remove names; subsequent pages still resume in name order. Storage
errors are unavailable, never successful partial listings. Exhausting a
listing's resource allowance is resource_exhausted, never successful
truncation. An empty namespace and one containing only unreadable private
repositories MUST have identical public results, including the absence of a
continuation.
Reference-server behavior. Tokens use a dedicated MAC domain derived from
deployment ticket keys; retained keys verify after rotation. Multi-repository
reference deployments require configured MAC keys for listing and return
failed_precondition when none are configured.
Public scans use fan-out at most two and fetch at most 101 records per scan. Short backend pages, including empty pages with a continuation, resume using that range's backend cursor before merging. The reference server permits 102 top-level storage calls per listing; full listings reserve one for a batched visibility read. Worker stores perform that read in one call; the default storage adapter performs at most 201 primitive calls. Its atomic writes retain 100-operation and 1-MiB limits, and Worker requests retain their shared call budgets. These are reference-server bounds, not protocol limits.
8. Out of scope
This document specifies the proto and its consumption pattern only. Explicitly deferred to sibling issues:
- The reference Worker implementation (mkit#699).
Implemented, then removed with the standalone server — see §7.2.mkit serve's HTTP mode (mkit#700).The native CLI Connect client (mkit#701).Implemented — see §7.3.- Fully deleting
mkit-transport-httpand SPEC-TRANSPORT §5 (waits on amkit.transport.v1equivalent for itssparse-checkout/pack-shardsextensions, not just core-verb parity — see §7.3). The shared retry/backoff Connect interceptor (mkit#703).Implemented directly inConnectTransport(mkit#790) — see §7.3.- S3 multipart upload (unrelated transport; not superseded by this document at all).
- Migrating
mkit.repo.v1.WatchRefsto real Connect server-streaming using the bridge pattern in §6.3 (amkit.repo.v1change, not amkit.transport.v1one — tracked separately). - End-to-end runtime verification of the
DownloadPackstreaming bridge (§6.3's known risk) — this is a proto/design review gate, not a working-server acceptance gate. - Generated TypeScript (
connect-es) clients for this service (M2 scope, tracked with mkit#706). - Implementing version 2 (M1): multi-repository routing, the namespace
and write policies, sharded server metadata,
GetServerInfo, upload tickets and resumable parts, ref deletion, andListRefspaging, with the proto additions they need (mkit#1084, mkit#1090). - Implementing write and read grants, epochs, signed reads, private repositories and URL tokens, specified in SPEC-WRITE-GRANTS (M2; mkit#1085, mkit#1089).
- Implementing admission (M3, mkit#1086): the
AdmissionChallengeandChallengeproto messages, theAdmissionRequiredTransportErrorvariant and the retryable mapping ofaborted(§5), the server's admission step and outcomes (§5.1, §7.7), and the client's 402 handling,admission_helper, and header allowlist (§5.1). - The remote-hook contract and outcome guarantees: see SPEC-SERVER.
- Pricing, payment verification, and settlement: deployment policy, never mkit's (§5.1).
9. Version history
| Version | Status | Changes |
|---|---|---|
2 | draft | §7.4 client URL parsing accepts the union of the self-certifying and authority-mode namespace grammars; the server still enforces only its own mode's grammar, and grants, grant epochs, and visibility statements keep the self-certifying grammar. No wire schema change. |
2 | draft | Opt-in authority-owned opaque namespace grammar in §7.4; disjoint from self-certifying names, with deployment-rooted ownership and refused owner statements under SPEC-SERVER §6.2.2. No wire schema or CLI change. |
2 (ListRepos) | draft | Additive §7.10 namespace listing RPC and new messages, atomic coordinator visibility indexes, bounded sorted pages and integrity-protected tokens; grants retain public listing rights only. |
2 (WP-2.16) | draft | Additive namespace Get/SetAuthorityGeneration RPCs outside auth-v2, with deployment-authority statements and pending completion hints. |
2 (WP-1.28c) | draft | §7.9 states the client rule for stale listings: a listed branch whose packmap and head are both strongly absent is skipped without a tracking ref; a present head with no packmap and any transport error stay failures. |
2 (WP-1.15) | draft | §7.4's ssh/enc paragraph gains ssh root mode (mkit serve --root, one repository per process addressed by <NAMESPACE>/<NAME>) and the enc --enc-repository listener binding, and notes the same-session implicit-membership rule transport-identity sessions use in place of upload tickets (informative). |
2 (WP-1.28b) | draft | §7.9 clarifies per-bucket ListRefs index lag and head/packmap age differences. |
2 (WP-1.11b) | draft | §5 and §7.6: a part receipt bound to another ticket is an invalid receipt (invalid_argument), with no cross-ticket oracle. |
2 (WP-3.13) | draft | §5.1 permits order-preserving combined WWW-Authenticate challenge lists on platforms that fold response fields; servers SHOULD separate field lines and clients/helpers MUST parse challenge lists (RFC 9110). |
2 | draft | Informative §7.1 clarification: concurrent new-operation duplicates may each reach admission before reservation; payment credentials are single-use to avoid double charging (WP-3.13 orchestrator ruling). |
2 | draft | §7.9 defines absent or zero page_size as the advertised maximum and malformed or foreign page tokens as invalid_argument (WP-1.28a). |
2 (WP-1.10) | draft | §7.6 requires canonical branch-head/packmap pairing for ticketed advances; §7.8 rejects deletion with tickets. |
2 (WP-1.11a) | draft | §5 classifies invalid part receipts as invalid_argument; §7.6 permits storage-session abort after a root mismatch and retains rotated receipt keys for at least seven days. |
2 | draft | §7.4 repository addressing; §7.5 namespace and write policy (owner key); GetServerInfo (§2.1); §7.6 upload tickets and resumable parts; §7.8 ref deletion; §7.9 consistency and ListRefs paging; error-code split between unauthenticated and permission_denied (§5) (mkit#1084, mkit#1090); SPEC-WRITE-GRANTS (mkit#1085): signed reads and X-Write-Grant (§7.1), the M2 RPC rows (§2), and grant cross-references. §5.1 admission challenges: HTTP 402 with permission_denied and an opaque challenge list, raw MPP/x402 header pass-through, the header-returning admission_helper with its allowlist and hard-reserved set; §7.1 replay lookup after authentication and before authorization and admission, with signed reads outside the ledger; retryable aborted for in-flight operations (§5); §7.7 lifecycle per RPC (mkit#1086). The M0 server implementation still resumes an interrupted UploadPack through its in_flight replay record until M1 tickets land. M1: branch-sharded servers MAY require the canonical AdvanceRefs head/packmap pairing (§4; WP-1.22 amendment 1). Indexed mode: PendingVerification polling with a 1,000 ms floor (§5, §7.6), delta-base mapping and self-contained replanning in a new signed operation (§5, §7.6), packlist rebuilding (§7.6), advertised max_delta_chain_depth (§2.1), and the membership-dependent lag window and replay exclusion (§7.1, §7.9; SPEC-SERVER §9.4). The additive empty MembershipNotVisible error detail marks the membership-lag unavailable (§5); the message stays unchanged. BeginUpload open-ticket cap error and client no-retry carve-out (§5), and admission-free AlreadyPresent/live-ticket results (§7.6; WP-1.9a amendment 1). WP-4.11 scopes §7.4's Host/path/forwarded-selector prohibition to Connect RPCs and cross-links plain HTTP read admission (§5.1). |
2 | draft | Additive GetServerInfoResponse.leases = 17 (§2.1; SPEC-SERVER §12); §7.7 ticket-pack loss clarified as a defensive abort case. |
2 | draft | Additive GetServerInfoResponse.async_inspection = 18 (§2.1); published-view and quarantine rules in SPEC-SERVER §§10–11; §7.1 never stores a sync inspector unavailable for replay; §7.6 AlreadyPresent is never answered for a pack with hidden content. |
2 | draft | Additive GetServerInfoResponse.inspection_max_objects = 19 (§2.1) advertises the configured synchronous-inspection advance cap, absent without inspectors (WP-5.5a). |
2 | draft | Additive GetReceipt signed writer-view read (§2.2), storage-receipt fields on UpdateRefResponse and AdvanceRefsResponse (§§3–4), and current receipt-and-notice key semantics in GetServerInfo (§2.1); SPEC-SERVER §15. |
2 | draft | Editorial: §3 and §7.3 retry obligations for UpdateRef/AdvanceRefs reconciled with SPEC-TRANSPORT §7 (verification toolchain). |
1 | draft | Initial mkit.transport.v1 proto: 7 wire RPCs covering every Transport trait verb (§2), PackChunk reused byte-for-byte from ssh.proto, RefExpectation/RefEntry duplicated with pinned wire numbers pending mkit#679's shared-proto extraction. |
10. Test anchors
The acceptance gate for this document itself is static, not behavioral:
buf lint(STANDARDcategory) againstproto/mkit/transport/v1/transport.proto— zero errors, zero lint exceptions.buf breakinginitialized (breaking.use: [FILE]inbuf.yaml, §1) as the baseline for every future change to this module.- The proto compiles cleanly through the real
buffa/connectrpc-buildcodegen path (not justprotoc) — verified by generating and compiling the full client and server stub set (TransportService,TransportServiceClient, every request/response/oneofmessage) againstbuffa 0.9.1/connectrpc 0.9/connectrpc-build 0.9, mirroring the exactinclude_generated!()patternmkit-repo-client/apps/repo-workeruse. - Explicit maintainer sign-off on the RPC shapes in this document, per the originating issue's Testing Decisions.
mkit#699/#700/#701 each own their own runtime test anchors (integration
tests against a real or locally-hosted server); this document is not
amended to list them. mkit#701 (§7.3) is the first to land one:
rust/crates/mkit-transport-connect/tests/roundtrip.rs runs a real
in-process TransportService server and drives every Transport verb
through it via the generated client — see §7.3 for what it does and
does not prove (a memory-backed in-process server, not the R2/DO
reference Worker).
11. Invariants
| Invariant | Enforced by |
|---|---|
Every Transport trait verb has exactly one corresponding wire RPC, or is a documented client-side default-impl delegation with no independent wire shape. | §2's mapping table; protocol.rs's default-impl doc comments. |
RefExpectation's wire numbers (ANY=1, MISSING=2, MATCH=3) never change, even after mkit#679 extracts a shared proto. | buf breaking (FILE category, §1); the RefExpectation doc comment matching ssh.proto's "do NOT renumber" contract. |
A rejected UploadPack stream never creates or overwrites the destination pack. | §6.1's server-side rejection checks, mirroring SPEC-TRANSPORT §4.2's SSH requirement. |
DownloadPack never sends a partial stream silently — it either completes with chunk.last = true or fails the whole call before any message is sent. | §6.2. |
Every TransportError variant a server can raise has exactly one Connect code it maps to; a client's inverse mapping is mechanical, not heuristic. | §5's table. |
| No RPC on one repository reads or changes another repository's refs, pack membership, or replay records. | §7.4 isolation; the X-Repository carriage rule. |
| A write is authorized, or rejected with nothing allocated, before any quota or replay state is touched. | §7.5 order. |
A retry of a committed signed write returns the stored result and never reaches admission; a retry of an in-flight one gets a retryable aborted, except that a single-repository deployment MAY resume an un-ticketed UploadPack by re-reading its stream. | §7.1 order and its scoped upload exception. |
A challenged or denied request changes no state, and no challenge or PendingVerification answer is stored as a replay result. | §5.1 "No state on challenge"; §7.1. |
A challenge is HTTP 402 with permission_denied, only on a unary RPC, and is never retried automatically. | §5.1; §5; SPEC-TRANSPORT §7. |
| A client attaches only allowlisted helper headers and never a hard-reserved one, whatever the configuration says. | §5.1 header allowlist and hard-reserved headers. |
Every admitted reservation gets exactly one outcome: Committed, Aborted, or Expired. | §7.7. |
A stock multi-repository deployment never runs write_policy = open. | §7.5 write policy. |
An AdvanceRefs conflict is a typed response value, never a Connect error. | §4 — matches AdvanceOutcome's three-variant, no-error-variant shape in protocol.rs. |
The DownloadPack Workers-streaming design is documented as unverified end-to-end until a sibling issue proves real client-visible delivery. | §6.3's "Known risk" paragraph; mkit#699/#702's re-verification requirement. |
Wasm server deadline policy
Reference-server behavior.
The mkit wasm binding ignores connect-timeout-ms and grpc-timeout before
Connect dispatch, and does not apply configured Connect deadline policies.
Native bindings retain deadline enforcement. This runtime policy leaves body
limits, streaming and authentication unchanged; hosts may bound work using their
platform clock and timer.
Runtime-independent client (informative)
Reference-server behavior.
Complete ref listings in the Rust client, sync adapter, and Connect transport
share a runtime-free accumulator in mkit-core. It rejects unordered or duplicate
names within and across pages, repeated nonempty cursors, cursors over 4 KiB,
and listings exceeding 100,000 pages or a caller's lower cap.
ConnectTransport::list_refs_with_page_limit lets a native caller lower this cap. Its 128 MiB
retained-state budget charges names, fixed ref metadata, collection overhead,
32-byte BLAKE3 cursor digests, and the active cursor. Empty pages consume page
and cursor-history budgets. The client page primitive also checks ordering and
cursor length. These are local defensive limits; the wire format is unchanged.
mkit-client implements the client side
of this protocol from public code, on native and wasm32 with no async runtime or
C compression in its dependency graph. It shares the mkit-rpc/transport-messages
types and exposes one typed call per RPC: GetServerInfo, ListRefs (paged,
response cap 2 MiB), ReadRef, PackExists, DownloadPack (caller size cap),
UpdateRef (general CAS and delete), BeginUpload, UploadPack, UploadPart,
CompleteUpload and AdvanceRefs. A client with a signer signs every RPC but
discovery (§7.1); an anonymous client sends unsigned reads. There is no fork RPC
in mkit.transport.v1, so none is exposed; branch creation from a published
source is AdvanceRefs with source_ref (§4.1).
The client lets its host run policy around every signed request without
changing the wire: a pre-dispatch observer sees each write (the procedure and
its exact ref moves, packs and tickets) and may veto it or add unsigned
per-attempt headers (never a hard-reserved one, §5.1; the client also refuses Authorization,
since it cannot know whether its transport sends one); the signer sees the
procedure, repository, audience, content commitment and validity window it
signs and may refuse; and an envelope whose validity window has ended is not
sent. The order is observer, signer, deadline check, dispatch, once per signed
envelope. The client can also build the signed envelope headers for a procedure
the host dispatches itself.
A per-RPC call sends one request and never waits. The outcomes a caller
schedules come back as typed errors: a challenge (§5.1, every challenge, and a
402 without a Connect body parsed from WWW-Authenticate and PAYMENT-REQUIRED),
a PendingVerification hint, a membership-lag unavailable, and an
unavailable carrying Retry-After (1–60 s). Every signed call accepts a
caller-owned request identity (the nonce, which is also the Idempotency-Key,
and the validity window) and uses it exactly, returning the identity it used, so
a persisted identity reconstructs the same authenticated operation, body and
identity; unsigned observer headers may be regenerated. For a write, a server
that saved the first reply returns it (§7.1; signed reads keep no reply). Calls
without an identity generate it from the host clock.
Push::run is the bounded whole-push built on the same calls. It preserves the
signed request identity through pending polls while valid, honors typed poll
hints and Retry-After, and reports conflict/replan outcomes; its waits live
only in that loop. Read helpers walk a packmap chain and unpack packs into an
abstract object sink, verifying commit, remix and tag signatures unless the
caller opts out. The HTTP boundary is injectable and carries buffered Connect
frames with an explicit response limit; clock, entropy, waiting and signing are
also injectable. A streaming request body does not fit that boundary: the
transport must replay the exact request bytes after an ambiguous failure, and
browser fetch cannot stream request bodies portably, so request bodies stay
buffered (bounded per part by the ticket part size). Exact-byte replay applies
to retained requests; reconstructing a request with the same identity preserves
its authenticated operation and body. No result claims eternal replay retention
or proves
that an earlier ambiguous attempt never wrote. Native repository push lives in
mkit-sync::push(dir, source, options), which returns PushReport or typed
PushError and sends progress through SyncObserver. The CLI delegates the
repository mechanics while retaining configuration, trust gates, grants,
admission policy, output, and exit codes. Both push drivers use the pure
mkit-core::transfer::packmap planner for node bytes, idempotency, and packmap
CAS conditions. Chain validation, rollover policy, upload, and conflict handling
remain in the drivers; no wire or stored-format change results. See the client guide
for bounds and the API. This library adds no wire change.
The native mkit-sync source adapters preserve per-RPC resumable outcomes in
backend-neutral SourceError, carried by SyncError::Source. Reads return
these outcomes immediately; writes retry only through an installed host recovery
callback. Raw Connect failures retain code, message, details, HTTP status,
and response headers for host policy. An observed signed Client also works
as a ClientSource; reads do not invoke its write observer. This adapter API
does not change the wire contract or the synchronous Connect transport's own
polling policy.
Native push runs through RemoteSource. TransportSource supports every
existing scheme, including mkit+https. A signed ClientSource supports writes
through the canonical per-RPC client. Its caller supplies request policy,
scheduling and admission recovery, and a durable receipt store. Acknowledged
parts are persisted before progress callbacks or later uploads; fresh instances
recover the open ticket with BeginUpload and reuse matching receipts.
Native extra-CA and configuration wiring remain host-owned. Pack and packmap
ids, ref moves, CAS retries, limits, and receipt records stay unchanged. No wire
or stored-format change or wipe results. Push backend failures remain
SourceError, carried by PushError::Sync(SyncError::Source(_)).