# ADR: The five-minute gate

Status: accepted, 2026-09-09. The whole test wall, every suite on both
stores, runs in under five minutes on the machine that ships, and the numbers
that say so are in this record. Parent: ADR: The tests, explained for a new developer.

## In plain words

The full test run, every suite on both databases, was cut from about twenty minutes to under five on the machine that ships. Test applications now boot a thousand vehicles instead of a hundred thousand, and the gate runs each suite once per store with its two sides at the same time.

What that is worth: a developer gets a verdict on every change in minutes with nothing skipped, and the organization ships more often without testing less.

## Context

Steve asked for the tests in total to run in no more than five
minutes. The ship gate at 1.0.0.96 took about twenty minutes: xUnit on SQLite,
the six live store tests, the whole xUnit suite booted on Cosmos DB, the
browser suite twice on SQLite and once on Cosmos DB, and in front of them
prettier, lint, tsc, dotnet format, the SQL project, vitest and the em dash
scan, one after another.

Where the time went was measured before anything was changed, from the test
runner's own per-test timings, on the machine that ships (four cores, eight
threads, eight gigabytes with about two free):

- **xUnit on SQLite, 74 seconds** of wall clock for 423 seconds of test time,
  and the first test of every class took twelve to twenty seconds. That first
  test was paying for the class's application to boot, and a boot expanded the
  two hundred seed vehicles to a hundred thousand and built the search index
  over them, the site's shape and not the test's need. Nineteen class
  fixtures did that, and the classes that boot a host per test (AuthTests
  thirteen times, PersistenceTests twelve, PeerTests seven) did it again for
  each. The same expansion, held in every one of those processes at once, is
  what turned a two-minute suite into a thirty-minute crawl the night before
  (ADR: One container, both stores, "What it costs").
- **xUnit booted on Cosmos DB, 154 seconds** for 681 seconds of test time,
  the same boots plus a round trip to West US 2 for every store operation.
- **The browser suite, 114 seconds** for 311 seconds of test time on four
  workers, whose ideal is 78. The accessibility scans were one file of nine
  tests that ran one after another on one worker, 91 seconds, which set the
  length of the whole run.

## Decision

**A test application boots a thousand vehicles, and the one class that is
about the size asks for the hundred thousand by name.** A module initializer
in the test assembly sets the catalogue size before any host is built, the
same setting the container reads, so a developer who sets it themselves still
wins; `ApiIntegrationTests`, whose subject is the synthetic dataset, opts
back in through a `FullCatalogue` fixture. A thousand is enough for every
other test because the schedule spreads a thousand auctions across a week:
there are live, upcoming and ended vehicles on every page and every path the
tests walk is the same path. A test holds the number, so a change to how the
host reads its configuration cannot put a hundred thousand vehicles back into
every test application without that test failing.

```csharp
/// <summary>
/// The catalogue every test application boots with (ADR: The five-minute
/// gate).
///
/// <para>The site expands its two hundred seed vehicles to a hundred thousand
/// in memory at startup, and until this file existed so did every application
/// the suite booted: nineteen class fixtures and a few dozen per-test hosts,
/// each expanding a hundred thousand vehicles, building the search index over
/// them and holding all of it for the life of the class. Measured, the first
/// test of every class took twelve to twenty seconds, which was the boot, and
/// the suite's memory was the reason a two-minute run once became a
/// thirty-minute crawl.</para>
///
/// <para>A thousand is enough for every test that is not about the size: the
/// schedule spreads a thousand auctions across a week, so there are live,
/// upcoming and ended vehicles on every page, and every path the tests walk
/// is the same path. The one class that is about the size opts back in
/// through <see cref="FullCatalogue"/>. A module initializer rather than a
/// fixture, because it has to run before any host is built and the plain
/// <c>WebApplicationFactory&lt;Program&gt;</c> a class fixture hands out has
/// nowhere to say so; the setting is read by the same configuration the
/// container reads, so a developer who sets the variable themselves wins.</para>
/// </summary>
internal static class TestCatalogue
{
    public const int Vehicles = 1_000;

    [ModuleInitializer]
    internal static void Shrink()
    {
        if (Environment.GetEnvironmentVariable("Inventory__TargetCount") is null)
        {
            Environment.SetEnvironmentVariable("Inventory__TargetCount", Vehicles.ToString());
        }
    }
}

/// <summary>The site's own catalogue, a hundred thousand vehicles, for the tests that are about its size.</summary>
public sealed class FullCatalogue : WebApplicationFactory<Program>
{
    public const int Vehicles = 100_000;

    protected override void ConfigureWebHost(Microsoft.AspNetCore.Hosting.IWebHostBuilder builder) =>
        builder.UseSetting("Inventory:TargetCount", Vehicles.ToString());
}

/// <summary>Holds that a test application boots the small test catalogue and the full catalogue is only built when asked for by name.</summary>
public class TestCatalogueTests(WebApplicationFactory<Program> factory)
    : IClassFixture<WebApplicationFactory<Program>>
{
    /// <summary>
    /// The number the whole suite's speed rests on, held here so a future
    /// change to how the host reads its configuration cannot quietly put a
    /// hundred thousand vehicles back into every test application.
    /// </summary>
    [Fact]
    public async Task A_test_application_boots_a_thousand_vehicles_and_the_full_catalogue_is_asked_for_by_name()
    {
        var client = factory.CreateClient();
        var page = await client.GetFromJsonAsync<JsonElement>("/api/vehicles?limit=1");
        Assert.Equal(TestCatalogue.Vehicles, page.GetProperty("total").GetInt32());

        // The schedule still puts every state on the page: the tests that walk
        // a live auction, and the ones that look for an ended or an upcoming
        // one, all find what they came for in a thousand.
        foreach (string status in new[] { "live", "upcoming", "ended" })
        {
            var some = await client.GetFromJsonAsync<JsonElement>($"/api/vehicles?status={status}&limit=1");
            Assert.InRange(some.GetProperty("total").GetInt32(), 1, TestCatalogue.Vehicles - 1);
        }
    }
}
```
*Live from [`api/TheYard.Tests/TestCatalogue.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Tests/TestCatalogue.cs#L9-L80), region `test-catalogue`, read from this build at 1d6b50b.*

**The accessibility scans run in parallel.** Each scan is its own page and
its own sign-in, so the file is declared parallel and its nine tests go to
whichever worker is free. On this machine that changes the shape of the run
more than its length, because four Chrome workers on four cores are the
floor; on a wider machine it is the difference.

**The account tests are three classes over one base.** xUnit runs a class's
tests one after another and the classes beside each other, and the account
tests boot a host each, so as one class they were the suite's longest single
line on the document store: eighty-six seconds while the rest of the suite
finished around them. Sign-in, lockout and bids are now three classes with
the same tests under the same names, and the longest line is thirty.

**The browser suite's server starts prebuilt on the gate.** `npm run api`
builds the API before it runs it, which is right on a developer's machine
and ten seconds twice over on a gate that built it a minute earlier;
`YARD_API_PREBUILT` says so and the server starts with `--no-build`.

**The gate runs each suite once per store, and runs the two sides at the same
time.** One build, then two jobs side by side: the node side (prettier, lint,
tsc, vitest, the browser suite on SQLite, then the store specs on Cosmos DB)
and the .NET side (dotnet format, the SQL project, xUnit on SQLite, the six
live store tests, xUnit booted on Cosmos DB). The browser suite used to run
twice on SQLite as a check against its own flakiness. In forty-six ships the
second run disagreed with a green first run once, on 1.0.0.92, and that once
was a real race in the page that a single run catches on some ships and not
others (ADR: Accounts and per-user bids, addendum); the answer to that class of defect is the
test written for it, not a second run of every test on every ship, so the
second run is gone and its two minutes with it. On Cosmos DB
the browser suite runs the three spec files whose behaviour depends on the
store: `store-toggle` (the switch, with two stores), `account` (registration
and sign-in against the document store's accounts) and `admin` (the store
log's card). The other ten spec files are about documents, layout, the
keyboard, accessibility, the room and the page's own state, none of which
changes with the store; each of them runs on SQLite in the same gate, and
the whole xUnit suite runs on both stores, which is where a bid's
persistence on the document store is proven. The six live store
tests keep their own step, before the suite boots on the same test
containers: one of them empties a container and watches it be reseeded, and a
suite booting beside it reseeds it first.

## The numbers

Measured on the same machine, the same day, before and after:

| Suite | Before | After |
| --- | --- | --- |
| xUnit on SQLite (373 tests) | 74 s | 16 s |
| The six live store tests | 44 s | 44 s |
| xUnit booted on Cosmos DB (373 tests) | 154 s | 62 s |
| Browser suite on SQLite (58 tests) | 114 s, twice | 120 s, once |
| Browser suite on Cosmos DB | 126 s, all 58 | 60 s, the 12 that depend on the store |
| vitest (72 tests) | 3 s | 3 s |

Each suite alone, one after another, on an otherwise idle machine. The
suites in a row went from about ten minutes to under five; side by side in
the gate, with the build and the checks around them, they contend for the
same four cores and each takes longer than it does alone, which is the
number the record's last section carries.

## What did not change

Nothing is skipped and nothing asserts less. The BrokenWindows gate still
forbids a skipped test. CI runs the same suites it ran before, on the same
runners, and gets faster for the same reason. The production catalogue is a
hundred thousand vehicles, as it was; the browser suite's API boots the
site's own shape and its smoke test still counts to a hundred thousand.

## Consequences

- A test that needs more than a thousand vehicles says so through
  `FullCatalogue`, which is one line and a reason.
- A new browser spec that reads or writes a store is added to the Cosmos DB
  list in the gate script, or it only ever runs on SQLite; the list is short
  enough to read.
- The gate's own log times every step, so the next slow test is a number
  before it is a feeling.

## The gate, measured

The gate's own log, the run before the one that shipped this record
(queue script 690, 2026-09-08 22:37 CDT), every step timed: preview card 4
s, build 7 s; the node side prettier 7 s, lint 1 s, tsc 2 s, vitest 2 s,
the browser suite on SQLite 142 s (58 passed), the three store specs on
Cosmos DB 62 s (12 passed); the .NET side dotnet format 40 s, the SQL
project 20 s, xUnit on SQLite 79 s (373 passed), the six live store tests
46 s, xUnit on Cosmos DB 67 s (373 passed); the em dash scan over 324
files 2 s. **The whole gate: 275 seconds, everything green.** The xUnit
suite that takes sixteen seconds alone took seventy-nine beside the browser
suite, which is the price of the two sides sharing four cores and still
the cheaper shape: the sides in a row would be about six minutes.

## Where it sits

No ring: this is a process decision about the test gate and the test assembly beside the onion, so the rings and SOLID do not apply to it.

## Files

- [`api/TheYard.Tests/TestCatalogue.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Tests/TestCatalogue.cs): the thousand, the hundred thousand by name, and the test that holds the number.
- [`api/TheYard.Tests/ApiIntegrationTests.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Tests/ApiIntegrationTests.cs): the one class that boots the full catalogue.
- [`tests/e2e/axe.spec.ts`](https://github.com/SteveStout/TheYard/blob/main/tests/e2e/axe.spec.ts): the scans, declared parallel.
- [`api/TheYard.Tests/AuthTests.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Tests/AuthTests.cs): the account tests, three classes over one base.
- [`playwright.config.ts`](https://github.com/SteveStout/TheYard/blob/main/playwright.config.ts): the prebuilt server on the gate.
- [`api/TheYard.Api/Composition/StoreRegistration.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Api/Composition/StoreRegistration.cs): where `Inventory:TargetCount` is read.
- [`tests/e2e/coverage.spec.ts`](https://github.com/SteveStout/TheYard/blob/main/tests/e2e/coverage.spec.ts): every listed page at three widths, read on the page's own signals (the addendum of 10 October).

## Addendum, 2026-09-09: the gate on a loaded machine, and the scans declared slow

The morning after this record, the gate ran seven times for four versions
and went red three times on the browser suite alone, with every xUnit run
green each time: a sixty-second timeout on the phone scan (queue script 715,
the gate at 452 s), a Chrome session closed under the practices spec (717,
493 s), and a sixty-second timeout inside axe on the open records index
(724, 373 s). The machine was measured before each retake rather than
blamed: 8,040 MB in total, 1,100 MB free at 09:00 with Chrome at 3,277 MB
across 51 processes, 1,793 MB free at 09:11 with Chrome at 1,898 MB, 1,219 MB free at 10:03 with Chrome at 2,080 MB across 45, read a minute before the take of 1.0.0.103 that went green at 337 s with the scans declared slow. The
gate's two sides share those four cores and that memory with the browser
the developer is working in, and the two scans that failed are the two that
read the most nodes.

Two of the three are one defect and it is in the budget, not the scan: the
accessibility scans measurably take more than sixty seconds on this machine
under the gate's parallel load, and a budget that a test overruns while
doing its work correctly is a wrong number. The describe now calls
`test.slow()`, which triples the budget for those nine tests and nothing
else; the scans assert exactly what they asserted, zero violations, and a
scan that hangs still fails. The third, a closed session, is memory, and the
lever for it is outside the repository: fewer Chrome windows while a gate
runs, or a wider machine. It is logged in the gate's own numbers so the next
one is a pattern and not a surprise.

The five minutes stands as the target. A green gate on this machine with
the developer's browser open measured 302 s and 342 s today, and 372 s on
the first run after a restart with cold caches; the suites themselves fit,
and the wall clock is the machine's.

## Addendum, 2026-09-14: the five-second expect

Three takes of 1.0.0.127 on 2026-09-14 (queue scripts 840, 844 and 849) went
red on six different browser tests and on nothing else. Every xUnit pass was
green all three times, and each failure was Playwright's default five-second
`expect` waiting on a page element that follows a round trip costing a
password hash or a store read: a refused sign-in, a reset, a bid, the forgot
note, and the visitor table twice, on SQLite and on Cosmos DB, under two
different tests. No test failed twice. The machine was measured before
it was blamed. The first take shared the four cores with a second runner (a
terminal and a scheduled task had each started one, and they ran the prep
script and the gate side by side). The second ran under the scheduled task's
runner at BelowNormal, the default priority Windows gives a scheduled task,
beneath the desktop apps at Normal. The third ran at Normal with 8,040 MB in
total and 1,372 MB free five minutes before it started; xUnit on SQLite took
140 s against 68 s on the take before it, and the browser suite 4.9 minutes
against 3.7.

The decision is the one the scans got above: a budget that a test overruns
while doing its work correctly is a wrong number. `expect` now waits fifteen
seconds instead of five, for every assertion, in `playwright.config.ts`. A
green run is no slower, because an assertion returns the moment its element
appears; only a failing one waits longer, and the sixty-second test budget
still caps it. The three tests that can name the request they wait on (the
registration, the refused sign-in and the reset) wait on the response itself,
which says what the server answered when the answer is wrong. The runner's
scheduled task runs at Normal from now on.

## Addendum, 2026-09-21: every check runs once, and every result is on the Admin tab

On the time a version took to reach the live site, Steve asked for each check
to run only once, for every test to keep running, and for the effort to go into
making the tests that run faster. Measured on 1.0.0.172 before anything changed, one
version cost about twenty-four minutes: a precheck of about five minutes on
the machine that ships, the gate at 417 seconds on the same tree, CI at 4
minutes 37 seconds on GitHub's runners, Deploy at 3 minutes 49, Deploy
Cosmos at 1 minute 49 after it, and about two minutes of reading the roll.
The suites ran three times: in the precheck, in the gate and in CI.

**The gate is the one run.** A separate precheck of the same suites on the
same tree no longer runs before it; the gate commits nothing when it is red,
so a red gate costs what a red precheck cost and a green one is not paid for
twice. CI no longer runs on a push to main, because the only thing that
pushes to main is a green gate, and the gate runs more than CI does: every
xUnit test on both stores, the live Cosmos DB tests CI has no credential for,
and the browser suite's store specs on Cosmos DB. CI keeps every suite for a
pull request, which has had no gate.

**Every result ships with the version.** Each runner in the gate writes its
own report into the gate's raw folder (Vitest and Playwright as JSON, a TRX
file for each xUnit pass), and when every suite is green
`scripts/test_results.mjs` reads them into `data/test-results.json`: every
suite with its counts and its seconds, every test with its group, its name,
its outcome and its milliseconds, one per line so a diff reads test by test,
and the checks that are not tests. It is committed with the version, the
image carries it beside the dataset, `/api/admin/tests` serves it, and the
Admin tab's card "Every test, for this build" shows it, a suite opening to
its tests with failures first and then the slowest. The slowest list is
where the next speed-up is chosen from, which is the point of keeping the
milliseconds. `TestResultsTests` holds the endpoint and holds a committed
file to the only kind a green gate writes: every suite, counts equal to rows,
nothing failed and nothing skipped.

```javascript
const decode = (text) =>
  text
    .replace(/&quot;/g, '"')
    .replace(/&apos;/g, "'")
    .replace(/&lt;/g, '<')
    .replace(/&gt;/g, '>')
    .replace(/&amp;/g, '&');

function attributes(tag) {
  const values = {};
  for (const match of tag.matchAll(/([A-Za-z]+)="([^"]*)"/g)) values[match[1]] = decode(match[2]);
  return values;
}

/** hh:mm:ss.fffffff, as TRX writes a duration, in milliseconds. */
function milliseconds(duration) {
  const parts = /^(\d+):(\d+):(\d+(?:\.\d+)?)$/.exec(duration ?? '');
  if (!parts) return 0;
  return Math.round((Number(parts[1]) * 3600 + Number(parts[2]) * 60 + Number(parts[3])) * 1000);
}

function trx(file) {
  const full = path.join(raw, file);
  if (!existsSync(full)) return null;
  const text = readFileSync(full, 'utf8');
  const classes = new Map();
  for (const match of text.matchAll(
    /<UnitTest\b[^>]*\bid="([^"]+)"[\s\S]*?<TestMethod\b([^>]*)>/g
  )) {
    classes.set(match[1], attributes(match[2]).className ?? '');
  }
  const tests = [];
  for (const match of text.matchAll(/<UnitTestResult\b([^>]*)>/g)) {
    const result = attributes(match[1]);
    const className = (classes.get(result.testId) ?? '').replace(/^TheYard\.Tests\./, '');
    let name = (result.testName ?? '').replace(/^TheYard\.Tests\./, '');
    if (className && name.startsWith(`${className}.`)) name = name.slice(className.length + 1);
    const outcome =
      result.outcome === 'Passed' ? 'p' : result.outcome === 'NotExecuted' ? 's' : 'f';
    tests.push([className, name, outcome, milliseconds(result.duration)]);
  }
  return tests;
}
```
*Live from [`scripts/test_results.mjs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/scripts/test_results.mjs#L108-L150), region `trx`, read from this build at 1d6b50b.*

The first take of this version went red on load and not on the change. With
1,548 MB free of 8,040 when it started and both sides running, one browser
test on each store waited twenty seconds for the page to mount and it had
not, while every other test of the run mounted it; `openTheYard` gives the
mount thirty-five seconds now, inside the same sixty-second test budget, by
the rule the scans and the expect got above. The same take read the first
file of results: 1,487 tests across six suites, and the slowest xUnit tests
are each class's first, at thirty to forty-five seconds, which is a host
booting while the browser suite runs beside it. That is the next thing to
make faster, and the card now shows it test by test.

What 1.0.0.173 measured, from the first gate that wrote the file: 1,487
tests, the gate at 417 seconds, the push at 12:30:50 CDT and both sites on
the new version at 12:36:46, and the whole cycle, gate to live look, 907
seconds with the build cache still empty. The file then named the next two
things to try, and the first take of 1.0.0.174 tried both: the Admin tab's
browser spec, one file of twenty-three tests on one worker, declared
parallel as the scans are, and the seven live Cosmos DB tests moved beside
the xUnit pass on SQLite. Measured, the gate went from 417 seconds to 619,
the browser pass on SQLite from 211 to 365, three of the live tests timed
out and one phone test found a real defect of this version's own. On this
machine, four cores and eight gigabytes shared with the developer's
browser, more work at once is slower work, which the record's first
section already said about the two sides of the gate. Both changes are
taken back; nothing was committed by that take.

On the card itself, Steve asked for a small mark on a pass or a fail, a plus
sign or a red cross, something that makes the result obvious at a
glance. A tick in a green disc or a cross in a red one, from the
status tokens because passed and failed are states, 44 pixels beside the
card's sentence and 16 on every suite, check and test; a mark carries its
own label only where no word beside it already says the same.

## Addendum, 2026-09-22: the two cuts, and the name

The gate measured 487 to 745 seconds on the nights of 21 and 22 September,
every check once on both stores, on the same four-core laptop; Steve's
ceiling is five to ten minutes for the whole wall. Two cuts, each with the
files that decide it spelled out, and neither runs on every ship:

- `dotnet format` (39 to 53 seconds) runs only when a file the formatter
  reads changed since the last green commit: anything ending in `.cs`,
  `.csproj`, `.slnx` or `.editorconfig`. A ship that touches none of them
  cannot change what the formatter would say.
- The two passes on Cosmos DB, xUnit on Cosmos DB (61 to 106 seconds) and
  the three store specs in the browser (93 to 128 seconds), run only when
  something under `api/`, under `infra/cosmos/`, or one of
  `tests/e2e/store-toggle.spec.ts`, `tests/e2e/account.spec.ts` and
  `tests/e2e/admin.spec.ts` changed. The seven live tests against the real
  account still run on every ship, and so does every other suite.

When the two passes are skipped, the gate says so to
`scripts/test_results.mjs` (`YARD_CARRY_COSMOS=1`) and the results file
carries those two suites forward from the previous version, row for row,
each marked `carried` with the version whose gate ran them. The Admin tab's
card says so beside the suite's name, and `TestResultsTests` holds the rule:
only those two suites may be carried, and only from a named version. Every
suite is still in every file, so a reader is never shown a build with a
pass missing and no word about it.

The name. This record keeps its title as the record of the target; the
living documents call it the one gate, because what it holds to is every
check once before anything rolls, and the minutes are measured, not named.
Together the two cuts take a frontend-only ship's gate from about 480
seconds to about 270 to 300; a ship that touches `api/` runs everything.
Steve chose both on 2026-09-22, and the 44 px pills that ship beside them.

## Addendum, 2026-09-28: the ship's wall clock

Steve asked for the time from the start of the gate to both sites live to
come down, with every check still run once. Measured first, from the gate's
own log for 1.0.3.36 (queue script 1494): the gate 831 seconds, of which the
.NET side took 224 and then the node side 541, the browser pass on SQLite
414 of those; the push at 838 seconds; both sites serving the new version at
1,483; and the reads after the roll (the Admin tab's health on both sites,
the page sweep, the live look and the load proof) another 264 seconds, to
1,747. The lane that shipped waited on all of it.

**The reads after the roll no longer hold the ship.** The ship ends when
both domains serve the new version and commit. The reads that follow run as
a job of their own on the same runner, write their own log with one verdict
line, and a red one is reported the moment it is read. Deploy and Deploy
Cosmos already check the version, `/readyz` and the store before either
finishes, so nothing that decides whether a version is live moved; on
1.0.3.36 this is the 264 seconds after the roll.

**The gate script every lane copies is the current one.** The gate lives
outside this repository (ADR: Where a gate lives), and the template a lane
starts from had fallen behind its copies: it still started the .NET side and
the node side together, the shape the addendum of 21 September measured
slower. It now runs the .NET side to its end and then the node side, and
carries what the lane copies carry: the version gate, the two cuts, the test
results file and the allowed list.

**The coverage checks in parts, tried and taken back.** On 1.0.3.36 the
three checks that read every page the site lists, at 390, 1024 and 1280
wide, ran one after another on one browser worker, 307 seconds, while the
other three workers finished early (394 seconds on that worker, 234 to 239
on each of the others). The trial read each width in four parts, every
fourth page, with the file declared parallel so the four workers shared the
twelve parts, and every page still read at every width. Measured on one take
with every pass forced on so it compared like for like (queue script 1497):

| | 1.0.3.36 | In parts |
| --- | --- | --- |
| Browser pass on SQLite | 414 s | 626 s |
| The coverage checks, test time | 5.2 minutes | about 18 minutes |
| The whole gate | 831 s | 1,131 s |

Each part of a width took about as long as the whole width had taken alone,
and one part was retried when the Admin tab did not draw inside thirty
seconds, the starving the file's own comment names for more than three
pages at once. The machine was busier on the trial (1,189 MB free with
Chrome at 2,450 MB, against 1,236 MB and 1,270 MB), and the .NET side, which
the change does not touch, went from 224 seconds to 279; the browser pass
moved by 212 seconds, and the per-test times say where. The browser pass on
this machine is bound by its four cores, and four workers already use them:
spreading the same pages across more tabs at once is slower work, the
finding of 21 September a third time. The parts are taken back and the file
is as it was. The long worker is still the place to look, and the lever
there is what one page costs to read at the phone width.

## Addendum, 2026-09-29: stack, gate once, push once

On 29 September Steve asked for releases to stack instead of shipping one at a time, with the pipeline always taking the latest push. With the deploy group cancelling the run in flight (ADR: The deploy pipeline, addendum of 29 September), the rule for every lane that ships:

- **Stack.** A lane's changes are local commits in order, each checked with the quick check only (the type check, the unit tests, the lint and the .NET tests that read what moved; about two minutes). No commit is rolled on its own.
- **Gate once.** The full gate runs once, on the stacked head. A commit that turns it red is fixed on top; the whole gate is never run again per commit.
- **Push once, without looking.** A lane pushes when its stack is green, without checking whether another roll is in flight: the pipeline takes the newest push and cancels the older roll.
- **Never wait on a roll.** After the push the lane moves on. The proof that a version is live is the version the Actions run's own Verify step reports, not a lane polling both domains.

This version is the second push of the proof: it went to main while 1.0.3.40 was rolling.

## Addendum, 2026-10-05: CI runs on every push again

On 21 September CI stopped running on a push to main, because the gate had just run every suite and each check was to run once. The cost showed up in a floor only CI measures: CI went unrun from 21 September to 3 October, and when it was run by hand on 3 October the coverage step was red, with branches at 67.7% against a floor of 68 (ADR: Counting what the tests cover). On 5 October, with the code under review, Steve asked for CI to run on every push again.

`ci.yml` now runs on a push to main as well as on a pull request and by hand. It runs on GitHub's runners beside Deploy and Deploy Cosmos and holds neither of them up, so the time from push to live does not change. What it adds is a public result on every commit, on a clean machine, and the coverage floor read on the commit that crosses it. GitHub's runners cost nothing on a public repository. A push that touches only the sample under `samples/` skips it, as Deploy does. The gate stays the one that decides whether anything is pushed. `CiTriggerTests` holds the triggers.

## Addendum, 2026-10-10: the coverage read waits on the page, not on the network

Steve asked on 10 October for the test suite to run faster on the laptop that ships (8 GB, four cores) with every step kept. Measured first from the gate's own logs of 9 October with their Playwright reports: the browser pass on SQLite was 545 to 742 seconds of every gate (52 to 72 per cent of it). Its two workers finish within two seconds of each other every time, so the pass is the sum of its test time divided by two; the order the specs run in moves nothing. `coverage.spec.ts` was 389 to 470 seconds of that sum of 1,024 to 1,246 seconds. Each of its three "every page" reads (one per width) took 130 to 170 seconds. On the morning of 10 October, with more running beside the gate, the pass took 1,134 seconds and the phone read overran its twelve-minute budget once.

Then what one page costs, phase by phase, every listed page once in one tab per width (queue 2021, `mentor\logs\gatespeed-2021-pagecost.log`): 166 pages in 204 to 217 seconds a width. The wait for a quiet network was 113 to 119 seconds of it; the fixed wait after the view's signal was 53 seconds; the reader itself was 4 to 8 seconds. Reading the page was a fifth of the time; waiting for nothing in particular was four fifths. `networkidle` is a 500 millisecond window with no request in it, and a view that polls (the vehicle page's room, the Admin cards) keeps that window from opening for a second or more.

**The read waits on what it measures.** `everyPage` opens a page on the load event, which is the browser's own word that the document is in with its sheets and its images. It then waits on the same signals as before (the view's announcement; on the Admin tab the workbench with no card on its way; on the API reference its panel), then on every face being loaded with two frames painted since (`painted`), then reads. Nothing a page is read for changed: the same reader returns the same nine facts for every listed page at the same three widths. The proof that the signals are enough was read before the change shipped. Every page at every width was read twice (once each way) with the facts compared page by page. The first reading (queue 2022, `mentor\logs\gatespeed-2022-signals.log`) found three document pages read before their document had arrived, because the view's announcement says home for a document and the window's own loading line is the signal; that line joined the signals. The second reading of the first draft hung on the inventory, because a wait on every picture never ends for a lazy picture below the fold, which the browser does not fetch until it scrolls into view; the wait is on the pictures in view. The reading that shipped (queue 2027, `mentor\logs\gatespeed-2027-signals.log`): 166 pages at each of 1280, 1024 and 390, facts differing on 0, missing 0, pages failing the look 0 both ways; one tab, one width, 219 to 86 seconds at 1280, 218 to 79 at 1024, 211 to 70 at 390. The first gate of this version (queue 2023) was then red on one page: a vehicle page at 390, read while the inventory it stands on was still loading, which one tab never saw and three tabs on a shared core did; every loading line a view shows (the inventory's, the Admin tab's, the account's) joined the signals, with the vehicle's own layout on a vehicle page. The second gate (queue 2028) ran the desk read into its twelve-minute budget twice on a wait with no end of its own, so every wait in the read now has one: a page that has not loaded in a minute fails the read by name, and a picture that fires neither load nor error holds it for five seconds at most.

```ts
/**
 * The page has what the reader measures: every face is loaded, so a width is the page's own and
 * not a fallback face's; every picture in view has arrived or failed, so a box has its final
 * size (a lazy picture below the fold is not asked for, because the browser will not fetch it
 * until it scrolls into view and the wait would never end); and the browser has painted two
 * frames since, so a layout that follows the last change is in. A named condition in place of a
 * timer: a timer is a guess at how long the page needs, and the guess was three hundred
 * milliseconds on about three hundred and eighty pages a run.
 */
async function painted(tab: Page): Promise<void> {
  await tab.evaluate(async () => {
    await document.fonts.ready;
    const inView = (image: HTMLImageElement) => {
      const box = image.getBoundingClientRect();
      return box.bottom > 0 && box.top < innerHeight && box.right > 0 && box.left < innerWidth;
    };
    const pictures = Promise.all(
      Array.from(document.images)
        .filter((image) => !image.complete && (image.loading !== 'lazy' || inView(image)))
        .map(
          (image) =>
            new Promise<void>((settled) => {
              image.addEventListener('load', () => settled(), { once: true });
              image.addEventListener('error', () => settled(), { once: true });
            })
        )
    );
    // A picture that fires neither load nor error (one whose source is swapped while it is
    // waited on, or one the browser decides not to fetch) must not hold the read for ever; after
    // five seconds the page is read as it stands, which is what a quiet network read too.
    await Promise.race([pictures, new Promise<void>((enough) => setTimeout(enough, 5000))]);
    await new Promise<void>((done) =>
      requestAnimationFrame(() => requestAnimationFrame(() => done()))
    );
  });
}
```
*Live from [`tests/e2e/coverage.spec.ts`](https://github.com/SteveStout/TheYard/blob/1d6b50b/tests/e2e/coverage.spec.ts#L5-L40), region `painted`, read from this build at 1d6b50b.*

What it is worth is read off the gate's own lines in the next addendum, before against after. The gate of this version is the first to run the read this way; a record cannot quote a number from a gate that has not run.

## Addendum, 2026-10-10: the first wait, and the gate's own numbers for the coverage read

The gate's numbers for the version before this one (1.0.3.116, the coverage read on the page's own signals) come from `mentor\logs\gatespeed-2029-gate.log` with the results file it wrote, read against 1.0.3.115's. The browser pass on SQLite: 499 seconds against 465 (545 to 742 across 9 October). The three "every page" reads: 121, 105 and 113 seconds against 113, 117 and 103. The whole gate: 721 seconds against 957, with the two Cosmos DB passes skipped by the gate's own rule both times. Every suite ran, nothing was skipped that was not skipped before, and the pass was green on the third take (the first two are in the addendum above).

So the honest reading: in one tab the read fell from about 210 seconds a width to about 70, and at the gate it did not move. The gate reads three tabs a worker on two workers, six tabs on four cores beside the API and the preview server, and on a saturated machine a tab waiting for a quiet network was giving its core to another tab; take the wait away and the other tab was already using the core. The pass is bound by what the browser has to compute, not by what the test waits for. What the version keeps is worth having anyway: a read that waits on named conditions with an end to each instead of a heuristic window plus a timer. A developer running the spec alone gets the one-tab number. What it means for the next lever is plainer: the time is in rendering about 380 cold page loads of a 2.8 MB application; only fewer loads move the pass, or lighter ones.

Then the floor under every xUnit pass. The slowest test on both stores was one fact, `PersistenceTests.A_database_that_will_not_open_leaves_the_site_serving_from_files`, at 31 seconds on SQLite and 30.7 on Cosmos DB, with the next slowest at 8.8 on one store and 14.5 on the other. The test boots the host against a path that cannot be a database and reads the catalogue from the files. The host asks the store five times before it falls back, with waits of two seconds doubling to sixteen between the asks (ADR: The relational store). The thirty seconds were the waits rather than the asks; the policy's waits are already held by `StoreSecondChanceTests` with a wait that returns at once. **The first wait is read from the configuration** (`Store:FirstWaitMs`, two seconds unless set), the fallback test sets it to zero, and the five asks still happen. Nothing in the container changes: no deployed configuration names the setting. The floor under the xUnit pass drops from the 31 seconds of that one test to the next slowest class, and the gate's own line for this version is in the next addendum.

## Addendum, 2026-10-10: the coverage read arrives in the app, and the gate's numbers for the first wait

The gate's numbers for 1.0.3.117 (the first wait as a setting), from `mentor\logs\gatespeed-2030-gate.log`: xUnit on SQLite 38 seconds against 50 to 57 on the day's gates before it (918 tests, the five rows of the new theory included); the seven live tests 47; xUnit booted on Cosmos DB 104 against 77 to 104, where the floor was never that one test; the whole gate 1,008 seconds with every pass run, against 1,021 to 1,270 for the ships of 9 October that touched `api/`. Green on take one.

Then the lever the addendum above pointed at: fewer loads. Every page of the coverage read was a cold load of the 2.8 MB application (about 380 of them a run); on four cores that rendering was the read's time. **The read arrives in the app the way Back and Forward do.** Each tab loads its first page cold. For every app page after it the address bar is set with a popstate event fired behind it, so the app re-reads the whole view from the address (`useAddressBar`, the back-forward region); home comes first, so the page's own signals start from nothing. A page the API serves on its own (the API reference, `/about`, the HTML addresses the sweep holds) is still a load of its own. The same reader returns the same nine facts for every listed page at every width, with every signal of the addendum above still waited on. Cold loads keep their own readers: the smoke spec opens pages cold, as do the landing spec, the drawn spec and the live quality review of every page on both domains.

The proof before it shipped (queue 2031, `mentor\logs\gatespeed-2031-inapp.log`): every page at every width read both ways (cold, then in the app) with the facts compared page by page: 166 pages at each of 1280, 1024 and 390, facts differing on 0, missing 0; one tab, one width, 84 to 39 seconds at 1280, 81 to 37 at 1024, 71 to 32 at 390. What that is worth at the gate, where six tabs share four cores, is the gate's own line for this version, in the next addendum.

```ts
/** The tab holds the application, not a page the API serves on its own. */
function appIsOpen(tab: Page): boolean {
  const address = tab.url();
  return address.includes('localhost:5173') && !/\/api\/|\/about$/.test(address);
}

/**
 * Reaches an address the way Back and Forward do: the address bar is set and a popstate event
 * fired, and the app re-reads the whole view from the address. Home first, so the page's own
 * loading signals start from nothing; a document window still open from the last page, or a
 * vehicle still drawn, would otherwise be read as the new page. A cold load of the 2.8 MB
 * application per page was most of the read's time on the gate's four cores; the first page of
 * each tab still loads cold, and so does every page the API serves on its own.
 */
async function arriveInApp(tab: Page, address: string): Promise<void> {
  await tab.evaluate(() => {
    window.history.pushState({}, '', '/');
    window.dispatchEvent(new PopStateEvent('popstate'));
  });
  await expect(tab.getByTestId('view-announcement')).toHaveText('The Yard, home', {
    timeout: 30_000,
  });
  await expect(tab.locator('dialog[open]')).toHaveCount(0, { timeout: 30_000 });
  await tab.evaluate((next) => {
    window.history.pushState({}, '', next);
    window.dispatchEvent(new PopStateEvent('popstate'));
  }, address);
}
```
*Live from [`tests/e2e/coverage.spec.ts`](https://github.com/SteveStout/TheYard/blob/1d6b50b/tests/e2e/coverage.spec.ts#L44-L71), region `arrive-in-app`, read from this build at 1d6b50b.*

## Addendum, 2026-10-10: the gate's numbers for the read that arrives in the app, and the day in one table

The gate's numbers for 1.0.3.118 come from `mentor\logs\gatespeed-2032-gate.log` with the results file it wrote. The three "every page" reads 63, 70 and 58 seconds against 121, 105 and 113 at 1.0.3.116 (and 113, 117 and 103 at 1.0.3.115); the coverage spec 196 seconds of test time against 337 to 344; the browser pass on SQLite 401 seconds against 465 to 511 on the day's gates before it and 545 to 742 across 9 October; the whole gate 655 seconds against 875 to 905 for the 9 October ships of the same shape (the two Cosmos DB passes skipped by the gate's own rule). Every one of the 156 browser tests ran, green on take one. So the lever the two addenda above pointed at was the right one: fewer cold loads of the application moved the pass where shorter waits did not.

The day, measured on the gate's own lines, is tabled on the Performance page under "The gate on an 8 GB laptop, measured on 10 October": what was kept (the signals; the first wait as a setting; the read arriving in the app; in the gate copies, prettier's cache with the scoped format check) and what was measured then dropped (the order the specs run in; more browser workers), each with its log. The target this record is named for stands: five minutes, and 655 seconds is not it. What is left is the browser pass at 401 seconds on four cores, of which the coverage spec is now about a quarter, and the rest is 150 tests that each open the site cold.

## Addendum, 2026-10-13: the gate runs where the memory is

From 1.0.3.123 the gate runs on a GitHub runner with 16 GB, not on the laptop that ships (ADR: The gate runs where the memory is). Every check in this record runs there, in the same order, with the same two cuts; The Shed's suites run beside it on Windows. The laptop builds the commit, pushes it to `gate/<version>`, and fast-forwards main only when the run is green. The reason is the one this record kept measuring: on an 8 GB laptop the gate waited for memory, and started short of it, specs the change never touched timed out.

## Addendum, 2026-10-16 (1.0.3.145): the commit on main is the tested commit plus its results

The ship pushes the commit to `gate/<version>`, waits for the gate, then folds the results files the gate produced (`data/test-results.json`, the README's counts, the Built with AI page) into that same commit and fast-forwards main to it. So main's hash is never the hash the gate ran on: the gate tested the code, and main holds the code with three generated files added. Two things make that safe, and both are checked. The fold touches only files the ship script lists, and that script (`ship-from-branch.ps1`, which lives in the operator's folder on the shipping PC, outside this repository, as the ship's own tooling always has) refuses the commit if anything else changed. CI on main then runs every suite again on the folded commit, and the Deploy workflows read the version from that commit. The review of 13 October asked for this to be written down; it is this paragraph. A ship that wanted main's hash to equal the gate's would have to produce the results before the gate ran, which is the thing the gate exists to prevent.

The ship also leaves its `gate/<version>` branch on GitHub. From 1.0.3.145 that script, outside this repository, deletes the branch once both domains answer the new version, so the branch list holds only the gates still in flight.
