Skip to content
zit Artifact [b0f11969f7]
Login

Artifact [b0f11969f7]

Artifact b0f11969f754f0d37d201b0396a88e98f51a0291da2d7e38c1d5f004bd6fb8dd:


//! Sync Protocol — draft-fossil-sync-protocol-00.
//!
//! Two repositories reconcile a content-addressed artifact set over HTTP. The
//! protocol is client-driven; the server is stateless across rounds, so only
//! the client runs a state machine (Section 3.8).

const std = @import("std");
const repeat = @import("repeat.zig").repeat;
const rfc_vectors = @import("rfc_vectors.zig");
const artifact = @import("artifact.zig");
const cone_mod = @import("cone.zig");
const ignore_mod = @import("ignore.zig");
const lookslike = @import("lookslike.zig");
const repostate = @import("repostate.zig");
const delta = @import("delta.zig");
const codec = @import("container.zig");
const clusterindex = @import("clusterindex.zig");

/// The client's protocol state (Section 3.8). The server reacts to each
/// request independently and has no state machine.
pub const ClientState = enum {
    /// Operation selected; no round sent yet.
    init,
    /// First round of a modern clone: login suppressed, config/uv-hash
    /// deferred, an `error` reply ignored (Sections 8.3, 8.4, 11.1).
    clone_round0,
    /// Steady round loop: authenticated request, reply dispatched, phantom /
    /// send-queue / config / private work advances (Section 3.2).
    exchange,
    /// Clean stop: no continuation condition of Section 3.7 held.
    terminated,
    /// A fatal protocol error ended the sync without convergence.
    aborted,
};

/// The operation a sync round is performing.
pub const Operation = enum { pull, push, sync, clone };

/// §9.1/§12 — the upper bound on a received project code: an artifact hash,
/// so at most the SHA3-256 hex width (64). The clone client copies the
/// peer's project code into a fixed buffer of this size; a longer field is
/// out of spec and ignored (bounded, never truncated into storage).
pub const max_projectcode_len: usize = 64;

pub const Error = error{ FatalProtocolError, NotAuthorized };

/// Robustness posture (Section 3.9): conservative in what it sends, strict in
/// what it accepts. An unknown non-`pragma` operator and a malformed content
/// card are fatal; liberality is confined to the four enumerated points.
pub const Client = struct {
    state: ClientState = .init,
    operation: Operation = .sync,
    /// Tolerate-and-report — unversioned files this session declined:
    /// legal, validated `uvfile`/`uvigot` cards zit has no unversioned store
    /// to honor. The CLI reads it after the run and reports the count, so a
    /// fossil peer's uv data is dropped VISIBLY, never silently.
    uv_skipped: u64 = 0,
    /// §5.1/§10.1 — replies whose body ended inside a `file`, `cfile` or
    /// `uvfile` payload's declared count. Each one is a handler content
    /// error: the reply is read no further, the prefix already processed
    /// stands, and the round continues as usual. The CLI reads it after the
    /// run and prints a warning.
    payloads_truncated: u64 = 0,
    /// §3.2/§8.5 L1687-1689 — this client's own repository identifiers,
    /// emitted verbatim in the pull/push operation cards (SERVERCODE then
    /// PROJECTCODE). Set once at session init; `buildRequest` reads them and
    /// never writes them.
    self_servercode: []const u8 = "",
    project_code: []const u8 = "",
    /// §8.3 L1661-1662/§3.2 L415 — the PEER's project code, learned from the
    /// `push SERVERCODE PROJECTCODE` card a clone reply closes with (§8.2).
    /// A fresh clone carries none of its own until it records the peer's here
    /// (the shared identifier, §9.1); the CLI reads `peerProjectCode` after
    /// the run and persists it into the new repository's config. An inline,
    /// `max_projectcode_len`-bounded copy — no heap, since the wire slice is
    /// freed at the round boundary.
    peer_project_code_buf: [max_projectcode_len]u8 = undefined,
    peer_project_code_len: usize = 0,
    /// §3.2 L378-379/§9.1 L1897-1898 — the client release/timestamp emitted as the leading
    /// `pragma client-version` line of every request.
    version: VersionInfo = .{ .version = 0 },
    /// §6.1/§6.3 — the login credential, or null for an anonymous/nobody
    /// session (which emits no login card, §6.1 L1091-1092). Sans-IO: the
    /// plaintext password never lives here, only the derived shared secret.
    credential: ?Credential = null,
    /// A clone's login, held as a name and password until the round-0 reply
    /// teaches the peer's project code, which the shared secret is derived
    /// from. fossil sends no login card on a clone's first round for the
    /// same reason. Borrows the caller's bytes; cleared once derived.
    pending_login: ?PendingLogin = null,
    /// §6.5 — the x-f-l-c cookie value `buildRequest` minted for the
    /// REQUEST IT JUST BUILT, zero-length when that request's channel was not
    /// `.cookie`. Rebuilt every round: the nonce covers the round's own body,
    /// so yesterday's cookie is exactly the replayable credential §6.5 exists
    /// to prevent. The driver reads it through `loginCookie` after each
    /// `buildRequest` and hands it to the transport.
    cookie_buf: [max_line_len]u8 = undefined,
    cookie_len: usize = 0,
    /// §5.3 L1078-1079 — the per-round randomness comment appended after the
    /// content cards, so the login nonce (derived over the post-login body)
    /// differs between otherwise-identical rounds. Null in the sans-IO core:
    /// there is no RNG here, so an outer layer injects it.
    round_comment: ?[]const u8 = null,
    /// §8.2 L1593-1603 — the clone sequence number the next clone request
    /// carries (`clone 3 SEQNO`); a received clone_seqno 0 ends the clone, so
    /// a request built at seqno 0 carries no clone card (the cleanup round).
    clone_seqno: i64 = 1,
    // The round loop is a sans-IO state machine: `consumeReply` takes a reply's
    // bytes and updates `state`; the request-emission half and the transport
    // (real HTTP or the simulator) are the caller's — the server is stateless,
    // only the client keeps state (Section 3.8).

    /// §3.3 r24 — the client-side phantom set, keyed by store Rid. It persists
    /// across rounds: the §3.7(a) phantom table lives on the client, not in the
    /// per-round tables. An igot creates an entry; arriving content removes it.
    /// Rid is a stable key — `intern` returns the existing rid for a known hash
    /// and `put` interns first, so the rid an igot recorded is the rid the
    /// later `file` payload removes. The value owns no heap, so `remove`/`deinit`
    /// ignore it.
    phantoms: std.AutoHashMapUnmanaged(repostate.Rid, PhantomState) = .empty,
    /// Rids some held cluster already names. A request announces
    /// held ∧ ¬clustered; the peer learns the rest from the clusters it
    /// obtains. Null → unbounded (the pre-index behavior).
    clustered: ?*const std.DynamicBitSetUnmanaged = null,
    /// Phantoms this run planted on its OWN initiative from held
    /// clusters (`seedClusterPhantoms`), as opposed to ones a peer announced.
    /// A peer that later names one promotes it out of here. The distinction
    /// is what `unresolvedPhantoms` reports on: an expectation the peer set
    /// and did not meet is a failed pull; a promise a cluster made that this
    /// peer cannot keep is a gap to report, not a failure to converge.
    seeded: std.AutoHashMapUnmanaged(repostate.Rid, void) = .empty,
    /// §3.1 L312-313, §3.2 L419-421 — the per-session outbound send buffer:
    /// rids of held artifacts the peer requested with gimme and this side may
    /// push. It persists across rounds (never reset at a round boundary) and is
    /// drained when the caller builds the next request. Rid is a stable key —
    /// `store.lookup` resolves the gimme'd hash to the rid its content lives
    /// under, so what is queued is exactly what gets uploaded.
    send_queue: std.AutoHashMapUnmanaged(repostate.Rid, void) = .empty,
    /// §3.3 r24 — the running count of full-content `file` cards this client has
    /// uploaded across the session. Only a content-sending operation queues sends
    /// (`pushActive`), so this stays 0 for a pull or clone; each queued rid drains
    /// and uploads exactly once, so the sum is the number of distinct artifacts
    /// pushed — the `zit push` yield metric. A round boundary never resets it.
    content_sent: usize = 0,
    /// §7.4/§3.3 — deltas received against a basis this client does not yet hold,
    /// retained until the basis arrives and then reconstructed to a fixpoint. It
    /// persists across rounds (the basis is gimme'd in a later round), owns a
    /// private copy of each retained delta, and derives §3.3 content-received via
    /// `hasTarget` so a dangling target is not re-requested.
    pending: PendingDeltaQueue = .{},
    /// §10.1 r13 — the peer record, updated by each server-version pragma.
    peer: Peer = .{ .version = 0 },
    /// §5.3, §A.4 — the last `# timestamp DATETIME errors N` comment seen; read
    /// for clock skew (the skew computation itself is deferred).
    last_timestamp: ?TimestampComment = null,
    /// §11.2 item 1/3 — a one-way SYNC_PUSH latch. A `pull only *` message
    /// received while push intent is live sets this for the rest of the session
    /// (r6); a round boundary never resets it, and setting it edits no send-
    /// buffer bytes (r10). The live push flag is derived with `operation` in
    /// `pushActive`, so no construction site restates the operation->push map.
    push_cleared: bool = false,
    /// §3.6 L600-601 — the client's single held sync cookie. A server reply's
    /// cookie card overwrites it; a round boundary never resets it. Inline
    /// buffer, no heap — `deinit` needs no change for it.
    cookie: CookieStore = .{},
    /// §7.5 L1380-1389 — client receive-side pending private marker. Per-session:
    /// persists across consumeReply bodies (r5); the next file/cfile card consumes
    /// it at handler entry (r4), a fresh Client (new session) starts clear (r6). A
    /// bare private card sets it, tolerant of intervening comment/non-content
    /// cards (r2), and a repeat leaves it set (r3). A round boundary never resets
    /// it — same per-session lifetime as push_cleared/cookie.
    private_pending: bool = false,
    /// §7.5 L1413-1414 — the receive-side store gate: true once the server has
    /// accepted a send-private pragma (the setter is the pragma-round batch's
    /// job; B3 only consumes it as the store-vs-discard decision). Per-session:
    /// a fresh Client starts false and a round boundary never resets it, the
    /// same lifetime as `private_pending`. For the client receiver this is the
    /// operational encoding of "holds Private" — §9.5 only accepts send-private
    /// when the client holds the capability.
    private_granted: bool = false,
    /// draft-fossil-submanifests-00 §7.2 — set once the server has sent the
    /// `avoid-submanifests` pragma: "a client receiving it MUST NOT emit S
    /// cards in commits created during the session." Per-session and one-way,
    /// exactly like `push_cleared`: a round that carries no pragma never clears
    /// it, and a fresh Client (new session) starts clear. Read through
    /// `clientMayEmitSCards` together with the §8.1 repository setting.
    avoid_submanifests: bool = false,
    /// submanifests §8.1 — this repository's `enable-submanifests` setting, the
    /// client-side twin of `Server.submanifests_enabled`: default false, arms
    /// the §7.2 gate on this client's UPLOAD path (push/sync) and raises the
    /// version it announces. Off leaves every byte this client puts on the wire
    /// exactly as it was before this draft.
    submanifests_enabled: bool = submanifest_emission_default,
    /// submanifests §8.1 — this repository's durable seen-submanifest indicator,
    /// the SECOND thing that arms the §7.2 upload gate. See
    /// `Server.seen_submanifest` for why the setting alone is not enough.
    seen_submanifest: bool = false,
    /// submanifests §7.2 — artifacts this session declined to upload because the
    /// peer's announced version predates sub-manifest support. Session-wide and
    /// monotonic, like `content_sent`. Non-zero is a failed run, not a warning:
    /// the draft rules out silent omission, so a push that withheld an artifact
    /// MUST NOT report success.
    submanifests_refused: usize = 0,
    /// submanifests §7.2 — the rids this session has already refused, so a peer
    /// that re-`gimme`s one is not answered by re-queueing it.
    ///
    /// Not bookkeeping: without it the session LIVELOCKS. A refused artifact is
    /// drained from the send queue and never sent, the peer therefore still
    /// lacks it and gimmes it again next round, and a fresh queue append is
    /// §3.7(b) "content queued this round" — so the exchange continues forever
    /// and a push against a pre-S peer ends in `RoundLimitExceeded` instead of a
    /// clean terminal with a counted refusal. Skipping the re-queue makes the
    /// refusal settle: the round after the refusal queues nothing, §3.7 stops,
    /// and `submanifests_refused` is what the operator sees.
    ///
    /// It also makes the count DISTINCT ARTIFACTS rather than distinct rounds,
    /// which is the number §7.2 wants reported. Cleared whenever the peer
    /// re-announces a version (see `consumeReply`).
    submanifests_withheld: std.AutoHashMapUnmanaged(repostate.Rid, void) = .empty,
    /// submanifests §7.3/§9.4 — the third deferred-edge kind. §9.4: "Deferred-
    /// processing cascades typically know two edge kinds: delta-manifest
    /// baselines and content delta sources. The S reference is a third. An
    /// implementation MUST record, when a tree manifest's processing is deferred
    /// (Section 7.3), enough to rediscover that manifest when the missing
    /// sub-manifest arrives, and its dephantomization path MUST traverse that
    /// record."
    ///
    /// `pending` is zit's record for the two OLDER kinds; this is the third,
    /// kept beside it and traversed at the same point (`noteSubManifestArrival`,
    /// on every dephantomization). Optional and opened lazily: the record needs
    /// the store and an allocator, neither of which a `Client` literal has, and
    /// a session that never receives an S card never allocates it — which is
    /// every session against a repository predating this draft.
    tree_deferrals: ?repostate.TreeDeferrals = null,
    /// §9.3 / submanifests §6.3/§8.1 — the three project-scope sub-manifest
    /// settings latched off the peer's scalar `config /config` card, for the CLI
    /// to adopt into a FRESH clone after the run (the exact shape
    /// `peer_project_code_buf` already has for §9.1's shared identity).
    ///
    /// Latch only: this layer never decides what to do with them. Only the
    /// clone verb applies them, which is what keeps clone-time adoption from
    /// leaking into pull/sync — a later pull against a repository whose operator
    /// re-pinned k locally cannot silently clobber that pin, because no code
    /// path outside `runClone` reads this field. Ongoing refresh would need real
    /// MTIME conflict resolution, and zit's config frame stores no per-field
    /// write time (see `submanifest_config_mtime`); that is a separate piece of
    /// work, deliberately not smuggled in here.
    peer_submanifest: SubmanifestConfig = .{},
    /// §9.3 r28 — the peer's shun table, latched off its `config /shun` cards for
    /// the clone verb to adopt. Latch only, exactly like `peer_submanifest`: a
    /// pull must never silently re-shun on a peer's say-so, so nothing outside
    /// `runClone` reads this.
    ///
    /// Rows are COPIED, not borrowed: the reply body they were parsed from is a
    /// per-round buffer, and a clone's cleanup round outlives it.
    peer_shun: std.ArrayListUnmanaged(OwnedShunRow) = .empty,
    /// The peer's user rows as latched this session, copied for the
    /// same reason `peer_shun` copies: the reply body is a per-round buffer.
    peer_users: std.ArrayListUnmanaged(OwnedUserRow) = .empty,
    /// Which configuration group an explicit `config pull` asks
    /// for. `/shun` is the default and the only one a CLONE ever requests;
    /// `/user` is reachable only through the operator-initiated verb, because
    /// the rows carry password hashes and fossil serves them to an
    /// administrator alone.
    config_group: []const u8 = config_shun_group,
    /// §9.3 r28 — ask for the configuration groups on an ORDINARY round,
    /// not only a clone's cleanup round. Set by `zit config pull`, fossil's
    /// `configuration pull AREA`: an explicit, operator-initiated request.
    ///
    /// Deliberately opt-in and never automatic. Refreshing a shun table on
    /// every sync would let any peer suppress content here on its own say-so;
    /// making the operator ask is what keeps the takedown decision theirs.
    request_config: bool = false,
    /// §3.3 L553-556, §8.8 L1839-1841 — the repository shun table the per-round
    /// phantom scan consults. Borrowed for the session's life and never written
    /// here; the CLI supplies the durable set and a bare `Client` gets the empty
    /// one, so a construction site that names none behaves as it did before the
    /// filter existed. Read on every scan, never memoised: freshness is the
    /// implementation's business, not the protocol layer's.
    shun: ShunSet = .none,
    /// Sparse-cone RFC section 5.2 rule 3 -- answers "is this wire name an
    /// elided phantom under the store cone". Same total-function contract as
    /// `shun`; `.none` (every dense store) answers false for every name.
    elided: ShunSet = .none,
    /// Sparse-cone RFC section 6.1 -- the cone this client requests, RAW
    /// members borrowed from the operator's parse. Empty = dense request.
    /// Encoded at emission (`pragma cone MEMBER`), one card per member, on
    /// every round: the server is stateless across rounds (section 3.8).
    cone_members: []const []const u8 = &.{},
    /// Section 6.1 exclusion members, emitted as `pragma cone-not MEMBER`.
    cone_not_members: []const []const u8 = &.{},
    /// §7.7 L1463-1467 — "shun-sha1 applies exactly when the receiving
    /// repository's effective hash-policy value at the start of processing that
    /// card is the shun-sha1 value defined by [FossilArtifact], Section 7. No
    /// peer state or artifact-transfer card field participates in this
    /// predicate." So it is a property of THIS repository, carried per session
    /// and read per card — the other half of `admitInbound`'s combined target
    /// predicate, alongside `shun`. The CLI supplies the repository's stored
    /// value; a bare `Client` gets `.auto`, whose only §7.7-relevant property is
    /// that it is not shun-sha1, so a construction site that names none behaves
    /// exactly as it did before this field existed.
    hash_policy: artifact.HashPolicy = .auto,

    /// The last peer error-card text, decoded, bounded -- kept so a refusal
    /// can be rendered in the peer's own words after the exchange aborts.
    peer_error_buf: [peer_error_cap]u8 = undefined,
    peer_error_len: usize = 0,
    /// §11.2 r9 — the server's advisory `message` cards, decoded, in arrival
    /// order, for the CLI to show (fossil prints them as "Server says: ...").
    /// Owned; at most `max_server_messages` are kept, so a chatty server costs
    /// a bounded amount.
    server_messages: std.ArrayListUnmanaged([]u8) = .empty,
    /// The server kept a pushed check-in on an attempt branch this session
    /// (`pragma zit-diverted`), so the local repository is stale until it pulls.
    diverted: bool = false,

    /// §7.5 L1416-1420 — per-phantom privacy. Map presence is still the phantom
    /// marker (content-absent, §3.3 r24); this value carries the private flag a
    /// private `igot HASH 1` sets, so §3.3(c) gimme-eligibility stops re-
    /// requesting content that will never be sent. Default public.
    const PhantomState = struct { private: bool = false };

    /// §3.2/§3.7/§3.8 — the closed error set `consumeReply` returns, so the CLI
    /// can switch it exhaustively. Legal-but-unsupported unversioned cards are
    /// no longer an error at all: they validate, count into `uv_skipped`, and
    /// the session continues (tolerate-and-report). Auth failures never
    /// surface here: they arrive as `error` cards and route through
    /// `clientOnErrorCard`.
    pub const ConsumeError = error{ OutOfMemory, FatalProtocolError };

    /// The peer's last error-card text, or null when none arrived.
    pub fn peerErrorText(self: *const Client) ?[]const u8 {
        if (self.peer_error_len == 0) return null;
        return self.peer_error_buf[0..self.peer_error_len];
    }

    /// Frees the phantom set and the send buffer. The Rid keys own no memory,
    /// so this is just the maps' own storage.
    pub fn deinit(self: *Client, gpa: std.mem.Allocator) void {
        self.phantoms.deinit(gpa);
        self.seeded.deinit(gpa);
        self.send_queue.deinit(gpa);
        self.submanifests_withheld.deinit(gpa);
        self.pending.deinit(gpa);
        for (self.peer_shun.items) |row| {
            gpa.free(row.hash);
            gpa.free(row.reason);
        }
        self.peer_shun.deinit(gpa);
        for (self.peer_users.items) |*row| freeOwnedUserRow(gpa, @constCast(row), row.owned.len);
        self.peer_users.deinit(gpa);
        for (self.server_messages.items) |m| gpa.free(m);
        self.server_messages.deinit(gpa);
        if (self.tree_deferrals) |*td| td.deinit(); // submanifests §7.3/§9.4
        self.* = undefined;
    }

    /// §9.3 r28 — the peer's shun rows as latched this session, for the clone
    /// verb to persist. Borrowed from the client; valid until `deinit`.
    pub fn peerShunRows(self: *const Client) []const OwnedShunRow {
        return self.peer_shun.items;
    }

    /// The peer's user rows as latched this session. Borrowed from
    /// the client; valid until `deinit`.
    pub fn peerUserRows(self: *const Client) []const OwnedUserRow {
        return self.peer_users.items;
    }

    /// Parse one `config /user` payload and keep an owned copy. A row that
    /// does not parse is dropped rather than aborting the session, exactly as
    /// a malformed shun row is: configuration is advisory payload (§9.3), and
    /// one unreadable row must not cost the operator the whole table.
    fn latchUserRow(self: *Client, gpa: std.mem.Allocator, payload: []const u8) error{OutOfMemory}!void {
        const q = parseUserConfigPayload(payload) catch return;
        const mtime = std.fmt.parseInt(i64, q.mtime_text, 10) catch return;
        var row: OwnedUserRow = .{ .login = "", .pw = "", .cap = "", .info = "", .mtime = mtime };
        var filled: usize = 0;
        errdefer freeOwnedUserRow(gpa, &row, filled);
        for ([_]struct { q: ?[]const u8, out: *[]const u8 }{
            .{ .q = q.quoted_login, .out = &row.login },
            .{ .q = q.quoted_pw, .out = &row.pw },
            .{ .q = q.quoted_cap, .out = &row.cap },
            .{ .q = q.quoted_info, .out = &row.info },
        }) |f| {
            const quoted = f.q orelse {
                filled += 1;
                continue;
            };
            const buf = try gpa.alloc(u8, quoted.len);
            errdefer gpa.free(buf);
            const plain = fossilUnquote(quoted, buf) catch {
                gpa.free(buf);
                return;
            };
            // `fossilUnquote` writes into `buf` and returns a prefix of it;
            // keep the whole allocation so the free below is exact.
            f.out.* = plain;
            row.owned[filled] = buf;
            filled += 1;
        }
        if (row.login.len == 0) {
            freeOwnedUserRow(gpa, &row, filled);
            return; // a row with no primary key names nobody
        }
        try self.peer_users.append(gpa, row);
    }

    /// Parse one `config /shun` payload and keep a copy. A row that does not
    /// parse, or whose name is not a plausible artifact name, is dropped: the
    /// only thing worse than missing a tombstone is writing one against the
    /// wrong artifact, which would suppress content nobody asked to suppress.
    fn latchShunRow(
        self: *Client,
        gpa: std.mem.Allocator,
        payload: []const u8,
    ) error{OutOfMemory}!void {
        var bufs: ShunPayloadBufs = undefined;
        const row = parseShunConfigPayload(payload, &bufs) orelse return;
        const hash_copy = try gpa.dupe(u8, row.hash);
        errdefer gpa.free(hash_copy);
        const reason_copy = try gpa.dupe(u8, row.reason);
        errdefer gpa.free(reason_copy);
        try self.peer_shun.append(gpa, .{ .hash = hash_copy, .epoch = row.epoch, .reason = reason_copy });
    }

    /// submanifests §5.2/§7.3 — how many received check-ins still have at least
    /// one recorded S-reference gap: the count of trees this session may NOT
    /// present as a complete file set. Distinct from `unresolvedPhantoms`, which
    /// counts MISSING HASHES; §9.4's record is keyed by DEPENDENT, which is the
    /// identity an operator (and a retry) needs. Zero for every session that
    /// received no S card, so a run against a repository predating this draft
    /// reports exactly what it reported before.
    pub fn deferredTrees(self: *const Client) usize {
        if (self.tree_deferrals) |*td| return td.deferredCount();
        return 0;
    }

    /// §8.3/§9.1 — the peer's project code as learned from a received
    /// `push`/`pull` card, or empty if none was seen this session. Borrows
    /// the client's own inline buffer; valid until the next such card.
    pub fn peerProjectCode(self: *const Client) []const u8 {
        return self.peer_project_code_buf[0..self.peer_project_code_len];
    }

    /// §9.3 — the three sub-manifest settings this session heard from the peer,
    /// all-null when it sent none (an older peer, or one that ignored the
    /// request). Owns no memory: the wire slices were unquoted into scalars at
    /// receipt, so this survives the round boundary that frees the reply bytes.
    pub fn peerSubmanifestConfig(self: *const Client) SubmanifestConfig {
        return self.peer_submanifest;
    }

    /// §3.1 L312-313, §3.2 L419-421 — copy the send buffer's rids into `out`
    /// and empty the buffer. The caller sizes `out` from `send_queue.count()`
    /// and encodes one file card per rid for the next request; returns the
    /// count written. Total and allocation-free — the capacity bound is
    /// asserted, never risked. The buffer is emptied only here (an explicit
    /// upload), never by a round boundary, per §3.1's per-session rule.
    pub fn drainSendQueue(self: *Client, out: []repostate.Rid) usize {
        const count = self.send_queue.count();
        std.debug.assert(out.len >= count);
        var i: usize = 0;
        var it = self.send_queue.keyIterator();
        while (it.next()) |rid| {
            out[i] = rid.*;
            i += 1;
        }
        std.debug.assert(i == count);
        self.send_queue.clearRetainingCapacity();
        std.debug.assert(self.send_queue.count() == 0);
        return count;
    }

    /// §11.2 item 1/3a — the single live-SYNC_PUSH predicate every push-gated
    /// append site reads: push intent is live iff the operation direction
    /// sends content (§3.3) AND no `pull only *` message has cleared it. Once
    /// cleared it stays cleared (r6), so the two conditions never both hold.
    pub fn pushActive(self: *const Client) bool {
        const active = clientSendsContent(self.operation) and !self.push_cleared;
        return active;
    }

    /// §3.3 r24 — how many artifacts the peer announced (igot, or an M card of
    /// a received cluster) that this side still lacks. Live, not cumulative: an
    /// entry stops counting the moment its content lands, because the store —
    /// not the phantom table — is the authority on what is held. A run that
    /// reaches §3.7's clean terminal with a nonzero count converged on nothing
    /// it was told about, which is the caller's cue to refuse to report success.
    /// submanifests §7.2 — the upload decision for one outbound artifact, given
    /// its plaintext. The client mirror of `Server.submanifestSendGate`, with
    /// the same two guards in the same cost order: the §8.1 arming indicator
    /// first (so a repository that has never held a gated artifact never touches
    /// `bytes`, and its uploads are what they were before this draft), then the
    /// peer's announced version.
    ///
    /// `self.peer.version` is the SERVER's announced release integer (§10.1 r13)
    /// — the peer's, which is what §7.2 reads. The field default is 0, §10.1
    /// r10's "nothing announced yet" value rather than r14's own-version seed,
    /// and it is deliberately the pre-S value: an upload happens only in answer
    /// to a `gimme`, which arrives in a reply, and every zit request carries
    /// DATE/TIME (`serverEmitsServerVersion`), so the server's announcement has
    /// been recorded before the first artifact is ever queued. A peer that never
    /// announced is treated as pre-S, which is the safe direction and matches
    /// the server half.
    /// submanifests §7.2/§8.1 — is the upload gate armed? The client half of
    /// `Server.submanifestGateArmed`, same disjunction and same reason.
    fn submanifestGateArmed(self: *const Client) bool {
        return self.submanifests_enabled or self.seen_submanifest;
    }

    fn submanifestSendGate(self: *const Client, bytes: []const u8) GateDecision {
        if (!self.submanifestGateArmed()) return .emit;
        // §7.2's gated set is "of type SUBMANIFEST, OR a manifest carrying an S
        // card" — two shapes, not one. `transferGatedArtifact` is that whole
        // disjunction; an S-card scan alone would let every LEAF sub-manifest
        // (F cards, no S card, §4.4) past a gate that refused its tree.
        return submanifestPeerVersionGate(self.peer.version, repostate.transferGatedArtifact(bytes));
    }

    ///
    /// A SHUNNED phantom does not count. §3.3 L553-556 excludes its name from
    /// the scan, so this run deliberately never asked for it: it is a phantom
    /// that was told about and refused, not one that was asked for and withheld.
    /// Counting it would make `zit pull` report failure — permanently, since the
    /// peer re-announces the hash every round — for doing exactly what the
    /// operator's shun row instructed. The phantom itself stays (creation is
    /// unconditional on the shun table, §3.3 r24(1) L537-540); only the
    /// unmet-expectation tally excludes it.
    pub fn unresolvedPhantoms(self: *const Client, store: repostate.ArtifactStore) usize {
        var unresolved: usize = 0;
        var it = self.phantoms.keyIterator();
        while (it.next()) |rid| {
            if (store.held(rid.*)) continue;
            if (self.shun.contains(store.hashOf(rid.*))) continue;
            // Planted from a held cluster and never named by the
            // peer: asked for on this run's own initiative, so its absence
            // is a cluster gap (`clusterGaps`), not an unmet expectation.
            if (self.seeded.contains(rid.*)) continue;
            unresolved += 1;
        }
        std.debug.assert(unresolved <= self.phantoms.count());
        return unresolved;
    }

    /// Promises held clusters made that this run could not collect
    /// from this peer: seeded, still unheld, and not shunned. Reported, never
    /// failed on — the repository is converged against the peer regardless.
    pub fn clusterGaps(self: *const Client, store: repostate.ArtifactStore) usize {
        var gaps: usize = 0;
        var it = self.seeded.keyIterator();
        while (it.next()) |rid| {
            if (store.held(rid.*)) continue;
            if (self.shun.contains(store.hashOf(rid.*))) continue;
            gaps += 1;
        }
        return gaps;
    }

    /// §3.2/§3.8 — the closed error set `buildRequest` returns. `OutOfMemory`
    /// covers the growable output buffer; `RequestTooLarge` fences a request
    /// whose body or any card line would exceed zit's non-normative (§4 L830-833)
    /// ceiling. No auth or protocol error appears here: composing a request
    /// cannot fail those ways.
    pub const BuildError = error{ OutOfMemory, RequestTooLarge };

    /// §3.2 — compose one request body from Client and store state, the
    /// send-direction inverse of `consumeReply`. Sans-IO: bytes out, no
    /// transport. The layout is the client-version pragma (§10.1, always
    /// first), an optional body login card (§6.1, only for a named identity on
    /// an un-gated peer), then the operation card(s), an `igot` per held
    /// artifact (pull/push/sync only; §8.2 L1643-1650 clone announces none), a
    /// `gimme` per eligible phantom, and a `file` per queued send
    /// (§3.2 L404-406). `out` is a reused backing buffer, cleared here; the
    /// returned slice is `out.items`. Mutates only `send_queue` (drained) and
    /// reads the rest — it does not advance `ClientState` (the driver decides,
    /// via §3.7, whether to call again).
    pub fn buildRequest(
        self: *Client,
        gpa: std.mem.Allocator,
        store: repostate.ArtifactStore,
        out: *std.ArrayListUnmanaged(u8),
    ) BuildError![]const u8 {
        out.clearRetainingCapacity();
        const line_buf = try gpa.alloc(u8, max_line_len);
        defer gpa.free(line_buf);

        // §9.1 L1897-1898 / §3.2 r22 L378-379 — the client-version pragma is
        // the first line and precedes every content-requesting card.
        //
        // submanifests §7.2 — the announced release integer states what this
        // implementation can READ: the §7.2 gate reads the PEER's announcement,
        // so a peer that understands sub-manifests must say so or a conforming
        // sender will refuse it every tree. `announcedVersion` is raise-only and
        // turns on reader support, never on §8.1's emission setting — see its
        // doc comment for why conflating the two livelocks the feature.
        const announced = VersionInfo{
            .version = announcedVersion(self.version.version, submanifest_reader_support),
            .datetime = self.version.datetime,
        };
        const pragma = writeClientVersion(announced, line_buf) catch return error.RequestTooLarge;
        try appendCard(gpa, out, pragma);
        // Sparse-cone RFC section 6.1 -- one F-card-encoded member per card.
        // Stock fossil ignores unknown pragmas by contract, which IS the
        // section 6.3 degradation; a cone-aware server filters its reply.
        for (self.cone_members) |member| {
            var enc_buf: [2 * cone_mod.max_member_len]u8 = undefined;
            const encoded = artifact.encodeEscapes(member, &enc_buf);
            const card = std.fmt.bufPrint(line_buf, "pragma cone {s}", .{encoded}) catch return error.RequestTooLarge;
            try appendCard(gpa, out, card);
        }
        for (self.cone_not_members) |member| {
            var enc_buf: [2 * cone_mod.max_member_len]u8 = undefined;
            const encoded = artifact.encodeEscapes(member, &enc_buf);
            const card = std.fmt.bufPrint(line_buf, "pragma cone-not {s}", .{encoded}) catch return error.RequestTooLarge;
            try appendCard(gpa, out, card);
        }

        // §6.1 L1085-1093 / §6.5 — a body login card is emitted only for a
        // named identity, on a non-clone-round-0 request, against an un-gated
        // peer; anonymous, cookie-gated, and clone-round-0 requests carry none
        // in the body. The nonce (§6.2) covers the bytes after the login LF, so
        // the post-login body is built first, hashed, then prefixed.
        const identity: Identity = if (self.credential) |c| Identity.fromLoginName(c.user) else .anonymous;
        const round: u64 = if (self.state == .clone_round0) 0 else 1;
        const channel = loginTransport(identity, self.peer, self.operation, round);
        self.cookie_len = 0;
        switch (channel) {
            .body => {
                const cred = self.credential.?;
                var tail: std.ArrayListUnmanaged(u8) = .empty;
                defer tail.deinit(gpa);
                try self.appendBody(gpa, store, &tail, line_buf);
                if (tail.items.len > reply_body_cap) return error.RequestTooLarge;
                const nonce = computeNonce(tail.items);
                const signature = computeSignature(&nonce, &cred.secret_hex);
                const login = writeLogin(
                    .{ .user = cred.user, .nonce = &nonce, .signature = &signature },
                    line_buf,
                ) catch return error.RequestTooLarge;
                try appendCard(gpa, out, login);
                try out.appendSlice(gpa, tail.items);
            },
            .none, .cookie => {
                try self.appendBody(gpa, store, out, line_buf);
            },
        }

        if (out.items.len > reply_body_cap) return error.RequestTooLarge;

        // §6.5 — the cookie channel signs EVERY request: the nonce covers the
        // whole decompressed body (there is no login line to exclude), and a
        // fresh credential is minted per round. The bug was exactly this credential
        // going missing on rounds 2+ — the peer's gate flips true once round
        // 1's reply records its version, the body login correctly stops, and
        // without a per-round cookie the authenticated exchange went anonymous
        // for the rounds that carry the artifacts.
        if (channel == .cookie) {
            const cred = self.credential.?;
            const nonce = computeNonce(out.items);
            const signature = computeSignature(&nonce, &cred.secret_hex);
            const value = writeCookieLogin(
                .{ .user = cred.user, .nonce = &nonce, .signature = &signature },
                &self.cookie_buf,
            ) orelse return error.RequestTooLarge;
            self.cookie_len = value.len;
        }
        std.debug.assert((self.cookie_len != 0) == (channel == .cookie));
        std.debug.assert(out.items.len >= pragma.len + 1); // the pragma line is always present
        return out.items;
    }

    /// §6.5 — the cookie credential for the most recently built
    /// request, or null. Valid until the next `buildRequest`.
    pub fn loginCookie(self: *const Client) ?[]const u8 {
        if (self.cookie_len == 0) return null;
        return self.cookie_buf[0..self.cookie_len];
    }

    /// §3.2 L404-406 — append the post-login body (everything the nonce covers)
    /// into `buf`: operation card(s), then `igot` for every held artifact
    /// (pull/push/sync only — a clone round announces none, §8.2 L1643-1650),
    /// `gimme` for every eligible phantom, `file` for every queued send, and
    /// finally the §5.3 randomness comment when one is set. Never emits the
    /// pragma or login card — those are the caller's framing.
    fn appendBody(
        self: *Client,
        gpa: std.mem.Allocator,
        store: repostate.ArtifactStore,
        buf: *std.ArrayListUnmanaged(u8),
        line_buf: []u8,
    ) BuildError!void {
        // §3.2 L397-400 — operation card(s): pull, push, or both for a
        // bidirectional sync; for clone a `clone 3 SEQNO`, unless the round is
        // a post-clone_seqno-0 cleanup (§8.2 L1597-1603), which carries none.
        switch (self.operation) {
            .pull => try self.appendPushPull(gpa, buf, line_buf, .pull),
            .push => try self.appendPushPull(gpa, buf, line_buf, .push),
            .sync => {
                try self.appendPushPull(gpa, buf, line_buf, .pull);
                try self.appendPushPull(gpa, buf, line_buf, .push);
            },
            .clone => switch (nextRequestKind(true, self.clone_seqno)) {
                .clone_card => {
                    std.debug.assert(self.clone_seqno > 0);
                    const seqno: u64 = @intCast(self.clone_seqno);
                    const card = writeCloneCard(seqno, line_buf) catch return error.RequestTooLarge;
                    try appendCard(gpa, buf, card);
                },
                .cleanup_round => {
                    // §8.2 L1597-1603 — no operation card. §9.3 L1936 "Clone defers
                    // requests" / §9.3 r22 — this round is where a clone asks
                    // for configuration, and the ONLY round any zit client ever
                    // does: adoption is clone-time (see `peer_submanifest`).
                    // A peer that ignores the request answers with no config
                    // card, which §9.3 makes a silent, error-free no-op.
                    const round: u64 = if (self.state == .clone_round0) 0 else 1;
                    std.debug.assert(sendableAfterCloneRound0(self.operation, round));
                    const card = writeReqconfig(config_scalar_marker, line_buf) catch return error.RequestTooLarge;
                    try appendCard(gpa, buf, card);
                    // §9.3 r28 — and the peer's shun table. A separate card
                    // rather than widening the one above: `/config` is the
                    // SCALAR marker (r24), not a group name, so the two cannot
                    // be expressed as one request. A peer that models no shun
                    // group answers nothing and no error, which is what every
                    // peer predating this did.
                    const shun_card = writeReqconfig(config_shun_group, line_buf) catch
                        return error.RequestTooLarge;
                    try appendCard(gpa, buf, shun_card);
                },
                .stop => unreachable, // a request is composed only while §3.7 continues
            },
        }

        // §9.3 r28 — an explicit `zit config pull` asks on whatever round
        // it is running, since it has no clone cleanup round to defer to.
        if (self.request_config and self.operation != .clone) {
            const card = writeReqconfig(self.config_group, line_buf) catch return error.RequestTooLarge;
            try appendCard(gpa, buf, card);
            // ONE-SHOT. §3.2 makes an exchange multi-round, and re-asking every
            // round would draw the same table again per round -- wasted bytes,
            // and a duplicate-laden latch the caller then has to de-duplicate.
            self.request_config = false;
        }

        const count: repostate.Rid = store.count();
        // §3.2 L404-405, §A.1 L2509-2513 — a pull/push/sync request announces every
        // held artifact with igot: §8.5 L1689-1690 makes the announcement live
        // (a server creates its phantoms from a client igot only under push).
        // §8.2 L1643-1650 — a clone session enables no push direction (its
        // `clone 3 SEQNO` carries no push card), so a clone-round igot creates
        // no server phantom and never drives the reply, which §8.2 L1643-1646
        // builds solely from the RID-cursor cfile sequence. The §8.2 L1647-1650
        // cleanup "remaining-work" igot permission is therefore never applicable
        // to clone; the walk is skipped so a clone round carries only its
        // operation card(s), pragmas, gimme, and randomness.
        if (self.operation != .clone) {
            var rid: repostate.Rid = 1;
            while (rid <= count) : (rid += 1) {
                if (store.held(rid)) {
                    if (self.clustered) |c| if (rid < c.capacity() and c.isSet(rid)) continue;
                    const card = writeIgot(store.hashOf(rid), false, line_buf) catch return error.RequestTooLarge;
                    try appendCard(gpa, buf, card);
                }
            }
        }
        // §3.2 L404-405, §3.3 L553-556 — request every eligible phantom with
        // gimme. The store IS the phantom set (§3.3 r24: content == null); the
        // private flag, when known, rides on the Client phantom record. A
        // phantom is only ever created under a receive-capable operation
        // (§3.3 r24(1)), so the store's phantom set already encodes receive
        // eligibility — no separate direction gate is needed here. The
        // peer-lacks filter (§3.3 (a)) is empty when composing a request: no
        // peer gimme has been observed for this prospective round. The shun
        // filter (§3.3 (b), L553-556) is NOT empty — it is the repository's
        // durable shun table, asked here on every scan and never memoised, so
        // whatever the injected set is willing to re-derive is honoured on the
        // next scan (the set itself decides that; see `ShunSet`).
        var rid: repostate.Rid = 1;
        while (rid <= count) : (rid += 1) {
            if (!store.held(rid)) {
                // §3.3/§7.4 OPTION B — a phantom whose hash is a retained delta's
                // TARGET is content-received (its bytes reconstruct on basis
                // arrival), so it is excluded from gimme; the DELTASRC phantom it
                // depends on is not a target and stays eligible.
                if (self.pending.hasTarget(store.hashOf(rid))) continue;
                const private = if (self.phantoms.get(rid)) |ps| ps.private else false;
                if (gimmeEligible(.{
                    .peer_lacks = false,
                    // §3.3 L553-556 — "the ordinary phantom scan excludes …
                    // names in the repository shun table". Asked with the
                    // COMPLETE wire name the store holds, never a prefix.
                    .shunned = self.shun.contains(store.hashOf(rid)),
                    .private = private,
                    .private_sync_enabled = self.private_granted,
                    .elided = self.elided.contains(store.hashOf(rid)),
                })) {
                    const card = writeGimme(store.hashOf(rid), line_buf) catch return error.RequestTooLarge;
                    try appendCard(gpa, buf, card);
                }
            }
        }

        // §3.2 L404-405, §7.1 — a full-content file card per queued send. The
        // send buffer holds rids the gimme gate proved materialisable (§3.1
        // L605-608 keys the send decision on `content()`, not bare `held()`), so
        // `content()` is expected non-null here; drain it into a bounded scratch
        // first.
        const queued = self.send_queue.count();
        if (queued > 0) {
            const rids = try gpa.alloc(repostate.Rid, queued);
            defer gpa.free(rids);
            const drained = self.drainSendQueue(rids);
            std.debug.assert(drained == queued);
            for (rids[0..drained]) |send_rid| {
                // §7.7 — a wire-backed container may be held yet not
                // reconstructable (a W4 corrupt full container: content()==null).
                // The gimme gate already excludes such a rid, so this is
                // defence-in-depth: skip it rather than upload corrupt bytes or
                // trap on a null deref — never assert content is present.
                const content = store.content(send_rid) orelse continue;
                // submanifests §7.2 — the upload half of the send gate, on the
                // same "uploading composes from plaintext" path the server's
                // half sits on. The refusal is counted rather than turned into
                // an outbound `error` card: an `error` card in a REQUEST is
                // outside the §3.9 strict-accept request vocabulary and would be
                // answered `bad command`, aborting the round. §7.2 forbids
                // SILENT omission; `submanifests_refused` is what makes it
                // un-silent — the CLI reports it and fails the run, so the
                // operator learns the artifact was withheld and why.
                if (self.submanifestSendGate(content) == .refuse) {
                    const gop = try self.submanifests_withheld.getOrPut(gpa, send_rid);
                    if (!gop.found_existing) self.submanifests_refused += 1;
                    continue;
                }
                writeFile(gpa, buf, store.hashOf(send_rid), content) catch |err| switch (err) {
                    error.OutOfMemory => return error.OutOfMemory,
                    error.NoSpaceLeft => return error.RequestTooLarge,
                };
                self.content_sent += 1; // §3.3 r24 — one artifact uploaded this round
            }
        }

        // §5.3 L1078-1079 — the randomness comment closes the body; null in the
        // sans-IO core, so this is a no-op there.
        if (self.round_comment) |comment| {
            const card = std.fmt.bufPrint(line_buf, "# {s}", .{comment}) catch return error.RequestTooLarge;
            try appendCard(gpa, buf, card);
        }
    }

    /// §8.5 L1687-1689 — append one `push|pull SERVERCODE PROJECTCODE`
    /// operation card, framed by its LF.
    fn appendPushPull(
        self: *Client,
        gpa: std.mem.Allocator,
        buf: *std.ArrayListUnmanaged(u8),
        line_buf: []u8,
        dir: PushPull,
    ) BuildError!void {
        const card = writePushPullCard(dir, self.self_servercode, self.project_code, line_buf) catch return error.RequestTooLarge;
        try appendCard(gpa, buf, card);
    }

    /// §3.2/§3.7/§3.8 — consume one reply body: dispatch each card, apply store
    /// effects, evaluate the §3.7 continuation, advance the §3.8 state. Bytes in
    /// / RoundObservation out; the transport is the caller's (sans-IO).
    /// Precondition: a reply is dispatched only mid-exchange (`exchange`) or in
    /// a clone's first round (`clone_round0`) — the two reply-bearing states of
    /// §3.8 Table 3.
    pub fn consumeReply(
        self: *Client,
        gpa: std.mem.Allocator,
        store: repostate.ArtifactStore,
        body: []const u8,
    ) ConsumeError!RoundObservation {
        std.debug.assert(self.state == .exchange or self.state == .clone_round0);
        var rt = RoundTables.init(gpa);
        defer rt.deinit();
        var obs = RoundObservation{ .operation = self.operation };

        // §5.1 — the body is a flat LF-delimited card stream; the cursor strictly
        // advances (a line consumes ≥1 byte through its LF; a file card also
        // consumes its byte-counted payload), so the loop is bounded by body.len.
        // A payload cut short by the body end resumes at body.len, which ends
        // the loop with the prefix kept (§5.1, §10.1).
        var cursor: usize = 0;
        while (cursor < body.len) {
            std.debug.assert(cursor < body.len);
            const line = readLine(body, cursor) catch return self.abort();
            var next_cursor = line.next;
            if (isCommentLine(line.bytes)) {
                // §5-r25, §A.4 — a timestamp comment updates the skew record;
                // any other comment (nonce, `# sending-clusters`) is skipped.
                if (parseTimestampComment(line.bytes)) |tc| self.last_timestamp = tc;
            } else {
                const cc = classifyCard(line.bytes);
                if (cc.operator == null or isFatal(cc.disposition, .client)) return self.abort();
                next_cursor = try self.dispatchCard(gpa, store, &rt, &obs, cc.operator.?, cc.tokens, body, line.next);
            }
            std.debug.assert(next_cursor > cursor);
            cursor = next_cursor;
        }
        std.debug.assert(cursor == body.len);

        // §7.5 L1387-1389 — a body may end with private_pending still set (no
        // file/cfile card arrived to consume it): not by itself a fatal error, and
        // it is deliberately not cleared here — it persists to the next reply body
        // in this session (r5).

        // §3.7(a)/(b) — the continuation flags read the final table state. A
        // phantom created earlier in the body may have been dephantomized by a
        // later file card, so phantom_created does not imply a non-empty table;
        // phantom_table_nonempty, computed here, is the sole §3.7(a) authority.
        obs.phantom_table_nonempty = self.phantoms.count() > 0;

        if (self.state == .clone_round0) {
            // Sparse-cone RFC section 5.3 -- a round-0 reply that carried an
            // error card and NO clone progress (no artifact, no clone-seqno
            // advance) is a refusal. The section 11.1 discard rule exists for
            // cosmetic error cards riding an otherwise-progressing reply;
            // treating a progress-free one as fatal is what keeps a refused
            // clone from "succeeding" empty (silent incompleteness).
            if (obs.error_card_discarded and
                !obs.artifact_received_this_round and
                !obs.clone_seqno_received)
            {
                // One refusal is not final: fossil answers an anonymous first
                // round of a read-gated clone with the project code AND an
                // error card, and its client discards that error on round 0,
                // derives the secret from the code it was just taught, and
                // sends the clone card again with a login. With a pending
                // login and the code in hand, this is that retry.
                if (self.pending_login == null or self.peer_project_code_len == 0) return self.abort();
            }
            // The round-0 reply named the peer's project code; a pending
            // login can now be signed, and every later round carries it.
            if (self.pending_login) |pl| {
                if (self.peer_project_code_len > 0) {
                    self.credential = .{ .user = pl.user, .secret_hex = deriveSharedSecret(self.peerProjectCode(), pl.user, pl.password) };
                }
                self.pending_login = null;
            }
            // §3.8 r14 — a processed clone reply advances unconditionally.
            self.state = transition(.clone_round0, .clone_reply_processed);
        } else {
            const ev: Event = if (continueExchange(obs)) .round_continues else .round_terminates;
            self.state = transition(.exchange, ev);
        }
        return obs;
    }

    /// Dispatch one classified card (§3.2), returning the offset the loop
    /// resumes at — the payload end for a `file`/`cfile` card, otherwise the
    /// next line.
    /// Exhaustive over all 19 operators: no reply card is silently unhandled.
    fn dispatchCard(
        self: *Client,
        gpa: std.mem.Allocator,
        store: repostate.ArtifactStore,
        rt: *RoundTables,
        obs: *RoundObservation,
        op: Operator,
        tokens: Tokens,
        body: []const u8,
        payload_start: usize,
    ) ConsumeError!usize {
        switch (op) {
            .pragma => {
                // §10.1 — record a server/client-version pragma; §3.5 — any
                // other pragma name is tolerated and ignored here.
                if (tokens.len >= 2 and isVersionPragmaName(tokens.items[1])) {
                    if (parseVersionPragma(tokens)) |v| {
                        const was = self.peer.version;
                        recordPeerVersion(&self.peer, v);
                        // submanifests §7.2 — a peer that RAISES its version
                        // gets a fresh gate answer, so forget what was withheld
                        // from it before. The trigger is the change, not the
                        // announcement: a pre-S peer re-announces the same
                        // version in every reply, and clearing on each one
                        // re-offers artifacts it has already refused. Since
                        // this set is what makes the exchange terminate at all
                        // (see its use in the send decision below), clearing it
                        // every round is a livelock, not a lost optimisation.
                        if (self.peer.version != was) {
                            self.submanifests_withheld.clearRetainingCapacity();
                        }
                    } else |_| {}
                } else if (tokens.len >= 2 and std.mem.eql(u8, tokens.items[1], divert_pragma)) {
                    // The server moved a pushed check-in onto an attempt
                    // branch; the CLI pulls the move so the local repository
                    // agrees. Only the fact is kept; the message says where.
                    self.diverted = true;
                } else if (tokens.len >= 2 and parsePragmaName(tokens.items[1]) == .@"avoid-submanifests") {
                    // submanifests §7.2 — latch the server's refusal for the
                    // session; §3.5's tolerate-and-ignore still covers every
                    // other unrecognised name.
                    self.avoid_submanifests = true;
                }
                return payload_start;
            },
            .push, .pull => {
                // §9.1 — arity-validate (arity < 3 is malformed → fatal).
                // §8.3 L1661-1662/§3.2 L415 — a clone reply's `push` card is how the
                // client first learns the peer's project code (§8.2/§9.1), so
                // record it into the session's inline buffer (bounded copy;
                // the wire slice dies at the round boundary). The CLI reads it
                // via `peerProjectCode` after the run to persist the shared
                // identity into the fresh repository (§9.1).
                const card = parsePushPull(tokens) orelse return self.abort();
                if (card.projectcode.len <= max_projectcode_len) {
                    @memcpy(self.peer_project_code_buf[0..card.projectcode.len], card.projectcode);
                    self.peer_project_code_len = card.projectcode.len;
                }
                return payload_start;
            },
            .clone => {
                // §3.9 — a client→server request-initiation card is never legal
                // in a server reply: a strict-accept protocol violation, not an
                // unimplemented feature.
                return self.abort();
            },
            .file => {
                // §7.5 L1391-1392 — the marker is consumed the moment file handling
                // begins, before arity/hash validation; the delta-form card
                // therefore consumes it too, which is correct: it is still a file
                // card whose handling began.
                const was_private = self.takePrivateMarker();
                // §7.1-r1/r5 — full form (file HASH SIZE) or delta form
                // (file HASH DELTASRC SIZE); arity alone disambiguates. SIZE
                // frames the uncompressed payload identically in both.
                const fc = parseFileCard(tokens) catch return self.abort();
                const payload = consumeContentPayload(body, payload_start, fc.size) catch return self.stopAtTruncatedPayload(body);
                // §8.2 L1586-1589 — a framed file card increments the raw clone
                // received-artifact count before the store/discard split
                // (§3.7(e), read by continuesClone alongside the cursor).
                obs.artifact_count_grew = true;
                // §7.5 L1413-1414 — a private artifact without the grant is
                // discarded without error: bytes consumed (framing ran first so
                // the parser still advances), store untouched, phantom left
                // intact, no versioned progress. Applies to both forms.
                if (was_private and privateReceipt(self.private_granted) == .discard) {
                    obs.private_content_discarded = true;
                    return payload.next;
                }
                // §7.7 L1472-1475 — "A non-clone file whose target is shunned is
                // consumed and discarded after its payload and pending private
                // marker; it emits no error, does not set content-received, and
                // makes no repository mutation. A clone file bypasses this
                // combined target check." Both halves are here: the guard is on
                // `operation != .clone` (the tree's clone-context predicate, set
                // at session init and true across every clone batch — see
                // `receiveDelta`'s clone fast path), and the discard is an early
                // return past both the delta and the store arms, so not even the
                // `intern` a `put` would do lands.
                //
                // The ASYMMETRY with `cfile` below is deliberate and is what the
                // applied text says; do not "restore" a symmetric rule.
                //
                // Placement: after the marker (taken at handler entry above) and
                // after the payload framing, exactly as "after its payload and
                // pending private marker" requires. `private_content_discarded`
                // is NOT set — that flag is §7.5's record of a §7.5 discard, and
                // §7.7 asks for no observation at all.
                if (!self.targetAdmission().admits(fc.hash, false)) return payload.next;
                if (fc.deltasrc) |deltasrc| {
                    // §7.1-r5/§7.4 — delta form: the payload is a [FossilDelta]
                    // against DELTASRC. Reconstruct against a held basis or defer.
                    // A file card is uncompressed (§7.1): no §4.1 container to
                    // retain verbatim, so pass null (persists as full form).
                    try self.receiveDelta(gpa, store, obs, fc.hash, deltasrc, payload.bytes, was_private, null);
                    return payload.next;
                }
                const rid = try store.put(fc.hash, payload.bytes);
                // §3.7(a) — arriving content dephantomizes; the remove is a
                // no-op for a hash never phantom'd (underflow defined away).
                _ = self.phantoms.remove(rid);
                try self.absorbCluster(gpa, store, obs, payload.bytes);
                // submanifests §7.1 — a stored S-bearing artifact phantomizes
                // each absent sub-manifest it references.
                try self.absorbSubManifestRefs(gpa, store, obs, store.hashOf(rid), payload.bytes, was_private);
                // submanifests §9.4 — …and this arrival may itself be the
                // sub-manifest some earlier check-in was deferred on.
                try self.noteSubManifestArrival(gpa, store, obs, store.hashOf(rid));
                obs.versioned_content_delivered = true;
                obs.artifact_received_this_round = true; // §3.7(e) — a stored file delivery this round
                if (was_private) obs.private_content_delivered = true; // §7.5 L1381-1382
                // §7.4 — a full-content store may itself be a retained delta's
                // basis; complete the chain to a fixpoint.
                self.pending.completeFor(gpa, store, store.hashOf(rid), self.targetAdmission()) catch |err| switch (err) {
                    error.OutOfMemory => return ConsumeError.OutOfMemory,
                    error.MalformedDelta, error.ChecksumMismatch => return self.abort(),
                };
                return payload.next;
            },
            .igot => {
                // §3.9 strict-accept — parse precedes the canObtain guard so a
                // malformed igot is fatal in any direction, push-side included.
                const card = parseIgotCard(tokens) catch return self.abort();
                if (card.private and !self.private_granted) {
                    // §7.5 L1411-1413 — a private `igot HASH 1` from a peer that
                    // LACKS private-content authorization (at this client
                    // receiver, private_granted false: no accepted send-private
                    // pragma, so the content will never be sent here) is never
                    // an error and never creates a phantom (L2478 → lookup, not
                    // intern); it marks an already-held phantom private so
                    // §3.3(c) stops re-requesting it, records that the peer
                    // holds HASH, and processing continues.
                    const maybe_rid = store.lookup(card.hash);
                    const pptr: ?*PhantomState = if (maybe_rid) |rid| self.phantoms.getPtr(rid) else null;
                    const receipt = receivePrivateIgot(pptr != null);
                    std.debug.assert(!receipt.create_phantom and receipt.record_peer_has); // negative space
                    if (receipt.mark_phantom_private) {
                        pptr.?.private = true;
                        obs.phantom_marked_private = true;
                    }
                    obs.private_igot_peer_has = receipt.record_peer_has;
                    return payload_start;
                }
                // §3.3 r24/r25 — a public igot, or (§7.5 L1416-1420) a private
                // igot when this receiver holds private-content authorization
                // (private_granted, so it may pull the private content), creates
                // a phantom only when the artifact is unheld and this side can
                // obtain it. The guard skips interning for a push-side reply the
                // client can never obtain from — an intern-avoidance
                // optimization over the canObtain check inside igotDisposition,
                // not dead logic.
                if (!igotCanObtain(.client, self.operation)) return payload_start;
                const rid = try store.intern(card.hash);
                if (igotDisposition(.client, self.operation, store.held(rid)) == .create_phantom) {
                    const gop = try self.phantoms.getOrPut(gpa, rid);
                    _ = self.seeded.remove(rid); // the peer named it: an expectation now
                    if (!gop.found_existing) {
                        gop.value_ptr.* = .{};
                        obs.phantom_created = true;
                    }
                }
                return payload_start;
            },
            .gimme => {
                // §3.3 L569 — a gimme always records that the peer lacks HASH.
                // The 40/64-hex length recordPeerLacks requires is enforced by
                // parseGimmeCard, so it is never asserted on wire input.
                const g = parseGimmeCard(tokens) catch return self.abort();
                try rt.recordPeerLacks(g.hash);
                // §3.2 L419-421 / §3.3 L569-572 — if this side holds HASH and
                // the operation direction permits sending, queue its rid in the
                // per-session send buffer (§3.1 L312-313) for the next request.
                // lookup, not intern: an unheld or garbled gimme creates no
                // phantom, keeping §3.3's phantom set closed. §3.7(b): a fresh
                // append this round sets the per-round content_queued flag.
                if (store.lookup(g.hash)) |rid| {
                    // Queue only a rid we can MATERIALISE for upload. `held()` is
                    // true for a wire-backed container even when `content()` is
                    // null — a W4 (§7.2/§7.7) full container retained verbatim
                    // that fails to inflate — but uploading needs reconstructable
                    // plaintext (§3.2 L419-421 composes from `content()`), so the
                    // send gate reads `content()`, not bare `held()`. A held-but-
                    // uninflatable hash folds to the same byte-neutral silent-skip
                    // (§3.3) as an unheld gimme: not queued, no corrupt bytes sent.
                    const holds = store.content(rid) != null;
                    // policy_permits is true: shun/private disclosure gating is
                    // not modeled at this layer yet, as at every gimmeResponse
                    // call site.
                    // §11.2 r11 — the send gate reads the *live* push flag, not
                    // the immutable operation intent: a `pull only *` message
                    // earlier in this same reply clears it and suppresses a
                    // later gimme's content append. r12 (an earlier gimme's
                    // append is retained) falls out of in-order dispatch.
                    // submanifests §7.2 — an artifact this session already
                    // refused is not re-queued. Re-queueing it would be a fresh
                    // §3.7(b) content-queued observation every round, so the
                    // exchange would never terminate (see `submanifests_withheld`).
                    if (gimmeResponse(holds, self.pushActive(), true) == .send and
                        !self.submanifests_withheld.contains(rid))
                    {
                        const gop = try self.send_queue.getOrPut(gpa, rid);
                        if (!gop.found_existing) obs.content_queued = true;
                    }
                }
                return payload_start;
            },
            .clone_seqno => {
                // §8.2 L1577-1581, §3.8 r19 — validate NEXT's sign at parse: a
                // negative wire value is a fatal protocol error, never stored
                // into obs.clone_seqno_next where continueExchange's `>= 0`
                // precondition would otherwise trip on wire data.
                const n = parseCloneSeqno(tokens) catch return self.abort();
                if (cloneSeqnoIsFatal(n)) return self.abort();
                std.debug.assert(n >= 0);
                obs.clone_seqno_next = n;
                obs.clone_seqno_received = true;
                return payload_start;
            },
            .@"error" => {
                // §11.1 — an error card is data, not a directive: discarded in a
                // clone's first round, otherwise it aborts the exchange. Either
                // way the decoded text is kept (bounded) so the CLI can render
                // a refusal in the peer's own words.
                if (tokens.len >= 2) {
                    const encoded = tokens.items[1];
                    var scratch: [peer_error_cap]u8 = undefined;
                    const take = @min(encoded.len, scratch.len);
                    const decoded = artifact.decodeEscapes(encoded[0..take], &scratch);
                    @memcpy(self.peer_error_buf[0..decoded.len], decoded);
                    self.peer_error_len = decoded.len;
                }
                return switch (clientOnErrorCard(self.state)) {
                    .discard => blk: {
                        obs.error_card_discarded = true;
                        break :blk payload_start;
                    },
                    .terminate => self.abort(),
                };
            },
            .message => {
                // §10.2 L2191 — "Exactly two visible tokens are required."
                // A different visible arity is a defined-operator arity
                // failure (§5.2 L989-990), which this client aborts on: a
                // 1-token bare `message` or a 3-to-6-token form.
                // classifyCard's §5 surplus check already caught a 7th+ token
                // pre-dispatch. The r3 nonfatal disposition governs only the
                // well-formed two-token card below.
                if (tokens.len != 2) return self.abort();
                const encoded = tokens.items[1];
                std.debug.assert(encoded.len <= max_line_len); // alloc bound
                // §11.2 r2 — defossilize before matching; the decoded form never
                // grows past the encoded token, and the scratch is freed here.
                const scratch = try gpa.alloc(u8, encoded.len);
                defer gpa.free(scratch);
                const decoded = artifact.decodeEscapes(encoded, scratch);
                // §11.2 item 1/2 — clear SYNC_PUSH iff push is live and the
                // payload matches `pull only *` (r4); otherwise the advisory
                // path (r9), which changes no flag. This sans-IO layer performs
                // no display, so r5 suppression holds trivially and r9's
                // MAY-display is the CLI caller's choice.
                switch (clientOnMessageCard(self.pushActive(), decoded)) {
                    .pull_only_silent => {
                        self.push_cleared = true; // r6/r10/r13 — one flag, latched
                        std.debug.assert(!self.pushActive()); // now clear
                    },
                    .display_optional => if (self.server_messages.items.len < max_server_messages) {
                        try self.server_messages.append(gpa, try gpa.dupe(u8, decoded));
                    },
                }
                return payload_start;
            },
            .cookie => {
                // §3.6 L600-601, §4.3 — the server MAY emit a cookie; the
                // client MUST store the opaque TEXT, replacing any held one.
                // §B L2891 `cookie-card = "cookie" SP fossilized LF`: a bare
                // `cookie` with no TEXT fails the card's arity check, a fatal
                // client abort (§5.2 L1033-1036). §3.9 L797-802 tolerance is scoped
                // to an unexpected server, not to a missing argument token.
                if (tokens.len < 2) return self.abort();
                std.debug.assert(tokens.items[1].len <= max_line_len);
                self.cookie.storeFossilized(tokens.items[1]);
                obs.cookie_stored = true;
                // §5.1 — cookie carries no byte-counted payload; resume at the
                // next line, not past any content bytes.
                return payload_start;
            },
            .login, .reqconfig => {
                // §6.1 L1085-1093 (login is a request-only card) / §9.3
                // L1935-1939 (reqconfig is client→server). Either in a server
                // reply is outside the tolerated cases of §3.9 L797-803; this
                // client aborts on it.
                return self.abort();
            },
            .config => {
                // §9.3 L1941/L1942-1944 — `config GROUPNAME SIZE` then SIZE
                // content bytes and one mandatory trailing LF. Frame by the
                // byte count, never by scanning; a malformed header or a frame
                // past the body is a fatal content error (§3.9).
                const cc = parseConfigCard(tokens) catch return self.abort();
                const p = consumeConfigPayload(body, payload_start, cc.size) catch return self.abort();
                // §9.3/§9.3 r24 — the scalar marker is the only group whose
                // payload this implementation reads; a table group (r28) is
                // framed and skipped exactly as it was before. The three
                // sub-manifest settings are latched here and applied nowhere in
                // this layer — see `peer_submanifest` for why the split matters.
                if (configKind(cc.groupname) == .scalar) {
                    const got = parseSubmanifestConfigPayload(p.bytes);
                    if (got.enable) |v| self.peer_submanifest.enable = v;
                    if (got.k) |v| self.peer_submanifest.k = v;
                    if (got.auto_retune) |v| self.peer_submanifest.auto_retune = v;
                } else if (std.mem.eql(u8, cc.groupname, config_shun_group)) {
                    // A row that does not parse is SKIPPED, not fatal: §9.3
                    // treats configuration as advisory payload, and aborting a
                    // whole clone over one malformed shun row would trade a
                    // missing tombstone for no repository at all. The skip is
                    // the same posture the scalar group takes per row.
                    self.latchShunRow(gpa, p.bytes) catch return self.abort();
                } else if (std.mem.eql(u8, cc.groupname, config_user_group)) {
                    // Same advisory posture as the shun group: a row that does
                    // not parse is skipped, never fatal.
                    self.latchUserRow(gpa, p.bytes) catch return self.abort();
                }
                return p.next;
            },
            .cfile => {
                // §7.5 L1391-1392 — consume the pending marker at cfile handler entry,
                // before arity/hash validation (see the file arm and
                // takePrivateMarker), so the staged delta form clears it as well.
                const was_private = self.takePrivateMarker();
                // §7.2-r1/r2 — full form (cfile HASH USIZE CSIZE) or delta form
                // (cfile HASH DELTASRC USIZE CSIZE); arity alone disambiguates.
                // Any other arity or a non-numeric USIZE|CSIZE is a fatal
                // strict-accept protocol error (§3.9, §5.2).
                const cc = parseCfileCard(tokens) catch return self.abort();
                // §7.2-r2/r3/r11 — frame by CSIZE, never USIZE; a span past the
                // body end is a truncated payload (§5.1, §10.1).
                const payload = consumeContentPayload(body, payload_start, cc.compressed_size) catch return self.stopAtTruncatedPayload(body);
                // §5.1 L937-948 — resume past the payload AND its optional
                // trailing-LF padding; keyed on the payload's last byte so a
                // following real card (or empty line) is never mis-skipped.
                const resume_at = cfilePaddingEnd(body, payload);
                // §8.2 L1586-1589 — the clone received-artifact count is a raw
                // handler count: a framed cfile increments it here, *before* the
                // store/discard split, so a verified duplicate or policy-
                // discarded cfile counts too. continuesClone (§3.7(e)) pairs it
                // with a positive `clone_seqno` cursor to continue the clone.
                obs.artifact_count_grew = true;
                // §7.5 L1394-1396 — discard a private cfile without the grant
                // before decoding it: only the CSIZE framing above must succeed
                // (a bad frame is a §3.9 fatal); the container is never decoded
                // and the store stays untouched.
                if (was_private and privateReceipt(self.private_granted) == .discard) {
                    obs.private_content_discarded = true;
                    return resume_at; // §5.1 — the padding is on the wire even when discarding
                }
                // §7.7 L1475-1479 — "For cfile, the private-admission behavior of
                // Section 7.5 runs first; if processing continues, a shunned
                // target is consumed and discarded after its payload even in
                // clone, and its raw parser artifact-card count nevertheless
                // increments after handler invocation."
                //
                // All three clauses are satisfied by this line's POSITION, not by
                // new code:
                //   * "§7.5 runs first" — the private discard above returns, so
                //     sitting below it IS the ordering;
                //   * "even in clone" — no `operation` guard here, unlike the
                //     `file` arm above (L1440's clone bypass is for `file` only);
                //   * "the count nevertheless increments" — `artifact_count_grew`
                //     was set above, before the store/discard split, so the
                //     §3.7(e) clone continuation still advances and no second
                //     increment belongs here.
                if (!self.targetAdmission().admits(cc.hash, true)) {
                    return resume_at; // §5.1 — the padding is on the wire even when discarding
                }
                if (cc.deltasrc) |deltasrc| {
                    // §7.2-r5/r9/r12 — the delta form genuinely needs the inflated
                    // payload: it is one §4.1 stored container, already outer-
                    // decoded, so it is container-decoded exactly once here. A short
                    // header, a length-field lie, or a corrupt RFC1950 stream cannot
                    // be reconstructed and is a fatal content error (§3.9); OOM
                    // propagates. (The FULL form below is NOT inflated at all — see
                    // there — so this decode lives inside the delta branch only.)
                    const decoded = codec.decode(gpa, payload.bytes, reply_body_cap) catch |err| switch (err) {
                        error.OutOfMemory => return ConsumeError.OutOfMemory,
                        error.TruncatedHeader, error.InvalidStream, error.OutputTooLarge => return self.abort(),
                    };
                    defer gpa.free(decoded);
                    // §7.2-r13/§7.4 — delta form: the container held a stored
                    // compressed delta, so `decoded` is the [FossilDelta] against
                    // DELTASRC (not the artifact). Reconstruct or defer. Pass the
                    // received container (`payload.bytes`) so the immediate path
                    // retains the compact delta form verbatim (§7.2).
                    try self.receiveDelta(gpa, store, obs, cc.hash, deltasrc, decoded, was_private, payload.bytes);
                    return resume_at;
                }
                // §7.2-r6/r7/r8 FULL form — the payload is inflated NOWHERE at
                // receive. Per §7.2 (USIZE is parsed syntactically but not transfer-
                // layer validated; the payload is the repository's stored compressed
                // full artifact retransmitted verbatim) the wire bytes are interned
                // and retained AS-IS via `retain` below. The store reconstructs
                // plaintext lazily from that container — the read/derivation path
                // inflates it under a cap (logstore.decodeContainer), so the §3.9
                // decompression-bomb guard stays intact THERE — and §7.7 defers
                // integrity (hashing the RECONSTRUCTED bytes) to the persist commit
                // hook, from whose pre-storage check a clone-context cfile is
                // excluded (§7.7 r6). §5.2: a full container that fails to inflate is
                // therefore NOT a receive-time fatal; content-addressing plus the
                // capped read path surface corruption at read, not as a clone abort.
                // Only the §4.1 frame floor is enforced here — a payload too short to
                // carry the 4-byte length prefix is not a container at all (a framing
                // error, §3.9 fatal, distinct from the decompression-time corruption
                // §7.2/§7.7 governs) and would break `retain`'s container.len >= 4
                // precondition.
                if (payload.bytes.len < 4) return self.abort();
                const rid = try store.intern(cc.hash);
                _ = self.phantoms.remove(rid);
                obs.versioned_content_delivered = true; // §3.7(a) / §8.2 r14 — identical to a full file card
                obs.artifact_received_this_round = true; // §3.7(e) — a stored cfile delivery this round
                if (was_private) obs.private_content_delivered = true; // §7.5 L1381-1382
                // §7.2/§4.1 — retain the received full container verbatim (the
                // cfile payload IS a §4.1 container) so a later serve retransmits
                // it byte-for-byte. Advisory; §7.7 verification of these bytes is
                // deferred to the persist commit hook (a clone skips the
                // transfer-layer pre-storage check, §7.7 r6).
                store.retain(rid, .{ .full = payload.bytes }) catch |err| switch (err) {
                    error.OutOfMemory => return ConsumeError.OutOfMemory,
                };
                // §3.3 r24 — the wire bytes are a §4.1 container, so cluster
                // membership is readable only after an inflate. Inflate into a
                // scratch buffer freed on the spot rather than through
                // `store.content`, which would memoise the plaintext and cost
                // the §7.2 wire-backed peak its O(wire) bound. Gated so a clone
                // — which absorbs no cluster — never pays the inflate at all. A
                // container that will not inflate announces nothing: §7.2/§7.7
                // defer that verdict to the read path, not to this round.
                //
                // submanifests §7.1 is NOT satisfied by this branch on a clone,
                // and deliberately is not attempted here: it needs the same
                // plaintext, and a clone has none. §7.1 is a duty of STORING, so
                // a clone discharges it where it actually stores — the persist
                // commit hook (`cmd/sync.phantomizeSubManifestRefs`), which is
                // already holding each artifact's reconstructed plaintext for
                // the §7.7 verify. That keeps the clone's O(wire) receive bound
                // exactly as it is while still minting a phantom for every
                // sub-manifest the CLONED PEER did not itself hold.
                if (self.clusterAnnouncementsApply()) {
                    if (codec.decode(gpa, payload.bytes, reply_body_cap)) |plain| {
                        defer gpa.free(plain);
                        try self.absorbCluster(gpa, store, obs, plain);
                        // submanifests §7.1, on the plaintext this branch
                        // already paid to inflate.
                        try self.absorbSubManifestRefs(gpa, store, obs, store.hashOf(rid), plain, was_private);
                    } else |err| switch (err) {
                        error.OutOfMemory => return ConsumeError.OutOfMemory,
                        error.TruncatedHeader, error.InvalidStream, error.OutputTooLarge => {},
                    }
                }
                // submanifests §9.4 — the third edge kind, traversed beside the
                // two older ones below. Unlike the cluster/S-run absorb above,
                // this is NOT gated on `clusterAnnouncementsApply`: a clone
                // stores the very sub-manifest an earlier check-in was deferred
                // on, and the record must retire whoever asks for it.
                try self.noteSubManifestArrival(gpa, store, obs, store.hashOf(rid));
                // §7.4 — a full-content cfile may complete a retained delta.
                self.pending.completeFor(gpa, store, store.hashOf(rid), self.targetAdmission()) catch |err| switch (err) {
                    error.OutOfMemory => return ConsumeError.OutOfMemory,
                    error.MalformedDelta, error.ChecksumMismatch => return self.abort(),
                };
                return resume_at; // §7.2-r3/§5.1 — past CSIZE payload bytes and the optional padding LF
            },
            .private => {
                // §7.5 L1380-1382 — a bare `private` (arity 1, ABNF §B L2919
                // `private-card = "private" LF`) sets the receive-side pending
                // marker for the next file/cfile card. A defined operator with a
                // bad visible arg count is that card's own §5.2 arity violation
                // (L1770-1776): the client aborts the round with no outbound error
                // card (§5.2 L1033-1036, §3.9). A 7th+ token is already fatal at
                // classifyCard (surplus → .bad_command → isFatal client), so this
                // gate covers only the 2..6-token window that reaches here.
                std.debug.assert(tokens.len >= 1); // operator token always present
                if (tokens.len != 1) return self.abort();
                self.private_pending = true; // r1/r3 — a repeat leaves it set
                return payload_start; // arg-less card carries no byte payload
            },
            // §7.3 — parse and frame legal unversioned-file cards before
            // retaining the deliberate unsupported-feature boundary. These cards
            // never consume `private_pending`, store content, or mutate `obs`.
            .uvfile => {
                const card = parseUvfileCard(tokens) catch return self.abort();
                if (!validUvHash(card.hash)) return self.abort();
                if (card.isDeletion() and card.size != 0) return self.abort();
                const payload = consumeUvfilePayload(body, payload_start, card) catch return self.stopAtTruncatedPayload(body);
                if (!card.isDeletion() and !card.contentOmitted() and !verifyUvContent(card.hash, payload.bytes)) {
                    return self.abort();
                }
                // Tolerate-and-report: the card was legal and its content
                // verified, and zit has no unversioned store to put it in.
                // Counted so the session can SAY what it declined; never an
                // abort — a peer holding uv data must still sync everything
                // versioned.
                self.uv_skipped += 1;
                return payload.next; // past the framed uv content, at the next card
            },
            .uvigot => {
                const card = parseUvigotCard(tokens) catch return self.abort();
                if (!validUvHash(card.hash)) return self.abort();
                if (std.mem.eql(u8, card.hash, "-") and card.size != 0) return self.abort();
                // An announcement of a file this side will never fetch:
                // one uv file not transferred, same count as an unsolicited
                // uvfile above.
                self.uv_skipped += 1;
                return payload_start; // announcement card, no byte payload
            },
            .uvgimme => {
                // The peer asking US for a uv file: nothing is lost on
                // this side (zit holds none), so validated and ignored, not
                // counted.
                _ = parseUvgimmeCard(tokens) catch return self.abort();
                return payload_start; // request card, no byte payload
            },
        }
    }

    /// §7.5 L1391-1392 — consume the pending private marker at file/cfile handler
    /// entry: read it, clear it unconditionally, return the prior value. The
    /// consume is before arity/hash validation (L2432-2434 states this for the
    /// server, and the receive rule is direction-symmetric), so a later-rejected
    /// card still clears the marker and never binds a subsequent card.
    fn takePrivateMarker(self: *Client) bool {
        const was_pending = self.private_pending;
        self.private_pending = false;
        std.debug.assert(!self.private_pending); // negative space: always clear after
        return was_pending;
    }

    /// §7.4/§3.3 — receive one delta-form file/cfile payload. `delta_bytes` is
    /// the [FossilDelta] reconstructing `hash` from `deltasrc` (already §4.1
    /// container-decoded for cfile). If the basis is held, reconstruct now,
    /// verify §7.7 pre-storage integrity for a non-clone file (the reconstruction
    /// MUST hash to `hash`), store it, and run the §7.4 completion cascade to a
    /// fixpoint. If the basis is absent, retain the dangling delta, create the
    /// DELTASRC phantom (§3.3), and drop `hash` from the phantom set so it is not
    /// re-gimme'd — §3.3 content-received, derived via `pending.hasTarget`. MUST
    /// NOT reject a dangling delta (§7.4).
    /// §3.3 r24 / §8.5 L1689-1691 — fold a received cluster's membership into
    /// the phantom set. A cluster (§4.5.2: M cards, closed by its Z checksum)
    /// is the bulk form of `igot`: once a server folds an artifact into a
    /// cluster it stops announcing that artifact individually, so the M cards
    /// ARE the announcement. Without this the §3.7(a) continuation reads an
    /// empty phantom table after the cluster lands and terminates a pull that
    /// fetched nothing — silently, because every card was well-formed.
    ///
    /// A clone is exempt for the same reason it announces no igot (§8.2
    /// L1417-1427): it converges on the RID-cursor cfile sequence, never on
    /// gimme, so cluster-derived phantoms would only bloat its requests.
    ///
    /// Content that is not a cluster leaves the client untouched. Membership is
    /// validated in full before any of it is applied, so a malformed artifact
    /// that merely opens with an M card mints no phantom.
    /// Seed the §3.3 phantom set with the promises held clusters
    /// made that this repository never collected (`clusterindex.Resolved.
    /// missing`). Fossil carries these as durable phantom rows; zit re-derives
    /// them at every sync open and plants them here, so an interrupted pull
    /// resumes from what its clusters already announced instead of waiting
    /// for a peer to re-announce every artifact. Same disposition gate as a
    /// received cluster: a clone (cfile cursor, never gimme) seeds nothing.
    pub fn seedClusterPhantoms(
        self: *Client,
        gpa: std.mem.Allocator,
        store: repostate.ArtifactStore,
        names: []const []const u8,
    ) error{OutOfMemory}!void {
        if (!self.clusterAnnouncementsApply()) return;
        for (names) |name| {
            const rid = try store.intern(name);
            if (igotDisposition(.client, self.operation, store.held(rid)) != .create_phantom) continue;
            const gop = try self.phantoms.getOrPut(gpa, rid);
            if (!gop.found_existing) gop.value_ptr.* = .{};
            try self.seeded.put(gpa, rid, {});
        }
    }

    fn clusterAnnouncementsApply(self: *const Client) bool {
        return self.operation != .clone and igotCanObtain(.client, self.operation);
    }

    fn absorbCluster(
        self: *Client,
        gpa: std.mem.Allocator,
        store: repostate.ArtifactStore,
        obs: *RoundObservation,
        content: []const u8,
    ) error{OutOfMemory}!void {
        if (!self.clusterAnnouncementsApply()) return;
        // §4.5.2 — cards are emitted in letter order, so a cluster opens with M
        // and nothing else does. Two cheap bytes reject every other artifact
        // type before a line is scanned.
        if (content.len < 2 or content[0] != 'M' or content[1] != ' ') return;

        // Pass 1: every line must be a well-formed M or Z card.
        var cursor: usize = 0;
        while (cursor < content.len) {
            const end = std.mem.indexOfScalarPos(u8, content, cursor, '\n') orelse return;
            const card = artifact.tokenizeCardLine(content[cursor .. end + 1]) orelse return;
            switch (card.letter) {
                'M' => if (artifact.parseMCard(card) == null) return,
                'Z' => {},
                else => return, // §4.5.2 — a cluster admits M and Z only
            }
            cursor = end + 1;
        }
        std.debug.assert(cursor == content.len);

        // Pass 2: each unheld member becomes a phantom, exactly as the `igot`
        // announcing it would have (§3.3 r25).
        cursor = 0;
        while (cursor < content.len) {
            const end = std.mem.indexOfScalarPos(u8, content, cursor, '\n').?;
            const card = artifact.tokenizeCardLine(content[cursor .. end + 1]).?;
            cursor = end + 1;
            if (card.letter != 'M') continue;
            const member = artifact.parseMCard(card).?.hash;
            const rid = try store.intern(member);
            if (igotDisposition(.client, self.operation, store.held(rid)) != .create_phantom) continue;
            const gop = try self.phantoms.getOrPut(gpa, rid);
            _ = self.seeded.remove(rid); // the peer named it: an expectation now
            if (!gop.found_existing) {
                gop.value_ptr.* = .{};
                obs.phantom_created = true;
            }
        }
    }

    /// draft-fossil-submanifests-00 §7.1 — "Storing a structural artifact that
    /// carries S cards whose referenced sub-manifests are absent MUST create a
    /// phantom for each missing reference, with the referencing artifact's
    /// privacy classification, exactly as an absent delta source creates a
    /// source phantom." This is that rule, at the one place a client stores a
    /// received artifact.
    ///
    /// Structurally the sibling of `absorbCluster`: same validate-then-apply
    /// discipline (an artifact that merely opens with an S card mints nothing
    /// unless its whole card sequence tokenizes), and the same phantom-set
    /// disposition through `igotDisposition`. It differs in one deliberate way:
    /// the STORE phantom is interned unconditionally, because §7.1's MUST is
    /// about repository state, while entry into `self.phantoms` — the §3.3
    /// gimme set — keeps the operation-dependent gate a clone needs (§8.2: a
    /// clone converges on the cfile cursor, never on gimme).
    ///
    /// `repostate.sBearingCandidate` is the fast reject: every artifact that
    /// predates this draft fails it before a line is scanned, so no existing
    /// repository pays for this. §7.1's "with the referencing artifact's
    /// privacy classification" is carried by `private`, exactly as the DELTASRC
    /// phantom carries it in `receiveDelta`.
    fn absorbSubManifestRefs(
        self: *Client,
        gpa: std.mem.Allocator,
        store: repostate.ArtifactStore,
        obs: *RoundObservation,
        /// §7.3/§9.4 — the wire name of the artifact whose S run this is: the
        /// DEPENDENT the gap record is keyed by, and the identity §9.4 requires
        /// be rediscoverable when the missing sub-manifest arrives. Null only
        /// where the caller has no name to give (no such caller exists today;
        /// the parameter is explicit so a future one cannot silently skip it).
        dependent: ?[]const u8,
        content: []const u8,
        private: bool,
    ) error{OutOfMemory}!void {
        // The card scan itself is `repostate.collectSubManifestRefs` — one
        // extractor shared with the persist commit hook, so §7.1's two storing
        // boundaries cannot drift apart. It returns false for a malformed card
        // run, having appended nothing: validate-then-apply, so a half-read
        // artifact never leaves a half-minted phantom set behind.
        var refs: std.ArrayListUnmanaged([]const u8) = .empty;
        defer refs.deinit(gpa);
        if (!try repostate.collectSubManifestRefs(gpa, content, &refs)) return;

        for (refs.items) |hash| {
            const rid = try store.intern(hash); // §7.1: the store phantom is unconditional
            // §7.3/§9.4 — an ABSENT reference defers this artifact's processing:
            // "record the gap keyed by dependent and missing hash, idempotently,
            // and retry on arrival." Recorded regardless of the gimme-set gate
            // below, because §7.3's record is about repository state, not about
            // which operation asks for content: a clone converges on the cfile
            // cursor and creates no gimme phantom, yet the check-in it just
            // stored is every bit as deferred.
            if (!store.held(rid)) {
                if (dependent) |dep| {
                    const td = try self.treeDeferrals(gpa, store);
                    try td.deferProcessing(dep, hash);
                }
            }
            if (igotDisposition(.client, self.operation, store.held(rid)) != .create_phantom) continue;
            const gop = try self.phantoms.getOrPut(gpa, rid);
            _ = self.seeded.remove(rid); // the peer named it: an expectation now
            if (!gop.found_existing) {
                gop.value_ptr.* = .{};
                obs.phantom_created = true;
            }
            if (private) gop.value_ptr.private = true;
        }
    }

    /// §7.3/§9.4 — open the S-reference deferral record on first use. Lazy so a
    /// session that never sees an S card allocates nothing.
    fn treeDeferrals(
        self: *Client,
        gpa: std.mem.Allocator,
        store: repostate.ArtifactStore,
    ) error{OutOfMemory}!*repostate.TreeDeferrals {
        if (self.tree_deferrals == null) self.tree_deferrals = repostate.TreeDeferrals.init(gpa, store);
        return &self.tree_deferrals.?;
    }

    /// §9.4 — the dephantomization path's traversal of the third edge kind.
    /// Called at every dephantomization point, beside `pending.completeFor`
    /// (which traverses the two older kinds), so "a cascade that walks only the
    /// two older edge kinds leaves such a check-in permanently unprocessed" is
    /// not what zit does.
    ///
    /// Traversing a recorded edge does two things: it retires the gap, so a
    /// dependent whose LAST gap closes leaves the deferred set (§5.2: a tree
    /// with any gap left is still not a file set), and it RE-RUNS §7.1 over the
    /// rediscovered dependent's own plaintext — the retry §7.3 asks for. The
    /// re-run is what makes the §7.1 "converges in at most d+1 pull rounds"
    /// claim hold for a dependent whose reference set could not be applied when
    /// it was stored.
    ///
    /// Total: `onArrival` removes the edge it reports, so each recorded gap
    /// drives exactly one retry and the loop cannot revisit one.
    fn noteSubManifestArrival(
        self: *Client,
        gpa: std.mem.Allocator,
        store: repostate.ArtifactStore,
        obs: *RoundObservation,
        arrived: []const u8,
    ) error{OutOfMemory}!void {
        if (self.tree_deferrals == null) return; // no S card seen this session: nothing recorded
        var retries: std.ArrayListUnmanaged(repostate.Rid) = .empty;
        defer retries.deinit(gpa);
        try self.tree_deferrals.?.onArrival(arrived, &retries);
        for (retries.items) |dep_rid| {
            // Owned copies: the retry re-enters `store.intern`, and only a
            // store's own contract says whether a borrowed name or plaintext
            // survives that. Two short-lived dupes per RECORDED GAP (not per
            // artifact) buy the traversal independence from that contract.
            const borrowed = store.content(dep_rid) orelse continue;
            const plain = try gpa.dupe(u8, borrowed);
            defer gpa.free(plain);
            const dep_name = try gpa.dupe(u8, store.hashOf(dep_rid));
            defer gpa.free(dep_name);
            // Privacy: `false` never CLEARS a phantom's private marker (the
            // setter is one-way), so a retry cannot downgrade what §7.5 set.
            try self.absorbSubManifestRefs(gpa, store, obs, dep_name, plain, false);
        }
    }

    /// §7.7 L1472-1479 — this client's admission context, re-read on every ask.
    /// `shun` is a vtable whose answer MAY change between rounds (§3.3
    /// L518-525), so this is deliberately a snapshot taken at the point of use
    /// and never a value cached at session init.
    fn targetAdmission(self: *const Client) TargetAdmission {
        return .{ .policy = self.hash_policy, .shun = self.shun, .clone = self.operation == .clone };
    }
    fn receiveDelta(
        self: *Client,
        gpa: std.mem.Allocator,
        store: repostate.ArtifactStore,
        obs: *RoundObservation,
        hash: []const u8,
        deltasrc: []const u8,
        delta_bytes: []const u8,
        was_private: bool,
        /// §7.2/§4.1 — the received §4.1 delta container to retain verbatim for
        /// a compact re-serve, or null when there is no container to preserve (a
        /// file card carries the raw [FossilDelta] uncompressed, §7.1). Retained
        /// by BOTH paths: the immediate-reconstruct path (basis held) and the
        /// dangling path (through the pending queue, persisting the compact
        /// §7.2 delta form on completion). A container-less file card completes
        /// to full form.
        wire_container: ?[]const u8,
    ) ConsumeError!void {
        // §7.7 L1472-1479 — the mutation-side guard. Both card arms ask before
        // calling in, so today this only ever re-confirms; it is here because
        // EVERY path below mutates the store and the arms are not the only way in
        // (a future card path, and the deferred queue, both land bytes under
        // `hash`). `from_cfile` is `wire_container != null`: only §7.2's delta
        // form carries a §4.1 container, and §7.1 passes null, so the §7.7 L1475
        // clone-`file` bypass and the L1440-1444 clone-`cfile` discard stay
        // distinguishable this far down. Discarding is a silent return — §7.7
        // asks for no error and no observation.
        // artifact-format §3.2 L347-350 — DELTASRC NAMES AN ARTIFACT, and this
        // receiver keys DURABLE STATE by it: the dangling arm below interns it as
        // a phantom and every later §3.3 scan asks the shun table and the pending
        // queue about that name, both of which are keyed by complete artifact
        // name. `parseFileCard`/`parseCfileCard` validate arity and the byte
        // counts but deliberately not the hash width (they take no hash-policy
        // dependency), so without this check the PEER chooses the key domain of
        // this client's own phantom set. `parseIgotCard`/`parseGimmeCard` already
        // reject exactly this shape as malformed; a delta card is simply the third
        // way a name enters the set, and it gets the same answer. HASH itself
        // needs no such gate — `admitInbound` answers an out-of-domain width
        // rather than asserting on it, and `store.put`/`intern` keep a name
        // opaque.
        if (artifact.nameAlgo(deltasrc.len) == null) return self.abort();
        const admission = self.targetAdmission();
        if (!admission.admits(hash, wire_container != null)) return;
        // §7.4 — a delta whose target is already locally verified is consumed
        // and ignored: MUST NOT downgrade the target or enqueue an edge. `held`
        // classifies without inflating a wire-backed entry (§3.3 r24).
        if (store.lookup(hash)) |rid| {
            if (store.held(rid)) return;
        }
        // §7.4 — basis held: reconstruct immediately against its content. §7.7
        // pre-storage integrity (the reconstructed bytes MUST hash to HASH for a
        // non-clone file; a clone file defers to the commit hook, §7.7 r6) is
        // enforced inside the shared `reconstructDeltaTarget` core. Negative
        // space: a malformed delta or a reconstruction that hashes to the wrong
        // name aborts, never landing bytes under a name they do not match.
        if (store.lookup(deltasrc)) |src_rid| {
            // §7.2/§7.4 clone fast path — the basis is held and this is a cfile
            // delta (a §4.1 container on the wire), so the delta is orderable now.
            // Store it WIRE-BACKED (verbatim container + basis name + reconstructed
            // USIZE), skipping reconstruction entirely: content() reconstructs
            // lazily and the clone never memoises the inflated target (peak stays
            // O(wire)). §7.7 verification is the persist commit hook (§7.7 r6), not
            // the transfer layer, for a clone. Pull/`file` cards keep the immediate
            // reconstruct-and-verify path below (small, and its pre-storage §7.7
            // check is preserved). A malformed delta whose USIZE header will not
            // parse aborts the round (§3.9) rather than storing an unusable frame.
            if (self.operation == .clone and wire_container != null and store.held(src_rid)) {
                const container = wire_container.?;
                const target_size = delta.targetSize(delta_bytes) catch return self.abort();
                const rid = try store.intern(hash);
                store.retain(rid, .{ .delta = .{ .src = deltasrc, .container = container, .target_size = target_size } }) catch |err| switch (err) {
                    error.OutOfMemory => return ConsumeError.OutOfMemory,
                };
                _ = self.phantoms.remove(rid);
                obs.versioned_content_delivered = true;
                obs.artifact_received_this_round = true;
                if (was_private) obs.private_content_delivered = true;
                // submanifests §9.4 — an arrival retires the gaps recorded
                // against it, on the clone path too.
                try self.noteSubManifestArrival(gpa, store, obs, store.hashOf(rid));
                // §7.4 cascade — storing HASH may itself be the basis a retained
                // delta named (a chain C<-A<-B); complete to a fixpoint.
                self.pending.completeFor(gpa, store, store.hashOf(rid), admission) catch |err| switch (err) {
                    error.OutOfMemory => return ConsumeError.OutOfMemory,
                    error.MalformedDelta, error.ChecksumMismatch => return self.abort(),
                };
                return;
            }
            if (store.content(src_rid)) |src_bytes| {
                const reconstructed = reconstructDeltaTarget(gpa, hash, src_bytes, delta_bytes, self.operation != .clone) catch |err| switch (err) {
                    error.OutOfMemory => return ConsumeError.OutOfMemory,
                    error.MalformedDelta, error.IntegrityMismatch => return self.abort(),
                };
                defer gpa.free(reconstructed);
                const rid = try store.put(hash, reconstructed);
                _ = self.phantoms.remove(rid);
                try self.absorbCluster(gpa, store, obs, reconstructed);
                // submanifests §7.1 — same rule on the reconstructed plaintext.
                try self.absorbSubManifestRefs(gpa, store, obs, store.hashOf(rid), reconstructed, was_private);
                try self.noteSubManifestArrival(gpa, store, obs, store.hashOf(rid)); // §9.4
                obs.versioned_content_delivered = true;
                obs.artifact_received_this_round = true;
                if (was_private) obs.private_content_delivered = true;
                // §7.2/§4.1 — the basis was held and HASH is now stored: retain
                // the received delta container verbatim keyed to this rid so a
                // re-serve retransmits the compact delta form byte-for-byte. The
                // reconstructed bytes above proved the delta applies, so its
                // header (delta §3.2) parses; a parse failure only skips the
                // advisory retention. cfile only — a file card has no container.
                if (wire_container) |container| retain_blk: {
                    const target_size = delta.targetSize(delta_bytes) catch break :retain_blk;
                    store.retain(rid, .{ .delta = .{ .src = deltasrc, .container = container, .target_size = target_size } }) catch |err| switch (err) {
                        error.OutOfMemory => return ConsumeError.OutOfMemory,
                    };
                }
                // §7.4 cascade — storing HASH may itself be the basis a retained
                // delta named (a chain C<-A<-B); complete to a fixpoint.
                self.pending.completeFor(gpa, store, store.hashOf(rid), admission) catch |err| switch (err) {
                    error.OutOfMemory => return ConsumeError.OutOfMemory,
                    error.MalformedDelta, error.ChecksumMismatch => return self.abort(),
                };
                return;
            }
        }
        // §7.4/§3.3 dangling delta — basis absent. Retain a private copy, delete
        // HASH's phantom, create the DELTASRC phantom, set content-received. A
        // cfile carries a §4.1 container we keep VERBATIM (so completion persists
        // the compact §7.2 delta form — the storage-efficiency win); a `file`
        // card has none, so only its raw [FossilDelta] is kept and it completes
        // to full form.
        const payload: PendingDelta.Payload = if (wire_container) |c| .{ .container = c } else .{ .raw = delta_bytes };
        self.pending.retain(gpa, hash, deltasrc, payload) catch |err| switch (err) {
            error.OutOfMemory => return ConsumeError.OutOfMemory,
            error.QueueFull => return self.abort(),
        };
        // §3.3 — delete HASH's phantom: content-received is now derived from the
        // retained delta (pending.hasTarget), so HASH is not requested again.
        if (store.lookup(hash)) |h_rid| _ = self.phantoms.remove(h_rid);
        // §3.3 item 2 / §7.5 L1407-1409 — create the DELTASRC phantom, private
        // iff the delta carried a private marker.
        const src_rid = try store.intern(deltasrc);
        const gop = try self.phantoms.getOrPut(gpa, src_rid);
        if (!gop.found_existing) gop.value_ptr.* = .{};
        if (was_private) gop.value_ptr.private = true;
        // §3.3/§3.7(a) — a dangling delta is a versioned-progress event that
        // creates a phantom; flag it so the round continues to gimme the basis.
        obs.phantom_created = true;
    }

    /// §5.1/§10.1 — a body that ends before a `file`, `cfile` or `uvfile`
    /// payload's declared count is a handler content error, not a fatal one.
    /// Nothing of the partial payload is stored; returning the body end stops
    /// the reply-card loop there, so the prefix already processed stands and
    /// `consumeReply` evaluates §3.7 continuation exactly as for a whole body.
    /// The private marker was already consumed at handler entry (§7.5).
    fn stopAtTruncatedPayload(self: *Client, body: []const u8) usize {
        std.debug.assert(self.state == .clone_round0 or self.state == .exchange);
        self.payloads_truncated += 1;
        return body.len;
    }

    /// §3.8 r15/r18 — record a fatal protocol error and return it. From
    /// clone_round0 the fatal event is `clone_fatal`; from exchange it is
    /// `fatal_error`; both land in `aborted`.
    fn abort(self: *Client) ConsumeError {
        std.debug.assert(self.state == .clone_round0 or self.state == .exchange);
        self.state = transition(self.state, if (self.state == .clone_round0) .clone_fatal else .fatal_error);
        std.debug.assert(self.state == .aborted);
        return ConsumeError.FatalProtocolError;
    }
};

/// §3.1 L262-264 — the stateless per-request server. The whole round loop
/// lives on the `Client`; the server carries only its own static identity and
/// answers each request purely from the durable `ArtifactStore` it is given.
/// `respond` takes `self: *const Server`, so the compiler proves no field is
/// written across requests: every working table is a call-local freed before
/// return, and the only mutation is into the caller's store (§3.3 ingest),
/// exactly as `consumeReply` mutates it on the client side.
pub const Server = struct {
    /// §9.1 L1898-1909 — the server release/timestamp emitted as
    /// `pragma server-version` when the client's client-version carried a
    /// DATE and TIME. Always carries a datetime: a real server knows its own
    /// build timestamp, so the server-version form is always the full one.
    version: VersionInfo,
    /// §6.4 L1139-1157 — the stored 40-hex shared secret used to VERIFY a
    /// named login, never a plaintext password and never emitted. Null means
    /// the server accepts unauthenticated push/pull (§A.1 note 1): no login
    /// verification is performed.
    credential: ?Credential = null,
    /// §8.2 L1573-1575/§8.5 L1687-1689 — the repository identifiers emitted verbatim in a
    /// clone reply's `push SERVERCODE PROJECTCODE` card. SERVERCODE is
    /// informational; PROJECTCODE is how a fresh clone client first learns the
    /// peer's project code (§8.3/§6.3). Both MUST be non-empty and free of
    /// Fossil whitespace so the card carries three tokens on the wire — a
    /// server-config precondition `emitCloneReply` asserts; the default "" is a
    /// non-clone-serving server that never reaches the clone path.
    self_servercode: []const u8 = "",
    project_code: []const u8 = "",
    /// §8.2 L1573-1574 — the per-reply upper bound on `cfile` cards a clone batch
    /// emits. Defaults to `clone_cfile_batch_max`; a smaller value pages a
    /// repository across more batches, exercising the multi-batch clone
    /// continuation (§8.2 L1593-1595). A server tuning knob, never wire-visible: the
    /// client resumes each batch from the `clone_seqno NEXT` cursor regardless
    /// of size (§8.2 L1552-1553 — clients MUST NOT infer count or size from range).
    clone_batch_max: usize = clone_cfile_batch_max,
    /// Sparse-cone RFC section 6.2 -- the embedder's cone classifier; `.none`
    /// serves every cone request densely (section 6.3 degradation, from the
    /// server's own side).
    cone_classifier: ConeClassifier = .none,
    /// This server's own content policy for pushes. `.none` accepts
    /// everything, which is what every server that declared nothing does.
    push_policy: PushPolicy = .none,
    /// The branch resolver `push_policy.protect_globs` needs. Null
    /// on a server that protects nothing; a server given globs and no
    /// resolver refuses every protected-branch decision (see `pushProtectVerdict`).
    branch_of: ?BranchResolver = null,
    /// The front's divert directive for this request, or null when it sent
    /// none: a push that would join a protected branch is then simply
    /// refused. See `pushProtectVerdict`.
    divert: ?Divert = null,
    /// Rids some held cluster already names, in this store's rid
    /// space. A reply announces held ∧ ¬clustered (fossil's
    /// `send_unclustered`): the clusters carry the rest. Null → unbounded,
    /// the pre-index behavior, which costs bytes and never correctness.
    clustered: ?*const std.DynamicBitSetUnmanaged = null,
    /// Every held cluster's name, for `pragma req-clusters`.
    cluster_names: []const []const u8 = &.{},
    /// Sparse-cone RFC section 6.2/5.3 -- this store's OWN cone when it is
    /// sparse; null for a dense store. Arms the section 5.3 clone-origin
    /// refusal: a clone request whose cone this store does not wholly cover
    /// (or a dense clone request) is answered with one error card.
    origin_cone: ?*const cone_mod.Cone = null,
    /// The per-reply clone byte budget (see `clone_cfile_batch_bytes`). A
    /// server tuning knob, never wire-visible; tests shrink it to page a
    /// repository across byte-bound batches.
    clone_batch_bytes: usize = clone_cfile_batch_bytes,
    /// submanifests §8.1 — this repository's `enable-submanifests` setting: "a
    /// project-scope boolean, default false". It is the ONE input that arms the
    /// §7.2 peer-version gate on the send path and raises the version this
    /// server announces (`announcedVersion`).
    ///
    /// Off is not "gate disabled by accident": off means this repository does
    /// not participate in sub-manifests at all, which is the state §8.2 requires
    /// of every repository that is not a disposable test fixture, and the state
    /// in which zit's send path is byte-identical to what it was before this
    /// draft. On, the sender pays a plaintext scan per emitted artifact and
    /// refuses every S-bearing one to a peer below the threshold. An operator
    /// turning it on is shown `submanifest_enable_warning` first.
    /// §9.3 r28 — this repository's shun table, answered when a request selects
    /// the `/shun` group (fossil's `configure_send_group`). Carried as a plain
    /// field, like every other repository-scope fact this struct holds, rather
    /// than reached through `ArtifactStore`: the store vtable is the ARTIFACT
    /// interface, and shun rows are a table about artifacts, not artifacts.
    ///
    /// Empty by default, which is also the answer for a repository that shuns
    /// nothing — so a server that never populates it behaves exactly as it did
    /// before the group existed.
    shun_rows: []const ShunRow = &.{},
    submanifests_enabled: bool = submanifest_emission_default,
    /// submanifests §8.1 — this repository's durable seen-submanifest indicator
    /// ("When the first S-bearing artifact is stored, an implementation SHOULD
    /// record a local seen-submanifest indicator"), read from the repository
    /// config. The SECOND thing that arms the §7.2 gate, and the one that makes
    /// the gate honest: §8.1 says disabling the setting "cannot retire existing
    /// ones", so a repository can hold trees with the setting off — which is the
    /// state of any zit whose operator ran `zit submanifest disable` after
    /// committing, and of every zit that received a tree by sync without ever
    /// enabling emission. Arming on the setting alone would serve those trees
    /// ungated to a fossil peer, collapsing §8's transfer layer into its
    /// emission layer.
    ///
    /// Arming on this indicator costs nothing anywhere it matters: it is false
    /// for every repository predating this draft, and the persist boundary sets
    /// it for BOTH §7.2 shapes, so "indicator false" implies "holds no gated
    /// artifact" and the gate it skips would have decided `.emit` for every byte
    /// it looked at.
    seen_submanifest: bool = false,
    /// submanifests §6.3 — this repository's pinned `submanifest-k` (0 =
    /// unpinned), served over §9.3's scalar channel so a clone partitions
    /// identically. Read-only here: the transfer layer never consults k, only
    /// the §9.3 responder reports it.
    submanifest_k: u8 = 0,
    /// submanifests §6.4 — this repository's `submanifest-auto-retune`
    /// setting, served for the same project-scope reason and consulted by
    /// nothing else on this side.
    submanifest_auto_retune: bool = false,
    /// §3.3 L553-556 / §8.8 L1839-1841 — this repository's shun table, asked by
    /// the reply's phantom scan. §3.3 states the filter on "the ordinary phantom
    /// scan" without qualifying a side, and a server under push-receive IS a
    /// receiver (§3.3 r24(1) L537-540): it interns a client's `igot` for content
    /// it lacks and would otherwise `gimme` it, storing what the operator shunned.
    /// Borrowed for the reply's life and never written; `.none` leaves a server
    /// that names no set behaving exactly as it did before the filter existed.
    shun: ShunSet = .none,
    /// §7.7 L1463-1467 — this repository's effective hash-policy value, the
    /// SHA1-name half of `admitInbound`'s combined target predicate. Same
    /// per-session, read-per-card shape and same `.auto` default as
    /// `Client.hash_policy`; see there.
    hash_policy: artifact.HashPolicy = .auto,
    /// §3.2/§6.4 — the DATETIME of the reply's closing `# timestamp DATETIME
    /// errors N` comment. This layer is sans-IO and has no clock, so the
    /// embedder supplies the round's time; null omits the comment.
    reply_time: ?[]const u8 = null,

    /// §3.8 — the closed error set `respond` returns. `OutOfMemory` covers the
    /// growable reply buffer and the store's ingest; `ResponseTooLarge` fences
    /// a reply that would exceed zit's local cap (§4 L830-833 sets none). An
    /// authentication failure is deliberately NOT here: it is an in-band
    /// `error` card reply (§5.2/§6.4), not a returned Zig error.
    pub const RespondError = error{ OutOfMemory, ResponseTooLarge };

    /// §4.1 — a reply body plus the media type that governs its framing. The
    /// body aliases the caller's `out.items`; `media` is `x_fossil_uncompressed`
    /// for a §8.1 accepting-clone reply (cfile payloads are already stored
    /// compressed, so no outer container is applied) and `x_fossil` for every
    /// other reply (the compressed container). The transport reads `media` to
    /// frame the body and pick the Content-Type in one decision, so the two can
    /// never disagree.
    pub const Reply = struct {
        body: []const u8,
        media: MediaType,
        /// §9.3 r31/r33 — the sub-manifest settings an AUTHORIZED pushed
        /// config card asked this repository to adopt; all-null on every other
        /// reply, including a rejected push. Reported rather than applied
        /// because applying is I/O and this layer is sans-IO: the CLI seam that
        /// owns the served repository's config performs the write.
        config_apply: SubmanifestConfig = .{},
    };

    /// §3.2/§3.8 — answer one request body from the durable store, returning
    /// just the reply bytes. A thin convenience over `respondWithMedia` for the
    /// callers (the round loop, the sweep, most tests) that reframe a normal
    /// `application/x-fossil` reply and never serve the §8.1 clone media type.
    pub fn respond(
        self: *const Server,
        gpa: std.mem.Allocator,
        store: repostate.ArtifactStore,
        request: []const u8,
        out: *std.ArrayListUnmanaged(u8),
    ) RespondError![]const u8 {
        return (try self.respondWithMedia(gpa, store, request, null, out)).body;
    }

    /// `respond` for a transport that carried an `x-f-l-c` cookie header —
    /// the §6.5 credential channel a ≥22700 client uses on every round.
    pub fn respondCookie(
        self: *const Server,
        gpa: std.mem.Allocator,
        store: repostate.ArtifactStore,
        request: []const u8,
        cookie: ?[]const u8,
        out: *std.ArrayListUnmanaged(u8),
    ) RespondError![]const u8 {
        return (try self.respondWithMedia(gpa, store, request, cookie, out)).body;
    }

    /// §3.2/§3.8 — answer one request body from the durable store, the reply
    /// half of the exchange. Sans-IO: bytes in / bytes out, no transport.
    /// Parses the request cards (§5), verifies a named login (§6.4), ingests
    /// pushed content and interns announced-but-unheld hashes as phantoms
    /// (§3.3), then emits the version pragma (§10.1), a file card for each
    /// satisfiable gimme (§3.3), and — walking the store once — an igot for
    /// every holding the client neither announced nor requested and a gimme
    /// for every phantom (§3.2). A malformed card resets the partial output
    /// and emits a single error card (§5.2). `out` is a reused backing buffer,
    /// cleared here; the returned `body` is `out.items`. `media` reports whether
    /// the reply is a §8.1 clone reply (`x_fossil_uncompressed`) or an ordinary
    /// compressed reply (`x_fossil`).
    pub fn respondWithMedia(
        self: *const Server,
        gpa: std.mem.Allocator,
        store: repostate.ArtifactStore,
        request: []const u8,
        cookie: ?[]const u8,
        out: *std.ArrayListUnmanaged(u8),
    ) RespondError!Reply {
        out.clearRetainingCapacity();
        const line_buf = try gpa.alloc(u8, max_line_len);
        defer gpa.free(line_buf);

        var scan: RequestScan = .{ .cookie_login = cookie != null };
        defer scan.deinit(gpa);

        // §5.2 — a malformed card, or (§6.5) a cookie that does not verify,
        // produces a single error card over the (still empty) output. Parse
        // fully before touching the store, so a rejected request ingests
        // nothing. An error reply carries no cfiles, so it is the ordinary
        // compressed media type.
        if (try scanRequest(gpa, self, store, request, &scan)) |fault|
            return .{ .body = try emitError(gpa, out, line_buf, fault), .media = .x_fossil };
        if (authFault(self.credential, request, cookie)) |fault|
            return .{ .body = try emitError(gpa, out, line_buf, fault), .media = .x_fossil };
        // Fossil's `isPush`: only an accepted push card may store anything.
        // Content without one is refused whole, before any byte is stored,
        // and announcements without one stay unfetched. Fossil's refusal
        // resets the reply and leaves the card loop, then still answers a
        // pull with its inventory.
        const writes = scan.push_accepted;
        if (scan.refused_content) {
            _ = try emitError(gpa, out, line_buf, .write_unauthorized);
            try self.emitInventory(gpa, store, &scan, out, line_buf, .{ .igot = scan.pull, .gimme = false });
            if (out.items.len > reply_body_cap) return error.ResponseTooLarge;
            return .{ .body = out.items, .media = .x_fossil };
        }
        // §6.4 erratum — a failed login card ended the scan. The reply is the
        // error, then the end-of-request steps for whatever direction the
        // cards before it established, then the timestamp comment.
        if (scan.login_fault) |fault| {
            try self.emitLoginFailure(gpa, store, &scan, fault, out, line_buf);
            if (out.items.len > reply_body_cap) return error.ResponseTooLarge;
            return .{ .body = out.items, .media = .x_fossil };
        }

        // §9.3 r31 L1976-1979 — a pushed config card is Admin-gated, and the
        // gate is decided BEFORE the clone branch and before ingest so an
        // unauthorized push changes nothing at all: the rejection is the whole
        // reply (reset-and-emit, §5.2 L1002-1014), not an error card appended
        // beside a served round. Authorized rows are reported to the caller,
        // never applied here — this layer performs no I/O.
        var config_apply: SubmanifestConfig = .{};
        if (scan.config_push) |payload| {
            switch (serverConfigPushDecision(scan.config_push_admin)) {
                .reject => return .{
                    .body = try emitError(gpa, out, line_buf, .config_push_unauthorized),
                    .media = .x_fossil,
                },
                .apply => config_apply = parseSubmanifestConfigPayload(payload),
            }
        }

        // §8 — a clone request is answered by its own paginated cfile reply and
        // never touches the durable store: clone is receive-only (the client
        // sends no push/file cards), and §8.9's stateless server holds no clone
        // state, so ingest and the pull/push walk are both skipped.
        if (scan.clone_start) |start| {
            // Sparse-cone RFC section 5.3 -- "a server SHOULD refuse to act
            // as a clone origin for cones it does not cover". Checked before
            // any reply byte: refusal is one error card, never a partial
            // clone that heals into silent incompleteness elsewhere.
            if (self.origin_cone) |origin| {
                if (!cloneConeAdmissible(origin, scan.cone_members.items)) {
                    return .{ .body = try emitError(gpa, out, line_buf, .sparse_origin), .media = .x_fossil };
                }
            }
            try self.emitCloneReply(gpa, store, &scan, out, line_buf, start);
            if (out.items.len > reply_body_cap) return error.ResponseTooLarge;
            // §8.1 L1529-1530 — a version-3 clone reply is served as
            // application/x-fossil-uncompressed; §4.1 L885-887 — no outer
            // container, because the cfile payloads are already stored compressed.
            return .{ .body = out.items, .media = .x_fossil_uncompressed, .config_apply = config_apply };
        }

        // Decided BEFORE ingest, so a refused push stores nothing. A push
        // is a batch, and accepting the conforming half would leave the client's
        // DAG naming artifacts this server declined — phantoms manufactured by
        // policy. The whole round is refused instead.
        var policy_buf: [peer_error_cap]u8 = undefined;
        if (try pushPolicyFault(gpa, self.push_policy, &scan, &policy_buf)) |text| {
            return .{ .body = try emitError(gpa, out, line_buf, .{ .content_policy = text }), .media = .x_fossil };
        }
        // Same shape, same batch rule, same card: a membership
        // change to a protected branch refuses the whole round, unless the
        // front allows a divert and the pusher was told; then the round lands
        // with a move that keeps it off the protected branch.
        var divert: ?DivertPlan = switch (try pushProtectVerdict(gpa, self, &scan, &policy_buf)) {
            .admit => null,
            .refuse => |text| return .{ .body = try emitError(gpa, out, line_buf, .{ .content_policy = text }), .media = .x_fossil },
            .divert => |plan| plan,
        };
        defer if (divert) |*plan| plan.deinit();

        try ingest(self, gpa, store, &scan, writes);
        // The move lands in the same store as the round, so it persists in
        // the same commit: no reader sees the round without it.
        if (divert) |plan| {
            _ = try store.put(plan.name, plan.bytes);
            for (plan.moves) |mv| try self.divert.?.ledger.diverted(mv);
        }
        // fossil's `create_cluster` point: on a pull, once enough held
        // artifacts are unclustered, fold them into cluster artifacts. They land
        // in the round's store like any other write, persist with it, and the
        // index extends by them. THIS reply still announces their members
        // individually (its index is the snapshot's); the next announces only
        // the clusters — one round of at most ~900 igot, then bounded. Minting
        // needs the index: without one, nothing is known to be clustered.
        if (scan.pull) if (self.clustered) |c| try mintClusters(gpa, store, c);
        // §6.6 erratum — Fossil checks Write at the push card. After an
        // accepted pull the refusal is the nonfatal pull-only message;
        // otherwise it is the error, which resets the reply. Neither ends
        // card processing, so a pull is served either way.
        const push_refusal: PushRefusal = if (!scan.push_refused)
            .none
        else if (scan.pull_before_push)
            .pull_only
        else
            .not_authorized;
        try self.emitReply(gpa, store, &scan, push_refusal, out, line_buf);
        if (divert) |plan| try emitDivert(gpa, out, line_buf, plan);

        if (out.items.len > reply_body_cap) return error.ResponseTooLarge;
        return .{ .body = out.items, .media = .x_fossil, .config_apply = config_apply };
    }

    /// submanifests §7.2 — the release integer this server announces, raised to
    /// the gate threshold because zit reads sub-manifests. The §7.2 gate reads
    /// the PEER's announcement, so a server that will serve trees must announce
    /// that it understands them.
    fn announcedVersionInfo(self: *const Server) VersionInfo {
        return .{
            .version = announcedVersion(self.version.version, submanifest_reader_support),
            .datetime = self.version.datetime,
        };
    }

    /// submanifests §7.2/§8.1 — is the send gate armed for this repository?
    ///
    /// Either the operator opted in (§8.1's setting) or the repository has
    /// already stored a §7.2-gated artifact (§8.1's seen-submanifest indicator).
    /// The disjunction is what keeps the gate from being a function of the
    /// EMISSION policy alone — §8.1's own "Disabling the setting ... cannot
    /// retire existing ones" is the state the second disjunct covers.
    fn submanifestGateArmed(self: *const Server) bool {
        return self.submanifests_enabled or self.seen_submanifest;
    }

    /// submanifests §7.2 — does this server advertise `avoid-submanifests`?
    /// "A server whose enable-submanifests setting (Section 8.1) is false sends
    /// the pragma avoid-submanifests, in the same reply positions as
    /// avoid-delta-manifests."
    ///
    /// The condition is the SETTING alone, not `submanifestGateArmed`, and this
    /// predicate exists so that difference is a name rather than a comment above
    /// an inline `if`. It sits beside `submanifestGateArmed` on purpose: two
    /// visibly separate questions, each asked once by the draft. The pragma
    /// tells a client "do not put S cards into commits you make against me",
    /// which a repository that already holds trees but has emission off still
    /// means — §8.1's "Disabling the setting stops the emission of new tree
    /// manifests; it cannot retire existing ones". Reading the armed disjunction
    /// here would silently stop advertising the refusal for exactly those
    /// repositories.
    ///
    /// Do not unify the two. The single state where they disagree — setting
    /// off, seen-submanifest on — is pinned in both directions by the test
    /// "the emitted pragma keys on the setting alone, not on the armed
    /// disjunction".
    fn advertisesAvoidSubmanifests(self: *const Server) bool {
        return !self.submanifests_enabled;
    }

    /// submanifests §7.2 — emit the pragma whenever
    /// `advertisesAvoidSubmanifests` says this server advertises it.
    ///
    /// Position: [FossilSync] fixes no ordinal for avoid-delta-manifests beyond
    /// "the reply" (§B.7 gives only its production; §9 the receipt semantics),
    /// and §8.2 L1575-1576 fixes only that server-version precedes the clone
    /// sequence. So both replies emit it in the leading pragma run, immediately
    /// after server-version where that is emitted — one position, stated once,
    /// for the normal and the clone reply alike.
    ///
    /// zit's own default is false, so this pragma is on every reply a
    /// stock-configured zit server sends. That is what §7.2 specifies, and it is
    /// safe against a pre-S peer by [FossilSync] §3.5's tolerate-and-ignore rule
    /// for unrecognised cards — the `zig build smoke` battery against fossil
    /// 2.28 is the evidence, not the assumption.
    fn appendAvoidSubmanifests(
        self: *const Server,
        gpa: std.mem.Allocator,
        out: *std.ArrayListUnmanaged(u8),
        line_buf: []u8,
    ) RespondError!void {
        if (!self.advertisesAvoidSubmanifests()) return;
        const p = writeFlagPragma(.@"avoid-submanifests", line_buf) catch return error.ResponseTooLarge;
        try appendCard(gpa, out, p);
    }

    /// §9.3/§9.3 r21-r26 — answer a `reqconfig /config` with this
    /// repository's three project-scope sub-manifest settings.
    ///
    /// Read permission is the SOLE gate for reading (r21); Admin is required
    /// only for the push-and-apply direction (r31). zit's server performs no
    /// per-user capability lookup and grants Read to every requester (§A.1
    /// note 1), so the disposition is stated through `serverReqconfigDisposition`
    /// rather than assumed — the one place a future capability model has to
    /// change.
    ///
    /// Emitted only when the request actually named the scalar group, so a peer
    /// that never asks — every peer predating this feature — gets a reply
    /// byte-for-byte identical to what it got before.
    fn appendScalarConfig(
        self: *const Server,
        gpa: std.mem.Allocator,
        scan: *const RequestScan,
        out: *std.ArrayListUnmanaged(u8),
        line_buf: []u8,
    ) RespondError!void {
        if (!scan.reqconfig_scalar) return;
        if (serverReqconfigDisposition(server_grants_read) == .ignore) return;
        var payload_buf: [submanifest_config_payload_max]u8 = undefined;
        const payload = writeSubmanifestConfigPayload(
            self.submanifests_enabled,
            self.submanifest_k,
            self.submanifest_auto_retune,
            &payload_buf,
        ) catch return error.ResponseTooLarge;
        const card = writeConfigCard(config_scalar_marker, @intCast(payload.len), line_buf) catch
            return error.ResponseTooLarge;
        try appendCard(gpa, out, card);
        // §9.3 r23/r29 — SIZE content bytes then the one mandatory trailing LF,
        // which `appendCard` writes for us.
        try appendCard(gpa, out, payload);
    }

    /// §9.3 r28 — answer the TABLE groups a request selected. Today that is
    /// `/shun`, the group whose absence is a SAFETY gap rather than a cosmetic
    /// one: fossil's shun table is its takedown mechanism, so a peer that
    /// clones a repository without it will, on its next sync with any third
    /// party, re-accept exactly the content an operator was ordered to remove.
    ///
    /// One card per row, fossil's `configure_send_group` shape, because
    /// `configure_receive` reads the first token pair of a payload as
    /// `(mtime, primary-key)` and every later pair as `(field, value)`.
    ///
    /// Read permission gates this exactly as it gates the scalar group (r21);
    /// a request that selected nothing emits nothing and no error, which is
    /// what every request did before this group existed.
    fn appendGroupConfig(
        self: *const Server,
        gpa: std.mem.Allocator,
        scan: *const RequestScan,
        out: *std.ArrayListUnmanaged(u8),
        line_buf: []u8,
    ) RespondError!void {
        if (scan.reqconfig_groups.count() == 0) return;
        if (serverReqconfigDisposition(server_grants_read) == .ignore) return;
        if (!scan.reqconfig_groups.contains(.shun)) return;
        var payload_buf: [shun_config_payload_max]u8 = undefined;
        for (self.shun_rows) |row| {
            // A row this server cannot spell on the wire is SKIPPED, never
            // truncated: a half-written name would name a different artifact,
            // and un-shunning by typo is the one outcome worse than not
            // carrying the row at all. Storage bounds both fields, so this is
            // unreachable in practice and stated rather than asserted.
            if (row.hash.len > 64 or row.reason.len > shun_reason_max) continue;
            const payload = writeShunConfigPayload(row, &payload_buf) catch
                return error.ResponseTooLarge;
            const card = writeConfigCard(config_shun_group, @intCast(payload.len), line_buf) catch
                return error.ResponseTooLarge;
            try appendCard(gpa, out, card);
            try appendCard(gpa, out, payload);
        }
    }

    /// submanifests §7.2 — the send decision for one outbound artifact, given
    /// its plaintext.
    ///
    /// Two guards, in cost order. §8.1's arming indicator comes first and is the
    /// reason an existing repository pays nothing: with neither the setting nor
    /// the seen-submanifest indicator set this returns `.emit` without touching
    /// `bytes`, so the send path is byte-for-byte and instruction-for-instruction
    /// what it was before this draft. Only a repository that opted in or already
    /// holds a gated artifact pays the card scan.
    ///
    /// The peer's version is its announced `pragma client-version` (§10.1); a
    /// peer that announced none is `Peer.serverInit()`'s 0, which is below every
    /// threshold, so an unannounced peer is treated as pre-S. That is the safe
    /// direction and matches §10.1 r10's own pre-pragma default.
    fn submanifestSendGate(
        self: *const Server,
        scan: *const RequestScan,
        bytes: []const u8,
    ) GateDecision {
        if (!self.submanifestGateArmed()) return .emit;
        // The same two-shape disjunction the client half reads: §7.2 gates a
        // SUBMANIFEST artifact by TYPE as well as an S-bearing manifest, and a
        // leaf sub-manifest is only ever the former.
        return submanifestPeerVersionGate(peerVersionOf(scan), repostate.transferGatedArtifact(bytes));
    }

    /// §6.6 erratum — how this reply refuses the request's push card.
    const PushRefusal = enum { none, pull_only, not_authorized };

    /// §6.4 erratum — the reply to a request whose login card failed. The
    /// error replaces anything staged (`cgi_reset_content`), and the request
    /// is not rolled back: an accepted push still interns its igots and
    /// requests the server's phantoms, and a pull still gets its inventory,
    /// in Fossil's end-of-request order. Content pushed before the failed
    /// login is not stored, so no push policy is bypassed by a bad login.
    /// The count in the timestamp comment includes a refused push's error,
    /// which the login failure's reset discarded from the reply.
    fn emitLoginFailure(
        self: *const Server,
        gpa: std.mem.Allocator,
        store: repostate.ArtifactStore,
        scan: *const RequestScan,
        fault: RespondFault,
        out: *std.ArrayListUnmanaged(u8),
        line_buf: []u8,
    ) RespondError!void {
        std.debug.assert(scan.login_fault != null);
        _ = try emitError(gpa, out, line_buf, fault);
        var errors: u64 = 1;
        if (scan.push_refused and !scan.pull_before_push) errors += 1;
        for (scan.offers.items) |hash| _ = try store.intern(hash);
        try self.emitInventory(gpa, store, scan, out, line_buf, .{ .igot = false, .gimme = scan.push_accepted });
        try self.emitInventory(gpa, store, scan, out, line_buf, .{ .igot = scan.pull, .gimme = false });
        // Only this reply ends with the timestamp comment. Every completed
        // Fossil reply does, but the other reply paths do not track their
        // error count, and a comment with a wrong N would be worse than none.
        if (self.reply_time) |when| {
            const card = std.fmt.bufPrint(line_buf, "# timestamp {s} errors {d}", .{ when, errors }) catch
                return error.ResponseTooLarge;
            try appendCard(gpa, out, card);
        }
    }

    /// §10.1/§3.2/§3.3 — emit the reply cards over the ingested store: the
    /// server-version pragma, a file card per satisfiable gimme, then one store
    /// walk announcing holdings the client lacks and requesting the server's
    /// phantoms.
    fn emitReply(
        self: *const Server,
        gpa: std.mem.Allocator,
        store: repostate.ArtifactStore,
        scan: *const RequestScan,
        push_refusal: PushRefusal,
        out: *std.ArrayListUnmanaged(u8),
        line_buf: []u8,
    ) RespondError!void {
        std.debug.assert((push_refusal == .none) == !scan.push_refused);
        // §6.6 erratum — the error form resets the reply, so nothing precedes
        // it. A `pragma server-version` still follows it here where Fossil's
        // reset would have dropped one sent before the push card; that is
        // unobservable, because a Fossil client stops at the first error.
        if (push_refusal == .not_authorized) {
            try appendCard(gpa, out, writeSignalCard(.err, write_unauthorized_text, line_buf));
        }
        // §9.1 L1898-1911 — reply with server-version exactly when the
        // client-version carried DATE and TIME.
        if (scan.client_version) |cv| {
            if (serverEmitsServerVersion(cv)) {
                std.debug.assert(self.version.datetime != null); // server config precondition
                const p = writeServerVersion(self.announcedVersionInfo(), line_buf) catch return error.ResponseTooLarge;
                try appendCard(gpa, out, p);
            }
        }
        if (push_refusal == .pull_only) {
            try appendCard(gpa, out, writeSignalCard(.message, push_refused_pull_only_text, line_buf));
        }
        try self.appendAvoidSubmanifests(gpa, out, line_buf);
        try self.appendScalarConfig(gpa, scan, out, line_buf);
        try self.appendGroupConfig(gpa, scan, out, line_buf);

        // §3.3 L569-570 — satisfy each gimme the server holds with a file card,
        // in request order; a hash it lacks is silently skipped (§3.3 L572).
        //
        // submanifests §7.2 — the send gate lives here, on the ONE path that
        // already holds the artifact's plaintext ("uploading composes from
        // plaintext"), so an armed gate costs one card-run walk per emitted
        // artifact and never a second inflate. Refusing per artifact
        // rather than per reply is what §7.2 asks for: the reply still carries
        // every artifact the peer CAN read, and the error card says exactly what
        // was withheld.
        var s_refused = false;
        for (scan.gimmes.items) |hash| {
            if (store.lookup(hash)) |rid| {
                if (store.content(rid)) |bytes| {
                    if (self.submanifestSendGate(scan, bytes) == .refuse) {
                        s_refused = true;
                        continue; // §7.2: refused, never converted or downgraded
                    }
                    writeFile(gpa, out, store.hashOf(rid), bytes) catch |err| switch (err) {
                        error.OutOfMemory => return error.OutOfMemory,
                        error.NoSpaceLeft => return error.ResponseTooLarge,
                    };
                }
            }
        }
        // §7.2 "with an explicit error" — one error card for the round, at
        // §11.1 r8's APPEND disposition (`serverErrorReset(.peer_version_gate)`
        // is `.append`), which is the disposition the parallel SHA3-256 gate of
        // [FossilSync] §7.6 carries and the reason the reply's other artifacts
        // survive alongside it.
        if (s_refused) {
            std.debug.assert(serverErrorReset(.peer_version_gate) == .append);
            const card = writeSignalCard(.err, submanifest_peer_version_error_text, line_buf);
            try appendCard(gpa, out, card);
        }

        // §5.3 L1073-1076 — `pragma req-clusters`: the cluster layer
        // on request, enclosed. Receivers ignore the brackets; neither is
        // emitted for an empty run.
        if (scan.req_clusters and self.cluster_names.len != 0) {
            try appendCard(gpa, out, "# sending-clusters");
            for (self.cluster_names) |name| {
                const card = writeIgot(name, false, line_buf) catch return error.ResponseTooLarge;
                try appendCard(gpa, out, card);
            }
            try appendCard(gpa, out, "# end-of-clusters");
        }

        // A refused push enables nothing: Fossil requests its phantoms only
        // for an accepted push and announces its holdings only for a pull.
        const parts: InventoryParts = if (push_refusal == .none)
            .{ .igot = true, .gimme = true }
        else
            .{ .igot = scan.pull, .gimme = scan.push_accepted };
        try self.emitInventory(gpa, store, scan, out, line_buf, parts);
    }

    /// Which halves of the §3.2 store walk a reply carries.
    const InventoryParts = struct { igot: bool, gimme: bool };

    /// §3.2 L406-410 — the reply's store walk: an igot for each holding the
    /// client neither announced nor requested, and a gimme for each phantom,
    /// limited to the halves `parts` selects.
    fn emitInventory(
        self: *const Server,
        gpa: std.mem.Allocator,
        store: repostate.ArtifactStore,
        scan: *const RequestScan,
        out: *std.ArrayListUnmanaged(u8),
        line_buf: []u8,
        parts: InventoryParts,
    ) RespondError!void {
        if (!parts.igot and !parts.gimme) return;
        // The names the requester announced or asked for, as ONE set
        // built once per reply. The walk below consults it once per held rid;
        // consulting the request's lists directly was a linear scan per rid,
        // and with a peer that announces its whole holdings that is
        // O(held × announced) — 78× more server CPU for 9.5× more artifacts,
        // measured. Keys borrow the request bytes, which outlive this reply.
        var peer_named: std.StringHashMapUnmanaged(void) = .empty;
        defer peer_named.deinit(gpa);
        try peer_named.ensureTotalCapacity(gpa, @intCast(scan.igots.items.len + scan.gimmes.items.len));
        for (scan.igots.items) |h| peer_named.putAssumeCapacity(h, {});
        for (scan.gimmes.items) |h| peer_named.putAssumeCapacity(h, {});

        // §3.2 L406-410/§8.6 L1813-1823 — one bounded store walk. A held artifact the
        // client neither announced (igot) nor requested (gimme, delivered above)
        // is advertised with igot; a phantom (content absent) is requested with
        // gimme. Rids are dense in 1..=count (repostate contract).
        //
        // The gate is `held()`, NOT `content()`: this walk visits every rid, and
        // `content()` on a wire-backed entry INFLATES it, so reading the held set
        // through it made advertising the store cost the store's whole
        // uncompressed size — on every round, which is what a large repository
        // could not answer inside a connection deadline. `held()` is the §3.3 r24
        // held-set predicate and answers from the retained form alone. The two
        // disagree only for a W4 container that does not inflate; that artifact
        // IS held (it is retransmitted verbatim and persisted), so advertising it
        // with igot rather than begging for it with gimme is also the more
        // accurate statement. The SEND gate above still reads `content()`,
        // because uploading composes from plaintext.
        // Sparse-cone RFC section 6.2, the pull half: a cone-scoped requester
        // is not told about out-of-cone holdings, so it never mints phantoms
        // for them and never gimmes them -- the transfer narrows at its
        // source. The server's own gimme-begging arm below stays unfiltered:
        // its phantoms are its own holdings policy, not the requester's.
        const advertise_keep: ?*const std.DynamicBitSetUnmanaged = if (scan.cone_members.items.len != 0)
            self.cone_classifier.vtable.classify(self.cone_classifier.context, scan.cone_members.items, scan.cone_not_members.items)
        else
            null;

        const n: repostate.Rid = store.count();
        var rid: repostate.Rid = 1;
        while (rid <= n) : (rid += 1) {
            const hash = store.hashOf(rid);
            if (store.held(rid)) {
                if (!parts.igot) continue;
                if (advertise_keep) |k| {
                    if (rid < k.capacity() and !k.isSet(rid)) continue;
                }
                // A name some held cluster already announces is not
                // announced again, unless the peer asked for the full
                // inventory (send-catalog: the §9 L2041 resync state).
                if (!scan.send_catalog) if (self.clustered) |c| if (rid < c.capacity() and c.isSet(rid)) continue;
                emit_reply_probes += 1;
                if (!peer_named.contains(hash)) {
                    const card = writeIgot(hash, false, line_buf) catch return error.ResponseTooLarge;
                    try appendCard(gpa, out, card);
                }
            } else if (parts.gimme and !self.shun.contains(hash)) {
                // §3.3 L553-556 — the ordinary phantom scan excludes names in
                // the repository shun table. The phantom stays (the client's
                // igot still minted it, §3.3 r24(1) L537-540); the reply simply
                // never begs for it, so a shunned artifact the peer holds is
                // never pulled in through the serve direction either.
                const card = writeGimme(hash, line_buf) catch return error.ResponseTooLarge;
                try appendCard(gpa, out, card);
            }
        }
    }

    /// §8.2 L1551-1552/L1573-1576 — answer one `clone 3 SEQNO` request: a bounded batch of
    /// the store's artifacts as `cfile` cards in RID order from SEQNO, then the
    /// `clone_seqno NEXT` cursor and the `push SERVERCODE PROJECTCODE` card. The
    /// server is stateless (§3.8/§8.9): the request's SEQNO IS the cursor, so no
    /// clone progress is remembered between requests. `start` is a blob-table RID
    /// (§8.2 L1551); the store's RIDs are dense in 1..=count (repostate
    /// contract), so "silently skipping gaps" (L1552) reduces to skipping
    /// phantoms (content absent) — never sent, they carry no stored bytes to retransmit.
    fn emitCloneReply(
        self: *const Server,
        gpa: std.mem.Allocator,
        store: repostate.ArtifactStore,
        scan: *const RequestScan,
        out: *std.ArrayListUnmanaged(u8),
        line_buf: []u8,
        start: u64,
    ) RespondError!void {
        std.debug.assert(start >= 1); // §8.1 — SEQNO 0 was rejected before this
        std.debug.assert(self.clone_batch_max >= 1); // a zero-size batch never advances the cursor (§3.7 L646-647 liveness)
        std.debug.assert(self.clone_batch_bytes >= 1); // ditto: the first card always lands, so the cursor advances
        // §8.2 L1575-1576 — server-version precedes the clone sequence, on the
        // same §10.1 gate `emitReply` uses (emitted iff the client-version
        // carried DATE and TIME). The clone client sends an undated
        // client-version, so this is normally silent — §8.2 fixes the ordering
        // among emitted cards, §10.1 fixes whether server-version is emitted.
        if (scan.client_version) |cv| {
            if (serverEmitsServerVersion(cv)) {
                std.debug.assert(self.version.datetime != null); // server config precondition
                const p = writeServerVersion(self.announcedVersionInfo(), line_buf) catch return error.ResponseTooLarge;
                try appendCard(gpa, out, p);
            }
        }
        try self.appendAvoidSubmanifests(gpa, out, line_buf);

        // §8.2 L1573-1574 — zero or more `cfile` cards in RID order from `start`,
        // a batch of at most `clone_cfile_batch_max` artifacts. Both the walk
        // (bounded by `count()`) and the batch (bounded by the cap) are explicit
        // upper bounds; the cursor `rid` after the loop is the first RID not yet
        // sent, or count+1 when the store is exhausted.
        //
        // submanifests §7.2 — the clone half of the send gate. A clone serves
        // stored containers VERBATIM and never inflates one, which is the
        // O(wire) property this reply is built around; deciding S-bearingness
        // needs plaintext, so the decision is fenced twice before any inflate
        // can happen. Both fences are false for every repository that predates
        // this draft, so the clone path here is instruction-identical to what it
        // was: neither §8.1 arming disjunct holds, and even when one does the
        // gate is inert for a peer at or above the threshold. Only a repository
        // that holds trees being cloned to a pre-S peer pays an inflate, and
        // pays it to REFUSE, which is the outcome §7.2 demands over a silent
        // omission.
        const clone_gate_armed = self.submanifestGateArmed() and
            submanifestPeerVersionGate(peerVersionOf(scan), true) == .refuse;
        var s_refused = false;

        // Sparse-cone RFC section 6.2: a request carrying cone pragmas is
        // answered from the keep classification when the embedder provides
        // one; otherwise densely (section 6.3 -- degradation, never error).
        // The SEQNO cursor advances over skipped rids exactly as over sent
        // ones, so a cone-scoped clone still converges in bounded rounds.
        const cone_keep: ?*const std.DynamicBitSetUnmanaged = if (scan.cone_members.items.len != 0)
            self.cone_classifier.vtable.classify(self.cone_classifier.context, scan.cone_members.items, scan.cone_not_members.items)
        else
            null;

        const n: usize = store.count();
        const clone_body_start = out.items.len; // only cfile bytes count against the budget
        var rid: usize = start;
        var emitted: usize = 0;
        var batch_full = false;
        while (rid <= n) : (rid += 1) {
            // Batch full — by bytes (the practical limit) or count (the DoS
            // backstop): resume at `rid`.
            if (out.items.len - clone_body_start >= self.clone_batch_bytes or emitted >= self.clone_batch_max) {
                batch_full = true;
                break;
            }
            if (cone_keep) |k| {
                if (rid < k.capacity() and !k.isSet(rid)) continue; // elided server-side (RFC 6.2)
            }
            const r: repostate.Rid = @intCast(rid);
            if (clone_gate_armed) {
                // The inflate happens ONLY inside this branch, which the two
                // fences above keep unreachable for every unarmed server.
                if (store.content(r)) |plain| {
                    if (repostate.transferGatedArtifact(plain)) {
                        s_refused = true;
                        continue; // §7.2: refused, never converted or downgraded
                    }
                }
            }
            if (store.wire(r)) |wf| {
                // §4.1/§7.2 — serve the stored container verbatim, no
                // re-compression. Form-aware: full vs delta.
                if (wf.delta) |d| {
                    // §7.2 delta form — USIZE is the RECONSTRUCTED artifact
                    // size (delta §3.2), never the container's 4-byte prefix
                    // (the raw delta length, §7.2 L1262-1264).
                    // NO CONTENT-RECONSTRUCTING ASSERTS HERE. This arm used to
                    // reconstruct every served artifact AND its delta basis
                    // under `runtime_safety`, purely to assert that the declared
                    // size matched and that the basis was present at an earlier
                    // rid. That made the SHIPPED build (ReleaseSafe enables
                    // runtime_safety) do several times the I/O of `--release=fast`
                    // on the hot clone path, and it was not free in the way an
                    // assert is supposed to be: it was O(store) work inside the
                    // server's per-request answer.
                    //
                    // It caused a real outage rather than catching one. The
                    // per-connection deadline in `serve.zig` bounds read plus
                    // answer plus write, so once these reconstructions pushed a
                    // clone round past it, the deadline cancelled the connection
                    // mid-answer, the in-flight pread failed, and the peer got an
                    // HTTP 500 on a perfectly healthy store.
                    //
                    // The properties are still checked, in the place that can
                    // afford them: `zit verify` reconstructs every artifact
                    // through its delta chain and re-hashes it against its own
                    // name, which subsumes the size assert entirely. An fsck run
                    // on demand is the right home for O(store) verification; a
                    // clone round is not.
                    writeCfileVerbatimDelta(gpa, out, store.hashOf(r), d.src_hash, d.target_size, wf.container) catch |err| switch (err) {
                        error.OutOfMemory => return error.OutOfMemory,
                        error.NoSpaceLeft => return error.ResponseTooLarge,
                    };
                } else {
                    // §7.2 full form — USIZE is the container's 4-byte prefix.
                    // The assert that reconstructed the artifact to prove that
                    // prefix equalled the uncompressed length is gone for the
                    // reason given in the delta arm above: it is O(store) work on
                    // the server's answer path, and `zit verify` proves the same
                    // property on demand.
                    writeCfileVerbatim(gpa, out, store.hashOf(r), wf.container) catch |err| switch (err) {
                        error.OutOfMemory => return error.OutOfMemory,
                        error.NoSpaceLeft => return error.ResponseTooLarge,
                    };
                }
                emitted += 1;
            } else if (store.content(r)) |bytes| {
                // Fallback: a raw or non-wire store — compress on the fly (§7.2).
                writeCfile(gpa, out, store.hashOf(r), bytes) catch |err| switch (err) {
                    error.OutOfMemory => return error.OutOfMemory,
                    error.InputTooLarge, error.NoSpaceLeft => return error.ResponseTooLarge,
                };
                emitted += 1;
            }
        }

        // §8.2 L1576 — NEXT is the next RID to fetch, or 0 at exhaustion. A
        // positive NEXT is only produced when the batch filled (advancing the
        // cursor past `start`), so the §3.7 L643-647 liveness invariant holds: a
        // positive NEXT always ends a round that advanced the sequence.
        const next: u64 = if (rid > n) 0 else rid;
        std.debug.assert(next == 0 or (next > start and next <= n));
        std.debug.assert((next > 0) == batch_full);
        std.debug.assert(!batch_full or emitted >= 1); // a full batch carried at least one card (liveness)
        const seqcard = writeCloneSeqno(next, line_buf) catch return error.ResponseTooLarge;
        try appendCard(gpa, out, seqcard);

        // §8.2 L1575, §8.5 L1687-1688 — `push SERVERCODE PROJECTCODE` closes the
        // clone sequence; the client learns the project code from it (§8.3/§6.3).
        // Non-empty codes are a server-config precondition (§9.1: an empty field
        // would re-tokenize the card to two tokens and abort the peer).
        std.debug.assert(self.self_servercode.len > 0 and self.project_code.len > 0);
        const pushcard = writePushPullCard(.push, self.self_servercode, self.project_code, line_buf) catch return error.ResponseTooLarge;
        try appendCard(gpa, out, pushcard);

        // submanifests §7.2 "with an explicit error" — appended AFTER the
        // sequence cards so the batch the peer CAN read still lands and the
        // §8.2 cursor still advances; §11.1 r8 puts the peer-version gate at the
        // append disposition precisely so a refusal does not discard the reply.
        if (s_refused) {
            std.debug.assert(serverErrorReset(.peer_version_gate) == .append);
            const card = writeSignalCard(.err, submanifest_peer_version_error_text, line_buf);
            try appendCard(gpa, out, card);
        }
    }
};

/// §8.2 L1573-1574 — the default per-reply upper bound on `cfile` cards a clone
/// batch emits (a server may override it via `Server.clone_batch_max`). A
/// DoS backstop only: the BYTE budget below is the practical limiter (a
/// count-bound batch made every clone round carry ~1MB — 0.2% of the byte
/// ceiling — so an N-artifact clone paid ~N/1024 serial round trips and the
/// transfer was latency-bound instead of throughput-bound). The client
/// resumes the remainder with `clone 3 NEXT` (§8.2 L1593-1594), so a
/// repository of any size clones across a bounded number of rounds rather
/// than one unbounded reply.
const clone_cfile_batch_max: usize = 65536;

/// The default per-reply BYTE budget a clone batch targets — the real batch
/// limiter (`Server.clone_batch_bytes` overrides it). Large enough that
/// rounds are throughput-bound, small enough that a slow peer's round
/// completes well inside serve's per-connection deadline (the
/// failure mode — a healthy-but-slow answer cancelled mid-flight — is what
/// an unbounded reply would re-approach). The budget is a TARGET, not a
/// ceiling: the card that crosses it still lands whole, so one oversized
/// artifact overshoots by its own size — exactly the exposure the old count
/// cap had — and `reply_body_cap` remains the hard error. Never
/// wire-visible: §8.2 L1552-1553 forbids clients inferring count or size.
const clone_cfile_batch_bytes: usize = 4 << 20;

/// §5.2/§6.4 — the three in-band reply faults `respond` can raise: an unknown
/// operator (`error bad command: LINE`), a malformed content card (`error
/// malformed CARD line`), and an authentication failure (`error TEXT`). Each is
/// a wire reply, never a returned Zig error, so the RespondError set stays
/// {OutOfMemory, ResponseTooLarge}.
const RespondFault = union(enum) {
    bad_command: []const u8, // the offending line (borrows the request)
    malformed: Operator, // a content-bearing card whose arity failed
    unauthorized, // §6.4 signature mismatch, or a §6.5 cookie that does not verify
    duplicate_login, // §6.1 — a second login card, or a login card beside a cookie
    invalid_clone_seqno, // §8.1 L1533-1548 — `clone VERSION SEQNO` with VERSION≥2 and digit-only SEQNO≤0
    config_push_unauthorized, // §9.3 r31 L1976-1979 — a pushed config card without Admin
    write_unauthorized, // file content from a request that is not an authorized push
    sparse_origin, // sparse-cone RFC §5.3 — a clone this sparse origin does not cover
    /// A pushed check-in introduces content this server's declared
    /// policy refuses. The payload is the refusal text, already assembled by
    /// the classifier because only it knows the path and the rule.
    content_policy: []const u8,
};

/// §6.4 — the auth-failure TEXT, `error login\sfailed` on the wire: the text
/// Fossil sends for a login card whose signature fails, used here for a
/// cookie this server cannot verify too.
const respond_unauthorized_text = "login failed";

/// §6.1 — Fossil's TEXT for a second login (xfer.c `multiple login cards`).
const duplicate_login_text = "multiple login cards";

/// §6.6 erratum — the nonfatal refusal of a push that follows an accepted
/// pull, which a client handles as §10.2's `pull only *` message.
const push_refused_pull_only_text = "pull only - not authorized to push";

/// Sparse-cone RFC section 5.3 -- the impl-chosen refusal TEXT for a clone
/// request this sparse origin does not cover. The SHOULD-refuse behaviour is
/// the spec's; the wording is ours, and it names the remedy.
const sparse_origin_refusal_text = "sparse origin does not cover the requested clone cone; clone from a dense peer or request a covered cone";

/// The server's own content policy for pushes, supplied by the embedder
/// exactly as `cone_classifier` is. `.none` (the default, both globs empty)
/// accepts every push, so a server that declared nothing behaves as before.
///
/// The policy is deliberately the SERVER's, never read from the artifacts being
/// pushed: a push that carried its own `.zit-settings` would otherwise arrive
/// bearing permission to land, which is the same reasoning that keeps
/// `before-commit` consent local.
///
/// Only INSPECTING rules live here. A server must never execute a command that
/// a peer's content chose, so `before-commit` has no server-side counterpart —
/// the division (inspect binds everyone, execute binds only where accepted)
/// carries across the wire unchanged.
pub const PushPolicy = struct {
    binary_glob: []const u8 = "",
    crlf_glob: []const u8 = "",
    /// Branch globs (the `ignore-glob` dialect) whose MEMBERSHIP this
    /// round may not change: no pushed check-in may resolve to one, and no
    /// pushed tag may add or cancel one on a check-in already held. Unlike the
    /// two content globs this is never a repository setting — it is different
    /// for every pusher, so it arrives per request from the front that
    /// authenticated the push (`serve --fronted`) and is never persisted.
    protect_globs: []const u8 = "",

    pub const none: PushPolicy = .{};

    /// Whether this server has an opinion at all. Undeclared is not
    /// "refuse everything" — it is the state of every server that never asked.
    pub fn declared(self: PushPolicy) bool {
        return self.binary_glob.len != 0 or self.crlf_glob.len != 0 or self.protect_globs.len != 0;
    }

    /// The rule `path` violates, or null. Shares the commit-side classifier
    /// rather than re-deriving it: a class is checked only when
    /// its glob is set, and binary is tested first because a binary file
    /// usually also carries CR/LF. Two spellings of "is this binary" would let
    /// a server refuse a file zit's own commit had just accepted -- which is
    /// what a UTF-16 file did while the two tests were written out separately.
    ///
    /// `encoding-glob` has no counterpart here on purpose: fossil's server
    /// gates no content at all, so the two classes it covers are a local
    /// commit-time policy with nothing to mirror across the wire.
    pub fn violation(self: PushPolicy, path: []const u8, content: []const u8) ?[]const u8 {
        const look = lookslike.classify(content);
        // Binary is terminal here as it is at commit time: a blob with a NUL
        // is answered by binary-glob alone. Falling through to crlf-glob would
        // refuse a file zit's own commit had just let through.
        if (look.nul) {
            if (self.binary_glob.len == 0) return null;
            return if (ignore_mod.listMatches(self.binary_glob, path)) null else "binary-glob";
        }
        if (self.crlf_glob.len != 0 and look.cr and
            !ignore_mod.listMatches(self.crlf_glob, path)) return "crlf-glob";
        return null;
    }
};

/// How the membership rule learns which branch a check-in ALREADY
/// in the store resolves to. `respond` is sans-IO and holds only an
/// `ArtifactStore`, which knows hashes and bytes and nothing about branches;
/// branch resolution is crosslink's answer and lives a layer up. The embedder
/// supplies it. `Unavailable` is the resolver saying it cannot answer at all
/// (it could not open the repository view); the rule then REFUSES rather than
/// admits, because a protection that fails open under a read fault is not one.
pub const BranchResolver = struct {
    ctx: *anyopaque,
    /// The branch a held check-in resolves to, or null when `hash` names
    /// nothing held or something that is not a check-in.
    branchOfFn: *const fn (ctx: *anyopaque, hash: []const u8) error{Unavailable}!?[]const u8,
    /// The repository's main branch — what a check-in with no parent and no
    /// branch tag resolves to (repo-state §6.4).
    mainBranchFn: *const fn (ctx: *anyopaque) error{Unavailable}![]const u8,
    /// A held check-in's active tag rows with their §5.3 mtimes, or null when
    /// `hash` names nothing held or something that is not a check-in. The
    /// slice is allocated from `gpa`; names and values borrow the resolver
    /// and live as long as it does.
    tagsOfFn: *const fn (ctx: *anyopaque, gpa: std.mem.Allocator, hash: []const u8) error{ Unavailable, OutOfMemory }!?[]repostate.RepoState.TagEntry,

    pub fn branchOf(self: BranchResolver, hash: []const u8) error{Unavailable}!?[]const u8 {
        return self.branchOfFn(self.ctx, hash);
    }
    pub fn mainBranch(self: BranchResolver) error{Unavailable}![]const u8 {
        return self.mainBranchFn(self.ctx);
    }
    pub fn tagsOf(self: BranchResolver, gpa: std.mem.Allocator, hash: []const u8) error{ Unavailable, OutOfMemory }!?[]repostate.RepoState.TagEntry {
        return self.tagsOfFn(self.ctx, gpa, hash);
    }
};

/// Bound on the peer error-card text a client copies out for diagnostics.
pub const peer_error_cap: usize = 256;
/// Bound on the server `message` cards one session keeps for display.
pub const max_server_messages: usize = 32;

/// §7.1/§7.4 — one pushed file card collected during the request scan, ready to
/// store. `hash` borrows the request bytes. `payload` is the artifact content to
/// store: for a full-content card it borrows the request bytes; for a delta-form
/// card it is the §7.4-reconstructed content, a scan-owned allocation pointed to
/// by `owned` and freed in `deinit`. Both borrows are valid for the duration of
/// `respond`.
const RequestFile = struct {
    hash: []const u8,
    payload: []const u8,
    owned: ?[]u8 = null, // non-null → a reconstructed delta target this scan allocated
};

/// A pushed delta-form `file` card whose basis was not held when the scan
/// reached it. fossil sends a batch of check-ins as one round with each
/// manifest a delta against the previous one, so the basis is routinely a
/// card of the SAME round, delivered before or after this one. The scan
/// parks the card and resolves it once every card has been seen. Borrows
/// the request bytes.
const DeferredDelta = struct {
    hash: []const u8,
    deltasrc: []const u8,
    delta: []const u8,
};

/// §3.2 — the call-local tables `respond` derives its reply from. All borrow
/// the request bytes or own only their own list storage, freed before return;
/// no fragment outlives the call, so the server keeps no cross-request state.
const RequestScan = struct {
    igots: std.ArrayListUnmanaged([]const u8) = .empty,
    gimmes: std.ArrayListUnmanaged([]const u8) = .empty,
    files: std.ArrayListUnmanaged(RequestFile) = .empty,
    /// Every received file by name → its index in `files`, so a delta card
    /// can find a basis this round delivered without a rescan.
    received: std.StringHashMapUnmanaged(usize) = .empty,
    /// Delta cards waiting on a basis that may still arrive in this round.
    deferred_deltas: std.ArrayListUnmanaged(DeferredDelta) = .empty,
    client_version: ?VersionInfo = null,
    /// §9 L2041-2045 — `pragma send-catalog` enables the server's
    /// resync state: the reply announces EVERY eligible holding, not just the
    /// unclustered tail. The peer's way to ask for the full inventory.
    send_catalog: bool = false,
    /// §5.3 L1073-1076 — `pragma req-clusters`: announce every held cluster,
    /// enclosed by `# sending-clusters` / `# end-of-clusters`.
    req_clusters: bool = false,
    /// The request carried a pull card: the reply is where fossil
    /// mints clusters (`create_cluster` runs on the pull path only).
    pull: bool = false,
    /// Fossil's `isPush`: a push card was accepted. Rights are judged as
    /// each card is reached, so a push card counts only when the server holds
    /// no credential or a verified login (card or cookie) precedes it. The
    /// login signature covers only the bytes after its card, so a card placed
    /// before it must not borrow the signer's rights. Only an accepted push
    /// may write: without one, file content is refused and igot announces
    /// nothing to fetch.
    push_accepted: bool = false,
    /// §6.6 erratum — a push card was refused for want of Write.
    push_refused: bool = false,
    /// §6.6 erratum — the first refused push card came after a pull card, so
    /// the refusal is the nonfatal pull-only message rather than the error.
    pull_before_push: bool = false,
    /// A file card arrived before any accepted push. Fossil refuses it and
    /// leaves the card loop there, so the scan stops at it.
    refused_content: bool = false,
    /// The igots that arrived after an accepted push: Fossil makes a phantom
    /// of an igot only while pushing. Borrows the request bytes.
    offers: std.ArrayListUnmanaged([]const u8) = .empty,
    /// §9.3 r31 — the pushed config card had Admin where it stood: the server
    /// holds a credential and a login card verified before it.
    config_push_admin: bool = false,
    /// §6.5 — the transport carried an `x-f-l-c` cookie, which counts as this
    /// request's first login when the scan meets a login card.
    cookie_login: bool = false,
    /// §6.4 erratum — the fault of a login card that failed (signature or
    /// duplicate); the scan stopped there. Later cards are not parsed, while
    /// the directions earlier cards established are still answered.
    login_fault: ?RespondFault = null,
    /// Sparse-cone RFC section 6.1 -- the request's cone, one F-card-encoded
    /// member per `pragma cone MEMBER` card, borrowing the request buffer.
    /// Bounded at the cone member cap; the 65th is ignored rather than
    /// faulted (pragmas are requests only).
    cone_members: std.ArrayListUnmanaged([]const u8) = .empty,
    /// Section 6.1 exclusion members (`pragma cone-not MEMBER`), same
    /// encoding and bounds discipline as `cone_members`.
    cone_not_members: std.ArrayListUnmanaged([]const u8) = .empty,
    login_count: usize = 0,
    /// The request's login card, set only once it has verified (or the server
    /// holds no credential to verify it against).
    login: ?LoginCard = null,
    clone_start: ?u64 = null, // §8.2 — an accepting `clone 3 SEQNO`'s SEQNO (a blob-table RID ≥ 1)
    /// §9.3 r20/r24 — this request asked for the SCALAR settings group
    /// (`reqconfig /config`). A table group name leaves it false: its group mask
    /// is zero here, which §9.3 makes a silent no-op, not an error.
    reqconfig_scalar: bool = false,
    /// §9.3 r28 — the TABLE groups this request selected, as a mask: fossil's
    /// `/all` names several at once, so several reqconfig cards in one request
    /// union rather than overwrite.
    reqconfig_groups: ConfigGroupMask = ConfigGroupMask.empty,
    /// §9.3 r23/r29 — the framed payload of a pushed scalar `config /config`
    /// card, borrowing the request bytes. Recorded, never applied, during the
    /// scan: §9.3 r31's Admin gate is decided after the login verdict, and a
    /// rejected push must leave the request wholly without effect.
    config_push: ?[]const u8 = null,
    /// §7.4 erratum/§7.7 L1482-1485 — DELTASRC names of pushed delta-form
    /// `file` cards whose basis neither the store nor this round supplied:
    /// "an absent DELTASRC is left behind as a phantom and is requested by a
    /// later gimme", unless it is shunned, when "the Section 3.3 scan excludes
    /// it, so it is not requested." Kept separate from `igots` because these
    /// names were never ANNOUNCED — a dangling source must still be
    /// gimme-eligible when it is not shunned, which `emitReply`'s
    /// announced-name suppression would silently defeat if the two lists were
    /// merged. Borrows the request bytes.
    delta_phantoms: std.ArrayListUnmanaged([]const u8) = .empty,

    fn deinit(self: *RequestScan, gpa: std.mem.Allocator) void {
        self.cone_members.deinit(gpa);
        self.cone_not_members.deinit(gpa);
        self.igots.deinit(gpa);
        self.offers.deinit(gpa);
        self.gimmes.deinit(gpa);
        self.delta_phantoms.deinit(gpa);
        self.deferred_deltas.deinit(gpa);
        self.received.deinit(gpa);
        // §7.4 — free every reconstructed delta target this scan allocated; a
        // full-content file's payload borrows the request and owns nothing.
        for (self.files.items) |f| if (f.owned) |buf| gpa.free(buf);
        self.files.deinit(gpa);
        self.* = undefined;
    }

    /// Record a received file and index it by name.
    fn addFile(self: *RequestScan, gpa: std.mem.Allocator, f: RequestFile) error{OutOfMemory}!void {
        try self.files.append(gpa, f);
        errdefer _ = self.files.pop();
        try self.received.put(gpa, f.hash, self.files.items.len - 1);
    }
};

/// §3.2 — one scanned card's outcome: advance the cursor to the given offset
/// (past any byte-counted payload, §5.1), or abort with a `RespondFault`.
const ScanOutcome = union(enum) {
    advance: usize,
    fault: RespondFault,
    /// §6.4 erratum — end card processing at this card without discarding
    /// what earlier cards established (a failed login).
    end,
};

/// §5.1/§5.2 — walk the request card stream once, collecting the reply-relevant
/// cards into `scan`. Returns the first fatal card as a `RespondFault` (unknown
/// operator or malformed content) or null when the whole body parses. Ingests
/// nothing: a fault must leave the store untouched, so storage waits for pass 2.
///
/// `server` is read for the §7.7 L1469-1472 admission predicate only: the pushed
/// `file` arm must answer it BEFORE it reconstructs, because a shunned target
/// "emits no error" (L1437-1440) and reconstruction is the one step of this scan
/// that can raise one.
fn scanRequest(
    gpa: std.mem.Allocator,
    server: *const Server,
    store: repostate.ArtifactStore,
    request: []const u8,
    scan: *RequestScan,
) Server.RespondError!?RespondFault {
    var cursor: usize = 0;
    while (cursor < request.len) {
        const line = readLine(request, cursor) catch
            return RespondFault{ .bad_command = request[cursor..@min(request.len, cursor + max_line_len)] };
        var next = line.next;
        if (!isCommentLine(line.bytes)) {
            const cc = classifyCard(line.bytes);
            if (cc.operator == null) return RespondFault{ .bad_command = line.bytes };
            if (isFatal(cc.disposition, .server)) return RespondFault{ .malformed = cc.operator.? };
            switch (try scanCard(gpa, server, store, request, scan, cc, line)) {
                .fault => |fault| return fault,
                .advance => |offset| next = offset,
                .end => break,
            }
        }
        std.debug.assert(next > cursor); // §5.1 — the cursor strictly advances
        cursor = next;
    }
    return resolveDeferredDeltas(gpa, store, scan);
}

/// Settle every delta card the scan parked for want of a basis, now that the
/// whole round has been seen. A basis is either held by the store or was
/// delivered by this round — possibly as another parked delta, so this runs
/// to a fixpoint: each pass resolves what it can, and stops when a pass
/// resolves nothing.
///
/// §7.4 erratum — whatever is left names a basis this round never delivered
/// and the store never held. Fossil does not fault that round: it records
/// DELTASRC as a phantom and its end-of-request scan asks for it with gimme.
/// This server does the same. Being stateless (§3.8), it cannot retain the
/// delta itself, so the target does not land this round; the pusher's igot
/// of it is interned and requested like any other unheld announcement once
/// the basis has arrived. A DELTASRC in the shun table is still recorded and
/// never requested: §7.7 L1482-1485, enforced by `emitReply`'s phantom scan.
fn resolveDeferredDeltas(
    gpa: std.mem.Allocator,
    store: repostate.ArtifactStore,
    scan: *RequestScan,
) Server.RespondError!?RespondFault {
    var progress = true;
    while (progress and scan.deferred_deltas.items.len > 0) {
        progress = false;
        var i: usize = 0;
        while (i < scan.deferred_deltas.items.len) {
            const d = scan.deferred_deltas.items[i];
            const src_bytes: ?[]const u8 = blk: {
                if (scan.received.get(d.deltasrc)) |idx| break :blk scan.files.items[idx].payload;
                if (store.lookup(d.deltasrc)) |rid| if (store.content(rid)) |bytes| break :blk bytes;
                break :blk null;
            };
            const src = src_bytes orelse {
                i += 1;
                continue;
            };
            const reconstructed = reconstructDeltaTarget(gpa, d.hash, src, d.delta, true) catch |err| switch (err) {
                error.OutOfMemory => return error.OutOfMemory,
                error.MalformedDelta, error.IntegrityMismatch => return RespondFault{ .malformed = .file },
            };
            errdefer gpa.free(reconstructed);
            try scan.addFile(gpa, .{ .hash = d.hash, .payload = reconstructed, .owned = reconstructed });
            _ = scan.deferred_deltas.swapRemove(i);
            progress = true;
        }
    }
    // The deltas themselves are dropped. A stock client deltas only against
    // a source the server announced, so only a non-stock client reaches this
    // path, and its target converges through its igot once the source lands.
    for (scan.deferred_deltas.items) |d| try scan.delta_phantoms.append(gpa, d.deltasrc);
    return null;
}

/// §3.2 — collect one non-fatal-classified card into `scan`, returning the
/// cursor offset to resume at, or a per-card `RespondFault` for an arity/format
/// failure the card's own handler detects (§5.2). Only the A0 request
/// vocabulary is accepted; any other defined operator is rejected like an
/// unknown one, aborting the exchange.
fn scanCard(
    gpa: std.mem.Allocator,
    server: *const Server,
    store: repostate.ArtifactStore,
    request: []const u8,
    scan: *RequestScan,
    cc: CardClass,
    line: Line,
) Server.RespondError!ScanOutcome {
    switch (cc.operator.?) {
        .pragma => {
            // §10.1 — record the client-version (VERSION [DATE TIME]); every
            // other pragma name is tolerated and ignored (§3.5).
            if (cc.tokens.len >= 2 and std.mem.eql(u8, cc.tokens.items[1], "client-version")) {
                scan.client_version = parseVersionPragma(cc.tokens) catch null;
            } else if (cc.tokens.len == 3 and parsePragmaName(cc.tokens.items[1]) == .cone) {
                // Sparse-cone RFC section 6.1. Requests only: over-cap or
                // malformed members are ignored, never faulted.
                if (scan.cone_members.items.len < cone_mod.max_members) {
                    try scan.cone_members.append(gpa, cc.tokens.items[2]);
                }
            } else if (cc.tokens.len == 3 and parsePragmaName(cc.tokens.items[1]) == .@"cone-not") {
                // The joint cap covers both polarities: total members bound
                // the request's cone exactly as Cone.init will.
                if (scan.cone_members.items.len + scan.cone_not_members.items.len < cone_mod.max_members) {
                    try scan.cone_not_members.append(gpa, cc.tokens.items[2]);
                }
            } else if (cc.tokens.len >= 2 and parsePragmaName(cc.tokens.items[1]) == .@"send-catalog") {
                scan.send_catalog = true; // §9 L1873-1892 — acts despite visible surplus
            } else if (cc.tokens.len >= 2 and parsePragmaName(cc.tokens.items[1]) == .@"req-clusters") {
                scan.req_clusters = true;
            }
        },
        .push, .pull => {
            // §9.1 — arity-validate only; project-code persistence is a later
            // batch. Too few tokens is a simple-card failure (§5.2 bad command).
            if (parsePushPull(cc.tokens) == null) return .{ .fault = .{ .bad_command = line.bytes } };
            switch (cc.operator.?) {
                .pull => scan.pull = true,
                .push => if (signedIn(server, scan)) {
                    scan.push_accepted = true;
                } else if (!scan.push_refused) {
                    scan.push_refused = true;
                    scan.pull_before_push = scan.pull;
                },
                else => unreachable,
            }
        },
        .login => {
            // §6.1 — a malformed login is a simple-card failure. A well-formed
            // one is verified HERE, at its place in the card stream: Fossil
            // checks the login when its card is reached and, on a duplicate
            // (§6.1) or a signature that fails to reconstruct (§6.4), leaves
            // the card loop. Later cards are not parsed; earlier ones stand,
            // and `respondWithMedia` still answers the directions they set.
            // The failed login grants nothing, so `login` is cleared.
            const card = parseLoginCard(cc.tokens) orelse return .{ .fault = .{ .bad_command = line.bytes } };
            scan.login_count += 1;
            // §6.2 — the nonce covers exactly the bytes after the login LF.
            if (duplicateLogin(scan.cookie_login, scan.login_count)) {
                scan.login = null;
                scan.login_fault = .duplicate_login;
                return .end;
            }
            if (!loginVerifies(server.credential, card, request[line.next..])) {
                scan.login = null;
                scan.login_fault = .unauthorized;
                return .end;
            }
            scan.login = card;
        },
        .igot => {
            const card = parseIgotCard(cc.tokens) catch return .{ .fault = .{ .bad_command = line.bytes } };
            try scan.igots.append(gpa, card.hash);
            if (scan.push_accepted) try scan.offers.append(gpa, card.hash);
        },
        .gimme => {
            const card = parseGimmeCard(cc.tokens) catch return .{ .fault = .{ .bad_command = line.bytes } };
            try scan.gimmes.append(gpa, card.hash);
        },
        .file => {
            // §7.1 — full form (file HASH SIZE) or delta form
            // (file HASH DELTASRC SIZE); arity alone disambiguates. A bad arity
            // or non-numeric SIZE is `error malformed file line`. SIZE frames the
            // payload identically in both forms.
            const fc = parseFileCard(cc.tokens) catch return .{ .fault = .{ .malformed = .file } };
            const payload = consumeContentPayload(request, line.next, fc.size) catch
                return .{ .fault = .{ .malformed = .file } };
            // §7.7 L1472-1475 — "A non-clone file whose target is shunned is
            // consumed and discarded after its payload and pending private
            // marker; it emits no error, does not set content-received, and makes
            // no repository mutation." Server push-receive is always non-clone
            // (§3.9's request vocabulary carries no clone-side content delivery),
            // so the discard arm applies unconditionally and no `operation` guard
            // belongs here.
            //
            // The gate sits in the SCAN, above the reconstruction, and not merely
            // on the pass-2 mutation: "emits no error" is unconditional across
            // both forms, and `reconstructPushedDelta` below is the one step of
            // this scan that can raise a fault. A basis this repository does not
            // hold is the COMMON case for a shunned target — precisely because it
            // is shunned, its neighbours are unlikely to be here — so gating only
            // the `put` would answer a required-silent discard with
            // `error malformed file line`. `ingest` keeps the same test as a
            // belt-and-braces mutation guard; this one keeps the reply clean.
            //
            // Discarding is an early `.advance` past the framed payload, so the
            // card contributes no `scan.files` entry, no `delta_phantoms` entry
            // (admission is decided before DELTASRC is ever looked at), and the
            // cursor still resumes exactly where the framing says (§5.1).
            if (admitInbound(server.hash_policy, server.shun, fc.hash) == .reject)
                return .{ .advance = payload.next };
            // Fossil refuses content without an accepted push and leaves the
            // card loop: nothing after this card is parsed.
            if (!scan.push_accepted) {
                scan.refused_content = true;
                return .end;
            }
            if (fc.deltasrc) |deltasrc| {
                // §7.4 erratum — "If HASH already names a locally verified
                // artifact, the receiver consumes and ignores the duplicate
                // card's payload"; checking HASH first, as the erratum
                // permits, skips delta-source handling, so a duplicate whose
                // source is absent mints no phantom and draws no gimme.
                if (store.lookup(fc.hash)) |rid| if (store.held(rid)) return .{ .advance = payload.next };
                // §7.1-r5/§7.4 — delta form: the payload is a [FossilDelta]
                // against DELTASRC. The stateless server (§3.8) reconstructs
                // immediately against a HELD basis and §7.7-verifies before store;
                // a malformed or wrong-hash delta is rejected as a malformed file
                // card (store untouched — the fault stops the scan before pass-2
                // ingest), never silently dropped. Reconstructing in the read-only
                // scan pass keeps `respond` stateless: same request + same store →
                // same reconstructed bytes.
                const reconstructed = reconstructPushedDelta(gpa, store, fc.hash, deltasrc, payload.bytes) catch |err| switch (err) {
                    error.OutOfMemory => return error.OutOfMemory,
                    // Not held YET: the basis may be another card of this
                    // round. Park the card; `resolveDeferredDeltas` settles it
                    // once the whole round has been scanned, and a basis that
                    // never arrives becomes a phantom there (§7.4 erratum).
                    error.BasisAbsent => {
                        try scan.deferred_deltas.append(gpa, .{ .hash = fc.hash, .deltasrc = deltasrc, .delta = payload.bytes });
                        return .{ .advance = payload.next }; // §5.1 — resume past the payload
                    },
                    error.MalformedDelta, error.IntegrityMismatch => return .{ .fault = .{ .malformed = .file } },
                };
                errdefer gpa.free(reconstructed); // a failed append must not leak the reconstruction
                try scan.addFile(gpa, .{ .hash = fc.hash, .payload = reconstructed, .owned = reconstructed });
                return .{ .advance = payload.next }; // §5.1 — resume past the payload
            }
            try scan.addFile(gpa, .{ .hash = fc.hash, .payload = payload.bytes });
            return .{ .advance = payload.next }; // §5.1 — resume past the payload
        },
        .clone => {
            // §8.1/§8.2 — record an accepting `clone 3 SEQNO`'s SEQNO as the
            // reply cursor; a VERSION≥2, digit-only SEQNO≤0 is the fatal
            // invalid-sequence error (L1369-1376); any other shape (wrong arity,
            // noninteger VERSION, non-digit SEQNO, out-of-scope VERSION) is a
            // plain §5.2 bad command, per §8.1 L1538-1548's explicit carve-out.
            switch (classifyCloneRequest(cc.tokens)) {
                .accept => |seqno| scan.clone_start = seqno,
                .invalid_seqno => return .{ .fault = .invalid_clone_seqno },
                .malformed => return .{ .fault = .{ .bad_command = line.bytes } },
            }
        },
        .reqconfig => {
            // §9.3/§9.3 r20-r21 — a configuration request. NAME MUST begin
            // with '/' (r20 L2369-2370); a NAME-less or non-slash form is a
            // §5.2 malformed card. zit's anonymous server grants Read (§A.1
            // note 1), so the disposition is `.reply`.
            //
            // §9.3 r24 — the SCALAR marker is the one group zit models: it
            // carries the three project-scope sub-manifest settings
            // (submanifests §6.3/§8.1 and §6.4). Every TABLE group
            // (r28) stays out of scope, so its mask is zero and the server
            // emits NO config cards and NO error (§9.3 L1937-1939) — which is
            // also what keeps a Fossil clone's `reqconfig /all` completing
            // past the §8.4 configuration phase instead of drawing an
            // `error bad command`. (The project code does NOT travel here: it
            // rides the clone reply's `push` card, §8.2/§8.3, and the RFC
            // defines no project-code config setting.)
            const name = parseReqconfigName(cc.tokens) orelse
                return .{ .fault = .{ .bad_command = line.bytes } };
            if (!configNameHasSlashPrefix(name)) return .{ .fault = .{ .bad_command = line.bytes } };
            if (configKind(name) == .scalar) {
                scan.reqconfig_scalar = true;
            } else {
                // r28 — a TABLE group. Unmodelled names resolve to an empty
                // mask, which stays the §9.3 L1937-1939 silent no-op it always
                // was; `/shun` and `/all` now resolve to something.
                scan.reqconfig_groups.setUnion(configGroupMask(name));
            }
        },
        .config => {
            // §9.3 r23/r29/r32 — a pushed configuration card: `config
            // GROUPNAME SIZE`, SIZE content bytes, one mandatory trailing LF.
            // Frame by the byte count, never by scanning. A malformed header, a
            // negative SIZE (r32's own fatal action is to abort the
            // transaction — which reset-and-emit is), or a frame running past
            // the request is `error malformed config line`, and the scan stops
            // before pass-2 ingest, so a bad card stores nothing.
            const header = parseConfigCard(cc.tokens) catch return .{ .fault = .{ .malformed = .config } };
            const p = consumeConfigPayload(request, line.next, header.size) catch
                return .{ .fault = .{ .malformed = .config } };
            // §9.3 r34 — only the scalar form is pushed; a table group is
            // framed and ignored rather than rejected, so a peer that pushes
            // one still gets a served round.
            if (configKind(header.groupname) == .scalar) {
                scan.config_push = p.bytes;
                scan.config_push_admin = hasAdmin(server, scan);
            }
            return .{ .advance = p.next }; // §5.1 — resume past the payload
        },
        // §3.9 — any other defined operator is outside the A0 request
        // vocabulary; strict-accept rejects it and aborts.
        else => return .{ .fault = .{ .bad_command = line.bytes } },
    }
    return .{ .advance = line.next };
}

/// Whether the rights of a signed-in user apply at the scan's current card: the
/// server holds no credential (it takes anyone's push, §A.1 note 1), or a
/// cookie (whose nonce covers the whole body, and which `authFault` verifies
/// before any of this scan is acted on) or a login card verified earlier in
/// the stream established them.
fn signedIn(server: *const Server, scan: *const RequestScan) bool {
    return server.credential == null or scan.cookie_login or scan.login != null;
}

/// §9.3 r31 — the Admin predicate for a pushed config card, at the card.
///
/// `Capability` deliberately models no admin letter ("admin has none"), so
/// there is no capability string to test; the only authority this server
/// establishes is a VERIFIED named login. Admin is therefore exactly "this
/// server holds a credential AND a login card verified before this card".
///
/// The consequence is deliberate and is the safe direction §9.3 r31 asks
/// for: an anonymous server (`credential == null`, which is every `zit
/// serve` today) grants Admin to nobody, so every pushed config card is
/// rejected rather than applied by a stranger.
fn hasAdmin(server: *const Server, scan: *const RequestScan) bool {
    return server.credential != null and scan.login != null;
}

/// §6.4 — whether a login card's signature reconstructs over `signed`, the
/// request bytes after its LF. A server holding no credential has nothing to
/// verify against and accepts any login (§A.1 note 1).
fn loginVerifies(credential: ?Credential, card: LoginCard, signed: []const u8) bool {
    const cred = credential orelse return true;
    if (!std.mem.eql(u8, card.user, cred.user)) return false;
    const nonce = computeNonce(signed);
    const signature = computeSignature(&nonce, &cred.secret_hex);
    return std.mem.eql(u8, &signature, card.signature);
}

/// §6.5 — the cookie-channel authentication verdict. An `x-f-l-c` value the
/// transport carried is a login whose nonce covers the WHOLE request body
/// (there is no login line to exclude); a malformed or mismatched cookie is
/// `.unauthorized`, not ignored — a credential that fails to parse must not
/// fall through to anonymous. Login CARDS, and a card that duplicates the
/// cookie, are verified in the scan at their place in the card stream.
fn authFault(credential: ?Credential, request: []const u8, cookie: ?[]const u8) ?RespondFault {
    const value = cookie orelse return null;
    var user_buf: [max_line_len]u8 = undefined;
    if (value.len >= user_buf.len) return .unauthorized;
    const card = parseCookieLogin(value, &user_buf) orelse return .unauthorized;
    const cred = credential orelse return null;
    if (!std.mem.eql(u8, card.user, cred.user)) return .unauthorized;
    const nonce = computeNonce(request);
    if (!std.mem.eql(u8, &nonce, card.nonce)) return .unauthorized;
    const signature = computeSignature(&nonce, &cred.secret_hex);
    if (!std.mem.eql(u8, &signature, card.signature)) return .unauthorized;
    return null;
}

/// §9.1 r10 L1899-1909 — the peer version a reply-side gate reads: the
/// client-version pragma this request carried, or the server's pre-pragma
/// default of 0 when it carried none. Stated once so the §7.2 gate's two
/// reply paths (`emitReply`, `emitCloneReply`) cannot disagree about what an
/// unannounced peer is; 0 is below every threshold, which is the safe
/// direction and the default §10.1 r10 already fixes.
fn peerVersionOf(scan: *const RequestScan) u32 {
    return if (scan.client_version) |cv| cv.version else 0;
}

/// §3.3 — apply the request's store effects: store each pushed file (§3.3 L574,
/// dephantomizing) and intern each announced hash (idempotent — a held or
/// already-phantom hash is unchanged, an unheld one becomes a phantom the reply
/// will gimme, §3.3 L537-543/§3.2 L406-410).
///
/// §7.7 L1472-1475 — this is the SERVER's receive-side admission point. A push
/// request's `file` cards are the only inbound content a server takes, and
/// server push-receive is always non-clone (§3.9's request vocabulary carries no
/// `cfile` and no clone-side content delivery), so the non-clone discard arm
/// applies unconditionally: no `operation` guard is asked for or written here.
///
/// The DECIDING gate is in `scanCard`'s `file` arm, above the delta
/// reconstruction, because "it emits no error" is unconditional and
/// reconstruction is the only step that can raise one; a shunned card therefore
/// never reaches `scan.files` at all. The test repeated below is the
/// belt-and-braces one on the MUTATION itself: `put` is what §7.7 forbids, and
/// a future scan path that collects a file card without asking must still not
/// land it.
///
/// SO IT IS DELIBERATELY UNTESTABLE, and recorded as such rather than left to
/// read as covered. `server` is const across scan and ingest within one
/// `respond`, and `scanCard` asks the identical predicate over it before
/// appending, so no input `scanRequest` can produce reaches this line with
/// `.reject`. No mutant of it can be killed by the suite; the §7.7 server tests
/// exercise the scan gate. That is the price of a guard whose value is against a
/// scan path that does not exist yet, and it is the right trade here — the
/// failure it guards is landing content under a name a takedown order names.
/// Nothing else about the request changes — no error card is emitted,
/// and the round proceeds to `emitReply` as if the card had not carried content
/// this repository refuses to hold.
///
/// §3.3 r24(1) L537-540 — the `igot` intern is deliberately NOT filtered:
/// phantom creation is unconditional on the shun table. §3.3 L553-556 excludes
/// the shunned phantom at the SCAN, so the reply never begs for it.
///
/// submanifests §7.1 — "Storing a structural artifact that carries S cards whose
/// referenced sub-manifests are absent MUST create a phantom for each missing
/// reference." The rule is about STORING and is side-agnostic, so a pushed tree
/// manifest whose sub-manifests the pusher does not hold phantomizes them here,
/// on the server, exactly as the client's receive path does for a pulled one.
/// Because the phantom lands in the same store the §3.2 walk below reads, the
/// server's own reply then gimmes the missing sub-manifests in the SAME round —
/// which is the whole point: without it, a push leaves the server holding a
/// check-in it can never enumerate and never asks anyone for the rest.
///
/// The two guards compose in one direction only: admission is asked FIRST, so a
/// card §7.7 refuses is never stored and mints no sub-manifest phantoms either.
/// That is the wanted reading — the S references of an artifact this repository
/// declines to hold are not names it should then go begging for.
///
/// `gpa` buys only the extractor's scratch; the scan is fenced by
/// `sBearingCandidate`, so a push of artifacts predating this draft costs one
/// memchr per artifact and allocates nothing.
/// The refusal text for a push the server's policy declines. Assembled
/// here because only this point knows both the path and the rule; the client
/// copies it out of the error card verbatim (`peer_error_cap`), so it has to
/// name what to fix without any further round trip.
fn pushPolicyText(buf: []u8, path: []const u8, rule: []const u8) []const u8 {
    return std.fmt.bufPrint(
        buf,
        "'{s}' violates this server's {s}",
        .{ path, rule },
    ) catch "a pushed file violates this server's content policy";
}

/// Whether any check-in in this push introduces content the server's
/// policy refuses, and if so the text saying which.
///
/// A push sends artifacts BY HASH: a blob does not know its own path, and only
/// a manifest's F cards supply one. So the walk is manifest-first — for each
/// pushed manifest, resolve each F card's blob out of this same push and
/// classify it against the path the card gives it.
///
/// That choice makes the policy gate what becomes REACHABLE rather than what
/// bytes transit, which is the honest line for a content-addressed store: a
/// blob no retained manifest names is not part of history. A blob whose
/// manifest is not in this push therefore goes unclassified — it is also
/// unreferenced, and `purge`/`shun` reclaim it. Refusing the round is what
/// stops the check-in that would have made it reachable.
///
/// Bounded by the push itself: no store reads, no allocation beyond the
/// manifest parse the walk already needs.
fn pushPolicyFault(
    gpa: std.mem.Allocator,
    policy: PushPolicy,
    scan: *const RequestScan,
    buf: []u8,
) Server.RespondError!?[]const u8 {
    if (!policy.declared()) return null;

    for (scan.files.items) |candidate| {
        // Only a manifest carries paths. A blob that fails to parse as one is
        // simply not a manifest — never an error here, since this walk is a
        // policy question and admission is decided elsewhere.
        var loaded = artifact.parseForImport(gpa, candidate.payload) catch continue;
        defer loaded.deinit(gpa);
        if (loaded.manifest.kind != .manifest) continue;

        for (loaded.manifest.f) |fc| {
            const hash = fc.hash orelse continue; // a delete marker names no blob
            for (scan.files.items) |blob| {
                if (!std.mem.eql(u8, blob.hash, hash)) continue;
                if (policy.violation(fc.filename, blob.payload)) |rule| {
                    return pushPolicyText(buf, fc.filename, rule);
                }
                break;
            }
        }
    }
    return null;
}

/// What the protected-branch rule decided about one push.
const ProtectVerdict = union(enum) {
    admit,
    /// Refuse the whole round with this text.
    refuse: []const u8,
    /// Admit the round together with a move of the check-ins that would have
    /// joined a protected branch onto attempt branches.
    divert: DivertPlan,
};

/// The control artifact a divert adds to the round, and what it moved. Owns
/// its memory; `deinit` frees it.
const DivertPlan = struct {
    arena: std.heap.ArenaAllocator,
    name: []const u8,
    bytes: []const u8,
    /// One per run the push brought: its first check-in, the attempt branch it
    /// now starts, and the protected branch it would have joined.
    moves: []const DivertMove,

    fn deinit(self: *DivertPlan) void {
        self.arena.deinit();
        self.* = undefined;
    }
};

pub const DivertMove = struct {
    hash: []const u8,
    branch: []const u8,
    protected: []const u8,
};

/// Whether this push changes which check-ins are on a branch
/// `push_policy.protect_globs` names, and what to do about it.
///
/// Two kinds of artifact are judged, by two rules.
///
/// A tag aimed at a check-in this server HOLDS is judged by what it changes:
/// moving that check-in onto, off, or between protected branches, or closing
/// or reopening one, is refused. Nothing in a push can change a held
/// check-in's ancestry, so its tags are the whole question.
///
/// Every check-in this push BRINGS is judged by where it ends up, and that is
/// the derive's question, not this rule's: a fresh `RepoState` is seeded with
/// the held check-ins it builds on and handed the push, and the check-in's
/// branch is read off it (`collectJoins`). A move in the same push counts, so
/// a refused check-in amended onto another branch is admitted, but only when
/// the derive itself would put it there. A tag aimed at a check-in in neither
/// place is judged when that check-in arrives; until then only a claim on a
/// protected name, or a cancel that would drop it onto a protected main
/// branch, is refused.
///
/// A check-in that would join a protected branch through its `branch` row is
/// refused, unless the front allows a divert (`Server.divert`) and the pusher
/// has already been refused for it: then the round is admitted with a move of
/// that check-in onto an attempt branch (`planDivert`).
///
/// A resolver that cannot answer is a read fault, and the rule fails CLOSED.
fn pushProtectVerdict(
    gpa: std.mem.Allocator,
    server: *const Server,
    scan: *const RequestScan,
    buf: []u8,
) Server.RespondError!ProtectVerdict {
    const globs = server.push_policy.protect_globs;
    if (globs.len == 0) return .admit;
    const resolver = server.branch_of orelse return .{ .refuse = resolver_refusal };

    var round: std.ArrayListUnmanaged(RoundArtifact) = .empty;
    defer {
        for (round.items) |*r| r.im.deinit(gpa);
        round.deinit(gpa);
    }
    for (scan.files.items) |candidate| {
        // A blob that fails to parse is content, never a fault here: admission
        // is decided elsewhere and this walk is a policy question.
        const im = artifact.parseForImport(gpa, candidate.payload) catch continue;
        try round.append(gpa, .{ .hash = candidate.hash, .im = im });
    }
    if (round.items.len == 0) return .admit;
    const main = resolver.mainBranch() catch return .{ .refuse = resolver_refusal };
    var index: RoundIndex = .empty;
    defer index.deinit(gpa);
    for (round.items, 0..) |r, i| try index.put(gpa, r.hash, i);

    for (round.items) |r| {
        for (r.im.manifest.t) |t| {
            // A check-in's own tags, and tags aimed at a check-in in this
            // push, are judged where that check-in lands, below.
            const target = t.target orelse continue;
            if (index.contains(target)) continue;
            const current = resolver.branchOf(target) catch return .{ .refuse = resolver_refusal };
            if (current) |on| {
                if (heldTagFault(t, on, main, globs)) |branch| {
                    return .{ .refuse = std.fmt.bufPrint(
                        buf,
                        "tag {c}{s} on {s} would change membership of protected branch '{s}'",
                        .{ t.sign.byte(), t.name, abbrevHash(target), branch },
                    ) catch "a pushed tag would change membership of a protected branch" };
                }
                // Lifecycle, not membership: `closed` on a held check-in of a
                // protected branch shuts that branch for its owners (their next
                // commit is refused against a closed leaf), and `-closed`
                // reopens it. A membership rule that admitted this covered half
                // the journey: the cheapest way past a protected branch was to
                // close it rather than push to it.
                if (std.mem.eql(u8, t.name, "closed") and ignore_mod.listMatches(globs, on)) {
                    return .{ .refuse = std.fmt.bufPrint(
                        buf,
                        "tag {c}closed on {s} would {s} protected branch '{s}'",
                        .{ t.sign.byte(), abbrevHash(target), if (t.sign == .cancel) "reopen" else "close", on },
                    ) catch "a pushed tag would close a protected branch" };
                }
                continue;
            }
            // Neither held nor here. A cancel removes the target from nothing
            // held and is judged on the target's arrival: this is the way back
            // from a refused push, the pusher amending the check-in elsewhere.
            // A claim on a protected name is refused now, since nothing would
            // judge it again, and so is a cancel of `branch` that would drop
            // the target onto a protected main branch.
            const branch: ?[]const u8 = if (t.sign == .cancel)
                (if (std.mem.eql(u8, t.name, "branch") and ignore_mod.listMatches(globs, main)) main else null)
            else
                protectedBranchOfTag(t, globs);
            if (branch) |b| {
                return .{ .refuse = std.fmt.bufPrint(
                    buf,
                    "tag {c}{s} on {s} would change membership of protected branch '{s}'",
                    .{ t.sign.byte(), t.name, abbrevHash(target), b },
                ) catch "a pushed tag would change membership of a protected branch" };
            }
        }
    }

    if (try retagTieFault(gpa, round.items, buf)) |text| return .{ .refuse = text };

    var keep = std.heap.ArenaAllocator.init(gpa);
    defer keep.deinit();
    var joins: std.ArrayListUnmanaged(Join) = .empty;
    for ([_]RoundOrder{ .controls_first, .checkins_first }) |order| {
        collectJoins(gpa, keep.allocator(), round.items, &index, order, resolver, main, globs, &joins) catch |e| switch (e) {
            error.OutOfMemory => return error.OutOfMemory,
            error.Unavailable => return .{ .refuse = resolver_refusal },
        };
    }
    if (joins.items.len == 0) return .admit;
    // Only a join by `branch` row is diverted. A missing parent is fixed by
    // pushing it, and a taken name by cancelling it on a check-in that keeps
    // its own branch: moving either somewhere else would answer a different
    // question than the pusher asked.
    for (joins.items) |j| if (j.kind != .branch) return .{ .refuse = joinText(buf, j, null) };
    const dv = server.divert orelse return .{ .refuse = joinText(buf, joins.items[0], null) };
    return planDivert(gpa, dv, round.items, &index, joins.items, resolver, main, globs, buf) catch |e| switch (e) {
        error.OutOfMemory => return error.OutOfMemory,
        error.Unavailable => return .{ .refuse = resolver_refusal },
    };
}

/// One parsed artifact of the push being judged.
const RoundArtifact = struct {
    hash: []const u8,
    im: artifact.ImportedManifest,
};

/// A push's artifacts by hash: built once, so judging a round costs its
/// length rather than its square.
const RoundIndex = std.StringHashMapUnmanaged(usize);

/// One check-in the push brings that lands on a protected branch.
const Join = struct {
    hash: []const u8,
    /// The protected branch it lands on.
    branch: []const u8,
    kind: enum {
        /// Its `branch` row names a protected branch (repo-state §6.1).
        branch,
        /// An active `sym-B` would resolve the protected name B to it (§8.2).
        name,
        /// It has no `branch` row because its primary line ends at `parent`,
        /// which the server does not hold, so it falls to a protected main
        /// branch until that parent arrives.
        missing_parent,
    },
    parent: []const u8 = "",
    /// Its active `sym-` rows, each of which a move must cancel.
    syms: []const []const u8 = &.{},
};

/// The refusal for `j`. With `attempt`, the push is one the front allows a
/// divert for, and the text says what the next push will do. The amend clause
/// is the first thing dropped when the text would not fit `peer_error_cap`.
fn joinText(buf: []u8, j: Join, attempt: ?[]const u8) []const u8 {
    const h = abbrevHash(j.hash);
    if (j.kind == .missing_parent) {
        return std.fmt.bufPrint(
            buf,
            "check-in {s} builds on {s}, which this server does not have, so it would join protected branch '{s}'; push {s} first",
            .{ h, abbrevHash(j.parent), j.branch, abbrevHash(j.parent) },
        ) catch "a pushed check-in builds on one this server does not have and would join a protected branch";
    }
    const what = if (j.kind == .name) "would take the name of" else "would join";
    if (attempt) |a| {
        return std.fmt.bufPrint(
            buf,
            "check-in {s} {s} protected branch '{s}'. Your next push, including the autosync after your next commit, keeps it on branch {s}; or move it yourself with `amend {s} --branch NAME`.",
            .{ h, what, j.branch, a, h },
        ) catch std.fmt.bufPrint(
            buf,
            "check-in {s} {s} protected branch '{s}'. Your next push, including the autosync after your next commit, keeps it on branch {s}.",
            .{ h, what, j.branch, a },
        ) catch "a pushed check-in would join a protected branch; push again to keep it on an attempt branch";
    }
    return std.fmt.bufPrint(
        buf,
        "check-in {s} {s} protected branch '{s}'; move it with `amend {s} --branch NAME` and push again",
        .{ h, what, j.branch, h },
    ) catch std.fmt.bufPrint(
        buf,
        "check-in {s} {s} protected branch '{s}'",
        .{ h, what, j.branch },
    ) catch "a pushed check-in would join a protected branch";
}

/// The front's divert directive for one request, set by the embedder from
/// `X-Zit-Divert-Login`. Only a front that authenticated the pusher can send
/// it, so `login` is an identity, never a name the client wrote.
pub const Divert = struct {
    login: []const u8,
    /// Now, in Unix milliseconds. The move is dated no earlier.
    now_ms: u64,
    ledger: DivertLedger,
};

/// The served repository's memory of whom it refused for which check-in,
/// supplied by the embedder (this layer does no I/O). A divert needs the
/// pusher to have been told first: `refused` answers that for this request's
/// login, `record` notes a refusal now being sent, and `diverted` reports a
/// move the reply is about to carry.
pub const DivertLedger = struct {
    ctx: *anyopaque,
    refusedFn: *const fn (ctx: *anyopaque, hash: []const u8) bool,
    recordFn: *const fn (ctx: *anyopaque, hash: []const u8) error{OutOfMemory}!void,
    divertedFn: *const fn (ctx: *anyopaque, move: DivertMove) error{OutOfMemory}!void,

    pub fn refused(self: DivertLedger, hash: []const u8) bool {
        return self.refusedFn(self.ctx, hash);
    }
    pub fn record(self: DivertLedger, hash: []const u8) error{OutOfMemory}!void {
        return self.recordFn(self.ctx, hash);
    }
    pub fn diverted(self: DivertLedger, move: DivertMove) error{OutOfMemory}!void {
        return self.divertedFn(self.ctx, move);
    }
};

/// Who the move is recorded as. The pusher did not write it; the server did.
pub const divert_user = "zit-serve";
/// The pragma a diverted round's reply carries, one per move, naming the
/// check-in and its attempt branch. Fossil ignores pragmas it does not know;
/// zit's client pulls the move on seeing it.
pub const divert_pragma = "zit-diverted";

/// `attempt/LOGIN/HASH12`. The login is reduced to what a branch name can
/// carry: anything outside letters, digits and `._@+-` becomes `-`, and it is
/// capped, so a hostile or odd account name cannot shape the tag.
fn attemptBranch(arena: std.mem.Allocator, login: []const u8, root: []const u8) error{OutOfMemory}![]const u8 {
    const cap = 32;
    var clean: [cap]u8 = undefined;
    const n = @min(login.len, cap);
    for (login[0..n], clean[0..n]) |c, *o| {
        o.* = if (std.ascii.isAlphanumeric(c) or std.mem.indexOfScalar(u8, "._@+-", c) != null) c else '-';
    }
    const who: []const u8 = if (n == 0) "anonymous" else clean[0..n];
    return std.fmt.allocPrint(arena, "attempt/{s}/{s}", .{ who, abbrevHash(root) });
}

/// Admit a refused push the pusher has pushed again: one control artifact
/// moving every check-in in `joins` onto the attempt branch of its run, the
/// same tag set `amend --branch` writes. A run's first check-in is one whose
/// primary parent is not itself joining; the rest move with it, each named
/// explicitly so one that names the protected branch itself moves too.
///
/// The pusher must have been refused for every run first. Any run they were
/// not is recorded now and the push refused with the text that promises the
/// divert, so nothing lands on an attempt branch they were not told about.
///
/// The move is dated after every date in the push, every held row it builds
/// on, and now: tags resolve newest-wins and a tie loses (repo-state §5.3), so
/// a plain "now" would lose to a push dated ahead of the server. It is then
/// judged like any other artifact in the push; if anything still joins, the
/// push is refused.
fn planDivert(
    gpa: std.mem.Allocator,
    dv: Divert,
    round: []const RoundArtifact,
    index: *const RoundIndex,
    joins: []const Join,
    resolver: BranchResolver,
    main: []const u8,
    globs: []const u8,
    buf: []u8,
) error{ OutOfMemory, Unavailable }!ProtectVerdict {
    var plan_arena = std.heap.ArenaAllocator.init(gpa);
    errdefer plan_arena.deinit();
    const arena = plan_arena.allocator();

    var joining: std.StringHashMapUnmanaged(void) = .empty;
    for (joins) |j| try joining.put(arena, j.hash, {});
    const runRoot = struct {
        fn of(r: []const RoundArtifact, ix: *const RoundIndex, set: *const std.StringHashMapUnmanaged(void), hash: []const u8) []const u8 {
            var at = hash;
            var steps: usize = 0;
            while (steps <= r.len) : (steps += 1) {
                const i = ix.get(at) orelse return at;
                const p = primaryParent(&r[i].im.manifest) orelse return at;
                if (!set.contains(p)) return at;
                at = p;
            }
            return at;
        }
    }.of;

    var moves: std.ArrayListUnmanaged(DivertMove) = .empty;
    var unwarned: ?Join = null;
    for (joins) |j| {
        if (!std.mem.eql(u8, runRoot(round, index, &joining, j.hash), j.hash)) continue;
        const nb = try attemptBranch(arena, dv.login, j.hash);
        // Copied: `j` borrows the request and the caller's scratch, and the
        // moves outlive both.
        try moves.append(arena, .{ .hash = try arena.dupe(u8, j.hash), .branch = nb, .protected = try arena.dupe(u8, j.branch) });
        if (!dv.ledger.refused(j.hash)) {
            try dv.ledger.record(j.hash);
            if (unwarned == null) unwarned = j;
        }
    }
    if (unwarned) |j| {
        const nb = try attemptBranch(arena, dv.login, j.hash);
        const text = joinText(buf, j, nb);
        plan_arena.deinit();
        return .{ .refuse = text };
    }

    var latest = dv.now_ms;
    for (round) |r| {
        const d = r.im.manifest.d orelse continue;
        if (repostate.parseMtime(d.timestamp)) |m| latest = @max(latest, m);
        if (r.im.manifest.kind != .manifest) continue;
        const parent = primaryParent(&r.im.manifest) orelse continue;
        if (index.contains(parent)) continue;
        const rows = (try resolver.tagsOf(arena, parent)) orelse continue;
        for (rows) |row| latest = @max(latest, row.mtime);
    }
    // A push dated at the end of year 9999 leaves no later date to write
    // (artifact §4.6.4 has four year digits): nothing can outdate it, so it
    // cannot be moved, only refused.
    var date_buf: [23]u8 = undefined;
    const date = try arena.dupe(u8, formatMsTimestamp(latest +| 1, &date_buf) orelse {
        const text = joinText(buf, joins[0], null);
        plan_arena.deinit();
        return .{ .refuse = text };
    });

    var cards: std.ArrayListUnmanaged(artifact.TCard) = .empty;
    for (joins) |j| {
        const nb = try attemptBranch(arena, dv.login, runRoot(round, index, &joining, j.hash));
        const new_sym = try std.mem.concat(arena, u8, &.{ artifact.sym_tag_prefix, nb });
        try cards.append(arena, .{ .sign = .propagate, .name = "branch", .target = j.hash, .value = nb });
        try cards.append(arena, .{ .sign = .propagate, .name = new_sym, .target = j.hash, .value = null });
        for (j.syms) |sym| {
            if (std.mem.eql(u8, sym, new_sym)) continue;
            try cards.append(arena, .{ .sign = .cancel, .name = sym, .target = j.hash, .value = null });
        }
    }
    // §4.7.16: by the signed name, then by target.
    std.mem.sort(artifact.TCard, cards.items, {}, struct {
        fn less(_: void, a: artifact.TCard, b: artifact.TCard) bool {
            if (artifact.tagTokenLess({}, a, b)) return true;
            if (artifact.tagTokenLess({}, b, a)) return false;
            return std.mem.order(u8, a.target.?, b.target.?) == .lt;
        }
    }.less);

    var m: artifact.Manifest = .{
        .arena_state = std.heap.ArenaAllocator.init(gpa),
        .kind = .control,
        .a = null,
        .b = null,
        .c = null,
        .d = .{ .timestamp = date },
        .e = null,
        .g = null,
        .h = null,
        .i = null,
        .k = null,
        .l = null,
        .n = null,
        .r = null,
        .u = .{ .login = divert_user },
        .z = null,
        .w = null,
        .f = &.{},
        .j = &.{},
        .m = &.{},
        .p = &.{},
        .q = &.{},
        .t = cards.items,
    };
    defer m.deinit();
    const bytes = artifact.create(&m, arena) catch |e| switch (e) {
        error.OutOfMemory => return error.OutOfMemory,
        else => unreachable, // every card above is well-formed by construction
    };
    var name_buf: [64]u8 = undefined;
    const name = try arena.dupe(u8, artifact.artifactName(.sha3_256, bytes, &name_buf));

    // Judge the push with the move in it, exactly as any push is judged.
    var move_im = artifact.parseForImport(gpa, bytes) catch unreachable; // built above, so it parses
    defer move_im.deinit(gpa);
    const with = try arena.alloc(RoundArtifact, round.len + 1);
    @memcpy(with[0..round.len], round);
    with[round.len] = .{ .hash = name, .im = move_im };
    var with_index: RoundIndex = .empty;
    defer with_index.deinit(gpa);
    for (with, 0..) |r, i| try with_index.put(gpa, r.hash, i);
    var keep = std.heap.ArenaAllocator.init(gpa);
    defer keep.deinit();
    var still: std.ArrayListUnmanaged(Join) = .empty;
    for ([_]RoundOrder{ .controls_first, .checkins_first }) |order| {
        try collectJoins(gpa, keep.allocator(), with, &with_index, order, resolver, main, globs, &still);
    }
    if (still.items.len != 0) {
        const text = joinText(buf, still.items[0], null);
        plan_arena.deinit();
        return .{ .refuse = text };
    }
    return .{ .divert = .{ .arena = plan_arena, .name = name, .bytes = bytes, .moves = moves.items } };
}

/// `YYYY-MM-DDTHH:MM:SS.SSS` for Unix milliseconds: the D-card writer form
/// with milliseconds (artifact §4.6.4). Null past year 9999, which the form
/// cannot write.
fn formatMsTimestamp(ms: u64, out: *[23]u8) ?[]const u8 {
    const secs = std.time.epoch.EpochSeconds{ .secs = ms / 1000 };
    const day = secs.getEpochDay();
    const yd = day.calculateYearDay();
    const md = yd.calculateMonthDay();
    const ds = secs.getDaySeconds();
    return std.fmt.bufPrint(out, "{d:0>4}-{d:0>2}-{d:0>2}T{d:0>2}:{d:0>2}:{d:0>2}.{d:0>3}", .{
        yd.year,
        md.month.numeric(),
        md.day_index + 1,
        ds.getHoursIntoDay(),
        ds.getMinutesIntoHour(),
        ds.getSecondsIntoMinute(),
        ms % 1000,
    }) catch null;
}

/// The protected branch a tag aimed at a held check-in on `on` would change,
/// or null. `branch` is the check-in's membership itself: any change to it on
/// a protected check-in moves it, a new value moves it onto that branch, and a
/// cancel drops it onto the main branch (repo-state §6.4). `sym-B` names B
/// under any sign.
fn heldTagFault(t: artifact.TCard, on: []const u8, main: []const u8, globs: []const u8) ?[]const u8 {
    if (std.mem.eql(u8, t.name, "branch")) {
        if (ignore_mod.listMatches(globs, on)) return on;
        if (t.sign == .cancel) return if (ignore_mod.listMatches(globs, main)) main else null;
    }
    return protectedBranchOfTag(t, globs);
}

/// The protected branch a tag card names, or null. `*branch=B` names B by
/// value; `sym-B` names it by name, under any sign — `+` and `*` join, `-`
/// leaves, and both are membership changes.
fn protectedBranchOfTag(t: artifact.TCard, globs: []const u8) ?[]const u8 {
    const branch: []const u8 = if (std.mem.eql(u8, t.name, "branch"))
        t.value orelse return null
    else if (std.mem.startsWith(u8, t.name, artifact.sym_tag_prefix))
        t.name[artifact.sym_tag_prefix.len..]
    else
        return null;
    if (branch.len == 0) return null;
    return if (ignore_mod.listMatches(globs, branch)) branch else null;
}

/// The one refusal a resolver fault produces. Fail closed: a
/// protection that admits under a read fault is not one.
const resolver_refusal = "this server cannot resolve branches, so a push against its protected branches is refused";

/// Which of a push's artifacts the derive meets first. The server's log keeps
/// them in an order the push does not fix, and fossil's tag rules are not
/// order-free: a move that reaches a check-in before it is linked is a direct
/// row nothing inherited can displace, while one that arrives after must be
/// newer than the row the check-in inherited. The two extremes bound every
/// interleaving, so the rule judges both and refuses if either joins.
const RoundOrder = enum { controls_first, checkins_first };

/// Two control artifacts in one push that set the same tag on the same
/// check-in at the same time, differently. The derive keeps whichever it meets
/// first, so the push's order, not its content, would choose the branch.
fn retagTieFault(gpa: std.mem.Allocator, round: []const RoundArtifact, buf: []u8) error{OutOfMemory}!?[]const u8 {
    var arena_state = std.heap.ArenaAllocator.init(gpa);
    defer arena_state.deinit();
    const arena = arena_state.allocator();
    // (target, name, time) -> the first effect seen for it.
    var seen: std.StringHashMapUnmanaged(artifact.TCard) = .empty;
    for (round) |r| {
        if (r.im.manifest.kind != .control) continue;
        const d = r.im.manifest.d orelse continue;
        for (r.im.manifest.t) |t| {
            const target = t.target orelse continue;
            const key = try std.fmt.allocPrint(arena, "{s}\x00{s}\x00{s}", .{ target, t.name, d.timestamp });
            const gop = try seen.getOrPut(arena, key);
            if (!gop.found_existing) {
                gop.value_ptr.* = t;
                continue;
            }
            if (gop.value_ptr.sign == t.sign and optionalEql(gop.value_ptr.value, t.value)) continue;
            return std.fmt.bufPrint(
                buf,
                "two tags '{s}' on {s} carry the same time, so their order would decide its branch",
                .{ t.name, abbrevHash(target) },
            ) catch "two pushed tags on one check-in carry the same time";
        }
    }
    return null;
}

fn optionalEql(a: ?[]const u8, b: ?[]const u8) bool {
    if (a == null or b == null) return a == null and b == null;
    return std.mem.eql(u8, a.?, b.?);
}

/// Derive this push the way the server's own derive will, in `order`, over a
/// fresh state seeded with every held check-in it builds on, and add to
/// `joins` each check-in it brings that lands on a protected branch (once).
///
/// Three ways land there. Its `branch` row names the branch (repo-state §6.1).
/// It has no `branch` row because its primary line ends at a parent this
/// server does not hold, so it falls to the main branch until that parent
/// arrives; that counts when the main branch is protected. Or an active
/// `sym-B` would resolve the protected name B to it (§8.2): counted when the
/// push claims that name, or when the check-in leaves B without cancelling it.
/// A `sym-B` inherited further along a branch that already carries it is the
/// held history's, and counting it would shut every push onto that branch.
///
/// A held check-in is seeded rather than linked: its active rows go in at
/// their own mtimes, which is all its children inherit (§5.4). Its cancel rows
/// are not seeded; they matter only to a tag aimed at the held check-in
/// itself, and every such tag was judged before this runs.
///
/// Names in `joins` borrow `round` or live in `keep`.
fn collectJoins(
    gpa: std.mem.Allocator,
    keep: std.mem.Allocator,
    round: []const RoundArtifact,
    index: *const RoundIndex,
    order: RoundOrder,
    resolver: BranchResolver,
    main: []const u8,
    globs: []const u8,
    joins: *std.ArrayListUnmanaged(Join),
) error{ OutOfMemory, Unavailable }!void {
    var arena_state = std.heap.ArenaAllocator.init(gpa);
    defer arena_state.deinit();
    const arena = arena_state.allocator();
    var ms = repostate.MemStore.init(gpa);
    defer ms.deinit();
    var rs = repostate.RepoState.init(gpa, ms.store());
    defer rs.deinit();
    rs.main_branch = main;

    var held: std.StringHashMapUnmanaged(void) = .empty;
    for (round) |r| {
        if (r.im.manifest.kind != .manifest) continue;
        if (try seedHeld(arena, &rs, &held, resolver, r.hash)) continue;
        const parent = primaryParent(&r.im.manifest) orelse continue;
        if (index.contains(parent)) continue;
        _ = try seedHeld(arena, &rs, &held, resolver, parent);
    }
    var claimed: std.StringHashMapUnmanaged(void) = .empty;
    for (round) |r| for (r.im.manifest.t) |t| {
        if (t.sign != .cancel) try claimed.put(arena, t.name, {});
    };
    var missing: MissingMemo = .empty;

    const passes: [2]artifact.ArtifactType = switch (order) {
        .controls_first => .{ .control, .manifest },
        .checkins_first => .{ .manifest, .control },
    };
    for (passes) |kind| {
        for (round) |r| {
            const m = &r.im.manifest;
            if (m.kind != kind or held.contains(r.hash)) continue;
            const d = m.d orelse continue;
            const mtime = repostate.parseMtime(d.timestamp) orelse continue;
            const tags = try arena.alloc(repostate.TagOp, m.t.len);
            for (m.t, tags) |tc, *op| op.* = .{
                .type = switch (tc.sign) {
                    .add => .singleton,
                    .cancel => .cancel,
                    .propagate => .propagate,
                },
                .name = tc.name,
                .target = tc.target,
                .value = tc.value,
            };
            if (kind == .control) {
                _ = try rs.ingestControlTags(r.hash, mtime, tags);
                continue;
            }
            var parents: std.ArrayListUnmanaged([]const u8) = .empty;
            for (m.p) |pc| {
                var it = pc.hashes();
                while (it.next()) |h| try parents.append(arena, h);
            }
            _ = try rs.crosslink(.{ .hash = r.hash, .mtime = mtime, .parents = parents.items, .tags = tags });
        }
    }

    next: for (round) |r| {
        if (r.im.manifest.kind != .manifest or held.contains(r.hash)) continue;
        for (joins.items) |j| if (std.mem.eql(u8, j.hash, r.hash)) continue :next;
        const rid = ms.store().lookup(r.hash) orelse continue;
        if (!rs.hasEvent(rid)) continue;
        const rows = try rs.tagsOf(arena, rid);
        var syms: std.ArrayListUnmanaged([]const u8) = .empty;
        for (rows) |row| {
            if (std.mem.startsWith(u8, row.name, artifact.sym_tag_prefix)) try syms.append(keep, try keep.dupe(u8, row.name));
        }
        const branch = branchOfRows(rows, main);
        if (!branch.tagged) {
            if (try missingAncestor(arena, &missing, round, index, &held, r.hash)) |parent| {
                if (ignore_mod.listMatches(globs, main)) {
                    try joins.append(keep, .{ .hash = r.hash, .branch = main, .kind = .missing_parent, .parent = parent });
                }
                continue;
            }
        }
        if (ignore_mod.listMatches(globs, branch.name)) {
            try joins.append(keep, .{ .hash = r.hash, .branch = try keep.dupe(u8, branch.name), .kind = .branch, .syms = syms.items });
            continue;
        }
        const parent_branch: ?[]const u8 = if (primaryParent(&r.im.manifest)) |p| blk: {
            const prid = ms.store().lookup(p) orelse break :blk null;
            if (!rs.hasEvent(prid)) break :blk null;
            break :blk branchOfRows(try rs.tagsOf(arena, prid), main).name;
        } else null;
        for (rows) |row| {
            if (!std.mem.startsWith(u8, row.name, artifact.sym_tag_prefix)) continue;
            const name = row.name[artifact.sym_tag_prefix.len..];
            if (name.len == 0 or !ignore_mod.listMatches(globs, name)) continue;
            const leaves_it = if (parent_branch) |pb| std.mem.eql(u8, pb, name) else false;
            if (!claimed.contains(row.name) and !leaves_it) continue;
            try joins.append(keep, .{ .hash = r.hash, .branch = try keep.dupe(u8, name), .kind = .name, .syms = syms.items });
            break;
        }
    }
}

/// A check-in's branch from its active rows (repo-state §6.1), by hand rather
/// than `branchOf`: a pushed `branch` row may carry an empty value, which the
/// derive's reader asserts against, and the main-branch fallback is the same.
/// `tagged` says whether a `branch` row exists at all.
fn branchOfRows(rows: []const repostate.RepoState.TagEntry, main: []const u8) struct { name: []const u8, tagged: bool } {
    for (rows) |row| {
        if (!std.mem.eql(u8, row.name, "branch")) continue;
        const v = row.value orelse "";
        return .{ .name = if (v.len != 0) v else main, .tagged = true };
    }
    return .{ .name = main, .tagged = false };
}

/// Per check-in in the push: the unheld parent its primary line ends at, or
/// null when it ends at a root or a held check-in.
const MissingMemo = std.StringHashMapUnmanaged(?[]const u8);

/// The parent this server does not hold that `hash`'s primary line, followed
/// through the push, ends at; null when it ends at a root or a held check-in.
/// Every check-in walked gets the same answer, so the push is walked once in
/// total. A cycle is not a DAG and ends nowhere.
fn missingAncestor(
    arena: std.mem.Allocator,
    memo: *MissingMemo,
    round: []const RoundArtifact,
    index: *const RoundIndex,
    held: *const std.StringHashMapUnmanaged(void),
    hash: []const u8,
) error{OutOfMemory}!?[]const u8 {
    var walked: std.ArrayListUnmanaged([]const u8) = .empty;
    var at = hash;
    const answer: ?[]const u8 = while (walked.items.len <= round.len) {
        if (memo.get(at)) |known| break known;
        const i = index.get(at) orelse break (if (held.contains(at)) null else at);
        if (round[i].im.manifest.kind != .manifest) break null;
        try walked.append(arena, at);
        const parent = primaryParent(&round[i].im.manifest) orelse break null;
        if (held.contains(parent)) break null;
        at = parent;
    } else null;
    for (walked.items) |h| try memo.put(arena, h, answer);
    return answer;
}

fn primaryParent(m: *const artifact.Manifest) ?[]const u8 {
    if (m.p.len == 0) return null;
    var it = m.p[0].hashes();
    return it.next();
}

/// Seed `hash` into `rs` when the server holds it as a check-in: linked with
/// no parents, then given its active rows, each at its own mtime. Returns
/// whether it is held.
fn seedHeld(
    arena: std.mem.Allocator,
    rs: *repostate.RepoState,
    held: *std.StringHashMapUnmanaged(void),
    resolver: BranchResolver,
    hash: []const u8,
) error{ Unavailable, OutOfMemory }!bool {
    if (held.contains(hash)) return true;
    const rows = (try resolver.tagsOf(arena, hash)) orelse return false;
    try held.put(arena, hash, {});
    _ = try rs.crosslink(.{ .hash = hash, .mtime = 0 });
    for (rows, 0..) |row, i| {
        // Each row is its own carrier so it keeps its own mtime. A carrier is
        // interned only to name the row's source; it can never collide with
        // an artifact, whose names are hex.
        const carrier = try std.fmt.allocPrint(arena, "seed:{s}:{d}", .{ hash, i });
        _ = try rs.ingestControlTags(carrier, row.mtime, &.{.{ .type = row.tagtype, .name = row.name, .target = hash, .value = row.value }});
    }
    return true;
}

/// Tell the pusher where each diverted run went: a `message` card a person
/// reads (stock fossil prints it as "Server says: ..."), and a `pragma
/// zit-diverted HASH BRANCH` that zit's client acts on.
fn emitDivert(gpa: std.mem.Allocator, out: *std.ArrayListUnmanaged(u8), line_buf: []u8, plan: DivertPlan) Server.RespondError!void {
    for (plan.moves) |mv| {
        var text_buf: [peer_error_cap]u8 = undefined;
        const text = std.fmt.bufPrint(
            &text_buf,
            "check-in {s} would have joined protected branch '{s}'; it is kept on branch {s}. Pull to see it there.",
            .{ abbrevHash(mv.hash), mv.protected, mv.branch },
        ) catch std.fmt.bufPrint(&text_buf, "a pushed check-in is kept on branch {s}. Pull to see it there.", .{mv.branch}) catch
            "a pushed check-in is kept on an attempt branch. Pull to see it there.";
        try appendCard(gpa, out, writeSignalCard(.message, text, line_buf));
        const pragma = std.fmt.bufPrint(line_buf, "pragma " ++ divert_pragma ++ " {s} {s}", .{ mv.hash, mv.branch }) catch return error.ResponseTooLarge;
        try appendCard(gpa, out, pragma);
    }
}

/// A hash shortened for an error card a person reads.
fn abbrevHash(hash: []const u8) []const u8 {
    return hash[0..@min(hash.len, 12)];
}

/// Store the clusters `clusterindex.mintClusters` folds the
/// unclustered holdings into, named by their own bytes like any artifact.
fn mintClusters(gpa: std.mem.Allocator, store: repostate.ArtifactStore, clustered: *const std.DynamicBitSetUnmanaged) error{OutOfMemory}!void {
    var bodies: std.ArrayListUnmanaged([]u8) = .empty;
    defer {
        for (bodies.items) |b| gpa.free(b);
        bodies.deinit(gpa);
    }
    try clusterindex.mintClusters(gpa, store, clustered, &bodies);
    for (bodies.items) |body| {
        var name: [64]u8 = undefined;
        _ = artifact.artifactName(.sha3_256, body, &name);
        _ = try store.put(&name, body);
    }
}

fn ingest(
    server: *const Server,
    gpa: std.mem.Allocator,
    store: repostate.ArtifactStore,
    scan: *const RequestScan,
    writes: bool,
) Server.RespondError!void {
    // The caller refused content from a request that may not write, so
    // anything left to store here came with an authorized push.
    std.debug.assert(writes or scan.files.items.len == 0);
    for (scan.files.items) |f| {
        if (admitInbound(server.hash_policy, server.shun, f.hash) == .reject) continue;
        _ = try store.put(f.hash, f.payload);
        var refs: std.ArrayListUnmanaged([]const u8) = .empty;
        defer refs.deinit(gpa);
        // Validate-then-apply inside the extractor: a malformed card run mints
        // nothing rather than a half-minted phantom set.
        if (!try repostate.collectSubManifestRefs(gpa, f.payload, &refs)) continue;
        for (refs.items) |hash| _ = try store.intern(hash);
        // A received cluster's M cards are announcements (§3.3):
        // intern each unheld member so the reply begs for it. Fossil does the
        // same in the cluster's crosslink (`uuid_to_rid(…, 1)`); without it a
        // peer that announces only its unclustered tail never converges here.
        refs.clearRetainingCapacity();
        if (!try clusterindex.collectClusterMembers(gpa, f.payload, &refs)) continue;
        for (refs.items) |hash| _ = try store.intern(hash);
    }
    // Fossil makes a phantom of an igot only while pushing: a client that
    // cannot write has nothing for this server to ask for.
    std.debug.assert(writes or scan.offers.items.len == 0);
    for (scan.offers.items) |hash| _ = try store.intern(hash);
    // §7.7 L1482-1485 — "ordinary dangling-delta handling creates or retains the
    // source phantom, but the Section 3.3 scan excludes it, so it is not
    // requested." Same `intern` the announced set uses (§3.3 r24(1) L537-540):
    // idempotent for a name already held or already phantom. Unfiltered, exactly
    // like the igot intern above — admission is a TARGET-only question
    // (L1434-1435), so a shunned source still gets its phantom; §3.3 L553-556 at
    // `emitReply` is what stops the reply begging for it.
    for (scan.delta_phantoms.items) |hash| _ = try store.intern(hash);
}

/// §5.2/§6.4 — reset the partial output and emit the single error card for
/// `fault`. Every branch is one `error TEXT` card with TEXT fossilize-encoded
/// (§11.1 r2): `bad command: LINE` and `malformed CARD line` are the texts
/// §5.2/Appendix C pin, and on the wire their spaces are `\s` like any other
/// TEXT. A stock fossil client reads an error card as exactly two tokens and
/// treats anything after as further, unknown cards; an unencoded refusal was
/// discarded and the push it refused reported as sent.
fn emitError(
    gpa: std.mem.Allocator,
    out: *std.ArrayListUnmanaged(u8),
    line_buf: []u8,
    fault: RespondFault,
) Server.RespondError![]const u8 {
    out.clearRetainingCapacity(); // §5.2 L977-988 / §6.1 L1090-1091 — reset partial output
    const err_prefix = "error ";
    const card: []const u8 = switch (fault) {
        .bad_command => |line| blk: {
            // Encoding at most doubles the text; refuse before the writer's bound would.
            const text_len = "bad command: ".len + line.len;
            if (err_prefix.len + 2 * text_len > line_buf.len) return error.ResponseTooLarge;
            const text_buf = try gpa.alloc(u8, text_len);
            defer gpa.free(text_buf);
            const text = writeBadCommand(line, text_buf);
            break :blk writeSignalCard(.err, text, line_buf);
        },
        .malformed => |op| blk: {
            var text_buf: [64]u8 = undefined;
            const text = writeMalformedCard(op, &text_buf);
            break :blk writeSignalCard(.err, text, line_buf);
        },
        .unauthorized => writeSignalCard(.err, respond_unauthorized_text, line_buf),
        .duplicate_login => writeSignalCard(.err, duplicate_login_text, line_buf),
        // §8.1 L1535-1538 — the fatal `error invalid clone sequence number` over the
        // (already cleared) output: the request transaction is rolled back (no
        // ingest ran) and no later request card is parsed (the scan stopped here).
        .invalid_clone_seqno => writeSignalCard(.err, clone_invalid_seqno_text, line_buf),
        // §9.3 r31 L1976-1979 — the RFC pins this TEXT exactly, unlike the
        // impl-chosen `respond_unauthorized_text` above; it is stated once, in
        // `config_push_unauthorized_text`, and emitted verbatim here.
        .config_push_unauthorized => writeSignalCard(.err, config_push_unauthorized_text, line_buf),
        .write_unauthorized => writeSignalCard(.err, write_unauthorized_text, line_buf),
        .sparse_origin => writeSignalCard(.err, sparse_origin_refusal_text, line_buf),
        // The text is assembled by the classifier, which is the only
        // place that knows the path and the rule; §11.1 r2's free-text error
        // card is the carrier, as it is for the auth and sparse refusals.
        .content_policy => |text| writeSignalCard(.err, text, line_buf),
    };
    try appendCard(gpa, out, card);
    return out.items;
}

test "every server error card is two tokens: the operator and one encoded TEXT" {
    // The client side of §11.1 tokenizes on SP; a TEXT with a raw space is
    // several tokens and a stock fossil client drops the card as unknown.
    const gpa = std.testing.allocator;
    const line_buf = try gpa.alloc(u8, max_line_len);
    defer gpa.free(line_buf);
    var out: std.ArrayListUnmanaged(u8) = .empty;
    defer out.deinit(gpa);
    const faults = [_]RespondFault{
        .{ .bad_command = "frobnicate x y" },
        .{ .malformed = .file },
        .unauthorized,
        .duplicate_login,
        .invalid_clone_seqno,
        .config_push_unauthorized,
        .write_unauthorized,
        .sparse_origin,
        .{ .content_policy = "refused: a.bin is binary" },
    };
    for (faults) |fault| {
        const reply = try emitError(gpa, &out, line_buf, fault);
        try std.testing.expect(std.mem.startsWith(u8, reply, "error "));
        try std.testing.expect(reply[reply.len - 1] == '\n');
        const text = reply["error ".len .. reply.len - 1];
        try std.testing.expect(text.len > 0);
        try std.testing.expectEqual(@as(?usize, null), std.mem.indexOfScalar(u8, text, ' '));
    }
}

/// §8.1 L1535-1538 — the fixed free text of the invalid-clone-sequence error card.
/// The RFC pins the `error` operator and the reset-and-roll-back behaviour; this
/// exact wording is the one Fossil emits and the interoperable string.
const clone_invalid_seqno_text = "invalid clone sequence number";

/// Linear membership over a small collected hash list — the request card count
/// bounds it, so no set structure is warranted. Compares by bytes, so a store
/// hash slice matches a request hash slice with the same content.
/// §10.1 r6/r11 — the two version-pragma names `consumeReply` records the peer
/// version from; every other pragma name is tolerated and ignored (§3.5).
fn isVersionPragmaName(name: []const u8) bool {
    return std.mem.eql(u8, name, "server-version") or std.mem.eql(u8, name, "client-version");
}

// ---------------------------------------------------------------------------
// Section 5 — Card Format
// ---------------------------------------------------------------------------

/// A zit resource limit, NOT a protocol rule: the sync draft imposes no
/// maximum line length (§5.2 L970; §11.3 L2257). `readLine` will not scan for
/// LF past this window, so a longer line -- which a conforming peer may legally
/// send -- fails as `MissingTerminator` here. Measured from the first line byte
/// through and including the terminating LF, excluding any following
/// byte-counted payload.
///
/// The citation this carried (`§5.2 -- card/comment line bound`) named a bound
/// that section explicitly disclaims. An earlier review classified this constant as a
/// principled tripwire on the strength of that citation; the justification is
/// void, and it survives as documented local policy rather than as a spec
/// requirement.
pub const max_line_len: usize = 65535;

/// §5.2 — current Fossil implementations expose at most the first six
/// parsed tokens to the per-card handler; later tokens are surplus.
pub const max_tokens: usize = 6;

/// §5.2.1, §5-r20 — the complete card operator catalog. `parseOperator`
/// resolves a token against this set by exact field-name match, so the
/// catalog itself is the lexical gate for §5-r6 (lowercase-plus-
/// underscore operator tokens): anything else simply has no field.
pub const Operator = enum {
    cookie,
    login,
    push,
    pull,
    clone,
    file,
    cfile,
    uvfile,
    private,
    igot,
    uvigot,
    gimme,
    uvgimme,
    reqconfig,
    config,
    clone_seqno,
    pragma,
    @"error",
    message,
};

comptime {
    // §5-r20 — Section 5.2.1 lists 4, the remaining-operators sentence
    // lists 15; 4 + 15 = 19.
    std.debug.assert(@typeInfo(Operator).@"enum".field_names.len == 19);
}

/// Which end of the exchange is classifying the card (§5-r17: the same
/// disposition is fatal for the client and non-fatal for the server).
pub const Side = enum { client, server };

/// §5.2 outcome of classifying one card line.
pub const Disposition = enum { dispatch, unknown_operator, malformed_content, bad_command };

/// §5.1 — one LF-delimited line. `bytes` excludes the LF; `next` is the
/// body offset immediately after it, where a following byte-counted
/// payload (if any) begins.
pub const Line = struct {
    bytes: []const u8,
    next: usize,
};

/// §5-r11 — the sole line-framing failure: no LF within the bound.
pub const LineError = error{MissingTerminator};

/// §5.2 tokenization result: up to `max_tokens` slots into the source
/// line, plus a flag for tokens beyond that window (§5-r14).
pub const Tokens = struct {
    items: [max_tokens][]const u8 = undefined,
    len: usize = 0,
    surplus: bool = false,
};

/// §5.2 classification of a card line: its tokens, the resolved
/// operator (null when unrecognized), and the disposition that follows.
pub const CardClass = struct {
    tokens: Tokens,
    operator: ?Operator,
    disposition: Disposition,
};

/// §5.3, App. A.4, App. B.3 — a parsed `# timestamp DATETIME errors N`
/// comment subform.
pub const TimestampComment = struct {
    datetime: []const u8,
    errors: u64,
};

/// §5.1, §5-r10/r11 — find the next LF-terminated line starting at
/// `start`. The LF must fall within `max_line_len` bytes of `start`
/// (bound counted through-and-including the LF); a following byte-
/// counted payload is never scanned (§5-r3) — the caller advances past
/// it using `next + declared_count`.
pub fn readLine(body: []const u8, start: usize) LineError!Line {
    std.debug.assert(start <= body.len);
    const window_end = @min(body.len, start + max_line_len);
    var i: usize = start;
    while (i < window_end) : (i += 1) {
        if (body[i] == '\n') {
            const line: Line = .{ .bytes = body[start..i], .next = i + 1 };
            std.debug.assert(line.bytes.len <= max_line_len - 1);
            std.debug.assert(line.next <= body.len);
            return line;
        }
    }
    return LineError.MissingTerminator;
}

/// §5.2, §5-r7 — Fossil whitespace: SP, HTAB, VT, FF, CR. LF is
/// deliberately excluded: it terminates the card line and is never a
/// token separator.
pub fn isFossilWhitespace(b: u8) bool {
    return switch (b) {
        ' ', '\t', 0x0b, 0x0c, '\r' => true,
        else => false,
    };
}

/// §5.2, §5-r4/r5/r7/r8/r9/r14 — tokenize a card line (LF already
/// stripped by `readLine`) on runs of Fossil whitespace. The canonical
/// single-space wire form and the compatibility multi-run extension
/// produce identical tokens. A token past the sixth sets `surplus` and
/// is not stored (Fossil's fixed six-slot handler window).
pub fn tokenizeCard(line: []const u8) Tokens {
    std.debug.assert(line.len <= max_line_len - 1);
    std.debug.assert(std.mem.indexOfScalar(u8, line, '\n') == null);
    var result: Tokens = .{};
    var i: usize = 0;
    while (i < line.len) {
        if (isFossilWhitespace(line[i])) {
            i += 1;
            continue;
        }
        const start = i;
        while (i < line.len and !isFossilWhitespace(line[i])) : (i += 1) {}
        const token = line[start..i];
        if (result.len < max_tokens) {
            result.items[result.len] = token;
            result.len += 1;
        } else {
            result.surplus = true;
        }
    }
    std.debug.assert(result.len <= max_tokens);
    std.debug.assert(!result.surplus or result.len == max_tokens);
    return result;
}

/// §5.2, §5-r6/r12 — resolve an operator token against the catalog.
/// `std.meta.stringToEnum` only matches an exact field-name string, so
/// an uppercase letter, a stray digit, or a misplaced underscore falls
/// through to null (unknown operator) without a separate lexical check.
pub fn parseOperator(token: []const u8) ?Operator {
    return std.meta.stringToEnum(Operator, token);
}

/// §5-r16 — the three payload-bearing card types, whose arity failure
/// aborts with `malformed CARD line` rather than `bad command:`.
pub fn isContentBearing(op: Operator) bool {
    return switch (op) {
        .file, .cfile, .uvfile => true,
        else => false,
    };
}

/// §5.2 dispatch gate: tokenize once, then apply §5-r12 (unknown first
/// token) and §5-r14/r15/r16/r17 (surplus-token disposition). A `uvfile`
/// deliberately dispatches with its six captured slots even when more visible
/// tokens follow; every other operator retains strict surplus rejection.
/// Per-operator arity within the captured window is that card's own handler
/// (§5-r13), out of scope here.
pub fn classifyCard(line: []const u8) CardClass {
    const tokens = tokenizeCard(line);
    const result: CardClass = blk: {
        if (tokens.len == 0) {
            break :blk .{ .tokens = tokens, .operator = null, .disposition = .unknown_operator };
        }
        const operator = parseOperator(tokens.items[0]);
        if (operator == null) {
            break :blk .{ .tokens = tokens, .operator = null, .disposition = .unknown_operator };
        }
        if (tokens.surplus and operator.? != .uvfile) {
            const disposition: Disposition = if (isContentBearing(operator.?)) .malformed_content else .bad_command;
            break :blk .{ .tokens = tokens, .operator = operator, .disposition = disposition };
        }
        break :blk .{ .tokens = tokens, .operator = operator, .disposition = .dispatch };
    };
    // Positive/negative space pairing on the disposition: dispatch carries a
    // resolved operator and either no surplus, or the `uvfile` six-slot
    // compatibility exception; every other outcome fails one of those gates.
    std.debug.assert((result.disposition == .dispatch) ==
        (result.operator != null and (!result.tokens.surplus or result.operator.? == .uvfile)));
    std.debug.assert((result.disposition == .unknown_operator) == (result.operator == null));
    return result;
}

/// §5-r12/r16/r17, Table 3 — unknown operator and malformed content are
/// fatal on either side. Bad command is fatal only when the client
/// detects it; on the server it is non-fatal (§5-r17).
pub fn isFatal(d: Disposition, side: Side) bool {
    return switch (d) {
        .unknown_operator, .malformed_content => true,
        .bad_command => side == .client,
        .dispatch => false,
    };
}

/// §5-r17/r18, App. C — the sole unrecognized-card wire text: exactly
/// `"bad command: "` followed by the offending line. There is no
/// "Unknown message" text anywhere in the protocol.
pub fn writeBadCommand(line: []const u8, out: []u8) []u8 {
    const prefix = "bad command: ";
    std.debug.assert(out.len >= prefix.len + line.len);
    @memcpy(out[0..prefix.len], prefix);
    @memcpy(out[prefix.len .. prefix.len + line.len], line);
    return out[0 .. prefix.len + line.len];
}

/// §5-r16, App. C — `malformed CARD line`, where CARD is the operator
/// name (the same uppercase-metavariable convention as LINE in
/// §5-r17), e.g. `malformed file line`.
pub fn writeMalformedCard(op: Operator, out: []u8) []u8 {
    std.debug.assert(isContentBearing(op));
    const name = @tagName(op);
    const prefix = "malformed ";
    const suffix = " line";
    const needed = prefix.len + name.len + suffix.len;
    std.debug.assert(out.len >= needed);
    var i: usize = 0;
    @memcpy(out[i .. i + prefix.len], prefix);
    i += prefix.len;
    @memcpy(out[i .. i + name.len], name);
    i += name.len;
    @memcpy(out[i .. i + suffix.len], suffix);
    i += suffix.len;
    return out[0..i];
}

/// §5.3, §5-r21 — a comment line is any line whose first byte is
/// ASCII `#` (0x23).
pub fn isCommentLine(line: []const u8) bool {
    return line.len > 0 and line[0] == '#';
}

/// §5.3, §5-r23/r24/r25, App. A.4, App. B.3 — parse the
/// `# timestamp DATETIME errors N` subform out of a comment line. Any
/// other shape (nonce comment, `# sending-clusters`, a non-numeric N)
/// yields null: those lines are not semantically interpreted (§5-r25).
pub fn parseTimestampComment(line: []const u8) ?TimestampComment {
    std.debug.assert(isCommentLine(line));
    const tokens = tokenizeCard(line);
    if (tokens.len != 5 or tokens.surplus) return null;
    if (!std.mem.eql(u8, tokens.items[0], "#")) return null;
    if (!std.mem.eql(u8, tokens.items[1], "timestamp")) return null;
    if (!std.mem.eql(u8, tokens.items[3], "errors")) return null;
    const n = std.fmt.parseInt(u64, tokens.items[4], 10) catch return null;
    return .{ .datetime = tokens.items[2], .errors = n };
}

// ---------------------------------------------------------------------------
// Section 10 — Pragmas
// ---------------------------------------------------------------------------

/// §9 L1863-1867 — the draft's pragma catalog is FIFTEEN names: nine client-
/// to-server (send-private, send-catalog, client-version, uv-hash, ci-lock,
/// ci-unlock, client-url, req-links, req-clusters) and six server-to-client
/// (server-version, uv-push-ok, uv-pull-only, ci-lock-fail, avoid-delta-
/// manifests, and link), in the draft's own listing order.
///
/// ZIT-LOCAL OMISSION — `link`. §9.5 L2052-2059 makes `pragma link URL ARG
/// MTIME` response-local to req-links and gates client acceptance on "local
/// share-links mode", directing a client without it to "discard all fields".
/// zit implements no share-links mode, so every link card must be discarded.
/// §9 L1860-1862 requires unrecognized pragma names be silently ignored, and
/// leaving `link` out of this enum IS that discard: `parsePragmaName` returns
/// null and the card takes the ignore path. Modelling it would buy a handler
/// with nothing to do. So: fourteen of the draft's fifteen names, followed by
/// server names followed by five server-to-client names, in the RFC's
/// own listing order.
pub const PragmaName = enum {
    /// Sparse-cone RFC section 6.1 -- `pragma cone MEMBER`, one per member,
    /// F-card-encoded. Riding the pragma mechanism is what makes stock
    /// fossil's silent-ignore contract the degradation path of section 6.3.
    cone,
    /// Sparse-cone RFC section 6.1 -- `pragma cone-not MEMBER`, an exclusion
    /// member. A distinct name, not a marker byte: every byte that could
    /// mark a member negative is a legal filename byte. A server knowing
    /// cone but not cone-not over-sends; the requester's local filter
    /// absorbs it, so partial understanding degrades like none (§3.2).
    @"cone-not",
    @"send-private",
    @"send-catalog",
    @"client-version",
    @"uv-hash",
    @"ci-lock",
    @"ci-unlock",
    @"client-url",
    @"req-links",
    @"req-clusters",
    @"server-version",
    @"uv-push-ok",
    @"uv-pull-only",
    @"ci-lock-fail",
    @"avoid-delta-manifests",
    /// draft-fossil-submanifests-00 §7.2 amends this catalog: "A server whose
    /// enable-submanifests setting (Section 8.1) is false sends the pragma
    /// avoid-submanifests, in the same reply positions as
    /// avoid-delta-manifests." Same direction, same arg-less shape; listed
    /// immediately after the pragma it is named for.
    @"avoid-submanifests",
};

comptime {
    // §9 L1863-1867 enumerates fifteen sync pragma names (nine client-to-
    // server, six server-to-client). `PragmaName` models the fourteen zit
    // acts on — all but `link`, per the ZIT-LOCAL OMISSION note on the enum —
    // plus the one draft-fossil-submanifests-00 §7.2 adds.
    // draft-fossil-submanifests-00 §7.2 adds.
    std.debug.assert(@typeInfo(PragmaName).@"enum".field_names.len == 14 + 1 + 2); // +2: sparse-cone RFC 6.1 cone / cone-not pragmas
}

/// §9 L1863-1867 — which side originates a pragma.
pub const PragmaDirection = enum { client_to_server, server_to_client };

/// §9 L1863-1867 — direction table, split exactly as the RFC lists it.
pub fn pragmaDirection(name: PragmaName) PragmaDirection {
    return switch (name) {
        .cone, // sparse-cone RFC 6.1: the requester scopes its own transfer
        .@"cone-not",
        .@"send-private",
        .@"send-catalog",
        .@"client-version",
        .@"uv-hash",
        .@"ci-lock",
        .@"ci-unlock",
        .@"client-url",
        .@"req-links",
        .@"req-clusters",
        => .client_to_server,
        .@"server-version",
        .@"uv-push-ok",
        .@"uv-pull-only",
        .@"ci-lock-fail",
        .@"avoid-delta-manifests",
        // submanifests §7.2: sent by a SERVER whose enable-submanifests setting
        // is false, "in the same reply positions as avoid-delta-manifests".
        .@"avoid-submanifests",
        => .server_to_client,
    };
}

comptime {
    // §9 L1863-1867 — the draft splits its fifteen names nine client-to-server
    // and six server-to-client. zit models all nine client-to-server names and
    // five of the six server-to-client (it omits `link`; see the ZIT-LOCAL
    // OMISSION note on `PragmaName`). submanifests §7.2 adds one more server-
    // to-client name, avoid-submanifests — hence 5 + 1 below.
    // server-to-client." submanifests §7.2 adds one more server-to-client name.
    var c2s: usize = 0;
    var s2c: usize = 0;
    for (@typeInfo(PragmaName).@"enum".field_values) |v| {
        switch (pragmaDirection(@fromBackingInt(@intCast(v)))) {
            .client_to_server => c2s += 1,
            .server_to_client => s2c += 1,
        }
    }
    std.debug.assert(c2s == 9 + 2 and s2c == 5 + 1); // +2: cone, cone-not (sparse-cone RFC 6.1)
}

/// §9 r2/r3/r5 L1860-1863 — resolve a pragma name token. Unrecognised
/// tokens fall through to null via the same exact-field-name match
/// `parseOperator` uses for card operators; this IS the forward-
/// compatibility mechanism, not a special case of it.
pub fn parsePragmaName(token: []const u8) ?PragmaName {
    return std.meta.stringToEnum(PragmaName, token);
}

/// §9 L1860-1863 result of classifying a `pragma` card: the resolved name
/// (null when the receiver does not recognise it, §10 r2) plus the raw
/// tokens for the caller's own per-pragma argument parsing.
pub const PragmaClass = struct { name: ?PragmaName, tokens: Tokens };

/// §9 r1 L1860 — a pragma card is `pragma NAME [ARGUMENTS]`. Runs only
/// after `classifyCard` has already dispatched the line as `.pragma`
/// with no surplus tokens (§5); this function does not re-derive that.
pub fn classifyPragma(tokens: Tokens) PragmaClass {
    std.debug.assert(tokens.len >= 1);
    std.debug.assert(parseOperator(tokens.items[0]) == .pragma);
    std.debug.assert(!tokens.surplus);
    const name: ?PragmaName = if (tokens.len >= 2) parsePragmaName(tokens.items[1]) else null;
    return .{ .name = name, .tokens = tokens };
}

/// §9.1 L1897-1898 — DATE is YYYYMMDD, TIME is HHMMSS; a pragma carries
/// both together or neither (L2312, L2320-2321), so this is one
/// both-or-neither optional on `VersionInfo`/`Peer`, never two fields.
pub const DateTime = struct { date: u32, time: u32 };

/// §10.1 — one parsed wire shape shared by both version pragmas
/// (client-version and server-version carry the same VERSION [DATE
/// TIME] grammar, L2306/L2319-2320).
pub const VersionInfo = struct { version: u32, datetime: ?DateTime = null };

/// §3.1 L271-274 endpoint state entity ("peer version/date")
/// kept deliberately distinct from `VersionInfo`: `VersionInfo` is the
/// parsed wire payload of one pragma, `Peer` is the carried per-session
/// record with a role-specific pre-pragma default (server seeds VERSION
/// 0; client seeds its own release integer — §7.6 L1426-1429). Same fields, different lifecycle and default — merging
/// them would erase which constructor supplied the seed.
pub const Peer = struct {
    version: u32,
    datetime: ?DateTime = null,

    /// §9.1 r10 L1899-1909 — server's peer record before any
    /// client-version pragma has been received.
    pub fn serverInit() Peer {
        return .{ .version = 0 };
    }

    /// §9.1 r14 L1912-1914 — client's peer record before any
    /// server-version pragma has been received: its own release
    /// integer.
    pub fn clientInit(own_version: u32) Peer {
        return .{ .version = own_version };
    }
};

/// §9.1 r9 L1898-1909 (server records client) and r13 L1909-1914
/// (client records server) — the recording action is byte-for-byte
/// identical on both sides, so one function serves both; only the
/// pre-record default differs, and that lives in the two `Peer`
/// constructors above, not here.
pub fn recordPeerVersion(p: *Peer, v: VersionInfo) void {
    p.version = v.version;
    p.datetime = v.datetime;
    std.debug.assert(p.version == v.version and std.meta.eql(p.datetime, v.datetime));
}

/// §3.2 r6 L378-379 — client-version is mandatory on every request;
/// a missing or non-decimal VERSION is a real parse failure, not a
/// modelled-away optional.
pub const PragmaError = error{Malformed};

/// §9 r11 L1873-1892 — "at least five" tokens total (pragma, NAME,
/// VERSION, DATE, TIME) is the sole gate for DATE/TIME presence.
pub const min_datetime_tokens: usize = 5;

/// §2 r7 L249-250 — VERSION = MAJOR*10000 + MINOR*100 + PATCH.
/// MINOR and PATCH are two-digit fields; MAJOR is bounded so the sum
/// cannot overflow the u32 result.
pub fn encodeVersion(major: u32, minor: u32, patch: u32) u32 {
    std.debug.assert(minor < 100 and patch < 100);
    const max_major = (std.math.maxInt(u32) - 9999) / 10000;
    std.debug.assert(major <= max_major);
    return major * 10000 + minor * 100 + patch;
}

/// §9.1 r6/r8/r11 L1897-1911 — parse `pragma {client,server}-version
/// VERSION [DATE TIME]`. VERSION is `items[2]` and mandatory (r6);
/// DATE/TIME are `items[3]`/`items[4]`, present iff the card has at
/// least `min_datetime_tokens` tokens (r11).
pub fn parseVersionPragma(tokens: Tokens) PragmaError!VersionInfo {
    if (tokens.len < 3) return PragmaError.Malformed;
    const version = std.fmt.parseInt(u32, tokens.items[2], 10) catch return PragmaError.Malformed;
    var datetime: ?DateTime = null;
    if (tokens.len >= min_datetime_tokens) {
        const date = std.fmt.parseInt(u32, tokens.items[3], 10) catch return PragmaError.Malformed;
        const time = std.fmt.parseInt(u32, tokens.items[4], 10) catch return PragmaError.Malformed;
        datetime = .{ .date = date, .time = time };
    }
    const result: VersionInfo = .{ .version = version, .datetime = datetime };
    std.debug.assert((result.datetime == null) == (tokens.len < min_datetime_tokens));
    return result;
}

/// §9.1 r11/r12 L1898-1911 — the server replies with server-version
/// exactly when the received client-version pragma carried DATE and
/// TIME.
pub fn serverEmitsServerVersion(v: VersionInfo) bool {
    return v.datetime != null;
}

/// §9.1 r6/r8 L1897-1911 — write `pragma client-version VERSION [DATE
/// TIME]`: full form when `v.datetime` is set, VERSION-only otherwise.
/// DATE/TIME are zero-padded fixed-width YYYYMMDD/HHMMSS (L2310-2312
/// specifies positional *forms*, which requires leading zeros).
pub fn writeClientVersion(v: VersionInfo, out: []u8) std.fmt.BufPrintError![]u8 {
    if (v.datetime) |dt| {
        std.debug.assert(dt.date < 100000000 and dt.time < 1000000);
        const needed = std.fmt.count("pragma client-version {d} {d:0>8} {d:0>6}", .{ v.version, dt.date, dt.time });
        std.debug.assert(out.len >= needed);
        return try std.fmt.bufPrint(out, "pragma client-version {d} {d:0>8} {d:0>6}", .{ v.version, dt.date, dt.time });
    }
    const needed = std.fmt.count("pragma client-version {d}", .{v.version});
    std.debug.assert(out.len >= needed);
    return try std.fmt.bufPrint(out, "pragma client-version {d}", .{v.version});
}

/// §9.1 r11 L1898-1911 — write `pragma server-version VERSION DATE
/// TIME`. Always the full form (the server never emits it without
/// DATE/TIME, r12); same zero-padded fixed-width form as
/// `writeClientVersion`.
pub fn writeServerVersion(v: VersionInfo, out: []u8) std.fmt.BufPrintError![]u8 {
    std.debug.assert(v.datetime != null);
    const dt = v.datetime.?;
    std.debug.assert(dt.date < 100000000 and dt.time < 1000000);
    const needed = std.fmt.count("pragma server-version {d} {d:0>8} {d:0>6}", .{ v.version, dt.date, dt.time });
    std.debug.assert(out.len >= needed);
    return try std.fmt.bufPrint(out, "pragma server-version {d} {d:0>8} {d:0>6}", .{ v.version, dt.date, dt.time });
}

/// §8.5.1 r15 L1697 — `pragma uv-hash HASH`; HASH is `items[2]`.
pub fn parseUvHash(tokens: Tokens) ?[]const u8 {
    return if (tokens.len >= 3) tokens.items[2] else null;
}

/// §8.4 — a request class deferred on clone's first round: the client
/// suppresses login, config exchange, and uv-hash while `op == .clone`
/// and `round == 0`. One definition shared by every deferred pragma
/// (§10.2 r16, §9.3 r22) rather than each re-deriving the predicate.
/// §8.3 L1654-1655 gates the same round on the login side: round 0 knows
/// no project code yet, so both body-login and cookie-login transport
/// belong under this one predicate too. `loginTransport` now consumes it
/// for login gating alongside `clientMaySendUvHash` for uv-hash gating —
/// one predicate, both deferred surfaces.
pub fn sendableAfterCloneRound0(op: Operation, round: u64) bool {
    return !(op == .clone and round == 0);
}

/// §9.2 r16 L1921-1922, §8.5.1 L1697-1698 — uv-hash goes out at most
/// once per session and never on clone round 0.
pub fn clientMaySendUvHash(hash_sent: bool, op: Operation, round: u64) bool {
    return !hash_sent and sendableAfterCloneRound0(op, round);
}

/// §9.2 r17/r18 L1922-1925 — the server's per-request unversioned-file
/// response.
pub const UvResponse = enum { none, push_ok, pull_only };

/// §9.2 r17/r18 L1922-1925, §8.5.1 L1714-1718 — matching catalog hash
/// or missing read permission yields no response; otherwise the server
/// grants push or pull-only depending on upload permission.
pub fn serverUvResponse(match: bool, has_read: bool, may_upload: bool) UvResponse {
    const result: UvResponse = blk: {
        if (match) break :blk .none;
        if (!has_read) break :blk .none;
        break :blk if (may_upload) .push_ok else .pull_only;
    };
    std.debug.assert(result != .push_ok or (!match and has_read and may_upload));
    std.debug.assert(result != .pull_only or (!match and has_read and !may_upload));
    std.debug.assert(result != .none or (match or !has_read));
    return result;
}

/// §8.5.1 L1774-1775, §9.2 L1925-1927 — the client's per-session
/// unversioned-upload state as a single enum, not two independent
/// bools: the r19 pull-only latch is one field and the contradictory
/// "push-ok AND pull-only" state is unrepresentable.
pub const UvUpload = enum { undetermined, push_ok, pull_only };

/// §3.1 L302-311 — mirrors the "UV catalog-sent" flag and the collapsed
/// upload latch [rw-1] as the client's per-session unversioned state.
pub const UvSession = struct {
    hash_sent: bool = false,
    upload: UvUpload = .undetermined,
};

/// §9.2 L1925-1927 — pragma uv-push-ok enables client upload;
/// uv-pull-only disables it destructively (status-4/5 removal), so in zit
/// once pull-only, a later push-ok is a no-op.
pub fn clientOnUvPushOk(s: *UvSession) void {
    const old = s.upload;
    if (s.upload != .pull_only) s.upload = .push_ok;
    std.debug.assert(if (old == .pull_only) s.upload == .pull_only else s.upload == .push_ok);
}

/// §9.2 L1925-1927 (uv-pull-only disables upload) — in zit that
/// latches the session unconditionally; it always wins.
pub fn clientOnUvPullOnly(s: *UvSession) void {
    s.upload = .pull_only;
    std.debug.assert(s.upload == .pull_only);
}

// §3.5 — Request and Response Pragmas (pointer clause into §10; establishes
// only that pragmas dispatch in round order and the unknown-name/unknown-
// operator split, both realized by the existing §5/§10 primitives).

// ---------------------------------------------------------------------------
// Section 9.3 — Configuration Exchange
// ---------------------------------------------------------------------------

/// §5.2 — tokenization splits a card on Fossil-whitespace runs, so an
/// unquoted wire arg (group name, pragma arg) that embeds one would
/// silently split into two tokens on the wire; shared by every writer
/// below that formats a raw token straight onto the wire.
pub fn containsFossilWhitespace(s: []const u8) bool {
    for (s) |b| {
        if (isFossilWhitespace(b)) return true;
    }
    return false;
}

/// §9.3 L1946-1947 — the scalar-settings marker: a fixed GROUPNAME
/// that carries every scalar repository setting regardless of which
/// group the client actually requested (r24, r27).
pub const config_scalar_marker: []const u8 = "/config";

/// §9.3 L1946-1959 — a config card's payload is either the fixed
/// scalar marker or a table GROUPNAME (r24 vs. r28).
pub const ConfigKind = enum { scalar, table };

/// §9.3 r24/r28 — classify a GROUPNAME by exact match against the
/// scalar marker; anything else is a table-type group name.
pub fn configKind(groupname: []const u8) ConfigKind {
    return if (std.mem.eql(u8, groupname, config_scalar_marker)) .scalar else .table;
}

/// §9.3 r28 — the TABLE configuration groups a `reqconfig NAME` can select,
/// as a set rather than a single name: fossil's `/all` selects every group at
/// once, so a request resolves to a MASK, not to one group.
///
/// zit models the groups whose data it actually holds. `shun` is the one that
/// matters to a version-control core rather than a web UI: fossil's shun table
/// is its takedown mechanism, and a peer that clones without it will re-accept
/// on its next sync exactly the content an operator was ordered to remove. The
/// rest of fossil's groups (`/skin`, `/css`, `/ticket`, `/user`, `/email`,
/// `/alias`, `/subscriber`, `/interwiki`) describe a web interface and a user
/// table zit has no model for; they resolve to an empty mask, which §9.3
/// L1884-1886 makes a silent no-op, exactly as an unmodelled group was before.
pub const ConfigGroup = enum { shun };
pub const ConfigGroupMask = std.enums.EnumSet(ConfigGroup);

/// fossil's `configure_name_to_mask` (src/configure.c), including its PREFIX
/// matching: the request's name is compared against the first `name.len` bytes
/// of each known group, so `/sh` selects `/shun` and `/a` selects `/all`. The
/// leading slash is stripped before comparison there; it is required here
/// (r20) and skipped the same way.
///
/// Faithfulness to the prefix rule is not pedantry — fossil's own `configure
/// pull shun` passes an abbreviated name, so a server that demanded the full
/// spelling would answer nothing and look like a server with no shun list.
pub fn configGroupMask(name: []const u8) ConfigGroupMask {
    std.debug.assert(configNameHasSlashPrefix(name));
    const bare = name[1..];
    if (bare.len == 0) return .empty;
    // `/all` selects everything zit models; every other entry selects itself.
    // Ordered as fossil orders `aGroupName`, so a prefix ambiguous between two
    // entries resolves to the same one fossil resolves it to.
    const table = [_]struct { name: []const u8, groups: []const ConfigGroup }{
        .{ .name = "shun", .groups = &.{.shun} },
        .{ .name = "all", .groups = &.{.shun} },
    };
    for (table) |entry| {
        if (bare.len > entry.name.len) continue;
        if (!std.mem.eql(u8, bare, entry.name[0..bare.len])) continue;
        var mask: ConfigGroupMask = .empty;
        for (entry.groups) |g| mask.insert(g);
        return mask;
    }
    return .empty;
}

/// §9.3 r20 L1935 — the reqconfig NAME MUST-begin-with-/ gate.
pub fn configNameHasSlashPrefix(name: []const u8) bool {
    return name.len >= 1 and name[0] == '/';
}

/// The `/shun` group's GROUPNAME on the wire (fossil `configure_send_group`).
pub const config_shun_group: []const u8 = "/shun";

/// A user row whose four strings this client owns, latched from a
/// peer's `config /user` card and freed in `Client.deinit`. `owned` holds the
/// four backing allocations: `fossilUnquote` returns a PREFIX of the buffer
/// it wrote into, so freeing the visible slice would free the wrong length.
pub const OwnedUserRow = struct {
    login: []const u8,
    pw: []const u8,
    cap: []const u8,
    info: []const u8,
    mtime: i64,
    owned: [4]?[]u8 = .{ null, null, null, null },
};

fn freeOwnedUserRow(gpa: std.mem.Allocator, row: *OwnedUserRow, filled: usize) void {
    for (row.owned[0..@min(filled, row.owned.len)]) |maybe| {
        if (maybe) |b| gpa.free(b);
    }
}

/// A shun row whose two strings this client owns, latched from a peer's
/// `config /shun` card and freed in `Client.deinit`.
pub const OwnedShunRow = struct {
    hash: []const u8,
    epoch: i64,
    reason: []const u8,
};

/// An artifact name as the shun table carries it: lowercase hex, 40 or 64
/// bytes (SHA-1 or SHA3-256). Deliberately stricter than "any hex": a shun row
/// names an artifact, and accepting a name no artifact could have would let a
/// malformed peer plant tombstones that match nothing and confuse `verify`.
fn isLowerHexName(s: []const u8) bool {
    if (s.len != 40 and s.len != 64) return false;
    for (s) |c| switch (c) {
        '0'...'9', 'a'...'f' => {},
        else => return false,
    };
    return true;
}

/// One shun-table row as this protocol carries it. Mirrors fossil's `shun`
/// table columns exactly — `(mtime, uuid, scom)` — because the payload IS
/// those three values and a receiving fossil inserts them straight back.
pub const ShunRow = struct {
    /// The shunned artifact's name, lowercase hex (fossil's `uuid`).
    hash: []const u8,
    /// Unix seconds the shun was recorded (fossil's `mtime`).
    epoch: i64,
    /// Free operator text (fossil's `scom`, the "shun comment"). May be empty.
    reason: []const u8,
};

/// Payload ceiling for one `/shun` row: a signed decimal mtime, a
/// SP-and-quoted 64-hex name, the literal ` scom `, and a quoted reason whose
/// every byte may double under `fossilQuote`.
/// Matches `logstore.max_shun_reason`, restated rather than imported: this
/// module is the wire and deliberately does not depend on the storage layer.
/// `writeShunConfigPayload` refuses a longer reason instead of trusting it.
pub const shun_reason_max: usize = 200;
pub const shun_config_payload_max: usize =
    20 + 1 + (2 + 2 * 64) + " scom ".len + (2 + 2 * shun_reason_max);

/// One `/shun` row's payload, fossil's `configure_send_group` format byte for
/// byte: `MTIME 'UUID' scom 'SCOM'`, with `fossilQuote` standing in for
/// SQLite's `quote()`. A receiving fossil parses it with `configure_receive`'s
/// `@shun` type — primary key `uuid`, one field `scom` — so the spelling of
/// the field name is load-bearing, not decorative.
///
/// One row per card, never several packed into one: `configure_receive` reads
/// the first token pair as `(mtime, primary-key)` and every later pair as
/// `(field, value)`, so a second row inside one payload would be read as
/// nonsense field names and silently dropped.
pub fn writeShunConfigPayload(row: ShunRow, out: []u8) std.fmt.BufPrintError![]u8 {
    if (out.len < shun_config_payload_max) return error.NoSpaceLeft;
    var i: usize = 0;
    const mtime = try std.fmt.bufPrint(out[i..], "{d}", .{row.epoch});
    i += mtime.len;
    out[i] = ' ';
    i += 1;
    const uuid = fossilQuote(row.hash, out[i .. i + 2 + 2 * row.hash.len]);
    i += uuid.len;
    const literal = " scom ";
    @memcpy(out[i .. i + literal.len], literal);
    i += literal.len;
    const scom = fossilQuote(row.reason, out[i .. i + 2 + 2 * row.reason.len]);
    i += scom.len;
    return out[0..i];
}

/// §9.3 r20 — write `reqconfig NAME`.
pub fn writeReqconfig(name: []const u8, out: []u8) std.fmt.BufPrintError![]u8 {
    std.debug.assert(configNameHasSlashPrefix(name));
    std.debug.assert(!containsFossilWhitespace(name));
    const needed = std.fmt.count("reqconfig {s}", .{name});
    std.debug.assert(out.len >= needed);
    return try std.fmt.bufPrint(out, "reqconfig {s}", .{name});
}

/// §9.3 r20/r21 — the requested group name off a dispatched reqconfig
/// card; null when the card carries no NAME token.
pub fn parseReqconfigName(tokens: Tokens) ?[]const u8 {
    std.debug.assert(tokens.len >= 1);
    std.debug.assert(parseOperator(tokens.items[0]) == .reqconfig);
    return if (tokens.len >= 2) tokens.items[1] else null;
}

/// §9.3 r21 L1935-1936 — the server's reqconfig disposition: `.ignore`
/// emits no error card at all, unlike every fatal disposition in §5.
pub const ReqconfigDisposition = enum { reply, ignore };

/// §9.3 r21 — Read permission is the sole gate; unauthorized is a
/// silent no-op, not a rejection card.
pub fn serverReqconfigDisposition(has_read: bool) ReqconfigDisposition {
    return if (has_read) .reply else .ignore;
}

/// §9.3 r21 / §A.1 note 1 — whether THIS server holds Read for a requester.
///
/// zit's server performs no per-user capability lookup: `Capability` models the
/// letters the RFC defines and the server accepts unauthenticated pull/clone,
/// which is Read granted to everyone. Named rather than inlined as `true` so
/// the single place a capability model would attach is visible, and so the
/// read gate reads as a gate at the call site instead of as a constant.
const server_grants_read: bool = true;

/// §9.3 r23 L1941-1942 — a parsed `config GROUPNAME SIZE` card header.
pub const ConfigCard = struct { groupname: []const u8, size: u32 };

/// §9.3 L1973-1979 — the draft gives a negative SIZE no distinct outcome: a
/// signed SIZE and a non-decimal SIZE alike never enter the configuration
/// handler and take the unknown-card path. The split tag is zit-local and
/// diagnostic only: both call sites collapse `Malformed` and `NegativeSize`
/// into the same reject, and sync_test.zig's "a malformed config header is a
/// fatal abort" pins all four header faults, negative SIZE included.
pub const ConfigError = error{ Malformed, NegativeSize };

/// §9.3 r23/r32 — parse `config GROUPNAME SIZE`. SIZE is read as a
/// signed field so a negative literal is representable and reported as
/// `NegativeSize` rather than folded into `Malformed`; a positive value
/// exceeding `u32` is rejected outright, never silently truncated.
pub fn parseConfigCard(tokens: Tokens) ConfigError!ConfigCard {
    std.debug.assert(tokens.len >= 1);
    std.debug.assert(parseOperator(tokens.items[0]) == .config);
    if (tokens.len < 3) return ConfigError.Malformed;
    const raw = std.fmt.parseInt(i64, tokens.items[2], 10) catch return ConfigError.Malformed;
    if (raw < 0) return ConfigError.NegativeSize;
    if (raw > std.math.maxInt(u32)) return ConfigError.Malformed;
    const size: u32 = @intCast(raw);
    return .{ .groupname = tokens.items[1], .size = size };
}

/// Scratch the `/shun` payload parser unquotes into; the returned row borrows it.
pub const ShunPayloadBufs = struct {
    hash: [64]u8,
    reason: [2 * shun_reason_max]u8,
};

/// Parse one `/shun` payload — `writeShunConfigPayload`'s inverse — or null
/// when it is not a row this side can hold: a name that is not lowercase hex,
/// a reason past `shun_reason_max`, any structural miss. The wire and a
/// configuration export file share it, so a file cannot admit a row the wire
/// would refuse.
pub fn parseShunConfigPayload(payload: []const u8, bufs: *ShunPayloadBufs) ?ShunRow {
    const keyed = parseKeyedConfig(payload, "scom") catch return null;
    const epoch = std.fmt.parseInt(i64, keyed.mtime_text, 10) catch return null;
    if (keyed.quoted_name.len > 2 + bufs.hash.len) return null;
    const hash = fossilUnquote(keyed.quoted_name, &bufs.hash) catch return null;
    if (hash.len == 0 or !isLowerHexName(hash)) return null;
    if (keyed.quoted_value.len > 2 + bufs.reason.len) return null;
    const reason = fossilUnquote(keyed.quoted_value, &bufs.reason) catch return null;
    if (reason.len > shun_reason_max) return null;
    return .{ .hash = hash, .epoch = epoch, .reason = reason };
}

/// §9.3 r23 — write `config GROUPNAME SIZE`.
pub fn writeConfigCard(groupname: []const u8, size: u32, out: []u8) std.fmt.BufPrintError![]u8 {
    std.debug.assert(!containsFossilWhitespace(groupname));
    const needed = std.fmt.count("config {s} {d}", .{ groupname, size });
    std.debug.assert(out.len >= needed);
    return try std.fmt.bufPrint(out, "config {s} {d}", .{ groupname, size });
}

/// §9.3 r23/r29/r33 — the SIZE content bytes plus the offset just past
/// the mandatory trailing newline.
pub const ConfigPayload = struct { bytes: []const u8, next: usize };

/// §9.3 r29 L1941-1944 — the two ways a config payload frame fails:
/// the declared span (or its trailing newline byte) runs past the
/// buffer, or the byte immediately after SIZE bytes is not the LF r29
/// mandates.
pub const ConfigFrameError = error{ Truncated, MissingNewline };

/// §9.3 r23/r29/r33 — slice out the SIZE content bytes starting at
/// `start` and consume the single mandatory trailing newline.
/// Overflow-safe: `start + size + 1` is computed with checked addition
/// so an adversarial SIZE cannot wrap `usize` into passing the bound
/// check.
pub fn consumeConfigPayload(body: []const u8, start: usize, size: u32) ConfigFrameError!ConfigPayload {
    std.debug.assert(start <= body.len);
    const end = std.math.add(usize, start, size) catch return ConfigFrameError.Truncated;
    const next = std.math.add(usize, end, 1) catch return ConfigFrameError.Truncated;
    if (next > body.len) return ConfigFrameError.Truncated;
    if (body[end] != '\n') return ConfigFrameError.MissingNewline;
    const result: ConfigPayload = .{ .bytes = body[start..end], .next = next };
    std.debug.assert(result.next == start + @as(usize, size) + 1);
    std.debug.assert(body[start + @as(usize, size)] == '\n');
    return result;
}

/// §9.3 r26 L1951-1952 — wrap `raw` in Fossil quote encoding: enclosing
/// single quotes, every interior single quote doubled (SQL-style
/// string-literal escaping).
pub fn fossilQuote(raw: []const u8, out: []u8) []u8 {
    std.debug.assert(out.len >= 2 + 2 * raw.len);
    var i: usize = 0;
    out[i] = '\'';
    i += 1;
    for (raw) |b| {
        out[i] = b;
        i += 1;
        if (b == '\'') {
            out[i] = '\'';
            i += 1;
        }
    }
    out[i] = '\'';
    i += 1;
    const result = out[0..i];
    std.debug.assert(result.len >= 2 and result[0] == '\'' and result[result.len - 1] == '\'');
    std.debug.assert(result.len <= 2 + 2 * raw.len);
    return result;
}

/// §9.3 r26 — a quoted token missing its enclosing quotes, or with an
/// interior single quote that is not doubled, is malformed.
pub const QuoteError = error{Malformed};

/// §9.3 r26 L1951-1952 — undo `fossilQuote`: strip the enclosing
/// quotes and undouble every interior `''` back to `'`. A single pass
/// bounded by `quoted.len`.
pub fn fossilUnquote(quoted: []const u8, out: []u8) QuoteError![]const u8 {
    if (quoted.len < 2 or quoted[0] != '\'' or quoted[quoted.len - 1] != '\'') return QuoteError.Malformed;
    std.debug.assert(out.len >= quoted.len - 2);
    const inner_end = quoted.len - 1;
    var i: usize = 1;
    var j: usize = 0;
    while (i < inner_end) {
        if (quoted[i] == '\'') {
            if (i + 1 < inner_end and quoted[i + 1] == '\'') {
                out[j] = '\'';
                j += 1;
                i += 2;
            } else {
                return QuoteError.Malformed;
            }
        } else {
            out[j] = quoted[i];
            j += 1;
            i += 1;
        }
    }
    const result = out[0..j];
    std.debug.assert(result.len <= quoted.len - 2);
    return result;
}

/// §9.3 r25 L1946-1949 — the scalar payload's two quoted fields, still
/// in their wire-quoted form (unquoting is the caller's explicit
/// `fossilUnquote` step, so this parse stays allocation-free).
pub const ScalarConfig = struct { mtime: u64, quoted_name: []const u8, quoted_value: []const u8 };

/// §9.3 r25/r26 — scan one Fossil-quoted token starting at `payload[start]`
/// (which must be `'`); returns the index just past its closing quote,
/// or null if the payload ends before the token closes. Bounded by
/// `payload.len`.
fn scanQuoted(payload: []const u8, start: usize) ?usize {
    std.debug.assert(start < payload.len);
    std.debug.assert(payload[start] == '\'');
    var i: usize = start + 1;
    while (i < payload.len) {
        if (payload[i] == '\'') {
            if (i + 1 < payload.len and payload[i + 1] == '\'') {
                i += 2;
                continue;
            }
            return i + 1;
        }
        i += 1;
    }
    return null;
}

/// The `/user` group's GROUPNAME on the wire.
pub const config_user_group: []const u8 = "/user";

/// One user-table row as this protocol carries it, values still WIRE-QUOTED.
/// Quoted like `KeyedConfig` and for the same reason: unquoting is the
/// caller's explicit `fossilUnquote` step, so this parse stays
/// allocation-free. A field fossil omitted (its column was NULL) is null
/// here — distinct from a field it sent as the empty string `''`.
pub const QuotedUserRow = struct {
    mtime_text: []const u8,
    quoted_login: []const u8,
    quoted_pw: ?[]const u8 = null,
    quoted_cap: ?[]const u8 = null,
    quoted_info: ?[]const u8 = null,
};

/// fossil `configure.c`'s `{ "@user", "login", 5, {"pw","cap","info","photo","jx"} }`
/// payload: `MTIME 'LOGIN'` then zero or more `FIELD VALUE` pairs, in any
/// order, with any subset present — `configure_send_group` omits every column
/// whose `quote()` is NULL, so a row with no password simply has no `pw` pair.
///
/// TOKENIZATION IS FOSSIL'S, and it is not "everything is quoted". Values are
/// read by `blob_sqltoken` (fossil `blob.c:871`): a token starting with `'` is
/// a quoted string with doubled-quote escaping; ANYTHING ELSE RUNS TO THE NEXT
/// WHITESPACE. That second branch is load-bearing rather than defensive —
/// `photo` is a BLOB, so `quote()` renders it as SQLite's `X'89504e47…'`
/// literal, which does not start with a quote and which `fossilUnquote` would
/// reject as malformed. Scanning it as a bare token is what lets a repository
/// with user avatars parse at all.
///
/// `photo` and `jx` are SKIPPED, not stored: an avatar and a JSON extension
/// blob have no zit consumer, and carrying bytes nothing reads would be
/// storage this side cannot justify. An UNKNOWN field name is skipped the
/// same way (fossil's own receive loop does exactly this: `if( jj>=nField )
/// continue;`), so a future fossil column cannot make a row unparseable.
pub fn parseUserConfigPayload(payload: []const u8) error{Malformed}!QuotedUserRow {
    const sp1 = std.mem.indexOfScalar(u8, payload, ' ') orelse return error.Malformed;
    const mtime_text = payload[0..sp1];
    if (mtime_text.len == 0) return error.Malformed;
    var i = sp1 + 1;
    while (i < payload.len and payload[i] == ' ') i += 1;
    if (i >= payload.len or payload[i] != '\'') return error.Malformed;
    const login_end = scanQuoted(payload, i) orelse return error.Malformed;
    var row: QuotedUserRow = .{ .mtime_text = mtime_text, .quoted_login = payload[i..login_end] };
    i = login_end;

    // The FIELD VALUE pairs. A trailing field name with no value is
    // malformed, not silently dropped: fossil's own loop advances only on a
    // complete pair, so a half pair means the payload was truncated.
    while (true) {
        while (i < payload.len and payload[i] == ' ') i += 1;
        if (i >= payload.len) break;
        const name_start = i;
        while (i < payload.len and payload[i] != ' ') i += 1;
        const name = payload[name_start..i];
        if (name.len == 0) return error.Malformed;
        while (i < payload.len and payload[i] == ' ') i += 1;
        if (i >= payload.len) return error.Malformed; // field with no value
        const value_start = i;
        if (payload[i] == '\'') {
            i = scanQuoted(payload, i) orelse return error.Malformed;
        } else {
            while (i < payload.len and payload[i] != ' ') i += 1;
        }
        const value = payload[value_start..i];
        if (std.mem.eql(u8, name, "pw")) {
            row.quoted_pw = value;
        } else if (std.mem.eql(u8, name, "cap")) {
            row.quoted_cap = value;
        } else if (std.mem.eql(u8, name, "info")) {
            row.quoted_info = value;
        }
        // else: `photo`, `jx`, or a field a later fossil added — skipped.
    }
    return row;
}

/// Payload ceiling for one `/user` row: a signed decimal mtime, a SP-and-
/// quoted login, and three ` NAME ` + quoted fields whose every byte may
/// double under `fossilQuote`.
pub const user_config_payload_max: usize =
    20 + 1 + (2 + 2 * 32) + 3 * (" info ".len + 2 + 2 * 255);

/// One `/user` row's payload in `configure_send_group`'s format, byte for
/// byte: `MTIME 'LOGIN' pw 'PW' cap 'CAP' info 'INFO'`. All three fields are
/// always emitted — fossil omits only NULL columns, and zit models an absent
/// field as the empty string, which is `''` on the wire and which a receiving
/// fossil stores as empty rather than NULL. `photo`/`jx` are never emitted:
/// this side does not store them, and inventing values would be worse than
/// leaving the receiver's own columns untouched.
pub fn writeUserConfigPayload(row: UserRow, out: []u8) std.fmt.BufPrintError![]u8 {
    if (out.len < user_config_payload_max) return error.NoSpaceLeft;
    var i: usize = 0;
    const mtime = try std.fmt.bufPrint(out[i..], "{d}", .{row.mtime});
    i += mtime.len;
    out[i] = ' ';
    i += 1;
    i += fossilQuote(row.login, out[i..]).len;
    for ([_]struct { name: []const u8, value: []const u8 }{
        .{ .name = "pw", .value = row.pw },
        .{ .name = "cap", .value = row.cap },
        .{ .name = "info", .value = row.info },
    }) |f| {
        out[i] = ' ';
        i += 1;
        @memcpy(out[i..][0..f.name.len], f.name);
        i += f.name.len;
        out[i] = ' ';
        i += 1;
        i += fossilQuote(f.value, out[i..]).len;
    }
    return out[0..i];
}

/// One user-table row as the wire carries it, unquoted. Mirrors fossil's
/// `user` table columns that zit models — `(login, pw, cap, info, mtime)`.
pub const UserRow = struct {
    login: []const u8,
    pw: []const u8,
    cap: []const u8,
    info: []const u8,
    mtime: i64,
};

/// §9.3 r25 L1946-1949 — parse `MTIME SP QUOTED-NAME SP value SP
/// QUOTED-VALUE`: the literal token `value` appears verbatim between
/// the two quoted fields. A single left-to-right pass bounded by
/// `payload.len`; any structural miss is `Malformed`.
/// One `MTIME 'KEY' <field> 'VALUE'` config payload, still quoted. The mtime
/// is left as TEXT: `/config` reads it unsigned and `/shun` signed, and a
/// parser that picked one would force the other to launder a value it never
/// had a reason to reject.
pub const KeyedConfig = struct {
    mtime_text: []const u8,
    quoted_name: []const u8,
    quoted_value: []const u8,
};

/// The shared row grammar behind every fossil config group `configure_receive`
/// handles: an mtime, a quoted primary key, a FIELD NAME, and a quoted value.
/// Only the field name differs between groups — `value` for `/config`, `scom`
/// for `/shun`, `content` for `/concealed` — so it is a parameter here rather
/// than a second copy of the scanner.
pub fn parseKeyedConfig(payload: []const u8, field: []const u8) error{Malformed}!KeyedConfig {
    const sp1 = std.mem.indexOfScalar(u8, payload, ' ') orelse return error.Malformed;
    const mtime_text = payload[0..sp1];
    if (mtime_text.len == 0) return error.Malformed;
    var i = sp1 + 1;
    if (i >= payload.len or payload[i] != '\'') return error.Malformed;
    const name_end = scanQuoted(payload, i) orelse return error.Malformed;
    const quoted_name = payload[i..name_end];
    i = name_end;
    if (i >= payload.len or payload[i] != ' ') return error.Malformed;
    i += 1;
    if (i + field.len > payload.len or !std.mem.eql(u8, payload[i .. i + field.len], field)) return error.Malformed;
    i += field.len;
    if (i >= payload.len or payload[i] != ' ') return error.Malformed;
    i += 1;
    if (i >= payload.len or payload[i] != '\'') return error.Malformed;
    const value_end = scanQuoted(payload, i) orelse return error.Malformed;
    if (value_end != payload.len) return error.Malformed;
    return .{ .mtime_text = mtime_text, .quoted_name = quoted_name, .quoted_value = payload[i..value_end] };
}

pub fn parseScalarConfig(payload: []const u8) error{Malformed}!ScalarConfig {
    const keyed = try parseKeyedConfig(payload, "value");
    const mtime = std.fmt.parseInt(u64, keyed.mtime_text, 10) catch return error.Malformed;
    const result: ScalarConfig = .{ .mtime = mtime, .quoted_name = keyed.quoted_name, .quoted_value = keyed.quoted_value };
    std.debug.assert(result.quoted_name.len >= 2 and result.quoted_name[0] == '\'' and result.quoted_name[result.quoted_name.len - 1] == '\'');
    std.debug.assert(result.quoted_value.len >= 2 and result.quoted_value[0] == '\'' and result.quoted_value[result.quoted_value.len - 1] == '\'');
    return result;
}

/// §9.3 r25/r26 — assemble `MTIME SP 'NAME' SP value SP 'VALUE'`,
/// quoting the raw name/value with `fossilQuote`.
pub fn writeScalarConfig(mtime: u64, raw_name: []const u8, raw_value: []const u8, out: []u8) std.fmt.BufPrintError![]u8 {
    const mtime_len = std.fmt.count("{d}", .{mtime});
    const name_bound = 2 + 2 * raw_name.len;
    const value_bound = 2 + 2 * raw_value.len;
    const literal = " value ";
    const needed = mtime_len + 1 + name_bound + literal.len + value_bound;
    std.debug.assert(out.len >= needed);
    var i: usize = 0;
    const mtime_str = try std.fmt.bufPrint(out[i..], "{d}", .{mtime});
    i += mtime_str.len;
    out[i] = ' ';
    i += 1;
    const name_q = fossilQuote(raw_name, out[i .. i + name_bound]);
    i += name_q.len;
    @memcpy(out[i .. i + literal.len], literal);
    i += literal.len;
    const value_q = fossilQuote(raw_value, out[i .. i + value_bound]);
    i += value_q.len;
    const result = out[0..i];
    std.debug.assert(result.len <= needed);
    return result;
}

/// The error-card text for file content a request may not write: Fossil's
/// own wording for a `file` card outside an authorized push.
pub const write_unauthorized_text: []const u8 = "not authorized to write";

/// §9.3 r31 L1976-1979 — the exact error-card text for an
/// unauthorized pushed config card.
pub const config_push_unauthorized_text: []const u8 = "not authorized to push configuration";

/// §9.3 r31/r33 — the server's disposition of a pushed config card.
pub const ConfigPushDecision = enum { apply, reject };

/// §9.3 r31 — Admin capability is the sole gate for a pushed config
/// card; without it the card is rejected and not applied.
pub fn serverConfigPushDecision(has_admin: bool) ConfigPushDecision {
    return if (has_admin) .apply else .reject;
}

// --- §9.3 scalar settings: the sub-manifest partition configuration --------
//
// draft-fossil-submanifests-00 §6.3 requires PROJECT scope of the partition
// parameter ("configuration propagating to clones ... is REQUIRED"), §8.1 the
// same of `enable-submanifests`, and §6.4 adds `submanifest-auto-retune` as a
// third project-scope boolean. [FossilSync] §9.3's scalar channel is the only
// transport the RFCs give for a repository setting, so these three ride it.
//
// The names below ARE the wire contract: a peer matches on them literally, so
// they are stated once here and never re-spelled at a call site.

/// submanifests §8.1 — the emission opt-in, as named on the §9.3 wire.
pub const submanifest_setting_enable: []const u8 = "enable-submanifests";
/// submanifests §6.3 — the pinned partition parameter, as named on the wire.
pub const submanifest_setting_k: []const u8 = "submanifest-k";
/// submanifests §6.4 — the opt-in automatic re-tune switch, as named on the
/// wire.
pub const submanifest_setting_auto_retune: []const u8 = "submanifest-auto-retune";

/// §9.3 r25 — the MTIME this implementation stamps on the three rows it
/// emits.
///
/// zit's config frame carries no per-field write time (`logstore.encodeConfig`
/// appends the three settings as raw tail bytes, with no timestamp anywhere in
/// the frame), so there is no honest clock to report. 0 says exactly that:
/// untracked. It is also the conservative value — §9.3's channel is
/// last-write-wins on MTIME, so a 0-stamped row loses every comparison against
/// a peer that does track write times, and this implementation can therefore
/// never overwrite a better-informed peer's setting by accident. Clone-time
/// adoption, the only direction this implementation applies (a fresh repository
/// has nothing of its own to lose), does not consult MTIME at all.
pub const submanifest_config_mtime: u64 = 0;

/// §9.3 — the three settings as they arrive from, or depart for, a peer. Every
/// field is optional and independently absent: a row this implementation could
/// not read, or a peer that never sent one, leaves its field null rather than
/// substituting a default, so "the peer said off" and "the peer said nothing"
/// stay distinguishable at the adoption seam.
pub const SubmanifestConfig = struct {
    enable: ?bool = null,
    k: ?u8 = null,
    auto_retune: ?bool = null,

    /// True when at least one setting was actually named. The adoption
    /// wrapper's whole gate: all-null means "peer sent none; keep local".
    pub fn any(self: SubmanifestConfig) bool {
        return self.enable != null or self.k != null or self.auto_retune != null;
    }
};

/// §9.3 r25/r26 — the boolean rendering these rows carry. `on`/`off` matches
/// what `zit submanifest` shows an operator and what §9.3's own example rows
/// use for a Fossil boolean setting.
fn submanifestBoolText(v: bool) []const u8 {
    return if (v) "on" else "off";
}

/// §3.5 tolerate-and-ignore, at value granularity — read a boolean setting
/// value, or null when the bytes are not one this implementation understands.
/// `on`/`off` is what zit writes; `1`/`0` is what Fossil's own config table
/// stores a boolean as, so both are accepted on the read side. Anything else is
/// skipped by the caller rather than coerced.
fn parseSubmanifestBool(raw: []const u8) ?bool {
    if (std.mem.eql(u8, raw, "on") or std.mem.eql(u8, raw, "1")) return true;
    if (std.mem.eql(u8, raw, "off") or std.mem.eql(u8, raw, "0")) return false;
    return null;
}

/// The widest quoted-name/value scratch one row may need. Names are the three
/// literals above; values are `on`/`off` or a decimal `u8`. Both bounds are
/// deliberately generous — a peer may send any name at all, and a row wider
/// than the scratch is skipped, never truncated into a different setting.
const submanifest_row_name_max: usize = 64;
const submanifest_row_value_max: usize = 32;

/// The byte ceiling of the three-row payload `writeSubmanifestConfigPayload`
/// produces, derived from the row grammar rather than guessed: per row a
/// worst-case decimal MTIME, one space, the quoted name (every byte doubled by
/// `fossilQuote` in the worst case), the ` value ` literal, and the quoted
/// value; plus the two LF separators between the three rows.
pub const submanifest_config_payload_max: usize = blk: {
    const mtime_max = 20; // u64 decimal ceiling
    const name_max = 2 + 2 * submanifest_setting_auto_retune.len; // the widest of the three
    const value_max = 2 + 2 * "off".len; // wider than "on" and than any u8 decimal
    const row = mtime_max + 1 + name_max + " value ".len + value_max;
    break :blk 3 * row + 2;
};

/// §9.3 r25 — assemble this repository's three settings as LF-separated scalar
/// rows. No trailing LF INSIDE the payload: §9.3 r29's single mandatory
/// newline follows the SIZE bytes and is the frame's, not a row separator, so
/// splitting the received payload on LF yields exactly the rows written here.
pub fn writeSubmanifestConfigPayload(
    enable: bool,
    k: u8,
    auto_retune: bool,
    out: []u8,
) std.fmt.BufPrintError![]u8 {
    // The direct `out[i]` writes below are safe only because the buffer is at
    // least the payload ceiling. This was an assert; the return type already
    // carries the right answer, so refuse instead of panicking a ReleaseSafe
    // caller that sized its buffer wrong.
    if (out.len < submanifest_config_payload_max) return error.NoSpaceLeft;
    var k_buf: [4]u8 = undefined; // u8 decimal ceiling is 3 digits
    const k_text = try std.fmt.bufPrint(&k_buf, "{d}", .{k});
    var i: usize = 0;
    const rows = [3]struct { name: []const u8, value: []const u8 }{
        .{ .name = submanifest_setting_enable, .value = submanifestBoolText(enable) },
        .{ .name = submanifest_setting_k, .value = k_text },
        .{ .name = submanifest_setting_auto_retune, .value = submanifestBoolText(auto_retune) },
    };
    for (rows, 0..) |row, n| {
        if (n > 0) {
            out[i] = '\n';
            i += 1;
        }
        const written = try writeScalarConfig(submanifest_config_mtime, row.name, row.value, out[i..]);
        i += written.len;
    }
    const result = out[0..i];
    std.debug.assert(result.len <= submanifest_config_payload_max);
    return result;
}

/// §9.3/§3.5 — read whatever of the three settings a scalar `/config` payload
/// names, tolerating everything else.
///
/// Row-granular tolerance is the whole point: a peer's scalar payload legally
/// carries every setting IT models, most of which zit does not, and an older or
/// newer peer may spell a value in a shape this implementation cannot read. A
/// row that fails to parse, unquote, or convert is SKIPPED — the rows around it
/// still apply and nothing is raised — so a peer that predates these settings,
/// or postdates them, degrades to "sent none" rather than to a failed exchange.
/// Allocation-free: each row unquotes through bounded stack scratch, and a row
/// too wide for it is one more skip.
pub fn parseSubmanifestConfigPayload(payload: []const u8) SubmanifestConfig {
    var cfg: SubmanifestConfig = .{};
    var rows = std.mem.splitScalar(u8, payload, '\n');
    while (rows.next()) |row| {
        if (row.len == 0) continue;
        const sc = parseScalarConfig(row) catch continue;
        var name_buf: [submanifest_row_name_max]u8 = undefined;
        if (sc.quoted_name.len - 2 > name_buf.len) continue;
        const name = fossilUnquote(sc.quoted_name, &name_buf) catch continue;
        var value_buf: [submanifest_row_value_max]u8 = undefined;
        if (sc.quoted_value.len - 2 > value_buf.len) continue;
        const value = fossilUnquote(sc.quoted_value, &value_buf) catch continue;
        if (std.mem.eql(u8, name, submanifest_setting_enable)) {
            if (parseSubmanifestBool(value)) |b| cfg.enable = b;
        } else if (std.mem.eql(u8, name, submanifest_setting_k)) {
            // §6.3's range rule is NOT applied here: 0 is the real "unpinned"
            // value, and an out-of-range pin is a state §6.3 already defines a
            // fallback for (`submanifest.emission` reports it to the operator).
            // Filtering it out here would silently substitute a parameter of
            // this implementation's own, which §6.3 forbids.
            cfg.k = std.fmt.parseInt(u8, value, 10) catch continue;
        } else if (std.mem.eql(u8, name, submanifest_setting_auto_retune)) {
            if (parseSubmanifestBool(value)) |b| cfg.auto_retune = b;
        }
    }
    return cfg;
}

// ---------------------------------------------------------------------------
// Section 9.4 — Commit-Lock Pragmas
// ---------------------------------------------------------------------------

/// §9.4 L1989-1991 — `pragma ci-lock HASH CLIENT-ID` arguments.
pub const CiLock = struct { hash: []const u8, client_id: []const u8 };

/// §9.4 — HASH/CLIENT-ID off a dispatched `pragma ci-lock` card.
pub fn parseCiLock(tokens: Tokens) ?CiLock {
    std.debug.assert(tokens.len >= 2);
    std.debug.assert(parseOperator(tokens.items[0]) == .pragma);
    std.debug.assert(parsePragmaName(tokens.items[1]) == .@"ci-lock");
    if (tokens.len < 4) return null;
    return .{ .hash = tokens.items[2], .client_id = tokens.items[3] };
}

/// §9.4 — write `pragma ci-lock HASH CLIENT-ID`.
pub fn writeCiLock(hash: []const u8, client_id: []const u8, out: []u8) std.fmt.BufPrintError![]u8 {
    std.debug.assert(hash.len > 0 and !containsFossilWhitespace(hash));
    std.debug.assert(client_id.len > 0 and !containsFossilWhitespace(client_id));
    const needed = std.fmt.count("pragma ci-lock {s} {s}", .{ hash, client_id });
    std.debug.assert(out.len >= needed);
    return try std.fmt.bufPrint(out, "pragma ci-lock {s} {s}", .{ hash, client_id });
}

/// §9.4 L1996-2007 — the server's disposition of a `ci-lock` request:
/// granted when unheld or already held by the same requester, failed
/// when held by a different client.
pub const CiLockResult = enum { granted, failed };

/// §9.4 r36 — `holder` is the current lock holder's login, if any.
pub fn serverCiLockResult(holder: ?[]const u8, requester: []const u8) CiLockResult {
    const result: CiLockResult = blk: {
        const h = holder orelse break :blk .granted;
        break :blk if (std.mem.eql(u8, h, requester)) .granted else .failed;
    };
    std.debug.assert(result != .failed or (holder != null and !std.mem.eql(u8, holder.?, requester)));
    return result;
}

/// §9.4 L2008-2025 — `pragma ci-lock-fail LOGIN MTIME` arguments.
pub const CiLockFail = struct { login: []const u8, mtime: u64 };

/// §9.4 L2008-2025 — LOGIN/MTIME off a dispatched `pragma ci-lock-fail`
/// card; null on a missing or non-decimal MTIME. A parsed fault does
/// not by itself abort the round (L2538-2542: the client records the
/// fault and continues processing the remainder of the round); it only
/// gates a later commit against the locked leaf, per
/// `commitMayProceedAfterCiLockFail`. A negative or non-integer MTIME
/// already fails to parse here and is null (L2542-2547: a receiver MAY
/// treat that as fatal; a non-negative MTIME MUST NOT be a wire error).
pub fn parseCiLockFail(tokens: Tokens) ?CiLockFail {
    std.debug.assert(tokens.len >= 2);
    std.debug.assert(parseOperator(tokens.items[0]) == .pragma);
    std.debug.assert(parsePragmaName(tokens.items[1]) == .@"ci-lock-fail");
    if (tokens.len < 4) return null;
    const mtime = std.fmt.parseInt(u64, tokens.items[3], 10) catch return null;
    return .{ .login = tokens.items[2], .mtime = mtime };
}

/// §9.4 L2008-2025 — write `pragma ci-lock-fail LOGIN MTIME`.
pub fn writeCiLockFail(login: []const u8, mtime: u64, out: []u8) std.fmt.BufPrintError![]u8 {
    std.debug.assert(login.len > 0 and !containsFossilWhitespace(login));
    const needed = std.fmt.count("pragma ci-lock-fail {s} {d}", .{ login, mtime });
    std.debug.assert(out.len >= needed);
    return try std.fmt.bufPrint(out, "pragma ci-lock-fail {s} {d}", .{ login, mtime });
}

/// §9.4 L2008-2025 — zit-local (the RFC only records the fault): with a recorded ci-lock fault, a commit against
/// the same check-in MUST NOT complete if it would fork the locked
/// leaf, unless the user requested an override that removes the
/// fork-avoidance guard.
pub fn commitMayProceedAfterCiLockFail(
    fault_recorded: bool,
    would_create_fork: bool,
    override_requested: bool,
) bool {
    const result = !(fault_recorded and would_create_fork and !override_requested);
    std.debug.assert(fault_recorded or result); // §9.4 L2008-2025 (RFC records the fault only): no recorded fault -> commit proceeds
    std.debug.assert(!override_requested or result); // §9.4 L2008-2025: RFC states no commit rule; zit-local override always permits
    return result;
}

/// §9.4 L2026-2027 — CLIENT-ID off a dispatched `pragma ci-unlock` card.
pub fn parseCiUnlock(tokens: Tokens) ?[]const u8 {
    std.debug.assert(tokens.len >= 2);
    std.debug.assert(parseOperator(tokens.items[0]) == .pragma);
    std.debug.assert(parsePragmaName(tokens.items[1]) == .@"ci-unlock");
    return if (tokens.len >= 3) tokens.items[2] else null;
}

/// §9.4 L2026-2027 — write `pragma ci-unlock CLIENT-ID`.
pub fn writeCiUnlock(client_id: []const u8, out: []u8) std.fmt.BufPrintError![]u8 {
    std.debug.assert(client_id.len > 0 and !containsFossilWhitespace(client_id));
    const needed = std.fmt.count("pragma ci-unlock {s}", .{client_id});
    std.debug.assert(out.len >= needed);
    return try std.fmt.bufPrint(out, "pragma ci-unlock {s}", .{client_id});
}

/// §9.4 L2030-2033 — a client with a known client identifier sends
/// ci-unlock in a later round (round > 0) when no lock is pending for
/// the current exchange.
pub fn clientSendsCiUnlock(has_client_id: bool, lock_pending: bool, round: u64) bool {
    return has_client_id and !lock_pending and round > 0;
}

// ---------------------------------------------------------------------------
// Section 9.5 — Private-Content and Discovery Pragmas
// ---------------------------------------------------------------------------

/// ABNF App. B — the seven `"pragma" SP NAME LF` productions
/// that carry no argument (send-private, send-catalog, req-links,
/// req-clusters, avoid-delta-manifests, uv-push-ok, uv-pull-only). The
/// other seven names all carry `SP fossilized` / `SP decimal` / `SP
/// artifact-hash` after NAME. Derived from the ABNF, not from this
/// batch's callers, so `writeFlagPragma` stays the general arg-less
/// writer rather than one special-cased to §9.5.
pub fn pragmaIsArgless(name: PragmaName) bool {
    return switch (name) {
        .@"send-private",
        .@"send-catalog",
        .@"req-links",
        .@"req-clusters",
        .@"avoid-delta-manifests",
        .@"uv-push-ok",
        .@"uv-pull-only",
        // submanifests §7.2 states no argument for avoid-submanifests and
        // places it "in the same reply positions as avoid-delta-manifests",
        // whose production is `"pragma" SP NAME LF`.
        .@"avoid-submanifests",
        => true,
        .cone, // carries one F-card-encoded member
        .@"cone-not", // same shape, negative polarity
        .@"client-version",
        .@"server-version",
        .@"uv-hash",
        .@"ci-lock",
        .@"ci-lock-fail",
        .@"ci-unlock",
        .@"client-url",
        => false,
    };
}

/// §10 ABNF — write `pragma NAME` for an arg-less pragma. `@tagName`
/// reproduces the wire token exactly: `PragmaName` fields are spelled to
/// match the wire names (`parsePragmaName` relies on the same identity).
/// Precondition `pragmaIsArgless(name)` — a name that carries an argument
/// is a caller bug, not a runtime error.
pub fn writeFlagPragma(name: PragmaName, out: []u8) std.fmt.BufPrintError![]u8 {
    std.debug.assert(pragmaIsArgless(name));
    const tag = @tagName(name);
    const needed = std.fmt.count("pragma {s}", .{tag});
    std.debug.assert(out.len >= needed);
    return try std.fmt.bufPrint(out, "pragma {s}", .{tag});
}

/// §9.5 L2037-2038 — the exact unauthorized-send-private error text.
pub const send_private_unauthorized_text = "not authorized to sync private content";

/// §9.5 L2037-2039 — the server's disposition of a `send-private`
/// pragma.
pub const SendPrivateDecision = enum { enable, reject };

/// §9.5 r41 — private-content transfer is enabled for the session only
/// when the requesting user holds the Private (x) capability; carries no
/// state of its own, so a repeated call with the same input always
/// yields the same decision (r43's idempotence).
pub fn serverSendPrivateDecision(has_private_x: bool) SendPrivateDecision {
    return if (has_private_x) .enable else .reject;
}

/// §7.5 L1413-1414 / §9.5 — the send-side mirror of
/// `serverSendPrivateDecision`: pulling private content MUST be preceded by a
/// send-private pragma, itself gated on the client holding the Private (x)
/// capability, so a client emits send-private only when it wants private sync
/// and holds x. The caller derives `has_private_x` from
/// `CapabilitySet.contains(.private)`. Pure — no state, so idempotent.
pub fn clientSendsSendPrivate(wants_private: bool, has_private_x: bool) bool {
    const result = wants_private and has_private_x;
    std.debug.assert(result == (wants_private and has_private_x));
    return result;
}

/// §9.5 r43/r48 — the two per-pragma send schedules the RFC states in
/// §9.5. Partial by design: the RFC gives a schedule for exactly three
/// names (send-private, client-url, req-links); the other eleven are
/// RFC-silent, so this returns `null` for them rather than fabricating a
/// total table the spec never states.
pub const PragmaSchedule = enum { once_pre_loop, every_private_round };

/// §9.5 r43 L2037-2039 (send-private repeats every private-sync round)
/// vs. r48 L2488-2490 (client-url/req-links go once, before the round
/// loop).
pub fn pragmaSchedule(name: PragmaName) ?PragmaSchedule {
    return switch (name) {
        .@"send-private" => .every_private_round,
        .@"client-url", .@"req-links" => .once_pre_loop,
        else => null,
    };
}

/// §9.5 r47, ABNF §B `client-url-pragma` L2985 — write `pragma client-url
/// <fossilized(url)>` in place: the fixed prefix goes straight into
/// `out`, then `artifact.encodeEscapes` fills the remainder — one
/// buffer, no scratch. Precondition `out.len >= prefix.len +
/// 2*url.len` (the encoder's own worst case), so unlike the sibling
/// `write*` helpers this never touches `std.fmt.bufPrint` and cannot
/// fail — no error union to force a `try` on an unreachable path.
pub fn writeClientUrl(url: []const u8, out: []u8) []u8 {
    const prefix = "pragma client-url ";
    std.debug.assert(out.len >= prefix.len + 2 * url.len);
    @memcpy(out[0..prefix.len], prefix);
    const encoded = artifact.encodeEscapes(url, out[prefix.len..]);
    return out[0 .. prefix.len + encoded.len];
}

/// §9.5 r47 — read the URL back off a dispatched `pragma client-url`
/// card: `items[2]` decoded. Null on a short card (negative space);
/// never indexes past `tokens.len`.
pub fn parseClientUrl(tokens: Tokens, out: []u8) ?[]const u8 {
    std.debug.assert(tokens.len >= 1);
    std.debug.assert(parseOperator(tokens.items[0]) == .pragma);
    if (tokens.len < 3) return null;
    std.debug.assert(parsePragmaName(tokens.items[1]) == .@"client-url");
    return artifact.decodeEscapes(tokens.items[2], out);
}

// ---------------------------------------------------------------------------
// Section 11 — Error and Message Cards
// ---------------------------------------------------------------------------

/// §7.6 L1429-1431, App. A.2 L2603 decoded text — the exact decoded text
/// emitted when the SHA3-256 peer-version gate (§7.6) fires.
pub const peer_version_error_text = "Fossil version 2.0 or later required.";

/// §11.1/§11.2 — an error card is a fatal protocol signal (outside clone
/// round 0); a message card is a non-fatal advisory. TEXT is encoded
/// identically on both (§11.1 r2, §11.2 r2), so one writer/parser pair serves
/// both — only the leading operator word differs.
pub const SignalKind = enum { err, message };

/// §5.2 L961-963 (usage sites: §11.1 r1/r2, §11.2 r1/r2, §3.6 L600-603)
/// — write `OP TEXT`, TEXT fossilize-encoded in place. Shared body for
/// every single-text-argument card: `op` carries its own trailing SP, then
/// `artifact.encodeEscapes` fills the remainder — one buffer, no scratch.
/// Like `writeClientUrl`, this never touches `std.fmt.bufPrint` and cannot
/// fail, so it returns `[]u8` directly rather than an error union nothing
/// ever populates.
fn writeTextArgCard(op: []const u8, text: []const u8, out: []u8) []u8 {
    std.debug.assert(out.len >= op.len + 2 * text.len);
    @memcpy(out[0..op.len], op);
    const encoded = artifact.encodeEscapes(text, out[op.len..]);
    return out[0 .. op.len + encoded.len];
}

/// §10.1 r1/r2 L2111-2113, §10.2 r1/r2 L2189-2191 — write `error TEXT`
/// or `message TEXT`; the operator word is the only thing that varies.
pub fn writeSignalCard(kind: SignalKind, text: []const u8, out: []u8) []u8 {
    const op: []const u8 = switch (kind) {
        .err => "error ",
        .message => "message ",
    };
    return writeTextArgCard(op, text, out);
}

/// §11.1 r2, §11.2 r2 — read TEXT back off a dispatched `error`/
/// `message` card: `items[1]` decoded. Null on a card with no TEXT
/// token (negative space); never indexes past `tokens.len`.
pub fn parseSignalText(tokens: Tokens, out: []u8) ?[]const u8 {
    std.debug.assert(tokens.len >= 1);
    if (tokens.len < 2) return null;
    return artifact.decodeEscapes(tokens.items[1], out);
}

/// §11.1 r4/r10 — the client's disposition of a received `error` card.
pub const ClientErrorAction = enum { discard, terminate };

/// §10.1 r4 L2113-2117 (outside clone round 0: terminate) / r10 L2120-2123
/// (first round of a clone: silently discard) / r9 L2639-2640 (no HTTP-
/// status input in scope — the signal lives wholly in the message body).
/// Exhaustive over `ClientState`: only `clone_round0` discards.
pub fn clientOnErrorCard(state: ClientState) ClientErrorAction {
    return switch (state) {
        .clone_round0 => .discard,
        .init, .exchange, .terminated, .aborted => .terminate,
    };
}

/// §10.1 L2156-2162 — the fatal-error sites the RFC distinguishes by
/// whether the server resets prior partial output before emitting the
/// error card.
pub const ErrorSite = enum {
    wrong_project,
    auth_failure,
    duplicate_login,
    unknown_operator,
    peer_version_gate,
    unauthorized_send_private,
    private_card,
};

/// §10.1 L2156-2162 — reset (discard prior partial output) vs. append
/// (extend the already-built response) before emitting the error card.
pub const ErrorReset = enum { reset, append };

/// §10.1 L2156-2162 (wrong project, auth failure, duplicate login,
/// unknown operator -> reset) vs. r8 L2626-2629 (peer-version gate,
/// unauthorized send-private, private card -> append).
pub fn serverErrorReset(site: ErrorSite) ErrorReset {
    return switch (site) {
        .wrong_project, .auth_failure, .duplicate_login, .unknown_operator => .reset,
        .peer_version_gate, .unauthorized_send_private, .private_card => .append,
    };
}

/// §11.2 — the client's disposition of a received `message` card.
pub const MessageAction = enum { pull_only_silent, display_optional };

/// §10.2 L2194-2198, ambiguities.md §11.2 Reading A (settled) — the
/// glob `pull only *` carries its separating space; the trigger is the
/// literal 10-byte prefix `"pull only "`, so a bare `"pull only"` (no
/// trailing content) does not match.
pub fn matchesPullOnly(decoded: []const u8) bool {
    return std.mem.startsWith(u8, decoded, "pull only ");
}

/// §11.2 item 1/2 — the client's disposition of a received `message` card.
/// The pull-only suppression path (r4) is entered only when push intent is
/// *live* (`push_active`) AND the decoded payload matches `pull only *`; a
/// match while push is already clear (r14) or any non-matching payload (r9)
/// falls through to the advisory display path.
pub fn clientOnMessageCard(push_active: bool, decoded: []const u8) MessageAction {
    return if (push_active and matchesPullOnly(decoded)) .pull_only_silent else .display_optional;
}

// ---------------------------------------------------------------------------
// Section 3.6 — Cookie Card
// ---------------------------------------------------------------------------

/// §3.6 L600, grammar `cookie-card = "cookie" SP fossilized LF` (App. B.4)
/// — the opaque TEXT is fossilize-encoded exactly like error/message TEXT.
/// One writer serves both the server's emit (L703) and the client's return
/// (L706-707): the card shape is identical either direction.
pub fn writeCookieCard(text: []const u8, out: []u8) []u8 {
    return writeTextArgCard("cookie ", text, out);
}

/// §3.6 L600-601 — the client's single held sync cookie: storing a new
/// value replaces any previously held one ("the client MUST replace its
/// stored value"). Client-only: the server keeps no cookie state of its own
/// (L601), so this type is never instantiated server-side. `buf` is bounded
/// by the §5.2 card-line ceiling (`max_line_len`) — a longer TEXT could
/// never have arrived as a card line in the first place.
pub const CookieStore = struct {
    buf: [max_line_len]u8 = undefined,
    len: usize = 0,
    held: bool = false,

    /// §3.6 L600-601 — overwrite the decoded opaque value, discarding
    /// whatever was held before.
    pub fn store(self: *CookieStore, text: []const u8) void {
        std.debug.assert(text.len <= self.buf.len);
        @memcpy(self.buf[0..text.len], text);
        self.len = text.len;
        self.held = true;
        std.debug.assert(self.len == text.len and self.held);
    }

    /// §3.6 L600-601, §4.3 — store a wire cookie token: defossilize `encoded`
    /// (a reply-body slice, disjoint from `buf`) straight into `buf`, then set
    /// the length and held flag. Allocation-free: the decoded form never grows
    /// past the encoded token (§4.3), and the token can be at most one card
    /// line (`max_line_len`), so `buf` always fits it — the cookie OOM path is
    /// defined out of existence rather than allocating a transient scratch.
    pub fn storeFossilized(self: *CookieStore, encoded: []const u8) void {
        std.debug.assert(encoded.len <= self.buf.len);
        const decoded = artifact.decodeEscapes(encoded, &self.buf);
        self.len = decoded.len;
        self.held = true;
        std.debug.assert(self.len <= encoded.len and self.held);
    }

    /// §3.6 L600-603 — null when nothing has ever been stored, so
    /// a client that never returns a cookie (L713) has nothing to send.
    pub fn value(self: *const CookieStore) ?[]const u8 {
        return if (self.held) self.buf[0..self.len] else null;
    }
};

// ---------------------------------------------------------------------------
// Section 7.6 / 12.4 — SHA3-256 Peer-Version Gate
// ---------------------------------------------------------------------------

/// §7.6 L1425-1426, §2 L249-250 — the Fossil release integer for 2.0
/// ("2.0 is 20000").
pub const sha3_min_peer_version: u32 = 20000;

/// §7.6 L1424-1431, §11.4 L2263-2264 — exactly two outcomes. No third
/// "downgrade" outcome exists: §11.4 L2263-2264 forbids any SHA1
/// substitution or re-identification, so the gate can never rewrite a
/// name — the two-variant enum forbids that structurally.
pub const GateDecision = enum { emit, refuse };

/// §7.6 L1424-1426 — refuse iff `hash` names a SHA3-256 artifact (64
/// lowercase hex, [FossilArtifact] §3.2) AND `peer_version` is less than
/// `sha3_min_peer_version`. Site-agnostic: the file/cfile/uvfile send
/// sites (§7.6) all share this one decision.
///
/// `peer_version` is the caller's already-recorded version-pragma integer
/// for the peer — this function is a PURE function of that integer; it
/// does not choose one for an unrecorded peer. Per §9.1 L1897-1914 the
/// unrecorded-peer default is SIDE-DEPENDENT (server: before any
/// client-version pragma, treats VERSION as 0 -> refuse; client: before
/// any server-version pragma, treats VERSION as its own release integer
/// -> emit for a SHA3-capable client), so selecting that default is the
/// caller's responsibility, deferred to the round-loop/content-transfer
/// work that owns the recorded-peer-version state. On `.refuse` the
/// caller emits `writeSignalCard(.err, peer_version_error_text)` at
/// `ErrorSite.peer_version_gate`.
pub fn sha3PeerVersionGate(peer_version: u32, hash: []const u8) GateDecision {
    const is_sha3 = artifact.validateName(hash) and hash.len == 64;
    const d: GateDecision = if (is_sha3 and peer_version < sha3_min_peer_version) .refuse else .emit;
    // Downgrade-safety invariant (§11.4 L2263-2264): a non-64-char name —
    // every SHA1 name — is never refused, so the gate has no path that
    // could truncate or re-identify. Derived from hash.len, not a
    // restatement of the branch above.
    std.debug.assert(d == .emit or hash.len == 64);
    return d;
}

// §7.6 r7: the 2-field GateDecision enum is a precondition value, not a
// card variant — the gate defines no new card syntax, only a yes/no on
// whether the caller may write the card it already knows how to write.
// §7.6 r6: the gate applies at three independent send sites (file, cfile,
// uvfile) and a sender MUST apply it at each site independently. The gate
// itself is site-agnostic by design (L1782 folds the uvfile "64-character
// lowercase hexadecimal content hash" into the same HASH-shape test as the
// file/cfile name), so at the decision layer "independent per site" reduces
// to: the same pure function fires identically for three distinct 64-hex
// values, standing in for the file-name, cfile-name, and uvfile-hash forms.
// The three real call sites land with the future file/cfile/uvfile send
// batches (§7.1/§7.2/§7.3); this pins the decision those sites will share.
// App. A.2 L2598-2601: the hand-transcribed 4-line gate-fire response body
// (Content-Length: 191), cited not machine-generated. zit owns no
// clone_seqno/# timestamp writer or multi-card response assembler (out of
// §7.6 scope), so this only pins the fixture's byte length and that the one
// card §7.6 owns -- the error card -- is byte-identical to the body's first
// line. Decoded-text/encoding equality is not re-asserted here (that's §11
// r8); this test is about the body shape, not the text content.
// ---------------------------------------------------------------------------
// draft-fossil-submanifests-00 §7.2 / §8.1 — Sub-Manifest Transfer and Emission
// ---------------------------------------------------------------------------

/// submanifests §7.2 — the announced peer version at or above which a peer is
/// known to support sub-manifests. The draft deliberately leaves the number to
/// the implementation: "a threshold to be fixed at the first release
/// implementing this specification". zit fixes it here, and the choice is
/// constrained on both sides.
///
/// It MUST be strictly above every released Fossil, because no released Fossil
/// has any sub-manifest support: a threshold at or below 2.28 would let the
/// gate pass an S-bearing artifact to a peer that would reject it as
/// structural, which is exactly the silent-divergence §8 exists to prevent.
/// 2.29.0 is the first version integer above the newest release.
///
/// It is NOT the version zit announces by default. `announcedVersion` raises
/// the announcement to this value only when the operator has enabled emission
/// (§8.1); with emission off — the default — zit announces exactly what it
/// announced before this draft existed, so no existing peer, Fossil included,
/// observes any change.
pub const submanifest_min_peer_version: u32 = 22900;

comptime {
    std.debug.assert(submanifest_min_peer_version == encodeVersion(2, 29, 0));
    // Above the newest released Fossil (2.28), which has no sub-manifest
    // support: see the constant's doc for why this direction is the safe one.
    std.debug.assert(submanifest_min_peer_version > encodeVersion(2, 28, 0));
}

/// submanifests §7.2 — the explicit error a refused send carries. The draft
/// fixes the RULE ("with an explicit error -- never by converting, downgrading,
/// or silently omitting it") but no literal, and names its model: "The rule
/// parallels the SHA3-256 peer-version gate of [FossilSync], Section 7.6."
/// This text is therefore CONSTRUCTED on that parallel — the §7.6 form with
/// this gate's own threshold — not quoted from either draft.
pub const submanifest_peer_version_error_text = "Fossil version 2.29 or later required for sub-manifests.";

/// submanifests §7.2 — refuse iff the artifact is S-bearing AND the peer's
/// announced version predates sub-manifest support. `carries_s_card` is the
/// draft's own disjunction already collapsed by the caller: "a local artifact
/// is of type SUBMANIFEST, or is a manifest carrying an S card" — one predicate,
/// because the gate treats the two identically and a caller that has classified
/// the artifact knows both.
///
/// Pure in `peer_version`, exactly like `sha3PeerVersionGate`: choosing the
/// default for an unrecorded peer is side-dependent (§9.1 L1897-1914) and so
/// stays the caller's decision. On `.refuse` the caller emits
/// `writeSignalCard(.err, submanifest_peer_version_error_text)` — never a
/// converted, downgraded, or omitted artifact.
///
/// Reuses `GateDecision` rather than minting a parallel two-variant enum: the
/// §7.6 rationale carries over verbatim — there is no third "downgrade"
/// outcome, and sharing the type keeps that structurally true of both gates.
pub fn submanifestPeerVersionGate(peer_version: u32, carries_s_card: bool) GateDecision {
    const d: GateDecision = if (carries_s_card and peer_version < submanifest_min_peer_version) .refuse else .emit;
    // An artifact carrying no S card is never refused at any version: the gate
    // is incapable of touching traffic that predates this draft.
    std.debug.assert(d == .emit or carries_s_card);
    return d;
}

/// submanifests §8.1 — "a project-scope boolean, default false". Named rather
/// than written as a bare `false` at each site so the default is one fact.
pub const submanifest_emission_default: bool = false;

/// submanifests §8.1/§7.2/§8.2 — the consequence an operator MUST be shown
/// before enabling emission. Enabling is an opt-in with a cost, not a feature
/// flag: from the first tree it writes, this repository refuses every pre-2.29
/// peer that artifact (§7.2) and becomes a disposable test fixture (§8.2). The
/// text names both, because both are irreversible in practice — §8.1's
/// "Disabling the setting ... cannot retire existing ones".
pub const submanifest_enable_warning =
    "enabling sub-manifests makes this repository write tree manifests; from the first one, " ++
    "peers below Fossil 2.29 will be refused every S-bearing artifact rather than " ++
    "receive a converted or downgraded one, and disabling the setting later cannot retire " ++
    "the artifacts already written. While this specification is provisional " ++
    "a repository containing an S-bearing artifact must not be synchronized with any " ++
    "repository that is not itself a disposable test fixture.";

/// submanifests §7.2 — does this implementation read §5.1 trees? zit does: the
/// walk, the §7.1 phantom duty, the §7.3 deferral record and the §5.2 refusal
/// to present a partial closure are all here. Named rather than written as a
/// bare `true` so the one fact `announcedVersion` turns on is greppable.
pub const submanifest_reader_support: bool = true;

/// submanifests §7.2/[FossilSync] §10.1 — the release integer this peer
/// announces, given the one it would otherwise announce and whether it reads
/// sub-manifests.
///
/// A raise, never a lower: a peer that already announces a higher release
/// integer keeps it.
///
/// The key is READER SUPPORT, not §8.1's emission setting. §10.1's version is
/// what the implementation IS, and §7.2 gates on "the remote peer's announced
/// version" — a statement of capability the sender reads to decide whether the
/// peer can parse what it is about to receive. Keying it on the emission
/// setting instead collapses §8's layer 2 (transfer) into its layer 1
/// (emission): the announcement would then mean "I intend to WRITE S cards",
/// which is not what a sender needs to know and not what §10.1 says a version
/// is. It also livelocks the feature — a fresh clone target has neither the
/// setting nor the §8.1 indicator, so no tree could ever reach a new
/// repository, while §8.2 plainly contemplates a tree being "cloned to" a
/// fixture.
///
/// §8.2's provisional prohibition is NOT weakened by this: it is enforced where
/// §8 puts it, at layer 1. `clientMayEmitSCards` is false by default, so no zit
/// mints an S-bearing artifact unless an operator opts the repository in; a
/// repository that holds none announces the raise and never has anything for
/// the gate to refuse. What the announcement buys is the other direction —
/// fossil, at 22800, is below the threshold, so an opted-in zit refuses it every
/// tree rather than handing it artifacts §8 calls "unreadable".
pub fn announcedVersion(base: u32, reader_support: bool) u32 {
    const v = if (reader_support) @max(base, submanifest_min_peer_version) else base;
    std.debug.assert(v >= base); // never lowers what the peer already announced
    std.debug.assert(reader_support or v == base); // no reader ⇒ byte-identical announcement
    return v;
}

/// submanifests §7.2/§8.1 — may a commit created in this session emit S cards?
/// Both gates in one predicate, because the draft states them as one
/// conjunction: §8.1 "A writer MUST NOT emit an S card unless the repository's
/// enable-submanifests setting ... is true, and MUST NOT emit one in a commit
/// against a server from which it has received the avoid-submanifests pragma in
/// the current sync session (Section 7.2)."
///
/// The writer itself is out of this slice's scope; this is the decision the
/// writer will read, pinned where the pragma that feeds it is parsed.
pub fn clientMayEmitSCards(setting_enabled: bool, avoid_received: bool) bool {
    return setting_enabled and !avoid_received;
}

// ---------------------------------------------------------------------------
// Section 3.7 — Round Continuation
// ---------------------------------------------------------------------------

/// §3.7 — carried post-round observations (one struct, not loose args), so
/// `continueExchange` stays a pure total function of "what happened last
/// round."
pub const RoundObservation = struct {
    operation: Operation,
    // (a) r2 — non-clone versioned progress into a non-empty phantom table.
    versioned_content_delivered: bool = false,
    phantom_created: bool = false,
    phantom_table_nonempty: bool = false,
    // (b) r3 — the per-round file-send count is greater than zero: at least
    // one full-content card was appended to the send buffer while processing
    // this reply (§3.7(b) L621-626). Per-round, not the persistent buffer.
    content_queued: bool = false,
    // (c) r4 — private-content discovery round pending.
    private_discovery_pending: bool = false,
    // (d) r5/r6 — unversioned send/gimme cycle.
    uv_send_table_nonempty: bool = false,
    uv_upload_enabled: bool = false,
    uvgimme_queued: bool = false,
    uvfile_received: bool = false,
    session_round: u64 = 0, // round count AFTER incrementing for the completed round
    // (e) r7 — modern-clone continuation.
    first_clone_round: bool = false,
    artifact_received_this_round: bool = false,
    clone_seqno_next: i64 = 0, // received NEXT; negatives are the fatal path's job (§8.2)
    // A clone-seqno card was present this round -- distinguishes "NEXT is 0"
    // (§A.2 exhausted-range progress) from "no clone card at all" (the
    // sparse-cone §5.3 refusal shape).
    clone_seqno_received: bool = false,
    artifact_count_grew: bool = false,
    // (f) r8 — pending configuration exchange.
    config_exchange_pending: bool = false,
    // §3.6/§3.9 — a cookie card was accepted and stored this round. A pure
    // observation: the cookie exists "solely for the Section 3.6
    // optimization" (§4.3 L928-929), so this feeds no §3.7 continuation
    // condition.
    cookie_stored: bool = false,
    // §7.5 L1391-1392 — a content card bound a pending private marker this round
    // and was stored. Pure observation; feeds no §3.7 continuation condition.
    private_content_delivered: bool = false,
    // §7.5 L1392-1396 — a private artifact was discarded for lack of the grant.
    // Pure observation; it sets no versioned/phantom flag, so a discard never
    // feeds a §3.7 continuation condition (this is why deferred DST invariant
    // (b) — a discard does not stall §3.7 termination — holds by construction).
    private_content_discarded: bool = false,
    // §7.5 L1411-1413 — a private igot marked an already-held phantom private.
    phantom_marked_private: bool = false,
    // Sparse-cone RFC section 5.3 -- an error card was discarded under the
    // sync RFC section 11.1 clone-round-0 rule. Folded with the progress
    // flags at end of round: a reply that carried an error card AND no clone
    // progress is a refusal, not cosmetics, and the clone aborts loudly
    // instead of "succeeding" empty.
    error_card_discarded: bool = false,
    // §7.5 L1413-1414 — a private igot recorded that the peer holds HASH.
    private_igot_peer_has: bool = false,
};

/// §3.7(a) L610-612 — condition (a): versioned progress into a non-empty
/// phantom table, gated to non-clone operations (clone continuation is (e)).
fn continuesVersionedProgress(obs: RoundObservation) bool {
    if (obs.operation == .clone) return false;
    const progressed = obs.versioned_content_delivered or obs.phantom_created;
    return progressed and obs.phantom_table_nonempty;
}

/// §3.7(d) L633-638 — condition (d): the unversioned send/gimme cycle.
/// (d)(1) the send table is non-empty and upload is enabled; (d)(2) a
/// uvgimme was queued this round, and either a uvfile came back or the
/// session hasn't yet exhausted its three-round grace window.
fn continuesUnversionedCycle(obs: RoundObservation) bool {
    const d1 = obs.uv_send_table_nonempty and obs.uv_upload_enabled;
    const d2 = obs.uvgimme_queued and (obs.uvfile_received or obs.session_round < 3);
    return d1 or d2;
}

/// §3.7(e) L640-649 — condition (e): modern-clone continuation. A positive
/// NEXT alone does not continue the round (L1915-1924: the peer must also
/// have grown the artifact count this round).
fn continuesClone(obs: RoundObservation) bool {
    if (obs.operation != .clone) return false;
    if (obs.first_clone_round or obs.artifact_received_this_round) return true;
    return obs.clone_seqno_next > 0 and obs.artifact_count_grew;
}

/// §3.7 L607-644, L651-652 — (f) is zit-local; the client issues another round iff at least one
/// of conditions (a)-(f) holds; unresolved phantoms alone (r9) never do.
pub fn continueExchange(obs: RoundObservation) bool {
    std.debug.assert(obs.clone_seqno_next >= 0);
    std.debug.assert(!obs.first_clone_round or obs.operation == .clone);
    return continuesVersionedProgress(obs) or // (a)
        obs.content_queued or // (b)
        obs.private_discovery_pending or // (c)
        continuesUnversionedCycle(obs) or // (d)
        continuesClone(obs) or // (e)
        obs.config_exchange_pending; // (f)
}

/// Did this round MOVE BYTES — content delivered into the store, or content
/// queued out of it? This is not a §3.7 condition and never decides whether to
/// issue another round; `continueExchange` alone does that. It exists for the
/// driver's runaway guard, which needs to tell a peer that is converging slowly
/// from one that is looping while accomplishing nothing.
///
/// The distinction that matters is DELIVERY versus ANNOUNCEMENT. A peer can
/// satisfy §3.7 forever without ever converging: announce a fresh hash each
/// round and condition (a) holds indefinitely — a new phantom every round, a
/// phantom table that is never empty, and content that never arrives. So
/// `phantom_created` is deliberately NOT progress here, nor is anything else
/// that merely records what the peer claims or what is still pending
/// (`private_discovery_pending`, `config_exchange_pending`, `uvgimme_queued`,
/// `cookie_stored`, `phantom_marked_private`, `private_igot_peer_has`). Only
/// bytes crossing in either direction count.
///
/// Consequence, and the reason this function exists: a round count is NOT a
/// convergence bound. A clone transfers a bounded batch per round, so a flat
/// round cap silently becomes a ceiling on repository SIZE — a large, perfectly
/// healthy repository trips it while making full progress every round. Bounding
/// consecutive byte-free rounds instead bounds the thing actually being guarded
/// against, and does so independently of how much there is to transfer.
pub fn roundDeliveredContent(obs: RoundObservation) bool {
    return obs.versioned_content_delivered or // §3.3 — content into the store
        obs.content_queued or // §3.7(b) — content out of it
        obs.uvfile_received or // §3.5 — an unversioned file arrived
        obs.artifact_received_this_round or // §8.2 — a clone/cfile delivery
        obs.artifact_count_grew or // §3.7(a) L610-611 — stored content
        obs.private_content_delivered; // §7.5 L1391-1392
}

// ---------------------------------------------------------------------------
// Section 3.4 — The Clone Loop (pointer clause: no new bytes, no new
// mechanism — clone rides the operation-agnostic round primitives)
// ---------------------------------------------------------------------------

// ---------------------------------------------------------------------------
// Section 3.8 — Client Protocol State Machine
// ---------------------------------------------------------------------------

/// §3.8 Table 5 L733-746 — INIT departs to CLONE_ROUND0 for a modern clone;
/// push/pull/sync all send an authenticated first round straight to EXCHANGE.
pub fn initialState(op: Operation) ClientState {
    return switch (op) {
        .clone => .clone_round0,
        .pull, .push, .sync => .exchange,
    };
}

/// §3.8 Table 3 — the legal events driving a round0/exchange transition.
pub const Event = enum {
    clone_reply_processed, // r14
    clone_fatal, // r15
    round_continues, // r16
    round_terminates, // r17
    fatal_error, // r18
};

/// §3.8 Table 3, r14-r18 — the client's state transition function. Illegal
/// (state, event) pairs and the absorbing terminals (`terminated`,
/// `aborted`) are never re-stepped: unreachable, negative space.
pub fn transition(state: ClientState, event: Event) ClientState {
    return switch (state) {
        .clone_round0 => switch (event) {
            .clone_reply_processed => .exchange, // r14
            .clone_fatal => .aborted, // r15
            .round_continues, .round_terminates, .fatal_error => unreachable,
        },
        .exchange => switch (event) {
            .round_continues => .exchange, // r16
            .round_terminates => .terminated, // r17
            .fatal_error => .aborted, // r18
            .clone_reply_processed, .clone_fatal => unreachable,
        },
        .init, .terminated, .aborted => unreachable,
    };
}

/// §3.8 — zit's closed nine-cause fatal-error set, each member cited to
/// its governing subsection.
pub const FatalCause = enum {
    // server-emitted error cards -- silently discarded in CLONE_ROUND0 (§11.1).
    error_card, // §10.1 L2120-2123
    project_code_mismatch, // §9.1 -> error card, §10.1 L2156-2162
    auth_failure, // §6 -> error card, §10.1 L2156-2162
    peer_version_refusal, // §7.6 L1424-1431 -> error card
    // client-detected faults -- fatal in every state, round0 included.
    unknown_operator, // §3.9 L803-806 / §5
    malformed_content, // §3.9 L803-806 / §7
    over_length_line, // zit-local cap; §5.2 L969-970 imposes no maximum
    redirect_limit, // §4 L845-846 / Table-3 r15
    clone_seqno_negative, // zit-local; §8.2 L1577-1581 / §3.2 L427 tolerate it
};

/// §10.1 L2156-2162, §7.6 L1424-1431 — the four causes above that reach the
/// client only as a server-emitted `error` card, so they inherit
/// `clientOnErrorCard`'s round-0 discard exception.
fn deliveredByErrorCard(cause: FatalCause) bool {
    return switch (cause) {
        .error_card, .project_code_mismatch, .auth_failure, .peer_version_refusal => true,
        .unknown_operator, .malformed_content, .over_length_line, .redirect_limit, .clone_seqno_negative => false,
    };
}

/// §3.8 r19 — the nine-cause set is fatal in every state, except that the
/// four error-card-delivered causes are discarded in CLONE_ROUND0, per
/// `clientOnErrorCard`'s single round-0 exception (reused, not restated).
pub fn causeIsFatal(state: ClientState, cause: FatalCause) bool {
    if (deliveredByErrorCard(cause)) return clientOnErrorCard(state) == .terminate;
    return true;
}

/// §8.2 L1577-1581 / §3.2 L427 tolerate a nondecimal NEXT; zit's abort is zit-local, in
/// every state including CLONE_ROUND0 (§3.8 L658-659: the cited section
/// governs where it differs from the state-machine restatement).
pub fn cloneSeqnoIsFatal(next: i64) bool {
    return next < 0;
}

// ---------------------------------------------------------------------------
// Section 3.9 — Robustness Posture (coverage over the settled authorities;
// §3.9 is a restatement, so no new mechanism — each clause binds to the
// existing classifier/state-machine that owns it).
// ---------------------------------------------------------------------------

// §3.9 r4 helper — a canonical card is single-space separated, has no
// leading/trailing Fossil whitespace, no separator run wider than one byte,
// and re-tokenizes without surplus (§5.2 L961-963, L965-967, L972-975).
// ---------------------------------------------------------------------------
// Section 3.2.1 — Round Message Layout (non-authoritative quick reference,
// L1016-1018: §3.2 and each card's owning section govern; these tests bind
// the restated rows onto the authorities, adding no §3.10 mechanism).
// ---------------------------------------------------------------------------

// ---------------------------------------------------------------------------
// Section 3.1 / 3.3 — Round Tables & Phantom Lifecycle
// ---------------------------------------------------------------------------

/// §3.1 L331-332, r26/r27 — the two per-round tracking tables (Table 1: peer-
/// has, peer-lacks). Both are created-or-reset at round start and torn down
/// at round end (r20/r21, L433-436); they own no state past that boundary.
/// The string maps do not own their keys, so `reset`/`deinit` free the
/// `gpa.dupe`'d hashes explicitly, mirroring `MemStore` (repostate.zig).
pub const RoundTables = struct {
    gpa: std.mem.Allocator,
    peer_has: std.StringHashMapUnmanaged(void) = .empty,
    peer_lacks: std.StringHashMapUnmanaged(void) = .empty,

    pub fn init(gpa: std.mem.Allocator) RoundTables {
        return .{ .gpa = gpa };
    }

    pub fn deinit(self: *RoundTables) void {
        freeKeys(self.gpa, &self.peer_has);
        self.peer_has.deinit(self.gpa);
        freeKeys(self.gpa, &self.peer_lacks);
        self.peer_lacks.deinit(self.gpa);
        self.* = undefined;
    }

    /// r20/r21, L433-436 — create-or-reset at a round boundary: free every
    /// owned key and empty both tables, keeping bucket storage (capacity is
    /// bounded by one round's hash count).
    pub fn reset(self: *RoundTables) void {
        freeKeys(self.gpa, &self.peer_has);
        self.peer_has.clearRetainingCapacity();
        freeKeys(self.gpa, &self.peer_lacks);
        self.peer_lacks.clearRetainingCapacity();
        std.debug.assert(self.peer_has.count() == 0 and self.peer_lacks.count() == 0);
    }

    /// r26, L630-633 — record a hash the peer announced or sent this round.
    pub fn recordPeerHas(self: *RoundTables, hash: []const u8) error{OutOfMemory}!void {
        try recordInto(self.gpa, &self.peer_has, hash);
        std.debug.assert(self.peerHas(hash));
    }

    /// r27, L633-637 — record a hash the peer requested with gimme this round.
    pub fn recordPeerLacks(self: *RoundTables, hash: []const u8) error{OutOfMemory}!void {
        try recordInto(self.gpa, &self.peer_lacks, hash);
        std.debug.assert(self.peerLacks(hash));
    }

    pub fn peerHas(self: *const RoundTables, hash: []const u8) bool {
        return self.peer_has.contains(hash);
    }

    pub fn peerLacks(self: *const RoundTables, hash: []const u8) bool {
        return self.peer_lacks.contains(hash);
    }

    fn freeKeys(gpa: std.mem.Allocator, map: *std.StringHashMapUnmanaged(void)) void {
        var it = map.keyIterator();
        while (it.next()) |key| gpa.free(key.*);
    }

    /// Reserves capacity on `map` itself before dupe-ing `hash`, so the
    /// trailing insert is `putAssumeCapacity` (infallible) and an OOM can
    /// only happen before any owned memory exists — no partial state, no
    /// errdefer needed. A repeat record of an already-held key returns
    /// before the dupe, so it never leaks a duplicate key.
    fn recordInto(gpa: std.mem.Allocator, map: *std.StringHashMapUnmanaged(void), hash: []const u8) error{OutOfMemory}!void {
        std.debug.assert(hash.len == 40 or hash.len == 64);
        if (map.contains(hash)) return;
        try map.ensureUnusedCapacity(gpa, 1);
        const owned = try gpa.dupe(u8, hash);
        map.putAssumeCapacity(owned, {});
    }
};

/// §3.3 r24(1), L537-540 — whether `side` can obtain content in operation
/// `op`. Sync is bidirectional, so it appears on both arms.
pub fn igotCanObtain(side: Side, op: Operation) bool {
    return switch (side) {
        .client => switch (op) {
            .pull, .clone, .sync => true,
            .push => false,
        },
        .server => switch (op) {
            .push, .sync => true,
            .pull, .clone => false,
        },
    };
}

/// §3.3 L569-572, §3.2 L419-421 — whether the client may upload content in
/// response to a peer gimme: only when the operation direction permits
/// sending — push, and the push half of sync. pull and clone are
/// receive-only. (The truth table coincides with the server-obtains set of
/// `igotCanObtain`, but this names the distinct send-direction decision.)
pub fn clientSendsContent(op: Operation) bool {
    return op == .push or op == .sync;
}

pub const IgotDisposition = enum { create_phantom, ignore };

/// §3.3 r24(1), L537-540 — igot creates a phantom iff the receiver does not
/// already hold the artifact and is in an operation state that can obtain it.
pub fn igotDisposition(side: Side, op: Operation, already_holds: bool) IgotDisposition {
    const result: IgotDisposition = if (!already_holds and igotCanObtain(side, op)) .create_phantom else .ignore;
    std.debug.assert(result != .create_phantom or (!already_holds and igotCanObtain(side, op)));
    return result;
}

/// §3.3 L553-556 — "the ordinary phantom scan excludes … names in the
/// repository shun table …". The repository shun table as the phantom scan
/// needs it: not a table, one question — is this WIRE NAME shunned? A borrowed
/// capability (context + fn pointer, shaped like `repostate.ArtifactStore`), so
/// the protocol layer never learns whether the answer comes from a log scan, a
/// test fixture, or nothing at all. §3.3 L544-546 makes the set repository-local
/// and durable, distinct from the round-local tables, which is exactly why it
/// arrives from outside rather than living on `Client`.
///
/// KEY DOMAIN. §3.3 L544-545 keys the table by COMPLETE artifact name, and the
/// wire carries hex names, so `contains` takes the name verbatim — the caller
/// never truncates, pads, or prefix-matches it, and any hex→digest decode
/// belongs wholly inside one implementation. The table's keys are the
/// artifact-format §3.2 L347-350 complete forms, so a name of any other width cannot be in
/// it: `contains` answers `false` for one without consulting the implementation.
///
/// A CHECKED REJECTION, not an assert, and that is deliberate. Three boundaries
/// keep a name of another width out of the phantom set in the first place —
/// `parseIgotCard`, `parseGimmeCard`, and `receiveDelta`'s DELTASRC gate — so
/// every name that reaches a scan today is complete. But this is an INTERFACE:
/// its callers are whoever holds a `ShunSet`, which no implementation can
/// enumerate, and the two things that would reach it first if a fourth mint were
/// added are `unresolvedPhantoms` and the §3.3 gimme walk, both driven by peer
/// input. An assert there is a peer-reachable trap in Debug and ReleaseSafe and
/// nothing at all in ReleaseFast — the wrong instrument in both directions. The
/// answer is not a fail-open concession either: `putShun` refuses a non-complete
/// key and the open scan fails `error.Corrupt` on one, so no shun row can name a
/// name of that width and `false` is the exact truth.
///
/// FRESHNESS. The PROTOCOL layer probes this on every scan and caches nothing:
/// `buildRequest` and `Server.emitReply` ask per phantom, per round, so an
/// implementation whose answer changes is honoured by the very next scan. What
/// makes the answer change is the implementation's business, not this layer's —
/// see `RepoShunSet` in `src/cmd/sync.zig` for what the CLI's durable set does
/// and does not re-read.
/// Sparse-cone RFC section 6.2 -- the server-side cone classifier seam. The
/// library never parses manifests during a reply; the embedder (which owns
/// the snapshot and a memo) answers "which rids does this cone keep". A null
/// return serves densely -- refusal to classify is degradation, never error.
/// Sparse-cone RFC section 5.3 -- may this sparse origin serve a clone for
/// the requested cone? A dense request (no cone members) asks for everything
/// and is refused. A cone-scoped request is admitted only when every
/// requested inclusion lies WHOLLY inside the origin's coverage: covered by
/// the origin cone, with no origin exclusion inside its subtree. Requester
/// exclusions only narrow a request, so they never affect admission.
/// `members` are F-card-encoded exactly as they crossed the wire; an
/// undecodable or over-long member refuses (fail closed -- the requester
/// asked for something this origin cannot even name).
fn cloneConeAdmissible(origin: *const cone_mod.Cone, members: []const []const u8) bool {
    if (members.len == 0) return false;
    std.debug.assert(members.len <= cone_mod.max_members); // RequestScan caps collection.
    for (members) |encoded| {
        // Decoding never grows: the decoded member fits an encoded-size
        // buffer. An over-long member cannot have crossed a legal F card.
        if (encoded.len > 2 * cone_mod.max_member_len) return false;
        var buf: [2 * cone_mod.max_member_len]u8 = undefined;
        const member = artifact.decodeEscapes(encoded, buf[0..encoded.len]);
        if (member.len == 0) return false;
        if (!origin.covers(member)) return false;
        for (origin.not_members) |not_member| {
            if (memberCoversForOrigin(member, not_member)) return false;
        }
    }
    return true;
}

/// Prefix-at-'/'-boundary coverage, restated here because cone.zig keeps its
/// helper private: does `outer` cover `inner`?
fn memberCoversForOrigin(outer: []const u8, inner: []const u8) bool {
    std.debug.assert(outer.len >= 1);
    if (inner.len < outer.len) return false;
    if (!std.mem.eql(u8, outer, inner[0..outer.len])) return false;
    if (inner.len == outer.len) return true;
    return inner[outer.len] == '/';
}

pub const ConeClassifier = struct {
    context: *anyopaque,
    vtable: *const VTable,

    pub const VTable = struct {
        /// `members` are F-card-ENCODED cone members exactly as they crossed
        /// the wire. Returns a keep bitset over the server store's rids, or
        /// null to serve densely (bad member, over-cap, memo failure). The
        /// bitset borrows the embedder's memo and MUST stay valid for the
        /// reply being built.
        classify: *const fn (
            context: *anyopaque,
            members: []const []const u8,
            not_members: []const []const u8,
        ) ?*const std.DynamicBitSetUnmanaged,
    };

    pub const none: ConeClassifier = .{ .context = undefined, .vtable = &.{ .classify = neverClassify } };
    fn neverClassify(_: *anyopaque, _: []const []const u8, _: []const []const u8) ?*const std.DynamicBitSetUnmanaged {
        return null;
    }
};

pub const ShunSet = struct {
    context: *anyopaque,
    vtable: *const VTable,

    pub const VTable = struct {
        /// Is `name` — a wire artifact name, exactly as it appeared on the wire
        /// — in the repository shun table? Total: no allocation, no error, no
        /// I/O. An out-of-domain name answers false, never traps.
        contains: *const fn (context: *anyopaque, name: []const u8) bool,
    };

    pub fn contains(self: ShunSet, name: []const u8) bool {
        // The key-domain gate, enforced where it is claimed — as a rejection, for
        // the reason the doc block above gives. No shun row can key a name that is
        // not a complete artifact name, so `false` is the exact answer and the
        // implementation need never see one.
        if (name.len != 40 and name.len != 64) return false;
        return self.vtable.contains(self.context, name);
    }

    /// The identity: an empty shun table. §3.3 L549-551 — "Omitting that
    /// exchange does not omit the local set, which is empty when no local or
    /// exchanged entries supply names." Every construction site that names no
    /// shun set behaves exactly as it did before the set existed.
    pub const none: ShunSet = .{ .context = @constCast(&none_context), .vtable = &none_vtable };

    /// `context` is `*anyopaque`, which cannot be null, so the empty set still
    /// needs an address; this byte is never read.
    const none_context: u8 = 0;
    const none_vtable: VTable = .{ .contains = noneContains };
    fn noneContains(context: *anyopaque, _: []const u8) bool {
        // Negative space. Discarding the context is only sound while this vtable
        // is paired with `none_context`, and nothing else observes the pairing: a
        // construction site that pairs a LIVE context with `none_vtable`
        // silently disables the filter, and one that pairs `none_context` with a
        // real vtable writes through a const global. A mispairing is a local
        // construction error, never peer input, so an assert is the right
        // instrument — and it costs nothing in release.
        std.debug.assert(context == ShunSet.none.context);
        return false;
    }
};

/// §3.3 (a)/(b)/(c), L553-556 — the freshly-per-round (L542-547) gimme eligibility
/// filter inputs.
pub const Eligibility = struct {
    peer_lacks: bool,
    shunned: bool,
    private: bool,
    private_sync_enabled: bool,
    /// Sparse-cone RFC section 5.2 rule 3 -- deliberately absent under local
    /// cone policy. Distinct from `shunned`: shun is a durable prohibition
    /// that also silences SERVING; elision only silences spontaneous
    /// REQUESTING, and a targeted demand fetch (a later wave) asks anyway.
    elided: bool = false,
};

/// §3.3, L553-556 — order (a) peer-lacks, (b) shunned, (c) private-without-
/// sync-enabled; a phantom is gimme-eligible only if none reject it.
pub fn gimmeEligible(e: Eligibility) bool {
    if (e.peer_lacks) return false;
    if (e.shunned) return false;
    if (e.elided) return false; // RFC 5.2(3): never spontaneously requested
    if (e.private and !e.private_sync_enabled) return false;
    const result = true;
    std.debug.assert(!e.peer_lacks);
    std.debug.assert(!e.shunned);
    std.debug.assert(!e.elided);
    return result;
}

pub const GimmeResponse = enum { send, silent_skip };

/// §3.3 L569-572 — inbound-gimme disposition. Caller records peer-lacks
/// (r27) before calling. `.send` only when held, direction permits sending,
/// and policy permits disclosure; every other case is a silent skip — no
/// content, no error card, no deferred send (r34/r35 and the policy-denied
/// case fold to the same byte-neutral outcome since none emits a card).
pub fn gimmeResponse(holds: bool, direction_permits: bool, policy_permits: bool) GimmeResponse {
    const result: GimmeResponse = if (holds and direction_permits and policy_permits) .send else .silent_skip;
    return result;
}

/// §5.1 — append one card line into a growable body: `slice` carries the
/// card tokens without a terminating LF (as every card writer returns), and
/// this frames it with the single LF §5.1 requires between cards.
fn appendCard(gpa: std.mem.Allocator, buf: *std.ArrayListUnmanaged(u8), slice: []const u8) error{OutOfMemory}!void {
    try buf.appendSlice(gpa, slice);
    try buf.append(gpa, '\n');
}

/// §3.3 L569-570, §5.2 — write the content-request card `gimme HASH` into `out`
/// (no terminating LF; the caller frames it, matching the other card
/// writers). HASH is the artifact hash exactly as stored — 40 or 64
/// lowercase hex — and may not embed Fossil whitespace, which would
/// re-tokenize the card on the wire.
fn writeGimme(hash: []const u8, out: []u8) std.fmt.BufPrintError![]u8 {
    std.debug.assert(!containsFossilWhitespace(hash));
    std.debug.assert(hash.len <= max_line_len);
    return try std.fmt.bufPrint(out, "gimme {s}", .{hash});
}

/// §3.3 L537-538, §8.6 L1813-1823 — write the holding-announcement card
/// `igot HASH` (public) or `igot HASH 1` (private) into `out`, no
/// terminating LF. The trailing `1` is the §8.6/§5.2 private marker.
fn writeIgot(hash: []const u8, private: bool, out: []u8) std.fmt.BufPrintError![]u8 {
    std.debug.assert(!containsFossilWhitespace(hash));
    std.debug.assert(hash.len <= max_line_len);
    return if (private)
        try std.fmt.bufPrint(out, "igot {s} 1", .{hash})
    else
        try std.fmt.bufPrint(out, "igot {s}", .{hash});
}

/// §7.1 — write the full-content file card `file HASH SIZE\n` immediately
/// followed by exactly SIZE payload bytes, appended into `out`. Unlike the
/// line writers this emits its own LF (the header/payload separator is
/// intrinsic to the card) and grows the buffer, because the payload is
/// arbitrary length and NUL-legal (`[]const u8`, no sentinel). SIZE is the
/// byte length of `content`. The closed error set carries the alloc failure
/// and the (bounds-asserted, so unreached) header-format failure separately.
fn writeFile(
    gpa: std.mem.Allocator,
    out: *std.ArrayListUnmanaged(u8),
    hash: []const u8,
    content: []const u8,
) error{ OutOfMemory, NoSpaceLeft }!void {
    std.debug.assert(!containsFossilWhitespace(hash));
    std.debug.assert(hash.len <= 64); // §FossilArtifact — SHA-1 (40) or SHA-256 (64) hex
    std.debug.assert(content.len <= reply_body_cap);
    var header: [128]u8 = undefined;
    const h = try std.fmt.bufPrint(&header, "file {s} {d}\n", .{ hash, content.len });
    try out.appendSlice(gpa, h);
    try out.appendSlice(gpa, content);
}

test "a protected-branch glob list separates on whitespace too" {
    // The branch globs go through the same tokenizer as every `*-glob`
    // setting (fossil parses them with `glob_create` as well), so an operator
    // who writes the list the way fossil documents gets both patterns.
    const globs = "release/* hotfix/*";
    const rel: artifact.TCard = .{ .sign = .add, .name = "branch", .target = null, .value = "release/1.0" };
    const hot: artifact.TCard = .{ .sign = .add, .name = "branch", .target = null, .value = "hotfix/9" };
    const other: artifact.TCard = .{ .sign = .add, .name = "branch", .target = null, .value = "feat/x" };
    try std.testing.expectEqualStrings("release/1.0", protectedBranchOfTag(rel, globs).?);
    try std.testing.expectEqualStrings("hotfix/9", protectedBranchOfTag(hot, globs).?);
    try std.testing.expect(protectedBranchOfTag(other, globs) == null);
}

test "sync §3.3 L569-570: writeGimme emits the canonical gimme card" {
    var buf: [128]u8 = undefined;
    const h = "8e0933ed1ef9aa0051d185c8123f0b9ac79865e4b70098b877543fca0092c259";
    try std.testing.expectEqualStrings("gimme " ++ h, try writeGimme(h, &buf));
}

test "sync §8.6 L1813-1814: writeIgot emits the public and private igot forms" {
    var buf: [128]u8 = undefined;
    const h = "8e0933ed1ef9aa0051d185c8123f0b9ac79865e4b70098b877543fca0092c259";
    try std.testing.expectEqualStrings("igot " ++ h, try writeIgot(h, false, &buf));
    try std.testing.expectEqualStrings("igot " ++ h ++ " 1", try writeIgot(h, true, &buf));
}

test "sync §7.1: writeFile emits file HASH SIZE and exactly SIZE payload bytes" {
    const h = "8e0933ed1ef9aa0051d185c8123f0b9ac79865e4b70098b877543fca0092c259";
    // §A.1 L2547-2548, L2556-2557 — alpha.txt is 26 bytes ("...client\n").
    const payload = "alpha content from client\n";
    var out: std.ArrayListUnmanaged(u8) = .empty;
    defer out.deinit(std.testing.allocator);
    try writeFile(std.testing.allocator, &out, h, payload);
    try std.testing.expectEqualStrings("file " ++ h ++ " 26\n" ++ payload, out.items);
    // Negative space: the payload is raw bytes, so an embedded NUL is carried
    // verbatim and counted in SIZE (§4 r? — wire data is []const u8, NUL-legal).
    out.clearRetainingCapacity();
    const nul_payload = "a\x00b";
    try writeFile(std.testing.allocator, &out, h, nul_payload);
    try std.testing.expectEqualStrings("file " ++ h ++ " 3\n" ++ nul_payload, out.items);
}

test "sync §3.3 L553-555: buildRequest omits gimme for a private phantom until private sync is enabled" {
    var ms = repostate.MemStore.init(std.testing.allocator);
    defer ms.deinit();
    const h = "b1c1b05fea4f52bd90b15fdffd13f5c91dce6505f498eeb79b4952c2ea8d4d15";
    const rid = try ms.store().intern(h); // a phantom: content absent
    var client = Client{ .operation = .sync };
    defer client.deinit(std.testing.allocator);
    // A private `igot HASH 1` (§7.5) would set this; mark it directly here.
    try client.phantoms.put(std.testing.allocator, rid, .{ .private = true });
    var out: std.ArrayListUnmanaged(u8) = .empty;
    defer out.deinit(std.testing.allocator);

    // Private-content sync disabled (default) ⇒ the private phantom is excluded.
    const gated = try client.buildRequest(std.testing.allocator, ms.store(), &out);
    try std.testing.expect(std.mem.indexOf(u8, gated, "gimme " ++ h) == null);

    // Enabling it makes the same phantom eligible — proving the filter, not an
    // unrelated omission, is the cause.
    client.private_granted = true;
    const opened = try client.buildRequest(std.testing.allocator, ms.store(), &out);
    try std.testing.expect(std.mem.indexOf(u8, opened, "gimme " ++ h) != null);
}

// §3.3 r24: a phantom is a hash known with content absent — this is
// `ArtifactStore`'s own contract (`content(rid) == null`), not a sync-layer
// mechanism. No `isPhantom` wrapper enters this file; the definition is
// asserted directly against the store below (store-contract dependency
// test, not sync coverage).
// §3.3 r36: storing content dephantomizes — again the store's own contract,
// asserted directly (store-contract dependency test, not sync coverage).
/// §3.2 r22/r23, L463-481 — request-card classification for the ordering
/// rule. `content_request` covers clone and gimme (L471); `uvgimme` is
/// unversioned and deferred. `send_private`/`login` classify as `.other`
/// (L458-463), so the two transport/order exceptions fall out of the
/// ordering scan below without special-casing them.
pub const RequestCard = enum { client_version, content_request, other };

pub const RequestOrder = enum { ok, missing_client_version, version_after_content_request };

/// §3.2, L463-481 — a request is well-ordered iff it carries a
/// client-version pragma and no content-requesting card precedes it. An
/// empty body has no client-version card, so it is itself the r22
/// rejection — no length assertion, no panic (an empty request is legal
/// input, not a caller bug).
pub fn checkRequestOrder(cards: []const RequestCard) RequestOrder {
    var version_index: ?usize = null;
    var content_index: ?usize = null;
    for (cards, 0..) |card, i| {
        switch (card) {
            .client_version => if (version_index == null) {
                version_index = i;
            },
            .content_request => if (content_index == null) {
                content_index = i;
            },
            .other => {},
        }
    }
    const result: RequestOrder = if (version_index == null)
        .missing_client_version
    else if (content_index != null and content_index.? < version_index.?)
        .version_after_content_request
    else
        .ok;
    std.debug.assert(result != .ok or (version_index != null and (content_index == null or content_index.? > version_index.?)));
    return result;
}

// ---------------------------------------------------------------------------
// Section 8 — Clone
// ---------------------------------------------------------------------------

/// §8.1 L1529-1531 — the sole interoperable clone transfer path this
/// document defines. Orthogonal to the release integer carried in
/// version pragmas (L1881-1885): the 20000 threshold in `sha3PeerVersionGate`
/// names a Fossil release, never this transfer-path version.
pub const clone_version: u8 = 3;

/// §8.1 L1527 — write `clone VERSION SEQNO`. SEQNO is a blob-table row
/// identifier (§8.2 L1551), always non-negative on the request side (a
/// negative NEXT received in a reply is fatal before it is ever
/// re-sent, per `cloneSeqnoIsFatal`).
pub fn writeCloneCard(seqno: u64, out: []u8) std.fmt.BufPrintError![]u8 {
    const needed = std.fmt.count("clone {d} {d}", .{ clone_version, seqno });
    std.debug.assert(needed <= 28); // "clone 3 " + max u64 digits, ceiling
    std.debug.assert(out.len >= needed);
    return try std.fmt.bufPrint(out, "clone {d} {d}", .{ clone_version, seqno });
}

/// §8.2 L1575-1581 — parse the reply's `clone_seqno NEXT` card. A
/// present-but-non-integer NEXT is the sole real parse failure; a
/// negative value parses cleanly here and is instead the fatal-cause
/// path's job (`cloneSeqnoIsFatal`), not a rejection at this layer.
pub fn parseCloneSeqno(tokens: Tokens) error{Malformed}!i64 {
    if (tokens.len < 2) return error.Malformed;
    return std.fmt.parseInt(i64, tokens.items[1], 10) catch error.Malformed;
}

/// §8.2 L1637-1641 loop-tail — the shape of the next request, given
/// whether Section 3.7 continues the exchange and the NEXT just
/// received. `next > 0` sends another clone card; a live continuation
/// with NEXT already exhausted is a cleanup round (no clone card, L1930-
/// 1934); no continuation stops. The MUST-NOT-after-0 rule (L1927-1928)
/// falls out structurally: `.clone_card` is unreachable once NEXT is 0.
pub const NextRequest = enum { clone_card, cleanup_round, stop };

pub fn nextRequestKind(continuing: bool, clone_seqno_next: i64) NextRequest {
    std.debug.assert(clone_seqno_next >= 0); // negative NEXT aborts before this is reached
    if (!continuing) return .stop;
    return if (clone_seqno_next > 0) .clone_card else .cleanup_round;
}

/// §8.2 L1577-1579 + §3.7 L646-647 — no draft rule makes this a server MUST: the client never validates advancement, so this is a zit-local invariant that a
/// positive NEXT is only legal when the round it ends also advanced the
/// sequence. `Server.emitCloneReply` upholds it structurally (a positive
/// NEXT is produced only after a full batch advanced the cursor); this is
/// the pure predicate that states it.
pub fn cloneServerMustAdvance(clone_seqno_next: i64, unsent_at_or_above_next: bool) bool {
    std.debug.assert(clone_seqno_next >= 0);
    return clone_seqno_next > 0 and unsent_at_or_above_next;
}

/// §8.1/§8.2 — the outcome of classifying a server-received `clone` request.
/// `accept` carries the SEQNO (a blob-table RID ≥ 1) the reply pages from;
/// `invalid_seqno` is the §8.1 L1533-1538 fatal error; `malformed` is any other
/// shape, which §8.1 L1538-1548 explicitly keeps OUT of the fatal path (a plain
/// §5.2 bad command instead).
const CloneRequestClass = union(enum) { accept: u64, invalid_seqno, malformed };

/// §8.1 L1527-1548 — classify `clone VERSION SEQNO`. The fatal invalid-sequence
/// error is reserved for exactly three tokens, an integer VERSION ≥ 2, and a
/// digit-only SEQNO whose value is ≤ 0 (digit-only means ≥ 0, so ≤ 0 is exactly
/// 0). Acceptance additionally requires the sole interoperable VERSION 3 (L1365)
/// and a positive SEQNO. Wrong arity, a noninteger/out-of-scope VERSION, or a
/// non-digit SEQNO is `malformed`, never the fatal error (L1374-1376).
fn classifyCloneRequest(tokens: Tokens) CloneRequestClass {
    std.debug.assert(tokens.len >= 1);
    std.debug.assert(parseOperator(tokens.items[0]) == .clone);
    if (tokens.len != 3) return .malformed;
    const version = std.fmt.parseInt(i64, tokens.items[1], 10) catch return .malformed;
    if (!isAllDigits(tokens.items[2])) return .malformed; // §8.1 — SEQNO must be digit-only
    const seqno = std.fmt.parseInt(u64, tokens.items[2], 10) catch return .malformed;
    if (version >= 2 and seqno == 0) return .invalid_seqno; // §8.1 L1533-1537
    if (version == @as(i64, clone_version) and seqno > 0) return .{ .accept = seqno }; // §8.1 L1529
    return .malformed;
}

/// §8.1 L1539-1548 — "digit-only recognition": non-empty and every byte a
/// decimal digit, so a leading sign or any non-digit rejects (unlike
/// `parseInt`, which would accept a `+`/`-` sign).
fn isAllDigits(s: []const u8) bool {
    if (s.len == 0) return false;
    for (s) |c| if (c < '0' or c > '9') return false;
    return true;
}

/// §7.2 L1259-1264/§8.2 L1573-1575 — append one full-content `cfile HASH USIZE CSIZE\n`
/// card followed by exactly CSIZE stored-container bytes. USIZE is the
/// uncompressed artifact size; the payload is the §4.1 zlib container
/// (`codec.encode`) over `content`, which the peer decodes back to
/// `content` verbatim (§7.2 L1265-1266). The artifact hash is over the
/// UNCOMPRESSED bytes, so storage-side compression never changes HASH. NUL-legal
/// `[]const u8`, no sentinel. Closed error set: allocation, an oversized input
/// the container length field cannot frame, and the (bounds-asserted) header
/// format failure.
fn writeCfile(
    gpa: std.mem.Allocator,
    out: *std.ArrayListUnmanaged(u8),
    hash: []const u8,
    content: []const u8,
) error{ OutOfMemory, InputTooLarge, NoSpaceLeft }!void {
    std.debug.assert(!containsFossilWhitespace(hash));
    std.debug.assert(hash.len <= 64); // §FossilArtifact — SHA-1 (40) or SHA-256 (64) hex
    std.debug.assert(content.len <= reply_body_cap);
    const container = codec.encode(gpa, content, .default) catch |err| switch (err) {
        error.OutOfMemory => return error.OutOfMemory,
        error.InputTooLarge => return error.InputTooLarge,
    };
    defer gpa.free(container);
    var header: [160]u8 = undefined;
    const h = try std.fmt.bufPrint(&header, "cfile {s} {d} {d}\n", .{ hash, content.len, container.len });
    try out.appendSlice(gpa, h);
    try out.appendSlice(gpa, container);
    // §5.1 L937-948 — append one padding LF only when the counted compressed
    // payload does not itself end in LF, so a strict reference receiver consumes
    // it as an empty line (see cfilePaddingEnd, which skips under the identical
    // predicate). Keyed on the container's LAST byte; a zlib stream may embed LF
    // elsewhere. Omitted when the payload self-terminates in LF, so no receiver
    // over-skips the next card.
    if (container.len == 0 or container[container.len - 1] != '\n') {
        try out.append(gpa, '\n');
    }
}

/// §4.1 L918-919/§7.2 L1265-1266 — append one full-content cfile card whose
/// payload is the artifact's ALREADY-STORED §4.1 `container`, retransmitted
/// verbatim, with no re-compression: the stored blob "uses the same
/// byte-identical container and [is] retransmitted verbatim." USIZE is read
/// from the container's own first four bytes (§4.1 — the container's big-endian
/// uncompressed length); CSIZE is the container's byte length. The card is the
/// full form `cfile HASH USIZE CSIZE` — a delta-stored artifact has no verbatim
/// wire form here and is served through the `writeCfile` reconstruct path
/// instead. Applies the identical §5.1 trailing-LF padding rule as `writeCfile`,
/// keyed on the payload's last byte. NUL-legal `[]const u8`, no sentinel. Closed
/// error set: allocation and the (bounds-asserted) header format failure.
fn writeCfileVerbatim(
    gpa: std.mem.Allocator,
    out: *std.ArrayListUnmanaged(u8),
    hash: []const u8,
    container: []const u8,
) error{ OutOfMemory, NoSpaceLeft }!void {
    std.debug.assert(!containsFossilWhitespace(hash));
    std.debug.assert(hash.len <= 64); // §FossilArtifact — SHA-1 (40) or SHA-256 (64) hex
    std.debug.assert(container.len >= 4); // §4.1 — the container carries its 4-byte length prefix
    const usize_bytes = std.mem.readInt(u32, container[0..4], .big); // §4.1 — USIZE is the container's own prefix
    var header: [128]u8 = undefined;
    const h = try std.fmt.bufPrint(&header, "cfile {s} {d} {d}\n", .{ hash, usize_bytes, container.len });
    try out.appendSlice(gpa, h);
    try out.appendSlice(gpa, container);
    // §5.1 L937-948 — one padding LF iff the counted payload does not itself
    // end in LF, identical to `writeCfile` (round-trip with `cfilePaddingEnd`).
    if (container.len == 0 or container[container.len - 1] != '\n') {
        try out.append(gpa, '\n');
    }
}

/// §7.2 L1260/L1262-1264 — append one DELTA-form cfile card
/// `cfile HASH DELTASRC USIZE CSIZE` whose payload is the artifact's
/// ALREADY-STORED §4.1 container of a [FossilDelta] against DELTASRC,
/// retransmitted verbatim with no re-compression.
///
/// USIZE is `target_size`, the sender's full RECONSTRUCTED artifact size (read
/// from the delta's own header, delta §3.2 — "the length in bytes of the
/// reconstructed target"). It is deliberately NOT the container's 4-byte prefix:
/// that prefix is the RAW DELTA's length (§4.1 framing of the delta payload),
/// which is exactly the value the deleted code wrongly emitted. §7.2
/// L1142-1144: "USIZE is the sender's full reconstructed artifact size … in
/// delta form it is not delta size." CSIZE is the container's byte length; it
/// alone frames the payload. Same §5.1 trailing-LF padding rule as the full
/// forms. NUL-legal `[]const u8`, no sentinel. Closed error set: allocation and
/// the (bounds-asserted) header format failure.
fn writeCfileVerbatimDelta(
    gpa: std.mem.Allocator,
    out: *std.ArrayListUnmanaged(u8),
    hash: []const u8,
    deltasrc: []const u8,
    target_size: u64,
    container: []const u8,
) error{ OutOfMemory, NoSpaceLeft }!void {
    std.debug.assert(!containsFossilWhitespace(hash));
    std.debug.assert(!containsFossilWhitespace(deltasrc));
    std.debug.assert(hash.len <= 64 and deltasrc.len <= 64); // §FossilArtifact — SHA-1 (40) or SHA-256 (64) hex
    std.debug.assert(container.len >= 4); // §4.1 — the container carries its 4-byte length prefix
    // §7.2 — USIZE is the reconstructed size and MUST fit the u32 USIZE token
    // (the receiver parses it as u32); the delta header enforces the same cap.
    std.debug.assert(target_size <= std.math.maxInt(u32));
    // §7.2 L1262-1264 — emit `target_size` as USIZE, NEVER the container's
    // 4-byte prefix; reading that prefix here would reintroduce that defect.
    var header: [192]u8 = undefined;
    const h = try std.fmt.bufPrint(&header, "cfile {s} {s} {d} {d}\n", .{ hash, deltasrc, target_size, container.len });
    try out.appendSlice(gpa, h);
    try out.appendSlice(gpa, container);
    // §5.1 L937-948 — one padding LF iff the counted payload does not itself
    // end in LF, identical to the full forms (round-trip with `cfilePaddingEnd`).
    if (container.len == 0 or container[container.len - 1] != '\n') {
        try out.append(gpa, '\n');
    }
}

/// §8.2 L1575-1577 — write `clone_seqno NEXT`; a canonical sender uses decimal NEXT
/// (L1388). NEXT zero signals exhaustion; a positive NEXT is the next RID.
fn writeCloneSeqno(next: u64, out: []u8) std.fmt.BufPrintError![]u8 {
    const needed = std.fmt.count("clone_seqno {d}", .{next});
    std.debug.assert(out.len >= needed);
    return try std.fmt.bufPrint(out, "clone_seqno {d}", .{next});
}

// --- §8 clone: inline tests reaching file-private decls (per the project's
// kind-separation rule: the public `Server.respond` behaviour is tested from
// the sibling `sync_test.zig`; these reach `classifyCloneRequest`, `writeCfile`,
// and `clone_cfile_batch_max`, which are private to this file). Expected values
// come from the sync RFC §8 text only, never from observing Fossil.

test "sync §8.1: classifyCloneRequest accepts clone 3 SEQNO>0, and rejects only VERSION>=2 SEQNO 0" {
    const cls = struct {
        fn f(line: []const u8) CloneRequestClass {
            return classifyCloneRequest(classifyCard(line).tokens);
        }
    }.f;
    // §8.1 L1527-1531 — accept requires the sole interoperable VERSION 3 and a
    // positive, digit-only SEQNO; the SEQNO becomes the reply's paging cursor.
    try std.testing.expectEqual(@as(u64, 1), cls("clone 3 1").accept);
    try std.testing.expectEqual(@as(u64, 7), cls("clone 3 7").accept);
    // §8.1 L1533-1538 — the ONLY fatal shape: three tokens, VERSION>=2, and a
    // digit-only SEQNO whose value is <= 0 (digit-only => exactly 0).
    try std.testing.expect(cls("clone 3 0") == .invalid_seqno);
    try std.testing.expect(cls("clone 2 0") == .invalid_seqno);
}

test "sync §8.1 L1538-1548: the invalid-sequence carve-out — wrong shapes are bad command, never fatal" {
    const cls = struct {
        fn f(line: []const u8) CloneRequestClass {
            return classifyCloneRequest(classifyCard(line).tokens);
        }
    }.f;
    try std.testing.expect(cls("clone 3") == .malformed); // wrong arity
    try std.testing.expect(cls("clone x 5") == .malformed); // noninteger VERSION
    try std.testing.expect(cls("clone 3 -1") == .malformed); // SEQNO not digit-only
    try std.testing.expect(cls("clone 3 5z") == .malformed); // SEQNO not digit-only
    try std.testing.expect(cls("clone 1 5") == .malformed); // out-of-scope VERSION < 2, positive SEQNO
    try std.testing.expect(cls("clone 2 5") == .malformed); // VERSION 2 out of scope (only 3 interoperable)
    // §8.1 L1538-1548 — a noninteger VERSION with SEQNO 0 is NOT the fatal error.
    try std.testing.expect(cls("clone x 0") == .malformed);
}

test "sync §8.1 L1534-1535: isAllDigits requires a non-empty all-decimal string" {
    try std.testing.expect(isAllDigits("0"));
    try std.testing.expect(isAllDigits("2048"));
    try std.testing.expect(!isAllDigits("")); // empty is not digit-only
    try std.testing.expect(!isAllDigits("+5")); // a sign is not a digit
    try std.testing.expect(!isAllDigits("-5"));
    try std.testing.expect(!isAllDigits("1a"));
}

test "sync §8.2 L1575-1577: writeCloneSeqno emits decimal NEXT, 0 signalling exhaustion" {
    var buf: [64]u8 = undefined;
    try std.testing.expectEqualStrings("clone_seqno 0", try writeCloneSeqno(0, &buf));
    try std.testing.expectEqualStrings("clone_seqno 42", try writeCloneSeqno(42, &buf));
}

test "sync §7.2 L1259-1266: writeCfile frames cfile HASH USIZE CSIZE and a container decoding back to content" {
    const gpa = std.testing.allocator;
    const hash = "8e0933ed1ef9aa0051d185c8123f0b9ac79865e4b70098b877543fca0092c259";
    const content = "hello from zit over http\n"; // §A-style content, NUL-free here
    var out: std.ArrayListUnmanaged(u8) = .empty;
    defer out.deinit(gpa);
    try writeCfile(gpa, &out, hash, content);

    const nl = std.mem.indexOfScalar(u8, out.items, '\n').?;
    const cc = try parseCfileCard(classifyCard(out.items[0..nl]).tokens);
    try std.testing.expectEqualStrings(hash, cc.hash);
    try std.testing.expect(cc.deltasrc == null);
    // §7.2 L1262-1263 — USIZE is the uncompressed size; CSIZE frames the payload.
    try std.testing.expectEqual(@as(u32, content.len), cc.uncompressed_size);
    // §5.1 — the payload is exactly CSIZE bytes; any §5.1 padding LF that
    // writeCfile appends past it is not part of the counted payload.
    const payload = out.items[nl + 1 .. nl + 1 + cc.compressed_size];
    try std.testing.expectEqual(@as(usize, cc.compressed_size), payload.len);
    const decoded = try codec.decode(gpa, payload, reply_body_cap);
    defer gpa.free(decoded);
    try std.testing.expectEqualStrings(content, decoded); // §7.2 L1265-1266 — reconstructed verbatim
}

test "sync §7.2 L1262-1264: served delta cfile USIZE is the reconstructed artifact size, not the delta length" {
    // THE regression guard: the deleted code wrote the
    // container's 4-byte prefix (the RAW DELTA length) as USIZE. This proves
    // writeCfileVerbatimDelta emits the reconstructed target size instead.
    const gpa = std.testing.allocator;
    const base = "a fairly long base artifact whose bytes are largely shared with the target so the delta is dominated by one big copy segment here";
    const target = "a fairly long base artifact whose bytes are largely shared with the target so the delta is dominated by one big copy segment here!!";
    const raw = try delta.create(gpa, base, target);
    defer gpa.free(raw);
    const container = try codec.encode(gpa, raw, .default);
    defer gpa.free(container);
    const target_size = try delta.targetSize(raw);
    try std.testing.expectEqual(@as(usize, target.len), target_size);
    // The three candidate USIZE values are all distinct, so the guard is meaningful.
    const container_prefix = std.mem.readInt(u32, container[0..4], .big); // = raw delta length (§4.1 framing)
    try std.testing.expectEqual(@as(u32, @intCast(raw.len)), container_prefix);
    try std.testing.expect(raw.len != target.len);
    try std.testing.expect(container.len != target.len);

    const hash = "8e0933ed1ef9aa0051d185c8123f0b9ac79865e4b70098b877543fca0092c259";
    const deltasrc = "d1e5e4e40a2f3f6b1c9a8b7c6d5e4f30211122233344455566677788899900aa";
    var out: std.ArrayListUnmanaged(u8) = .empty;
    defer out.deinit(gpa);
    try writeCfileVerbatimDelta(gpa, &out, hash, deltasrc, target_size, container);

    const nl = std.mem.indexOfScalar(u8, out.items, '\n').?;
    const cc = try parseCfileCard(classifyCard(out.items[0..nl]).tokens);
    try std.testing.expectEqualStrings(hash, cc.hash);
    try std.testing.expect(cc.deltasrc != null);
    try std.testing.expectEqualStrings(deltasrc, cc.deltasrc.?);
    // §7.2 L1262-1264 — USIZE is the reconstructed target size, NEVER the delta
    // length and NEVER the container's 4-byte prefix (the raw delta length).
    try std.testing.expectEqual(@as(u32, target.len), cc.uncompressed_size);
    try std.testing.expect(cc.uncompressed_size != container_prefix);
    try std.testing.expect(cc.uncompressed_size != @as(u32, @intCast(raw.len)));
    // CSIZE frames the payload: exactly the container byte length.
    try std.testing.expectEqual(@as(u32, @intCast(container.len)), cc.compressed_size);
}

test "sync §7.2 L1265-1266: served delta cfile payload is the stored delta container, retransmitted verbatim, and round-trips" {
    const gpa = std.testing.allocator;
    const base = "verbatim delta serve: a base whose long shared run makes the delta a small copy-dominated payload, distinct from the reconstructed size";
    const target = "verbatim delta serve: a base whose long shared run makes the delta a small copy-dominated payload, distinct from the reconstructed size!";
    const raw = try delta.create(gpa, base, target);
    defer gpa.free(raw);
    const container = try codec.encode(gpa, raw, .default);
    defer gpa.free(container);
    const target_size = try delta.targetSize(raw);

    const hash = "1111111111111111111111111111111111111111111111111111111111111111";
    const deltasrc = "2222222222222222222222222222222222222222222222222222222222222222";
    var out: std.ArrayListUnmanaged(u8) = .empty;
    defer out.deinit(gpa);
    try writeCfileVerbatimDelta(gpa, &out, hash, deltasrc, target_size, container);

    const nl = std.mem.indexOfScalar(u8, out.items, '\n').?;
    const cc = try parseCfileCard(classifyCard(out.items[0..nl]).tokens);
    // §7.2 L1265-1266 — the counted payload equals the stored container byte-for-byte.
    const payload = out.items[nl + 1 .. nl + 1 + cc.compressed_size];
    try std.testing.expectEqualSlices(u8, container, payload);
    // §7.2/§7.4 — the container decodes to the raw delta, which reconstructs the
    // original target against its basis (client-side reconstruction).
    const decoded = try codec.decode(gpa, payload, reply_body_cap);
    defer gpa.free(decoded);
    const recon = try delta.apply(gpa, base, decoded);
    defer gpa.free(recon);
    try std.testing.expectEqualStrings(target, recon);
}

test "sync §5.1: cfilePaddingEnd consumes one padding LF iff the payload does not end in LF" {
    // A payload NOT ending in LF is followed on the wire by one padding LF, then
    // the next card: the resume offset skips exactly that padding LF.
    const with_pad = "ABCD\nnext"; // payload "ABCD" (last byte 'D'), padding LF at index 4
    const p1 = try consumeContentPayload(with_pad, 0, 4);
    try std.testing.expectEqual(@as(usize, 5), cfilePaddingEnd(with_pad, p1)); // skipped the padding LF at index 4

    // A payload ending in LF carries NO padding; the resume offset is unchanged,
    // so a following byte (real card or empty line) is left for the loop.
    const no_pad = "AB\nX"; // payload "AB\n" ends in LF
    const p2 = try consumeContentPayload(no_pad, 0, 3);
    try std.testing.expectEqual(@as(usize, 3), cfilePaddingEnd(no_pad, p2)); // no over-skip: 'X' left intact

    // Payload ending in LF immediately before a bare empty line: the empty line
    // is NOT consumed (negative space — it is the loop's to reject), not padding.
    const lf_then_empty = "AB\n\n";
    const p3 = try consumeContentPayload(lf_then_empty, 0, 3);
    try std.testing.expectEqual(@as(usize, 3), cfilePaddingEnd(lf_then_empty, p3));

    // Bounded: a payload not ending in LF that is the final bytes of the body
    // (no padding on the wire) never advances past the end.
    const truncated = "ABCD";
    const p4 = try consumeContentPayload(truncated, 0, 4);
    try std.testing.expectEqual(@as(usize, 4), cfilePaddingEnd(truncated, p4));
}

test "sync §5.1: writeCfile appends the trailing-LF padding iff the container does not end in LF" {
    const gpa = std.testing.allocator;
    const hash = repeat("b", 40);
    const content = "some artifact bytes for the padding predicate";
    const container = try codec.encode(gpa, content, .default);
    defer gpa.free(container);
    var out: std.ArrayListUnmanaged(u8) = .empty;
    defer out.deinit(gpa);
    try writeCfile(gpa, &out, hash, content);

    const header = try std.fmt.allocPrint(gpa, "cfile {s} {d} {d}\n", .{ hash, content.len, container.len });
    defer gpa.free(header);
    const framed = header.len + container.len; // header + counted CSIZE payload
    // §5.1 — padding LF present exactly when the container does not end in LF.
    const padded = container.len == 0 or container[container.len - 1] != '\n';
    if (padded) {
        try std.testing.expectEqual(framed + 1, out.items.len);
        try std.testing.expectEqual(@as(u8, '\n'), out.items[out.items.len - 1]);
    } else {
        try std.testing.expectEqual(framed, out.items.len);
    }
}

test "sync §7.2 L1265-1266: writeCfileVerbatim retransmits the stored container byte-for-byte" {
    const gpa = std.testing.allocator;
    const hash = "8e0933ed1ef9aa0051d185c8123f0b9ac79865e4b70098b877543fca0092c259";
    const content = "verbatim serve: the stored container is the wire payload\n";
    const container = try codec.encode(gpa, content, .default);
    defer gpa.free(container);

    // Full form (cfile HASH USIZE CSIZE): USIZE is read from the container's own
    // §4.1 4-byte prefix, CSIZE is the container length, and the payload is the
    // stored container verbatim — never re-encoded.
    {
        var out: std.ArrayListUnmanaged(u8) = .empty;
        defer out.deinit(gpa);
        try writeCfileVerbatim(gpa, &out, hash, container);
        const nl = std.mem.indexOfScalar(u8, out.items, '\n').?;
        const cc = try parseCfileCard(classifyCard(out.items[0..nl]).tokens);
        try std.testing.expectEqualStrings(hash, cc.hash);
        try std.testing.expect(cc.deltasrc == null); // §7.2 — verbatim serving is full-form only
        try std.testing.expectEqual(@as(u32, content.len), cc.uncompressed_size); // §4.1 — USIZE == the container prefix
        try std.testing.expectEqual(@as(u32, @intCast(container.len)), cc.compressed_size);
        const payload = out.items[nl + 1 .. nl + 1 + cc.compressed_size];
        try std.testing.expectEqualSlices(u8, container, payload); // §7.2 L1265-1266 — byte-for-byte the stored container
    }
}

test "sync §5.1: a verbatim-served cfile applies the trailing padding-LF rule" {
    const gpa = std.testing.allocator;
    const hash = repeat("c", 40);
    // The predicate keys on the payload's LAST byte only (§5.1 L937-948); the
    // container is opaque to the writer, so synthetic payloads carrying the §4.1
    // 4-byte length prefix (value 1) exercise both arms without a real stream.
    const Case = struct { container: []const u8, want_pad: bool };
    const cases = [_]Case{
        .{ .container = &[_]u8{ 0, 0, 0, 1, 'X', '\n' }, .want_pad = false }, // ends in LF: no padding
        .{ .container = &[_]u8{ 0, 0, 0, 1, 'X', 'Y' }, .want_pad = true }, // no trailing LF: one padding LF
    };
    for (cases) |c| {
        var out: std.ArrayListUnmanaged(u8) = .empty;
        defer out.deinit(gpa);
        try writeCfileVerbatim(gpa, &out, hash, c.container);
        const nl = std.mem.indexOfScalar(u8, out.items, '\n').?;
        const cc = try parseCfileCard(classifyCard(out.items[0..nl]).tokens);
        const payload = try consumeContentPayload(out.items, nl + 1, cc.compressed_size);
        try std.testing.expectEqualSlices(u8, c.container, payload.bytes); // verbatim
        const resume_at = cfilePaddingEnd(out.items, payload);
        if (c.want_pad) {
            try std.testing.expectEqual(payload.next + 1, resume_at); // §5.1 — one padding LF consumed
            try std.testing.expectEqual(out.items.len, resume_at);
            try std.testing.expectEqual(@as(u8, '\n'), out.items[out.items.len - 1]);
        } else {
            try std.testing.expectEqual(payload.next, resume_at); // no over-skip
            try std.testing.expectEqual(out.items.len, payload.next); // body ends exactly at the payload
        }
    }
}

/// Test walker: return the CSIZE-framed payload of the first `cfile` card in a
/// reply body whose HASH matches, advancing past each cfile's §5.1 padding.
fn findCfilePayload(body: []const u8, hash: []const u8) !([]const u8) {
    var cursor: usize = 0;
    while (cursor < body.len) {
        const line = try readLine(body, cursor);
        const cls = classifyCard(line.bytes);
        if (cls.operator == .cfile) {
            const cc = try parseCfileCard(cls.tokens);
            const payload = try consumeContentPayload(body, line.next, cc.compressed_size);
            if (std.mem.eql(u8, cc.hash, hash)) return payload.bytes;
            cursor = cfilePaddingEnd(body, payload);
        } else {
            cursor = line.next;
        }
    }
    return error.NotFound;
}

test "sync §8.2: a clone reply mixing raw and zlib stored artifacts serves both paths" {
    const gpa = std.testing.allocator;
    var ms = repostate.MemStore.init(gpa);
    defer ms.deinit();

    // rid 1: a RAW entry (put) → no stored wire → the fallback compresses it on
    // serve. rid 2: a ZLIB entry (putWire) → served as its stored container.
    const hash_a = repeat("a", 64);
    const content_a = "raw-stored artifact: served by the compress fallback\n";
    _ = try ms.store().put(hash_a, content_a);
    const hash_b = repeat("b", 64);
    const content_b = "zlib-stored artifact: served as the stored container\n";
    const container_b = try codec.encode(gpa, content_b, .default);
    defer gpa.free(container_b);
    _ = try ms.putWire(hash_b, container_b);

    const server = Server{
        .version = .{ .version = 22800 },
        .self_servercode = "1111111111111111",
        .project_code = "2222222222222222",
    };
    var scan: RequestScan = .{};
    defer scan.deinit(gpa);
    var out: std.ArrayListUnmanaged(u8) = .empty;
    defer out.deinit(gpa);
    var line_buf: [256]u8 = undefined;
    // §8.2 — clone reply from SEQNO 1: both artifacts as cfile cards, RID order.
    try server.emitCloneReply(gpa, ms.store(), &scan, &out, &line_buf, 1);

    const a = try findCfilePayload(out.items, hash_a);
    const b = try findCfilePayload(out.items, hash_b);
    // §7.2 L1265-1266 — the zlib artifact is retransmitted byte-for-byte.
    try std.testing.expectEqualSlices(u8, container_b, b);
    // Both payloads decode back to their content: the raw one through the
    // on-the-fly fallback, the zlib one through its verbatim container.
    const dec_a = try codec.decode(gpa, a, reply_body_cap);
    defer gpa.free(dec_a);
    try std.testing.expectEqualStrings(content_a, dec_a);
    const dec_b = try codec.decode(gpa, b, reply_body_cap);
    defer gpa.free(dec_b);
    try std.testing.expectEqualStrings(content_b, dec_b);
}

test "sync §5.1: writeCfile→consumeReply round-trips consecutive cfiles with mixed payload endings" {
    const gpa = std.testing.allocator;
    // Two back-to-back cfiles emitted by the writer are parsed and stored by the
    // reader through the §5.1 padding on each boundary — the sender-emits and
    // receiver-consumes halves are mutually consistent (no over/under-skip).
    const hash_a = repeat("c", 40);
    const hash_b = repeat("d", 40);
    var out: std.ArrayListUnmanaged(u8) = .empty;
    defer out.deinit(gpa);
    try writeCfile(gpa, &out, hash_a, "first artifact content over the wire");
    try writeCfile(gpa, &out, hash_b, "second artifact content, distinct bytes");

    var ms = repostate.MemStore.init(gpa);
    defer ms.deinit();
    var client = Client{ .operation = .sync, .state = .exchange };
    defer client.deinit(gpa);
    _ = try client.consumeReply(gpa, ms.store(), out.items);
    try std.testing.expect(client.state != .aborted);
    try std.testing.expect(ms.store().lookup(hash_a) != null); // both cfiles crossed the padded boundaries
    try std.testing.expect(ms.store().lookup(hash_b) != null);
}

// Test-only reply walker: iterate a reply's cards, framing past a file/cfile
// payload so a binary payload byte is never miscounted as a card boundary.
const CloneReplyIter = struct {
    body: []const u8,
    pos: usize = 0,
    fn next(self: *CloneReplyIter) ?CardClass {
        while (self.pos < self.body.len) {
            const line = readLine(self.body, self.pos) catch return null;
            self.pos = line.next;
            if (isCommentLine(line.bytes)) continue;
            const cc = classifyCard(line.bytes);
            if (cc.operator) |op| switch (op) {
                .cfile => {
                    const c = parseCfileCard(cc.tokens) catch return cc;
                    const payload = consumeContentPayload(self.body, self.pos, c.compressed_size) catch return cc;
                    self.pos = cfilePaddingEnd(self.body, payload); // §5.1 — skip the padding LF like the receiver
                },
                .file => {
                    const c = parseFileCard(cc.tokens) catch return cc;
                    self.pos = (consumeContentPayload(self.body, self.pos, c.size) catch return cc).next;
                },
                else => {},
            };
            return cc;
        }
        return null;
    }
};

test "sync §8.2 L1551-1595: emitCloneReply pages a store larger than one batch across clone_seqno cursors" {
    const gpa = std.testing.allocator;
    var ms = repostate.MemStore.init(gpa);
    defer ms.deinit();
    // Dense RIDs 1..=total, more artifacts than one batch carries (§8.2
    // L1504-1506). The count backstop is exercised through the test knob —
    // the shipped default is byte-bound long before 65,536 tiny cards.
    const batch_cap: usize = 4;
    const total = batch_cap + 3;
    var i: usize = 0;
    while (i < total) : (i += 1) {
        var name: [64]u8 = undefined;
        _ = std.fmt.bufPrint(&name, "{x:0>64}", .{i}) catch unreachable;
        _ = try ms.store().put(&name, "x");
    }
    const server = Server{ .version = .{ .version = 1 }, .self_servercode = "s", .project_code = "p", .clone_batch_max = batch_cap };

    // Batch 1 from SEQNO 1: exactly batch_cap cfiles, then a positive
    // NEXT = cap+1 (the cursor advanced past the sent batch).
    var out1: std.ArrayListUnmanaged(u8) = .empty;
    defer out1.deinit(gpa);
    const r1 = try server.respond(gpa, ms.store(), "pragma client-version 1\nclone 3 1\n", &out1);
    var it1 = CloneReplyIter{ .body = r1 };
    var cfiles1: usize = 0;
    var next1: ?i64 = null;
    while (it1.next()) |cc| {
        switch (cc.operator.?) {
            .cfile => cfiles1 += 1,
            .clone_seqno => next1 = try parseCloneSeqno(cc.tokens),
            else => {},
        }
    }
    try std.testing.expectEqual(batch_cap, cfiles1);
    try std.testing.expectEqual(@as(i64, @intCast(batch_cap + 1)), next1.?);

    // Batch 2 from NEXT: the remaining 3 cfiles and clone_seqno 0 (exhausted).
    var out2: std.ArrayListUnmanaged(u8) = .empty;
    defer out2.deinit(gpa);
    var req2: [48]u8 = undefined;
    const line2 = try std.fmt.bufPrint(&req2, "pragma client-version 1\nclone 3 {d}\n", .{batch_cap + 1});
    const r2 = try server.respond(gpa, ms.store(), line2, &out2);
    var it2 = CloneReplyIter{ .body = r2 };
    var cfiles2: usize = 0;
    var next2: ?i64 = null;
    while (it2.next()) |cc| {
        switch (cc.operator.?) {
            .cfile => cfiles2 += 1,
            .clone_seqno => next2 = try parseCloneSeqno(cc.tokens),
            else => {},
        }
    }
    try std.testing.expectEqual(@as(usize, 3), cfiles2);
    try std.testing.expectEqual(@as(i64, 0), next2.?); // §8.2 L1576 — NEXT zero means exhausted
}

// ---------------------------------------------------------------------------
// Section 9 — Synchronisation Strategies
// ---------------------------------------------------------------------------

/// §8.5 L1687-1688 — push and pull share one wire shape (`KEYWORD
/// SERVERCODE PROJECTCODE`); one writer keyed on direction avoids
/// duplicating the format string.
pub const PushPull = enum { push, pull };

/// §8.5 L1687-1688 — write `push SERVERCODE PROJECTCODE` or `pull
/// SERVERCODE PROJECTCODE`. Neither field may embed Fossil whitespace —
/// that would silently re-tokenize the card on the wire.
pub fn writePushPullCard(dir: PushPull, servercode: []const u8, projectcode: []const u8, out: []u8) std.fmt.BufPrintError![]u8 {
    std.debug.assert(!containsFossilWhitespace(servercode) and !containsFossilWhitespace(projectcode));
    std.debug.assert(servercode.len <= max_line_len and projectcode.len <= max_line_len);
    const keyword = switch (dir) {
        .push => "push",
        .pull => "pull",
    };
    const needed = std.fmt.count("{s} {s} {s}", .{ keyword, servercode, projectcode });
    std.debug.assert(out.len >= needed);
    return try std.fmt.bufPrint(out, "{s} {s} {s}", .{ keyword, servercode, projectcode });
}

/// §8.5 L1687-1688 — a parsed `push|pull SERVERCODE PROJECTCODE`
/// card; PROJECTCODE is also how a clone client, unauthenticated on
/// round 0, first learns the peer's project code (§6.3).
pub const PushPullCard = struct { servercode: []const u8, projectcode: []const u8 };

/// §8.5 L1687-1688 — positional parse; the caller already resolved the
/// operator (push vs. pull) via `classifyCard`/`parseOperator`, so one
/// parser serves both keywords, mirroring `parseReqconfigName`.
pub fn parsePushPull(tokens: Tokens) ?PushPullCard {
    std.debug.assert(tokens.len >= 1);
    const op = parseOperator(tokens.items[0]);
    std.debug.assert(op == .push or op == .pull);
    if (tokens.len < 3) return null;
    return .{ .servercode = tokens.items[1], .projectcode = tokens.items[2] };
}

/// §8.5 L1688-1689 — the server's project-code check: a mismatch is
/// fatal, never a silent proceed.
pub const SyncProjectCode = enum { proceed, refuse_fatal };

pub fn serverSyncProjectCodeDecision(stored: []const u8, received: []const u8) SyncProjectCode {
    std.debug.assert(stored.len <= max_line_len and received.len <= max_line_len);
    return if (std.mem.eql(u8, stored, received)) .proceed else .refuse_fatal;
}

/// §8.6 L1819-1822, §9.1 L1897-1898 (DATE is YYYYMMDD) — the private-
/// igot date threshold as an integer.
pub const private_igot_min_peer_date: u32 = 20200413;

/// §8.6 L1819-1822 — whether a gimme for private content is answered with
/// `igot HASH 1` for the recorded peer date. The draft answers only a date of
/// 2020-04-13 or later and sends nothing for "an earlier or unknown date";
/// this helper still announces for `null`, and only tests call it.
pub const PrivateIgotAction = enum { omit, announce_private };

pub fn privateIgotAction(peer_date: ?u32) PrivateIgotAction {
    comptime std.debug.assert(private_igot_min_peer_date == 20200413);
    const date = peer_date orelse return .announce_private;
    return if (date < private_igot_min_peer_date) .omit else .announce_private;
}

// ---------------------------------------------------------------------------
// Section 4 — Transport
// ---------------------------------------------------------------------------

/// §4.1 L879-887 — the three protocol-defined Content-Type values.
pub const MediaType = enum { x_fossil, x_fossil_debug, x_fossil_uncompressed };

/// §4.1 per-media-type facts, one row per Content-Type. Callers read a
/// field directly rather than going through a per-fact accessor.
pub const MediaTypeProps = struct {
    /// The literal Content-Type wire string.
    wire: []const u8,
    /// r15 true; r16/r17 false — is the outer message body zlib-wrapped?
    outer_compressed: bool,
    /// §4 r2/r3 — may a request arrive with this Content-Type?
    valid_request_type: bool,
    /// r17 — used only for a clone-v3 reply, never a request.
    clone_reply_only: bool,
    /// r16 — x-fossil-debug is diagnostic-only, not for production sync.
    production_allowed: bool,
};

pub const media_props = std.EnumArray(MediaType, MediaTypeProps).init(.{
    .x_fossil = .{ .wire = "application/x-fossil", .outer_compressed = true, .valid_request_type = true, .clone_reply_only = false, .production_allowed = true },
    .x_fossil_debug = .{ .wire = "application/x-fossil-debug", .outer_compressed = false, .valid_request_type = true, .clone_reply_only = false, .production_allowed = false },
    .x_fossil_uncompressed = .{ .wire = "application/x-fossil-uncompressed", .outer_compressed = false, .valid_request_type = false, .clone_reply_only = true, .production_allowed = true },
});

/// §4 r2/r3 — resolve a Content-Type header value against the table; null
/// when its media type names none of the three protocol types. Parameters
/// (`; charset=utf-8`, added by front web servers) are ignored
/// and the type compares case-insensitively, as fossil's own prefix
/// compare in http.c does.
pub fn parseMediaType(s: []const u8) ?MediaType {
    const end = std.mem.indexOfScalar(u8, s, ';') orelse s.len;
    const base = std.mem.trim(u8, s[0..end], " \t");
    for (std.meta.tags(MediaType)) |v| {
        if (std.ascii.eqlIgnoreCase(base, media_props.get(v).wire)) return v;
    }
    return null;
}

/// §4 — the four ways a sync message reaches the wire; only http/https
/// carry redirects (r13).
pub const Transport = enum { http, https, ssh, file };

/// §4 L815-817 (r1) — the request-target is the repository URL's path,
/// unchanged; an empty path uses "/". Total and allocation-free: the
/// result is always a view into `repo_path` or the "/" literal, so the
/// client structurally cannot append a path component (never /xfer).
pub fn syncRequestTarget(repo_path: []const u8) []const u8 {
    return if (repo_path.len == 0) "/" else repo_path;
}

// §4 L819-820 (r6, DEFERRED): the request Content-Length is the exact
// byte count of the payload that follows the header block — a bytes-not-
// cards HTTP-header fact the CLI request builder computes when it emits
// the request; there is no computation to perform or test at this layer.

/// §4 L822-824 (r4) — the sole protocol-defined nonstandard header.
pub const ssh_transport_header: []const u8 = "X-Fossil-Transport: SSH";

/// §4 L822-824 (r4) — sent only when the transport is SSH.
pub fn needsSshTransportHeader(t: Transport) bool {
    return switch (t) {
        .ssh => true,
        .http, .https, .file => false,
    };
}

/// §4 L818-819 (r5) outcome: a POST is processed as sync cards; any
/// other method is never processed and is redirected home instead.
pub const MethodDisposition = enum { process, redirect_home };

/// §4 L818-819 (r5) — a server MUST NOT process sync cards from a
/// non-POST request; it redirects such a request to the repository home
/// page instead.
pub fn serverMethodDisposition(is_post: bool) MethodDisposition {
    return if (is_post) .process else .redirect_home;
}

/// §4 L826-828 (r7/r8) — how the reply body is framed: an exact byte
/// count from Content-Length, or read-until-close when the reply carries
/// none.
pub const ReplyFraming = union(enum) { exact: usize, until_close };

/// §4 L826-828 (r7/r8).
pub fn replyFraming(content_length: ?usize) ReplyFraming {
    return if (content_length) |n| .{ .exact = n } else .until_close;
}

/// A zit-local cap, not a protocol one — §4 L830-833 disclaims any 512 MiB entity limit; this is our own bound on a Content-Length-less
/// reply body (512 MiB).
pub const reply_body_cap: usize = 536_870_912;

/// Evidence seam: how many announcement-suppression probes one reply
/// made — one per held rid the store walk visited. The suppression used to be
/// a linear scan of the requester's announced list per rid, O(held × announced):
/// a no-op pull at 1.25M artifacts spent 760 s of server CPU there and 99.97%
/// of a stack sample. It is a set now, so this count is the walk's length and
/// nothing else; the pin asserts it equals the held count at two sizes, whatever
/// the requester announced.
pub var emit_reply_probes: u64 = 0;

/// zit-local, not protocol (§4.1 L913-914: "This protocol specifies no separate 512 MiB limit") — a reply that would exceed `reply_body_cap` MUST
/// be rejected rather than buffered without bound.
pub fn replyCapCheck(len: usize) error{ReplyTooLarge}!void {
    if (len > reply_body_cap) return error.ReplyTooLarge;
}

/// §4 L845-846 (r10) — at most this many redirects are followed for a
/// single exchange.
pub const max_redirects: usize = 19;

/// §4 L845-846 (r10/r11) — `followed` is the count already consumed by
/// this exchange; the twentieth attempt (`followed == max_redirects`)
/// aborts with a fatal redirect-limit error rather than being followed.
pub fn redirectAllowed(followed: usize) error{RedirectLimit}!void {
    if (followed >= max_redirects) return error.RedirectLimit;
}

/// §4 L846-848 (r12) — a redirect MUST NOT downgrade an HTTPS exchange
/// to plaintext HTTP.
///
/// §11.2 L2249-2253 restates the surrounding transport posture, none of it
/// enforceable by a sans-IO format library that holds no socket:
/// confidentiality, server authentication, and replay protection of
/// credential-bearing traffic are the transport layer's responsibility
/// (§11.2 L2249-2251); deployments carrying such traffic SHOULD use HTTPS,
/// SSH, or an equivalent confidential, integrity-protected, server-
/// authenticated transport (§11.2 L2252-2253). Refusing an
/// HTTPS->HTTP redirect is this library's sole in-boundary contribution; the
/// §11.2 exposure observation (L2249-2252 — an intermediary reading a private
/// session reads the payloads) is non-normative rationale, no test.
pub fn checkRedirectDowngrade(from: Transport, to: Transport) error{InsecureRedirect}!void {
    if (from == .https and to == .http) return error.InsecureRedirect;
}

/// §4 L846-848 (r13) — SSH and local-file transports never follow a
/// redirect; there is no HTTP exchange for a redirect to occur within.
pub fn redirectAllowedForTransport(t: Transport) bool {
    return switch (t) {
        .http, .https => true,
        .ssh, .file => false,
    };
}

/// §4 L847-849, as corrected by the redirect erratum — when following a
/// redirect, strip ONE trailing "/xfer" path segment from the target
/// Location, then use the (possibly shortened) Location verbatim as the new
/// base. Fossil's `http_exchange` strip loop can only ever match once, so
/// `/b/xfer/xfer` becomes `/b/xfer`: the suffix undone is the single
/// endpoint segment a server appends, and a repository path that itself
/// ends in "xfer" survives.
/// No other path transformation; the original repository URL's path is
/// never consulted. Matched as a case-sensitive literal, exactly as
/// spelled in the RFC text. Total and allocation-free: returns a
/// subslice of `location`. Stripping is confined to the path component,
/// so an authority named "xfer" (e.g. https://xfer) is left intact.
pub fn stripRedirectXfer(location: []const u8) []const u8 {
    const scheme_sep = "://";
    const path_start = if (std.mem.indexOf(u8, location, scheme_sep)) |auth|
        std.mem.indexOfScalarPos(u8, location, auth + scheme_sep.len, '/') orelse location.len
    else
        0;

    const xfer = "/xfer";
    var end: usize = location.len;
    if (end >= path_start + xfer.len and std.mem.eql(u8, location[end - xfer.len .. end], xfer)) {
        end -= xfer.len;
    }

    std.debug.assert(end >= path_start and end <= location.len);
    std.debug.assert(end == location.len or end + xfer.len == location.len);
    return location[0..end];
}

// ---------------------------------------------------------------------------
// Section 4.1 — Message Compression: the zlib container
// ---------------------------------------------------------------------------

// The §4.1 container codec is the leaf `codec` module (`container.zig`),
// imported above and shared verbatim by `logstore` and `repostate`. Its
// public surface is `codec.encode`/`codec.decode` with `codec.Level` and
// the `codec.EncodeError`/`codec.DecodeError` sets.

// ---------------------------------------------------------------------------
// Section 4.2 — Round Structure
// ---------------------------------------------------------------------------

/// §4.2 L923-924 (r22/r23) — the outcome of probing one more offset
/// into a reply body: another card follows, the body is exhausted (a
/// clean round end, not an error), or a partial trailing line remains.
pub const ReplyBodyStep = enum { card_ready, round_complete, truncated };

/// §4.2 L923-924 — a round is one POST/response pair ending at body
/// exhaustion (r22); exhaustion at a card boundary is not an error
/// (r23), since there is no terminal card in the protocol.
pub fn replyBodyStep(body: []const u8, offset: usize) ReplyBodyStep {
    std.debug.assert(offset <= body.len);
    if (offset == body.len) return .round_complete;
    _ = readLine(body, offset) catch return .truncated;
    return .card_ready;
}

// ---------------------------------------------------------------------------
// Section 6 — Authentication
// ---------------------------------------------------------------------------

/// §6 — 40 lowercase-hex ASCII bytes; every digest this section produces.
pub const HashHex = [40]u8;

/// §6 — the one digest->hex conversion site every §6 formula funnels
/// through, so a hex-case or width bug can only ever be fixed in one place.
fn sha1FinalHex(digest: [20]u8) HashHex {
    return std.fmt.bytesToHex(digest, .lower);
}

/// §6 — SHA-1 [RFC3174] of `parts` concatenated with no separator, as 40
/// lowercase hex. The sole hashing funnel §6.2/§6.3/§6.4 share; a part
/// boundary never enters the digest, so callers own their own separators.
fn sha1HexOf(parts: []const []const u8) HashHex {
    var hasher = std.crypto.hash.Sha1.init(.{});
    for (parts) |p| hasher.update(p);
    var digest: [20]u8 = undefined;
    hasher.final(&digest);
    return sha1FinalHex(digest);
}

/// §6.2 — NONCE = SHA1(body bytes after the login-card LF). `body_tail`
/// inherits the §4 r8 reply-body bound since it is itself a message body.
pub fn computeNonce(body_tail: []const u8) HashHex {
    std.debug.assert(body_tail.len <= reply_body_cap);
    return sha1HexOf(&.{body_tail});
}

/// §9.1/§12 (ABNF `projectcode = artifact-hash`) — derive a repository
/// project code from a per-repo entropy string. Pure: a SHA-1 over the
/// parts rendered as 40 lowercase hex — an artifact-hash-shaped identifier,
/// distinct for a distinct entropy string, with no ambient RNG (the
/// determinism seam; the value replays from its seed). The RFC pins only
/// the wire form (an artifact hash), not a generation rule, so the CLI
/// seeds this with the repository's absolute path to give each repository a
/// stable, distinct code; the value is opaque and never re-derived or
/// validated as a content hash.
pub fn deriveProjectCode(entropy_parts: []const []const u8) HashHex {
    return sha1HexOf(entropy_parts);
}

/// §9.1 — a stable, distinct SERVERCODE for this repository INSTANCE, the
/// same derivation seam as `deriveProjectCode` but domain-separated so the
/// two codes never coincide over identical entropy. Servercode is
/// informational on the wire (§9.1: never validated by a peer), so deriving
/// it from the instance's absolute path is the whole requirement: stable per
/// checkout, distinct across clones of one project — and a moved repository
/// minting a new instance identity is exactly what a new path is. Never
/// adopted from a peer, unlike the project code.
pub fn deriveServerCode(entropy_parts: []const []const u8) HashHex {
    std.debug.assert(entropy_parts.len <= 7);
    var parts: [8][]const u8 = undefined;
    parts[0] = "zit-servercode/";
    for (entropy_parts, parts[1 .. entropy_parts.len + 1]) |p, *slot| slot.* = p;
    return sha1HexOf(parts[0 .. entropy_parts.len + 1]);
}

/// §6.3 — shared-secret = SHA1(project-code "/" login "/" password).
pub fn deriveSharedSecret(project_code: []const u8, login: []const u8, password: []const u8) HashHex {
    std.debug.assert(login.len > 0 and !containsFossilWhitespace(login));
    std.debug.assert(project_code.len <= max_line_len);
    return sha1HexOf(&.{ project_code, "/", login, "/", password });
}

/// §6.4 — SIGNATURE = SHA1(NONCE-hex || secret), an 80-byte input when
/// `secret` is itself 40-hex. `secret` is either the 40-hex shared-secret
/// (client; §6.4 step 3 fallback) or the raw stored credential bytes at
/// whatever width the server happens to store them (§6.4 step 2 primary) —
/// one function serves both r8 and r13.
pub fn computeSignature(nonce_hex: []const u8, secret: []const u8) HashHex {
    std.debug.assert(nonce_hex.len == 40);
    return sha1HexOf(&.{ nonce_hex, secret });
}

test "sync §6.2 [RFC3174] §7.3: SHA1('abc') primitive vector" {
    const digest = sha1HexOf(&.{"abc"});
    try std.testing.expectEqualStrings("a9993e364706816aba3e25717850c26c9cd0d89d", &digest);
}

test "sync §6.3: shared-secret composes project-code / login / password in order (relational to primitive)" {
    // §6.3 L1103-1105: shared-secret = SHA1(project-code "/" login "/"
    // password). Pinned relationally to the RFC3174-anchored sha1HexOf
    // funnel by spelling the composed byte string with its "/" separators
    // in order — never a fossil-derived digest.
    const pc = "c1b2a3d4e5f60718293a4b5c6d7e8f9012345678";
    const login = "alice";
    const pw = "secret-passphrase";
    const secret = deriveSharedSecret(pc, login, pw);
    const composed = sha1HexOf(&.{ pc, "/", login, "/", pw });
    try std.testing.expectEqualStrings(&composed, &secret);
    // Order matters: swapping login and password composes a different byte
    // string, hence a different digest.
    const swapped = sha1HexOf(&.{ pc, "/", pw, "/", login });
    try std.testing.expect(!std.mem.eql(u8, &secret, &swapped));
}

test "sync §6.4: SIGNATURE is SHA1(NONCE-hex || secret-hex) with no separator (relational to primitive)" {
    // §6.4 L1112 + §6.1 L1089-1090 + §6.3 L1105: 80-byte input is the two 40-hex
    // strings concatenated with no separator. Pinned relationally to the
    // sha1HexOf funnel over the explicitly-spelled 80-byte concatenation.
    const nonce_hex = "a9993e364706816aba3e25717850c26c9cd0d89d";
    const secret_hex = "0123456789abcdef0123456789abcdef01234567";
    const sig = computeSignature(nonce_hex, secret_hex);
    const composed = sha1HexOf(&.{ nonce_hex, secret_hex });
    try std.testing.expectEqualStrings(&composed, &sig);
    // A separator byte between the two fields would change the digest,
    // proving the concatenation is byte-level with none.
    const with_sep = sha1HexOf(&.{ nonce_hex, "/", secret_hex });
    try std.testing.expect(!std.mem.eql(u8, &sig, &with_sep));
}

/// §6.1 — a parsed `login USER NONCE SIGNATURE`. Slices borrow the source
/// (a card line or a decoded cookie value); the caller owns that memory.
pub const LoginCard = struct { user: []const u8, nonce: []const u8, signature: []const u8 };

/// §6.1 — exactly 40 lowercase hex ASCII bytes, the NONCE/SIGNATURE shape.
fn isLowerHex40(s: []const u8) bool {
    if (s.len != 40) return false;
    for (s) |c| {
        switch (c) {
            '0'...'9', 'a'...'f' => {},
            else => return false,
        }
    }
    return true;
}

/// §6.1 — parse a login card's tokens into a `LoginCard`, or null on any
/// malformed shape: wrong token count, an unrecognized first token, an
/// empty or Fossil-whitespace-bearing USER, or a NONCE/SIGNATURE that
/// isn't exactly 40 lowercase hex. Errors are defined out of existence via
/// `?` rather than an error set — every rejection is equally "not a login
/// card". `tokens` may come from `tokenizeCard` (body card) or from a
/// cookie-decoded line (§6.5), so the operator token is checked here
/// rather than assumed already-dispatched.
pub fn parseLoginCard(tokens: Tokens) ?LoginCard {
    std.debug.assert(tokens.len <= max_tokens);
    if (tokens.len != 4) return null;
    if (parseOperator(tokens.items[0]) != .login) return null;
    const user = tokens.items[1];
    if (user.len == 0 or containsFossilWhitespace(user)) return null;
    const nonce = tokens.items[2];
    const signature = tokens.items[3];
    if (!isLowerHex40(nonce)) return null;
    if (!isLowerHex40(signature)) return null;
    return .{ .user = user, .nonce = nonce, .signature = signature };
}

/// §6.1 L1087-1089 — write the body-card login form `login USER NONCE
/// SIGNATURE` into `out`. `card.user` is the ALREADY-fossilized wire USER
/// (fossilization is the card-builder's job via `artifact.encodeEscapes`);
/// this writer emits the four tokens verbatim, single-SP separated. The
/// no-Fossil-whitespace assertion documents that fossilize (§5.2) has
/// escaped every whitespace byte including SP, so the tokens split cleanly
/// on the wire. Non-allocating: writes into the caller buffer. Closed
/// error set (BufPrintError), no inferred `!`.
pub fn writeLogin(card: LoginCard, out: []u8) std.fmt.BufPrintError![]u8 {
    std.debug.assert(card.user.len > 0 and !containsFossilWhitespace(card.user));
    std.debug.assert(card.nonce.len == 40 and card.signature.len == 40);
    const needed = std.fmt.count("login {s} {s} {s}", .{ card.user, card.nonce, card.signature });
    std.debug.assert(out.len >= needed);
    std.debug.assert(needed <= max_line_len - 1); // §5.2 card-line ceiling
    return try std.fmt.bufPrint(out, "login {s} {s} {s}", .{ card.user, card.nonce, card.signature });
}

/// §6.5 / §B L2899-2900 — the cookie-USER encoding alphabet is pinned by
/// the appendix ABNF `pct-user = 1*( unreserved / pct-encoded )` with
/// `unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~"`: these bytes pass
/// through unescaped, every other byte is percent-encoded. That is the
/// RFC3986 unreserved set exactly; the §6.5 prose ("URL-unsafe bytes ...
/// are percent-encoded", L2097-2098) is made precise by the appendix
/// grammar — the normative alphabet lives in §B, not in the prose.
fn isUrlUnreserved(b: u8) bool {
    return switch (b) {
        'A'...'Z', 'a'...'z', '0'...'9', '-', '.', '_', '~' => true,
        else => false,
    };
}

fn hexDigitUpper(n: u4) u8 {
    const v: u8 = n;
    return if (v < 10) '0' + v else 'A' + (v - 10);
}

fn hexNibble(c: u8) ?u4 {
    return switch (c) {
        '0'...'9' => @intCast(c - '0'),
        'a'...'f' => @intCast(c - 'a' + 10),
        'A'...'F' => @intCast(c - 'A' + 10),
        else => null,
    };
}

/// §6.5 — append `s` to `out` starting at `start`, SP -> '+' and every
/// non-unreserved byte -> `%XX`; null on overflow. Bounded by `s.len`.
fn urlFormEncodeAppend(s: []const u8, out: []u8, start: usize) ?usize {
    var o = start;
    for (s) |b| {
        if (b == ' ') {
            if (o >= out.len) return null;
            out[o] = '+';
            o += 1;
        } else if (isUrlUnreserved(b)) {
            if (o >= out.len) return null;
            out[o] = b;
            o += 1;
        } else {
            if (o + 3 > out.len) return null;
            out[o] = '%';
            out[o + 1] = hexDigitUpper(@intCast(b >> 4));
            out[o + 2] = hexDigitUpper(@intCast(b & 0x0f));
            o += 3;
        }
    }
    return o;
}

/// §B L2899-2901 — decode one `pct-user` field into `out`: `%XX`
/// (HEXDIG-CI, either hex case) -> byte; every other byte passes through
/// unchanged. The literal `+` field separator is consumed by
/// `parseCookieLogin` before this and never reaches here, so `+` is never
/// SP inside a field. Null on a truncated or non-hex escape. Decoding only
/// ever shrinks, so `out.len >= field.len` always suffices.
fn pctDecodeField(field: []const u8, out: []u8) ?[]u8 {
    std.debug.assert(out.len >= field.len);
    var i: usize = 0;
    var o: usize = 0;
    while (i < field.len) {
        if (field[i] == '%') {
            if (i + 2 >= field.len) return null;
            const hi = hexNibble(field[i + 1]) orelse return null;
            const lo = hexNibble(field[i + 2]) orelse return null;
            out[o] = (@as(u8, hi) << 4) | @as(u8, lo);
            o += 1;
            i += 3;
        } else {
            out[o] = field[i];
            o += 1;
            i += 1;
        }
    }
    return out[0..o];
}

/// §6.5 — encode a login card as the `x-f-l-c` cookie value: `login+USER+
/// NONCE+SIGNATURE`, SP -> '+', URL-unsafe USER bytes percent-encoded.
/// NONCE/SIGNATURE are already 40 lowercase hex (unreserved), so only USER
/// ever needs escaping. Null on an `out` overflow.
pub fn writeCookieLogin(card: LoginCard, out: []u8) ?[]u8 {
    std.debug.assert(card.user.len > 0);
    std.debug.assert(card.nonce.len == 40 and card.signature.len == 40);
    const prefix = "login+";
    if (out.len < prefix.len) return null;
    @memcpy(out[0..prefix.len], prefix);
    var o = urlFormEncodeAppend(card.user, out, prefix.len) orelse return null;
    if (o + 1 + card.nonce.len + 1 + card.signature.len > out.len) return null;
    out[o] = '+';
    o += 1;
    @memcpy(out[o..][0..card.nonce.len], card.nonce);
    o += card.nonce.len;
    out[o] = '+';
    o += 1;
    @memcpy(out[o..][0..card.signature.len], card.signature);
    o += card.signature.len;
    return out[0..o];
}

/// §6.5 / §B L2898 — parse an `x-f-l-c` cookie value into a `LoginCard`;
/// null on any malformed value. The grammar is
/// `url-login-value = "login+" pct-user "+" nonce "+" signature`: the field
/// separator is the literal `+` and nothing else, so the value splits into
/// exactly four `+`-delimited segments. Only the `pct-user` segment is
/// percent-decoded (into `out`); `nonce` and `signature` are `sha1-hash`
/// (40 HEXDIG-LC) and are validated against the untouched wire bytes. A
/// `%20` standing in for a `+` separator is therefore not a separator and is
/// rejected — the sole grammar leniency is HEXDIG-CI (`pctDecodeField`).
/// `out` holds only the decoded USER; the returned card borrows `out` (user)
/// and `value` (nonce, signature), both caller-owned for the card's life.
pub fn parseCookieLogin(value: []const u8, out: []u8) ?LoginCard {
    std.debug.assert(out.len >= value.len);
    if (value.len == 0 or value.len > max_line_len - 1) return null;
    var fields: [4][]const u8 = undefined;
    var count: usize = 0;
    var start: usize = 0;
    for (value, 0..) |b, idx| {
        if (b != '+') continue;
        if (count == 3) return null; // a fifth "+"-delimited segment is malformed
        fields[count] = value[start..idx];
        count += 1;
        start = idx + 1;
    }
    if (count != 3) return null; // exactly three "+" separators -> four fields
    fields[3] = value[start..];
    if (!std.mem.eql(u8, fields[0], "login")) return null;
    const user = pctDecodeField(fields[1], out) orelse return null;
    if (user.len == 0 or containsFossilWhitespace(user)) return null; // §6.1 USER shape
    if (!isLowerHex40(fields[2]) or !isLowerHex40(fields[3])) return null;
    return .{ .user = user, .nonce = fields[2], .signature = fields[3] };
}

test "sync §6.1/§6.5 fuzz: parseLoginCard never crashes on adversarial tokens" {
    // Untrusted wire tokens: parseLoginCard must never crash, only return
    // null or a card whose fields satisfy §6.1's own shape invariants.
    var prng = std.Random.DefaultPrng.init(0x106E_6CDE);
    const rand = prng.random();
    const max_field: usize = 48;
    var field_buf: [max_tokens][max_field]u8 = undefined;
    var round: usize = 0;
    while (round < 500) : (round += 1) {
        var tokens: Tokens = .{};
        const n = rand.uintLessThan(usize, max_tokens + 1);
        tokens.len = n;
        var i: usize = 0;
        while (i < n) : (i += 1) {
            if (i == 0 and rand.boolean()) {
                tokens.items[0] = "login";
                continue;
            }
            const len = rand.uintLessThan(usize, max_field + 1);
            for (field_buf[i][0..len]) |*b| b.* = rand.int(u8);
            tokens.items[i] = field_buf[i][0..len];
        }
        const result = parseLoginCard(tokens);
        if (result) |card| {
            try std.testing.expect(card.user.len > 0 and !containsFossilWhitespace(card.user));
            try std.testing.expect(isLowerHex40(card.nonce));
            try std.testing.expect(isLowerHex40(card.signature));
        }
    }
}

/// §6.4 — constant-time, case-sensitive equality of two 40-hex hashes.
/// `b` is wire-derived (a received NONCE/SIGNATURE), so its width is
/// checked explicitly rather than assumed.
fn eqHash(a: HashHex, b: []const u8) bool {
    std.debug.assert(b.len == 40);
    var b_arr: HashHex = undefined;
    @memcpy(&b_arr, b);
    return std.crypto.timing_safe.eql(HashHex, a, b_arr);
}

/// §6.4 outcome. `special_case` is the nobody/anonymous unconditional
/// pass; a matching remote-user (HTTP Basic) identity is server-config IO
/// and stays out of this pure function.
pub const LoginResult = enum { special_case, ok, reject };

/// §6.4 steps 1-4 — verify a login card server-side: nobody/anonymous
/// pass unconditionally; otherwise the primary signature check (stored
/// bytes as secret) must pass, or — only when it fails and the stored
/// credential is not exactly 40 bytes — the stored-credential fallback
/// (re-derive via §6.3) must pass; independently, the tail-hash (fresh
/// NONCE over `body_tail`) must match the received NONCE. Both checks use
/// constant-time comparison. Sans-IO, no allocator.
pub fn serverVerifyLogin(
    card: LoginCard,
    stored_credential: []const u8,
    project_code: []const u8,
    body_tail: []const u8,
) LoginResult {
    std.debug.assert(card.nonce.len == 40 and card.signature.len == 40);
    if (std.mem.eql(u8, card.user, "nobody") or std.mem.eql(u8, card.user, "anonymous")) {
        return .special_case;
    }

    const primary_sig = computeSignature(card.nonce, stored_credential);
    var signature_ok = eqHash(primary_sig, card.signature);
    if (!signature_ok and stored_credential.len != 40) {
        const fallback_secret = deriveSharedSecret(project_code, card.user, stored_credential);
        const fallback_sig = computeSignature(card.nonce, &fallback_secret);
        signature_ok = eqHash(fallback_sig, card.signature);
    }

    const tail_hash = computeNonce(body_tail);
    const tail_ok = eqHash(tail_hash, card.nonce);

    return if (signature_ok and tail_ok) .ok else .reject;
}

test "sync §6.4: eqHash is case-sensitive" {
    const hex_lower = "d7a235115bf97d126c42140196c4fc6087312353";
    var a: HashHex = undefined;
    @memcpy(&a, hex_lower);
    try std.testing.expect(eqHash(a, hex_lower));

    var flipped: [40]u8 = undefined;
    @memcpy(&flipped, hex_lower);
    flipped[0] = 'D'; // flip case of the first hex digit
    try std.testing.expect(!eqHash(a, &flipped));
}

/// §6.3 L1103-1105 — a client's stored login credential: the login name and
/// the 40-hex shared secret (`SHA1(project-code "/" login "/" password)`,
/// derived once via `deriveSharedSecret`). Sans-IO capability: the plaintext
/// password is never held here — only the derived secret — so a request
/// builder can sign a nonce (§6.4) without ever touching cleartext.
pub const Credential = struct {
    user: []const u8,
    secret_hex: [40]u8,
};

/// A login a clone cannot sign yet: the secret needs the project code the
/// round-0 reply carries.
pub const PendingLogin = struct {
    user: []const u8,
    password: []const u8,
};

/// §6.1 L1091-1093, §6.4 step-1 L1141-1142 — the client login identity,
/// classifying the nobody/anonymous special names (which carry their
/// capabilities without a login card) apart from a real named login. The
/// special-name check lives here, once — mirroring `serverVerifyLogin`'s
/// `special_case` — rather than leaking a bare login-name string to every
/// caller. Sans-IO capability: it carries the login name only, no secret.
pub const Identity = union(enum) {
    anonymous,
    named: []const u8,

    /// §6.1 L1091-1093 — map a raw login name to an identity: nobody and
    /// anonymous collapse to `.anonymous` (no login card is ever emitted
    /// for them); every other name is a real `.named` login.
    pub fn fromLoginName(name: []const u8) Identity {
        if (std.mem.eql(u8, name, "nobody") or std.mem.eql(u8, name, "anonymous")) {
            return .anonymous;
        }
        return .{ .named = name };
    }

    /// §6.1 L1091-1092 — only a named identity authenticates with a login
    /// card; anonymous/nobody carry configured capabilities unchallenged.
    fn authenticates(self: Identity) bool {
        return switch (self) {
            .anonymous => false,
            .named => true,
        };
    }
};

/// §6.5 — the login channel a client uses for a request: `.none` when no
/// login card is emitted at all (anonymous/nobody per §6.1 L1091-1093, or
/// clone round 0 per §8.3 L1654), `.body` for the §6.1 body-embedded
/// card, `.cookie` for the §6.5 x-f-l-c header against a gated peer.
pub const LoginTransport = enum {
    none, // §6.1 L1091-1093 / §8.3 L1654 — no login card emitted.
    body, // §6.1 L1087 / §6.5 L1192-1193 — body-embedded login card, no x-f-l-c.
    cookie, // §6.5 L1183-1185,L1189-1190 — x-f-l-c cookie header, no body login.

    /// §6.5 L1171-1185 — the x-f-l-c header is emitted only in
    /// cookie mode; false for both `.body` and `.none`.
    pub fn emitsCookieHeader(t: LoginTransport) bool {
        return t == .cookie;
    }

    /// §6.1 L1087 / §6.5 L1192-1193 — a body login card is present only
    /// in body mode; false for both `.cookie` and `.none`.
    pub fn emitsBodyLogin(t: LoginTransport) bool {
        return t == .body;
    }
};

/// §6.5 L1181 — peer release integer >= 22700 (Fossil 2.27).
pub const cookie_gate_min_version: u32 = 22700;
comptime {
    std.debug.assert(cookie_gate_min_version == encodeVersion(2, 27, 0));
}

/// §6.5 L1181 — timestamp on/after 2025-07-27 11:05:00, recorded as
/// DATE (YYYYMMDD) / TIME (HHMMSS) per §9.1 L1897-1898.
pub const cookie_gate_min_date: u32 = 20250727;
pub const cookie_gate_min_time: u32 = 110500;
comptime {
    std.debug.assert(cookie_gate_min_date == 2025 * 10000 + 7 * 100 + 27);
    std.debug.assert(cookie_gate_min_time == 11 * 10000 + 5 * 100 + 0);
}

/// §6.5 L1181 (predicate) + L1192-1193 (unobserved fallback) — both gate
/// conditions hold for the recorded peer. A peer whose server-version has
/// never been observed carries `datetime == null` (§9.1 L1913-1914 records
/// 00000000/000000, which fails the predicate too), so the "no server-version
/// has been observed" fallback (§6.5 L1192-1193) is subsumed here: no
/// recorded timestamp means not gated. (date, time) compares
/// lexicographically because both are fixed-width YYYYMMDD/HHMMSS integers
/// that order like the wall clock (§9.1 L1897-1898).
pub fn peerGatedForCookie(peer: Peer) bool {
    const dt = peer.datetime orelse return false;
    if (peer.version < cookie_gate_min_version) return false;
    if (dt.date < cookie_gate_min_date) return false;
    if (dt.date == cookie_gate_min_date and dt.time < cookie_gate_min_time) return false;
    std.debug.assert(peer.version >= cookie_gate_min_version);
    std.debug.assert(dt.date > cookie_gate_min_date or
        (dt.date == cookie_gate_min_date and dt.time >= cookie_gate_min_time));
    return true;
}

/// §6.1/§6.5/§8.3 — the single authority for a request's login channel.
/// Non-emission comes first: an anonymous/nobody identity (§6.1
/// L1933-1935) and clone round 0 (§8.3 L1654, via the shared
/// `sendableAfterCloneRound0`) each yield `.none`. Otherwise the peer gate
/// (§6.5 L1171-1181) selects `.cookie` for a gated peer, `.body`
/// otherwise. Op-agnostic: identical for pull/push/sync and post-round-0
/// clone.
pub fn loginTransport(identity: Identity, peer: Peer, op: Operation, round: u64) LoginTransport {
    if (!identity.authenticates()) return .none;
    if (!sendableAfterCloneRound0(op, round)) return .none;
    const result: LoginTransport = if (peerGatedForCookie(peer)) .cookie else .body;
    std.debug.assert(result != .none);
    std.debug.assert(result.emitsCookieHeader() == peerGatedForCookie(peer));
    return result;
}

/// §6.1 L1090-1093, §6.5 L1195-1196 — the one-login-per-request
/// invariant across both channels: an x-f-l-c cookie login
/// (`cookie_login`) plus `body_logins` body login cards totalling more
/// than one is the §11.1 `duplicate_login` protocol error, regardless of
/// gating. `body_logins` is a bounded card-scan count the caller
/// supplies; this function loops, allocates, and recurses none, so it
/// needs no further bound of its own.
pub fn duplicateLogin(cookie_login: bool, body_logins: usize) bool {
    const total = @as(usize, @intFromBool(cookie_login)) + body_logins;
    std.debug.assert(total >= body_logins); // no wraparound
    const result = total > 1;
    if (cookie_login and body_logins >= 1) std.debug.assert(result);
    if (!cookie_login and body_logins <= 1) std.debug.assert(!result);
    return result;
}

// ---------------------------------------------------------------------------
// Section 6.6 — Capability Model
// ---------------------------------------------------------------------------

/// §6.6 L1200-1202 — the capability letters the server assigns: Read (o)
/// permits pull, Write (i) permits push, Clone (g) permits clone, Private
/// (x) permits private-content transfer. Administrator is named in §6.6
/// with no letter (L2159-2160); its configuration / user-management gate is
/// owned by §9.3 serverConfigPushDecision(has_admin), so it is deliberately
/// not a member — no §6.6 byte yields it. Distinct from Operation (round
/// mode) and Operator (card token); clone/private coexist across the three.
pub const Capability = enum { read, write, clone, private };

comptime {
    // §6.6 assigns exactly four capability letters (o/i/g/x); admin has none.
    std.debug.assert(@typeInfo(Capability).@"enum".field_names.len == 4);
}

/// §6.6 L1200-1202 — the server's capability letters. Maps an assigned letter to its capability; null for any other byte.
pub fn capabilityFromLetter(letter: u8) ?Capability {
    return switch (letter) {
        'o' => .read,
        'i' => .write,
        'g' => .clone,
        'x' => .private,
        else => null,
    };
}

/// §6.6 L1202-1223 — the established capability set the server checks each
/// card against (built §6.4 L1135-1138: baseline set, unioned with a
/// successful login card). Bounded by the four-member enum, membership-tested
/// by the gate.
pub const CapabilitySet = std.EnumSet(Capability);

/// §6.6 L1200-1202 — build the established set from the user's assigned letters;
/// a byte that names no §6.6 capability is skipped. Bounded by letters.len,
/// no allocation.
pub fn capabilitySetFromLetters(letters: []const u8) CapabilitySet {
    var set: CapabilitySet = .empty;
    for (letters) |c| {
        if (capabilityFromLetter(c)) |cap| set.insert(cap);
    }
    return set;
}

/// §6.4 L1135-1138 — a successful login card is purely additive: the
/// established set is the baseline unioned with the login grant, and a
/// grant can only add bits, never remove one. The baseline itself is
/// host/session-derived (§6.4 L1132-1134) and enters as an injected
/// CapabilitySet — no IO here. Non-failing: no way to remove a bit, so
/// no error union.
pub fn establishedCapabilities(baseline: CapabilitySet, login_grant: CapabilitySet) CapabilitySet {
    const result = baseline.unionWith(login_grant);
    // §6.4 L1135-1138 additive-only invariant (negative space: no baseline bit is cleared).
    std.debug.assert(result.supersetOf(baseline));
    return result;
}

/// §6.6 L1200-1202 — the capability a transfer operation requires: pull
/// needs Read (o), push needs Write (i), clone needs Clone (g). `sync` is a
/// combined pull+push round, not a single card, so §6.6 assigns it no
/// single letter and this returns null; its pull and push cards are each
/// gated as processed. Private-content transfer (x) and configuration
/// (admin) are gated by their own §7.5/§10 deciders, never keyed here (that
/// would duplicate those gates). Exhaustive over Operation, no `else`.
pub fn requiredCapability(op: Operation) ?Capability {
    return switch (op) {
        .pull => .read,
        .push => .write,
        .clone => .clone,
        .sync => null,
    };
}

/// §6.6 L1202-1223 — the per-card capability gate's disposition: process
/// the operation, or reject (r8: the server emits an error card). §6.6
/// fixes no error-card text for a lacked transfer capability, so `.reject`
/// IS the "error-card form" here — no fabricated golden bytes.
pub const CapabilityDecision = enum { process, reject };

/// §6.6 L1202-1223 — process the card when the user's established
/// capability set holds the capability the operation requires, else
/// reject. Precondition op != .sync: the combined round is decomposed
/// into per-card pull/push checks upstream and never handed here.
pub fn serverCapabilityDecision(held: CapabilitySet, op: Operation) CapabilityDecision {
    std.debug.assert(op != .sync);
    const required = requiredCapability(op).?;
    const result: CapabilityDecision = if (held.contains(required)) .process else .reject;
    std.debug.assert((result == .process) == held.contains(required));
    return result;
}

// ---------------------------------------------------------------------------
// Section 7.1 / 7.2 / 7.4 — Content Transfer: file / cfile Cards
// ---------------------------------------------------------------------------

/// §7.1-r1/r5 — a `file` card: HASH, an optional delta basis (four-argument
/// form only), and SIZE, the uncompressed payload byte count.
pub const FileCard = struct {
    hash: []const u8,
    deltasrc: ?[]const u8,
    size: u32,
};

/// §7.2-r1/r2 — a `cfile` card: HASH, an optional delta basis, USIZE (the
/// sender's reconstructed uncompressed size — syntax only, §7.2-r5), and
/// CSIZE, the wire payload byte count that actually frames the payload.
pub const CfileCard = struct {
    hash: []const u8,
    deltasrc: ?[]const u8,
    uncompressed_size: u32,
    compressed_size: u32,
};

/// §7 — the sole `file`/`cfile` parse failure. Unlike §9.3's `config`,
/// SIZE/USIZE/CSIZE have no signed literal to reject as a distinct case;
/// a negative token is simply unparseable as `u32` and folds into this.
pub const CardParseError = error{Malformed};

/// §7.1-r1/r5 — parse `file HASH SIZE` or `file HASH DELTASRC SIZE`.
/// Arity alone disambiguates the two forms. HASH and DELTASRC must each be a
/// complete lowercase artifact name, as Fossil's `blob_is_hname` check
/// requires: anything else would otherwise be recorded as a phantom and
/// requested back. Which algorithm a name may use is admission's question
/// (§7.7), not this parser's.
pub fn parseFileCard(tokens: Tokens) CardParseError!FileCard {
    std.debug.assert(tokens.len >= 1);
    std.debug.assert(parseOperator(tokens.items[0]) == .file);
    std.debug.assert(tokens.len <= max_tokens);
    const result: FileCard = switch (tokens.len) {
        3 => .{
            .hash = tokens.items[1],
            .deltasrc = null,
            .size = std.fmt.parseInt(u32, tokens.items[2], 10) catch return CardParseError.Malformed,
        },
        4 => .{
            .hash = tokens.items[1],
            .deltasrc = tokens.items[2],
            .size = std.fmt.parseInt(u32, tokens.items[3], 10) catch return CardParseError.Malformed,
        },
        else => return CardParseError.Malformed,
    };
    if (!cardNamesValid(result.hash, result.deltasrc)) return CardParseError.Malformed;
    std.debug.assert((result.deltasrc == null) == (tokens.len == 3));
    return result;
}

/// §7.2-r1/r2 — parse `cfile HASH USIZE CSIZE` or
/// `cfile HASH DELTASRC USIZE CSIZE`. Same arity-disambiguation shape and
/// name validation as `parseFileCard`.
pub fn parseCfileCard(tokens: Tokens) CardParseError!CfileCard {
    std.debug.assert(tokens.len >= 1);
    std.debug.assert(parseOperator(tokens.items[0]) == .cfile);
    std.debug.assert(tokens.len <= max_tokens);
    const result: CfileCard = switch (tokens.len) {
        4 => .{
            .hash = tokens.items[1],
            .deltasrc = null,
            .uncompressed_size = std.fmt.parseInt(u32, tokens.items[2], 10) catch return CardParseError.Malformed,
            .compressed_size = std.fmt.parseInt(u32, tokens.items[3], 10) catch return CardParseError.Malformed,
        },
        5 => .{
            .hash = tokens.items[1],
            .deltasrc = tokens.items[2],
            .uncompressed_size = std.fmt.parseInt(u32, tokens.items[3], 10) catch return CardParseError.Malformed,
            .compressed_size = std.fmt.parseInt(u32, tokens.items[4], 10) catch return CardParseError.Malformed,
        },
        else => return CardParseError.Malformed,
    };
    if (!cardNamesValid(result.hash, result.deltasrc)) return CardParseError.Malformed;
    std.debug.assert((result.deltasrc == null) == (tokens.len == 4));
    return result;
}

/// A content card's HASH, and its DELTASRC when present, are artifact names.
fn cardNamesValid(hash: []const u8, deltasrc: ?[]const u8) bool {
    if (!artifact.validateName(hash)) return false;
    if (deltasrc) |src| if (!artifact.validateName(src)) return false;
    return true;
}

/// §7.4/§7.7 — the shared delta-reception core, used by BOTH the client receive
/// path (`Client.receiveDelta`) and the stateless server push-ingest path
/// (`scanCard`): reconstruct a delta-form target's content against its already-
/// held basis and, when `verify_integrity` is set, enforce §7.7 pre-storage
/// integrity. Kept as one helper so the reconstruct-then-admit rule lives in a
/// single place rather than in two divergent copies.
const ReconstructError = error{ OutOfMemory, MalformedDelta, IntegrityMismatch };

/// §7.4/§7.7 — apply `delta_bytes` (a [FossilDelta], already §4.1 container-
/// decoded for a cfile) against the held basis content `src_bytes` to
/// reconstruct `hash`'s bytes. On success returns caller-owned reconstructed
/// bytes that, when `verify_integrity` is set, are proven to hash to `hash`
/// (§7.7: integrity hashes the RECONSTRUCTED bytes, never the wire delta), so a
/// wrong-hash delta never lands bytes under a name they do not match in the
/// content-addressed store. A clone-context file bypasses the target check
/// (§7.7), which the caller signals by passing `verify_integrity = false`.
fn reconstructDeltaTarget(
    gpa: std.mem.Allocator,
    hash: []const u8,
    src_bytes: []const u8,
    delta_bytes: []const u8,
    verify_integrity: bool,
) ReconstructError![]u8 {
    const reconstructed = delta.apply(gpa, src_bytes, delta_bytes) catch |err| switch (err) {
        error.OutOfMemory => return error.OutOfMemory,
        error.MalformedDelta, error.ChecksumMismatch => return error.MalformedDelta,
    };
    errdefer gpa.free(reconstructed);
    if (verify_integrity and !artifact.verify(hash, reconstructed)) return error.IntegrityMismatch;
    return reconstructed;
}

/// §7.1/§7.4/§7.7 — reconstruct a push-ingested delta-form `file` card against a
/// basis the stateless server already holds, verifying §7.7 pre-storage
/// integrity (a push is never a clone, so the target check always applies). On
/// success returns caller-owned reconstructed bytes; the caller stores them
/// exactly as a full-content pushed artifact.
///
/// The server is stateless (§3.8): unlike `Client.receiveDelta` it keeps no
/// cross-request `PendingDeltaQueue`, so it cannot retain a dangling delta until
/// a future request delivers the basis. A basis absent from the store is
/// therefore `BasisAbsent`, and the caller never lands an unverifiable delta
/// under `hash`.
///
/// `BasisAbsent` reports only that the basis was not resolvable; WHY decides
/// whether it is a fault, and that question belongs to the caller, which alone
/// holds the shun table. §7.7 L1479-1485 exempts exactly one cause: a DELTASRC
/// in the repository shun table is masked out of the served store, so an
/// otherwise conformant peer that deltas against a name it saw us hold BEFORE
/// the shun lands here, and the clause prescribes ordinary dangling-delta
/// handling rather than a fault. Every other cause — an unknown, truncated, or
/// attacker-chosen name — keeps the §7.4/§3.8 fault. `scanCard`'s `file` arm
/// draws that line; this function never guesses it.
const PushedDeltaError = error{ OutOfMemory, BasisAbsent, MalformedDelta, IntegrityMismatch };

fn reconstructPushedDelta(
    gpa: std.mem.Allocator,
    store: repostate.ArtifactStore,
    hash: []const u8,
    deltasrc: []const u8,
    delta_bytes: []const u8,
) PushedDeltaError![]u8 {
    const src_rid = store.lookup(deltasrc) orelse return error.BasisAbsent;
    const src_bytes = store.content(src_rid) orelse return error.BasisAbsent;
    return reconstructDeltaTarget(gpa, hash, src_bytes, delta_bytes, true);
}

/// §7.1-r2/r3/r4, §7.2-r2/r3/r4 — the payload bytes plus the offset just
/// past them.
pub const ContentPayload = struct { bytes: []const u8, next: usize };

/// §7.1-r3/r4, §7.2-r3/r4 — the sole content-frame failure: the declared
/// span runs past the buffer. There is no `MissingNewline` arm (unlike
/// `ConfigFrameError`): §7.1-r4/§7.2-r4 mandate no trailing separator, so
/// that failure mode is defined out of existence here.
pub const ContentFrameError = error{Truncated};

/// §7.1-r2/r3/r4, §7.2-r2/r3/r4 — slice out exactly `size` payload bytes
/// starting at `start`; the byte at `start + size` (if any) is the first
/// byte of the next card line, never consumed. Overflow-safe: the add is
/// checked so an adversarial `size` cannot wrap `usize` into passing the
/// bound check. Shared verbatim by `file` and `cfile`; callers pass
/// CSIZE — never USIZE — for cfile, which structurally enforces §7.2-r3
/// since `CfileCard.uncompressed_size` never reaches this parameter in
/// the framing call sites above.
pub fn consumeContentPayload(body: []const u8, start: usize, size: u32) ContentFrameError!ContentPayload {
    std.debug.assert(start <= body.len);
    const end = std.math.add(usize, start, size) catch return ContentFrameError.Truncated;
    if (end > body.len) return ContentFrameError.Truncated;
    const result: ContentPayload = .{ .bytes = body[start..end], .next = end };
    std.debug.assert(result.next == start + @as(usize, size));
    std.debug.assert(result.bytes.len == size);
    return result;
}

/// §5.1 L937-948 — the resume offset past a framed cfile payload, consuming the
/// optional trailing-LF padding. A cfile sender "appends one further LF only
/// when the counted compressed payload does not itself end in LF; reference
/// receivers consume that padding as an empty line." The condition keys on the
/// payload's LAST byte alone — the compressed bytes legally embed LF elsewhere
/// (§5.1 L935-946) — so a payload that self-terminates in LF carries no padding,
/// and a real following empty line is left for the card loop to reject (negative
/// space). The skip is bounded: the padding LF is consumed only when it is
/// actually on the wire, so a no-padding sender or a truncated final cfile never
/// over-advances. `writeCfile` emits under the identical predicate (round-trip).
pub fn cfilePaddingEnd(body: []const u8, payload: ContentPayload) usize {
    std.debug.assert(payload.next <= body.len);
    const ends_in_lf = payload.bytes.len != 0 and payload.bytes[payload.bytes.len - 1] == '\n';
    if (!ends_in_lf and payload.next < body.len and body[payload.next] == '\n') {
        return payload.next + 1; // §5.1 — consume the one padding LF as an empty line
    }
    return payload.next; // §5.1 — payload self-terminated, or no padding present
}

/// §7.4-r1 — one delta held pending its basis: HASH's delta against a DELTASRC
/// this receiver does not yet hold. The retained `payload` is the exact bytes
/// framed off the card; nothing further is decoded until the basis arrives.
pub const PendingDelta = struct {
    hash: []const u8,
    deltasrc: []const u8,
    payload: Payload,

    /// §7.1/§7.2 — the retained delta form, discriminated by whether a §4.1
    /// container was on the wire. `.container` is a cfile card's stored
    /// compressed container of the [FossilDelta], kept VERBATIM so completion
    /// persists the compact §7.2 delta frame and a clone-of-clone re-serves it
    /// byte-for-byte. `.raw` is a `file` card's bare [FossilDelta] (§7.1), which
    /// has no container to preserve and completes to full form. Keeping the
    /// compressed container — not the larger decoded delta — also shrinks the
    /// live queue during a clone (a real reply fans out thousands of these).
    pub const Payload = union(enum) {
        container: []const u8,
        raw: []const u8,

        /// The retained delta bytes, whichever variant. Both are `[]const u8`,
        /// so freeing and length checks share one accessor.
        fn bytes(self: Payload) []const u8 {
            return switch (self) {
                .container => |b| b,
                .raw => |b| b,
            };
        }
    };
};

/// §7.4-r1/r3 — deltas retained against an absent basis, replayed once the
/// basis is stored. Heap-backed and growable: a real clone reply legitimately
/// carries thousands of dangling deltas (§7.4 — fossil sends a cfile delta
/// before its basis arrives, and the receiver MUST retain it), so a fixed
/// slot count would abort a conformant exchange. Growth is not unbounded: the
/// §4 body cap already ceils how many cards a reply can carry, and `max_pending`
/// derives that ceiling explicitly (below), so a runaway peer still cannot make
/// this list allocate without bound.
pub const PendingDeltaQueue = struct {
    /// The smallest number of reply-body bytes a delta-bearing card can occupy:
    /// a `file HASH DELTASRC SIZE\n` header over two 40-hex (SHA-1) names, a
    /// one-digit SIZE, single-space separators and the terminating LF, plus at
    /// least one payload byte. Any real card is larger (cfile has a longer
    /// keyword, SHA-3 names are 64 hex, payloads exceed one byte), so this is a
    /// safe lower bound — it can only over-count how many cards fit, never
    /// under-count, so the ceiling it yields never rejects a conformant reply.
    pub const min_delta_card_bytes: usize = "file ".len + 40 + " ".len + 40 + " ".len + "1".len + "\n".len + 1;
    /// §7.4 + §4 L830-833 — the retention ceiling. Not an arbitrary constant, and not a protocol limit: §4 imposes no entity ceiling, so this rests on a zit-local one —
    /// a Content-Length-less reply body is capped at `reply_body_cap` bytes
    /// (§4-r8), and each retained delta consumed at least `min_delta_card_bytes`
    /// of that body, so no conformant reply can present more than
    /// `reply_body_cap / min_delta_card_bytes` dangling deltas. A peer that
    /// exceeds it has already exceeded the body cap; the queue rejects it rather
    /// than allocate past this locally derived bound.
    pub const max_pending: usize = reply_body_cap / min_delta_card_bytes;
    pub const RetainError = error{QueueFull} || std.mem.Allocator.Error;

    /// Slot arena: a retained entry keeps its index for life (null = free),
    /// so both indexes below hold STABLE handles and removal never compacts.
    /// Slot count never exceeds the ceiling (`live <= cap` and slots are
    /// reused through `free_slots` before the arena grows).
    items: std.ArrayListUnmanaged(?PendingDelta) = .empty,
    /// Reusable freed slots. Invariant: capacity >= items.len at all times
    /// (re-established after every arena growth), so returning a slot to the
    /// free list can never fail — removal paths stay infallible.
    free_slots: std.ArrayListUnmanaged(usize) = .empty,
    /// Occupied slots — the queue's logical size.
    live: usize = 0,
    /// O(1) `hasTarget`: live-entry count per target hash. The gimme driver
    /// consults the predicate once per phantom per round, so membership must
    /// not cost a queue scan (a delta-heavy peer retains 10^5+ entries).
    /// Keys are map-owned copies — an entry's own hash bytes die with the
    /// entry, and duplicate targets share one counted key.
    by_target: std.StringHashMapUnmanaged(u32) = .empty,
    /// O(1) dependents-of-DELTASRC: slot indices per basis hash, maintained
    /// by `retain` and drained whole by `completeFor` — a completion fires
    /// once per stored artifact, so resolving its dependents must not cost a
    /// queue scan either. A bucket's key borrows the deltasrc bytes of one
    /// of its own entries, and the bucket leaves the map before any of its
    /// entries' bytes are freed.
    by_src: std.StringHashMapUnmanaged(std.ArrayListUnmanaged(usize)) = .empty,
    /// The retention ceiling enforced by `retain`; defaults to the §7.4/§4-derived
    /// `max_pending`. A caller may lower it (tests exercise the QueueFull edge
    /// with a small ceiling) but never needs to raise it — the body cap already
    /// bounds a conformant peer below `max_pending`.
    cap: usize = max_pending,

    /// Number of deltas currently retained. Hides the backing representation so
    /// callers never reach into `items` directly.
    pub fn itemCount(self: *const PendingDeltaQueue) usize {
        return self.live;
    }

    /// Free every retained entry's owned bytes and the list backing. A queue
    /// still holding deltas at session end (their basis never arrived — §7.7
    /// permits the session to end with an unresolved phantom) is drained here so
    /// nothing leaks.
    pub fn deinit(self: *PendingDeltaQueue, gpa: std.mem.Allocator) void {
        for (self.items.items) |maybe| if (maybe) |entry| freeEntry(gpa, entry);
        self.items.deinit(gpa);
        self.free_slots.deinit(gpa);
        var keys = self.by_target.keyIterator();
        while (keys.next()) |k| gpa.free(k.*);
        self.by_target.deinit(gpa);
        var buckets = self.by_src.valueIterator();
        while (buckets.next()) |b| b.deinit(gpa);
        self.by_src.deinit(gpa);
    }

    /// One entry naming `hash` as target is leaving the queue: decrement its
    /// live count, dropping (and freeing) the map key at zero.
    fn releaseTarget(self: *PendingDeltaQueue, gpa: std.mem.Allocator, hash: []const u8) void {
        const count = self.by_target.getPtr(hash) orelse unreachable; // every entry was counted in
        count.* -= 1;
        if (count.* == 0) {
            const kv = self.by_target.fetchRemove(hash).?;
            gpa.free(kv.key);
        }
    }

    fn freeEntry(gpa: std.mem.Allocator, entry: PendingDelta) void {
        gpa.free(entry.hash);
        gpa.free(entry.deltasrc);
        gpa.free(entry.payload.bytes());
    }

    /// §7.4-r1 — retain a private copy of `hash`/`deltasrc` and the delta
    /// `payload`, deferring reconstruction to `completeFor`. The inputs slice
    /// into the per-round reply body (freed at the round boundary) while the
    /// basis may not arrive until a later round, so the queue OWNS its copies
    /// and frees them on completion or `deinit`. `payload` is borrowed here and
    /// duplicated; pass `.container` for a cfile (the §4.1 container retained
    /// verbatim) or `.raw` for a `file` card (the bare [FossilDelta]). Does not
    /// touch the store: creating the DELTASRC phantom (§7.4-r2) is the caller's
    /// job via `store.intern`.
    pub fn retain(self: *PendingDeltaQueue, gpa: std.mem.Allocator, hash: []const u8, deltasrc: []const u8, payload: PendingDelta.Payload) RetainError!void {
        std.debug.assert(self.live <= self.cap);
        if (self.live >= self.cap) return RetainError.QueueFull;
        const hash_owned = try gpa.dupe(u8, hash);
        errdefer gpa.free(hash_owned);
        const src_owned = try gpa.dupe(u8, deltasrc);
        errdefer gpa.free(src_owned);
        const payload_owned: PendingDelta.Payload = switch (payload) {
            .container => |b| .{ .container = try gpa.dupe(u8, b) },
            .raw => |b| .{ .raw = try gpa.dupe(u8, b) },
        };
        errdefer gpa.free(payload_owned.bytes());
        if (self.by_target.getPtr(hash_owned)) |count| {
            count.* += 1;
        } else {
            const key = try gpa.dupe(u8, hash_owned);
            self.by_target.put(gpa, key, 1) catch |err| {
                gpa.free(key);
                return err;
            };
        }
        errdefer self.releaseTarget(gpa, hash_owned);
        // Acquire a slot, keeping the free-list capacity invariant so every
        // later slot release is infallible.
        const slot: usize = if (self.free_slots.pop()) |reused| reused else grow: {
            try self.items.append(gpa, null);
            try self.free_slots.ensureTotalCapacity(gpa, self.items.items.len);
            break :grow self.items.items.len - 1;
        };
        errdefer self.free_slots.appendAssumeCapacity(slot);
        // Index by basis. A fresh bucket's key borrows THIS entry's owned
        // deltasrc copy; an existing bucket's key belongs to another live
        // entry with the same basis, drained in the same completion.
        const gop = try self.by_src.getOrPut(gpa, src_owned);
        if (!gop.found_existing) gop.value_ptr.* = .empty;
        gop.value_ptr.append(gpa, slot) catch |err| {
            if (gop.value_ptr.items.len == 0) _ = self.by_src.remove(src_owned);
            return err;
        };
        // Everything fallible is done: seat the entry.
        self.items.items[slot] = .{ .hash = hash_owned, .deltasrc = src_owned, .payload = payload_owned };
        self.live += 1;
        std.debug.assert(self.live <= self.cap);
    }

    /// §7.4-r3 — `deltasrc` has just been stored; complete every deferred delta
    /// that named it as basis and drop the entry. A clone `.container` entry lands
    /// wire-backed (verbatim container, lazy reconstruction); a pull `.raw` entry
    /// reconstructs against the now-held basis and stores the full plaintext.
    /// Swap-remove: the queue carries no arrival order to preserve.
    pub fn completeFor(
        self: *PendingDeltaQueue,
        gpa: std.mem.Allocator,
        store: repostate.ArtifactStore,
        deltasrc: []const u8,
        /// §7.7 L1472-1479 — re-asked HERE, not inherited from the round that
        /// retained the entry. A retained delta lands its target on a LATER
        /// round, and the shun table is durable and locally mutable (§3.3
        /// L518-525) with the scan re-probing it every round, so a shun applied
        /// between rounds must stop the completion too. A rejected entry is
        /// dropped exactly as its card would have been: freed, no store
        /// mutation, no error, and not enqueued as a basis for the cascade.
        admission: TargetAdmission,
    ) (delta.Error || std.mem.Allocator.Error)!void {
        // §7.4-r3 cascades: reconstructing a target STORES it, which is itself a
        // "source artifact subsequently stored" event for any pending delta that
        // named that target as its basis (a delta chain C<-A<-B). Drive the chain
        // to fixpoint via a worklist of newly-stored sources. Bounded: each queue
        // entry completes at most once (swap-removed and freed), so at most
        // `initial_len` target hashes are ever enqueued, plus the initial
        // deltasrc. Reserve that exact capacity up front so the appends below
        // never reallocate and the fixpoint loop terminates, asserted at the end.
        const initial_live = self.live;
        var work: std.ArrayListUnmanaged([]const u8) = .empty;
        defer work.deinit(gpa);
        try work.append(gpa, deltasrc);
        var done: usize = 0;
        while (done < work.items.len) {
            const src = work.items[done];
            done += 1;
            // Drain the whole bucket for this basis: O(dependents), found in
            // O(1) — a completion fires once per stored artifact, so this
            // must never rescan the queue.
            var bucket = (self.by_src.fetchRemove(src) orelse continue).value;
            defer bucket.deinit(gpa);
            var j: usize = 0;
            // A completion error aborts the round; the entries already
            // processed left the queue (exactly as the old eager
            // swap-remove guaranteed) and the unprocessed tail — including
            // the failing entry — is re-homed so it stays reachable.
            errdefer self.rehomeTail(gpa, &bucket, j);
            while (j < bucket.items.len) {
                const i = bucket.items[j];
                // `src` is stored before it enters the worklist (the caller stored
                // the initial deltasrc, and every cascaded src was stored below), so
                // `completeEntry` resolves it when it needs the basis. A clone's
                // `.container` entries need NO basis plaintext at all — they land
                // wire-backed — so the basis is never materialised here (peak stays
                // O(wire)); only a pull's `.raw` entry reads it, lazily.
                //
                // §7.7 L1472-1479 — but only for a target still admitted. The
                // payload tag IS the card type the L1440 rules split on: `.raw` is
                // a §7.1 `file` (no container), `.container` a §7.2 `cfile`.
                const entry = self.items.items[i].?;
                if (admission.admits(entry.hash, entry.payload == .container)) {
                    const target_rid = try self.completeEntry(gpa, store, entry);
                    // The target is now stored: enqueue it as a basis for any
                    // other pending delta (§7.4-r3 cascade). Amortized append —
                    // a per-call whole-queue reservation costs O(live) on EVERY
                    // stored artifact; an append failure aborts the round like
                    // any completion error, with the same rehome unwind.
                    std.debug.assert(work.items.len < initial_live + 1);
                    try work.append(gpa, store.hashOf(target_rid));
                }
                j += 1;
                self.items.items[i] = null;
                self.free_slots.appendAssumeCapacity(i);
                self.live -= 1;
                self.releaseTarget(gpa, entry.hash);
                freeEntry(gpa, entry);
            }
        }
        std.debug.assert(work.items.len <= initial_live + 1); // §7.4 fixpoint terminates
    }

    /// A completion aborted mid-bucket: re-key the unprocessed tail
    /// (including the failing entry, still seated in its slot) under the
    /// failing entry's own DELTASRC copy, so those entries stay reachable
    /// for a later round. On OOM during this unwind the tail entries stay
    /// live in their slots (`deinit` still frees them) but leave `by_src` —
    /// they can no longer complete this session, exactly as if their basis
    /// never arrived (§7.7 permits ending with unresolved phantoms).
    fn rehomeTail(self: *PendingDeltaQueue, gpa: std.mem.Allocator, bucket: *const std.ArrayListUnmanaged(usize), j: usize) void {
        if (j >= bucket.items.len) return;
        const key_owner = self.items.items[bucket.items[j]].?;
        const gop = self.by_src.getOrPut(gpa, key_owner.deltasrc) catch return;
        if (!gop.found_existing) gop.value_ptr.* = .empty;
        gop.value_ptr.appendSlice(gpa, bucket.items[j..]) catch {
            if (gop.value_ptr.items.len == 0) _ = self.by_src.remove(key_owner.deltasrc);
        };
    }

    /// §7.2/§7.4 — reconstruct one now-completable `entry` against its held
    /// basis `source_bytes`, store the reconstructed plaintext, and (for a cfile
    /// `.container` payload) retain the received §4.1 delta container VERBATIM
    /// keyed to the new rid, so the persist commit hook writes a compact `.delta`
    /// frame and a clone-of-clone re-serves the exact bytes (§7.2). A `.raw`
    /// payload (file card) has no container to preserve and lands as plaintext,
    /// which the persist path compresses to full form — the pre-slice-A
    /// behaviour, unchanged. §7.7 verification stays deferred to the persist
    /// commit hook (`persistStore`), which recomputes the hash before any durable
    /// write; the in-memory retention here is advisory. Returns the target rid.
    fn completeEntry(
        self: *PendingDeltaQueue,
        gpa: std.mem.Allocator,
        store: repostate.ArtifactStore,
        entry: PendingDelta,
    ) (delta.Error || std.mem.Allocator.Error)!repostate.Rid {
        _ = self;
        switch (entry.payload) {
            .raw => |raw| {
                // §7.1 file-card delta (pull): no container to preserve, so
                // reconstruct against the now-held basis and store the full
                // plaintext. The basis was interned before this entry entered the
                // worklist, so its lookup is present (§7.4 precondition). Its
                // content, however, may NOT be materialisable: the W4 full-form
                // path (§7.2/§7.7) now retains a stored cfile container VERBATIM
                // and defers §7.7 inflate/verify to read, so a corrupt full
                // container is interned+retained with a null content(). When such a
                // basis backs this dependent delta, the plaintext cannot be
                // reconstructed — surface it as MalformedDelta so completeFor's call
                // site aborts the round (§5.2), preserving the pre-W4 "abort on
                // corrupt basis" outcome without the eager receive-time inflate.
                const src_rid = store.lookup(entry.deltasrc) orelse unreachable;
                const source_bytes = store.content(src_rid) orelse return error.MalformedDelta;
                const reconstructed = try delta.apply(gpa, source_bytes, raw);
                defer gpa.free(reconstructed);
                return store.put(entry.hash, reconstructed);
            },
            .container => |container| {
                // §7.2/§7.4 clone cfile delta — store the verbatim §4.1 container
                // WIRE-BACKED (basis name + reconstructed USIZE), NOT the inflated
                // target: content() reconstructs lazily and the persist commit hook
                // verifies §7.7, so a clone never memoises the completed target
                // (peak stays O(wire)). The USIZE is the delta §3.2 header size, read
                // WITHOUT applying the delta. A corrupt retained delta — decode or a
                // malformed header — maps to MalformedDelta, aborting the round
                // exactly as the cfile handler would. Bounded by the reply body cap.
                // Only the delta HEADER is needed here — the target size is
                // its first bytes — so inflate just that prefix; the full
                // inflate ran once per received delta and was a visible slice
                // of a clone's client CPU.
                const raw = codec.decodePrefix(gpa, container, 16) catch |err| switch (err) {
                    error.OutOfMemory => return error.OutOfMemory,
                    error.TruncatedHeader, error.InvalidStream, error.OutputTooLarge => return error.MalformedDelta,
                };
                defer gpa.free(raw);
                const target_size = delta.targetSize(raw) catch return error.MalformedDelta;
                const rid = try store.intern(entry.hash);
                store.retain(rid, .{ .delta = .{ .src = entry.deltasrc, .container = container, .target_size = target_size } }) catch |err| switch (err) {
                    error.OutOfMemory => return error.OutOfMemory,
                };
                return rid;
            },
        }
    }

    /// §3.3 L574-585 dangling-delta path — OPTION B content-received
    /// derivation. A HASH whose delta payload has been retained (its basis
    /// not yet local) is content-received: §3.3 deletes its phantom on delta
    /// receipt, so gimme MUST NOT re-request it. The store stays binary
    /// (present / phantom), so that state is derived here — a live queue
    /// entry naming HASH as target — never a store mutation. The
    /// disjunction's other half (content already stored) is the store's own
    /// `content(rid) != null`, which the gimme driver checks as it
    /// enumerates phantoms; this predicate answers only the retained-delta
    /// half. A pure membership test: no error set, errors defined away, and
    /// O(1) — the gimme driver asks once per phantom per round.
    pub fn hasTarget(self: *const PendingDeltaQueue, hash: []const u8) bool {
        std.debug.assert(self.live <= self.cap);
        std.debug.assert(hash.len == 40 or hash.len == 64);
        return self.by_target.contains(hash);
    }
};

// §A.1 L2539-2549 (Round 2 request of the two-round push/pull worked
// example) — the vector holds the first file card's 272-byte manifest
// payload, the second file card's 26-byte payload, and enough of the
// following gimme card to prove the "no separator" boundary lands
// exactly where the RFC's own byte counts say it does.
// §7.4-r2 — the receiver "MUST create a phantom record for the named
// DELTASRC". A phantom is `ArtifactStore`'s own contract (content(rid) ==
// null), so this is asserted directly against the store, mirroring the
// §3.3 r24 store-contract test above: no sync-layer isPhantom wrapper.
// ---------------------------------------------------------------------------
// Section 7.5 — Private Content
// ---------------------------------------------------------------------------

/// §7.5 L1391-1396 (r4/r5) — the sole holds-Private gate on receipt: an
/// artifact is stored only when the authenticated user holds the
/// Private capability; otherwise it is discarded without error (r5) —
/// the plain enum, not an error union, is what defines "without error"
/// out of existence.
pub const PrivateReceipt = enum { store, discard };

pub fn privateReceipt(user_has_private: bool) PrivateReceipt {
    const result: PrivateReceipt = if (user_has_private) .store else .discard;
    std.debug.assert((result == .store) == user_has_private);
    return result;
}

/// §7.5 L1409-1411 (r7) — send-side delta-basis gate. Precondition: the
/// artifact being sent is itself public — the boundary only bites when a
/// *public* artifact's on-disk delta basis is private. Sending such a
/// delta to a peer lacking private-content authorization would leak the
/// basis artifact's existence/identity, so the sender is forced to full,
/// non-delta content instead.
pub const DeltaBasisSend = enum { delta_ok, force_full };

pub fn deltaBasisSend(basis_private: bool, peer_has_private: bool) DeltaBasisSend {
    const result: DeltaBasisSend = if (basis_private and !peer_has_private) .force_full else .delta_ok;
    std.debug.assert((result == .force_full) == (basis_private and !peer_has_private));
    return result;
}

/// §7.5 L1411-1413 (r9/r10/r11/r12) — receive-side disposition of a
/// private `igot HASH 1` from a peer that lacks private-content
/// authorization: never an error (r9), never creates a phantom (r10),
/// marks an already-held phantom private (r11), and always records that
/// the peer holds HASH (r12). `already_phantom` is the caller's lookup
/// of its own phantom table; the caller applies `mark_phantom_private`
/// back to that table — same returned-decision contract as
/// `igotDisposition`. No repostate code lands here.
pub const PrivateIgotReceipt = struct {
    is_error: bool,
    create_phantom: bool,
    mark_phantom_private: bool,
    record_peer_has: bool,
};

pub fn receivePrivateIgot(already_phantom: bool) PrivateIgotReceipt {
    const result: PrivateIgotReceipt = .{
        .is_error = false,
        .create_phantom = false,
        .mark_phantom_private = already_phantom,
        .record_peer_has = true,
    };
    std.debug.assert(!result.is_error and !result.create_phantom and
        result.record_peer_has and result.mark_phantom_private == already_phantom);
    return result;
}

/// §8.6 L1813-1814 / §B L2921-2922 — a parsed `igot HASH` (public) or `igot HASH 1`
/// (private) card. `1` is the only flag value this grammar defines.
pub const IgotCard = struct { hash: []const u8, private: bool };

/// §8.6 L1813-1814 / §B L2921-2922 — parse `igot HASH` or `igot HASH 1`. Arity alone
/// disambiguates the two forms, same shape as `parseFileCard`/
/// `parseUvigotCard`; a third token other than the literal "1" is
/// malformed, and any arity outside {2,3} is malformed.
///
/// HASH must be one of artifact-format §3.2 L347-350's two complete forms
/// (40-hex SHA-1 or 64-hex SHA3-256) — "A candidate name of any other length
/// MUST be rejected as invalid" — exactly as `parseGimmeCard` already rejects
/// one, so the 40/64 width is never assumed downstream on wire input. This is
/// the boundary `logstore.LogStore.isShunned`'s caller contract names: §3.3
/// L527-530 puts the shun table in the per-round phantom scan, and a phantom is
/// minted from an igot HASH, so an abbreviated name admitted here would become a
/// phantom whose key cannot be in a table keyed by complete name (§3.3 L544-545)
/// — a hash the shun filter answers "not shunned" for and the scan then requests.
/// Rejecting the name here makes that fail-open path unrepresentable instead of
/// widening the filter to tolerate it.
pub fn parseIgotCard(tokens: Tokens) CardParseError!IgotCard {
    std.debug.assert(tokens.len >= 1 and tokens.len <= max_tokens);
    std.debug.assert(parseOperator(tokens.items[0]) == .igot);
    const result: IgotCard = switch (tokens.len) {
        2 => .{ .hash = tokens.items[1], .private = false },
        3 => blk: {
            if (!std.mem.eql(u8, tokens.items[2], "1")) return CardParseError.Malformed;
            break :blk .{ .hash = tokens.items[1], .private = true };
        },
        else => return CardParseError.Malformed,
    };
    if (result.hash.len != 40 and result.hash.len != 64) return CardParseError.Malformed;
    std.debug.assert(!result.private or tokens.len == 3);
    return result;
}

/// §3.3 r27 — a parsed `gimme HASH` card.
pub const GimmeCard = struct { hash: []const u8 };

/// §3.3 r27 L569-572 / §B L2923 — parse `gimme HASH`. Arity 2 exactly; HASH is a 40-hex
/// SHA1 or 64-hex SHA3 token (the §3.1 hash-token lengths `recordPeerLacks`
/// records into). Any other arity or hash length is malformed and rejected
/// here — so the 40/64 length is never asserted downstream on wire input.
pub fn parseGimmeCard(tokens: Tokens) CardParseError!GimmeCard {
    std.debug.assert(tokens.len >= 1);
    std.debug.assert(parseOperator(tokens.items[0]) == .gimme);
    if (tokens.len != 2) return CardParseError.Malformed;
    const hash = tokens.items[1];
    if (hash.len != 40 and hash.len != 64) return CardParseError.Malformed;
    return .{ .hash = hash };
}

// ---------------------------------------------------------------------------
// Section 7.7 — Hash-Policy Admission
// ---------------------------------------------------------------------------

/// §7.7 L1469-1472 — target admission for one received artifact-transfer card.
/// "Admission applies to the received target HASH, not independently to
/// DELTASRC. A target is shunned for admission when its complete name is in the
/// repository shun table OR when shun-sha1 applies to its SHA1 name."
///
/// ONE COMBINED PREDICATE, not two checks. The draft states a single disjunction
/// and every call site asks the same question; splitting it into a policy gate
/// beside a table gate lets the halves drift apart. What differs between call
/// sites is what each one DOES with the answer (§7.7 L1472-1479 — a non-clone
/// `file` discards, a clone `file` never asks, a `cfile` discards even in
/// clone), so the asymmetry lives at the three call sites, each with its own
/// quoted clause, and never inside this function.
///
/// Neither input comes from the peer: `policy` is this repository's effective
/// hash-policy value (L1428-1432 — "No peer state or artifact-transfer card
/// field participates in this predicate") and `shun` is the repository-local
/// durable table (§3.3 L544-551).
///
/// It never recomputes or inspects content — that is content-integrity's job
/// (L1465-1471), a separate question this function is deliberately blind to.
///
/// OUT-OF-DOMAIN NAME WIDTH is answered, not asserted. `parseFileCard` and
/// `parseCfileCard` validate arity and the byte counts but NOT the hash width,
/// so a peer can put any token here; an assert would be reachable from wire
/// input (and would vanish in a release build besides). Neither half of the
/// predicate can fire on such a name — `nameAlgo` is null so shun-sha1 cannot
/// apply, and §3.3 L544-545 keys the table by COMPLETE name — so `admit` is the
/// exact answer, and the name goes on to whatever the store layer already did
/// with it.
pub const Admission = enum { admit, reject };

pub fn admitInbound(policy: artifact.HashPolicy, shun: ShunSet, hash: []const u8) Admission {
    if (artifact.nameAlgo(hash.len) == null) return .admit;
    std.debug.assert(hash.len == 40 or hash.len == 64);
    const sha1_shunned = (policy == .shun_sha1 and hash.len == 40);
    const table_shunned = shun.contains(hash);
    return if (sha1_shunned or table_shunned) .reject else .admit;
}

/// §7.7 L1469-1479 — may this receiver LAND content under this target name?
/// Answered wherever content is about to be stored rather than only where a card
/// is parsed.
///
/// `admitInbound` is the combined predicate; this adds the one input the
/// predicate deliberately excludes — WHICH CARD, in WHICH context — because the
/// draft attaches two different rules to the same answer: "A clone `file`
/// bypasses this combined target check" (L1440) while for `cfile` "a shunned
/// target is consumed and discarded after its payload EVEN IN CLONE"
/// (L1440-1444). Card-arm gates alone cannot carry that: a `file`'s bytes can
/// land rounds later, out of any arm, when a deferred delta's basis finally
/// arrives — and admission is a per-round question (§3.3 L544-551 makes the
/// table durable and locally mutable, and the scan re-probes it every round), so
/// a shun applied mid-session must reach the completion too. Bundling the three
/// inputs is what lets the queue re-ask without knowing about `Client`.
///
/// This is CONTROL FLOW, not an assertion: §7.7's "makes no repository mutation"
/// must hold in a release build, where `std.debug.assert` compiles to nothing.
pub const TargetAdmission = struct {
    policy: artifact.HashPolicy = .auto,
    shun: ShunSet = .none,
    /// §7.7 L1475 — the clone-context predicate the `file` bypass keys on.
    clone: bool = false,

    /// `from_cfile` selects which L1440 rule applies: a `cfile` target is always
    /// asked, a `file` target is not asked at all in clone.
    pub fn admits(self: TargetAdmission, hash: []const u8, from_cfile: bool) bool {
        if (self.clone and !from_cfile) return true;
        return admitInbound(self.policy, self.shun, hash) == .admit;
    }

    /// The identity — an empty table under `auto`, outside clone. Every call site
    /// that has no admission policy to express behaves exactly as it did before
    /// admission existed: `admits` is unconditionally true.
    pub const unrestricted: TargetAdmission = .{};
};

/// §7.7 L1492-1498 — the content-integrity check-point schedule for one
/// inbound content card. `pre_store` is the transfer-layer recompute-over-
/// reconstructed-bytes check (artifact.verify on the delta-applied content,
/// not the wire payload — r5); it fires only on non-clone file cards (r6).
/// cfile cards never pre-store-verify in the transfer layer (r7). `at_commit`
/// is the universal: commit-time verification applies on ALL paths (r6/r7
/// second clauses) — always true, pinned by assertion and by every test combo.
pub const IntegritySchedule = struct { pre_store: bool, at_commit: bool };

pub fn integritySchedule(op: Operator, clone_path: bool) IntegritySchedule {
    std.debug.assert(op == .file or op == .cfile); // §7.7 governs file/cfile; uvfile is §7.3-r13
    const result: IntegritySchedule = .{ .pre_store = (op == .file and !clone_path), .at_commit = true };
    std.debug.assert(result.at_commit); // commit-time on all paths, no branch may drop it
    std.debug.assert(result.pre_store == (op == .file and !clone_path));
    return result;
}

// ---------------------------------------------------------------------------
// Section 7.3 — uvfile Card
// ---------------------------------------------------------------------------

/// §7.3 — FLAGS bit 0x0001: deletion marker. Sender-side metadata only; the
/// receiver decides delete-vs-update from HASH (see isDeletion), but payload
/// framing gates on this bit (see uvfilePayloadFollows).
pub const uvfile_flag_deletion: u32 = 0x0001;

/// §7.3-r7 — FLAGS bit 0x0004: content is omitted from this transfer.
pub const uvfile_flag_omitted: u32 = 0x0004;

/// §7.3-r1/r2 — a `uvfile` card: unversioned-file sync state, carried
/// entirely outside the artifact model, its hash policy, and its
/// tag-state rules ([FossilArtifact] plays no part in this parse).
pub const UvfileCard = struct {
    name: []const u8, // §7.3-r3 — fossilize-escaped; NOT decoded here
    mtime: i64, // §7.3-r4 — decimal seconds since the Unix epoch
    hash: []const u8, // §7.3-r5/r16 — artifact-hash, or "-" for a deletion
    size: u32, // §7.3-r6 — payload byte count following the card line
    flags: u32, // §7.3-r7 — defined low bits of the decimal wire bit field

    /// §7.3 L1293-1295 — the receiver decides deletion from the HASH
    /// token, never the 0x0001 bit: a deletion is signaled by HASH
    /// equal to "-" (with SIZE 0); a sender emitting a deletion also
    /// sets 0x0001, but that bit is sender-side metadata only.
    pub fn isDeletion(self: UvfileCard) bool {
        return std.mem.eql(u8, self.hash, "-");
    }
    pub fn contentOmitted(self: UvfileCard) bool {
        return self.flags & uvfile_flag_omitted != 0;
    }
};

/// §7.3-r15 — the payload-free announcement form of a uvfile's state.
pub const UvigotCard = struct { name: []const u8, mtime: i64, hash: []const u8, size: u32 };

/// §7.3-r17 — a request for one named unversioned file.
pub const UvgimmeCard = struct { name: []const u8 };
/// §7.3/App. B — the numeric UV fields use `decimal = 1*DIGIT`; signs,
/// separators, and an empty token are not wire decimals. Range checking remains
/// field-specific, so SIZE stays bounded while FLAGS uses its own parser below.
fn parseUvDecimal(comptime T: type, token: []const u8) CardParseError!T {
    for (token) |byte| {
        if (byte < '0' or byte > '9') return CardParseError.Malformed;
    }
    if (token.len == 0) return CardParseError.Malformed;
    return std.fmt.parseInt(T, token, 10) catch return CardParseError.Malformed;
}

/// §7.3-r7 — FLAGS is an unbounded decimal bit field. Only defined low bits
/// survive validation; compute modulo 8 while scanning so reserved high bits
/// never impose a fixed-width range limit.
fn parseUvFlags(token: []const u8) CardParseError!u32 {
    var modulo_eight: u32 = 0;
    for (token) |byte| {
        if (byte < '0' or byte > '9') return CardParseError.Malformed;
        modulo_eight = (modulo_eight * 10 + @as(u32, byte - '0')) & 0x7;
    }
    if (token.len == 0) return CardParseError.Malformed;
    return modulo_eight & 0x5;
}

/// §7.3-r2 — parse the six captured `uvfile` slots: `NAME MTIME HASH SIZE
/// FLAGS`. The fixed parser window intentionally ignores any later visible
/// tokens (§5.2 L972-975); every other UV card retains strict arity.
pub fn parseUvfileCard(tokens: Tokens) CardParseError!UvfileCard {
    std.debug.assert(tokens.len >= 1 and tokens.len <= max_tokens);
    std.debug.assert(parseOperator(tokens.items[0]) == .uvfile);
    if (tokens.len != 6) return CardParseError.Malformed;
    const mtime = try parseUvDecimal(i64, tokens.items[2]);
    const size = try parseUvDecimal(u32, tokens.items[4]);
    const flags = try parseUvFlags(tokens.items[5]);
    return .{ .name = tokens.items[1], .mtime = mtime, .hash = tokens.items[3], .size = size, .flags = flags };
}

/// §7.3-r15 — parse `uvigot NAME MTIME HASH SIZE`. Arity 5 is below
/// `max_tokens` (6); `tokenizeCard` only ever sets `surplus` when
/// `len == max_tokens`, so a strict `len != 5` already excludes every
/// surplus line — no separate guard needed.
pub fn parseUvigotCard(tokens: Tokens) CardParseError!UvigotCard {
    std.debug.assert(tokens.len >= 1 and tokens.len <= max_tokens);
    std.debug.assert(parseOperator(tokens.items[0]) == .uvigot);
    if (tokens.len != 5) return CardParseError.Malformed;
    const mtime = try parseUvDecimal(i64, tokens.items[2]);
    const size = try parseUvDecimal(u32, tokens.items[4]);
    return .{ .name = tokens.items[1], .mtime = mtime, .hash = tokens.items[3], .size = size };
}

/// §7.3-r5/r16 — an unversioned hash is either the deletion literal `-` or a
/// canonical artifact hash. It deliberately has no repository hash-policy
/// dependency.
fn validUvHash(hash: []const u8) bool {
    return std.mem.eql(u8, hash, "-") or artifact.validateName(hash);
}

/// §7.3-r17 — parse `uvgimme NAME`. Same below-max-tokens arity
/// argument as `parseUvigotCard`: `len != 2` alone excludes every
/// surplus line.
pub fn parseUvgimmeCard(tokens: Tokens) CardParseError!UvgimmeCard {
    std.debug.assert(tokens.len >= 1 and tokens.len <= max_tokens);
    std.debug.assert(parseOperator(tokens.items[0]) == .uvgimme);
    if (tokens.len != 2) return CardParseError.Malformed;
    return .{ .name = tokens.items[1] };
}

/// §7.3-r9/r10/r12 — true iff a payload follows this card's LF: SIZE is
/// positive and neither the deletion (0x0001) nor the omitted-content (0x0004)
/// FLAGS bit is set. Payload framing keys on the FLAGS bits (`flags & 0x0005`),
/// not on the HASH-based deletion test that governs delete-vs-update; this
/// matches the reference receiver per the §7.3 uvfile-flags erratum.
pub fn uvfilePayloadFollows(card: UvfileCard) bool {
    return card.size > 0 and (card.flags & (uvfile_flag_deletion | uvfile_flag_omitted)) == 0;
}

/// §7.3-r10/r11/r12 — frame the payload declared by `card`, or the
/// empty span immediately after `start` when none follows (r12). The
/// payload-present path delegates to `consumeContentPayload`'s
/// overflow-checked bound.
pub fn consumeUvfilePayload(body: []const u8, start: usize, card: UvfileCard) ContentFrameError!ContentPayload {
    std.debug.assert(start <= body.len);
    if (!uvfilePayloadFollows(card)) {
        const result: ContentPayload = .{ .bytes = body[start..start], .next = start };
        std.debug.assert(result.bytes.len == 0 and result.next == start);
        return result;
    }
    return consumeContentPayload(body, start, card.size);
}

/// §7.3-r13 — verify HASH against `content` before the caller stores
/// it. A garbage-length `hash` (neither 40 nor 64 hex) simply fails to
/// verify rather than panicking on an unrecognized algorithm.
pub fn verifyUvContent(hash: []const u8, content: []const u8) bool {
    const algo = artifact.nameAlgo(hash.len) orelse return false;
    var buf: [64]u8 = undefined;
    return std.mem.eql(u8, artifact.artifactName(algo, content, &buf), hash);
}

/// §7.3-r18 — the outcome of landing one incoming uvfile: accepted, a
/// write-permission rejection, or a no-op (identical, local wins, or
/// dry-run).
pub const UvLanding = enum { land, not_authorized, no_op };

/// §7.3-r18, §8.5.1-uv2 — recheck the same status function (`uvDecide`)
/// and the write-permission gate before landing an incoming uvfile.
/// `pull_only` is always passed `false`: it drives only `send_table`
/// (never read on the landing path), and `false` is its
/// least-restrictive setting, so it never masks the `.local` outcome
/// this function reads.
pub fn uvLandDecision(local: ?UvRow, card: UvfileCard, mode: UvMode, may_write: bool) UvLanding {
    const result: UvLanding = if (!may_write)
        .not_authorized // §7.3-r18
    else switch (uvDecide(local, card.mtime, card.hash, mode, false).local) {
        .fetch, .record_deletion, .touch_mtime => .land, // remote wins, or a permitted metadata-only update (r14)
        .none => .no_op, // identical, local wins, or dry-run: never overwrite
    };
    std.debug.assert((result == .not_authorized) == !may_write);
    return result;
}

/// §7.3-r19 — an accepted uvfile change clears the cached unversioned
/// catalog hash; a rejection or no-op leaves it untouched.
pub fn uvLandingClearsCatalog(outcome: UvLanding) bool {
    return outcome == .land;
}

// ---------------------------------------------------------------------------
// Section 8.5.1 — Unversioned-File Synchronisation
// ---------------------------------------------------------------------------

/// §8.5.1 L1725-1726, L1756-1759 — a local unversioned-file row as classification sees
/// it. A null hash marks a locally recorded deletion. `size`/`encoding`/
/// `content` belong to the storage layer and play no part in status or
/// catalog-hash math, so they are not carried here.
pub const UvRow = struct {
    name: []const u8,
    mtime: i64,
    hash: ?[]const u8,
};

/// §8.5.1 L1723-1741 — the five-way (0..5) unversioned status code.
pub const UvStatus = enum(u3) {
    absent = 0,
    remote_wins = 1,
    older = 2,
    identical = 3,
    newer = 4,
    local_wins = 5,
};

/// §8.5.1 L1752-1761 — the local-store effect of an (already revert-mapped)
/// status. `fetch` queues uvgimme and drops the row that would be
/// replaced; `record_deletion` stores the received mtime and clears
/// hash/size/encoding/content; `touch_mtime` updates local mtime only.
pub const UvLocalAction = enum { none, fetch, record_deletion, touch_mtime };

/// §8.5.1 L1765-1775 — the send-table effect of an (already revert-mapped)
/// status.
pub const SendTableOp = enum { remove, mtime_only, keep_full };

/// §8.5.1 L1743-1745 — revert/dry-run posture for one uvigot classification.
pub const UvMode = struct {
    revert: bool = false,
    dry_run: bool = false,
};

/// §8.5.1 — the combined outcome of classifying one uvigot row: the
/// post-revert-map status plus both of its effects, computed together so
/// the revert map is applied exactly once (never for one effect but not
/// the other).
pub const UvDecision = struct {
    status: UvStatus,
    local: UvLocalAction,
    send_table: SendTableOp,
};

/// §8.5.1 L1714-1719 — the per-request catalog-emission dedup latch; one
/// instance covers one request.
pub const ServerUvRequest = struct {
    catalog_emitted: bool = false,
};

/// §8.5.1 L1723-1741 steps 1-5 — the raw status code, before revert
/// mapping. `remote_hash` is the wire HASH (40/64 hex, or "-" for a
/// remote deletion).
pub fn uvStatus(local: ?UvRow, remote_mtime: i64, remote_hash: []const u8) UvStatus {
    std.debug.assert(remote_hash.len >= 1 and remote_hash.len <= 64);
    const l = local orelse return .absent;
    if (l.hash) |h| std.debug.assert(h.len <= 64);
    const local_hash: []const u8 = l.hash orelse "-";
    const mtime_cmp = std.math.order(l.mtime, remote_mtime);
    const hash_cmp = std.mem.order(u8, local_hash, remote_hash);
    const result: UvStatus = if (hash_cmp == .eq)
        switch (mtime_cmp) {
            .lt => .older,
            .eq => .identical,
            .gt => .newer,
        }
    else if (mtime_cmp == .lt or (mtime_cmp == .eq and hash_cmp == .lt))
        .remote_wins
    else
        .local_wins;
    std.debug.assert(@backingInt(result) <= 5);
    return result;
}

/// §8.5.1 L1743-1744 — revert mode remaps 4->2 and 5->1; every other
/// status is unchanged.
fn revertMap(s: UvStatus) UvStatus {
    return switch (s) {
        .newer => .older,
        .local_wins => .remote_wins,
        else => s,
    };
}

/// §8.5.1 L1765-1775 — the send-table effect of an already-mapped status;
/// a pull-only session never keeps an upload queued.
fn sendTableOp(status: UvStatus, pull_only: bool) SendTableOp {
    const result: SendTableOp = switch (status) {
        .absent, .remote_wins, .older, .identical => .remove,
        .newer => if (pull_only) .remove else .mtime_only,
        .local_wins => if (pull_only) .remove else .keep_full,
    };
    std.debug.assert(!pull_only or (result != .mtime_only and result != .keep_full));
    return result;
}

/// §8.5.1 L1752-1761, L1744-1745 — the local-store effect of an
/// already-mapped status. `remote_is_deletion` is HASH == "-" on the wire;
/// dry-run suppresses every local change.
fn uvLocalAction(status: UvStatus, remote_is_deletion: bool, dry_run: bool) UvLocalAction {
    if (dry_run) return .none;
    const result: UvLocalAction = switch (status) {
        .absent => if (remote_is_deletion) .none else .fetch,
        .remote_wins => if (remote_is_deletion) .record_deletion else .fetch,
        .older => .touch_mtime,
        .identical, .newer, .local_wins => .none,
    };
    std.debug.assert(!dry_run or result == .none);
    return result;
}

/// §8.5.1 — the deep composed interface: raw status, revert-mapped at most
/// once, then both its local-store and send-table effects computed over
/// the mapped value.
pub fn uvDecide(local: ?UvRow, remote_mtime: i64, remote_hash: []const u8, mode: UvMode, pull_only: bool) UvDecision {
    const raw = uvStatus(local, remote_mtime, remote_hash);
    const status = if (mode.revert) revertMap(raw) else raw;
    const remote_is_deletion = std.mem.eql(u8, remote_hash, "-");
    const decision: UvDecision = .{
        .status = status,
        .local = uvLocalAction(status, remote_is_deletion, mode.dry_run),
        .send_table = sendTableOp(status, pull_only),
    };
    std.debug.assert(!mode.dry_run or decision.local == .none);
    std.debug.assert(@backingInt(decision.status) <= 5);
    return decision;
}

/// §8.5.1 L1705-1709 — pure civil-from-unix conversion (UTC, no leap
/// seconds), matching SQLite's `datetime(mtime,'unixepoch')` output
/// exactly: fixed-width `YYYY-MM-DD HH:MM:SS`. Civil-date math is
/// Hinnant's `civil_from_days` (proleptic Gregorian, floor division
/// throughout so negative `mtime` stays correct) — never `std.time`, so
/// the result is a pure function of `mtime`.
fn formatUvDatetime(mtime: i64, buf: *[19]u8) []const u8 {
    const secs_per_day: i64 = 86400;
    const days = @divFloor(mtime, secs_per_day);
    const secs_of_day = @mod(mtime, secs_per_day);
    std.debug.assert(secs_of_day >= 0 and secs_of_day < secs_per_day);

    // Hinnant civil_from_days: days since 1970-01-01 -> proleptic Gregorian
    // y/m/d (http://howardhinnant.github.io/date_algorithms.html). era/doe
    // split uses floor division so doe always lands in [0, 146096],
    // keeping every later division non-negative regardless of sign(days).
    const z = days + 719468;
    const era = @divFloor(z, 146097);
    const doe = z - era * 146097; // [0, 146096]
    const yoe = @divTrunc(doe - @divTrunc(doe, 1460) + @divTrunc(doe, 36524) - @divTrunc(doe, 146096), 365); // [0, 399]
    const y = yoe + era * 400;
    const doy = doe - (365 * yoe + @divTrunc(yoe, 4) - @divTrunc(yoe, 100)); // [0, 365]
    const mp = @divTrunc(5 * doy + 2, 153); // [0, 11]
    const d = doy - @divTrunc(153 * mp + 2, 5) + 1; // [1, 31]
    const m = mp + (if (mp < 10) @as(i64, 3) else @as(i64, -9)); // [1, 12]
    const year = y + (if (m <= 2) @as(i64, 1) else @as(i64, 0));

    std.debug.assert(year >= 0 and year <= 9999);
    std.debug.assert(m >= 1 and m <= 12);
    std.debug.assert(d >= 1 and d <= 31);

    const hours = @divTrunc(secs_of_day, 3600);
    const minutes = @divTrunc(@mod(secs_of_day, 3600), 60);
    const seconds = @mod(secs_of_day, 60);

    // Written field-by-field (no std.fmt.bufPrint) so there is no fallible
    // path at all — width is fixed and every value is pre-bounded above.
    writeFixedDigits(buf[0..4], @intCast(year));
    buf[4] = '-';
    writeFixedDigits(buf[5..7], @intCast(m));
    buf[7] = '-';
    writeFixedDigits(buf[8..10], @intCast(d));
    buf[10] = ' ';
    writeFixedDigits(buf[11..13], @intCast(hours));
    buf[13] = ':';
    writeFixedDigits(buf[14..16], @intCast(minutes));
    buf[16] = ':';
    writeFixedDigits(buf[17..19], @intCast(seconds));
    return buf[0..19];
}

/// Writes `value` as `buf.len` zero-padded ASCII digits, most significant
/// first. `value` must fit in `buf.len` digits (asserted, not truncated).
fn writeFixedDigits(buf: []u8, value: u32) void {
    var v = value;
    for (0..buf.len) |k| {
        const i = buf.len - 1 - k;
        buf[i] = '0' + @as(u8, @intCast(v % 10));
        v /= 10;
    }
    std.debug.assert(v == 0);
}

/// §8.5.1 L1700-1712 — the unversioned catalog hash: SHA-1 [RFC3174] over,
/// in name order, `NAME " " DATETIME " " HASH "\n"` for every row whose
/// hash is not null (a deleted row is excluded, L2064-2067). `rows` must
/// already be name-sorted — the store owns that ordering, not re-derived
/// here.
pub fn uvCatalogHash(rows: []const UvRow) HashHex {
    var hasher = std.crypto.hash.Sha1.init(.{});
    var prev: ?[]const u8 = null;
    for (rows) |r| {
        std.debug.assert(r.name.len <= max_line_len - 1);
        if (prev) |p| std.debug.assert(std.mem.order(u8, p, r.name) != .gt);
        prev = r.name;
        const h = r.hash orelse continue;
        std.debug.assert(h.len <= 64);
        var dt_buf: [19]u8 = undefined;
        const dt = formatUvDatetime(r.mtime, &dt_buf);
        hasher.update(r.name);
        hasher.update(" ");
        hasher.update(dt);
        hasher.update(" ");
        hasher.update(h);
        hasher.update("\n");
    }
    var digest: [20]u8 = undefined;
    hasher.final(&digest);
    const hex = sha1FinalHex(digest);
    for (hex) |c| std.debug.assert((c >= '0' and c <= '9') or (c >= 'a' and c <= 'f'));
    return hex;
}

/// §8.5.1 L1714-1719 — wraps `serverUvResponse` with the per-request
/// duplicate-catalog latch: once a catalog has gone out this request, a
/// later call on the same `state` yields `.none` regardless of inputs.
pub fn serverUvCatalog(state: *ServerUvRequest, match: bool, has_read: bool, may_upload: bool) UvResponse {
    const already = state.catalog_emitted;
    const result: UvResponse = if (already) .none else serverUvResponse(match, has_read, may_upload);
    state.catalog_emitted = already or (result != .none);
    std.debug.assert(!already or result == .none);
    return result;
}

/// §3.1 L306-308, §8.5.1 L1695-1696 — the client's per-session unversioned
/// send table: NAME -> mtimeOnly. Owns duped NAME keys, so entries outlive
/// the per-round wire buffers that named them (mirrors `RoundTables`).
pub const UvSendTable = struct {
    map: std.StringHashMapUnmanaged(bool) = .empty,

    /// §8.5.1 L1695-1696 — session start: every local row whose hash is
    /// not null seeds the table with mtimeOnly false.
    pub fn initFromLocal(gpa: std.mem.Allocator, rows: []const UvRow) error{OutOfMemory}!UvSendTable {
        var self: UvSendTable = .{};
        errdefer self.deinit(gpa);
        try self.map.ensureUnusedCapacity(gpa, @intCast(rows.len));
        for (rows) |r| {
            if (r.hash == null) continue;
            std.debug.assert(r.name.len <= max_line_len - 1);
            if (self.map.contains(r.name)) continue;
            const owned = try gpa.dupe(u8, r.name);
            self.map.putAssumeCapacity(owned, false);
        }
        return self;
    }

    pub fn deinit(self: *UvSendTable, gpa: std.mem.Allocator) void {
        var it = self.map.keyIterator();
        while (it.next()) |key| gpa.free(key.*);
        self.map.deinit(gpa);
        self.* = undefined;
    }

    /// §8.5.1 L1774-1775 — apply one status's send-table effect for NAME.
    pub fn apply(self: *UvSendTable, gpa: std.mem.Allocator, name: []const u8, op: SendTableOp) error{OutOfMemory}!void {
        std.debug.assert(name.len <= max_line_len - 1);
        switch (op) {
            .remove => if (self.map.fetchRemove(name)) |kv| gpa.free(kv.key),
            .mtime_only => try self.put(gpa, name, true),
            .keep_full => try self.put(gpa, name, false),
        }
        std.debug.assert(self.contains(name) == (op != .remove));
    }

    fn put(self: *UvSendTable, gpa: std.mem.Allocator, name: []const u8, mtime_only: bool) error{OutOfMemory}!void {
        if (self.map.getPtr(name)) |v| {
            v.* = mtime_only;
            return;
        }
        try self.map.ensureUnusedCapacity(gpa, 1);
        const owned = try gpa.dupe(u8, name);
        self.map.putAssumeCapacity(owned, mtime_only);
    }

    pub fn contains(self: *const UvSendTable, name: []const u8) bool {
        return self.map.contains(name);
    }

    pub fn mtimeOnly(self: *const UvSendTable, name: []const u8) ?bool {
        return self.map.get(name);
    }

    pub fn isEmpty(self: *const UvSendTable) bool {
        return self.map.count() == 0;
    }

    pub fn count(self: *const UvSendTable) usize {
        return self.map.count();
    }
};

test "sync §8.5.1: uv-hash datetime segment is SQLite datetime(mtime,'unixepoch')" {
    var buf1: [19]u8 = undefined;
    try std.testing.expectEqualStrings("1970-01-01 00:00:00", formatUvDatetime(0, &buf1));
    var buf2: [19]u8 = undefined;
    try std.testing.expectEqualStrings("2009-02-13 23:31:30", formatUvDatetime(1234567890, &buf2));
}

test "sync §8.5.1: status 0 fetches (uvgimme + drop replaced) unless HASH is -" {
    try std.testing.expectEqual(UvLocalAction.fetch, uvLocalAction(.absent, false, false));
    try std.testing.expectEqual(UvLocalAction.none, uvLocalAction(.absent, true, false));
}

test "sync §8.5.1: status 1 fetches when HASH present, records deletion when -" {
    try std.testing.expectEqual(UvLocalAction.fetch, uvLocalAction(.remote_wins, false, false));
    try std.testing.expectEqual(UvLocalAction.record_deletion, uvLocalAction(.remote_wins, true, false));
}

test "sync §8.5.1: status 2 updates local mtime only" {
    try std.testing.expectEqual(UvLocalAction.touch_mtime, uvLocalAction(.older, false, false));
    try std.testing.expectEqual(UvLocalAction.touch_mtime, uvLocalAction(.older, true, false));
}

test "sync §8.5.1: status 3 makes no local change" {
    try std.testing.expectEqual(UvLocalAction.none, uvLocalAction(.identical, false, false));
    try std.testing.expectEqual(UvLocalAction.none, uvLocalAction(.identical, true, false));
}

test "sync §8.5.1: status 4 keeps send-table entry mtimeOnly true when uploads allowed" {
    try std.testing.expectEqual(SendTableOp.mtime_only, sendTableOp(.newer, false));
}

test "sync §8.5.1: status 5 keeps/reinserts send-table entry mtimeOnly false when uploads allowed" {
    try std.testing.expectEqual(SendTableOp.keep_full, sendTableOp(.local_wins, false));
}

test "sync §8.5.1: send-table update — statuses 0-3 remove, 4 mtimeOnly, 5 keeps full" {
    const Case = struct { status: UvStatus, want: SendTableOp };
    const cases = [_]Case{
        .{ .status = .absent, .want = .remove },
        .{ .status = .remote_wins, .want = .remove },
        .{ .status = .older, .want = .remove },
        .{ .status = .identical, .want = .remove },
        .{ .status = .newer, .want = .mtime_only },
        .{ .status = .local_wins, .want = .keep_full },
    };
    for (cases) |c| try std.testing.expectEqual(c.want, sendTableOp(c.status, false));
}

test "sync §8.5.1: pull-only removes statuses 4 and 5 from the send table" {
    try std.testing.expectEqual(SendTableOp.remove, sendTableOp(.newer, true));
    try std.testing.expectEqual(SendTableOp.remove, sendTableOp(.local_wins, true));
}

/// §8.5.1-uv1 — the receiver's answer to a uvgimme: uvfile carrying
/// content when it fits the send-size budget, or uvigot re-announcing
/// the same state when it does not. The budget check itself (§4 body
/// cap) lives in the response assembler; this takes its verdict.
pub const UvGimmeReply = enum { uvfile, uvigot };

/// §8.5.1-uv1, §7.3 — "The receiver answers with uvfile ... or with
/// uvigot ... again if the send-size limit prevents including the
/// content in that response."
pub fn uvGimmeReply(content_fits: bool) UvGimmeReply {
    return if (content_fits) .uvfile else .uvigot;
}

// ---------------------------------------------------------------------------
// Section 8.6.1 — Derived Branch, Tag, Leaf, Closed, and Current-Trunk Views
// ---------------------------------------------------------------------------
//
// §9.2.1 defines no wire bytes: it delegates branch/tag/leaf/closed/trunk
// views entirely to the artifact-format tag vocabulary and repostate's
// derivation, and states the readiness precondition under which a receiver
// may trust those views. Every mechanism it names already exists elsewhere
// (Operator's catalog, repostate's crosslink/resolveName/currentTrunk); this
// section is covered as cited tests over that existing structure, not new
// production code.

// ---------------------------------------------------------------------------
// Section 9.3-9.5 — Round-loop cluster (binding over §3.3 / §3.7)
// ---------------------------------------------------------------------------
//
// §9.3-9.5 restate no algorithm: they import the §3.3 phantom/igot/gimme
// surface (self-declared authoritative, L599-604) and the §3.7 termination
// rule. These tests bind that import over the already-committed predicates;
// the only production addition is `PendingDeltaQueue.hasTarget`, the OPTION B
// content-received derivation for the §3.3 dangling-delta path.

// The default filter set: nothing excludes the phantom.
// ---------------------------------------------------------------------------
// Section 3.2 — Reply consumption (Client.consumeReply) fuzz corpus. The
// Appendix A worked-exchange tests over consumeReply live in sync_test.zig;
// the round-1 vector stays here only as raw bytes for the corruption fuzzer.
// ---------------------------------------------------------------------------

// §A.1 L2525-2530 — Round 1 reply, 367-byte body: server-version pragma, two
// gimme cards, two igot cards, closing timestamp. The vector holds raw wire
// bytes; the citation lives here, never inside the .txt.
const a1_round1_reply = rfc_vectors.sync_a1_round1_reply;

comptime {
    // The RFC states this body size directly (L3074); a transcription drift
    // trips here before any test runs.
    std.debug.assert(a1_round1_reply.len == 367);
}

// Emit one random card-shaped or garbage line into `out`; returns bytes
// written (0 when there is no room). Menu spans every wire-parsing branch the
// dispatcher gained: gimme/igot/clone_seqno/pragma/timestamp/message/file/
// private plus raw garbage.
fn fuzzWriteLine(rand: std.Random, out: []u8) usize {
    if (out.len < 96) return 0;
    var hash: [64]u8 = undefined;
    for (&hash) |*c| c.* = "0123456789abcdef"[rand.intRangeLessThan(u8, 0, 16)];
    const h = hash[0..(if (rand.boolean()) @as(usize, 40) else 64)];
    const line: []u8 = switch (rand.intRangeLessThan(u8, 0, 11)) {
        0 => std.fmt.bufPrint(out, "gimme {s}\n", .{h}) catch return 0,
        1 => std.fmt.bufPrint(out, "igot {s}\n", .{h}) catch return 0,
        2 => std.fmt.bufPrint(out, "igot {s} 1\n", .{h}) catch return 0,
        3 => std.fmt.bufPrint(out, "clone_seqno {d}\n", .{rand.int(i32)}) catch return 0,
        4 => std.fmt.bufPrint(out, "pragma server-version {d} 20260311 113146\n", .{rand.int(u16)}) catch return 0,
        5 => std.fmt.bufPrint(out, "# timestamp 2026-07-02T18:31:13 errors {d}\n", .{rand.int(u8)}) catch return 0,
        6 => std.fmt.bufPrint(out, "message {s}\n", .{h[0..8]}) catch return 0,
        // §7.5 — the private marker arm: bare (arity 1, valid) and argument-
        // carrying (arity >1, a §5.2 fatal abort). Both must be tolerated: a
        // declared error or a valid observation, never a trap.
        8 => std.fmt.bufPrint(out, "private\n", .{}) catch return 0,
        9 => std.fmt.bufPrint(out, "private {s}\n", .{h[0..rand.intRangeAtMost(usize, 1, 8)]}) catch return 0,
        7 => blk: {
            const glen = rand.intRangeAtMost(usize, 0, 16);
            for (out[0..glen]) |*c| c.* = rand.int(u8);
            out[glen] = '\n';
            break :blk out[0 .. glen + 1];
        },
        else => blk: {
            const size = rand.intRangeAtMost(u32, 0, 8);
            const head = std.fmt.bufPrint(out, "file {s} {d}\n", .{ h, size }) catch return 0;
            var w = head.len;
            var k: u32 = 0;
            while (k < size and w < out.len) : (k += 1) {
                out[w] = rand.int(u8);
                w += 1;
            }
            break :blk out[0..w];
        },
    };
    return line.len;
}

fn fuzzReplyBody(rand: std.Random, buf: []u8) []const u8 {
    // Half the runs corrupt a copy of the round-1 reply (payload-independent
    // bit flips); the rest build a random card/garbage sequence.
    if (rand.boolean()) {
        const n = @min(buf.len, a1_round1_reply.len);
        @memcpy(buf[0..n], a1_round1_reply[0..n]);
        var flips = rand.intRangeAtMost(usize, 0, 6);
        while (flips > 0) : (flips -= 1) buf[rand.intRangeLessThan(usize, 0, n)] ^= rand.int(u8);
        return buf[0..n];
    }
    var len: usize = 0;
    var lines = rand.intRangeAtMost(usize, 0, 12);
    while (lines > 0 and len < buf.len) : (lines -= 1) len += fuzzWriteLine(rand, buf[len..]);
    return buf[0..len];
}

test "sync §3.2 fuzz: consumeReply tolerates adversarial reply bytes" {
    // The dispatcher must return only a valid RoundObservation or a declared
    // ConsumeError — never trap — on any wire input, and the reported
    // phantom_table_nonempty must always match the final phantom set (§3.7(a)).
    var prng = std.Random.DefaultPrng.init(0x5CA1_AB1E_D00D_1E5E);
    const rand = prng.random();
    var buf: [1024]u8 = undefined;
    var iter: usize = 0;
    const iterations = 4000;
    while (iter < iterations) : (iter += 1) {
        const body = fuzzReplyBody(rand, &buf);
        var ms = repostate.MemStore.init(std.testing.allocator);
        defer ms.deinit();
        var client = Client{ .operation = .sync, .state = .exchange };
        defer client.deinit(std.testing.allocator);
        const obs = client.consumeReply(std.testing.allocator, ms.store(), body) catch |e| switch (e) {
            error.OutOfMemory, error.FatalProtocolError => continue,
        };
        try std.testing.expectEqual(client.phantoms.count() > 0, obs.phantom_table_nonempty);
        // §7.5 L1381-1387 — private content is only ever bound by a content card, so a
        // delivered private marker implies versioned content this round.
        if (obs.private_content_delivered) try std.testing.expect(obs.versioned_content_delivered);
    }
}

// ---------------------------------------------------------------------------
// Tests — draft-fossil-submanifests-00 §7.2 / §8.1.
// ---------------------------------------------------------------------------

test "submanifests §7.2: avoid-submanifests is a server-to-client arg-less pragma" {
    // §7.2: "A server whose enable-submanifests setting (Section 8.1) is false
    // sends the pragma avoid-submanifests, in the same reply positions as
    // avoid-delta-manifests" — so it joins the catalog with the same direction
    // and the same (arg-less) shape as the pragma it is named after.
    const name = parsePragmaName("avoid-submanifests").?;
    try std.testing.expectEqual(PragmaDirection.server_to_client, pragmaDirection(name));
    try std.testing.expectEqual(pragmaDirection(.@"avoid-delta-manifests"), pragmaDirection(name));
    try std.testing.expect(pragmaIsArgless(name));
    var buf: [64]u8 = undefined;
    try std.testing.expectEqualStrings("pragma avoid-submanifests", try writeFlagPragma(name, &buf));
}

test "submanifests §7.2: a server whose §8.1 setting is false sends avoid-submanifests, and one whose setting is true does not" {
    // The emission half of §7.2's sentence — the receipt half is pinned above
    // and below. The condition is the SETTING, so this walks both states over
    // both reply shapes: the ordinary §3.2 reply and the §8.2 clone reply, which
    // is a separate emitter and would otherwise be able to drift.
    const gpa = std.testing.allocator;
    var ms = repostate.MemStore.init(gpa);
    defer ms.deinit();
    _ = try ms.store().put(repeat("a", 64), "content\n");

    const requests = [_][]const u8{
        "pragma client-version 22700\ngimme " ++ repeat("a", 64) ++ "\n", // §3.2 reply
        "pragma client-version 22700\nclone 3 1\n", // §8.2 clone reply
    };
    for (requests) |req| {
        for ([_]bool{ false, true }) |enabled| {
            const server = Server{
                .version = .{ .version = 22800, .datetime = .{ .date = 20260715, .time = 0 } },
                .self_servercode = "1111111111111111",
                .project_code = "2222222222222222",
                .submanifests_enabled = enabled,
            };
            var out: std.ArrayListUnmanaged(u8) = .empty;
            defer out.deinit(gpa);
            const reply = try server.respond(gpa, ms.store(), req, &out);
            const present = std.mem.indexOf(u8, reply, "pragma avoid-submanifests\n") != null;
            try std.testing.expectEqual(!enabled, present);
            // "in the same reply positions as avoid-delta-manifests": the
            // leading pragma run. With an undated client-version there is no
            // server-version (§10.1 r12), so the pragma opens the reply.
            if (present) try std.testing.expect(std.mem.startsWith(u8, reply, "pragma avoid-submanifests\n"));
        }
    }
}

test "submanifests §7.2/§8.1: the emitted pragma keys on the setting alone, not on the armed disjunction" {
    // A repository that HOLDS trees but has emission off (§8.1: "Disabling the
    // setting ... cannot retire existing ones") must still tell a client not to
    // author new ones. Keying the pragma on `submanifestGateArmed` — the
    // disjunction the SEND gate reads — would silently stop advertising the
    // refusal for exactly those repositories.
    //
    // This is the ONE state in which `submanifestGateArmed` and
    // `advertisesAvoidSubmanifests` disagree, so it is what stands between the
    // two predicates and a plausible-looking "these say almost the same thing,
    // unify them" cleanup. The test fails in either direction: rewrite the
    // pragma to read the armed disjunction and the reply assertion goes; rewrite
    // armed to the setting alone and the arming assertion goes.
    const gpa = std.testing.allocator;
    var ms = repostate.MemStore.init(gpa);
    defer ms.deinit();
    _ = try ms.store().put(repeat("a", 64), "content\n");
    const server = Server{
        .version = .{ .version = 22800, .datetime = .{ .date = 20260715, .time = 0 } },
        .self_servercode = "1111111111111111",
        .project_code = "2222222222222222",
        .submanifests_enabled = false, // §8.1 setting off …
        .seen_submanifest = true, // … but the repository already holds trees
    };
    try std.testing.expect(server.submanifestGateArmed()); // the send gate IS armed
    try std.testing.expect(server.advertisesAvoidSubmanifests()); // … and the pragma still fires
    var out: std.ArrayListUnmanaged(u8) = .empty;
    defer out.deinit(gpa);
    const reply = try server.respond(gpa, ms.store(), "pragma client-version 22700\ngimme " ++ repeat("a", 64) ++ "\n", &out);
    try std.testing.expect(std.mem.indexOf(u8, reply, "pragma avoid-submanifests\n") != null);
}

test "submanifests §7.2: a client latches the pragma straight off a real server reply" {
    // End to end across the two halves: the server emits, the client's
    // `consumeReply` latches, and `clientMayEmitSCards` then refuses S cards for
    // the session even for a client whose own §8.1 setting is on.
    const gpa = std.testing.allocator;
    var ms = repostate.MemStore.init(gpa);
    defer ms.deinit();
    _ = try ms.store().put(repeat("a", 64), "content\n");
    const server = Server{
        .version = .{ .version = 22800, .datetime = .{ .date = 20260715, .time = 0 } },
        .self_servercode = "1111111111111111",
        .project_code = "2222222222222222",
        .submanifests_enabled = false,
    };
    var out: std.ArrayListUnmanaged(u8) = .empty;
    defer out.deinit(gpa);
    const reply = try server.respond(gpa, ms.store(), "pragma client-version 22700\ngimme " ++ repeat("a", 64) ++ "\n", &out);

    var client_ms = repostate.MemStore.init(gpa);
    defer client_ms.deinit();
    var client = Client{ .operation = .sync, .state = .exchange, .submanifests_enabled = true };
    defer client.deinit(gpa);
    try std.testing.expect(clientMayEmitSCards(client.submanifests_enabled, client.avoid_submanifests));
    _ = try client.consumeReply(gpa, client_ms.store(), reply);
    try std.testing.expect(client.avoid_submanifests);
    try std.testing.expect(!clientMayEmitSCards(client.submanifests_enabled, client.avoid_submanifests));
}

test "submanifests §7.2: a client that received avoid-submanifests emits no S card" {
    // §7.2: "a client receiving it MUST NOT emit S cards in commits created
    // during the session."
    try std.testing.expect(clientMayEmitSCards(true, false));
    try std.testing.expect(!clientMayEmitSCards(true, true));
    // §8.1: the setting is the outer gate — a client that never received the
    // pragma still emits nothing while the setting is off.
    try std.testing.expect(!clientMayEmitSCards(false, false));
    try std.testing.expect(!clientMayEmitSCards(false, true));
}

test "submanifests §7.2: a re-announced version does not re-offer what was withheld" {
    // The withheld set is what makes the exchange terminate: an artifact the
    // gate refuses is recorded so the next round does not offer it again. A
    // peer that RAISES its version earns a fresh answer, but a pre-S peer
    // re-announces the SAME version in every reply, and clearing on each
    // announcement re-offers what it has already refused -- the exchange then
    // never converges. The trigger is the change, not the announcement.
    var ms = repostate.MemStore.init(std.testing.allocator);
    defer ms.deinit();
    var client = Client{ .operation = .sync, .state = .exchange };
    defer client.deinit(std.testing.allocator);

    // Round 1: the peer announces. This is genuinely new (the version was
    // unknown), so it is not the case under test.
    _ = try client.consumeReply(std.testing.allocator, ms.store(), "pragma server-version 22800\n");

    // The gate then refuses an artifact to this peer and records it.
    client.state = .exchange;
    try client.submanifests_withheld.put(std.testing.allocator, 7, {});

    // Round 2: the SAME peer re-announces the SAME version, as fossil 2.28
    // does in every reply. The record must survive.
    _ = try client.consumeReply(std.testing.allocator, ms.store(), "pragma server-version 22800\n");
    try std.testing.expect(client.submanifests_withheld.contains(7));

    client.state = .exchange;
    _ = try client.consumeReply(std.testing.allocator, ms.store(), "pragma server-version 22800\n");
    try std.testing.expect(client.submanifests_withheld.contains(7)); // and again

    // A raise is a genuinely fresh answer, so the record is dropped.
    client.state = .exchange;
    _ = try client.consumeReply(std.testing.allocator, ms.store(), "pragma server-version 23000\n");
    try std.testing.expect(!client.submanifests_withheld.contains(7));
}

test "submanifests §7.2: consuming the pragma latches the session's no-S-card rule" {
    var ms = repostate.MemStore.init(std.testing.allocator);
    defer ms.deinit();
    var client = Client{ .operation = .sync, .state = .exchange };
    defer client.deinit(std.testing.allocator);
    try std.testing.expect(!client.avoid_submanifests);
    _ = try client.consumeReply(std.testing.allocator, ms.store(), "pragma avoid-submanifests\n");
    try std.testing.expect(client.avoid_submanifests);
    // Latched for the SESSION (§7.2: "during the session"), so a later round
    // that carries no pragma does not clear it.
    client.state = .exchange; // next round of the same session
    _ = try client.consumeReply(std.testing.allocator, ms.store(), "pragma server-version 22800\n");
    try std.testing.expect(client.avoid_submanifests);
}

test "submanifests §7.2: the peer-version gate refuses an S-bearing artifact to a pre-S peer" {
    // §7.2: "When the remote peer's announced version predates sub-manifest
    // support ... and a local artifact is of type SUBMANIFEST, or is a manifest
    // carrying an S card, the sender MUST refuse to transmit that artifact,
    // with an explicit error -- never by converting, downgrading, or silently
    // omitting it."
    try std.testing.expectEqual(GateDecision.refuse, submanifestPeerVersionGate(22800, true));
    try std.testing.expectEqual(GateDecision.refuse, submanifestPeerVersionGate(0, true));
    try std.testing.expectEqual(GateDecision.emit, submanifestPeerVersionGate(submanifest_min_peer_version, true));
    // Every artifact that carries no S card is unaffected at every version —
    // the gate can never touch pre-existing traffic.
    try std.testing.expectEqual(GateDecision.emit, submanifestPeerVersionGate(0, false));
    try std.testing.expectEqual(GateDecision.emit, submanifestPeerVersionGate(22800, false));
    // The threshold is strictly above the newest released Fossil, which has no
    // sub-manifest support at all.
    try std.testing.expect(submanifest_min_peer_version > 22800);
}

/// The two-artifact fixture the §7.2 send-gate tests share: one manifest
/// carrying an S card and one flat manifest that does not, so every assertion
/// below can say what the gate did AND what it left alone in the same reply.
const SGateFixture = struct {
    tree: []const u8 = "C t\nD 2026-07-27T12:00:00.000\nS a.txt " ++ (repeat("1", 64)) ++ "\nU u\n",
    flat: []const u8 = "C m\nD 2026-07-27T12:00:00.000\nF a.txt " ++ (repeat("2", 64)) ++ "\nU u\n",
    tree_name: [64]u8 = undefined,
    flat_name: [64]u8 = undefined,

    fn init(self: *SGateFixture, ms: *repostate.MemStore) !void {
        _ = artifact.artifactName(.sha3_256, self.tree, &self.tree_name);
        _ = artifact.artifactName(.sha3_256, self.flat, &self.flat_name);
        _ = try ms.store().put(&self.tree_name, self.tree);
        _ = try ms.store().put(&self.flat_name, self.flat);
    }

    /// A pull request announcing `peer_version` and gimme-ing both artifacts.
    fn request(self: *const SGateFixture, gpa: std.mem.Allocator, peer_version: u32) ![]u8 {
        return std.fmt.allocPrint(
            gpa,
            "pragma client-version {d}\npull 0000000000000000 2222222222222222\ngimme {s}\ngimme {s}\n",
            .{ peer_version, &self.tree_name, &self.flat_name },
        );
    }

    /// The §7.2 refusal AS IT APPEARS ON THE WIRE — an `error` card carries its
    /// text §3.4-escape-encoded, so a test matching the raw literal would pass
    /// against a card that never left. Built through the same writer the server
    /// uses, so the two cannot drift.
    fn refusalCard(out: []u8) []u8 {
        return writeSignalCard(.err, submanifest_peer_version_error_text, out);
    }
};

test "submanifests §7.2: the send path refuses an S-bearing artifact to a pre-S peer, with an explicit error" {
    // §7.2: "the sender MUST refuse to transmit that artifact, with an explicit
    // error -- never by converting, downgrading, or silently omitting it."
    // The gate is armed by §8.1: a repository that has opted into
    // sub-manifests is the only one that can hold one to refuse.
    const gpa = std.testing.allocator;
    var ms = repostate.MemStore.init(gpa);
    defer ms.deinit();
    var fx: SGateFixture = .{};
    try fx.init(&ms);

    const server = Server{
        .version = .{ .version = 22800 },
        .self_servercode = "1111111111111111",
        .project_code = "2222222222222222",
        .submanifests_enabled = true,
    };
    const req = try fx.request(gpa, 22700); // below submanifest_min_peer_version
    defer gpa.free(req);
    var out: std.ArrayListUnmanaged(u8) = .empty;
    defer out.deinit(gpa);
    const reply = try server.respond(gpa, ms.store(), req, &out);

    // Refused with an explicit error, and the artifact's bytes are NOT on the
    // wire — the draft rules out silent omission and refusal-without-error
    // alike, so both halves are asserted.
    var card_buf: [max_line_len]u8 = undefined;
    try std.testing.expect(std.mem.indexOf(u8, reply, SGateFixture.refusalCard(&card_buf)) != null);
    try std.testing.expect(std.mem.indexOf(u8, reply, fx.tree) == null);
    // The artifact carrying no S card is untouched at the same version: the
    // gate is incapable of reaching traffic that predates this draft.
    try std.testing.expect(std.mem.indexOf(u8, reply, fx.flat) != null);
}

test "submanifests §7.2: an opted-in peer at or above the threshold receives the S-bearing artifact" {
    const gpa = std.testing.allocator;
    var ms = repostate.MemStore.init(gpa);
    defer ms.deinit();
    var fx: SGateFixture = .{};
    try fx.init(&ms);

    const server = Server{
        .version = .{ .version = 22800 },
        .self_servercode = "1111111111111111",
        .project_code = "2222222222222222",
        .submanifests_enabled = true,
    };
    // A peer that announces sub-manifest support — which, per §7.2, is what any
    // peer that READS trees announces (`announcedVersion`).
    const req = try fx.request(gpa, submanifest_min_peer_version);
    defer gpa.free(req);
    var out: std.ArrayListUnmanaged(u8) = .empty;
    defer out.deinit(gpa);
    const reply = try server.respond(gpa, ms.store(), req, &out);
    var card_buf: [max_line_len]u8 = undefined;
    try std.testing.expect(std.mem.indexOf(u8, reply, SGateFixture.refusalCard(&card_buf)) == null);
    try std.testing.expect(std.mem.indexOf(u8, reply, fx.tree) != null);
    try std.testing.expect(std.mem.indexOf(u8, reply, fx.flat) != null);
}

test "submanifests §7.2/§8.1: an UNARMED repository's send path is byte-identical to the pre-draft one" {
    // The compatibility contract as a test. A repository with neither §8.1
    // disjunct — no emission setting, no seen-submanifest indicator, which is
    // every repository predating this draft — sends exactly what it sent before
    // this draft existed, to any peer.
    //
    // The S-bearing artifact in the store here is a FIXTURE, not a reachable
    // state: `persistStore` sets the indicator for anything §7.2 gates, so a
    // real repository holding this artifact is armed. What the test pins is that
    // the gate is skipped — not merely inert — when neither disjunct holds, so
    // an untouched repository pays nothing and behaves identically.
    const gpa = std.testing.allocator;
    var ms = repostate.MemStore.init(gpa);
    defer ms.deinit();
    var fx: SGateFixture = .{};
    try fx.init(&ms);

    const gated = Server{
        .version = .{ .version = 22800 },
        .self_servercode = "1111111111111111",
        .project_code = "2222222222222222",
        .submanifests_enabled = false, // the default, stated
    };
    const req = try fx.request(gpa, 22700);
    defer gpa.free(req);
    var out: std.ArrayListUnmanaged(u8) = .empty;
    defer out.deinit(gpa);
    const reply = try gated.respond(gpa, ms.store(), req, &out);
    var card_buf: [max_line_len]u8 = undefined;
    try std.testing.expect(std.mem.indexOf(u8, reply, SGateFixture.refusalCard(&card_buf)) == null);
    try std.testing.expect(std.mem.indexOf(u8, reply, fx.tree) != null);
    try std.testing.expect(std.mem.indexOf(u8, reply, fx.flat) != null);
}

test "submanifests §7.2: the upload path withholds an S-bearing artifact from a pre-S peer and says so" {
    const gpa = std.testing.allocator;
    var ms = repostate.MemStore.init(gpa);
    defer ms.deinit();
    var fx: SGateFixture = .{};
    try fx.init(&ms);

    var client = Client{
        .operation = .push,
        .state = .exchange,
        .self_servercode = "0000000000000000",
        .project_code = "2222222222222222",
        .version = .{ .version = 22700 },
        .submanifests_enabled = true,
        .peer = .{ .version = 22800 }, // a peer below the threshold
    };
    defer client.deinit(gpa);
    // Queue both artifacts for upload, exactly as the §3.3 send gate would.
    try client.send_queue.put(gpa, ms.store().lookup(&fx.tree_name).?, {});
    try client.send_queue.put(gpa, ms.store().lookup(&fx.flat_name).?, {});

    var out: std.ArrayListUnmanaged(u8) = .empty;
    defer out.deinit(gpa);
    const req = try client.buildRequest(gpa, ms.store(), &out);

    // §7.2 — the S-bearing artifact is not on the wire, the other one is, and
    // the refusal is COUNTED so the CLI can report it: never silently omitted.
    try std.testing.expect(std.mem.indexOf(u8, req, fx.tree) == null);
    try std.testing.expect(std.mem.indexOf(u8, req, fx.flat) != null);
    try std.testing.expectEqual(@as(usize, 1), client.submanifests_refused);
    try std.testing.expectEqual(@as(usize, 1), client.content_sent);
}

test "submanifests §7.2: an armed client announces the threshold and uploads to a peer at or above it" {
    const gpa = std.testing.allocator;
    var ms = repostate.MemStore.init(gpa);
    defer ms.deinit();
    var fx: SGateFixture = .{};
    try fx.init(&ms);

    var client = Client{
        .operation = .push,
        .state = .exchange,
        .self_servercode = "0000000000000000",
        .project_code = "2222222222222222",
        .version = .{ .version = 22700 },
        .submanifests_enabled = true,
        .peer = .{ .version = submanifest_min_peer_version },
    };
    defer client.deinit(gpa);
    try client.send_queue.put(gpa, ms.store().lookup(&fx.tree_name).?, {});

    var out: std.ArrayListUnmanaged(u8) = .empty;
    defer out.deinit(gpa);
    const req = try client.buildRequest(gpa, ms.store(), &out);
    try std.testing.expect(std.mem.indexOf(u8, req, fx.tree) != null);
    try std.testing.expectEqual(@as(usize, 0), client.submanifests_refused);
    // §7.2 — the announcement is what stops a conforming peer refusing this
    // side every tree, and it is `announcedVersion`'s raise, not the client's
    // own release integer.
    var vbuf: [64]u8 = undefined;
    const expect_pragma = try writeClientVersion(.{ .version = submanifest_min_peer_version }, &vbuf);
    try std.testing.expect(std.mem.startsWith(u8, req, expect_pragma));
}

test "submanifests §7.2: a server announces the threshold because it READS trees, not because it emits them" {
    // The SERVER half of the announcement, which the client half above depends
    // on: §7.2's gate reads "the remote peer's announced version", so a server
    // that does NOT raise its own announcement is refused every tree by a
    // conforming client — the trees vanish from the push and the server never
    // learns why. `announcedVersionInfo` is what prevents that, and it is only
    // ever observable through the emitted pragma, so the assertion is over the
    // reply bytes rather than over the function.
    //
    // The announcement does NOT depend on §8.1's emission setting. It cannot:
    // a fresh clone target has neither the setting nor the seen-submanifest
    // indicator, so an announcement keyed on either one would make a tree
    // unable to reach a new repository at all, while §8.2 plainly contemplates
    // one being "cloned to" a fixture. §8 keeps emission and transfer as
    // separate layers; so does this.
    const gpa = std.testing.allocator;
    var ms = repostate.MemStore.init(gpa);
    defer ms.deinit();

    // §10.1 r11/r12 — the server emits server-version exactly when the received
    // client-version carried DATE and TIME, so the request carries both.
    const req = "pragma client-version 22700 20260727 120000\npull 0000000000000000 2222222222222222\n";
    const dt: VersionInfo = .{ .version = 22800, .datetime = .{ .date = 20260727, .time = 120000 } };

    // Built through the same writer the server uses, so the expectation and the
    // emission cannot drift.
    var vbuf: [64]u8 = undefined;
    const raised = try writeServerVersion(
        .{ .version = submanifest_min_peer_version, .datetime = dt.datetime },
        &vbuf,
    );
    var vbuf2: [64]u8 = undefined;
    const base = try writeServerVersion(dt, &vbuf2);

    // Both arming states announce the SAME raised version — the announcement is
    // a statement about the reader, and the reader is the same either way.
    for ([_]bool{ true, false }) |enabled| {
        const server = Server{
            .version = dt,
            .self_servercode = "1111111111111111",
            .project_code = "2222222222222222",
            .submanifests_enabled = enabled,
        };
        var out: std.ArrayListUnmanaged(u8) = .empty;
        defer out.deinit(gpa);
        const reply = try server.respond(gpa, ms.store(), req, &out);
        try std.testing.expect(std.mem.indexOf(u8, reply, raised) != null);
        try std.testing.expect(std.mem.indexOf(u8, reply, base) == null);
    }
}

test "submanifests §8.1/§7.2: the §8.1 indicator arms the upload gate on a repository whose setting is OFF" {
    // THE LEAK THIS PINS. §8.1: "Disabling the setting stops the emission of new
    // tree manifests; it cannot retire existing ones." A repository can
    // therefore HOLD trees with `enable-submanifests` false — which is the state
    // of any zit whose operator ran `zit submanifest disable` after committing,
    // and of every zit that received a tree by sync without ever enabling it.
    // A gate armed on the setting alone is `.emit` for all of them, and every
    // such repository uploads its trees ungated to a pre-S peer: §8's transfer
    // layer collapsed into its emission layer. Arming on the durable
    // seen-submanifest indicator as well is what closes it.
    //
    // Remove `or self.seen_submanifest` from `Client.submanifestGateArmed` and
    // the first half of this test fails: the tree lands in the request body.
    const gpa = std.testing.allocator;
    var ms = repostate.MemStore.init(gpa);
    defer ms.deinit();
    var fx: SGateFixture = .{};
    try fx.init(&ms);

    var armed = Client{
        .operation = .push,
        .state = .exchange,
        .self_servercode = "0000000000000000",
        .project_code = "2222222222222222",
        .version = .{ .version = 22700 },
        .peer = .{ .version = 22800 }, // a pre-S peer
        .submanifests_enabled = false, // §8.1's default, stated
        .seen_submanifest = true, // …but this repository already holds a tree
    };
    defer armed.deinit(gpa);
    try armed.send_queue.put(gpa, ms.store().lookup(&fx.tree_name).?, {});
    try armed.send_queue.put(gpa, ms.store().lookup(&fx.flat_name).?, {});

    var out: std.ArrayListUnmanaged(u8) = .empty;
    defer out.deinit(gpa);
    const req = try armed.buildRequest(gpa, ms.store(), &out);
    try std.testing.expect(std.mem.indexOf(u8, req, fx.tree) == null);
    try std.testing.expect(std.mem.indexOf(u8, req, fx.flat) != null);
    try std.testing.expectEqual(@as(usize, 1), armed.submanifests_refused);

    // The compatibility contract, stated where it now lives: a repository with
    // NEITHER disjunct — every repository predating this draft — withholds
    // nothing and never reaches the content predicate at all.
    var unarmed = Client{
        .operation = .push,
        .state = .exchange,
        .self_servercode = "0000000000000000",
        .project_code = "2222222222222222",
        .version = .{ .version = 22700 },
        .peer = .{ .version = 22800 },
    };
    defer unarmed.deinit(gpa);
    try std.testing.expect(!unarmed.submanifestGateArmed());
    try unarmed.send_queue.put(gpa, ms.store().lookup(&fx.tree_name).?, {});
    var out2: std.ArrayListUnmanaged(u8) = .empty;
    defer out2.deinit(gpa);
    const req2 = try unarmed.buildRequest(gpa, ms.store(), &out2);
    try std.testing.expect(std.mem.indexOf(u8, req2, fx.tree) != null);
    try std.testing.expectEqual(@as(usize, 0), unarmed.submanifests_refused);
}

test "submanifests §8.1/§7.2: the §8.1 indicator arms the reply and clone gates on a setting-OFF server" {
    // The server half of the same leak. Every zit `serve` today runs with
    // `enable-submanifests` false — there is no operator surface for it — so a
    // gate armed on the setting alone is permanently `.emit` and a repository
    // that HOLDS a tree serves it to fossil, which §8 says can only reject it as
    // structural and hold it as opaque content.
    //
    // Remove `or self.seen_submanifest` from `Server.submanifestGateArmed` and
    // both halves fail: the leaf lands in the reply and in the clone batch.
    const gpa = std.testing.allocator;
    var ms = repostate.MemStore.init(gpa);
    defer ms.deinit();
    var fx: SLeafFixture = .{};
    try fx.init(&ms);

    const server = Server{
        .version = .{ .version = 22800 },
        .self_servercode = "1111111111111111",
        .project_code = "2222222222222222",
        .submanifests_enabled = false, // §8.1's default, stated
        .seen_submanifest = true, // …but this repository already holds a leaf
    };
    var card_buf: [max_line_len]u8 = undefined;

    const req = try fx.request(gpa, 22800); // fossil 2.28: below the threshold
    defer gpa.free(req);
    var out: std.ArrayListUnmanaged(u8) = .empty;
    defer out.deinit(gpa);
    const reply = try server.respond(gpa, ms.store(), req, &out);
    try std.testing.expect(std.mem.indexOf(u8, reply, SGateFixture.refusalCard(&card_buf)) != null);
    try std.testing.expect(std.mem.indexOf(u8, reply, fx.leaf) == null);
    try std.testing.expect(std.mem.indexOf(u8, reply, fx.flat) != null);

    var scan: RequestScan = .{};
    defer scan.deinit(gpa);
    scan.client_version = .{ .version = 22800 };
    var out2: std.ArrayListUnmanaged(u8) = .empty;
    defer out2.deinit(gpa);
    var line_buf: [max_line_len]u8 = undefined;
    try server.emitCloneReply(gpa, ms.store(), &scan, &out2, &line_buf, 1);
    try std.testing.expect(std.mem.indexOf(u8, out2.items, SGateFixture.refusalCard(&card_buf)) != null);
    try std.testing.expect(std.mem.indexOf(u8, out2.items, &fx.leaf_name) == null);
    try std.testing.expect(std.mem.indexOf(u8, out2.items, &fx.flat_name) != null);

    // Neither disjunct — every repository predating this draft — serves both,
    // with no gate and therefore no inflate.
    const unarmed = Server{
        .version = .{ .version = 22800 },
        .self_servercode = "1111111111111111",
        .project_code = "2222222222222222",
    };
    try std.testing.expect(!unarmed.submanifestGateArmed());
    var out3: std.ArrayListUnmanaged(u8) = .empty;
    defer out3.deinit(gpa);
    const plain = try unarmed.respond(gpa, ms.store(), req, &out3);
    try std.testing.expect(std.mem.indexOf(u8, plain, SGateFixture.refusalCard(&card_buf)) == null);
    try std.testing.expect(std.mem.indexOf(u8, plain, fx.leaf) != null);
}

test "submanifests §7.2: a clone to a pre-S peer refuses the S-bearing artifact, and an ordinary clone never inflates" {
    const gpa = std.testing.allocator;
    var ms = repostate.MemStore.init(gpa);
    defer ms.deinit();
    var fx: SGateFixture = .{};
    try fx.init(&ms);

    const armed = Server{
        .version = .{ .version = 22800 },
        .self_servercode = "1111111111111111",
        .project_code = "2222222222222222",
        .submanifests_enabled = true,
    };
    var scan: RequestScan = .{};
    defer scan.deinit(gpa);
    scan.client_version = .{ .version = 22700 }; // pre-S peer
    var out: std.ArrayListUnmanaged(u8) = .empty;
    defer out.deinit(gpa);
    var line_buf: [max_line_len]u8 = undefined;
    try armed.emitCloneReply(gpa, ms.store(), &scan, &out, &line_buf, 1);
    // §7.2 — refused with the explicit error, and the batch the peer CAN read
    // still lands (the §11.1 r8 append disposition).
    var card_buf: [max_line_len]u8 = undefined;
    try std.testing.expect(std.mem.indexOf(u8, out.items, SGateFixture.refusalCard(&card_buf)) != null);
    try std.testing.expect(std.mem.indexOf(u8, out.items, &fx.tree_name) == null);
    try std.testing.expect(std.mem.indexOf(u8, out.items, &fx.flat_name) != null);

    // The default server — every unarmed repository — serves both,
    // with no gate and therefore no inflate: the O(wire) clone is untouched.
    const plain = Server{
        .version = .{ .version = 22800 },
        .self_servercode = "1111111111111111",
        .project_code = "2222222222222222",
    };
    var out2: std.ArrayListUnmanaged(u8) = .empty;
    defer out2.deinit(gpa);
    try plain.emitCloneReply(gpa, ms.store(), &scan, &out2, &line_buf, 1);
    try std.testing.expect(std.mem.indexOf(u8, out2.items, SGateFixture.refusalCard(&card_buf)) == null);
    try std.testing.expect(std.mem.indexOf(u8, out2.items, &fx.tree_name) != null);
    try std.testing.expect(std.mem.indexOf(u8, out2.items, &fx.flat_name) != null);
}

/// submanifests §7.2 SECOND SHAPE — a LEAF sub-manifest beside a flat manifest.
///
/// A leaf carries F cards and no S card at all (§4.4: a SUBMANIFEST carrying
/// both F and S is invalid), so it is precisely the artifact an S-card-only
/// gate ships to a peer that cannot read it. §7.2 gates it by TYPE: "a local
/// artifact is of type SUBMANIFEST, or is a manifest carrying an S card".
const SLeafFixture = struct {
    leaf: []const u8 = "F a.txt " ++ (repeat("3", 64)) ++ "\nF b.txt " ++ (repeat("4", 64)) ++ "\n",
    flat: []const u8 = "C m\nD 2026-07-27T12:00:00.000\nF a.txt " ++ (repeat("2", 64)) ++ "\nU u\n",
    leaf_name: [64]u8 = undefined,
    flat_name: [64]u8 = undefined,

    fn init(self: *SLeafFixture, ms: *repostate.MemStore) !void {
        _ = artifact.artifactName(.sha3_256, self.leaf, &self.leaf_name);
        _ = artifact.artifactName(.sha3_256, self.flat, &self.flat_name);
        _ = try ms.store().put(&self.leaf_name, self.leaf);
        _ = try ms.store().put(&self.flat_name, self.flat);
        // The premise the whole case rests on, pinned rather than assumed: the
        // leaf IS type SUBMANIFEST under §4.3 and carries NO S card, so a gate
        // keyed on S-bearingness alone is structurally blind to it. The flat
        // manifest carries a D card and is therefore never gated (§4.3).
        try std.testing.expect(!repostate.sBearingCandidate(self.leaf));
        try std.testing.expect(repostate.transferGatedArtifact(self.leaf));
        try std.testing.expect(!repostate.transferGatedArtifact(self.flat));
    }

    /// A pull request announcing `peer_version` and gimme-ing both artifacts.
    fn request(self: *const SLeafFixture, gpa: std.mem.Allocator, peer_version: u32) ![]u8 {
        return std.fmt.allocPrint(
            gpa,
            "pragma client-version {d}\npull 0000000000000000 2222222222222222\ngimme {s}\ngimme {s}\n",
            .{ peer_version, &self.leaf_name, &self.flat_name },
        );
    }
};

test "submanifests §7.2: the reply gate refuses a LEAF sub-manifest exactly where it refuses a tree manifest" {
    // The gap this pins: `sBearingCandidate` is FALSE for every leaf, so a gate
    // reading only it refuses the tree manifest and then ships the leaves that
    // carry the tree's actual file entries — §8's "unreadable ... held as opaque
    // content" delivered to a peer that asked for a check-in.
    const gpa = std.testing.allocator;
    var ms = repostate.MemStore.init(gpa);
    defer ms.deinit();
    var fx: SLeafFixture = .{};
    try fx.init(&ms);

    const server = Server{
        .version = .{ .version = 22800 },
        .self_servercode = "1111111111111111",
        .project_code = "2222222222222222",
        .submanifests_enabled = true,
    };
    const req = try fx.request(gpa, 22700); // below submanifest_min_peer_version
    defer gpa.free(req);
    var out: std.ArrayListUnmanaged(u8) = .empty;
    defer out.deinit(gpa);
    const reply = try server.respond(gpa, ms.store(), req, &out);

    var card_buf: [max_line_len]u8 = undefined;
    try std.testing.expect(std.mem.indexOf(u8, reply, SGateFixture.refusalCard(&card_buf)) != null);
    try std.testing.expect(std.mem.indexOf(u8, reply, fx.leaf) == null);
    // …and the artifact that predates this draft still lands, so the gate is
    // proven to be discriminating rather than merely refusing the round.
    try std.testing.expect(std.mem.indexOf(u8, reply, fx.flat) != null);

    // An opted-in peer at the threshold receives it: the gate is the peer
    // version, not a ban on leaves.
    const req_ok = try fx.request(gpa, submanifest_min_peer_version);
    defer gpa.free(req_ok);
    var out_ok: std.ArrayListUnmanaged(u8) = .empty;
    defer out_ok.deinit(gpa);
    const reply_ok = try server.respond(gpa, ms.store(), req_ok, &out_ok);
    try std.testing.expect(std.mem.indexOf(u8, reply_ok, SGateFixture.refusalCard(&card_buf)) == null);
    try std.testing.expect(std.mem.indexOf(u8, reply_ok, fx.leaf) != null);
}

test "submanifests §7.2: the upload path withholds a LEAF sub-manifest from a pre-S peer and counts it" {
    const gpa = std.testing.allocator;
    var ms = repostate.MemStore.init(gpa);
    defer ms.deinit();
    var fx: SLeafFixture = .{};
    try fx.init(&ms);

    var client = Client{
        .operation = .push,
        .state = .exchange,
        .self_servercode = "0000000000000000",
        .project_code = "2222222222222222",
        .version = .{ .version = 22700 },
        .submanifests_enabled = true,
        .peer = .{ .version = 22800 }, // a peer below the threshold
    };
    defer client.deinit(gpa);
    try client.send_queue.put(gpa, ms.store().lookup(&fx.leaf_name).?, {});
    try client.send_queue.put(gpa, ms.store().lookup(&fx.flat_name).?, {});

    var out: std.ArrayListUnmanaged(u8) = .empty;
    defer out.deinit(gpa);
    const req = try client.buildRequest(gpa, ms.store(), &out);

    try std.testing.expect(std.mem.indexOf(u8, req, fx.leaf) == null);
    try std.testing.expect(std.mem.indexOf(u8, req, fx.flat) != null);
    // §7.2 forbids SILENT omission; the count is what makes the refusal audible.
    try std.testing.expectEqual(@as(usize, 1), client.submanifests_refused);
    try std.testing.expectEqual(@as(usize, 1), client.content_sent);
}

test "submanifests §7.2: a clone to a pre-S peer refuses a LEAF sub-manifest too" {
    const gpa = std.testing.allocator;
    var ms = repostate.MemStore.init(gpa);
    defer ms.deinit();
    var fx: SLeafFixture = .{};
    try fx.init(&ms);

    const armed = Server{
        .version = .{ .version = 22800 },
        .self_servercode = "1111111111111111",
        .project_code = "2222222222222222",
        .submanifests_enabled = true,
    };
    var scan: RequestScan = .{};
    defer scan.deinit(gpa);
    scan.client_version = .{ .version = 22700 }; // pre-S peer
    var out: std.ArrayListUnmanaged(u8) = .empty;
    defer out.deinit(gpa);
    var line_buf: [max_line_len]u8 = undefined;
    try armed.emitCloneReply(gpa, ms.store(), &scan, &out, &line_buf, 1);

    var card_buf: [max_line_len]u8 = undefined;
    try std.testing.expect(std.mem.indexOf(u8, out.items, SGateFixture.refusalCard(&card_buf)) != null);
    try std.testing.expect(std.mem.indexOf(u8, out.items, &fx.leaf_name) == null);
    try std.testing.expect(std.mem.indexOf(u8, out.items, &fx.flat_name) != null);
}

test "submanifests §7.2/§4.3: the gate's content predicate is exactly the section's two shapes" {
    // Shape one: type SUBMANIFEST. A leaf (F run, no D card) and an interior
    // node (S run, no D card) are both SUBMANIFEST under §4.3's reassignment.
    try std.testing.expect(repostate.transferGatedArtifact("F a.txt " ++ (repeat("1", 64)) ++ "\n"));
    try std.testing.expect(repostate.transferGatedArtifact("S a.txt " ++ (repeat("1", 64)) ++ "\n"));
    // Shape two: a MANIFEST carrying an S card — it has a D card, so §4.3 does
    // NOT reassign it; only the S run puts it inside the gate.
    try std.testing.expect(repostate.transferGatedArtifact(
        "C t\nD 2026-07-27T12:00:00.000\nS a.txt " ++ (repeat("1", 64)) ++ "\nU u\n",
    ));

    // Everything that predates this draft is OUTSIDE the gate — the whole
    // backwards-compatibility contract, stated over the predicate itself.
    try std.testing.expect(!repostate.transferGatedArtifact(
        "C m\nD 2026-07-27T12:00:00.000\nF a.txt " ++ (repeat("2", 64)) ++ "\nU u\n",
    )); // a flat check-in manifest
    try std.testing.expect(!repostate.transferGatedArtifact(
        "C m\nD 2026-07-27T12:00:00.000\nB " ++ (repeat("2", 64)) ++ "\nF a.txt " ++ (repeat("3", 64)) ++ "\nU u\n",
    )); // a delta manifest
    try std.testing.expect(!repostate.transferGatedArtifact("M " ++ (repeat("2", 64)) ++ "\n")); // a cluster
    try std.testing.expect(!repostate.transferGatedArtifact("hello, world\n")); // a file blob
    try std.testing.expect(!repostate.transferGatedArtifact("no trailing newline")); // not a card run
    try std.testing.expect(!repostate.transferGatedArtifact("")); // no cards at all

    // A FILE BLOB whose CONTENT holds a line beginning `S `. §7.2's gated set is
    // "of type SUBMANIFEST, or a manifest carrying an S card"; a blob is
    // neither, whatever its prose spells. `sBearingCandidate` is true for it by
    // design — it is a pre-filter with a real parse behind it at the §7.1
    // phantomize sites — but there is no parse behind the transfer gate, so a
    // gate reading it directly REFUSES ordinary source, prose and diffs to every
    // peer below the threshold. The fixture is the absorb-side battery's own.
    try std.testing.expect(repostate.sBearingCandidate("some prose\nS is the twenty-second letter\n"));
    try std.testing.expect(!repostate.transferGatedArtifact("some prose\nS is the twenty-second letter\n"));
    // A MANIFEST whose S card does not parse (§3.6 wants `S filename hash`) is
    // outside shape two: shape two needs an S card that actually parses, not a
    // letter in column one. The artifact is invalid under §5.2 anyway, so a
    // conforming reader holds it as opaque content exactly as this does.
    try std.testing.expect(!repostate.transferGatedArtifact(
        "C t\nD 2026-07-27T12:00:00.000\nS a.txt not-a-hash\nU u\n",
    ));
    // Prose that STARTS with `S ` and continues into a line that is not a card
    // is still not a card run, so still not gated.
    try std.testing.expect(!repostate.transferGatedArtifact("S a.txt 0123\nnot a card\n"));
    try std.testing.expect(!repostate.transferGatedArtifact("F a.txt " ++ (repeat("1", 64)) ++ "\ntrailing junk\n"));

    // THE RESIDUAL, stated rather than hidden: a byte string that is a
    // COMPLETE, valid, D-less card run is type SUBMANIFEST under §4.3 no matter
    // what a human meant by it, so it is gated. §9.1 fixes the classification
    // ("MUST determine SUBMANIFEST through the reassignment rule of Section
    // 4.3"), and a pre-S reader rejects the same bytes as structural, so the
    // refusal costs that peer nothing it could have read.
    try std.testing.expect(repostate.transferGatedArtifact("S is the twenty-second letter\n"));
}

test "submanifests §8.1/§7.2: emission is off by default; the announcement turns on reader support" {
    // §8.1: "a project-scope boolean, default false". This is the layer that
    // keeps §8.2's provisional prohibition real — no zit mints an S-bearing
    // artifact unless an operator opts the repository in.
    try std.testing.expect(!submanifest_emission_default);
    try std.testing.expect(!clientMayEmitSCards(submanifest_emission_default, false));
    // The operator opt-in carries its consequence in the text itself.
    try std.testing.expect(submanifest_enable_warning.len != 0);

    // The ANNOUNCEMENT is a different layer and a different input: §10.1's
    // version says what the implementation can read, and zit reads trees.
    try std.testing.expect(submanifest_reader_support);
    try std.testing.expectEqual(submanifest_min_peer_version, announcedVersion(22700, submanifest_reader_support));
    // …which is exactly what lets two zit peers exchange a tree at all: the
    // gate reads the peer's announcement.
    try std.testing.expectEqual(
        GateDecision.emit,
        submanifestPeerVersionGate(announcedVersion(22700, submanifest_reader_support), true),
    );
    // A peer with no sub-manifest reader announces what it always did, and the
    // function never lowers an already-higher announcement.
    try std.testing.expectEqual(@as(u32, 22700), announcedVersion(22700, false));
    try std.testing.expectEqual(@as(u32, 99999), announcedVersion(99999, true));
}

test "submanifests §7.1: a PUSHED tree manifest phantomizes its sub-manifests and the same reply gimmes them" {
    // §7.1 is a duty of STORING and is side-agnostic: the server stores a
    // pushed artifact exactly as a client stores a received one. Without this,
    // a push leaves the server holding a check-in it can never enumerate and
    // asking nobody for the rest.
    const gpa = std.testing.allocator;
    const sub_a = repeat("1", 64);
    const sub_b = repeat("2", 64);
    const manifest = "C t\nD 2026-07-27T12:00:00.000\nS a.txt " ++ sub_a ++ "\nS m.txt " ++ sub_b ++ "\nU u\n";
    var name_buf: [64]u8 = undefined;
    const name = artifact.artifactName(.sha3_256, manifest, &name_buf);

    const request = try std.fmt.allocPrint(
        gpa,
        "pragma client-version 22700\npush 0000000000000000 2222222222222222\nfile {s} {d}\n{s}",
        .{ name, manifest.len, manifest },
    );
    defer gpa.free(request);

    var ms = repostate.MemStore.init(gpa);
    defer ms.deinit();
    const server = Server{
        .version = .{ .version = 22800 },
        .self_servercode = "1111111111111111",
        .project_code = "2222222222222222",
    };
    var out: std.ArrayListUnmanaged(u8) = .empty;
    defer out.deinit(gpa);
    const reply = try server.respond(gpa, ms.store(), request, &out);

    // §7.1 — a phantom for each missing reference, in the store the §3.2 walk
    // reads, so the reply asks the pusher for them.
    const rid_a = ms.store().lookup(sub_a) orelse return error.TestExpectedEqual;
    const rid_b = ms.store().lookup(sub_b) orelse return error.TestExpectedEqual;
    try std.testing.expect(!ms.store().held(rid_a));
    try std.testing.expect(!ms.store().held(rid_b));
    try std.testing.expect(std.mem.indexOf(u8, reply, "gimme " ++ sub_a ++ "\n") != null);
    try std.testing.expect(std.mem.indexOf(u8, reply, "gimme " ++ sub_b ++ "\n") != null);
}

test "submanifests §7.1: a pushed artifact predating this draft still ingests exactly as before" {
    // The backwards-compatibility half of the rule above: an artifact with no S
    // card mints nothing, so the reply is byte-identical to the pre-draft one.
    const gpa = std.testing.allocator;
    const flat = "C msg\nD 2026-07-27T12:00:00.000\nF a.txt " ++ (repeat("1", 64)) ++ "\nU u\n";
    var name_buf: [64]u8 = undefined;
    const name = artifact.artifactName(.sha3_256, flat, &name_buf);
    const request = try std.fmt.allocPrint(
        gpa,
        "pragma client-version 22700\npush 0000000000000000 2222222222222222\nfile {s} {d}\n{s}",
        .{ name, flat.len, flat },
    );
    defer gpa.free(request);

    var ms = repostate.MemStore.init(gpa);
    defer ms.deinit();
    const server = Server{
        .version = .{ .version = 22800 },
        .self_servercode = "1111111111111111",
        .project_code = "2222222222222222",
    };
    var out: std.ArrayListUnmanaged(u8) = .empty;
    defer out.deinit(gpa);
    const reply = try server.respond(gpa, ms.store(), request, &out);
    try std.testing.expectEqual(@as(repostate.Rid, 1), ms.store().count()); // only the pushed artifact
    try std.testing.expect(std.mem.indexOf(u8, reply, "gimme ") == null);
}

test "submanifests §7.1: storing an S-bearing manifest phantomizes every absent reference" {
    const gpa = std.testing.allocator;
    const sub_a = repeat("1", 64);
    const sub_b = repeat("2", 64);
    // A tree manifest: a C/D/S/S/U run. The Z card is irrelevant here — §7.1
    // fires on what the receiver STORES, and the §6.2 verdict is the read
    // path's (and the persist hook's) job, not the transfer layer's.
    const manifest = "C t\nD 2026-07-27T12:00:00.000\nS a.txt " ++ sub_a ++ "\nS m.txt " ++ sub_b ++ "\nU u\n";
    var name_buf: [64]u8 = undefined;
    const name = artifact.artifactName(.sha3_256, manifest, &name_buf);

    const body = try std.fmt.allocPrint(gpa, "file {s} {d}\n{s}", .{ name, manifest.len, manifest });
    defer gpa.free(body);

    var ms = repostate.MemStore.init(gpa);
    defer ms.deinit();
    var client = Client{ .operation = .pull, .state = .exchange };
    defer client.deinit(gpa);

    const obs = try client.consumeReply(gpa, ms.store(), body);
    // §7.1 — "MUST create a phantom for each missing reference".
    const rid_a = ms.store().lookup(sub_a) orelse return error.TestExpectedEqual;
    const rid_b = ms.store().lookup(sub_b) orelse return error.TestExpectedEqual;
    try std.testing.expect(!ms.store().held(rid_a));
    try std.testing.expect(!ms.store().held(rid_b));
    // They are gimme-eligible, which is what makes §7.1's "a tree of depth d
    // converges in at most d+1 pull rounds" actually happen.
    try std.testing.expect(client.phantoms.contains(rid_a));
    try std.testing.expect(client.phantoms.contains(rid_b));
    try std.testing.expect(obs.phantom_created);
}

test "submanifests §7.3/§9.4: an absent S reference defers its dependent, and the arrival traverses the record" {
    // §7.3: "record the gap keyed by dependent and missing hash, idempotently,
    // and retry on arrival." §9.4: "a cascade that walks only the two older edge
    // kinds leaves such a check-in permanently unprocessed."
    //
    // The record is keyed by DEPENDENT — that is the whole point of §9.4, and
    // the thing a phantom set (keyed by missing hash) cannot express: which
    // check-in the gap actually breaks.
    const gpa = std.testing.allocator;

    const leaf_1 = "F a.txt " ++ (repeat("1", 64)) ++ "\n";
    const leaf_2 = "F m.txt " ++ (repeat("2", 64)) ++ "\n";
    var n1: [64]u8 = undefined;
    var n2: [64]u8 = undefined;
    const name_1 = artifact.artifactName(.sha3_256, leaf_1, &n1);
    const name_2 = artifact.artifactName(.sha3_256, leaf_2, &n2);

    const tree = try std.fmt.allocPrint(
        gpa,
        "C t\nD 2026-07-27T12:00:00.000\nS a.txt {s}\nS m.txt {s}\nU u\n",
        .{ name_1, name_2 },
    );
    defer gpa.free(tree);
    var nt: [64]u8 = undefined;
    const tree_name = artifact.artifactName(.sha3_256, tree, &nt);

    var ms = repostate.MemStore.init(gpa);
    defer ms.deinit();
    var client = Client{ .operation = .pull, .state = .exchange };
    defer client.deinit(gpa);

    // ROUND 1 — the tree manifest arrives with NEITHER sub-manifest held.
    const body_t = try std.fmt.allocPrint(gpa, "file {s} {d}\n{s}", .{ tree_name, tree.len, tree });
    defer gpa.free(body_t);
    _ = try client.consumeReply(gpa, ms.store(), body_t);

    // One DEPENDENT deferred, on two distinct gaps: §7.3's key shape exactly.
    try std.testing.expectEqual(@as(usize, 1), client.deferredTrees());
    try std.testing.expectEqual(@as(usize, 2), client.tree_deferrals.?.gapCount());
    try std.testing.expect(client.tree_deferrals.?.isDeferred(tree_name));

    // ROUND 2 — one sub-manifest arrives. The dephantomization path traverses
    // the record: that gap retires, and the dependent STAYS deferred because
    // §5.2 forbids presenting a partially resolved tree as a file set.
    const body_1 = try std.fmt.allocPrint(gpa, "file {s} {d}\n{s}", .{ name_1, leaf_1.len, leaf_1 });
    defer gpa.free(body_1);
    _ = try client.consumeReply(gpa, ms.store(), body_1);
    try std.testing.expectEqual(@as(usize, 1), client.tree_deferrals.?.gapCount());
    try std.testing.expectEqual(@as(usize, 1), client.deferredTrees());
    try std.testing.expect(client.tree_deferrals.?.isDeferred(tree_name));

    // ROUND 3 — the last one arrives; the dependent leaves the deferred set.
    // Without the §9.4 traversal it would sit there forever: this is the
    // "permanently unprocessed" check-in the section names.
    const body_2 = try std.fmt.allocPrint(gpa, "file {s} {d}\n{s}", .{ name_2, leaf_2.len, leaf_2 });
    defer gpa.free(body_2);
    _ = try client.consumeReply(gpa, ms.store(), body_2);
    try std.testing.expectEqual(@as(usize, 0), client.tree_deferrals.?.gapCount());
    try std.testing.expectEqual(@as(usize, 0), client.deferredTrees());
    try std.testing.expect(!client.tree_deferrals.?.isDeferred(tree_name));
}

test "submanifests §7.3: a session that receives no S card records no deferral and allocates none" {
    // The backwards-compatibility half: the §9.4 record is opened lazily, so a
    // pull against a repository predating this draft never touches it and
    // `deferredTrees` answers 0 without a store lookup.
    const gpa = std.testing.allocator;
    const flat = "C t\nD 2026-07-27T12:00:00.000\nF a.txt " ++ (repeat("3", 64)) ++ "\nU u\n";
    var name_buf: [64]u8 = undefined;
    const name = artifact.artifactName(.sha3_256, flat, &name_buf);
    const body = try std.fmt.allocPrint(gpa, "file {s} {d}\n{s}", .{ name, flat.len, flat });
    defer gpa.free(body);

    var ms = repostate.MemStore.init(gpa);
    defer ms.deinit();
    var client = Client{ .operation = .pull, .state = .exchange };
    defer client.deinit(gpa);
    _ = try client.consumeReply(gpa, ms.store(), body);
    try std.testing.expect(client.tree_deferrals == null);
    try std.testing.expectEqual(@as(usize, 0), client.deferredTrees());
}

test "submanifests §7.3: a tree whose sub-manifests are already held defers nothing" {
    const gpa = std.testing.allocator;
    const leaf = "F a.txt " ++ (repeat("1", 64)) ++ "\n";
    var nl: [64]u8 = undefined;
    const leaf_name = artifact.artifactName(.sha3_256, leaf, &nl);
    const tree = try std.fmt.allocPrint(
        gpa,
        "C t\nD 2026-07-27T12:00:00.000\nS a.txt {s}\nU u\n",
        .{leaf_name},
    );
    defer gpa.free(tree);
    var nt: [64]u8 = undefined;
    const tree_name = artifact.artifactName(.sha3_256, tree, &nt);

    var ms = repostate.MemStore.init(gpa);
    defer ms.deinit();
    _ = try ms.store().put(leaf_name, leaf); // held BEFORE the tree arrives

    var client = Client{ .operation = .pull, .state = .exchange };
    defer client.deinit(gpa);
    const body = try std.fmt.allocPrint(gpa, "file {s} {d}\n{s}", .{ tree_name, tree.len, tree });
    defer gpa.free(body);
    _ = try client.consumeReply(gpa, ms.store(), body);

    // Nothing is missing, so nothing is deferred — the gap record is about
    // ABSENCE, not about carrying an S card.
    try std.testing.expectEqual(@as(usize, 0), client.deferredTrees());
}

test "submanifests §7.1: a flat manifest and a non-artifact blob phantomize nothing" {
    const gpa = std.testing.allocator;
    // Two negatives: an artifact with no S card at all, and a blob whose bytes
    // trip the pre-filter but do not tokenize as cards. Neither may mint a
    // phantom — the pre-filter is allowed false positives, the absorber is not.
    for ([_][]const u8{
        "C t\nD 2026-07-27T12:00:00.000\nF a.txt " ++ repeat("3", 64) ++ "\nU u\n",
        "some prose\nS is the twenty-second letter\n",
    }) |content| {
        var name_buf: [64]u8 = undefined;
        const name = artifact.artifactName(.sha3_256, content, &name_buf);
        const body = try std.fmt.allocPrint(gpa, "file {s} {d}\n{s}", .{ name, content.len, content });
        defer gpa.free(body);

        var ms = repostate.MemStore.init(gpa);
        defer ms.deinit();
        var client = Client{ .operation = .pull, .state = .exchange };
        defer client.deinit(gpa);
        _ = try client.consumeReply(gpa, ms.store(), body);
        // Exactly one entry: the artifact itself. No reference was invented.
        try std.testing.expectEqual(@as(usize, 1), ms.store().count());
        try std.testing.expectEqual(@as(usize, 0), client.phantoms.count());
    }
}

test "submanifests §7.1: a malformed S card voids the whole run before anything is applied" {
    const gpa = std.testing.allocator;
    // The first S card is well-formed, the second names a hash that fails the
    // §3.5 hash map. Validate-then-apply means NEITHER reference is minted —
    // an artifact that is not a legal tree manifest announces nothing.
    const manifest = "C t\nD 2026-07-27T12:00:00.000\nS a.txt " ++ repeat("1", 64) ++ "\nS m.txt nothex\nU u\n";
    var name_buf: [64]u8 = undefined;
    const name = artifact.artifactName(.sha3_256, manifest, &name_buf);
    const body = try std.fmt.allocPrint(gpa, "file {s} {d}\n{s}", .{ name, manifest.len, manifest });
    defer gpa.free(body);

    var ms = repostate.MemStore.init(gpa);
    defer ms.deinit();
    var client = Client{ .operation = .pull, .state = .exchange };
    defer client.deinit(gpa);
    _ = try client.consumeReply(gpa, ms.store(), body);
    try std.testing.expectEqual(@as(usize, 1), ms.store().count()); // the artifact itself, nothing else
    try std.testing.expectEqual(@as(usize, 0), client.phantoms.count());
}

test "the push gate and the commit gate agree that UTF-16 is not binary" {
    // The two used to spell "contains a NUL" separately, so a UTF-16 file zit
    // had just committed was refused by a zit server holding a binary-glob.
    const utf16 = "\xFF\xFEh\x00i\x00";
    const policy: PushPolicy = .{ .binary_glob = "*.dat", .crlf_glob = "*.dat" };
    try std.testing.expect(policy.violation("wide.txt", utf16) == null);
    // The classes it does gate still gate.
    try std.testing.expectEqualStrings("binary-glob", policy.violation("x.o", "a\x00b").?);
    try std.testing.expectEqualStrings("crlf-glob", policy.violation("x.txt", "a\r\nb").?);
    // A UTF-16 file whose units really are zero is binary in both places.
    try std.testing.expectEqualStrings("binary-glob", policy.violation("wide.txt", "\xFF\xFEh\x00\x00\x00").?);
}

test "the push gate stops at the binary class, as the commit gate does" {
    // The commit gate returns from its binary branch (fossil's
    // `commit_warning` ordering), so a NUL-carrying file the server's
    // binary-glob permits must not then be refused for its CR/LF pairs -- the
    // two gates would disagree about a file zit had just committed.
    const policy: PushPolicy = .{ .binary_glob = "*.dat", .crlf_glob = "*.txt" };
    try std.testing.expect(policy.violation("x.dat", "a\x00b\r\n") == null);
    try std.testing.expectEqualStrings("binary-glob", policy.violation("x.txt", "a\x00b\r\n").?);
}

test "emitReply's announcement suppression probes once per held rid, whatever the requester announced" {
    // The regression this pins: suppressing `igot` for names the requester
    // announced was a linear scan of the announced list PER HELD RID, so a
    // peer announcing its whole holdings made a no-op pull O(held × announced)
    // — 760 s of server CPU at 1.25M artifacts. The evidence counter is the
    // walk's probe count; it must equal the held count at two sizes and be
    // indifferent to how many names were announced — a scan would not change
    // the count, so correctness of the suppression is pinned alongside it.
    const gpa = std.testing.allocator;
    const req_head = "pragma client-version 22700 20260727 120000\npull 0000000000000000 2222222222222222\n";
    const extra_announced: usize = 3000; // names the server does not hold, announced anyway

    var probes: [2]u64 = undefined;
    for ([_]usize{ 200, 2000 }, 0..) |held, which| {
        var ms = repostate.MemStore.init(gpa);
        defer ms.deinit();
        // Real artifacts: unique content named by its true sha3-256, so a
        // reply that reconstructs any of them verifies. Names are captured so
        // the request announces exactly what the store holds.
        var names = try gpa.alloc([64]u8, held);
        defer gpa.free(names);
        var i: usize = 0;
        while (i < held) : (i += 1) {
            var body: [32]u8 = undefined;
            const content = std.fmt.bufPrint(&body, "held blob {d}", .{i}) catch unreachable;
            _ = artifact.artifactName(.sha3_256, content, &names[i]);
            _ = try ms.store().put(&names[i], content);
        }
        const server = Server{ .version = .{ .version = 22800, .datetime = .{ .date = 20260727, .time = 120000 } }, .self_servercode = "1111111111111111", .project_code = "2222222222222222" };

        // Round A: announce every held name plus thousands the server lacks.
        var req: std.ArrayListUnmanaged(u8) = .empty;
        defer req.deinit(gpa);
        try req.appendSlice(gpa, req_head);
        for (names) |*nm| {
            try req.appendSlice(gpa, "igot ");
            try req.appendSlice(gpa, nm);
            try req.append(gpa, '\n');
        }
        i = 0;
        while (i < extra_announced) : (i += 1) {
            var name: [64]u8 = undefined;
            _ = std.fmt.bufPrint(&name, "{x:0>64}", .{i}) catch unreachable; // names the server lacks
            try req.appendSlice(gpa, "igot ");
            try req.appendSlice(gpa, &name);
            try req.append(gpa, '\n');
        }
        var out: std.ArrayListUnmanaged(u8) = .empty;
        defer out.deinit(gpa);
        const before = emit_reply_probes;
        const reply = try server.respond(gpa, ms.store(), req.items, &out);
        probes[which] = emit_reply_probes - before;
        try std.testing.expectEqual(@as(u64, held), probes[which]);
        // Every held name was announced, so the reply advertises none of them.
        try std.testing.expectEqual(@as(usize, 0), std.mem.count(u8, reply, "\nigot "));

        // Round B: announce only the first half; the reply must advertise
        // exactly the unannounced half — the suppression still works.
        var req2: std.ArrayListUnmanaged(u8) = .empty;
        defer req2.deinit(gpa);
        try req2.appendSlice(gpa, req_head);
        for (names[0 .. held / 2]) |*nm| {
            try req2.appendSlice(gpa, "igot ");
            try req2.appendSlice(gpa, nm);
            try req2.append(gpa, '\n');
        }
        var out2: std.ArrayListUnmanaged(u8) = .empty;
        defer out2.deinit(gpa);
        const reply2 = try server.respond(gpa, ms.store(), req2.items, &out2);
        try std.testing.expectEqual(held - held / 2, std.mem.count(u8, reply2, "\nigot "));
    }
    // Ten times the holdings, ten times the probes — and 3000 extra announced
    // names at both sizes moved neither number.
    try std.testing.expectEqual(probes[0] * 10, probes[1]);
}

// ---------------------------------------------------------------------------
// Bounded announcement over the cluster index.
// ---------------------------------------------------------------------------

/// Test fixture: `n` real full-form blobs plus one cluster naming the first
/// `named` of them, all in `ms`; returns the cluster's name.
fn seedClusteredStore(gpa: std.mem.Allocator, ms: *repostate.MemStore, n: usize, named: usize, cluster_name: *[64]u8) !void {
    const s = ms.store();
    var body_buf: [32]u8 = undefined;
    var cluster_body: std.ArrayListUnmanaged(u8) = .empty;
    defer cluster_body.deinit(gpa);
    var i: usize = 0;
    while (i < n) : (i += 1) {
        const body = std.fmt.bufPrint(&body_buf, "clustered blob {d}", .{i}) catch unreachable;
        var nm: [64]u8 = undefined;
        _ = artifact.artifactName(.sha3_256, body, &nm);
        _ = try s.put(&nm, body);
        if (i < named) {
            try cluster_body.appendSlice(gpa, "M ");
            try cluster_body.appendSlice(gpa, &nm);
            try cluster_body.append(gpa, '\n');
        }
    }
    try cluster_body.appendSlice(gpa, "Z 00000000000000000000000000000000\n");
    _ = artifact.artifactName(.sha3_256, cluster_body.items, cluster_name);
    _ = try s.put(cluster_name, cluster_body.items);
}

const pull_head_184 = "pragma client-version 22700 20260727 120000\npull 0000000000000000 2222222222222222\n";

fn server184(r: *const clusterindex.Resolved) Server {
    return .{
        .version = .{ .version = 22800, .datetime = .{ .date = 20260727, .time = 120000 } },
        .self_servercode = "1111111111111111",
        .project_code = "2222222222222222",
        .clustered = &r.clustered,
        .cluster_names = r.cluster_names,
    };
}

test "a reply announces only what no held cluster names; send-catalog restores the full inventory" {
    const gpa = std.testing.allocator;
    var ms = repostate.MemStore.init(gpa);
    defer ms.deinit();
    var cn: [64]u8 = undefined;
    try seedClusteredStore(gpa, &ms, 6, 4, &cn);
    var r = try clusterindex.resolve(gpa, ms.store(), &.{&cn});
    defer r.deinit();
    const server = server184(&r);

    var out: std.ArrayListUnmanaged(u8) = .empty;
    defer out.deinit(gpa);
    const reply = try server.respond(gpa, ms.store(), pull_head_184, &out);
    // Two unnamed blobs plus the cluster itself (named by no cluster): three.
    try std.testing.expectEqual(@as(usize, 3), std.mem.count(u8, reply, "\nigot "));
    try std.testing.expect(std.mem.indexOf(u8, reply, &cn) != null);

    // §9 L2041-2045 — send-catalog: the resync state, every eligible holding.
    var out2: std.ArrayListUnmanaged(u8) = .empty;
    defer out2.deinit(gpa);
    const reply2 = try server.respond(gpa, ms.store(), pull_head_184 ++ "pragma send-catalog\n", &out2);
    try std.testing.expectEqual(@as(usize, 7), std.mem.count(u8, reply2, "\nigot "));
}

test "req-clusters encloses every held cluster's igot; nothing is emitted for an empty run" {
    const gpa = std.testing.allocator;
    var ms = repostate.MemStore.init(gpa);
    defer ms.deinit();
    var cn: [64]u8 = undefined;
    try seedClusteredStore(gpa, &ms, 3, 3, &cn);
    var r = try clusterindex.resolve(gpa, ms.store(), &.{&cn});
    defer r.deinit();
    const server = server184(&r);

    var out: std.ArrayListUnmanaged(u8) = .empty;
    defer out.deinit(gpa);
    const reply = try server.respond(gpa, ms.store(), pull_head_184 ++ "pragma req-clusters\n", &out);
    const want = try std.fmt.allocPrint(gpa, "# sending-clusters\nigot {s}\n# end-of-clusters\n", .{&cn});
    defer gpa.free(want);
    try std.testing.expect(std.mem.indexOf(u8, reply, want) != null);

    // No clusters held: the brackets are absent (§5.3 "emit neither").
    var empty = clusterindex.Resolved{
        .clustered = try std.DynamicBitSetUnmanaged.initEmpty(gpa, 4),
        .missing = &.{},
        .cluster_names = &.{},
        .arena = std.heap.ArenaAllocator.init(gpa),
        .gpa = gpa,
    };
    defer empty.deinit();
    const bare = server184(&empty);
    var out2: std.ArrayListUnmanaged(u8) = .empty;
    defer out2.deinit(gpa);
    const reply2 = try bare.respond(gpa, ms.store(), pull_head_184 ++ "pragma req-clusters\n", &out2);
    try std.testing.expect(std.mem.indexOf(u8, reply2, "sending-clusters") == null);
}

test "a PUSHED cluster's unheld members are interned and the same reply gimmes them" {
    const gpa = std.testing.allocator;
    const ghost = repeat("e", 64);
    const cluster = "M " ++ ghost ++ "\nZ 00000000000000000000000000000000\n";
    var name_buf: [64]u8 = undefined;
    const name = artifact.artifactName(.sha3_256, cluster, &name_buf);
    const request = try std.fmt.allocPrint(
        gpa,
        "pragma client-version 22700\npush 0000000000000000 2222222222222222\nfile {s} {d}\n{s}",
        .{ name, cluster.len, cluster },
    );
    defer gpa.free(request);
    var ms = repostate.MemStore.init(gpa);
    defer ms.deinit();
    const server = Server{
        .version = .{ .version = 22800 },
        .self_servercode = "1111111111111111",
        .project_code = "2222222222222222",
    };
    var out: std.ArrayListUnmanaged(u8) = .empty;
    defer out.deinit(gpa);
    const reply = try server.respond(gpa, ms.store(), request, &out);
    try std.testing.expect(std.mem.indexOf(u8, reply, "gimme " ++ ghost) != null);
    // A malformed cluster mints nothing.
    const bad = "M " ++ ghost ++ "\nD 2026-01-01T00:00:00\n";
    var bad_name: [64]u8 = undefined;
    const bn = artifact.artifactName(.sha3_256, bad, &bad_name);
    const req2 = try std.fmt.allocPrint(gpa, "pragma client-version 22700\npush 0000000000000000 2222222222222222\nfile {s} {d}\n{s}", .{ bn, bad.len, bad });
    defer gpa.free(req2);
    var ms2 = repostate.MemStore.init(gpa);
    defer ms2.deinit();
    var out2: std.ArrayListUnmanaged(u8) = .empty;
    defer out2.deinit(gpa);
    const reply2 = try server.respond(gpa, ms2.store(), req2, &out2);
    try std.testing.expect(std.mem.indexOf(u8, reply2, "gimme " ++ ghost) == null);
}

test "a client announces only unclustered holdings and seeds cluster phantoms as gimme" {
    const gpa = std.testing.allocator;
    var ms = repostate.MemStore.init(gpa);
    defer ms.deinit();
    var cn: [64]u8 = undefined;
    try seedClusteredStore(gpa, &ms, 5, 3, &cn);
    // A held cluster that also promises a name this store lacks.
    const ghost = repeat("e", 64);
    const promise = "M " ++ ghost ++ "\nZ 00000000000000000000000000000000\n";
    var pn: [64]u8 = undefined;
    _ = artifact.artifactName(.sha3_256, promise, &pn);
    _ = try ms.store().put(&pn, promise);
    var r = try clusterindex.resolve(gpa, ms.store(), &.{ &cn, &pn });
    defer r.deinit();
    try std.testing.expectEqual(@as(usize, 1), r.missing.len);

    var client = Client{ .operation = .pull, .state = .exchange, .clustered = &r.clustered };
    defer client.deinit(gpa);
    try client.seedClusterPhantoms(gpa, ms.store(), r.missing);
    var buf: std.ArrayListUnmanaged(u8) = .empty;
    defer buf.deinit(gpa);
    const req = try client.buildRequest(gpa, ms.store(), &buf);
    // Two unnamed blobs + two clusters announced; three named blobs are not.
    try std.testing.expectEqual(@as(usize, 4), std.mem.count(u8, req, "\nigot "));
    try std.testing.expect(std.mem.indexOf(u8, req, "gimme " ++ ghost) != null);
    // Unobtained, the seeded promise is a cluster gap — not an unmet expectation.
    try std.testing.expectEqual(@as(usize, 0), client.unresolvedPhantoms(ms.store()));
    try std.testing.expectEqual(@as(usize, 1), client.clusterGaps(ms.store()));
    // Once the peer names it, it is the peer's expectation to meet.
    _ = client.seeded.remove(ms.store().lookup(ghost).?);
    try std.testing.expectEqual(@as(usize, 1), client.unresolvedPhantoms(ms.store()));
    try std.testing.expectEqual(@as(usize, 0), client.clusterGaps(ms.store()));

    // A clone seeds nothing: it converges on the cfile cursor, never gimme.
    var clone = Client{ .operation = .clone, .state = .clone_round0, .clustered = &r.clustered };
    defer clone.deinit(gpa);
    try clone.seedClusterPhantoms(gpa, ms.store(), r.missing);
    try std.testing.expectEqual(@as(usize, 0), clone.phantoms.count());
}

test "announcements per reply do not grow with repository size once the clusters cover it" {
    // The pin. At two sizes an order of magnitude apart, a store whose
    // clusters name all but a constant tail announces a constant number of
    // artifacts: the unnamed tail plus the clusters themselves.
    const gpa = std.testing.allocator;
    var counts: [2]usize = undefined;
    for ([_]usize{ 200, 2000 }, 0..) |n, which| {
        var ms = repostate.MemStore.init(gpa);
        defer ms.deinit();
        var cn: [64]u8 = undefined;
        try seedClusteredStore(gpa, &ms, n, n - 3, &cn);
        var r = try clusterindex.resolve(gpa, ms.store(), &.{&cn});
        defer r.deinit();
        const server = server184(&r);
        var out: std.ArrayListUnmanaged(u8) = .empty;
        defer out.deinit(gpa);
        const reply = try server.respond(gpa, ms.store(), pull_head_184, &out);
        counts[which] = std.mem.count(u8, reply, "\nigot ");
    }
    try std.testing.expectEqual(@as(usize, 4), counts[0]);
    try std.testing.expectEqual(counts[0], counts[1]);
}

test "a pull reply mints clusters over the unclustered holdings; the next index makes them bounded" {
    const gpa = std.testing.allocator;
    var ms = repostate.MemStore.init(gpa);
    defer ms.deinit();
    var cn: [64]u8 = undefined;
    // 150 blobs, none clustered (the seed cluster names zero of them).
    try seedClusteredStore(gpa, &ms, 150, 0, &cn);
    var r = try clusterindex.resolve(gpa, ms.store(), &.{&cn});
    defer r.deinit();
    const server = server184(&r);
    const before = ms.store().count();
    var out: std.ArrayListUnmanaged(u8) = .empty;
    defer out.deinit(gpa);
    _ = try server.respond(gpa, ms.store(), pull_head_184, &out);
    // 151 loose (150 blobs + the empty seed cluster) → one cluster minted.
    try std.testing.expectEqual(before + 1, ms.store().count());
    const minted = ms.store().content(@intCast(before + 1)).?;
    try std.testing.expect(clusterindex.isCluster(minted));
    try std.testing.expectEqual(@as(usize, 151), std.mem.count(u8, minted, "\nM ") + 1);
    // A push mints nothing.
    var out_push: std.ArrayListUnmanaged(u8) = .empty;
    defer out_push.deinit(gpa);
    _ = try server.respond(gpa, ms.store(), "pragma client-version 22700 20260727 120000\npush 0000000000000000 2222222222222222\n", &out_push);
    try std.testing.expectEqual(before + 1, ms.store().count());
    // Re-resolved over the minted cluster, the next pull announces one igot
    // (the cluster) instead of 151, and mints nothing more.
    var mn: [64]u8 = undefined;
    _ = artifact.artifactName(.sha3_256, minted, &mn);
    var r2 = try clusterindex.resolve(gpa, ms.store(), &.{ &cn, &mn });
    defer r2.deinit();
    const server2 = server184(&r2);
    var out2: std.ArrayListUnmanaged(u8) = .empty;
    defer out2.deinit(gpa);
    const reply2 = try server2.respond(gpa, ms.store(), pull_head_184, &out2);
    try std.testing.expectEqual(@as(usize, 1), std.mem.count(u8, reply2, "\nigot "));
    try std.testing.expectEqual(before + 1, ms.store().count());
}