Skip to content
zit Artifact [09e89786fd]
Login

Artifact [09e89786fd]

Artifact 09e89786fd1c435d15910b5c196ecf0e3cb8dcd0b785b31545fac9c4730e8abf:


//! zit — the shared repo-view loader (a `zit` library module, re-exported from
//! root.zig and used internally by `Repository`).
//!
//! It bridges the persisted `LogStore` (bytes) to the derived `RepoState`
//! (branches, leaves, tags, current-trunk) that the query verbs — `leaves`
//! first, then `branch`/`tag`/`info` — read through the `Repository` facade.
//! One `load`, one `deinit`: a caller builds the whole crosslinked view in a
//! single call and asks its questions of `RepoState` directly, never
//! re-deriving.
//!
//! Determinism seam: this module touches no clock, RNG, filesystem, or network.
//! It operates on an already-open `LogStore` (whose I/O capability was injected
//! at construction), so the effectful file handling stays in the CLI layer.

const std = @import("std");
const artifact = @import("artifact.zig");
const repostate = @import("repostate.zig");
const repoimage = @import("repoimage.zig");
const logstore = @import("logstore.zig");
const repository = @import("repository.zig");

/// Lowercase-hex identity space. The `RepoState`/`MemStore` identity for a
/// check-in is its 40/64-char artifact name — the lowercase hex of the log's
/// binary digest. P-card parents and T-card targets are already that hex on the
/// wire, so encoding `LogStore.hashOf` to hex puts every reference in one space.
const hex_digits = "0123456789abcdef";

/// The RFC repo-state §6.1 default-branch fallback: `main-branch` is used only
/// when the config setting is unset. Never the branch of a check-in that
/// carries its own active branch tag.
const default_branch: []const u8 = "trunk";

/// Evidence seam: every artifact-parse attempt the frame walk makes
/// (`linkOne`'s `parseForImport`) increments this. The checkpoint tests reset
/// it and assert a resumed derivation performs ZERO parses for frames below
/// the resume offset — a counter is evidence where wall time is only
/// suggestion (and the Task-11 benchmark gates on the same number). The derive
/// is single-threaded, like everything else in this module.
pub var parsed_artifacts: u64 = 0;

/// A fully crosslinked view of a repository, ready for the derived queries of
/// repo-state §6-9. Pinned in place by the caller: `state` holds an
/// `ArtifactStore` that borrows `&self.store`, so the value must not be moved
/// after `load`.
pub const RepoView = struct {
    /// Identity space only: interned hashes (hex names), no content bytes. Leaf
    /// derivation reads no content, so the store stays a pure hash↔rid map.
    store: repostate.MemStore,
    /// The derived state every query reads: leaf table, branch tags, closed
    /// markers, event times. Borrows `&self.store`.
    state: repostate.RepoState,
    /// The live config frame, retained so `main_branch` can borrow its bytes.
    /// null when the log carries no config (repo-state §6.1: caller owns the
    /// default).
    owned_config: ?logstore.OwnedConfig,
    /// The repo-state §6.1 default-branch name: the config `main-branch` value
    /// when set and non-empty, else `"trunk"`. Threaded into `branchOf`; never a
    /// hardcoded literal in the presence of a setting.
    main_branch: []const u8,
    /// Set only on the cache-hit path (`buildFromRecordBody`), where there is no
    /// `owned_config` to borrow the name from: an owned copy of `main_branch`
    /// that `deinit` frees. Null on the normal load path, where `main_branch`
    /// borrows `owned_config` (or is the `"trunk"` literal).
    owned_main_branch: ?[]u8 = null,
    /// Set only on the zero-parse image path (`buildFromImage`): the owned cache
    /// buffer whose image section `state`'s columns view. Freed by `deinit`
    /// after `state` (which borrows into it) is torn down. Null on every derive
    /// path, where the state owns its own hashmaps.
    image_bytes: ?[]u8 = null,
    /// Set by the derive when it rejects an artifact; read only after a failed
    /// `load`/`open`. `Repository.open` clears it before every attempt, because
    /// a `Repository` is declared `undefined` and this field would otherwise
    /// carry whatever was on the stack.
    fault: ?Fault = null,

    /// Every failure `load` can surface: allocation, a storage device error, or
    /// a structurally corrupt check-in (a manifest/control with no parseable
    /// timestamp). A closed set so the CLI can switch exhaustively.
    pub const LoadError = error{ OutOfMemory, Storage, Corrupt };

    /// WHICH artifact the derive rejected, and why.
    ///
    /// `error.Corrupt` alone says a repository cannot be read; it does not say
    /// what in it is unreadable, and the walk that discovers this runs over
    /// every artifact in the store — minutes on a large one. Without the name,
    /// the only way to find the offending artifact is to bisect the store or
    /// attach a debugger, neither of which is available to whoever is holding
    /// the broken repository.
    ///
    /// Plain data with no allocation, so it survives the `errdefer` that tears
    /// down a partially built view: the caller reads it after `open` has already
    /// failed, at which point nothing else on the view is valid.
    pub const Fault = struct {
        /// Lowercase-hex artifact name, as `hexName` renders it.
        name: [64]u8 = [_]u8{0} ** 64,
        name_len: u8 = 0,
        reason: Reason,

        pub const Reason = enum {
            /// A manifest or control artifact with no D card at all.
            missing_timestamp,
            /// A D card whose timestamp `parseMtime` refuses.
            unparsable_timestamp,
        };

        pub fn artifactName(self: *const Fault) []const u8 {
            return self.name[0..self.name_len];
        }
    };

    /// Every failure `buildFromRecordBody` can surface: allocation, or a record
    /// body whose framing is malformed (a length that runs past the buffer, a
    /// bad tag/kind byte). A malformed body is `Corrupt` so the cache layer
    /// treats it as a miss and rebuilds — never a trusted partial view.
    pub const BuildError = error{ OutOfMemory, Corrupt };

    /// Crosslink every persisted check-in and control artifact into a derived
    /// `RepoState`. `self` is initialized in place; `log` is borrowed for the
    /// duration and still owned by the caller. Transactional: on success the
    /// caller owns the view and must `deinit` it; on any error `load` tears down
    /// everything it built, so the caller neither can nor should `deinit`.
    ///
    /// Sequence: read the config (resolving `main_branch`), then walk the frames
    /// in persisted order. A frame whose bytes are not a structural artifact (a
    /// file blob) is skipped; a check-in manifest is crosslinked with its
    /// parents and tags; a control artifact's tags are ingested; every other
    /// artifact kind has no effect on the check-in DAG.
    pub fn load(self: *RepoView, gpa: std.mem.Allocator, log: *logstore.LogStore) LoadError!void {
        return self.loadInner(gpa, log, null, null);
    }

    /// Identical to `load`, but also serializes every crosslink input record
    /// (in persisted order) into `sink` — the exact byte body a `.zit-cache`
    /// stores. Replaying that body through `buildFromRecordBody` reconstructs a
    /// byte-identical view without touching the log, which is what makes a cache
    /// hit provably equal to a from-scratch derivation: the same records feed
    /// the same `crosslink`/`ingestControlTags` calls in the same order. `sink`
    /// is caller-owned; on failure the view tears itself down and `sink` (which
    /// may hold a partial body) is the caller's to discard.
    pub fn loadCapturing(self: *RepoView, gpa: std.mem.Allocator, log: *logstore.LogStore, sink: *std.ArrayList(u8)) LoadError!void {
        return self.loadInner(gpa, log, sink, null);
    }

    /// `loadCapturing` restricted to the log PREFIX strictly below
    /// `end_offset` (a frame boundary the caller proved with
    /// `logstore.logKeyAt`): the checkpoint builder captures the prefix
    /// record body this way, and `resumeFrom` later replays exactly these
    /// bytes. The view it builds answers only for the prefix — callers wanting
    /// the whole log use `loadCapturing`.
    pub fn loadCapturingPrefix(self: *RepoView, gpa: std.mem.Allocator, log: *logstore.LogStore, sink: *std.ArrayList(u8), end_offset: u64) LoadError!void {
        return self.loadInner(gpa, log, sink, end_offset);
    }

    /// Sets §6.1's main-branch name on the view AND on the state that answers
    /// branch queries. One setter because they are one fact: `branchOf` reads
    /// the view's copy through its parameter while `branchValueOf` reads the
    /// state's field, and a check-in with no active branch tag resolves through
    /// whichever one the caller happened to take. Assigning them separately is
    /// how they drift, and drift here means the display and the §7.2 leaf test
    /// disagree about which branch an untagged check-in is on.
    fn setMainBranch(self: *RepoView, name: []const u8) void {
        std.debug.assert(name.len != 0);
        self.main_branch = name;
        self.state.main_branch = name;
    }

    fn loadInner(self: *RepoView, gpa: std.mem.Allocator, log: *logstore.LogStore, sink: ?*std.ArrayList(u8), below: ?u64) LoadError!void {
        self.store = repostate.MemStore.init(gpa);
        self.state = repostate.RepoState.init(gpa, self.store.store());
        self.owned_config = null;
        self.owned_main_branch = null;
        self.image_bytes = null;
        self.setMainBranch(default_branch);
        // Self-clean on failure: a partially built view frees itself so the
        // caller's contract is "deinit only a view that loaded".
        errdefer {
            if (self.owned_config) |*oc| oc.deinit(gpa);
            self.state.deinit();
            self.store.deinit();
        }

        self.owned_config = try log.readConfig(gpa);
        if (self.owned_config) |oc| {
            // An empty stored value is treated as unset (defensive: commit
            // always writes a real name), so `main_branch` is never empty and
            // `branchOf`'s `len != 0` precondition always holds.
            if (oc.config.default_branch.len != 0) self.setMainBranch(oc.config.default_branch);
        }
        std.debug.assert(self.main_branch.len != 0);

        const rids = try log.ridsInPersistedOrder(gpa);
        defer gpa.free(rids);
        // A bounded plaintext memo for this one sweep: bases persist strictly
        // before their deltas, so resolving rids in persisted order keeps each
        // base hot and collapses delta-chain read-amplification to ~1x. Costs
        // nothing on an all-raw log (raw content is never cached).
        var cache = logstore.PlaintextCache.init();
        defer cache.deinit(gpa);
        var memo: ListMemo = .{};
        defer memo.deinit(gpa);
        // Bounded by the persisted artifact count; each rid is visited once.
        for (rids) |rid| {
            if (below) |b| {
                if (log.offsetOf(rid) >= b) continue;
            }
            try self.linkOne(gpa, log, rid, &cache, &memo, sink);
        }
    }

    /// Fold the log's TAIL into an already-loaded view, capturing the new
    /// records into `sink` exactly as `loadCapturing` would have. `self` must
    /// hold a view built from a record body that covers every frame strictly
    /// below `from_offset` -- the caller proves that with
    /// `logstore.validatePrefix` before calling. Rids are visited in persisted
    /// order, so the fold sees the tail exactly as a from-scratch derive would
    /// have seen it after the prefix, and the combined body (old ++ sink)
    /// replays to the same state a cold load over the whole log produces.
    ///
    /// NOT valid across a shun-table change: the prefix body was distilled
    /// under the OLD mask, and a record it carries may now be one the view
    /// must read as absent. The caller compares shun fingerprints and takes
    /// the full derive on any difference.
    pub fn extendCapturing(
        self: *RepoView,
        gpa: std.mem.Allocator,
        log: *logstore.LogStore,
        from_offset: u64,
        sink: *std.ArrayList(u8),
    ) LoadError!void {
        const rids = try log.ridsInPersistedOrder(gpa);
        defer gpa.free(rids);
        var cache = logstore.PlaintextCache.init();
        defer cache.deinit(gpa);
        var memo: ListMemo = .{};
        defer memo.deinit(gpa);
        for (rids) |rid| {
            if (log.offsetOf(rid) < from_offset) continue;
            try self.linkOne(gpa, log, rid, &cache, &memo, sink);
        }
    }

    /// Build this view by replaying a VALIDATED prefix record body (the bytes
    /// `buildFromRecordBody` accepts) and then continuing the ordinary frame
    /// walk from `from_offset`, capturing the COMBINED record body into `sink`
    /// (prefix body first, then the resumed walk's records in persisted
    /// order). The result must be indistinguishable from `loadCapturing` over
    /// the same log — same view, same captured body — so a `.zit-cache`
    /// written from `sink` is byte-identical to one written by a full derive.
    ///
    /// The caller owns proving that `from_offset` really is the boundary the
    /// body was captured at (`logstore.validatePrefix`) and that the shun
    /// table has not moved since capture (fingerprint compare) —
    /// checkpoint.zig does both. The LIVE log's config outranks whatever the
    /// prefix knew: a config frame in the tail renames the main branch here
    /// exactly as it would on a cold load. Transactional like `load`: on any
    /// error the view has torn itself down and only `sink` (possibly partial)
    /// is the caller's to discard.
    pub fn resumeFrom(
        self: *RepoView,
        gpa: std.mem.Allocator,
        log: *logstore.LogStore,
        prefix_body: []const u8,
        from_offset: u64,
        sink: *std.ArrayList(u8),
    ) LoadError!void {
        var oc = try log.readConfig(gpa);
        const main_branch = if (oc) |c|
            (if (c.config.default_branch.len != 0) c.config.default_branch else default_branch)
        else
            default_branch;

        self.buildFromRecordBody(gpa, prefix_body, main_branch) catch |e| {
            if (oc) |*c| c.deinit(gpa);
            return switch (e) {
                error.OutOfMemory => error.OutOfMemory,
                error.Corrupt => error.Corrupt,
            };
        };
        // The view owns the config from here (deinit frees it), matching a
        // cold load's bookkeeping — so every later failure tears down whole.
        self.owned_config = oc;
        sink.appendSlice(gpa, prefix_body) catch {
            self.deinit(gpa);
            return error.OutOfMemory;
        };
        self.extendCapturing(gpa, log, from_offset, sink) catch |e| {
            self.deinit(gpa);
            return e;
        };
    }

    /// Reconstruct a view from a `.zit-cache` record body (as produced by
    /// `loadCapturing`) plus the resolved `main_branch`, WITHOUT reading the
    /// log. Replays each record in stored order through the same
    /// `crosslink`/`ingestControlTags` procedure the live load uses, so the
    /// resulting `RepoState`/`MemStore` are byte-identical to a from-scratch
    /// derivation over the same log prefix. `body` and `main_branch` borrow the
    /// caller's cache buffer only for the duration of the call (every hash, tag
    /// name, and value is duped into the store), so the buffer may be freed on
    /// return. Transactional like `load`: on any error the view is torn down.
    pub fn buildFromRecordBody(self: *RepoView, gpa: std.mem.Allocator, body: []const u8, main_branch: []const u8) BuildError!void {
        std.debug.assert(main_branch.len != 0);
        self.store = repostate.MemStore.init(gpa);
        self.state = repostate.RepoState.init(gpa, self.store.store());
        self.owned_config = null;
        self.owned_main_branch = null;
        self.image_bytes = null;
        self.setMainBranch(default_branch);
        errdefer {
            if (self.owned_main_branch) |b| gpa.free(b);
            self.state.deinit();
            self.store.deinit();
        }
        self.owned_main_branch = try gpa.dupe(u8, main_branch);
        self.setMainBranch(self.owned_main_branch.?);

        var r: RecordReader = .{ .body = body };
        // Bounded: each record consumes at least one byte, so the loop runs at
        // most `body.len` times and every decode strictly advances `pos`.
        while (!r.atEnd()) {
            const before = r.pos;
            try self.replayRecord(gpa, &r);
            std.debug.assert(r.pos > before);
        }
    }

    /// Reconstruct a view from a `.zit-cache` image section (as produced by
    /// `repoimage.serialize`) with ZERO derivation. On success `self` takes
    /// ownership of `buf` (the whole cache buffer) — the loaded image's columns
    /// are offset windows into `image`, a sub-slice of `buf`, so the buffer must
    /// outlive the view. A malformed image is `Corrupt`, which the cache layer
    /// treats as a miss and rebuilds; on any error `self` is torn down and `buf`
    /// is NOT taken (the caller frees it). Transactional like `buildFromRecordBody`.
    pub fn buildFromImage(self: *RepoView, gpa: std.mem.Allocator, buf: []u8, image: []const u8, main_branch: []const u8) BuildError!void {
        std.debug.assert(main_branch.len != 0);
        const loaded = (repoimage.Image.load(gpa, image) catch return error.OutOfMemory) orelse return error.Corrupt;
        self.store = repostate.MemStore.init(gpa);
        self.state = repostate.RepoState.fromImage(gpa, self.store.store(), loaded); // state.img now owns `loaded`
        self.owned_config = null;
        self.owned_main_branch = null;
        self.image_bytes = null;
        self.setMainBranch(default_branch);
        errdefer {
            self.state.deinit(); // frees the image's owned leaf-rid slice
            self.store.deinit();
        }
        self.owned_main_branch = try gpa.dupe(u8, main_branch);
        self.setMainBranch(self.owned_main_branch.?);
        self.image_bytes = buf; // ownership transferred; freed in deinit after state
    }

    /// Frees the config frame and the derived state. Call only on a view that
    /// `load` returned successfully — a failed `load` already tore itself down.
    /// `state` borrows `store` (and, in the image backing, borrows `image_bytes`),
    /// so it is torn down first, then the owned cache buffer.
    pub fn deinit(self: *RepoView, gpa: std.mem.Allocator) void {
        if (self.owned_config) |*oc| oc.deinit(gpa);
        if (self.owned_main_branch) |b| gpa.free(b);
        self.state.deinit();
        self.store.deinit();
        if (self.image_bytes) |b| gpa.free(b);
        self.* = undefined;
    }

    /// The lowercase-hex artifact name of `rid` in this view's identity space —
    /// what a verb prints and compares against the current checkout. `rid` is a
    /// `RepoState` rid (the `MemStore` space), not a `LogStore` rid.
    pub fn hashOf(self: *RepoView, rid: repostate.Rid) []const u8 {
        if (self.state.img) |*img| return img.hashOf(rid);
        return self.store.store().hashOf(rid);
    }

    /// The `RepoState` rid for a lowercase-hex artifact name, or null when the
    /// name was never interned in this view. A verb resolves the current
    /// checkout's version this way to mark the corresponding leaf (§7.5).
    pub fn lookup(self: *RepoView, hash: []const u8) ?repostate.Rid {
        if (self.state.img) |*img| return img.lookup(hash);
        return self.store.store().lookup(hash);
    }

    /// Crosslink or ingest one persisted frame. A parse failure means the bytes
    /// are not a structural artifact (a file blob) — skipped, not an error,
    /// matching the "crosslink whatever parses" model. Allocation failure
    /// propagates.
    fn linkOne(self: *RepoView, gpa: std.mem.Allocator, log: *logstore.LogStore, rid: logstore.Rid, cache: *logstore.PlaintextCache, memo: *ListMemo, sink: ?*std.ArrayList(u8)) LoadError!void {
        // Null ⇒ nothing to crosslink: a phantom (no frame) or an artifact in the
        // repository shun table (frame on disk, masked by `contentCached`). The
        // second is deliberate and consequential — a shunned check-in manifest
        // contributes no timeline event, no leaf and no tag, i.e. it leaves the
        // derived view exactly as if the repository never held it, which is what
        // "reads as ABSENT" has to mean for a view.
        // A parent already parsed for a child that arrived first is not parsed
        // twice (zit#172). This skips the `contentCached` call above, which is
        // what masks shunned and phantom artifacts — safe only because nothing
        // enters the memo except through that same call, so a masked artifact
        // is never resident to be found here.
        {
            var hex_buf: [64]u8 = undefined;
            if (memo.cached(hexName(log.hashOf(rid), &hex_buf))) |m| {
                parsed_artifacts += 1; // the walk reached this frame all the same
                std.debug.assert(repostate.sweepDisposition(m.kind) == .crosslink);
                try self.link(gpa, log, rid, m, cache, memo, sink);
                return;
            }
        }
        const bytes = (try log.contentCached(gpa, rid, cache)) orelse return;
        defer gpa.free(bytes);
        // Read path: tolerate the legacy F-card trailing-separator quirk in
        // fossil-origin history (artifact.parseForImport), never on the writer.
        parsed_artifacts += 1; // evidence seam: a resumed derive must not reach here for prefix frames
        var im = artifact.parseForImport(gpa, bytes) catch |e| switch (e) {
            error.OutOfMemory => return error.OutOfMemory,
            else => return, // not a structural artifact — a file blob, skipped
        };
        defer im.deinit(gpa);
        const m = &im.manifest;
        // Only check-in manifests and control artifacts affect the check-in DAG
        // and its tags; clusters, wiki, tickets, technotes, and attachments do
        // not.
        //
        // draft-fossil-submanifests-00 §9.2/§7.3 — the admission rule is stated
        // ONCE, over the artifact type, in `repostate.sweepDisposition`. §7.3: a
        // SUBMANIFEST "has no direct crosslink effect ... it carries no
        // state-bearing card and contributes to repository state only through
        // the manifests whose trees contain it". Reading the rule from there
        // rather than restating it here is what §9.2 is asking for: this sweep
        // covers every persisted frame, and a hand-written condition is exactly
        // where a new type silently acquires the wrong disposition. The switch
        // is exhaustive, so adding an artifact type cannot compile without
        // deciding this.
        if (repostate.sweepDisposition(m.kind) != .crosslink) return;
        try self.link(gpa, log, rid, m, cache, memo, sink);
    }

    /// Record which artifact the derive is rejecting, then fail. Returns the
    /// error so callers read `return self.faulted(...)` — the name is captured
    /// on the same line that gives up, which is the only way it stays accurate
    /// as new rejection sites are added.
    fn faulted(self: *RepoView, hash: []const u8, reason: Fault.Reason) error{Corrupt} {
        var f: Fault = .{ .reason = reason };
        const n = @min(hash.len, f.name.len);
        @memcpy(f.name[0..n], hash[0..n]);
        f.name_len = @intCast(n);
        self.fault = f;
        return error.Corrupt;
    }

    /// Feed one manifest or control artifact into `RepoState`. Both carry a hash,
    /// an event timestamp, and tag cards; a manifest additionally carries its
    /// parent references (crosslinked into the DAG), while a control's hash gains
    /// no event — only its tags are ingested (repo-state §4.2, §7).
    fn link(self: *RepoView, gpa: std.mem.Allocator, log: *logstore.LogStore, rid: logstore.Rid, m: *const artifact.Manifest, cache: *logstore.PlaintextCache, memo: *ListMemo, sink: ?*std.ArrayList(u8)) LoadError!void {
        var hex_buf: [64]u8 = undefined;
        const hash = hexName(log.hashOf(rid), &hex_buf);
        // A check-in or control artifact without a parseable D-card timestamp is
        // structurally corrupt — no silent skip of a malformed check-in.
        const d = m.d orelse return self.faulted(hash, .missing_timestamp);
        const mtime = repostate.parseMtime(d.timestamp) orelse
            return self.faulted(hash, .unparsable_timestamp);

        var tags: std.ArrayList(repostate.TagOp) = .empty;
        defer tags.deinit(gpa);
        // Bounded by the manifest's own T-card count.
        for (m.t) |tc| try tags.append(gpa, tagOpOf(tc));

        switch (m.kind) {
            .manifest => {
                var parents: std.ArrayList([]const u8) = .empty;
                defer parents.deinit(gpa);
                // Bounded by the manifest's own P-card token count; each `next`
                // strictly shrinks the iterator.
                for (m.p) |pc| {
                    var it = pc.hashes();
                    while (it.next()) |parent| try parents.append(gpa, parent);
                }
                const comment: []const u8 = if (m.c) |c| c.comment else "";
                if (sink) |s| {
                    const user: []const u8 = if (m.u) |u| u.login else "";
                    var changed: std.ArrayList(ChangedFile) = .empty;
                    defer freeChanged(gpa, &changed);
                    const known = try captureFileFacts(gpa, log, cache, memo, m, parents.items, &changed);
                    try encodeRecord(gpa, s, .manifest, hash, mtime, parents.items, tags.items, comment, user, if (known) changed.items else null);
                }
                _ = try self.state.crosslink(.{
                    .hash = hash,
                    .mtime = mtime,
                    .parents = parents.items,
                    .tags = tags.items,
                    .comment = comment,
                    .user = if (m.u) |u| u.login else "",
                });
            },
            .control => {
                if (sink) |s| try encodeRecord(gpa, s, .control, hash, mtime, &[_][]const u8{}, tags.items, "", "", null);
                _ = try self.state.ingestControlTags(hash, mtime, tags.items);
            },
            else => unreachable, // linkOne admits only manifest/control
        }
    }

    /// Decode and replay the next record at `r`, driving the same derivation the
    /// live path drives. A manifest record crosslinks; a control record ingests
    /// tags. Every wire length is validated against the body bound before it is
    /// used to slice (TigerStyle: a claimed-huge length is `Corrupt`, never an
    /// out-of-bounds read), and the temporary parent/tag slices borrow the body,
    /// which the crosslink step dupes.
    fn replayRecord(self: *RepoView, gpa: std.mem.Allocator, r: *RecordReader) BuildError!void {
        const kind_byte = try r.byte();
        const hash = try r.lenPrefixed(u16);
        if (hash.len == 0) return error.Corrupt; // a record hash is never empty
        const mtime = try r.int(u64);

        const parent_count = try r.int(u32);
        var parents: std.ArrayList([]const u8) = .empty;
        defer parents.deinit(gpa);
        // Bounded: `parent_count` slices are each ≥ 2 body bytes, so the reader
        // exhausts the buffer before an oversized count can over-allocate.
        var pi: u32 = 0;
        while (pi < parent_count) : (pi += 1) {
            const p = try r.lenPrefixed(u16);
            if (p.len == 0) return error.Corrupt;
            try parents.append(gpa, p);
        }

        const tag_count = try r.int(u32);
        var tags: std.ArrayList(repostate.TagOp) = .empty;
        defer tags.deinit(gpa);
        var ti: u32 = 0;
        while (ti < tag_count) : (ti += 1) {
            const tt: repostate.TagType = switch (try r.byte()) {
                0 => .cancel,
                1 => .singleton,
                2 => .propagate,
                else => return error.Corrupt,
            };
            const name = try r.lenPrefixed(u16);
            if (name.len == 0) return error.Corrupt;
            const target: ?[]const u8 = if (try r.byte() != 0) try r.lenPrefixed(u16) else null;
            const value: ?[]const u8 = if (try r.byte() != 0) try r.lenPrefixed(u32) else null;
            try tags.append(gpa, .{ .type = tt, .name = name, .target = target, .value = value });
        }

        switch (kind_byte) {
            0 => {
                const comment = try r.lenPrefixed(u32);
                // #109 tail: the user the state now carries (listings render
                // it without re-reading a manifest), then the changed-file
                // set, which the state does not consume -- leaf/branch/tag
                // derivation reads no file data -- but which the replay walks
                // and VALIDATES, or a corrupt tail would slice out of the
                // record. Kept in lockstep with `encodeRecord` and
                // `RecordIter`.
                const user = try r.lenPrefixed(u16);
                switch (try r.byte()) {
                    0 => {
                        const n = try r.int(u32);
                        var fi: u32 = 0;
                        while (fi < n) : (fi += 1) {
                            switch (try r.byte()) {
                                'A', 'M', 'D' => {},
                                else => return error.Corrupt,
                            }
                            if ((try r.lenPrefixed(u16)).len == 0) return error.Corrupt;
                        }
                    },
                    1 => {},
                    else => return error.Corrupt,
                }
                _ = self.state.crosslink(.{ .hash = hash, .mtime = mtime, .parents = parents.items, .tags = tags.items, .comment = comment, .user = user }) catch |e| return e;
            },
            1 => _ = self.state.ingestControlTags(hash, mtime, tags.items) catch |e| return e,
            else => return error.Corrupt,
        }
    }
};

/// The kind byte a record carries: 0 = a check-in manifest (crosslinked with
/// its parents), 1 = a control artifact (tags only). Matches `artifact.Kind`'s
/// two structural cases the loader admits.
pub const RecordKind = enum(u8) { manifest = 0, control = 1 };

/// Append the crosslink input records for every artifact whose frame lies in
/// `[from_offset, below_offset)` to `sink`, WITHOUT deriving any state: the
/// record stream is a pure function of the artifacts in persisted order (the
/// same extraction `link` performs, minus the crosslink), so the emitted
/// bytes are identical to what `loadCapturingPrefix` would have captured over
/// the same span — which is what lets a checkpoint ADVANCE by appending a
/// tail capture to its stored body instead of re-deriving the whole prefix.
/// The §9.2 disposition rule is read from `repostate.sweepDisposition`, never
/// restated. A structurally corrupt check-in (missing/unparsable timestamp)
/// is `Corrupt`, exactly as the deriving walk treats it.
pub fn captureRecords(
    gpa: std.mem.Allocator,
    log: *logstore.LogStore,
    from_offset: u64,
    below_offset: u64,
    sink: *std.ArrayList(u8),
) RepoView.LoadError!void {
    std.debug.assert(from_offset <= below_offset);
    const rids = try log.ridsInPersistedOrder(gpa);
    defer gpa.free(rids);
    var cache = logstore.PlaintextCache.init();
    defer cache.deinit(gpa);
    var memo: ListMemo = .{};
    defer memo.deinit(gpa);
    for (rids) |rid| {
        const off = log.offsetOf(rid);
        if (off < from_offset or off >= below_offset) continue;
        var hex_buf: [64]u8 = undefined;
        const hash = hexName(log.hashOf(rid), &hex_buf);
        var im: artifact.ImportedManifest = undefined;
        var have_im = false;
        defer if (have_im) im.deinit(gpa);
        var bytes_owned: ?[]u8 = null; // the parse may borrow them: freed after the record
        defer if (bytes_owned) |b| gpa.free(b);
        const m: *const artifact.Manifest = blk: {
            // A parent parsed for its child is not parsed again (zit#172).
            if (memo.cached(hash)) |mm| {
                parsed_artifacts += 1;
                break :blk mm;
            }
            const bytes = (try log.contentCached(gpa, rid, &cache)) orelse continue;
            bytes_owned = bytes;
            parsed_artifacts += 1; // the same evidence seam the deriving walk counts through
            im = artifact.parseForImport(gpa, bytes) catch |e| switch (e) {
                error.OutOfMemory => return error.OutOfMemory,
                else => continue, // not a structural artifact — a file blob, skipped
            };
            have_im = true;
            break :blk &im.manifest;
        };
        if (repostate.sweepDisposition(m.kind) != .crosslink) continue;

        const d = m.d orelse return error.Corrupt; // the deriving walk faults here too
        const mtime = repostate.parseMtime(d.timestamp) orelse return error.Corrupt;
        var tags: std.ArrayList(repostate.TagOp) = .empty;
        defer tags.deinit(gpa);
        for (m.t) |tc| try tags.append(gpa, tagOpOf(tc));
        switch (m.kind) {
            .manifest => {
                var parents: std.ArrayList([]const u8) = .empty;
                defer parents.deinit(gpa);
                for (m.p) |pc| {
                    var it = pc.hashes();
                    while (it.next()) |parent| try parents.append(gpa, parent);
                }
                const comment: []const u8 = if (m.c) |c| c.comment else "";
                const user: []const u8 = if (m.u) |u| u.login else "";
                var changed: std.ArrayList(ChangedFile) = .empty;
                defer freeChanged(gpa, &changed);
                const known = try captureFileFacts(gpa, log, &cache, &memo, m, parents.items, &changed);
                try encodeRecord(gpa, sink, .manifest, hash, mtime, parents.items, tags.items, comment, user, if (known) changed.items else null);
            },
            .control => try encodeRecord(gpa, sink, .control, hash, mtime, &[_][]const u8{}, tags.items, "", "", null),
            else => unreachable, // sweepDisposition admits only these two to crosslink
        }
    }
}

/// Return the byte-prefix of `body` holding exactly the records for artifacts
/// whose frames lie below `below_offset` — records are emitted in persisted
/// order, so a whole-log capture's prefix IS the prefix capture (the split
/// property `repocache`'s differential test proves). Walks record framing
/// only, never parses artifacts; null when the body is corrupt or names an
/// unknown artifact — the caller treats that as "no prefix available".
pub fn recordPrefixBelow(log: *logstore.LogStore, body: []const u8, below_offset: u64) ?[]const u8 {
    var r = RecordReader{ .body = body };
    var raw: [logstore.max_hash_len]u8 = undefined;
    while (!r.atEnd()) {
        const start = r.pos;
        const kind_byte = r.byte() catch return null;
        const hash = r.lenPrefixed(u16) catch return null;
        if (hash.len == 0 or hash.len % 2 != 0 or hash.len / 2 > raw.len) return null;
        _ = r.int(u64) catch return null; // mtime
        const parent_count = r.int(u32) catch return null;
        var pi: u32 = 0;
        while (pi < parent_count) : (pi += 1) _ = r.lenPrefixed(u16) catch return null;
        const tag_count = r.int(u32) catch return null;
        var ti: u32 = 0;
        while (ti < tag_count) : (ti += 1) {
            _ = r.byte() catch return null; // type
            _ = r.lenPrefixed(u16) catch return null; // name
            if ((r.byte() catch return null) != 0) _ = r.lenPrefixed(u16) catch return null;
            if ((r.byte() catch return null) != 0) _ = r.lenPrefixed(u32) catch return null;
        }
        switch (kind_byte) {
            0 => {
                _ = r.lenPrefixed(u32) catch return null; // comment
                _ = r.lenPrefixed(u16) catch return null; // user (#109)
                switch (r.byte() catch return null) {
                    0 => {
                        const n = r.int(u32) catch return null;
                        var fi: u32 = 0;
                        while (fi < n) : (fi += 1) {
                            _ = r.byte() catch return null; // disposition
                            _ = r.lenPrefixed(u16) catch return null; // path
                        }
                    },
                    1 => {},
                    else => return null,
                }
            },
            1 => {},
            else => return null,
        }
        const raw_hash = raw[0 .. hash.len / 2];
        _ = std.fmt.hexToBytes(raw_hash, hash) catch return null;
        const rid = log.lookup(raw_hash) orelse return null;
        if (log.offsetOf(rid) >= below_offset) return body[0..start];
    }
    return body; // every record lies below the boundary
}

/// One entry of a check-in's changed-file set (#109): what this check-in did
/// to one path relative to its PRIMARY parent. A rename records as a delete
/// of the old path plus an add of the new one.
pub const FileDisp = enum(u8) { added = 'A', modified = 'M', deleted = 'D' };
pub const ChangedFile = struct { disp: FileDisp, path: []const u8 };

/// A parsed manifest the diff step keeps warm: the bytes it borrows and the
/// parse, heap-allocated so a pointer to it survives other insertions.
const ParsedEntry = struct {
    bytes: []u8,
    im: artifact.ImportedManifest,
    tick: u64,
    fn destroy(self: *ParsedEntry, gpa: std.mem.Allocator) void {
        self.im.deinit(gpa);
        gpa.free(self.bytes);
        gpa.destroy(self);
    }
};

/// The diff step's working set (zit#109, zit#172): a bounded cache of PARSED
/// manifests keyed by hex hash — the baseline every delta in a run shares,
/// and the parent a child arrives before (a clone's persisted order is
/// newest-first, so the parent is usually still unwalked when its child is
/// diffed; the walk then takes it from here instead of parsing it again).
/// Fossil keeps six parsed manifests warm for the same reason. Both bounds
/// are stated (TigerStyle): entries, and the bytes they borrow — a baseline
/// of a large tree runs to megabytes, and evicting one to admit its own
/// deltas is the thrash this cache exists to prevent. Least recently used
/// out; whichever bound binds first evicts.
const ListMemo = struct {
    parsed: std.StringArrayHashMapUnmanaged(*ParsedEntry) = .empty,
    bytes: usize = 0,
    tick: u64 = 0,
    /// Entries touched after this tick belong to the capture step now running
    /// and are NEVER evicted. One step holds live pointers into a parent, a
    /// child and their baselines at the same time, and evicting any of them
    /// under it would leave a merge-walk reading freed memory — reachable
    /// through the byte bound alone, since one oversized baseline can ask for
    /// the whole budget. Raised at the start of every step, so at most the
    /// four manifests a step touches can sit above the bound, and only until
    /// that step ends.
    floor: u64 = 0,
    const max_parsed: usize = 64;
    const max_bytes: usize = 256 << 20;
    fn deinit(self: *ListMemo, gpa: std.mem.Allocator) void {
        for (self.parsed.keys()) |k| gpa.free(k);
        for (self.parsed.values()) |e| e.destroy(gpa);
        self.parsed.deinit(gpa);
    }
    /// Open a capture step: the entry touched most recently — the caller
    /// already holds it — and everything the step touches from here are
    /// pinned until the next step opens. Inclusive on purpose: `linkOne`
    /// hands a cached manifest straight to the step that then diffs it.
    fn beginStep(self: *ListMemo) void {
        self.floor = self.tick;
    }
    /// A check-in already parsed for a child it arrived after (zit#172): the
    /// walk takes it from here instead of parsing the same bytes twice.
    fn cached(self: *ListMemo, hex: []const u8) ?*const artifact.Manifest {
        const e = self.parsed.get(hex) orelse return null;
        self.tick += 1;
        e.tick = self.tick;
        return &e.im.manifest;
    }
    /// The check-in manifest named by hex `hex`, parsed once and kept warm;
    /// null when it is absent from the log, shunned, unparseable, or not a
    /// check-in. The pointer stays valid across further lookups in the same
    /// capture step: an entry just touched carries the newest tick and is
    /// never the one evicted.
    fn manifest(self: *ListMemo, gpa: std.mem.Allocator, log: *logstore.LogStore, cache: *logstore.PlaintextCache, hex: []const u8) error{OutOfMemory}!?*const artifact.Manifest {
        if (self.cached(hex)) |m| return m;
        var raw: [logstore.max_hash_len]u8 = undefined;
        if (hex.len == 0 or hex.len % 2 != 0 or hex.len / 2 > raw.len) return null;
        const raw_hash = raw[0 .. hex.len / 2];
        _ = std.fmt.hexToBytes(raw_hash, hex) catch return null;
        const rid = log.lookup(raw_hash) orelse return null;
        const bytes = (log.contentCached(gpa, rid, cache) catch |e| switch (e) {
            error.OutOfMemory => return error.OutOfMemory,
            else => return null,
        }) orelse return null;
        var bytes_owned = true;
        defer if (bytes_owned) gpa.free(bytes);
        var im = artifact.parseForImport(gpa, bytes) catch |e| switch (e) {
            error.OutOfMemory => return error.OutOfMemory,
            else => return null,
        };
        var im_owned = true;
        defer if (im_owned) im.deinit(gpa);
        if (im.manifest.kind != .manifest) return null;
        // §4.5.3 checked once per parse, not once per diff: `deltaDiff` binary
        // searches a baseline run and merges two delta runs directly, and
        // re-walking a baseline of a large tree per check-in is the O(files)
        // cost this whole path exists to avoid (zit#172).
        if (std.debug.runtime_safety) artifact.assertSorted(im.manifest.f);
        while (self.parsed.count() >= max_parsed or (self.bytes + bytes.len > max_bytes and self.parsed.count() > 0)) {
            var oldest: ?usize = null;
            for (self.parsed.values(), 0..) |e, i| {
                if (e.tick >= self.floor) continue; // pinned by the running step
                if (oldest == null or e.tick < self.parsed.values()[oldest.?].tick) oldest = i;
            }
            const idx = oldest orelse break; // every resident entry is in use
            const k = self.parsed.keys()[idx];
            const e = self.parsed.values()[idx];
            self.parsed.orderedRemoveAt(idx);
            self.bytes -= e.bytes.len;
            gpa.free(k);
            e.destroy(gpa);
        }
        const entry = try gpa.create(ParsedEntry);
        errdefer gpa.destroy(entry);
        const key = try gpa.dupe(u8, hex);
        errdefer gpa.free(key);
        try self.parsed.put(gpa, key, entry);
        self.tick += 1;
        entry.* = .{ .bytes = bytes, .im = im, .tick = self.tick };
        self.bytes += bytes.len;
        bytes_owned = false;
        im_owned = false;
        return &entry.im.manifest;
    }
};

/// The section 8.3 eager-baseline resolver, mirroring `repository.EagerBaseline`:
/// exactly one baseline was pre-read, and the resolver hands it over.
const EagerView = struct {
    hash: ?[]const u8,
    view: ?artifact.ManifestFiles,
    fn resolve(ctx: *anyopaque, hash: []const u8) ?artifact.ManifestFiles {
        const self: *EagerView = @ptrCast(@alignCast(ctx));
        std.debug.assert(self.hash != null and std.mem.eql(u8, hash, self.hash.?));
        return self.view;
    }
};

/// Begin the section 4.7 merge-walk over `m`'s effective file list — its own
/// F cards over its baseline's, resolved through the memo — or null when the
/// list cannot be known here: a tree (S-card) manifest (flattening lives
/// behind the Repository facade, and a wrong list is worse than an unknown
/// one), or a baseline that is absent, shunned, unparseable, or itself a
/// tree. `eager` must outlive the walk: the walk resolves through it.
fn beginWalk(
    gpa: std.mem.Allocator,
    log: *logstore.LogStore,
    cache: *logstore.PlaintextCache,
    memo: *ListMemo,
    m: *const artifact.Manifest,
    eager: *EagerView,
) error{OutOfMemory}!?artifact.MergeWalk {
    if (m.s.len != 0) return null; // tree manifest: unknown at this layer
    eager.* = .{ .hash = null, .view = null };
    if (m.b) |b| {
        eager.hash = b.hash;
        const base = (try memo.manifest(gpa, log, cache, b.hash)) orelse return null;
        if (base.s.len != 0) return null; // tree baseline: unknown
        eager.view = repository.manifestFilesOf(base);
    }
    return artifact.enumerate(repository.manifestFilesOf(m), .{
        .ctx = eager,
        .resolveFn = EagerView.resolve,
    }) catch null; // BaselineUnresolvable / BaselineIsDelta: unknown
}

/// The next tracked file a walk yields — a section 4.7.6 delete marker is
/// not a file and is skipped.
fn nextFile(walk: *artifact.MergeWalk, prev: ?artifact.FCard) ?artifact.FCard {
    while (walk.next()) |f| {
        if (f.hash == null) continue;
        // §4.5.3: the merge-walk yields canonical path order, and the
        // two-pointer merge below depends on it.
        if (std.debug.runtime_safety) {
            if (prev) |p| std.debug.assert(std.mem.order(u8, p.filename, f.filename) == .lt);
        }
        return f;
    }
    return null;
}

/// Two-pointer diff of two effective file lists as their walks yield them,
/// in section 4.5.3 order, into `out` (#109) — no list is ever materialized
/// (zit#172). Every path is DUPLICATED into `out`; the caller frees them via
/// `freeChanged`. A null parent is the root check-in: everything is an add.
fn diffWalks(
    gpa: std.mem.Allocator,
    parent: ?*artifact.MergeWalk,
    child: *artifact.MergeWalk,
    out: *std.ArrayList(ChangedFile),
) error{OutOfMemory}!void {
    var p: ?artifact.FCard = if (parent) |pw| nextFile(pw, null) else null;
    var c: ?artifact.FCard = nextFile(child, null);
    while (p != null or c != null) {
        if (p == null) {
            try appendChange(gpa, out, .added, c.?.filename);
            c = nextFile(child, c);
        } else if (c == null) {
            try appendChange(gpa, out, .deleted, p.?.filename);
            p = nextFile(parent.?, p);
        } else switch (std.mem.order(u8, p.?.filename, c.?.filename)) {
            .lt => {
                try appendChange(gpa, out, .deleted, p.?.filename);
                p = nextFile(parent.?, p);
            },
            .gt => {
                try appendChange(gpa, out, .added, c.?.filename);
                c = nextFile(child, c);
            },
            .eq => {
                if (!std.mem.eql(u8, p.?.hash.?, c.?.hash.?) or p.?.mode != c.?.mode) {
                    try appendChange(gpa, out, .modified, c.?.filename);
                }
                p = nextFile(parent.?, p);
                c = nextFile(child, c);
            },
        }
    }
}

/// The card `name` resolves to in a sorted F-card run, by binary search.
fn seekCard(cards: []const artifact.FCard, name: []const u8) ?*const artifact.FCard {
    var lo: usize = 0;
    var hi: usize = cards.len;
    while (lo < hi) {
        const mid = lo + (hi - lo) / 2;
        switch (std.mem.order(u8, cards[mid].filename, name)) {
            .lt => lo = mid + 1,
            .gt => hi = mid,
            .eq => return &cards[mid],
        }
    }
    return null;
}

/// What a delta manifest says about `name` once its baseline is applied: its
/// own card when it has one (a section 4.7.6 delete marker resolves to
/// nothing), else the baseline's.
fn effectiveCard(delta: ?*const artifact.FCard, base: ?*const artifact.FCard) ?*const artifact.FCard {
    if (delta) |d| return if (d.hash != null) d else null;
    return base;
}

/// Diff two delta manifests that share one baseline over their own F cards
/// alone (zit#172, the shape of Fossil's `add_mlink`): a path neither delta
/// names is the baseline's on both sides, so only the union of the two
/// deltas' cards can differ. One merge over two sorted runs, a binary search
/// into the baseline per name; output in path order, exactly what
/// `diffWalks` would have emitted.
fn deltaDiff(
    gpa: std.mem.Allocator,
    child: []const artifact.FCard,
    parent: []const artifact.FCard,
    base: []const artifact.FCard,
    out: *std.ArrayList(ChangedFile),
) error{OutOfMemory}!void {
    // `enumerate` is bypassed here, so §4.5.3 is asserted at this door
    // instead. `parent` and `base` were checked as they entered the manifest
    // cache; only the child's own run arrives unchecked, and it is the small
    // one — a delta's changed files, never a whole tree.
    if (std.debug.runtime_safety) artifact.assertSorted(child);
    var i: usize = 0;
    var j: usize = 0;
    while (i < child.len or j < parent.len) {
        const take_child = j >= parent.len or (i < child.len and std.mem.order(u8, child[i].filename, parent[j].filename) != .gt);
        const take_parent = i >= child.len or (j < parent.len and std.mem.order(u8, parent[j].filename, child[i].filename) != .gt);
        std.debug.assert(take_child or take_parent);
        const name = if (take_child) child[i].filename else parent[j].filename;
        const c: ?*const artifact.FCard = if (take_child) &child[i] else null;
        const p: ?*const artifact.FCard = if (take_parent) &parent[j] else null;
        if (take_child) i += 1;
        if (take_parent) j += 1;
        const b = seekCard(base, name);
        const eff_c = effectiveCard(c, b);
        const eff_p = effectiveCard(p, b);
        if (eff_c == null and eff_p == null) continue;
        if (eff_p == null) {
            try appendChange(gpa, out, .added, name);
        } else if (eff_c == null) {
            try appendChange(gpa, out, .deleted, name);
        } else if (!std.mem.eql(u8, eff_c.?.hash.?, eff_p.?.hash.?) or eff_c.?.mode != eff_p.?.mode) {
            try appendChange(gpa, out, .modified, name);
        }
    }
}

/// Duplicate `path` and append the change — one owner from birth, so an
/// append that fails under OOM injection cannot strand the dupe (the
/// checkAllAllocationFailures pins caught exactly that in the first draft).
fn appendChange(gpa: std.mem.Allocator, out: *std.ArrayList(ChangedFile), disp: FileDisp, path: []const u8) error{OutOfMemory}!void {
    const owned = try gpa.dupe(u8, path);
    errdefer gpa.free(owned);
    try out.append(gpa, .{ .disp = disp, .path = owned });
}

/// Free a change list the diff filled.
fn freeChanged(gpa: std.mem.Allocator, changed: *std.ArrayList(ChangedFile)) void {
    for (changed.items) |f| gpa.free(f.path);
    changed.deinit(gpa);
}

/// The #109 capture step for one check-in manifest: the change set against
/// the PRIMARY parent into `out`. Two deltas over one baseline diff over their
/// own F cards (zit#172); otherwise — a root, a baseline change, a full
/// manifest on either side — the two effective lists are walked side by side.
/// Nothing is materialized either way. Returns false -- files unknown --
/// whenever any side cannot be known: a tree manifest, an absent or shunned
/// parent, an unresolvable baseline. Unknown is the honest degrade; an empty
/// change set is a claim.
fn captureFileFacts(
    gpa: std.mem.Allocator,
    log: *logstore.LogStore,
    cache: *logstore.PlaintextCache,
    memo: *ListMemo,
    m: *const artifact.Manifest,
    parents: []const []const u8,
    out: *std.ArrayList(ChangedFile),
) error{OutOfMemory}!bool {
    memo.beginStep();
    if (m.s.len != 0) return false; // tree manifest: unknown at this layer
    var child_eager: EagerView = undefined;
    if (parents.len == 0) {
        var child = (try beginWalk(gpa, log, cache, memo, m, &child_eager)) orelse return false;
        try diffWalks(gpa, null, &child, out);
        return true;
    }
    const parent = (try memo.manifest(gpa, log, cache, parents[0])) orelse return false;
    if (parent.s.len != 0) return false;
    if (m.b != null and parent.b != null and std.mem.eql(u8, m.b.?.hash, parent.b.?.hash)) {
        if (try memo.manifest(gpa, log, cache, m.b.?.hash)) |base| {
            if (base.s.len == 0 and base.b == null) {
                try deltaDiff(gpa, m.f, parent.f, base.f, out);
                return true;
            }
        }
    }
    var parent_eager: EagerView = undefined;
    var pw = (try beginWalk(gpa, log, cache, memo, parent, &parent_eager)) orelse return false;
    var cw = (try beginWalk(gpa, log, cache, memo, m, &child_eager)) orelse return false;
    try diffWalks(gpa, &pw, &cw, out);
    return true;
}

test "zit#172: deltaDiff over one baseline matches the effective-list diff" {
    const gpa = std.testing.allocator;
    const F = artifact.FCard;
    const card = struct {
        fn f(name: []const u8, hash: ?[]const u8) F {
            return .{ .filename = name, .hash = hash, .perm = null, .old_name = null, .mode = .regular };
        }
    };
    const base = [_]F{ card.f("a", "h1"), card.f("b", "h2"), card.f("c", "h3") };
    // parent: edits b, deletes c, adds d; child: edits b differently, restores c, adds e, says nothing of d.
    const parent = [_]F{ card.f("b", "h2p"), card.f("c", null), card.f("d", "h4") };
    const child = [_]F{ card.f("b", "h2c"), card.f("c", "h3"), card.f("e", "h5") };
    var out: std.ArrayList(ChangedFile) = .empty;
    defer freeChanged(gpa, &out);
    try deltaDiff(gpa, &child, &parent, &base, &out);
    try std.testing.expectEqual(@as(usize, 4), out.items.len);
    try std.testing.expectEqualStrings("b", out.items[0].path);
    try std.testing.expectEqual(FileDisp.modified, out.items[0].disp);
    try std.testing.expectEqualStrings("c", out.items[1].path);
    try std.testing.expectEqual(FileDisp.added, out.items[1].disp);
    try std.testing.expectEqualStrings("d", out.items[2].path);
    try std.testing.expectEqual(FileDisp.deleted, out.items[2].disp);
    try std.testing.expectEqualStrings("e", out.items[3].path);
    try std.testing.expectEqual(FileDisp.added, out.items[3].disp);
    // The same edit on both sides is silent; a delete of what the baseline lacks is silent.
    const p2 = [_]F{ card.f("b", "h2x"), card.f("zz", null) };
    const c2 = [_]F{card.f("b", "h2x")};
    var out2: std.ArrayList(ChangedFile) = .empty;
    defer freeChanged(gpa, &out2);
    try deltaDiff(gpa, &c2, &p2, &base, &out2);
    try std.testing.expectEqual(@as(usize, 0), out2.items.len);
}

test "zit#172: diffWalks over two baseline-resolved walks emits the same changes as deltaDiff" {
    const gpa = std.testing.allocator;
    const F = artifact.FCard;
    const card = struct {
        fn f(name: []const u8, hash: ?[]const u8) F {
            return .{ .filename = name, .hash = hash, .perm = null, .old_name = null, .mode = .regular };
        }
    };
    const base = [_]F{ card.f("a", "h1"), card.f("b", "h2"), card.f("c", "h3") };
    const parent = [_]F{ card.f("b", "h2p"), card.f("c", null), card.f("d", "h4") };
    const child = [_]F{ card.f("b", "h2c"), card.f("c", "h3"), card.f("e", "h5") };
    const base_files: artifact.ManifestFiles = .{ .baseline_hash = null, .files = &base };
    const Resolver = struct {
        fn resolve(ctx: *anyopaque, hash: []const u8) ?artifact.ManifestFiles {
            _ = hash;
            return @as(*const artifact.ManifestFiles, @ptrCast(@alignCast(ctx))).*;
        }
    };
    var ctx = base_files;
    var pw = try artifact.enumerate(.{ .baseline_hash = "base", .files = &parent }, .{ .ctx = &ctx, .resolveFn = Resolver.resolve });
    var cw = try artifact.enumerate(.{ .baseline_hash = "base", .files = &child }, .{ .ctx = &ctx, .resolveFn = Resolver.resolve });
    var walked: std.ArrayList(ChangedFile) = .empty;
    defer freeChanged(gpa, &walked);
    try diffWalks(gpa, &pw, &cw, &walked);
    var direct: std.ArrayList(ChangedFile) = .empty;
    defer freeChanged(gpa, &direct);
    try deltaDiff(gpa, &child, &parent, &base, &direct);
    try std.testing.expectEqual(direct.items.len, walked.items.len);
    for (direct.items, walked.items) |d, w| {
        try std.testing.expectEqual(d.disp, w.disp);
        try std.testing.expectEqualStrings(d.path, w.path);
    }
}

/// Append one crosslink input record to `s` in the `.zit-cache` wire format:
/// `kind u8 | hash(u16-len) | mtime u64 | parent_count u32 [parent(u16-len)]… |
/// tag_count u32 [type u8, name(u16-len), has_target u8 [target(u16-len)],
/// has_value u8 [value(u32-len)]]…`. All little-endian, self-delimiting, so the
/// decoder needs no separate record count. Deterministic: the fields come only
/// from the persisted artifact, in persisted order — no clock, no RNG.
fn encodeRecord(
    gpa: std.mem.Allocator,
    s: *std.ArrayList(u8),
    kind: RecordKind,
    hash: []const u8,
    mtime: repostate.Mtime,
    parents: []const []const u8,
    tags: []const repostate.TagOp,
    comment: []const u8,
    user: []const u8,
    files: ?[]const ChangedFile,
) error{OutOfMemory}!void {
    try s.append(gpa, @intFromEnum(kind));
    try appendLenPrefixed(gpa, s, u16, hash);
    try appendInt(gpa, s, u64, mtime);
    try appendInt(gpa, s, u32, @intCast(parents.len));
    for (parents) |p| try appendLenPrefixed(gpa, s, u16, p);
    try appendInt(gpa, s, u32, @intCast(tags.len));
    for (tags) |t| {
        try s.append(gpa, @intFromEnum(t.type));
        try appendLenPrefixed(gpa, s, u16, t.name);
        if (t.target) |target| {
            try s.append(gpa, 1);
            try appendLenPrefixed(gpa, s, u16, target);
        } else try s.append(gpa, 0);
        if (t.value) |value| {
            try s.append(gpa, 1);
            try appendLenPrefixed(gpa, s, u32, value);
        } else try s.append(gpa, 0);
    }
    // A manifest record closes with its C-card comment (u32-len, "" for
    // none), the U-card user (u16-len, "" for none), and the #109 changed-file
    // set: a state byte (0 = known, 1 = unknown), then when known a count and
    // per-file disposition byte + path. A control record carries none of these.
    if (kind == .manifest) {
        try appendLenPrefixed(gpa, s, u32, comment);
        try appendLenPrefixed(gpa, s, u16, user);
        if (files) |fl| {
            try s.append(gpa, 0);
            try appendInt(gpa, s, u32, @intCast(fl.len));
            for (fl) |f| {
                try s.append(gpa, @intFromEnum(f.disp));
                try appendLenPrefixed(gpa, s, u16, f.path);
            }
        } else try s.append(gpa, 1);
    }
}

fn appendInt(gpa: std.mem.Allocator, s: *std.ArrayList(u8), comptime T: type, v: T) error{OutOfMemory}!void {
    var buf: [@sizeOf(T)]u8 = undefined;
    std.mem.writeInt(T, &buf, v, .little);
    try s.appendSlice(gpa, &buf);
}

fn appendLenPrefixed(gpa: std.mem.Allocator, s: *std.ArrayList(u8), comptime L: type, bytes: []const u8) error{OutOfMemory}!void {
    std.debug.assert(bytes.len <= std.math.maxInt(L));
    try appendInt(gpa, s, L, @intCast(bytes.len));
    try s.appendSlice(gpa, bytes);
}

/// One decoded crosslink record (#109): everything `encodeRecord` wrote.
/// Slices borrow the body buffer; the per-record lists are owned (`deinit`
/// after use). `files_known == false` is the record saying the changed-file
/// set could not be determined at capture time -- a tree manifest, or a
/// parent absent from the store -- which is distinct from an empty set.
pub const DecodedRecord = struct {
    kind: RecordKind,
    hash: []const u8,
    mtime: repostate.Mtime,
    parents: std.ArrayList([]const u8),
    tags: std.ArrayList(repostate.TagOp),
    comment: []const u8,
    user: []const u8,
    files_known: bool,
    files: std.ArrayList(ChangedFile),

    pub fn deinit(self: *DecodedRecord, gpa: std.mem.Allocator) void {
        self.parents.deinit(gpa);
        self.tags.deinit(gpa);
        self.files.deinit(gpa);
        self.* = undefined;
    }
};

/// Iterate a record body, yielding each record fully decoded -- the read seam
/// `zit crosslink` streams through. Three readers walk this framing
/// (`replayRecord`, `recordPrefixBelow`, and this); all three MUST move in
/// lockstep with `encodeRecord`, and the crosslink verb's cache-vs-derive
/// byte-equality pin is the system-level check that they do.
pub const RecordIter = struct {
    r: RecordReader,

    pub fn init(body: []const u8) RecordIter {
        return .{ .r = .{ .body = body } };
    }

    pub fn next(self: *RecordIter, gpa: std.mem.Allocator) RepoView.BuildError!?DecodedRecord {
        const r = &self.r;
        if (r.atEnd()) return null;
        const kind: RecordKind = switch (try r.byte()) {
            0 => .manifest,
            1 => .control,
            else => return error.Corrupt,
        };
        const hash = try r.lenPrefixed(u16);
        if (hash.len == 0) return error.Corrupt;
        var rec: DecodedRecord = .{
            .kind = kind,
            .hash = hash,
            .mtime = try r.int(u64),
            .parents = .empty,
            .tags = .empty,
            .comment = "",
            .user = "",
            .files_known = false,
            .files = .empty,
        };
        errdefer rec.deinit(gpa);

        const parent_count = try r.int(u32);
        var pi: u32 = 0;
        while (pi < parent_count) : (pi += 1) {
            const parent = try r.lenPrefixed(u16);
            if (parent.len == 0) return error.Corrupt;
            try rec.parents.append(gpa, parent);
        }
        const tag_count = try r.int(u32);
        var ti: u32 = 0;
        while (ti < tag_count) : (ti += 1) {
            const tt: repostate.TagType = switch (try r.byte()) {
                0 => .cancel,
                1 => .singleton,
                2 => .propagate,
                else => return error.Corrupt,
            };
            const name = try r.lenPrefixed(u16);
            if (name.len == 0) return error.Corrupt;
            const target: ?[]const u8 = if (try r.byte() != 0) try r.lenPrefixed(u16) else null;
            const value: ?[]const u8 = if (try r.byte() != 0) try r.lenPrefixed(u32) else null;
            try rec.tags.append(gpa, .{ .type = tt, .name = name, .target = target, .value = value });
        }
        if (kind == .manifest) {
            rec.comment = try r.lenPrefixed(u32);
            rec.user = try r.lenPrefixed(u16);
            switch (try r.byte()) {
                0 => {
                    rec.files_known = true;
                    const n = try r.int(u32);
                    var fi: u32 = 0;
                    while (fi < n) : (fi += 1) {
                        const disp: FileDisp = switch (try r.byte()) {
                            'A' => .added,
                            'M' => .modified,
                            'D' => .deleted,
                            else => return error.Corrupt,
                        };
                        const path = try r.lenPrefixed(u16);
                        if (path.len == 0) return error.Corrupt;
                        try rec.files.append(gpa, .{ .disp = disp, .path = path });
                    }
                },
                1 => {},
                else => return error.Corrupt,
            }
        }
        return rec;
    }
};

/// A bounds-checked cursor over a `.zit-cache` record body. Every read validates
/// against the remaining bytes before advancing, so a truncated or lying body
/// yields `error.Corrupt` rather than an out-of-bounds slice.
const RecordReader = struct {
    body: []const u8,
    pos: usize = 0,

    fn atEnd(self: *const RecordReader) bool {
        return self.pos >= self.body.len;
    }

    fn byte(self: *RecordReader) error{Corrupt}!u8 {
        if (self.pos + 1 > self.body.len) return error.Corrupt;
        const b = self.body[self.pos];
        self.pos += 1;
        return b;
    }

    fn int(self: *RecordReader, comptime T: type) error{Corrupt}!T {
        const n = @sizeOf(T);
        if (self.pos + n > self.body.len) return error.Corrupt;
        const v = std.mem.readInt(T, self.body[self.pos..][0..n], .little);
        self.pos += n;
        return v;
    }

    fn lenPrefixed(self: *RecordReader, comptime L: type) error{Corrupt}![]const u8 {
        const len: usize = try self.int(L);
        if (self.pos + len > self.body.len) return error.Corrupt;
        const out = self.body[self.pos..][0..len];
        self.pos += len;
        return out;
    }
};

/// Map a parsed T card (repo-state §5.1 sign syntax) to a `RepoState` tag op:
/// `+` singleton, `-` cancel, `*` propagate. `name` is the sign-stripped,
/// escape-decoded tag name; `target` is null for a `*` self-tag; `value` passes
/// through. Total over `TSign`.
fn tagOpOf(tc: artifact.TCard) repostate.TagOp {
    return .{
        .type = switch (tc.sign) {
            .add => .singleton,
            .cancel => .cancel,
            .propagate => .propagate,
        },
        .name = tc.name,
        .target = tc.target,
        .value = tc.value,
    };
}

/// Encode a binary digest to its lowercase-hex artifact name into `out`,
/// returning the used prefix. A 20-byte (sha1) or 32-byte (sha3-256) digest
/// yields a 40- or 64-char name — always ≤ 64.
fn hexName(raw: []const u8, out: *[64]u8) []const u8 {
    std.debug.assert(raw.len >= 1 and raw.len <= 32);
    for (raw, 0..) |b, i| {
        out[i * 2] = hex_digits[b >> 4];
        out[i * 2 + 1] = hex_digits[b & 0x0f];
    }
    return out[0 .. raw.len * 2];
}

// ===========================================================================
// Tests — the loader's job is to reproduce the repo-state §6-7 derived views
// from persisted bytes. Fixtures are assembled with `artifact.create` (the
// library serializer) and stored through a real `LogStore`; every expected
// value comes from the repo-state RFC text, never from Fossil.
// ===========================================================================

const storage = @import("storage.zig");
const testing = std.testing;

/// A tmp-backed writable `LogStore` fixture plus the file handle it borrows.
/// Mirrors the FileStorage/tmp pattern the logstore tests use. `deinit` tears
/// the store down; the tmp dir is the caller's to clean up.
const Fixture = struct {
    fs: storage.FileStorage,
    log: logstore.LogStore,

    /// Opens a fresh empty `.zit`-style log in `tmp`. `hash_kind` 0 accepts the
    /// 32-byte sha3-256 digests the fixtures use. Initialized in place: the
    /// store's `StorageFile` borrows `&self.fs`, so `self` must not be moved
    /// after this call.
    fn init(self: *Fixture, tmp: *std.testing.TmpDir, io: std.Io) !void {
        const raw = try tmp.dir.createFile(io, "log", .{ .read = true });
        self.fs = storage.FileStorage.init(io, raw, 0);
        self.log = try logstore.LogStore.open(testing.allocator, self.fs.storageFile(), 0);
    }

    fn deinit(self: *Fixture) void {
        self.log.deinit(testing.allocator);
    }

    /// Serialize `m`, store it under its own sha3-256 digest (the content-address
    /// commit uses), and return the 64-hex artifact name (caller frees).
    fn put(self: *Fixture, m: *const artifact.Manifest) ![]u8 {
        const bytes = try artifact.create(m, testing.allocator);
        defer testing.allocator.free(bytes);
        var digest: [32]u8 = undefined;
        std.crypto.hash.sha3.Sha3_256.hash(bytes, &digest, .{});
        _ = try self.log.put(&digest, bytes);
        var name_buf: [64]u8 = undefined;
        return testing.allocator.dupe(u8, hexName(&digest, &name_buf));
    }
};

/// A skeleton check-in manifest (repo-state §6.1: a root carries the propagating
/// branch + sym tags; a child carries a P card and inherits by propagation).
/// `parent` null ⇒ a root check-in with the `trunk` branch tags; non-null ⇒ a
/// child of that parent name. `branch_value`/`extra_tags` let a test place an
/// explicit branch or closed tag. Files are irrelevant to leaf derivation, so
/// there are none.
const CheckinSpec = struct {
    ts: []const u8,
    parent: ?[]const u8 = null,
    /// When set, the root's branch/sym tags use this value instead of "trunk".
    branch_value: []const u8 = "trunk",
    /// Extra T cards appended after the branch tags (e.g. a self closed tag).
    extra_tags: []const artifact.TCard = &.{},
};

fn manifestOf(spec: CheckinSpec, tag_buf: []artifact.TCard, parent_buf: []artifact.PCard) artifact.Manifest {
    const is_root = spec.parent == null;
    var n: usize = 0;
    if (is_root) {
        tag_buf[n] = .{ .sign = .propagate, .name = "branch", .target = null, .value = spec.branch_value };
        n += 1;
    }
    for (spec.extra_tags) |t| {
        tag_buf[n] = t;
        n += 1;
    }
    var p_slice: []const artifact.PCard = &.{};
    if (spec.parent) |parent| {
        parent_buf[0] = .{ .parents = parent, .count = 1 };
        p_slice = parent_buf[0..1];
    }
    return .{
        .arena_state = std.heap.ArenaAllocator.init(testing.allocator),
        .kind = .manifest,
        .a = null,
        .b = null,
        .c = .{ .comment = "c" },
        .d = .{ .timestamp = spec.ts },
        .e = null,
        .g = null,
        .h = null,
        .i = null,
        .k = null,
        .l = null,
        .n = null,
        .r = null,
        .u = .{ .login = "u" },
        .z = null,
        .w = null,
        .f = &.{},
        .j = &.{},
        .m = &.{},
        .p = p_slice,
        .q = &.{},
        .t = tag_buf[0..n],
    };
}

/// Store a check-in built from `spec`, returning its 64-hex name (caller frees).
fn putCheckin(fx: *Fixture, spec: CheckinSpec) ![]u8 {
    var tag_buf: [8]artifact.TCard = undefined;
    var parent_buf: [1]artifact.PCard = undefined;
    var m = manifestOf(spec, &tag_buf, &parent_buf);
    defer m.deinit();
    return fx.put(&m);
}

/// Store a control artifact carrying `tags` (repo-state §4.2/§7.4 tag mutation).
/// `create` classifies a D/T/U artifact with no C card and no parents as a
/// control (tag) artifact.
fn putControl(fx: *Fixture, ts: []const u8, tags: []const artifact.TCard) ![]u8 {
    var m: artifact.Manifest = .{
        .arena_state = std.heap.ArenaAllocator.init(testing.allocator),
        .kind = .control,
        .a = null,
        .b = null,
        .c = null,
        .d = .{ .timestamp = ts },
        .e = null,
        .g = null,
        .h = null,
        .i = null,
        .k = null,
        .l = null,
        .n = null,
        .r = null,
        .u = .{ .login = "u" },
        .z = null,
        .w = null,
        .f = &.{},
        .j = &.{},
        .m = &.{},
        .p = &.{},
        .q = &.{},
        .t = tags,
    };
    defer m.deinit();
    return fx.put(&m);
}

/// Load a view over `fx.log`. Caller pins `view` and `deinit`s it.
fn loadView(view: *RepoView, fx: *Fixture) !void {
    try view.load(testing.allocator, &fx.log);
}

/// Resolve a 64-hex name to its `RepoState` rid via the view's identity space.
fn ridOf(view: *RepoView, name: []const u8) ?repostate.Rid {
    return view.store.store().lookup(name);
}

/// Whether `name` appears among the view's open leaves.
fn openLeafPresent(view: *RepoView, name: []const u8) bool {
    const rid = ridOf(view, name) orelse return false;
    var it = view.state.leaves();
    while (it.next()) |leaf| {
        if (leaf == rid) return view.state.isOpenLeaf(rid);
    }
    return false;
}

test "repoview: a rejected artifact is NAMED, not just reported as a corrupt repository" {
    var tmp = testing.tmpDir(.{});
    defer tmp.cleanup();
    var fx: Fixture = undefined;
    try fx.init(&tmp, testing.io);
    defer fx.deinit();

    const good = try putCheckin(&fx, .{ .ts = "2020-01-01T00:00:00" });
    defer testing.allocator.free(good);
    // Well-formed enough to be stored, unparsable as a timestamp: `parseMtime`
    // wants 19-23 characters once byte 10 is `T`, and this is 16. That is the
    // shape that gets past artifact validation and dies in the derive.
    const bad = try putCheckin(&fx, .{ .ts = "2020-01-01T00:00" });
    defer testing.allocator.free(bad);

    var view: RepoView = undefined;
    view.fault = null;
    if (view.load(testing.allocator, &fx.log)) |_| {
        view.deinit(testing.allocator); // loaded after all: tear down before failing
        return error.TestExpectedCorrupt;
    } else |e| {
        try testing.expectEqual(error.Corrupt, e);
    }

    // The point of the whole mechanism: the walk that found this runs over every
    // artifact in the store — minutes on a real one — so failing without saying
    // WHICH artifact leaves bisecting the store as the only way to find out.
    const f = view.fault orelse return error.TestExpectedFault;
    try testing.expectEqual(RepoView.Fault.Reason.unparsable_timestamp, f.reason);
    try testing.expectEqualStrings(bad, f.artifactName());
}

test "repoview §7.1: a single-commit repo loads its lone root as the open leaf" {
    var tmp = testing.tmpDir(.{});
    defer tmp.cleanup();
    var fx: Fixture = undefined;
    try fx.init(&tmp, testing.io);
    defer fx.deinit();

    const root = try putCheckin(&fx, .{ .ts = "2020-01-01T00:00:00" });
    defer testing.allocator.free(root);

    var view: RepoView = undefined;
    try loadView(&view, &fx);
    defer view.deinit(testing.allocator);

    const rid = ridOf(&view, root).?;
    try testing.expect(view.state.isLeaf(rid)); // §7.1: no same-branch child
    try testing.expect(view.state.isOpenLeaf(rid)); // §7.4: no closed tag
    try testing.expectEqualStrings("trunk", view.state.branchOf(rid, view.main_branch));
}

test "repoview §7.1: a closed leaf stays in leaves() but isOpenLeaf is false (closed derived, not stored)" {
    var tmp = testing.tmpDir(.{});
    defer tmp.cleanup();
    var fx: Fixture = undefined;
    try fx.init(&tmp, testing.io);
    defer fx.deinit();

    // A root that self-closes: an active +closed tag on itself (§7.4).
    const closed = [_]artifact.TCard{.{ .sign = .add, .name = "closed", .target = null, .value = null }};
    const root = try putCheckin(&fx, .{ .ts = "2020-01-01T00:00:00", .extra_tags = &closed });
    defer testing.allocator.free(root);

    var view: RepoView = undefined;
    try loadView(&view, &fx);
    defer view.deinit(testing.allocator);

    const rid = ridOf(&view, root).?;
    try testing.expect(view.state.isLeaf(rid)); // §7.1: still a leaf-table member
    try testing.expect(!view.state.isOpenLeaf(rid)); // §7.4: closed ⇒ not open
}

test "repoview §7.2: a check-in with a same-branch primary child is not a leaf" {
    var tmp = testing.tmpDir(.{});
    defer tmp.cleanup();
    var fx: Fixture = undefined;
    try fx.init(&tmp, testing.io);
    defer fx.deinit();

    const root = try putCheckin(&fx, .{ .ts = "2020-01-01T00:00:00" });
    defer testing.allocator.free(root);
    const child = try putCheckin(&fx, .{ .ts = "2020-01-02T00:00:00", .parent = root });
    defer testing.allocator.free(child);

    var view: RepoView = undefined;
    try loadView(&view, &fx);
    defer view.deinit(testing.allocator);

    try testing.expect(!view.state.isLeaf(ridOf(&view, root).?)); // §7.2: has a same-branch child
    try testing.expect(view.state.isLeaf(ridOf(&view, child).?)); // the tip is the leaf
}

test "repoview §7.2: a same-branch child via a non-primary (merge) P-card disqualifies the parent leaf" {
    var tmp = testing.tmpDir(.{});
    defer tmp.cleanup();
    var fx: Fixture = undefined;
    try fx.init(&tmp, testing.io);
    defer fx.deinit();

    // A root and a second independent root, both on trunk; then a merge child
    // whose PRIMARY parent is `mainline` and whose SECOND (merge, non-primary)
    // parent is `side`. §7.2: the merge edge still disqualifies `side` as a leaf.
    const mainline = try putCheckin(&fx, .{ .ts = "2020-01-01T00:00:00" });
    defer testing.allocator.free(mainline);
    const side = try putCheckin(&fx, .{ .ts = "2020-01-02T00:00:00" });
    defer testing.allocator.free(side);

    // Build the merge child by hand: two P tokens, primary then merge.
    var pbuf: [130]u8 = undefined;
    const joined = try std.fmt.bufPrint(&pbuf, "{s} {s}", .{ mainline, side });
    var m: artifact.Manifest = .{
        .arena_state = std.heap.ArenaAllocator.init(testing.allocator),
        .kind = .manifest,
        .a = null,
        .b = null,
        .c = .{ .comment = "merge" },
        .d = .{ .timestamp = "2020-01-03T00:00:00" },
        .e = null,
        .g = null,
        .h = null,
        .i = null,
        .k = null,
        .l = null,
        .n = null,
        .r = null,
        .u = .{ .login = "u" },
        .z = null,
        .w = null,
        .f = &.{},
        .j = &.{},
        .m = &.{},
        .p = &.{.{ .parents = joined, .count = 2 }},
        .q = &.{},
        .t = &.{},
    };
    defer m.deinit();
    const child = try fx.put(&m);
    defer testing.allocator.free(child);

    var view: RepoView = undefined;
    try loadView(&view, &fx);
    defer view.deinit(testing.allocator);

    // Both parents are on trunk (default fallback); the merge child is on trunk.
    // §7.2: neither parent is a leaf — `side` is reached only via a merge edge.
    try testing.expect(!view.state.isLeaf(ridOf(&view, mainline).?));
    try testing.expect(!view.state.isLeaf(ridOf(&view, side).?));
    try testing.expect(view.state.isLeaf(ridOf(&view, child).?));
}

test "repoview §7.3: open builds the full leaf table over every crosslinked check-in" {
    var tmp = testing.tmpDir(.{});
    defer tmp.cleanup();
    var fx: Fixture = undefined;
    try fx.init(&tmp, testing.io);
    defer fx.deinit();

    // A linear chain of three: only the tip is a leaf; the two interior
    // check-ins are excluded because each has a same-branch child (§7.3 full
    // rebuild over every check-in, roots included).
    const a = try putCheckin(&fx, .{ .ts = "2020-01-01T00:00:00" });
    defer testing.allocator.free(a);
    const b = try putCheckin(&fx, .{ .ts = "2020-01-02T00:00:00", .parent = a });
    defer testing.allocator.free(b);
    const c = try putCheckin(&fx, .{ .ts = "2020-01-03T00:00:00", .parent = b });
    defer testing.allocator.free(c);

    var view: RepoView = undefined;
    try loadView(&view, &fx);
    defer view.deinit(testing.allocator);

    var count: usize = 0;
    var it = view.state.leaves();
    while (it.next()) |_| count += 1;
    try testing.expectEqual(@as(usize, 1), count);
    try testing.expect(view.state.isLeaf(ridOf(&view, c).?));
}

test "repoview §7.3: a root check-in with no parent loads as a leaf" {
    var tmp = testing.tmpDir(.{});
    defer tmp.cleanup();
    var fx: Fixture = undefined;
    try fx.init(&tmp, testing.io);
    defer fx.deinit();

    // Two independent roots (no parent at all): §7.3 says a parentless check-in
    // is a leaf when it has no same-branch child, and MUST be included.
    const r1 = try putCheckin(&fx, .{ .ts = "2020-01-01T00:00:00" });
    defer testing.allocator.free(r1);
    const r2 = try putCheckin(&fx, .{ .ts = "2020-01-02T00:00:00" });
    defer testing.allocator.free(r2);

    var view: RepoView = undefined;
    try loadView(&view, &fx);
    defer view.deinit(testing.allocator);

    try testing.expect(view.state.isLeaf(ridOf(&view, r1).?));
    try testing.expect(view.state.isLeaf(ridOf(&view, r2).?));
}

test "repoview §7.4: a leaf without a closed tag is open; a control artifact closes it" {
    var tmp = testing.tmpDir(.{});
    defer tmp.cleanup();
    var fx: Fixture = undefined;
    try fx.init(&tmp, testing.io);
    defer fx.deinit();

    const root = try putCheckin(&fx, .{ .ts = "2020-01-01T00:00:00" });
    defer testing.allocator.free(root);

    // Open before any closed tag.
    {
        var view: RepoView = undefined;
        try loadView(&view, &fx);
        defer view.deinit(testing.allocator);
        try testing.expect(view.state.isOpenLeaf(ridOf(&view, root).?)); // §7.4
    }

    // A control artifact applies an active +closed tag to the leaf (§7.4: the
    // closed status is an active tag-cross-reference row, tagtype > 0).
    const close = [_]artifact.TCard{.{ .sign = .add, .name = "closed", .target = root, .value = null }};
    const ctl = try putControl(&fx, "2020-01-02T00:00:00", &close);
    defer testing.allocator.free(ctl);

    var view: RepoView = undefined;
    try loadView(&view, &fx);
    defer view.deinit(testing.allocator);
    const rid = ridOf(&view, root).?;
    try testing.expect(view.state.isLeaf(rid)); // still a leaf
    try testing.expect(!view.state.isOpenLeaf(rid)); // §7.4: now closed
}

test "repoview §7.4: a cancel (-closed) control reopens a previously closed leaf" {
    var tmp = testing.tmpDir(.{});
    defer tmp.cleanup();
    var fx: Fixture = undefined;
    try fx.init(&tmp, testing.io);
    defer fx.deinit();

    const root = try putCheckin(&fx, .{ .ts = "2020-01-01T00:00:00" });
    defer testing.allocator.free(root);
    const close = [_]artifact.TCard{.{ .sign = .add, .name = "closed", .target = root, .value = null }};
    const ctl_close = try putControl(&fx, "2020-01-02T00:00:00", &close);
    defer testing.allocator.free(ctl_close);
    // A later cancel row (tagtype 0) removes the active closed marker (§7.4:
    // closed is only "an assignment, not a cancellation").
    const reopen = [_]artifact.TCard{.{ .sign = .cancel, .name = "closed", .target = root, .value = null }};
    const ctl_open = try putControl(&fx, "2020-01-03T00:00:00", &reopen);
    defer testing.allocator.free(ctl_open);

    var view: RepoView = undefined;
    try loadView(&view, &fx);
    defer view.deinit(testing.allocator);
    try testing.expect(view.state.isOpenLeaf(ridOf(&view, root).?)); // reopened
}

test "repoview §7.5: two open leaves on one branch both load (ambiguous tip)" {
    var tmp = testing.tmpDir(.{});
    defer tmp.cleanup();
    var fx: Fixture = undefined;
    try fx.init(&tmp, testing.io);
    defer fx.deinit();

    // A root with two children on the same branch: a fork. §7.5: both children
    // are open leaves of trunk, so each is an ambiguous leaf of the other.
    const root = try putCheckin(&fx, .{ .ts = "2020-01-01T00:00:00" });
    defer testing.allocator.free(root);
    const c1 = try putCheckin(&fx, .{ .ts = "2020-01-02T00:00:00", .parent = root });
    defer testing.allocator.free(c1);
    const c2 = try putCheckin(&fx, .{ .ts = "2020-01-03T00:00:00", .parent = root });
    defer testing.allocator.free(c2);

    var view: RepoView = undefined;
    try loadView(&view, &fx);
    defer view.deinit(testing.allocator);

    const r1 = ridOf(&view, c1).?;
    const r2 = ridOf(&view, c2).?;
    try testing.expect(view.state.isOpenLeaf(r1));
    try testing.expect(view.state.isOpenLeaf(r2));
    try testing.expect(!view.state.isLeaf(ridOf(&view, root).?)); // parent forked
    try testing.expect(view.state.isAmbiguousLeaf(r1)); // §7.5: another open leaf on the branch
    try testing.expect(view.state.isAmbiguousLeaf(r2));
}

test "repoview §6.1: the configured main-branch names the default branch; absent config falls back to trunk" {
    var tmp = testing.tmpDir(.{});
    defer tmp.cleanup();

    // (a) No config frame: a tagless check-in falls back to "trunk". But our
    // root carries a propagating branch tag, so assert on a value-carrying root
    // named "trunk" and on the fallback via main_branch.
    {
        var fx: Fixture = undefined;
        try fx.init(&tmp, testing.io);
        defer fx.deinit();
        const root = try putCheckin(&fx, .{ .ts = "2020-01-01T00:00:00" });
        defer testing.allocator.free(root);
        var view: RepoView = undefined;
        try loadView(&view, &fx);
        defer view.deinit(testing.allocator);
        try testing.expectEqualStrings("trunk", view.main_branch); // §6.1: unset ⇒ trunk
    }

    // (b) A config frame naming a different main-branch: the loader reads it and
    // threads it into main_branch (§6.1: MUST read the configured value).
    {
        var fx: Fixture = undefined;
        try fx.init(&tmp, testing.io);
        defer fx.deinit();
        try fx.log.putConfig(.{ .hash_policy = 0, .default_branch = "mainline", .project_id = "p" });
        try fx.log.commit();
        // A check-in with NO branch tag (a bare control-free child is awkward, so
        // use a root but assert the fallback name only when a tagless check-in is
        // present). Here assert on main_branch itself — the §6.1 configured value.
        const root = try putCheckin(&fx, .{ .ts = "2020-01-01T00:00:00", .branch_value = "mainline" });
        defer testing.allocator.free(root);
        var view: RepoView = undefined;
        try loadView(&view, &fx);
        defer view.deinit(testing.allocator);
        try testing.expectEqualStrings("mainline", view.main_branch); // §6.1: configured value
        try testing.expectEqualStrings("mainline", view.state.branchOf(ridOf(&view, root).?, view.main_branch));
    }
}

test "repoview §6.1: a cancelled branch tag falls back to the configured main branch (tagtype>0 filter)" {
    var tmp = testing.tmpDir(.{});
    defer tmp.cleanup();
    var fx: Fixture = undefined;
    try fx.init(&tmp, testing.io);
    defer fx.deinit();

    // A root on trunk, then a control that CANCELS its branch tag. §6.1: only an
    // active (tagtype > 0) branch tag names a branch; a cancelled one falls back
    // to the default branch exactly like a missing one.
    const root = try putCheckin(&fx, .{ .ts = "2020-01-01T00:00:00", .branch_value = "feature" });
    defer testing.allocator.free(root);
    const cancel = [_]artifact.TCard{.{ .sign = .cancel, .name = "branch", .target = root, .value = "feature" }};
    const ctl = try putControl(&fx, "2020-01-02T00:00:00", &cancel);
    defer testing.allocator.free(ctl);

    var view: RepoView = undefined;
    try loadView(&view, &fx);
    defer view.deinit(testing.allocator);
    // The cancelled branch tag is inactive, so branchOf returns the fallback.
    try testing.expectEqualStrings("trunk", view.state.branchOf(ridOf(&view, root).?, view.main_branch));
}

test "repoview §6.1: a check-in's absent P-card parent is an eventless phantom" {
    var tmp = testing.tmpDir(.{});
    defer tmp.cleanup();
    var fx: Fixture = undefined;
    try fx.init(&tmp, testing.io);
    defer fx.deinit();

    // A child whose P card names a parent hash never stored: the loader interns
    // that name as a phantom rid with no event row. §6.1: such a rid has no
    // mtime, so it is not a resolvable tip — the guard predicate a checkout-mark
    // resolver relies on (mtimeOf == null ⇒ nothing to mark).
    const absent = "a" ** 64;
    const child = try putCheckin(&fx, .{ .ts = "2020-01-01T00:00:00", .parent = absent });
    defer testing.allocator.free(child);

    var view: RepoView = undefined;
    try loadView(&view, &fx);
    defer view.deinit(testing.allocator);

    const phantom = view.lookup(absent) orelse return error.PhantomNotInterned;
    try testing.expect(view.state.mtimeOf(phantom) == null); // eventless
    try testing.expect(view.state.mtimeOf(ridOf(&view, child).?) != null); // child bears an event
}

test "repoview §7.7: a closed non-leaf does not appear among open leaves (enumeration ≠ name resolution)" {
    var tmp = testing.tmpDir(.{});
    defer tmp.cleanup();
    var fx: Fixture = undefined;
    try fx.init(&tmp, testing.io);
    defer fx.deinit();

    // A root, then a child that is the tip. Close the root (a non-leaf). §7.7:
    // leaf enumeration answers "no same-branch descendant and still open"; the
    // closed root is neither a leaf nor an open leaf regardless of any name it
    // might resolve.
    const root = try putCheckin(&fx, .{ .ts = "2020-01-01T00:00:00" });
    defer testing.allocator.free(root);
    const child = try putCheckin(&fx, .{ .ts = "2020-01-02T00:00:00", .parent = root });
    defer testing.allocator.free(child);
    const close = [_]artifact.TCard{.{ .sign = .add, .name = "closed", .target = root, .value = null }};
    const ctl = try putControl(&fx, "2020-01-03T00:00:00", &close);
    defer testing.allocator.free(ctl);

    var view: RepoView = undefined;
    try loadView(&view, &fx);
    defer view.deinit(testing.allocator);

    try testing.expect(!openLeafPresent(&view, root)); // not a leaf at all
    try testing.expect(openLeafPresent(&view, child)); // the actual open leaf
}

test "repoview: a non-artifact file blob is skipped, not an error" {
    var tmp = testing.tmpDir(.{});
    defer tmp.cleanup();
    var fx: Fixture = undefined;
    try fx.init(&tmp, testing.io);
    defer fx.deinit();

    // Store raw non-artifact bytes (a file blob) alongside one real check-in.
    // The loader skips the blob (parse fails) and still derives the check-in.
    const blob = "this is not a manifest\n";
    var bdigest: [32]u8 = undefined;
    std.crypto.hash.sha3.Sha3_256.hash(blob, &bdigest, .{});
    _ = try fx.log.put(&bdigest, blob);
    const root = try putCheckin(&fx, .{ .ts = "2020-01-01T00:00:00" });
    defer testing.allocator.free(root);

    var view: RepoView = undefined;
    try loadView(&view, &fx);
    defer view.deinit(testing.allocator);
    try testing.expect(view.state.isOpenLeaf(ridOf(&view, root).?));
}

test "repoview: load is leak-free under every allocation failure" {
    var tmp = testing.tmpDir(.{});
    defer tmp.cleanup();
    var fx: Fixture = undefined;
    try fx.init(&tmp, testing.io);
    defer fx.deinit();

    const root = try putCheckin(&fx, .{ .ts = "2020-01-01T00:00:00" });
    defer testing.allocator.free(root);
    const child = try putCheckin(&fx, .{ .ts = "2020-01-02T00:00:00", .parent = root });
    defer testing.allocator.free(child);
    const close = [_]artifact.TCard{.{ .sign = .add, .name = "closed", .target = child, .value = null }};
    const ctl = try putControl(&fx, "2020-01-03T00:00:00", &close);
    defer testing.allocator.free(ctl);

    const Ctx = struct {
        fx: *Fixture,
        fn run(a: std.mem.Allocator, self: @This()) !void {
            var view: RepoView = undefined;
            try view.load(a, &self.fx.log);
            view.deinit(a);
        }
    };
    try testing.checkAllAllocationFailures(testing.allocator, Ctx.run, .{Ctx{ .fx = &fx }});
}

test "repoview fuzz: load never crashes on adversarial or mutated artifact bytes" {
    // Invariant (repo-state §7): building a view over arbitrary persisted bytes
    // returns only a declared LoadError or a consistent state — never a crash,
    // out-of-bounds read, or unhandled trap. Non-artifact frames are skipped;
    // a structurally corrupt check-in is a declared error.
    var prng = std.Random.DefaultPrng.init(0x1eaf5eed);
    const rand = prng.random();

    // A valid baseline manifest to mutate.
    var base_tag: [8]artifact.TCard = undefined;
    var base_p: [1]artifact.PCard = undefined;
    var base_m = manifestOf(.{ .ts = "2020-01-01T00:00:00" }, &base_tag, &base_p);
    const base = try artifact.create(&base_m, testing.allocator);
    base_m.deinit();
    defer testing.allocator.free(base);

    var round: usize = 0;
    while (round < 256) : (round += 1) {
        var tmp = testing.tmpDir(.{});
        defer tmp.cleanup();
        var fx: Fixture = undefined;
        try fx.init(&tmp, testing.io);
        defer fx.deinit();

        // Put a handful of frames: some random bytes, some byte-flipped copies of
        // the valid baseline. Each is stored under its own digest.
        var k: usize = 0;
        const frames = rand.intRangeAtMost(usize, 1, 5);
        while (k < frames) : (k += 1) {
            var payload: std.ArrayList(u8) = .empty;
            defer payload.deinit(testing.allocator);
            if (rand.boolean()) {
                const len = rand.intRangeAtMost(usize, 1, 128);
                try payload.resize(testing.allocator, len);
                rand.bytes(payload.items);
            } else {
                try payload.appendSlice(testing.allocator, base);
                const flips = rand.intRangeAtMost(usize, 1, 6);
                var f: usize = 0;
                while (f < flips) : (f += 1) {
                    const idx = rand.intRangeLessThan(usize, 0, payload.items.len);
                    payload.items[idx] ^= rand.int(u8);
                }
            }
            var digest: [32]u8 = undefined;
            std.crypto.hash.sha3.Sha3_256.hash(payload.items, &digest, .{});
            _ = fx.log.put(&digest, payload.items) catch continue;
        }

        var view: RepoView = undefined;
        view.load(testing.allocator, &fx.log) catch |e| {
            // Only the declared, closed error set may surface; a failed load has
            // already torn itself down, so no deinit here.
            switch (e) {
                error.OutOfMemory, error.Storage, error.Corrupt => {},
            }
            continue;
        };
        view.deinit(testing.allocator);
    }
}

test "repoview §1: a captured record body replays to a view equal to from-scratch load" {
    var tmp = testing.tmpDir(.{});
    defer tmp.cleanup();
    var fx: Fixture = undefined;
    try fx.init(&tmp, testing.io);
    defer fx.deinit();

    // A root, a same-branch child, a fork, and a control close — enough shape to
    // exercise leaves, propagation, and tags in the round-trip.
    const root = try putCheckin(&fx, .{ .ts = "2020-01-01T00:00:00" });
    defer testing.allocator.free(root);
    const child = try putCheckin(&fx, .{ .ts = "2020-01-02T00:00:00", .parent = root });
    defer testing.allocator.free(child);
    const fork = try putCheckin(&fx, .{ .ts = "2020-01-03T00:00:00", .parent = root });
    defer testing.allocator.free(fork);
    const close = [_]artifact.TCard{.{ .sign = .add, .name = "closed", .target = fork, .value = null }};
    const ctl = try putControl(&fx, "2020-01-04T00:00:00", &close);
    defer testing.allocator.free(ctl);

    // Reference: a plain from-scratch derivation.
    var ref: RepoView = undefined;
    try ref.load(testing.allocator, &fx.log);
    defer ref.deinit(testing.allocator);

    // Capture the record body, then replay it WITHOUT the log.
    var body: std.ArrayList(u8) = .empty;
    defer body.deinit(testing.allocator);
    var cap: RepoView = undefined;
    try cap.loadCapturing(testing.allocator, &fx.log, &body);
    cap.deinit(testing.allocator); // we only needed the body

    var replay: RepoView = undefined;
    try replay.buildFromRecordBody(testing.allocator, body.items, ref.main_branch);
    defer replay.deinit(testing.allocator);

    // §1: the replayed view is observably identical — same rids (same intern
    // order), leaves, open/closed status, branches, and mtimes.
    try testing.expectEqualStrings(ref.main_branch, replay.main_branch);
    try testing.expectEqual(ref.store.store().count(), replay.store.store().count());
    var lr = ref.state.leaves();
    var lp = replay.state.leaves();
    while (lr.next()) |a| {
        const b = lp.next() orelse return error.LeafCountMismatch;
        try testing.expectEqual(a, b);
        try testing.expectEqual(ref.state.isOpenLeaf(a), replay.state.isOpenLeaf(b));
        try testing.expectEqualStrings(ref.state.branchOf(a, ref.main_branch), replay.state.branchOf(b, replay.main_branch));
    }
    try testing.expect(lp.next() == null);
    var rid: repostate.Rid = 1;
    const count = ref.store.store().count();
    while (rid <= count) : (rid += 1) {
        try testing.expectEqualStrings(ref.hashOf(rid), replay.hashOf(rid));
        try testing.expectEqual(ref.state.mtimeOf(rid), replay.state.mtimeOf(rid));
        try testing.expectEqual(ref.state.isLeaf(rid), replay.state.isLeaf(rid));
    }
}

test "repoview: buildFromRecordBody is leak-free under every allocation failure" {
    var tmp = testing.tmpDir(.{});
    defer tmp.cleanup();
    var fx: Fixture = undefined;
    try fx.init(&tmp, testing.io);
    defer fx.deinit();

    const root = try putCheckin(&fx, .{ .ts = "2020-01-01T00:00:00" });
    defer testing.allocator.free(root);
    const child = try putCheckin(&fx, .{ .ts = "2020-01-02T00:00:00", .parent = root });
    defer testing.allocator.free(child);
    const close = [_]artifact.TCard{.{ .sign = .add, .name = "closed", .target = child, .value = null }};
    const ctl = try putControl(&fx, "2020-01-03T00:00:00", &close);
    defer testing.allocator.free(ctl);

    // Capture a valid body once with the real allocator; then replay it under
    // injected OOM at every allocation site.
    var body: std.ArrayList(u8) = .empty;
    defer body.deinit(testing.allocator);
    var cap: RepoView = undefined;
    try cap.loadCapturing(testing.allocator, &fx.log, &body);
    cap.deinit(testing.allocator);

    const Ctx = struct {
        body: []const u8,
        fn run(a: std.mem.Allocator, self: @This()) !void {
            var view: RepoView = undefined;
            view.buildFromRecordBody(a, self.body, "trunk") catch |e| switch (e) {
                error.OutOfMemory => return error.OutOfMemory,
                error.Corrupt => unreachable, // a valid body never decodes as corrupt
            };
            view.deinit(a);
        }
    };
    try testing.checkAllAllocationFailures(testing.allocator, Ctx.run, .{Ctx{ .body = body.items }});
}