Skip to content
zit Artifact [b79b3b64c6]
Login

Artifact [b79b3b64c6]

Artifact b79b3b64c666f677dc3f73710b9cb6c358372550c8090fbf8c7301549ea3d9de:


//! The durable rebuild checkpoint (hybrid-history acceleration).
//!
//! A `.zit-cache/checkpoint` sidecar records one prefix-anchored claim: "as of
//! check-in C, whose frame closes the log prefix `[0, prefix_key.end)`, the
//! fully-verified file set fingerprints to this value" — plus the crosslink
//! record body for that prefix, so `Repository.open`'s miss path can
//! `RepoView.resumeFrom` the prefix instead of re-parsing a permanently flat
//! F-card segment on every from-scratch derive (submanifests §7.4 applied to
//! the segment §7.4 cannot reach).
//!
//! SIDECAR ONLY, by design: a checkpoint never writes the log. An earlier
//! revision had a `tree` shape that pinned the anchor's file set as stored
//! SUBMANIFEST artifacts; it was retired because everything it bought is had
//! for free elsewhere — the rebuild win lives entirely in the prefix record,
//! `verify` recomputes the canonical tree in memory as its oracle, and the
//! §6.2 subtree-sharing bytes re-materialize at the first real S-emitting
//! commit at identical cost, attributed to a commit somebody actually made.
//! Minting them early only created permanent, unreferenced artifacts and the
//! policy apparatus to keep automation from doing it by accident.
//!
//! Advisory-tier all the way down, exactly like `repocache.zig`, on which the
//! container is modelled: deleting the file changes no answer, every
//! validation failure is a MISS (never an error), `--no-cache` bypasses it,
//! and the claim is independently re-derivable (`verify`). Strictly local,
//! never synced, never history: the sidecar appears in no artifact and
//! crosses no wire.

const std = @import("std");
const artifact = @import("artifact.zig");
const logstore = @import("logstore.zig");
const repocache = @import("repocache.zig");
const repository = @import("repository.zig");
const repostate = @import("repostate.zig");
const repoview = @import("repoview.zig");
const storage = @import("storage.zig");
const crc32 = @import("crc32.zig");

/// The sidecar's member inside the advisory `.zit-cache/` directory (the home
/// the consolidation reserved for it). Ignore-glob'd and never committed;
/// disposable by construction.
pub const checkpoint_filename: []const u8 = repocache.cache_dirname ++ "/checkpoint";

const magic = "ZITCKPNT";
// v2 carried the retired `tree` shape's fields; v3 is witness-only and drops
// them from the layout. An old sidecar misses and is rebuilt (or advanced)
// on the next explicit build or healing open. The body it replays is a run
// of crosslink records in `repocache`'s wire format, so this bumps whenever
// that record format does -- v5 with the per-file hash (zit#176); an old
// body replayed under a new reader would read as a corrupt cache.
const format_version: u16 = 5;

/// The sidecar's identity claim, exactly what the header stores. The hash
/// slice borrows the load's backing buffer (`Loaded.buf`).
pub const Claim = struct {
    /// The check-in whose frame closes the prefix.
    checkin_hash: []const u8,
    /// `artifact.fileSetFingerprint` over C's complete file set — the cheap
    /// structural witness `verify` re-derives from real history.
    fileset_fingerprint: [32]u8,
    /// The log prefix the record body distills; `logstore.validatePrefix` is
    /// the O(1) revalidation on every load.
    prefix_key: logstore.LogStore.LogKey,
    /// The shun-table fingerprint at capture: a record body distilled under an
    /// old mask may carry a record the mask must now hide, so any difference
    /// is a MISS (the same rule `.zit-cache` extension follows).
    shun_fp: u32,
};

/// Bytes before the variable spans:
/// `magic(8) | format_version(2) | reserved(2) | prefix_end(8) |
/// prefix_frame_count(8) | prefix_last_frame_offset(8) |
/// prefix_last_header_crc(4) | shun_fp(4) | checkin_hash_len(1) |
/// fileset_fingerprint(32) | main_branch_len(2) | body_len(4)`.
const fixed_prefix_len: usize = 8 + 2 + 2 + 8 + 8 + 8 + 4 + 4 + 1 + 32 + 2 + 4; // = 83
const checksum_len: usize = 4;

/// A validated load: `claim`, `main_branch` and `body` slice into `buf`.
pub const Loaded = struct {
    buf: []u8,
    claim: Claim,
    main_branch: []const u8,
    body: []const u8,

    pub fn deinit(self: *Loaded, gpa: std.mem.Allocator) void {
        gpa.free(self.buf);
        self.* = undefined;
    }
};

/// Evidence seam for the paranoid-bypass tests: every `tryLoad` entry counts,
/// BEFORE the sidecar is opened. `--no-cache` must leave this untouched — the
/// promise is "trust nothing derived", and a bypass that still consults the
/// sidecar is not one. Single-threaded like every derive path.
pub var tryload_consults: u64 = 0;

/// Read and fully validate the sidecar against the LIVE log: magic, version,
/// self-checksum over every pre-checksum byte, structural spans that exactly
/// account for the file size, and a prefix key that still validates
/// (`validatePrefix` — the log may have GROWN, which is the whole point;
/// truncation or a rewritten boundary is a miss). Returns null on ANY failure
/// — a checkpoint is advisory, so an unreadable one just means "derive from
/// the log". The caller still owns the shun-fingerprint compare (it needs an
/// open scan) and the anchor-resolution check.
pub fn tryLoad(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, log_file: storage.StorageFile) ?Loaded {
    tryload_consults += 1;
    var file = dir.openFile(io, checkpoint_filename, .{}) catch return null;
    defer file.close(io);
    const st = file.stat(io) catch return null;
    const size = std.math.cast(usize, st.size) orelse return null;
    if (size < fixed_prefix_len + checksum_len) return null;

    const buf = gpa.alloc(u8, size) catch return null;
    const n = file.readPositionalAll(io, buf, 0) catch {
        gpa.free(buf);
        return null;
    };
    if (n != size) {
        gpa.free(buf);
        return null;
    }
    const loaded = parse(buf) orelse {
        gpa.free(buf);
        return null;
    };
    if (!logstore.LogStore.validatePrefix(log_file, loaded.claim.prefix_key)) {
        gpa.free(buf);
        return null;
    }
    return loaded;
}

/// The pure structural half of `tryLoad`, split out so corruption tests can
/// drive it byte-by-byte. `buf` is the whole file; the returned slices borrow
/// it. Null on any framing violation — never partial trust.
fn parse(buf: []u8) ?Loaded {
    if (buf.len < fixed_prefix_len + checksum_len) return null;
    if (!std.mem.eql(u8, buf[0..8], magic)) return null;
    if (std.mem.readInt(u16, buf[8..10], .little) != format_version) return null;

    // The whole-file checksum first: no declared length below is trusted
    // before the bytes carrying it prove intact.
    const stored_crc = std.mem.readInt(u32, buf[buf.len - checksum_len ..][0..4], .little);
    var crc = crc32.Crc32.init();
    crc.update(buf[0 .. buf.len - checksum_len]);
    if (crc.final() != stored_crc) return null;

    if (std.mem.readInt(u16, buf[10..12], .little) != 0) return null; // reserved

    const key: logstore.LogStore.LogKey = .{
        .end = std.mem.readInt(u64, buf[12..20], .little),
        .frame_count = std.mem.readInt(u64, buf[20..28], .little),
        .last_frame_offset = std.mem.readInt(u64, buf[28..36], .little),
        .last_header_crc = std.mem.readInt(u32, buf[36..40], .little),
    };
    const shun_fp = std.mem.readInt(u32, buf[40..44], .little);
    const ckh_len: usize = buf[44];
    var fp: [32]u8 = undefined;
    @memcpy(&fp, buf[45..77]);
    const mb_len: usize = std.mem.readInt(u16, buf[77..79], .little);
    const body_len: usize = std.mem.readInt(u32, buf[79..83], .little);

    // Artifact names are 40 or 64 lowercase hex.
    if (ckh_len != 40 and ckh_len != 64) return null;
    if (mb_len == 0) return null;

    // Exact byte accounting: the declared spans plus framing must equal the
    // file, so neither truncation nor trailing garbage survives.
    const want = fixed_prefix_len + ckh_len + mb_len + body_len + checksum_len;
    if (want != buf.len) return null;

    var pos: usize = fixed_prefix_len;
    const ckh = buf[pos..][0..ckh_len];
    pos += ckh_len;
    const mb = buf[pos..][0..mb_len];
    pos += mb_len;
    const body = buf[pos..][0..body_len];

    return .{
        .buf = buf,
        .claim = .{
            .checkin_hash = ckh,
            .fileset_fingerprint = fp,
            .prefix_key = key,
            .shun_fp = shun_fp,
        },
        .main_branch = mb,
        .body = body,
    };
}

/// Atomically materialize the sidecar (temp + fsync + rename, the sequence
/// every sibling writer uses). Every failure is swallowed: a checkpoint is an
/// optimization, never a correctness dependency, so the worst outcome of a
/// failed write is the derive it would have skipped.
pub fn store(
    gpa: std.mem.Allocator,
    io: std.Io,
    dir: std.Io.Dir,
    claim: Claim,
    main_branch: []const u8,
    body: []const u8,
) void {
    std.debug.assert(claim.checkin_hash.len == 40 or claim.checkin_hash.len == 64);
    std.debug.assert(main_branch.len != 0);
    if (!repocache.ensureCacheDir(io, dir)) return;

    var out: std.ArrayList(u8) = .empty;
    defer out.deinit(gpa);
    appendAll(gpa, &out, claim, main_branch, body) catch return;

    var af = dir.createFileAtomic(io, checkpoint_filename, .{ .replace = true }) catch return;
    defer af.deinit(io);
    af.file.writePositionalAll(io, out.items, 0) catch return;
    af.file.sync(io) catch return;
    af.replace(io) catch return;
}

fn appendAll(
    gpa: std.mem.Allocator,
    out: *std.ArrayList(u8),
    claim: Claim,
    main_branch: []const u8,
    body: []const u8,
) error{OutOfMemory}!void {
    try out.appendSlice(gpa, magic);
    try appendInt(gpa, out, u16, format_version);
    try appendInt(gpa, out, u16, 0); // reserved
    try appendInt(gpa, out, u64, claim.prefix_key.end);
    try appendInt(gpa, out, u64, claim.prefix_key.frame_count);
    try appendInt(gpa, out, u64, claim.prefix_key.last_frame_offset);
    try appendInt(gpa, out, u32, claim.prefix_key.last_header_crc);
    try appendInt(gpa, out, u32, claim.shun_fp);
    try out.append(gpa, @intCast(claim.checkin_hash.len));
    try out.appendSlice(gpa, &claim.fileset_fingerprint);
    try appendInt(gpa, out, u16, @intCast(main_branch.len));
    try appendInt(gpa, out, u32, @intCast(body.len));
    try out.appendSlice(gpa, claim.checkin_hash);
    try out.appendSlice(gpa, main_branch);
    try out.appendSlice(gpa, body);
    var crc = crc32.Crc32.init();
    crc.update(out.items);
    try appendInt(gpa, out, u32, crc.final());
    std.debug.assert(out.items.len == fixed_prefix_len + claim.checkin_hash.len +
        main_branch.len + body.len + checksum_len);
}

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

/// Delete the sidecar. A missing file is success, not an error — the file is
/// disposable by construction (`zit checkpoint drop`).
pub fn drop(io: std.Io, dir: std.Io.Dir) void {
    dir.deleteFile(io, checkpoint_filename) catch {};
}

// ---------------------------------------------------------------------------
// The builder. One pass, sidecar only: enumerate C's file set through the
// §9.3 seam, fingerprint it, capture the prefix record body (reusing an
// existing sidecar's body and its delta-free attestation when possible), and
// atomically write the sidecar. The log is opened READ-ONLY — a checkpoint
// build cannot write history, structurally.
// ---------------------------------------------------------------------------

pub const BuildError = error{
    OutOfMemory,
    Storage,
    Corrupt,
    /// `C` is absent from the log, interned only as a phantom, or (for the
    /// null-checkin default) the repository has no leaves to anchor on.
    CheckinNotFound,
    /// `C` names an artifact that is not a check-in manifest.
    NotACheckin,
};

pub const BuildReport = struct {
    checkin_hash_buf: [64]u8,
    checkin_hash_len: u8,
    files: usize,
    prefix_frames: u64,

    pub fn checkinHash(self: *const BuildReport) []const u8 {
        return self.checkin_hash_buf[0..self.checkin_hash_len];
    }
};

/// Build (or rebuild) the checkpoint for `repo` anchored at `checkin` (a full
/// lowercase-hex check-in name), or at the NEWEST crosslinked check-in when
/// null. Ancestry plays no role: a resume replays the record body below the
/// prefix boundary and parses the tail above it, which is correct for any
/// record-boundary prefix whatever the open branches look like — the old
/// merge-base-of-all-leaves anchor collapsed to a near-root commit (a
/// two-frame prefix) on any repository with one dormant branch leaf, i.e. on
/// every real imported fossil history. Idempotent: rebuilding over unchanged
/// history rewrites an identical sidecar and touches nothing else.
pub fn build(
    gpa: std.mem.Allocator,
    io: std.Io,
    repo: *repository.Repository,
    dir: std.Io.Dir,
    checkin: ?[]const u8,
) BuildError!BuildReport {
    // 1. C: named, or the newest crosslinked check-in — maximal coverage.
    const c_rid: repostate.Rid = blk: {
        if (checkin) |hex| break :blk repo.view.lookup(hex) orelse return error.CheckinNotFound;
        break :blk repo.view.state.newestCheckin() orelse return error.CheckinNotFound;
    };

    // 3. C's file set, through the one §9.3 seam (`Repository.files` walks
    //    trees via `flattenIfTree`; no raw F-card iteration here).
    var fl = (repo.files(gpa, c_rid) catch |e| return mapFilesError(e)) orelse
        return error.CheckinNotFound; // interned phantom: no content to enumerate
    defer fl.deinit(gpa);
    var entries: std.ArrayList(artifact.FCard) = .empty;
    defer entries.deinit(gpa);
    var files_it = fl.iterator();
    while (files_it.next()) |f| try entries.append(gpa, f);
    const fingerprint = try artifact.fileSetFingerprint(gpa, entries.items);

    // 4. The log, read-only: a checkpoint build cannot write history.
    var file = dir.openFile(io, repository.repo_filename, .{}) catch return error.Storage;
    defer file.close(io);
    const st = file.stat(io) catch return error.Storage;
    var fs = storage.FileStorage.init(io, file, st.size);
    var log = logstore.LogStore.openReadOnly(gpa, fs.storageFile()) catch |e| return switch (e) {
        error.OutOfMemory => error.OutOfMemory,
        error.Storage => error.Storage,
        error.Corrupt => error.Corrupt,
    };
    defer log.deinit(gpa);

    // 5. C's frame closes the prefix.
    const c_hex = repo.view.hashOf(c_rid);
    var digest: [logstore.max_hash_len]u8 = undefined;
    const c_raw = digest[0 .. c_hex.len / 2];
    _ = std.fmt.hexToBytes(c_raw, c_hex) catch return error.Corrupt;
    const c_logrid = log.lookup(c_raw) orelse return error.CheckinNotFound;
    const key = log.logKeyAt(c_logrid) catch |e| return switch (e) {
        error.Storage => error.Storage,
        error.Corrupt => error.Corrupt,
    };

    // 6. The prefix record body, from the cheapest source. ADVANCE: a valid
    //    existing sidecar whose prefix is a prefix of ours already carries
    //    [0, L0); only the tail (L0, L) is parsed. COLD: capture the whole
    //    prefix view-free. Delta (B-card) manifests are captured like any
    //    other: records carry no file data, and the fingerprint's file-set
    //    resolution reads through baselines the same way every file read
    //    does.
    var body: std.ArrayList(u8) = .empty;
    defer body.deinit(gpa);
    var advanced_from: ?u64 = null;
    reuse: {
        var old = tryLoad(gpa, io, dir, fs.storageFile()) orelse break :reuse;
        defer old.deinit(gpa);
        if (old.claim.shun_fp != log.shunFingerprint()) break :reuse;
        if (old.claim.prefix_key.end > key.end) break :reuse; // anchor moved backward: rebuild cold
        body.appendSlice(gpa, old.body) catch return error.OutOfMemory;
        advanced_from = old.claim.prefix_key.end;
    }
    const capture_from: u64 = advanced_from orelse logstore.header_len;
    repoview.captureRecords(gpa, &log, capture_from, key.end, &body) catch |e| return switch (e) {
        error.OutOfMemory => error.OutOfMemory,
        error.Storage => error.Storage,
        error.Corrupt => error.Corrupt,
    };

    var report: BuildReport = .{
        .checkin_hash_buf = undefined,
        .checkin_hash_len = @intCast(c_hex.len),
        .files = entries.items.len,
        .prefix_frames = key.frame_count,
    };
    @memcpy(report.checkin_hash_buf[0..c_hex.len], c_hex);

    // 7. The sidecar, last. `store` swallows failures: the checkpoint is an
    //    optimization, and nothing durable preceded it.
    store(gpa, io, dir, .{
        .checkin_hash = c_hex,
        .fileset_fingerprint = fingerprint,
        .prefix_key = key,
        .shun_fp = log.shunFingerprint(),
    }, repo.view.main_branch, body.items);
    return report;
}

/// Opportunistically CREATE a checkpoint where none exists, from the full
/// derive that just ran — the one event that proves a checkpoint would have
/// paid for itself, and the one place its payload is already in hand:
/// `whole_body` is the derive's captured record body; records ride in
/// persisted order, so the checkpoint body is its byte-PREFIX cut at the
/// anchor's frame boundary (`repoview.recordPrefixBelow`). Creation costs
/// one prefix-key walk, the cut scan, and a file-set fingerprint — no
/// artifact re-parses. When no cut exists (the anchor unresolvable, the
/// body unrecognizable) skip silently; an explicit build or a later derive
/// covers it. Sidecar-only like every build; an existing sidecar (even an
/// invalid one) is left to `advance`/`build`.
pub fn autoCreate(
    gpa: std.mem.Allocator,
    io: std.Io,
    repo: *repository.Repository,
    dir: std.Io.Dir,
    log: *logstore.LogStore,
    whole_body: []const u8,
) void {
    var probe = dir.openFile(io, checkpoint_filename, .{}) catch |e| switch (e) {
        error.FileNotFound => null,
        else => return,
    };
    if (probe) |*p| {
        p.close(io);
        return; // the operator's (or a prior derive's) sidecar: advance owns it
    }

    // The anchor: the newest crosslinked check-in — the same choice `build`
    // makes, so the sidecars they write never differ.
    const a = repo.view.state.newestCheckin() orelse return; // an unborn repository has nothing to anchor
    const c_hex = repo.view.hashOf(a);

    // The prefix key at the anchor, and the derive body cut at its boundary.
    var digest: [logstore.max_hash_len]u8 = undefined;
    const c_raw = digest[0 .. c_hex.len / 2];
    _ = std.fmt.hexToBytes(c_raw, c_hex) catch return;
    const c_logrid = log.lookup(c_raw) orelse return;
    const key = log.logKeyAt(c_logrid) catch return;
    const body = repoview.recordPrefixBelow(log, whole_body, key.end) orelse return;

    // The fingerprint — the only real work left.
    var fl = (repo.files(gpa, a) catch return) orelse return;
    defer fl.deinit(gpa);
    var entries: std.ArrayList(artifact.FCard) = .empty;
    defer entries.deinit(gpa);
    var files_it = fl.iterator();
    while (files_it.next()) |f| entries.append(gpa, f) catch return;
    const fingerprint = artifact.fileSetFingerprint(gpa, entries.items) catch return;

    store(gpa, io, dir, .{
        .checkin_hash = c_hex,
        .fileset_fingerprint = fingerprint,
        .prefix_key = key,
        .shun_fp = log.shunFingerprint(),
    }, repo.view.main_branch, body);
}

/// How many bytes the log must have grown past a checkpoint's prefix before
/// the open path bothers advancing it. Below this, the resume already covers
/// the tail cheaply and an advance would spend more than it saves; above it,
/// the one-time advance cost (a tail parse plus one file-set fingerprint)
/// keeps the checkpoint from drifting toward a full re-derive.
pub const advance_min_growth_bytes: u64 = 4 << 20;

/// Opportunistically ADVANCE an existing checkpoint to the current mainline
/// anchor. Called from `Repository.open`'s healing paths behind the growth
/// and anchor-progress gates. Sidecar-only like every build, so there is
/// nothing an automatic act could make permanent. Best-effort end to end:
/// every refusal or failure is silent, and the worst outcome is the
/// checkpoint staying where it was. No-op when no checkpoint exists — the
/// operator opts in by building once; this only keeps that choice fresh.
pub fn advance(gpa: std.mem.Allocator, io: std.Io, repo: *repository.Repository, dir: std.Io.Dir) void {
    var file = dir.openFile(io, repository.repo_filename, .{}) catch return;
    defer file.close(io);
    const st = file.stat(io) catch return;
    var fs = storage.FileStorage.init(io, file, st.size);
    var loaded = tryLoad(gpa, io, dir, fs.storageFile()) orelse return;
    defer loaded.deinit(gpa);
    if (st.size < loaded.claim.prefix_key.end) return; // tryLoad validated, but stat raced
    if (st.size - loaded.claim.prefix_key.end < advance_min_growth_bytes) return;

    // Progress gate: the growth above may be blobs alone, with the anchor
    // unmoved — advancing then would rewrite the same prefix forever. The
    // would-be anchor is the same newest check-in `build` selects, compared
    // by hash so no frame walk is paid to discover there is nothing to do.
    {
        const a = repo.view.state.newestCheckin() orelse return;
        if (std.mem.eql(u8, repo.view.hashOf(a), loaded.claim.checkin_hash)) return;
    }

    _ = build(gpa, io, repo, dir, null) catch return;
}

// ---------------------------------------------------------------------------
// The verifier (DESIGN §6): re-derive every claim from real history alone.
// The sidecar's record body and fingerprint are COMPARANDS here, never
// inputs — the derivation and the file set are rebuilt independently and the
// sidecar must match them byte for byte. The §6.2 canonical-tree oracle runs
// entirely IN MEMORY: `buildTree` over the independently derived file set
// exercises the canonical construction without storing a byte.
// ---------------------------------------------------------------------------

pub const Verdict = union(enum) {
    ok: BuildReport,
    no_checkpoint,
    /// The sidecar does not validate against the live log (unparseable, or a
    /// prefix key the log no longer backs) — the same conditions the open
    /// path treats as a silent miss, named for the operator.
    stale,
    /// §6.1: an independently captured prefix record body differs from the
    /// sidecar's — a resume from this sidecar would not equal a full derive.
    derivation_mismatch,
    /// The independently derived file set at C fingerprints differently —
    /// the deep check the open path deliberately never pays for.
    fileset_mismatch,
};

/// Verify the sidecar against real history: open the repository with BOTH
/// derived sidecars bypassed, re-capture the prefix record body, re-enumerate
/// C's file set through the §9.3 seam, and compare. Reads the sidecar only to
/// know what is being claimed; changes nothing.
pub fn verify(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir) BuildError!Verdict {
    // The claim (comparand). Absence is its own verdict; any validation
    // failure — container or prefix key — is `stale`.
    var probe = dir.openFile(io, checkpoint_filename, .{}) catch return .no_checkpoint;
    probe.close(io);

    var file = dir.openFile(io, repository.repo_filename, .{}) catch return error.Storage;
    defer file.close(io);
    const st = file.stat(io) catch return error.Storage;
    var fs = storage.FileStorage.init(io, file, st.size);
    var loaded = tryLoad(gpa, io, dir, fs.storageFile()) orelse return .stale;
    defer loaded.deinit(gpa);

    // The paranoid derivation: trust nothing derived.
    var repo: repository.Repository = undefined;
    repository.Repository.open(&repo, gpa, io, dir, .{ .bypass_cache = true }) catch |e| return switch (e) {
        error.OutOfMemory => error.OutOfMemory,
        error.NotARepo, error.CannotRead => error.Storage,
        error.Corrupt => error.Corrupt,
    };
    defer repo.deinit(gpa);

    var log = logstore.LogStore.openReadOnly(gpa, fs.storageFile()) catch |e| return switch (e) {
        error.OutOfMemory => error.OutOfMemory,
        error.Storage => error.Storage,
        error.Corrupt => error.Corrupt,
    };
    defer log.deinit(gpa);
    if (loaded.claim.shun_fp != log.shunFingerprint()) return .stale;

    // §6.1: the prefix record body, re-captured from the log alone.
    {
        var body: std.ArrayList(u8) = .empty;
        defer body.deinit(gpa);
        repoview.captureRecords(gpa, &log, logstore.header_len, loaded.claim.prefix_key.end, &body) catch |e| return switch (e) {
            error.OutOfMemory => error.OutOfMemory,
            error.Storage => error.Storage,
            error.Corrupt => error.Corrupt,
        };
        if (!std.mem.eql(u8, body.items, loaded.body)) return .derivation_mismatch;
    }

    // C's file set, independently, through the seam.
    const c_rid = repo.view.lookup(loaded.claim.checkin_hash) orelse return .stale;
    var fl = (repo.files(gpa, c_rid) catch |e| return mapFilesError(e)) orelse return .stale;
    defer fl.deinit(gpa);
    var entries: std.ArrayList(artifact.FCard) = .empty;
    defer entries.deinit(gpa);
    var fit = fl.iterator();
    while (fit.next()) |f| try entries.append(gpa, f);
    const fingerprint = try artifact.fileSetFingerprint(gpa, entries.items);
    if (!std.mem.eql(u8, &fingerprint, &loaded.claim.fileset_fingerprint)) return .fileset_mismatch;

    // The in-memory canonical-tree oracle: batch construction over the
    // independently derived set exercises the §6.2 construction end to end
    // (its own invariants assert the canonical shape); nothing is stored, so
    // this proves without adding.
    {
        var built = try artifact.buildTree(gpa, entries.items, artifact.min_k, .sha3_256);
        built.deinit();
    }

    var report: BuildReport = .{
        .checkin_hash_buf = undefined,
        .checkin_hash_len = @intCast(loaded.claim.checkin_hash.len),
        .files = entries.items.len,
        .prefix_frames = loaded.claim.prefix_key.frame_count,
    };
    @memcpy(report.checkin_hash_buf[0..loaded.claim.checkin_hash.len], loaded.claim.checkin_hash);
    return .{ .ok = report };
}

fn mapFilesError(e: repository.Repository.FilesError) BuildError {
    return switch (e) {
        error.OutOfMemory => error.OutOfMemory,
        error.NotManifest => error.NotACheckin,
        // The check-in's own history is unreadable through the seam — the
        // repository, not the request, is at fault.
        error.NotARepo, error.CannotRead => error.Storage,
        error.Corrupt, error.BaselineUnresolvable, error.BaselineIsDelta => error.Corrupt,
    };
}