SPEC-TRANSPORT-CONNECT.md

This Kit feature is not available yet.

In docs/specs/

Preview

Text · 130.9 KiB

spec: 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.v1

This 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 methodRPCShape
(none; lists namespace repositories)ListRepos (§7.10)unary
list_refs(prefix)ListRefsunary
read_ref(name)ReadRefunary
update_ref(name, condition, hash)UpdateRefunary
write_ref(name, hash) (default impl: update_ref(.., Any, ..))(none — client calls UpdateRef with expectation = REF_EXPECTATION_ANY)—
advance_refs(..)AdvanceRefsunary
pack_exists(key)PackExistsunary
upload_pack(bytes, key)UploadPackclient-streaming
download_pack(key)DownloadPackserver-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:

FieldMeaning
protocolThe wire package, mkit.transport.v1.
spec_versionThis document's version, 2.
max_pack_bytesThe 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_sizeThe part size for resumable uploads (§7.6): a power of two, at least 8 MiB.
max_partsThe largest number of parts in one upload (§7.6).
max_list_refs_page_sizeThe largest number of refs one ListRefs page returns (§7.9).
begin_upload_threshold_bytesPacks 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_advanceWhether AdvanceRefs commits the head and packmap atomically (§4).
indexed_modeWhether the deployment decodes and verifies pushed packs before refs move.
admissionWhether 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_idRaw 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_schemesThe owner signature schemes the deployment accepts on grants and epoch statements (SPEC-WRITE-GRANTS §4). Empty on a deployment that accepts no grants.
namespace_policyallowlist, any, or single-repository (§7.5). single-repository is advertised, never configured.
index_fanoutThe deployment's repository-index fan-out advertisement (§7.9). This field does not require a particular index layout.
max_delta_chain_depthThe advertised delta-chain depth cap (SPEC-SERVER §9.8); 0 when indexed mode is off.
leasesWhether the deployment enforces storage leases under SPEC-SERVER §12.
async_inspectionWhether 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_objectsMaximum 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:

SelectorFieldsMeaning
advance = 1ref (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 = 2ref (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:

RefWriteConditionRefExpectationexpected_idSemantics
AnyREF_EXPECTATION_ANYemptyLast-writer-wins.
MissingREF_EXPECTATION_MISSINGemptyCreate-only; the ref MUST NOT already exist.
Match(h)REF_EXPECTATION_MATCH32-byte digest hCurrent 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:

AdvanceOutcomeAdvanceOutcome (proto)Meaning
CommittedADVANCE_OUTCOME_COMMITTEDBoth refs updated.
HeadConflictADVANCE_OUTCOME_HEAD_CONFLICTThe head precondition failed; branch moved under the caller.
PackmapConflictADVANCE_OUTCOME_PACKMAP_CONFLICTThe 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:

TransportErrorConnect codeRaised by
PackNotFoundnot_foundDownloadPack before any chunk is sent; PackExists never raises this (it returns exists = false instead).
AccessDeniedpermission_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).
RefConflictfailed_preconditionUpdateRef on a CAS mismatch, including deletion of an absent ref (§7.8). AdvanceRefs reports its conflicts as typed outcomes (§4), never as this error.
InvalidRefinvalid_argumentAny 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)—
ProtocolErrorinvalid_argumentA client-streaming call whose header is missing, arrives after a chunk, or whose declared/received byte counts disagree (§6).
PayloadTooLargeresource_exhaustedUploadPack 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.

ConditionConnect 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 repositorynot_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_namespace and creates_repo;
  • for BeginUpload, the pack_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, and Authorization. Authorization is on it only when the client does not already send Authorization to that remote, that is, when no MKIT_API_TOKEN bearer token (§7.3) is configured for it.
  • The user-scoped remote.<name>.admission_headers key 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 header X-Write-Grant; X-Mkit-Ref (§7.9); every header beginning X-Mkit-; every X-Forwarded-* header;
  • Host, every Content-* header, Transfer-Encoding, every Connect-* header, Cookie, Idempotency-Key, and Authorization when the client already sends it to that remote (§7.3);
  • the hop-by-hop headers Connection, Keep-Alive, every Proxy-* header, TE, Trailer, and Upgrade.

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_id does not match header.pack_id;
  • any chunk.offset does not equal the running received-byte count;
  • the stream ends without a chunk.last = true message;
  • the received byte count does not equal header.total_bytes; or
  • BLAKE3(received bytes) does not equal header.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:

  1. Authenticate. Verify the signature and the validity window. This step writes no state.
  2. Look up the replay record for (audience, repository, signer, nonce):

    • a stored fingerprint that differs from the request's is invalid_argument;
    • a committed operation 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 retryable unavailable without applying again;
    • an in_flight operation returns a retryable aborted, without reaching admission. A single-repository deployment MAY resume an un-ticketed UploadPack with 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.

  3. 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.
  4. Reserve. Insert the in_flight replay record.
  5. Apply the operation's effects.
  6. 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 at connect() time, sent as Authorization: Bearer <token> on every call. This is mkit-transport-http's scheme (SPEC-TRANSPORT §5.2) and is what bearer-mode mkit-server deployments (§7.2) expect.
  • Ed25519 envelope: mkit-client signs the auth v2 contract in §7.1 for reads and writes, with an exact request body commitment for reads and unary writes, including the framed DownloadPack request, and the declared pack id and length for streaming UploadPack writes. GetServerInfo and the grant-epoch RPCs remain unsigned. transport_auth = envelope is user-scoped and repository-forbidden. The CLI requires exact user-scoped trusted_remote_endpoint approval 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-f

In 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 is permission_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 under any MUST 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 run open. A stock multi-repository deployment MUST NOT run open.
  • owner: a write needs authorization for the repository in X-Repository.

Under owner, a write to <ns>/<name> is authorized when any of these holds:

  1. ns has the ed25519- form and the auth v2 signer is that key.
  2. 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.
  3. 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:

  1. The first message is a header {ticket_token, index}.
  2. The following messages carry the part's bytes in order.
  3. 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.json pins, 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 and 8 MiB − 1 bytes and one upload of only full parts. Vectors marked "test_geometry": true use 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.txt pins the file's BLAKE3.
  • rust/tests/golden/auth-v2/part.json pins a signed UploadPart envelope: its part: 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.

RPCAdmissionDurable effects
BeginUpload (unary; names its target ref)YesThe replay result, reservation responsibility, and ticket in one atomic unit.
UploadPart, CompleteUpload, and a ticketed UploadPack (the part path, §7.6)NoPack 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).
AdvanceRefsYes when ref admission is configuredIn 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 AdvanceRefs that 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, BeginUpload for the same signer, ref, and pack returns the existing ticket, never AlreadyPresent (§7.6). A BeginUpload that 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, or Expired. Committed reports 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 UpdateRef compare-and-swap loss, an epoch mismatch, or a replay race, the server records Aborted independently 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 AdvanceRefs consumes it produces Expired (§7.6).
  • Conflicts. An AdvanceRefs that ends in a typed conflict (§4) consumes no ticket. Its tickets stay usable until they expire. A lost compare-and-swap on AdvanceRefs is such a conflict, not a ticket abort. A separate ref admission reservation settles as Aborted on 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, delete is valid only with REF_EXPECTATION_MATCH and an empty new_id. The server removes the ref if its current value equals expected_id.
  • On AdvanceRefs, delete removes the branch head and its packmap together, under the same rules for each: both expectations are MATCH, and both new ids are empty. A conflict is a typed outcome (§4), as for any advance.
  • A delete with any other expectation or a nonempty new id is invalid_argument.
  • A deletion with ticket_ids is invalid_argument; deletion consumes no tickets.
  • Deleting an absent ref is a CAS conflict: failed_precondition on UpdateRef.

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 AlreadyPresent was not returned;
  • a retryable unavailable ("not yet visible", carrying the MembershipNotVisible detail);
  • an older listing;
  • not_found (or exists = false) for a pack when the request carried no X-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 authority mode, 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 returns writer_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. A permission_denied or not_found hook denial selects the public view for both owners and non-owners; hook/storage failures fail closed.
  • Presenting X-Write-Grant selects the public view even for an owner or authority caller. Grants do not add repository listing rights, including grants with read capability. The normal §9.3 private read rights remain unchanged. An unsigned grant header remains unauthenticated.

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).
  • mkit serve's HTTP mode (mkit#700). Implemented, then removed with the standalone server — see §7.2.
  • The native CLI Connect client (mkit#701). Implemented — see §7.3.
  • Fully deleting mkit-transport-http and SPEC-TRANSPORT §5 (waits on a mkit.transport.v1 equivalent for its sparse-checkout/pack-shards extensions, not just core-verb parity — see §7.3).
  • The shared retry/backoff Connect interceptor (mkit#703). Implemented directly in ConnectTransport (mkit#790) — see §7.3.
  • S3 multipart upload (unrelated transport; not superseded by this document at all).
  • Migrating mkit.repo.v1.WatchRefs to real Connect server-streaming using the bridge pattern in §6.3 (a mkit.repo.v1 change, not a mkit.transport.v1 one — tracked separately).
  • End-to-end runtime verification of the DownloadPack streaming 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, and ListRefs paging, 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 AdmissionChallenge and Challenge proto messages, the AdmissionRequired TransportError variant and the retryable mapping of aborted (§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

VersionStatusChanges
2draft§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.
2draftOpt-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)draftAdditive §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)draftAdditive 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).
2draftInformative §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).
2draft§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.
2draft§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).
2draftAdditive GetServerInfoResponse.leases = 17 (§2.1; SPEC-SERVER §12); §7.7 ticket-pack loss clarified as a defensive abort case.
2draftAdditive 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.
2draftAdditive GetServerInfoResponse.inspection_max_objects = 19 (§2.1) advertises the configured synchronous-inspection advance cap, absent without inspectors (WP-5.5a).
2draftAdditive 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.
2draftEditorial: §3 and §7.3 retry obligations for UpdateRef/AdvanceRefs reconciled with SPEC-TRANSPORT §7 (verification toolchain).
1draftInitial 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 (STANDARD category) against proto/mkit/transport/v1/transport.proto — zero errors, zero lint exceptions.
  • buf breaking initialized (breaking.use: [FILE] in buf.yaml, §1) as the baseline for every future change to this module.
  • The proto compiles cleanly through the real buffa/connectrpc-build codegen path (not just protoc) — verified by generating and compiling the full client and server stub set (TransportService, TransportServiceClient, every request/response/oneof message) against buffa 0.9.1/connectrpc 0.9/connectrpc-build 0.9, mirroring the exact include_generated!() pattern mkit-repo-client/apps/repo-worker use.
  • 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

InvariantEnforced 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(_)).

Files