KIT

Reference snapshot · source revision 7a2e36f2b250934a095e542ae20e62dcfb0d5f70

SPEC-TRANSPORT-CONNECT — kit.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). kit.transport.v1 evolves additively: v2 adds RPCs and fields and never renumbers or removes one, so no wire break occurs. The breaks are semantic only. v2 has no v1 reader, no negotiation, and no fallback mode. The proto messages v2 names land with the version 2 implementation, and the admission messages with the admission implementation (§8); until then the implementations described under "Reference implementation" below implement the v1 surface only. §9 records the version history.

Scope: the kit.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 servers and the native CLI client consume one generated codebase. It does not cover S3 multipart, the WatchRefs live feed, pricing or admission 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.

Serves kit+https:///kit+http:// remotes in kit-bin — kit-transport-connect is what remote_dispatch constructs for those schemes. The sparse-checkout extension has no kit.transport.v1 equivalent.

Reference implementation: kit-transport-connect (the native CLI client, §7.3) against kit-transport-connect/tests/roundtrip.rs's in-process server — real HTTP, real protobuf framing, real Connect streaming, backed by kit-transport-memory. No server for this protocol lives in this repository. ConnectTransport supports both the bearer-token scheme and the Ed25519 write envelope (§7.1) as independent, additive auth modes — see §7.3.

The proto lives at proto/kit/transport/v1/transport.proto.


1. Proto module and package layout

rust/crates/kit-rpc/proto         → kit.rpc.v1 (+ .signer, .ssh, .verify)
proto/kit/transport/v1/transport.proto   → kit.transport.v1
proto/kit/common/v1/refs.proto           → kit.common.v1

Every change to transport.proto MUST keep the wire format of each published field number and enum value.

kit.transport.v1 defines its own RefExpectation and RefEntry. They are byte-for-byte duplicates of the types in kit.rpc.v1.ssh and kit/common/v1/refs.proto. The wire numbers are fixed.


2. Verb-to-RPC mapping

TransportService maps one-to-one onto the verbs of the Transport trait — the same trait kit-transport-s3 and -ssh implement.

Transport trait method RPC Shape
(none; lists namespace repositories) ListRepos (§7.10) unary
list_refs(prefix) ListRefs unary
read_ref(name) ReadRef unary
update_ref(name, condition, hash) UpdateRef unary
write_ref(name, hash) (default impl: update_ref(.., Any, ..)) (none — client calls UpdateRef with expectation = REF_EXPECTATION_ANY) —
advance_refs(..) AdvanceRefs unary
pack_exists(key) PackExists unary
upload_pack(bytes, key) UploadPack client-streaming
download_pack(key) DownloadPack server-streaming
upload_blob(bytes, key) (default impl: delegates to upload_pack) (none — client calls UploadPack) —
download_blob(key) (default impl: delegates to download_pack) (none — client calls DownloadPack) —
(none — deployment discovery) GetServerInfo (§2.1) unary
(none — retained storage receipt) GetReceipt (§2.2) unary
(none — upload ticket) BeginUpload (§7.6) unary
(none — one part of a ticketed upload) UploadPart (§7.6) client-streaming
(none — completes a multipart upload) CompleteUpload (§7.6) unary
(none — reads a namespace authority generation) GetAuthorityGeneration unary
(none — raises a namespace authority generation) SetAuthorityGeneration unary
(none — reads a namespace's grant epoch) GetGrantEpoch (SPEC-WRITE-GRANTS §5.3) unary
(none — raises a namespace's grant epoch) SetGrantEpoch (SPEC-WRITE-GRANTS §5.3) unary
(none — sets a repository's visibility) SetRepoVisibility (SPEC-WRITE-GRANTS §9.1) unary
(none — mints a signed URL token) 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 rows for deployment discovery and the ticketed upload protocol are v2 additions. They have no Transport trait verb: a client drives them around the seven verb RPCs. Their proto messages land with the version 2 implementation (§8), additively. The rows for grant epochs, repository visibility, and URL tokens are SPEC-WRITE-GRANTS additions; their proto messages land with the grants implementation, additively.

Endpoints follow the standard Connect convention: POST /kit.transport.v1.TransportService/<Method>.

2.1 GetServerInfo

Protocol requirement.

GetServerInfo tells a client what a deployment supports before the client relies on it. The request is empty. It MAY carry X-Repository (§7.4). The response MUST NOT depend on whether that repository exists, so it is never an existence oracle (§7.4, isolation).

The call is unauthenticated: a server MUST answer it without auth v2 headers or a bearer token. The response MAY be cached with Cache-Control: private, max-age=<n> where n is at most 60 seconds.

GetServerInfoResponse carries these fields:

Field Meaning
protocol The wire package, kit.transport.v1.
spec_version This document's version, 2.
max_pack_bytes The largest pack the deployment accepts. A deployment MAY advertise a lower value in indexed mode than in opaque mode, for example on a runtime with tight CPU limits. A deployment whose storage cannot accept multipart uploads MUST advertise max_pack_bytes <= part_size.
part_size The part size for resumable uploads (§7.6): a power of two, at least 8 MiB.
max_parts The largest number of parts in one upload (§7.6).
max_list_refs_page_size The largest number of refs one ListRefs page returns (§7.9).
begin_upload_threshold_bytes Packs smaller than this MAY skip BeginUpload (§7.6). It is 0 on every multi-repository deployment, whenever admission is true, and with any inspector configured. On a single-repository deployment without admission or inspection, the value is deployment-defined.
atomic_advance Whether AdvanceRefs commits the head and packmap atomically (§4).
indexed_mode Whether the deployment decodes and verifies pushed packs before refs move.
admission Whether the deployment runs an admission step that can challenge a request (§5.1). When it is true, begin_upload_threshold_bytes is 0.
receipt_public_key, receipt_key_id Raw 32-byte current receipt-and-notice signing public key and its 64-lowercase-hex BLAKE3 key id. Both empty only when that role key is not configured. Historical keys are in the well-known list, not this response.
grant_schemes The owner signature schemes the deployment accepts on grants and epoch statements (SPEC-WRITE-GRANTS §4). Empty on a deployment that accepts no grants.
namespace_policy allowlist, any, or single-repository (§7.5). single-repository is advertised, never configured.
index_fanout The deployment's repository-index fan-out advertisement (§7.9). This field does not require a particular index layout.
max_delta_chain_depth The advertised delta-chain depth cap; 0 when indexed mode is off.
leases Whether the deployment enforces storage leases.
async_inspection Whether any asynchronous inspector is configured. Writers MUST sign reads to see their own pending content that is not held. Held content is hidden from every caller, including when this field is false and a synchronous inspector holds it.
inspection_max_objects Maximum distinct objects in one advance's complete inspected set. 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.
supports_pack_locations Whether the server supports optional DownloadPack locations (§6.2). Absent or false disables location negotiation.
max_pack_location_lifetime_ms Maximum location lifetime in milliseconds (§6.2). MUST be present and positive when supports_pack_locations is true; absent when support is disabled.

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 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 the lease check. The advance selector MUST then apply the lease check: under a suspended or deleted effective state of the repository or ref it MUST return permission_denied with public message lease suspended. GetReceiptRequest MUST select exactly one oneof arm:

Selector Fields Meaning
advance = 1 ref (full branch head or other ref name), advance_sequence (u64) Retrieve that advance's storage receipt; 0 selects the latest committed advance sequence for the ref, including one whose signature is pending. The branch selector is refs/heads/<x> even if the requested write advanced its paired refs/kit/packmap/<x>.
lease = 2 ref (full ref or empty), lease_version (u64), namespace_scope (bool) Retrieve that lease storage receipt. With namespace_scope = true, an empty ref selects the namespace of the signed repository identity. Otherwise an empty ref selects repository scope. 0 selects the latest committed lease_version for that scope, including one whose signature is pending.

An absent selector, an empty advance ref, an invalid nonempty ref, a packmap ref used as the selector, or namespace_scope = true with a nonempty lease ref MUST receive invalid_argument. The advance_sequence starts at 1; 0 is reserved for latest. The branch head and paired packmap share that sequence, 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. 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; their wire schemas do not change.


3. CAS semantics — UpdateRef

Identical in spirit to SPEC-TRANSPORT §4.2.1 and kit.repo.v1.RepoService.UpdateRef, expressed as a Connect unary call instead of an SshFrame or a JSON body:

RefWriteCondition RefExpectation expected_id Semantics
Any REF_EXPECTATION_ANY empty Last-writer-wins.
Missing REF_EXPECTATION_MISSING empty Create-only; the ref MUST NOT already exist.
Match(h) REF_EXPECTATION_MATCH 32-byte digest h Current ref value MUST equal h.

A conforming server MUST reject REF_EXPECTATION_UNSPECIFIED (the proto zero value) with Connect code invalid_argument.

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 kit.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 storage-receipt DSSE envelope JSON bytes. It is empty when receipts are disabled. A CAS or validation failure returns a Connect error, so no receipt is issued. The field attests the live advance, including deletion, and says nothing about publication.

This document reuses SPEC-TRANSPORT §7's caller obligations unchanged, for UpdateRef and for AdvanceRefs' conflict outcomes (§4). A deployment with the auth v2 replay ledger (§7.1) answers a re-issue that carries the same nonce with the stored result, so a re-issue within one call's retry ladder reports the true outcome there (§7.3); the read_ref obligation still applies to any other deployment and to a caller that invokes the operation again.


4. Atomic two-ref advance — AdvanceRefs

Protocol requirement.

Transport::advance_refs updates a branch's head ref and its packmap ref together, each under its own CAS precondition, so the delta-transfer invariant ("if head_ref resolves to T, the packmap reconstructs closure(T)") never has a window where the two refs disagree. AdvanceRefsRequest carries both preconditions in one message so a transactional server backend can commit both writes atomically in one round-trip:

message AdvanceRefsRequest {
  string head_ref = 1;
  RefExpectation head_expectation = 2;
  bytes head_expected_id = 3;
  bytes head_new_id = 4;

  string packmap_ref = 5;
  RefExpectation packmap_expectation = 6;
  bytes packmap_expected_id = 7;
  bytes packmap_new_id = 8;
}

AdvanceRefsResponse.outcome mirrors AdvanceOutcome exactly:

AdvanceOutcome AdvanceOutcome (proto) Meaning
Committed ADVANCE_OUTCOME_COMMITTED Both refs updated.
HeadConflict ADVANCE_OUTCOME_HEAD_CONFLICT The head precondition failed; branch moved under the caller.
PackmapConflict ADVANCE_OUTCOME_PACKMAP_CONFLICT The packmap precondition failed; a concurrent pusher advanced the chain first.

An atomic-capable client publishing a force push MUST compare the packmap in that same AdvanceRefs, including a head-only or idempotent write (set the packmap to its existing value under MATCH). A plan depending on the observed head's closure MUST also compare that head: separate reads do not establish an atomic pair, and a reset can restore an identical packmap digest. A full, self-contained append MAY use ANY for the head. On a precondition miss, the client MUST re-read the packmap and re-plan if it no longer extends the chain captured by the plan. Re-planning and publication attempts MUST share the existing bounded retry budget. These requirements use the existing fields; UpdateRef alone cannot carry the packmap dependency. If capability discovery failed or was invalid, the client MUST refuse a force push before uploading or writing refs. It cannot infer that other clients are unable to reset the packmap from its own discovery failure. A valid discovery response explicitly disabling atomic advance retains the non-atomic path.

A conflict is carried as a successful RPC with outcome != ADVANCE_OUTCOME_COMMITTED, not a Connect error — unlike UpdateRef, advance_refs's Rust signature already returns a typed enum rather than a boolean success/CAS-conflict split (see protocol.rs's AdvanceOutcome), so the wire follows the same shape instead of forcing a three-way outcome through a two-way (success / error) channel.

AdvanceRefsResponse.receipt = 2 contains the complete 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 (§7.1), including both sides of an AdvanceRefs or paired deletion.

A server MAY require packmap_ref to be refs/kit/packmap/<x> when head_ref is refs/heads/<x> (SPEC-REFS §2), and answer any other pairing with invalid_argument; every kit 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; 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. A missing source answers the same way for every caller. A mismatch with a given expected_tip is an error. The server checks the source a final time 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.


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 kit.rpc.v1.Error message, needed because raw stdio framing has no ambient error channel — this service defines no custom error message type. Every TransportError variant maps onto a standard Connect code:

TransportError Connect code Raised by
PackNotFound not_found DownloadPack before any chunk is sent; PackExists never raises this (it returns exists = false instead).
AccessDenied permission_denied; a client also maps unauthenticated to AccessDenied. Any RPC, when the deployment's write or namespace policy (§7.5) rejects an authenticated caller, or a ticket does not bind to the request (§7.6).
RefConflict failed_precondition UpdateRef on a CAS mismatch, including deletion of an absent ref (§7.8). AdvanceRefs reports its conflicts as typed outcomes (§4), never as this error.
InvalidRef invalid_argument Any RPC taking a ref name that fails SPEC-REFS §3, or (ReadRef, UpdateRef, either name of AdvanceRefs) a name outside refs/, which a server does not serve (SPEC-REFS §2); that message starts ref name must start with refs/.
ConnectionFailed (not server-raised — client-observed transport failure, for example TCP reset, deadline exceeded) —
ServerError{status} unavailable (5xx-equivalent), resource_exhausted (429-equivalent), or aborted (a client maps it to ServerError{status: 503}) Deployment-specific overload / backend failure; aborted also answers a retry of an operation that is still in flight (§7.1).
InvalidResponse (not server-raised — client-observed: malformed frame, wrong message on a streamed oneof, digest mismatch on DownloadPack) —
ProtocolError invalid_argument A client-streaming call whose header is missing, arrives after a chunk, or whose declared/received byte counts disagree (§6).
PayloadTooLarge resource_exhausted UploadPack header total_bytes (or the observed stream length) exceeds the server's cap.
AdmissionRequired{challenges, description} permission_denied, sent with HTTP status 402 and exactly one AdmissionChallenge detail (§5.1). A client also maps any HTTP 402 response to this variant. A unary RPC that the deployment's admission step challenges (§5.1). Never retried.
InsecureScheme (not applicable — URL-scheme concern, handled client-side before any RPC is made; see SPEC-TRANSPORT §3) —
RemoteError(String) unknown (server-raised, deployment-specific advisory failure with no more specific code applies) — also the client-side default target for any Connect code this table does not otherwise list (internal, data_loss, …), matching the variant's existing "catch-all" contract in protocol.rs. A deployment-specific backend failure that does not fit any row above.

A conforming client's Connect-to-TransportError mapping is the mechanical inverse of this table, with RemoteError(String) as the fallback arm for any Connect code not otherwise listed — the mapping is total in both directions, never a partial match. Before it applies the table, a client checks for admission: an HTTP 402 response, or a permission_denied error that carries an AdmissionChallenge detail, maps to AdmissionRequired, never to AccessDenied (§5.1). Before the generic failed_precondition mapping, a client MUST check whether AdvanceRefs returned failed_precondition with exact public message delta base not available in this repository. That answer is not a ticket failure: the client re-plans once as a self-contained pack (§7.6, Errors). On BeginUpload, failed_precondition with exact public message too many open upload tickets is also not a ticket failure. A client MUST also check for unavailable with a PendingVerification detail and follow §7.6's Pending verification polling rule, without its normal backoff ladder. is_retryable (SPEC-TRANSPORT §7) continues to apply once translated: unavailable, resource_exhausted, and aborted are retryable, and everything else is not. AdmissionRequired is never retryable.

Authentication, authorization, addressing, ticket, and storage failures map to these codes. The Condition column is what the server observed. Subject to the checks above, a v2 client maps failed_precondition on BeginUpload, UploadPart, CompleteUpload, a ticketed UploadPack, or an AdvanceRefs carrying ticket_ids to a ticket failure, which it resolves by calling BeginUpload again, never to RefConflict, except for the two cases the paragraph after the table excludes.

Condition Connect code
On an RPC that requires auth v2: a missing, malformed, or expired envelope, a bad signature, a missing X-Repository, or an X-Repository that differs from the signed <repository> (§7.4) unauthenticated
An authenticated principal that the namespace or write policy does not authorize (§7.5) permission_denied
A ticket whose audience, repository, signer, pack_id, or byte count differs from the request (§7.6) permission_denied
A malformed repository identity, or a missing X-Repository on an unsigned RPC to a multi-repository deployment (§7.4) invalid_argument
A read RPC on a repository that does not exist (§7.4) not_found
A signed read whose auth v2 envelope fails verification (SPEC-WRITE-GRANTS §9.2) unauthenticated
A grant that does not authorize a write, including an epoch mismatch found at apply (SPEC-WRITE-GRANTS §11) permission_denied
Any unauthorized read of a private repository (SPEC-WRITE-GRANTS §9.3), indistinguishable from a missing repository not_found
An expired, unknown, or missing ticket, or a ticket presented to an advance of a ref it does not name (§7.6) failed_precondition
Too many open upload tickets for the ref or the signer (§7.6) failed_precondition, with public message too many open upload tickets
An unresolved delta base after the relay-lag bound, in indexed mode 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 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. 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 permission_denied, exact message object blocked, with a repository-empty writer RedactionNotice detail whose rewrites are empty
An AdvanceRefs closure contains a repository tombstone 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 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 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 permission_denied, exact existing message lease suspended, with applicable writer RedactionNotice details; readers keep the 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. 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 fee, 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.

kit registers no challenge schemes and interprets none. An external protocol defines its own challenges, credentials, and receipts. This section defines only how they travel through kit.transport.v1 and which headers a client may attach in reply. Pricing, admission 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 kit.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 kit; at most 8,192 bytes
}

The proto messages land additively with the admission 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 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 external protocols the deployment supports: one or more WWW-Authenticate fields and an ADMISSION-REQUIRED field. The server passes them through as the deployment's business layer produced them. kit 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. ADMISSION-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. Admission-gated 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 an admission-gated 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):

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 challenge parameters, an opaque value, or a digest over the unary BeginUpload body, is the deployment's job. kit 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="Admission-Authorization" in every challenge it sends, so the admission credential never displaces the bearer token.

Caching. A 402 response carries Cache-Control: no-store. A response that carries an Admission-Receipt or an ADMISSION-RESPONSE field 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, Admission-Receipt, ADMISSION-REQUIRED, ADMISSION-RESPONSE) and allows the credential request headers (Admission-Authorization, ADMISSION-SIGNATURE, Authorization, and Accept-Admission). A preflight OPTIONS request never requires admission.

Redaction. Servers and clients MUST keep admission credentials and receipts out of logs, traces, error messages, and analytics. This covers every header a client attaches from its admission helper, Admission-Receipt, ADMISSION-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 ADMISSION-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 admission 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:

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": "/kit.transport.v1.TransportService/BeginUpload",
  "description": "text from the detail, or empty",
  "challenges": [{ "scheme": "…", "value": "…" }],
  "headers": {
    "www-authenticate": ["Admission id=\"…\", …"],
    "admission-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 admission-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:

{ "Admission-Authorization": "Admission eyJ…" }

The helper returns the credential in the header its challenge names (informative): Authorization, Admission-Authorization or ADMISSION-SIGNATURE. 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.

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:

A later kit specification that defines a request header MUST name it X-Kit-* or add it to this list.

kit 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 an Admission-Receipt or ADMISSION-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 kit.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:

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)

Protocol requirement.

DownloadPackResponse.body is a oneof of header (DownloadPackHeader{ total_bytes }), chunk (PackChunk), and location (DownloadPackLocation{ url, expires_at_ms, total_bytes }). A streaming response sends exactly one header first, then 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).

Opt-in locations. A client MAY set DownloadPackRequest.accept_location to true only when discovery (§2.1) advertises supports_pack_locations = true and a positive max_pack_location_lifetime_ms. A server MUST NOT send a location unless that request opted in and the server advertises support. Absent or false opt-in preserves the existing streaming wire response. Even with opt-in, the server MAY stream instead. A location response consists of exactly one location message and no header or chunks.

The location is single-pack and short-lived. It MUST name exactly the requested, verified, content-addressed pack using an absolute HTTPS URL without user information or a fragment. Its expires_at_ms is Unix time in milliseconds, MUST be later than issuance, and MUST be at most the advertised maximum lifetime after issuance. Its total_bytes is the exact pack length. A server MUST issue a location only after every check that streaming DownloadPack applies to that caller has succeeded: named-repository membership (§7.4), private-repository read authorization, denial and takedown, and hiding held and pending-inspection content. A location MUST NOT reveal content hidden from that caller. The server MUST NOT issue a location for pending-inspection content, including when the caller's writer view permits streaming that content. It MAY stream instead under the existing visibility rules.

Denial or takedown after issuance MUST prevent access through every issued location within max_pack_location_lifetime_ms, by expiry or purge. A deployment MUST NOT claim a faster denial or takedown guarantee than that advertised bound. Providers that cannot enforce the bound MUST NOT advertise or issue locations.

A client MUST fetch a location without kit credentials. It MUST NOT send auth-v2 headers, kit bearer tokens, grants, or other kit authentication material to the location host, including through redirects. It MUST verify both the requested pack id (content address) and exact total_bytes before using any location bytes. The caller's pack-size limits still apply.

Clients SHOULD reject location destinations outside public unicast address space before making a connection. This includes loopback, private, link-local, unique-local, carrier-grade NAT, multicast, broadcast, unspecified, documentation, and other reserved IPv4 and IPv6 ranges. IPv4-mapped and IPv4-compatible IPv6 addresses require the same checks as their embedded IPv4 addresses. The policy SHOULD filter the resolved addresses that the connector actually uses, including each new resolution, and check IP-literal hosts. Checking the URL hostname alone does not prevent DNS rebinding. A local caller MAY override this policy for a private pack host; remote responses MUST NOT control that override. Destination rejection is a location-fetch failure.

A location fetch SHOULD honor the remaining local request deadline, clamped to the location channel's transfer timeout. This budget is local metadata and MUST NOT add headers to the location request. On any location fetch or verification failure, including an invalid location response, the client MUST retry once with streaming DownloadPack and accept_location absent or false, unless the local request deadline has expired. The client MUST check the remaining budget before starting that fallback. It MUST NOT trust or expose unverified location bytes. A client that cannot fetch and verify locations, including a wasm client without that facility, MUST leave opt-in absent or false. Clients MUST provide a configuration switch that disables location opt-in.

Reference-client behavior. The native location channel uses a separate, credential-free HTTPS connector and rejects non-public destinations by default. Its filtering resolver checks connection addresses; IP literals pass the same policy. A local caller can allow private destinations with kit_sync::NativeHttp::allow_private_pack_locations(true). The channel does not follow redirects, requires HTTP 200 and identity content encoding, and bounds response bytes before pack verification. RequestPolicy::request_timeout covers the initial RPC, location fetch, and any streaming fallback. The location fetch receives only the remaining budget and cannot exceed its own transfer timeout. The wasm client continues to request streaming.

Conformance coverage. The two ordinary location cases remain Tier 1 wire cases. Optional fixture cases for location security, fallback, and expiry are Tier 2 server-control cases. They send fixture requests through the control client at control_url, which may differ from the wire origin. A host without the required controls receives a declared control skip for those cases; absence of controls is not feature-based NotApplicable.


7. Deployment targets

One proto, one generated codebase — no per-target dialect.

7.1 Reference server

Reference-server behavior.

A reference server stores packs in an object store and keeps ref CAS in one strongly consistent store. A deployment with one global ref store 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). 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). 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:

kit-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; The server verifies the deployment-authority statement, accepts only monotonic targets, and applies a 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). 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 storage 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. 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 admission 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 admission 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, and a synchronous inspector's fail_closed unavailable 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. 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 Server implementations

Reference-server behavior.

No server for kit.transport.v1 lives in this repository. A server binds the generated TransportService and chooses its own storage backend. A filesystem backend uses the .kit on-disk layout, per SPEC-WORKTREE/SPEC-CONCURRENCY.

kit serve <path> (the CLI) is only the kit+ssh:// forced-command server, speaking the SSH-frame protocol on stdin/stdout. Its former HTTP mode (--http) and encrypted listener (--listen-enc) are gone, and with them the axum-hosted TransportServer of kit-transport-connect's former server feature.

7.3 Native CLI Connect client

Reference-server behavior.

kit-client owns the runtime-independent protocol implementation: canonical messages, signed envelopes, Connect framing, and response decoding. kit-sync::ClientSource drives native repository operations over its per-RPC calls. The CLI routes kit+https and kit+http push, fetch, pull, and clone through that source.

kit-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; KIT_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 the implementation that libkit::transfer and kit-bin's remote_dispatch construct for the kit+https:// scheme (and loopback-only kit+http://) — see rust/crates/libkit/src/transfer/open.rs and rust/crates/kit-bin/src/remote_dispatch/mod.rs.

Auth modes (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:

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. Servers with a transactional ref store can therefore enable the existing packmap re-baseline gate automatically; the gate still requires advertised atomicity before resetting a chain.

Every Transport method ConnectTransport implements is driven through the same kit_core::protocol::retrying/BackoffIterator ladder kit-transport-ssh shares: 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 (the 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/kit-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): 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. 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 owner statements are not supported in authority namespace mode 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 in this section. 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 server 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. These rules apply equally to issuing a DownloadPack location (§6.2): globally shared storage or a presigning provider MUST NOT substitute for the named repository's membership and caller-visibility checks.

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: kit+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 kit serve <path> serves the one repository under that path, as it always has. kit 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). SSH carries the 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 kit.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:

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:

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:

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.

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. 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.

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, 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 (KPKL) are uploads like any other pack, and need tickets too. Reference-server behavior. An advance names at most seven tickets (§4).

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/kit/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 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. 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. 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. This upload restriction does not prohibit presigned GET locations for already-verified packs under §6.2. Those downloads retain all streaming read checks, the advertised revocation bound, and client verification duties; they do not authorize presigned uploads.

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 §7.1. These requirements apply to any conforming server, independently of its storage implementation.

Every RPC carries its own nonce: BeginUpload, each UploadPart, CompleteUpload, a ticketed UploadPack, and AdvanceRefs are separate signed operations.

The server also runs admission on every other signed unary write: UpdateRef, including deletion (§7.8), and an AdvanceRefs that consumes no ticket. Allowing, challenging or denying it is deployment policy. A configured non-default admission also runs on ticketed AdvanceRefs and the same-session implicit consuming UpdateRef (§7.4). That admission covers ref effects with zero bytes; uploads keep their independent byte reservations.

RPC Admission Durable effects
BeginUpload (unary; names its target ref) Yes The replay result, reservation responsibility, and ticket in one atomic unit.
UploadPart, CompleteUpload, and a ticketed UploadPack (the part path, §7.6) No Pack or part bytes, authorized by the ticket token; no replay or repository-membership effects. The ticket's audience, repository, and signer MUST equal the request's, and for a ticketed UploadPack its pack_id and byte count MUST equal the request's pack: commitment; otherwise the request is permission_denied (§7.6).
AdvanceRefs Yes when ref admission is configured In one atomic acceptance unit: the head and packmap effects, replay result, applicable quota effects, consumed-ticket state, added membership, one terminal Committed outcome per consumed-ticket reservation, and the separately granted zero-byte ref reservation's outcome. Responsibility for propagating any deferred membership projection is durable in that unit; read indexes may lag under §7.9.

A deployment settles a charge 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. Repeated delivery does not create another terminal outcome or settlement.

Reference-server behavior. The built-in default admission 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.

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 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-Kit-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-Kit-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 kit.transport.v1.RedactionNotice { bytes envelope = 1; }. The envelope is a DSSE envelope (SPEC-ATTESTATIONS §4). Its payloadType is exactly application/vnd.kit.redaction-notice.v1+json, and the DSSE signature covers that string. A verifier MUST reject an envelope with any other payloadType. The golden vectors in rust/tests/golden/redaction/ pin this value, and scripts/check-redaction-goldens.py checks it. 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:

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. Object-store backends 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. 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 later work:


9. Version history

Version Status Changes
2 draft §7.4 client URL parsing accepts the union of the self-certifying and authority-mode namespace grammars; the server still enforces only its own mode's grammar, and grants, grant epochs, and visibility statements keep the self-certifying grammar. No wire schema change.
2 draft Opt-in authority-owned opaque namespace grammar in §7.4; disjoint from self-certifying names, with deployment-rooted ownership and refused owner statements. No wire schema or CLI change.
2 (ListRepos) draft Additive §7.10 namespace listing RPC and new messages, atomic coordinator visibility indexes, bounded sorted pages and integrity-protected tokens; grants retain public listing rights only.
2 draft Additive namespace Get/SetAuthorityGeneration RPCs outside auth-v2, with deployment-authority statements and pending completion hints.
2 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 draft §7.4's ssh/enc paragraph gains ssh root mode (kit 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 draft §7.9 clarifies per-bucket ListRefs index lag and head/packmap age differences.
2 draft §5 and §7.6: a part receipt bound to another ticket is an invalid receipt (invalid_argument), with no cross-ticket oracle.
2 draft §5.1 permits order-preserving combined WWW-Authenticate challenge lists on platforms that fold response fields; servers SHOULD separate field lines and clients/helpers MUST parse challenge lists (RFC 9110).
2 draft §5.1 names the admission headers with the Admission- prefix: ADMISSION-REQUIRED, Admission-Authorization, ADMISSION-SIGNATURE, Admission-Receipt and ADMISSION-RESPONSE. The sample challenge schemes are neutral.
2 draft Informative §7.1 clarification: concurrent new-operation duplicates may each reach admission before reservation; admission credentials are single-use to avoid double charging.
2 draft §7.9 defines absent or zero page_size as the advertised maximum and malformed or foreign page tokens as invalid_argument.
2 draft §7.6 requires canonical branch-head/packmap pairing for ticketed advances; §7.8 rejects deletion with tickets.
2 draft §5 classifies invalid part receipts as invalid_argument; §7.6 permits storage-session abort after a root mismatch and retains rotated receipt keys for at least seven days.
2 draft §7.4 repository addressing; §7.5 namespace and write policy (owner key); GetServerInfo (§2.1); §7.6 upload tickets and resumable parts; §7.8 ref deletion; §7.9 consistency and ListRefs paging; error-code split between unauthenticated and permission_denied (§5); SPEC-WRITE-GRANTS: signed reads and X-Write-Grant (§7.1), the grant 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. The M0 server implementation still resumes an interrupted UploadPack through its in_flight replay record until tickets land. Branch-sharded servers MAY require the canonical AdvanceRefs head/packmap pairing (§4). 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). 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). It scopes §7.4's Host/path/forwarded-selector prohibition to Connect RPCs and cross-links plain HTTP read admission (§5.1).
2 draft Additive opt-in pack locations in DownloadPack, discovery support and lifetime fields (§2.1), client verification and streaming fallback (§6.2), and unchanged repository isolation and upload verification (§§7.4, 7.6).
2 draft Additive GetServerInfoResponse.leases = 17 (§2.1); §7.7 ticket-pack loss clarified as a defensive abort case.
2 draft Additive GetServerInfoResponse.async_inspection = 18 (§2.1); §7.1 never stores a sync inspector unavailable for replay; §7.6 AlreadyPresent is never answered for a pack with hidden content.
2 draft Additive GetServerInfoResponse.inspection_max_objects = 19 (§2.1) advertises the configured synchronous-inspection advance cap, absent without inspectors.
2 draft Additive GetReceipt signed writer-view read (§2.2), storage-receipt fields on UpdateRefResponse and AdvanceRefsResponse (§§3–4), and current receipt-and-notice key semantics in GetServerInfo (§2.1).
2 draft Editorial: §3 and §7.3 retry obligations for UpdateRef/AdvanceRefs reconciled with SPEC-TRANSPORT §7 (verification toolchain).
1 draft Initial kit.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 shared-proto extraction.

10. Test anchors

The acceptance gate for this document itself is static, not behavioral:

The native client (§7.3) owns its runtime test anchors. rust/crates/kit-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 a production server).


11. Invariants

Invariant Enforced by
Every Transport trait verb has exactly one corresponding wire RPC, or is a documented client-side default-impl delegation with no independent wire shape. §2's mapping table; protocol.rs's default-impl doc comments.
RefExpectation's wire numbers (ANY=1, MISSING=2, MATCH=3) never change, even after a shared proto is extracted. 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 either completes its stream with chunk.last = true, returns one negotiated location, or fails the whole call before any message is sent. Location bytes are verified before use, with one streaming fallback on failure. §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.

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 kit-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.

kit-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 kit-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 kit.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 ADMISSION-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 kit-sync::push(dir, source, options), which returns PushReport or typed PushError and sends progress through Observer. The CLI delegates the repository mechanics while retaining configuration, trust gates, grants, admission policy, output, and exit codes. Both push drivers use the pure kit-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 kit-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 kit+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(_)).