# ADR: Same performance, proven

Status: accepted, 2026-09-09. A card on the Admin tab runs the same requests
a visitor makes against both stores, inside one container, in paired rounds,
and says on each path whether the two stores answer in the same time, and
when they do not, whether the difference is the stores or their distance.
Parent: ADR: One container, both stores.

## In plain words

A card on the Admin tab sends the same requests a visitor makes to both databases, SQL and Cosmos DB, from inside the same server, and shows whether they answer in the same time. Running both from one place takes the network out of the comparison, so only the database differs.

What that is worth: a claim about speed is backed by a measurement anyone can run again, and the organization can choose a database on evidence instead of opinion.

## Context

Steve asked for proof that SQL and Cosmos DB perform
the same. The measurement record (ADR: Measuring both stores) had
already compared the two containers from a laptop in Missouri, twenty
paired rounds, and found the pages that never touch a store equal to within
a few milliseconds and every write path faster on Cosmos DB by about eighty.
It also said why: the Cosmos DB account is in the container's region and the
SQL server is one region away, because that region refused to create one.

A number measured from a laptop across two containers is a number with two
networks in it. To prove the stores the same, the proof has to take the
networks out: the same process, the same request, the same request ring, and
only the store different. That is what one container running both stores
makes possible, and this record is the measurement that runs there.

## Decision

**The container measures itself.** `POST /api/admin/proof` starts a run in
the background; `GET /api/admin/proof` reads the run in progress or the last
result. A run signs into one account of its own per store (registered the
first time this process runs the proof and kept; the addendum on the accounts
says why), then for each of eight rounds, alternating which store goes first, sends the requests a
visitor makes to this container's own address with the store named in a
header: sign in, the listing page, a vehicle page, the filter values, a bid,
a raise, and a reset. Every request is timed around the whole exchange,
body included, and the two rings are read for what it caused: how many
statements or operations, and what the document store charged.

```csharp
/// <summary>
/// One whole run: make sure each store has a signed-in account, take the
/// paired rounds, measure the round trip to each store, and sum it up.
/// Clamps the rounds to between one and <see cref="MaxRounds"/>.
/// </summary>
public async Task<ProofResult> RunAsync(int rounds)
{
    rounds = Math.Clamp(rounds, 1, MaxRounds);
    if (backends.All.Count < 2)
    {
        return ProofResult.Failed("this container runs one store, and a proof needs two", backends);
    }

    var started = DateTimeOffset.UtcNow;
    var stores = backends.All.Select(backend => new StoreRun(backend)).ToArray();
    using var client = clients.Create();

    // One account per store for the life of the process: registered and
    // timed on the first run, signed into on every run after that. A run
    // that finds its account gone (the store was reset under it) registers
    // a fresh one, which is the one case that spends a registration.
    foreach (var store in stores)
    {
        if (_accounts.TryGetValue(store.Backend.Key, out var account) && await SignsInAsync(client, store, account.Email))
        {
            store.Email = account.Email;
            store.Samples.Add(account.Registration);
            continue;
        }

        string email = $"proof-{Guid.NewGuid():N}@example.com";
        using var registered = await Timed(client, store, "register", HttpMethod.Post, "/api/auth/register",
            new { email, password = _password });
        if (!registered.IsSuccessStatusCode)
        {
            return ProofResult.Failed($"registration on {store.Backend.Name} answered {(int)registered.StatusCode}", backends);
        }
        store.Cookie = SessionCookie(registered);
        store.Email = email;
        _accounts[store.Backend.Key] = new ProofAccount(email, store.Samples.Last(sample => sample.Path == "register"));
    }

    for (int round = 0; round < rounds; round++)
    {
        // Alternate which store goes first, so whichever effect order has
        // (a warm cache, a busy second) lands on both sides equally.
        var order = round % 2 == 0 ? stores : stores.Reverse().ToArray();
        foreach (var store in order)
        {
            await RoundAsync(client, store);
        }
    }

    // The round trip to each store on its own: a statement that does no
    // work on the relational side, a point read of one document on the
    // document side. Five each, the median kept.
    foreach (var store in stores)
    {
        store.HopMs = await HopAsync(store.Backend);
    }

    return ProofResult.Of(started, rounds, stores);
}

/// <summary>
/// One round on one store, the way a visitor goes: sign in, open the
/// listing, open a vehicle, read the filter values, bid, raise the bid,
/// and reset. A round with no vehicle worth bidding on stops after the
/// listing rather than bidding on one that would end or sell mid-run.
/// </summary>
private async Task RoundAsync(HttpClient client, StoreRun store)
{
    using var signedIn = await Timed(client, store, "sign_in", HttpMethod.Post, "/api/auth/login",
        new { email = store.Email, password = _password });
    if (signedIn.IsSuccessStatusCode)
    {
        store.Cookie = SessionCookie(signedIn) ?? store.Cookie;
    }

    using var listing = await Timed(client, store, "listing", HttpMethod.Get, "/api/vehicles?status=live&sort=most-bids&limit=25");
    string? vehicleId = await ChooseVehicleAsync(listing);
    if (vehicleId is null)
    {
        return;
    }

    using var vehicle = await Timed(client, store, "vehicle", HttpMethod.Get, $"/api/vehicles/{vehicleId}");
    int? minimum = await MinimumAsync(vehicle);
    using var facets = await Timed(client, store, "facets", HttpMethod.Get, "/api/facets");

    if (minimum is { } first)
    {
        using var bid = await Timed(client, store, "bid", HttpMethod.Post, $"/api/vehicles/{vehicleId}/bids",
            new { amount = first });
        int? next = await NextAsync(bid);
        if (next is { } second)
        {
            using var raise = await Timed(client, store, "raise", HttpMethod.Post, $"/api/vehicles/{vehicleId}/bids",
                new { amount = second });
        }
    }

    using var reset = await Timed(client, store, "reset", HttpMethod.Delete, "/api/bids");
}
```
*Live from [`api/TheYard.Api/ProofRunner.Rounds.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Api/ProofRunner.Rounds.cs#L11-L114), region `rounds`, read from this build at 1d6b50b.*

**The round trip to each store is measured on its own.** After the rounds,
five round trips to each store doing as little as a round trip can: a
`SELECT 1` on the relational side, a point read of one document on the
document side, the median kept. That number is what lets the card say
whether a difference is the store or the distance to it.

```csharp
/// <summary>
/// One request to this container, on one store, with the clock around the
/// whole exchange and the two rings read for what the request caused: how
/// many statements or operations, and what the document store charged.
/// </summary>
private async Task<HttpResponseMessage> Timed(HttpClient client, StoreRun store, string path, HttpMethod method, string url, object? body = null)
{
    using var request = new HttpRequestMessage(method, url);
    request.Headers.Add(Backends.HeaderName, store.Backend.Key);
    if (store.Cookie is not null)
    {
        request.Headers.Add("Cookie", store.Cookie);
    }
    if (body is not null)
    {
        request.Content = JsonContent.Create(body);
    }

    var at = DateTimeOffset.UtcNow;
    long start = Stopwatch.GetTimestamp();
    var response = await client.SendAsync(request);
    // The body is part of what a visitor waits for.
    await response.Content.LoadIntoBufferAsync();
    long elapsedMs = (long)Stopwatch.GetElapsedTime(start).TotalMilliseconds;

    int statements = store.Backend.LogsStatements ? sqlLog.Snapshot().Count(statement => statement.At >= at) : 0;
    var operations = store.Backend.LogsOperations
        ? storeLog.Snapshot().Where(operation => operation.At >= at).ToArray()
        : [];
    store.Samples.Add(new Sample(path, elapsedMs, (int)response.StatusCode, statements + operations.Length, operations.Sum(operation => operation.RequestCharge)));
    return response;
}

/// <summary>
/// Whether the proof's remembered account still signs in on this store.
/// Not timed and not sampled: it is a check that the account survived,
/// and the round's own sign-in is the measurement.
/// </summary>
private async Task<bool> SignsInAsync(HttpClient client, StoreRun store, string email)
{
    using var request = new HttpRequestMessage(HttpMethod.Post, "/api/auth/login");
    request.Headers.Add(Backends.HeaderName, store.Backend.Key);
    request.Content = JsonContent.Create(new { email, password = _password });
    using var response = await client.SendAsync(request);
    if (!response.IsSuccessStatusCode)
    {
        return false;
    }
    store.Cookie = SessionCookie(response);
    return true;
}

/// <summary>The session cookie a sign-in or registration set, as name=value ready to send back, or null when it set none.</summary>
private static string? SessionCookie(HttpResponseMessage response)
{
    if (!response.Headers.TryGetValues("Set-Cookie", out var cookies))
    {
        return null;
    }
    foreach (string cookie in cookies)
    {
        if (cookie.StartsWith(TokenIssuer.CookieName + "=", StringComparison.Ordinal))
        {
            return cookie.Split(';', 2)[0];
        }
    }
    return null;
}

/// <summary>A live vehicle with ten minutes left and room under buy-now for a bid and a raise.</summary>
private static async Task<string?> ChooseVehicleAsync(HttpResponseMessage listing)
{
    if (!listing.IsSuccessStatusCode)
    {
        return null;
    }
    var body = await listing.Content.ReadFromJsonAsync<JsonElement>();
    long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
    foreach (var vehicle in body.GetProperty("vehicles").EnumerateArray())
    {
        long endsAt = vehicle.GetProperty("auction_ends_at").GetInt64();
        int price = vehicle.TryGetProperty("current_bid", out var current) && current.ValueKind == JsonValueKind.Number
            ? current.GetInt32()
            : vehicle.GetProperty("starting_bid").GetInt32();
        bool underBuyNow = !vehicle.TryGetProperty("buy_now_price", out var buyNow)
            || buyNow.ValueKind != JsonValueKind.Number
            || price + 2_000 < buyNow.GetInt32();
        if (endsAt - now > 10 * 60_000 && underBuyNow)
        {
            return vehicle.GetProperty("id").GetString();
        }
    }
    return null;
}

/// <summary>The lowest bid the vehicle page will accept, read from its min_next_bid, or null when the page did not answer.</summary>
private static async Task<int?> MinimumAsync(HttpResponseMessage vehicle)
{
    if (!vehicle.IsSuccessStatusCode)
    {
        return null;
    }
    var body = await vehicle.Content.ReadFromJsonAsync<JsonElement>();
    return body.TryGetProperty("min_next_bid", out var minimum) ? minimum.GetInt32() : null;
}

/// <summary>The next acceptable bid after an accepted one, from the vehicle the bid answered with.</summary>
private static async Task<int?> NextAsync(HttpResponseMessage bid)
{
    if (!bid.IsSuccessStatusCode)
    {
        return null;
    }
    var body = await bid.Content.ReadFromJsonAsync<JsonElement>();
    if (body.TryGetProperty("kind", out var kind) && kind.GetString() != "accepted")
    {
        return null;
    }
    return body.TryGetProperty("vehicle", out var vehicle) && vehicle.TryGetProperty("min_next_bid", out var minimum)
        ? minimum.GetInt32()
        : null;
}

/// <summary>The median of five round trips to the store, doing as little work as a round trip can.</summary>
private static async Task<long?> HopAsync(Backend backend)
{
    var samples = new List<long>();
    for (int i = 0; i < 5; i++)
    {
        long start = Stopwatch.GetTimestamp();
        if (backend.Cosmos is { } cosmos)
        {
            // Two point reads, so half the time is one.
            await cosmos.ProbeAsync();
            samples.Add((long)(Stopwatch.GetElapsedTime(start).TotalMilliseconds / 2));
        }
        else if (backend.Contexts is { } contexts)
        {
            await YardDatabase.PingAsync(contexts);
            samples.Add((long)Stopwatch.GetElapsedTime(start).TotalMilliseconds);
        }
        else
        {
            return null;
        }
    }
    return Percentiles.Of(samples, 50);
}
```
*Live from [`api/TheYard.Api/ProofRunner.Requests.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Api/ProofRunner.Requests.cs#L15-L162), region `timed`, read from this build at 1d6b50b.*

**The verdict is arithmetic, not adjectives.** Per path: the median of each
store's samples, and the median of the paired differences, round by round,
so a slow second on both sides cancels. Two stores are "the same" on a path
when that difference is within fifteen milliseconds or fifteen per cent of
the slower one, whichever is more; fifteen milliseconds is about the spread
between two rounds of the same request on the same store. When they are
not the same, the card names the leader and by how much, and then takes one
round trip per operation off each side: if what is left is within the
tolerance, the card says the difference is all round trip.

```csharp
/// <summary>
/// How much the two stores are allowed to differ and still be called the
/// same: fifteen milliseconds or fifteen per cent of the slower one,
/// whichever is more. Fifteen milliseconds is about the spread between
/// two rounds of the same request on the same store.
/// </summary>
public static bool Same(long difference, long slower) =>
    Math.Abs(difference) <= Math.Max(15, slower * 0.15);

/// <summary>
/// A finished run summed up: for registration and every path, a cell per
/// store, the median of the paired differences (round by round, second
/// store minus first), the same with each side's round trips taken off,
/// and the row's verdict; then the sentence over all the rows.
/// </summary>
public static ProofResult Of(DateTimeOffset started, int rounds, IReadOnlyList<ProofRunner.StoreRun> stores)
{
    var first = stores[0];
    var second = stores[1];
    var rows = new List<ProofRow>();
    foreach (var (path, label) in ProofRunner.Paths.Prepend(("register", "Register")))
    {
        var cells = stores.Select(store => Cell(store, path)).ToArray();
        var a = first.Samples.Where(s => s.Path == path && s.Status < 400).Select(s => s.Ms).ToArray();
        var b = second.Samples.Where(s => s.Path == path && s.Status < 400).Select(s => s.Ms).ToArray();
        int pairs = Math.Min(a.Length, b.Length);
        long? difference = pairs == 0 ? null : Percentiles.Of(Enumerable.Range(0, pairs).Select(i => b[i] - a[i]).ToArray(), 50);
        long? withoutHops = difference is null
            ? null
            : difference - (long)Math.Round(
                (cells[1].OperationsPerRequest * (second.HopMs ?? 0)) - (cells[0].OperationsPerRequest * (first.HopMs ?? 0)));
        rows.Add(new ProofRow(path, label, cells, difference, withoutHops, Verdict(difference, withoutHops, cells, first.Backend.Name, second.Backend.Name)));
    }

    return new ProofResult(
        "done",
        null,
        started,
        DateTimeOffset.UtcNow,
        rounds,
        stores.Select(store => new ProofStore(store.Backend.Key, store.Backend.Name, store.HopMs)).ToArray(),
        rows,
        SentenceFor(rows, stores));
}

/// <summary>One path on one store from its samples: failures counted apart, medians and averages over the requests that answered below 400.</summary>
private static ProofCell Cell(ProofRunner.StoreRun store, string path)
{
    var all = store.Samples.Where(s => s.Path == path).ToArray();
    var ok = all.Where(s => s.Status < 400).ToArray();
    long[] ms = ok.Select(s => s.Ms).ToArray();
    return new ProofCell(
        store.Backend.Name,
        ok.Length,
        Percentiles.Of(ms, 50),
        Percentiles.Of(ms, 95),
        ok.Length == 0 ? 0 : Math.Round(ok.Average(s => s.Operations), 1),
        !store.Backend.LogsOperations || ok.Length == 0 ? null : Math.Round(ok.Average(s => s.RequestUnits), 2),
        all.Length - ok.Length);
}

/// <summary>
/// The row's phrase: not measured, the same, or which store leads and by
/// how much, with a note when the lead is all round trip, meaning that
/// taking one round trip per operation off each side leaves them the same.
/// </summary>
private static string Verdict(long? difference, long? withoutHops, ProofCell[] cells, string firstName, string secondName)
{
    if (difference is null)
    {
        return "not measured";
    }
    long slower = Math.Max(cells[0].P50Ms, cells[1].P50Ms);
    if (Same(difference.Value, slower))
    {
        return "the same";
    }
    string leader = difference < 0 ? secondName : firstName;
    string sentence = $"{leader} leads by {Math.Abs(difference.Value)} ms";
    if (withoutHops is { } adjusted && Same(adjusted, slower))
    {
        sentence += ", all of it the round trip to the store";
    }
    return sentence;
}

/// <summary>The one sentence over every row: how many paths came out the same, how many differ only by the round trip, and whether any differ by more.</summary>
private static string SentenceFor(IReadOnlyList<ProofRow> rows, IReadOnlyList<ProofRunner.StoreRun> stores)
{
    int same = rows.Count(row => row.Verdict == "the same");
    int measured = rows.Count(row => row.MedianDifferenceMs is not null);
    int hops = rows.Count(row => row.Verdict.EndsWith("the round trip to the store", StringComparison.Ordinal));
    string first = stores[0].Backend.Name;
    string second = stores[1].Backend.Name;
    string trips = string.Join(" and ", stores.Select(store => $"{store.HopMs?.ToString() ?? "?"} ms to {store.Backend.Name}"));
    if (measured == 0)
    {
        return "Nothing could be measured; the rows say why.";
    }
    if (same == measured)
    {
        return $"On every path measured, {first} and {second} answer in the same time, within the tolerance the record states. One round trip to the store is {trips}.";
    }
    if (same + hops == measured)
    {
        return $"On {same} of {measured} paths the two stores answer in the same time. On the other {hops} the difference is the round trip to the store, {trips}, and taking one round trip per operation off each side leaves them the same.";
    }
    return $"On {same} of {measured} paths the two stores answer in the same time; {hops} more differ by exactly the round trip to the store ({trips}); the rest differ by more than that, and the rows say by how much.";
}
```
*Live from [`api/TheYard.Api/ProofResult.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Api/ProofResult.cs#L92-L200), region `verdict`, read from this build at 1d6b50b.*

**The card says the whole thing in one sentence**, computed from the rows:
how many paths are the same, how many differ by exactly the round trip, and
how many differ by more, with the round trips stated.

**A run is rationed, and starting one takes a signed-in visitor.** Reading
the result is as public as the rest of the Admin tab. Starting a run writes
sixteen bids, so since 1.0.0.109 it takes a signed-in visitor, the rule every
other write on this site follows (ADR: The one write a stranger can make,
addendum); as first shipped it was anonymous, which with two fresh accounts
per run made a loop of one start a minute exactly the hour's allowance of
registrations. A second start while one is running, or within a minute of the
last, answers 409 and the card shows the result it has.

## What it measures, and what it does not

It measures the visitor's path: HTTP in, JSON out, with authentication,
validation, the domain rules, the store and the serialisation all inside the
clock, the way a visitor's request has all of them inside it. It does not
measure the visitor's own network, which the measurement record does, and it
does not measure the stores under load, which nothing here does: one request
at a time, in order, is the shape of a demo with one visitor and it is the
shape this proof is honest about.

The two rings the proof reads for statements, operations and request charge
are the container's, so a visitor bidding during a run adds their statements
to a sample's count on whichever store served them. The times are the
proof's own requests and unaffected; the operation counts, and with them the
"without the round trips" column, can carry a visitor's work for the few
seconds a run takes. Reading the card a second time settles it.

The relational round trip is a whole statement, not the wire alone, so the
"without the round trips" column on a path with several statements takes off
a little more than the wire on that side. The direction of the error is
towards the relational store, which is the store the distance handicaps, so
the correction can only understate that store's case, never overstate it.

## The numbers

Run on both containers on 1.0.0.94 within a quarter of an hour of each coming
up, eight paired rounds each time, read back from the live sites. The
sentence both cards arrived at, word for word (the second container's round
trip to Cosmos DB came out at 1 ms rather than 2):

> On 3 of 8 paths the two stores answer in the same time. On the other 5 the
> difference is the round trip to the store, 39 ms to Azure SQL Database and
> 2 ms to Azure Cosmos DB, and taking one round trip per operation off each
> side leaves them the same.

That is the proof, and it is the honest one: the two stores answer in the
same time once the distance to each is taken out, and the distance is real.
The relational server is one region away from the container and every
statement crosses that gap; the document account is in the container's own
region. On this workload a bid is two statements or two operations, so the
relational side pays the gap twice and the card shows exactly that.

The rows from the live site's container. Times are the median over eight
samples unless marked; the difference is the median of the paired
differences, negative when Cosmos DB was faster, which is why it is not
always the difference of the two medians; the last column takes one round
trip per statement or operation off each side:

| Path | Azure SQL Database | Azure Cosmos DB | Difference | Without the round trips | Verdict |
| --- | --- | --- | --- | --- | --- |
| Register (one sample) | 233 ms, 3 statements | 133 ms, 4 operations, 13.04 RU | -100 ms | +9 ms | all of it the round trip |
| Sign in | 122 ms, 1 statement | 82 ms, 2 operations, 2 RU | -39 ms | -4 ms | all of it the round trip |
| Listing page | 51 ms | 56 ms | -6 ms | -6 ms | the same |
| Vehicle page | 1 ms | 1 ms | 0 ms | 0 ms | the same |
| Filter values | 12 ms | 29 ms | +7 ms | +7 ms | the same |
| Bid write | 84 ms, 2 statements | 10 ms, 2 operations, 6.52 RU | -75 ms | -1 ms | all of it the round trip |
| Bid raise | 85 ms, 2 statements | 9 ms, 2 operations, 11.29 RU | -77 ms | -3 ms | all of it the round trip |
| Reset | 44 ms, 1 statement | 10 ms, 2 operations, 7.78 RU | -35 ms | 0 ms | all of it the round trip |

The second container, whose default is the document store, produced the same
shape to within a few milliseconds on every row: register 197 against 94 with
10 ms left after the round trips, sign in 117 against 77 with 1 ms left, bid
write 85 against 11 with 2 ms left, bid raise 86 against 10 with 1 ms left,
reset 44 against 10 with 3 ms left, and the three pages that never touch a
store the same on both. Two containers, two runs each, one answer.

The card on the live site after that run, and the same card on the second
container:

![The proof card on the live site: the sentence, the round trips, and the eight rows with both stores, the difference, the difference without the round trips, and a verdict on each](/api/docs/images/proof-sql.png#960x874)

![The proof card on the second container, whose default store is Cosmos DB: the same sentence and the same shape on every row](/api/docs/images/proof-cosmos.png#960x874)

Two things the run taught that the card alone would not.

**The first run after a deploy is not the second.** On both containers the
first run since boot put Register at 578 ms against 194 on the live site and
483 against 163 on the second, with 275 and 207 ms left after the round
trips, and the verdict said so. The second run a few minutes later put the
same row at 233 against 133 and 197 against 94, with 9 and 10 ms left. The
card registers one account per store per run, so that row has one sample and
carries whatever a process does only once: the first pass through a code
path on both sides, and on the relational side the compiling EF Core does the
first time it meets a query shape and a command shape. This record does not
prove the cause, only that a second run removes it, and it keeps both numbers
because the first is the one a visitor gets right after a deploy.

**The relational store's free offer sleeps.** The first health check after a
quiet stretch took 25,882 ms on the live site and a direct read of the origin
timed out, because Azure SQL Database's free offer pauses the database when
nobody has used it for a while and resumes it on the next connection. The
script that started these runs wakes both stores with a health check first
and starts a run only when every store answers in under a second and a half,
so no row here is a store waking up; a visitor who arrives after a pause pays
it once, on the first request that reaches the database, and the site serves
the catalogue from memory while it waits. The document store has no such
state on the free tier.

## Consequences

- The proof is a card, so it runs on the deployed container and not on a
  laptop, and the numbers it shows are the numbers a visitor to that
  container gets, minus their own network.
- Two accounts per process, named `proof-...@example.com`, one per store,
  made on the first run after a roll and reused after it, so a container
  adds two accounts to each store per deploy rather than two per run; they
  live in the store until the next reset of that store's test data.
- The interview sentence is the card's sentence, and the numbers behind it
  are on the page it is said from.

## Where it sits

The proof sits in the host Api (ProofRunner and ProofResult) because it drives the app over HTTP the same way a visitor does; its one reach inward is the round trip that calls YardDatabase.PingAsync in Infrastructure and the probe in Infrastructure.Cosmos. Single responsibility, a class having one reason to change, shapes the split: ProofClients decides where the requests go, ProofRunner times them, and ProofResult holds the verdict arithmetic that ProofTests checks with no store at all. It cost two kept accounts per store per process, and it measures one request at a time, so it says nothing about load. A question about capacity under many visitors would call for a separate load harness run from outside the container.

## Files

- [`api/TheYard.Api/Proof.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Api/Proof.cs), [`api/TheYard.Api/ProofRunner.Rounds.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Api/ProofRunner.Rounds.cs), [`api/TheYard.Api/ProofRunner.Requests.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Api/ProofRunner.Requests.cs) and [`api/TheYard.Api/ProofResult.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Api/ProofResult.cs): the runner, the samples, the verdict.
- [`api/TheYard.Api/Endpoints/AdminEndpoints.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Api/Endpoints/AdminEndpoints.cs): the two endpoints (region proof-endpoints).
- [`api/TheYard.Api/SelfAddress.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Api/SelfAddress.cs): the loopback address.
- [`api/TheYard.Api/Composition/AdminRegistration.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Api/Composition/AdminRegistration.cs): the client the proof uses (region proof-clients).
- [`api/TheYard.Tests/ProofTests.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Tests/ProofTests.cs): the arithmetic without a store, and the endpoints with whichever stores the run has.
- [`src/components/admin/ProofCard/ProofCard.tsx`](https://github.com/SteveStout/TheYard/blob/main/src/components/admin/ProofCard/ProofCard.tsx): the card.
- [`docs/decisions/ADR-064-measuring-both-stores.md`](https://github.com/SteveStout/TheYard/blob/main/docs/decisions/ADR-064-measuring-both-stores.md): the measurement from the visitor's side, which this one completes.

## Addendum, 2026-09-09: the proof's accounts, and who may start it

A reader with no context (the lane's review of 2026-09-09, three readers,
one an architect) put it plainly: `POST /api/admin/proof` was anonymous, a
run registered two accounts, and with a one-minute cooldown a loop of one
request a minute spent exactly the 120 registrations an hour that ADR: The
one write a stranger can make set as the site's ceiling, so a stranger could
stop real visitors registering by asking the site to prove itself. Two
changes, both in 1.0.0.109. Starting a run takes a signed-in visitor now,
which is the rule every other write here follows; reading stays public, and
the card says so and disables its button signed out rather than failing
after the click. And the proof's accounts are made once per process and
kept: the first run after a roll registers one per store with a password
that is random per process and lives nowhere but in memory, and every later
run signs into them, carrying the first run's registration sample into its
result so the Register row still shows what registering cost. A run that
finds its account gone, because the store's test data was reset under it,
registers a fresh one, which is the one case that spends a registration.
`ProofTests` holds both: a stranger's start is 401 and nothing runs, and a
second run registers nothing.

## Addendum, 2026-09-13: the sign-in button goes somewhere

Steve reported from his phone that the sign-in button on the comparison did
not work. It did not: signed out, the button read "Sign in to run the proof" and sat
disabled, on the reasoning that a write the visitor cannot make should not
be a button that fails after the click. That reasoning held for the failure
and missed the person: a control that reads like a call to action and does
nothing is a broken control to the one tapping it, and on a phone there is
no hover state to say otherwise. Now the same button, signed out, opens the
account view, which is where the site signs a visitor in; signed in, it
starts the run as before. The browser test holds both halves: the click goes
to `?view=account`, and after a sign-in the label offers the run.

## Addendum, 2026-10-03: the relational round trip

The relational side's `SELECT 1` is now `YardDatabase.PingAsync` in the relational adapter, so the proof runner in the host asks the adapter for a round trip instead of running a statement itself. The document side does two point reads and halves the time, which the paragraph above calls one point read; the arithmetic it reports is the same.
