# ADR: The store does the work

Status: accepted, 2026-10-18. The hundred thousand vehicles live in the store and nowhere else: the Catalogue table on Azure SQL Database and the catalogue container on Cosmos DB, each vehicle written once with the slot its id's hash gives its auction window. A listing, the filter values, a vehicle by id and the simulated room's picks are queries against the store the request is on; the store filters, orders and pages, and only the page comes back. The deterministic expansion of the 200 seed vehicles is now the seeder: it fills an empty store once and is let go. Each .NET site runs under a heap limit of 30 per cent.

## Reading list

For Steve, when he is fresh, in this order:

1. [Memory, held by the build](https://theyard.stevenstout.biz/?doc=memory), the Best Practices page: the eight rules, each with why, the number this change moved, and the code that holds it.
2. This record: why the catalogue in memory was a cache pretending to be a store, what replaced it, and what it measured.
3. [ADR-094, compiled before it ships](https://theyard.stevenstout.biz/?doc=adr-compiled-before-it-ships): where the GC settings this record leans on were chosen.
4. Microsoft Learn, [Fundamentals of garbage collection](https://learn.microsoft.com/en-us/dotnet/standard/garbage-collection/fundamentals): generations, and why a long-lived object costs more than a short-lived one.
5. Microsoft Learn, [The large object heap on Windows](https://learn.microsoft.com/en-us/dotnet/standard/garbage-collection/large-object-heap): the 85,000-byte line and what crossing it costs.
6. Microsoft Learn, [Runtime configuration options for garbage collection](https://learn.microsoft.com/en-us/dotnet/core/runtime-config/garbage-collector): every setting this record names, including the one that has to be written in hexadecimal.

Each outside link was fetched on 2026-10-18 before it was cited.

## In plain words

The site used to load all hundred thousand vehicles into its own memory, twice, once for each database, and answer every search by sorting through that copy. The databases held only the 200 starting vehicles. Now the databases hold all hundred thousand and answer the search themselves; the site asks for one page and gets one page.

What that is worth: the memory the four apps share stops being spent on a second copy of data a database already keeps, and the database does the job it is built for. A slower answer is the price, and this record says exactly how much slower.

## Context

Steve's ruling, 2026-10-18, 15:41 to 15:43 CDT: keep the B2, keep the hundred thousand vehicles, but stop holding them in RAM, "this work should happen on the database", and hold the apps to memory best practices he can learn from.

The plan card read 2.77 GB of 3.5 GB at 15:40 CDT. Measured before anything changed, 1.0.3.176 at 22:01 to 22:03 UTC, about fifty minutes after its roll:

| reading | SQL site | Cosmos DB site |
| --- | ---: | ---: |
| working set | 753.2 MB | 746.6 MB |
| GC heap | 418.5 MB | 461.2 MB |
| managed bytes in use | 386.3 MB | 341.3 MB |
| catalogues loaded | both, 2 x 100,000 | both, 2 x 100,000 |
| listing p50, server, 32 reads | 28 ms | 43 ms |
| listing p95 | 68 ms | 69 ms |

The plan as a whole: 82 per cent, 2.87 of 3.5 GB, amber, with a thirty-day 95th of 83 and a peak of 93. Workstation GC with concurrent collection was already in effect on both (ADR-094), and `Store:WarmOthers` was already off, but the keep-warm loop loaded the other store's catalogue within minutes anyway, so each site held both.

A catalogue in memory was a cache pretending to be a store. It had no bound, it never expired, it was rebuilt from the seed on every start, and it was the one copy the site trusted. The databases, which are the place a hundred thousand rows belong, held two hundred.

## Decision

**The store holds the hundred thousand, each with its slot.** `AuctionSchedule.Window` puts every window at the day's anchor plus an offset that depends on the id alone. Those two offsets, start and end, are written beside each vehicle at the fill, with the price a buyer competes against and the lowercased searchable text. The hash stays the authority. A test reads all hundred thousand slots back and holds each one to the window the hash gives on four anchors either side of both daylight-saving changes, and the status the clock derives to the segment the slot gives. A vehicle's status is still derived from the window and the clock; the store compares the slot with now instead of the code doing it.

```csharp
/// <summary>
/// The slot a vehicle's id gives it: where its window starts and ends, as milliseconds from
/// the day's anchor. <see cref="AuctionSchedule.Window"/> places every window at the anchor plus
/// an offset that depends on the id alone, so the offsets are the same every day and can be
/// written down beside the vehicle, which is what lets the store order a page by auction time.
/// The hash stays the authority: a test reads the slot back for every vehicle and holds it to the
/// window the hash gives on several anchors (ADR: The store does the work).
/// </summary>
/// <param name="StartOffsetMs">The window's start, milliseconds from the anchor; negative before it.</param>
/// <param name="EndOffsetMs">The window's end, milliseconds from the anchor.</param>
public readonly record struct CatalogueSlot(long StartOffsetMs, long EndOffsetMs)
{
    /// <summary>The slot for an id: the window the schedule gives it on an anchor of zero.</summary>
    public static CatalogueSlot For(string vehicleId)
    {
        var window = AuctionSchedule.Window(vehicleId, 0);
        return new CatalogueSlot(window.StartsAtMs, window.EndsAtMs);
    }

    /// <summary>The price a buyer competes against on the dataset's own figures: the current bid, or the opening ask.</summary>
    public static int PriceOf(Vehicle vehicle) => vehicle.CurrentBid ?? vehicle.StartingBid;

    /// <summary>Where this auction stands at a moment given as milliseconds since the anchor, by the schedule's own rule.</summary>
    public CatalogueSegment SegmentAt(long sinceAnchorMs) =>
        sinceAnchorMs < StartOffsetMs ? CatalogueSegment.Upcoming
        : sinceAnchorMs < EndOffsetMs ? CatalogueSegment.Live
        : CatalogueSegment.Ended;
}
```
*Live from [`api/TheYard.Application/CatalogueFilter.cs`](https://github.com/SteveStout/TheYard/blob/7830983/api/TheYard.Application/CatalogueFilter.cs#L194-L221), region `slot`, read from this build at 7830983.*

```csharp
/// <summary>
/// The hash stays the authority. For all hundred thousand vehicles and on days either side of
/// both daylight-saving changes, the anchor plus the stored slot is the window the schedule
/// derives, to the millisecond, and the segment it puts the vehicle in is the status it derives.
/// </summary>
[Fact]
public async Task The_slot_beside_every_vehicle_is_the_window_the_hash_gives_on_any_day()
{
    var vehicles = await ExpansionAsync(100_000);
    long[] anchors =
    [
        new DateTimeOffset(2026, 3, 8, 0, 0, 0, TimeSpan.Zero).ToUnixTimeMilliseconds(),
        new DateTimeOffset(2026, 10, 18, 0, 0, 0, TimeSpan.Zero).ToUnixTimeMilliseconds(),
        new DateTimeOffset(2026, 11, 1, 0, 0, 0, TimeSpan.Zero).ToUnixTimeMilliseconds(),
        new DateTimeOffset(2027, 2, 28, 0, 0, 0, TimeSpan.Zero).ToUnixTimeMilliseconds(),
    ];
    int wrong = 0;
    foreach (var vehicle in vehicles)
    {
        var slot = CatalogueSlot.For(vehicle.Id);
        foreach (long anchor in anchors)
        {
            var window = AuctionSchedule.Window(vehicle.Id, anchor);
            long now = anchor + 13 * 3_600_000L;
            var status = AuctionSchedule.Status(window, now);
            var segment = slot.SegmentAt(now - anchor);
            bool same = window.StartsAtMs == anchor + slot.StartOffsetMs
                && window.EndsAtMs == anchor + slot.EndOffsetMs
                && segment.ToString() == status.ToString();
            wrong += same ? 0 : 1;
        }
    }

    Assert.Equal(0, wrong);
}
```
*Live from [`api/TheYard.Tests/StoreCatalogueTests.cs`](https://github.com/SteveStout/TheYard/blob/7830983/api/TheYard.Tests/StoreCatalogueTests.cs#L24-L58), region `slot`, read from this build at 7830983.*

**Application plans, the adapters translate.** The port `IVehicleCatalogue` has five primitives: how full the store is, a fill, the counts split by schedule segment, one page in one order, and point reads by id. The listing is planned once, in `InventoryService`. The default listing is three segments read one after another (live by closest end, upcoming by soonest start, ended by most recent end), each off its own index, stopping at the page's last row. The adapters only translate a filter and an order into their own query:

```csharp
/// <summary>
/// The filter as a WHERE clause: the store's own spellings compared with equality, the
/// segment as a comparison of the slot with now, every typed word as a substring of the
/// searchable text or a status word that names the row's segment, and the ids somebody's
/// bid moved left out as one JSON parameter, however many there are.
/// </summary>
private IQueryable<CatalogueRow> Query(YardDbContext db, CatalogueFilter filter)
{
    long d = filter.SinceAnchorMs;
    var rows = db.Catalogue.AsNoTracking();
    if (_beyond)
    {
        rows = rows.Where(row => row.Seq < target);
    }
    if (filter.Make is { } make)
    {
        rows = rows.Where(row => row.Make == make);
    }
    if (filter.BodyStyle is { } bodyStyle)
    {
        rows = rows.Where(row => row.BodyStyle == bodyStyle);
    }
    if (filter.TitleStatus is { } titleStatus)
    {
        rows = rows.Where(row => row.TitleStatus == titleStatus);
    }
    if (filter.Province is { } province)
    {
        rows = rows.Where(row => row.Province == province);
    }
    // A typed word is looked for in every row, so on a SQLite file the schedule's columns are
    // then compared as "+ 0", which no index serves: SQLite's planner, which has no statistics,
    // reads the narrow words index alone rather than ranging over a schedule index and reading
    // every row in the range for its text. SQL Server's planner has statistics and is left to them.
    rows = (filter.Segment, local && filter.Tokens.Count > 0) switch
    {
        (CatalogueSegment.Live, false) => rows.Where(row => row.StartOffsetMs <= d && row.EndOffsetMs > d),
        (CatalogueSegment.Upcoming, false) => rows.Where(row => row.StartOffsetMs > d),
        (CatalogueSegment.Ended, false) => rows.Where(row => row.EndOffsetMs <= d),
        (CatalogueSegment.Live, true) => rows.Where(row => row.StartOffsetMs + 0 <= d && row.EndOffsetMs + 0 > d),
        (CatalogueSegment.Upcoming, true) => rows.Where(row => row.StartOffsetMs + 0 > d),
        (CatalogueSegment.Ended, true) => rows.Where(row => row.EndOffsetMs + 0 <= d),
        _ => rows,
    };
    if (filter.MinCondition is { } grade)
    {
        rows = rows.Where(row => row.ConditionGrade >= grade);
    }
    if (filter.PriceMin is { } low)
    {
        rows = rows.Where(row => row.Price >= low);
    }
    if (filter.PriceMax is { } high)
    {
        rows = rows.Where(row => row.Price <= high);
    }
    foreach (var token in filter.Tokens)
    {
        string text = token.Text;
        bool live = token.AlsoMatches.Contains(CatalogueSegment.Live);
        bool upcoming = token.AlsoMatches.Contains(CatalogueSegment.Upcoming);
        bool ended = token.AlsoMatches.Contains(CatalogueSegment.Ended);
        rows = rows.Where(row => row.SearchText.Contains(text)
            || (live && row.StartOffsetMs <= d && row.EndOffsetMs > d)
            || (upcoming && row.StartOffsetMs > d)
            || (ended && row.EndOffsetMs <= d));
    }
    if (filter.Exclude.Count > 0)
    {
        string[] moved = [.. filter.Exclude];
        rows = rows.Where(row => !EF.Parameter(moved).Contains(row.Id));
    }
    return rows;
}

/// <summary>The order as an ORDER BY that one index serves, the place breaking every tie.</summary>
private static IQueryable<CatalogueRow> Ordered(IQueryable<CatalogueRow> rows, CatalogueOrder order) => order switch
{
    CatalogueOrder.EndAscending => rows.OrderBy(row => row.EndOffsetMs).ThenBy(row => row.Seq),
    CatalogueOrder.StartAscending => rows.OrderBy(row => row.StartOffsetMs).ThenBy(row => row.Seq),
    CatalogueOrder.EndDescending => rows.OrderByDescending(row => row.EndOffsetMs).ThenBy(row => row.Seq),
    CatalogueOrder.PriceAscending => rows.OrderBy(row => row.Price).ThenBy(row => row.Seq),
    CatalogueOrder.PriceDescending => rows.OrderByDescending(row => row.Price).ThenBy(row => row.Seq),
    CatalogueOrder.ConditionDescending => rows.OrderByDescending(row => row.ConditionGrade).ThenBy(row => row.Seq),
    CatalogueOrder.BidsDescending => rows.OrderByDescending(row => row.BidCount).ThenBy(row => row.Seq),
    _ => rows.OrderBy(row => row.Seq),
};

/// <summary>
/// The three segments' counts in one statement: one scan of the narrow counts index, three
/// conditional counts. A filter kept to one segment is counted without the segment in its WHERE
/// and the other two counts set to nothing here, because the conditional counts already split
/// the rows by segment, and a segment in the WHERE draws the planner to a range over a schedule
/// index that then reads every row in it for the other columns the filter names.
/// </summary>
public async Task<SegmentCounts> CountAsync(CatalogueFilter filter)
{
    await using var db = await contexts.CreateDbContextAsync();
    long d = filter.SinceAnchorMs;
    var counts = await Query(db, filter.In(CatalogueSegment.Any))
        .GroupBy(_ => 1)
        .Select(all => new
        {
            Live = all.Count(row => row.StartOffsetMs <= d && row.EndOffsetMs > d),
            Upcoming = all.Count(row => row.StartOffsetMs > d),
            Ended = all.Count(row => row.EndOffsetMs <= d),
        })
        .FirstOrDefaultAsync();
    if (counts is null)
    {
        return SegmentCounts.None;
    }
    return filter.Segment switch
    {
        CatalogueSegment.Live => new SegmentCounts(counts.Live, 0, 0),
        CatalogueSegment.Upcoming => new SegmentCounts(0, counts.Upcoming, 0),
        CatalogueSegment.Ended => new SegmentCounts(0, 0, counts.Ended),
        _ => new SegmentCounts(counts.Live, counts.Upcoming, counts.Ended),
    };
}

/// <summary>One page: the ORDER BY, then OFFSET and FETCH in the database, so it reads only as far as the page.</summary>
public async Task<IReadOnlyList<CatalogueEntry>> PageAsync(CatalogueFilter filter, CatalogueOrder order, int skip, int take)
{
    if (take <= 0)
    {
        return [];
    }
    await using var db = await contexts.CreateDbContextAsync();
    if (!local || filter.Tokens.Count == 0)
    {
        var rows = await Ordered(Query(db, filter), order).Skip(skip).Take(take).ToListAsync();
        return [.. rows.Select(row => row.ToEntry())];
    }
    // With a typed word on a SQLite file, the page's places first, off the words index alone and in an order no
    // index serves, so the planner cannot walk an order's index reading every row for its text;
    // then the page's rows by their place.
    var places = await Unindexed(Query(db, filter), order).Skip(skip).Take(take).Select(row => row.Seq).ToListAsync();
    if (places.Count == 0)
    {
        return [];
    }
    var found = (await db.Catalogue.AsNoTracking().Where(row => EF.Parameter(places).Contains(row.Seq)).ToListAsync())
        .ToDictionary(row => row.Seq);
    return [.. places.Select(place => found[place].ToEntry())];
}
```
*Live from [`api/TheYard.Infrastructure/EfVehicleCatalogue.cs`](https://github.com/SteveStout/TheYard/blob/7830983/api/TheYard.Infrastructure/EfVehicleCatalogue.cs#L70-L215), region `ef-catalogue-query`, read from this build at 7830983.*

```csharp
/// <summary>
/// The filter as a WHERE clause on indexed paths: the store's own spellings compared with
/// equality, the segment as a comparison of the slot with now, every typed word as CONTAINS on
/// the searchable text or a status word that names the segment, and the moved ids left out
/// with one array parameter.
/// </summary>
private static (string Where, List<(string Name, object Value)> Parameters, string? Partition) Where(CatalogueFilter filter, int target)
{
    var where = new StringBuilder(Written);
    var parameters = new List<(string, object)> { ("@target", target), ("@d", filter.SinceAnchorMs) };
    void Equal(string path, string name, string? value)
    {
        if (value is not null)
        {
            where.Append($" AND c.{path} = {name}");
            parameters.Add((name, value));
        }
    }
    Equal("make", "@make", filter.Make);
    Equal("body_style", "@body", filter.BodyStyle);
    Equal("title_status", "@title", filter.TitleStatus);
    Equal("province", "@province", filter.Province);
    where.Append(Segment(filter.Segment) is { } segment ? $" AND {segment}" : "");
    if (filter.MinCondition is { } grade)
    {
        where.Append(" AND c.condition_grade >= @grade");
        parameters.Add(("@grade", grade));
    }
    if (filter.PriceMin is { } low)
    {
        where.Append(" AND c.price >= @low");
        parameters.Add(("@low", low));
    }
    if (filter.PriceMax is { } high)
    {
        where.Append(" AND c.price <= @high");
        parameters.Add(("@high", high));
    }
    for (int i = 0; i < filter.Tokens.Count; i++)
    {
        var token = filter.Tokens[i];
        var either = new List<string> { $"CONTAINS(c.search, @t{i})" };
        either.AddRange(token.AlsoMatches.Select(Segment).OfType<string>());
        where.Append($" AND ({string.Join(" OR ", either)})");
        parameters.Add(($"@t{i}", token.Text));
    }
    if (filter.Exclude.Count > 0)
    {
        where.Append(" AND NOT ARRAY_CONTAINS(@moved, c.id)");
        parameters.Add(("@moved", filter.Exclude.ToArray()));
    }
    return (where.ToString(), parameters, filter.Make);
}

private static string? Segment(CatalogueSegment segment) => segment switch
{
    CatalogueSegment.Live => "(c.start_offset <= @d AND c.end_offset > @d)",
    CatalogueSegment.Upcoming => "c.start_offset > @d",
    CatalogueSegment.Ended => "c.end_offset <= @d",
    _ => null,
};

/// <summary>The order as an ORDER BY one composite index serves (infra/cosmos/catalogue.json), the place breaking every tie.</summary>
private static string OrderBy(CatalogueOrder order) => order switch
{
    CatalogueOrder.EndAscending => "c.end_offset ASC, c.seq ASC",
    CatalogueOrder.StartAscending => "c.start_offset ASC, c.seq ASC",
    CatalogueOrder.EndDescending => "c.end_offset DESC, c.seq ASC",
    CatalogueOrder.PriceAscending => "c.price ASC, c.seq ASC",
    CatalogueOrder.PriceDescending => "c.price DESC, c.seq ASC",
    CatalogueOrder.ConditionDescending => "c.condition_grade DESC, c.seq ASC",
    CatalogueOrder.BidsDescending => "c.bid_count DESC, c.seq ASC",
    _ => "c.seq ASC",
};

private static QueryDefinition Define(string text, List<(string Name, object Value)> parameters)
{
    var query = new QueryDefinition(text);
    foreach (var (name, value) in parameters)
    {
        query = query.WithParameter(name, value);
    }
    return query;
}

/// <summary>
/// The three segments' counts: one COUNT per segment the filter keeps, each answered off the
/// index without loading a document, because the store has no conditional count.
/// </summary>
public async Task<SegmentCounts> CountAsync(CatalogueFilter filter)
{
    async Task<int> Count(CatalogueSegment segment)
    {
        if (filter.Segment != CatalogueSegment.Any && filter.Segment != segment)
        {
            return 0;
        }
        var (where, parameters, partition) = Where(filter.In(segment), target);
        var counts = await store.QueryAsync<int>(Catalogue, Define($"SELECT VALUE COUNT(1) FROM c WHERE {where}", parameters), partition, Label(partition));
        return counts.Count == 0 ? 0 : counts[0];
    }
    return new SegmentCounts(
        await Count(CatalogueSegment.Live),
        await Count(CatalogueSegment.Upcoming),
        await Count(CatalogueSegment.Ended));
}

/// <summary>One page: the ORDER BY, then OFFSET and LIMIT in the store, so it reads only as far as the page. The two numbers are integers this code computed, written into the text.</summary>
public async Task<IReadOnlyList<CatalogueEntry>> PageAsync(CatalogueFilter filter, CatalogueOrder order, int skip, int take)
{
    if (take <= 0)
    {
        return [];
    }
    var (where, parameters, partition) = Where(filter, target);
    var documents = await store.QueryAsync<CatalogueDocument>(
        Catalogue,
        Define($"SELECT * FROM c WHERE {where} ORDER BY {OrderBy(order)} OFFSET {skip} LIMIT {take}", parameters),
        partition,
        Label(partition));
    return [.. documents.Select(document => document.ToEntry())];
}
```
*Live from [`api/TheYard.Infrastructure.Cosmos/CosmosVehicleCatalogue.cs`](https://github.com/SteveStout/TheYard/blob/7830983/api/TheYard.Infrastructure.Cosmos/CosmosVehicleCatalogue.cs#L61-L182), region `cosmos-catalogue-query`, read from this build at 7830983.*

On SQL Server each order is one index ending in `Seq` (`api/TheYard.Database/Tables/Catalogue.sql`), so a page is a seek and a scan that stops, and every read is `AsNoTracking`. The two schedule indexes carry the other end of the window, so a segment's count and a live page's test of its start never read the row, and a filtered count reads one narrow index holding only the columns a count compares, `IX_Catalogue_Counts`, rather than the table at about two kilobytes a row. That index leads with the columns no page ranges over, so the planner never pages off it and then sorts. Measured on the PC before it was added, against a local file of all hundred thousand: the unfiltered count took 400 to 600 ms and a listing 300 to 1,100 ms, enough for seven of the browser suite's 168 tests to run out of time under four browsers at once. A query starts from `Seq < target` only when the table holds rows past the catalogue, because that range over the clustered key is what kept the planner off the order's own index. When bids have moved prices, the count is the store's cached count less the moved vehicles that match on the dataset's figures, and the page is the store's cached page made longer by that many, with the moved ones dropped in the process and each row's place worked out from how many moved vehicles sort before it. Putting the ids into the store's query made it test every row it read against the list, 25 seconds for a count and 58 for a page once under the browser suite, and gave the cache a new key with every bid. And a cache miss loads once a key: everybody who asks while a count is loading waits for that one. Each descending order has an index that descends on its figure and ascends on the place, the way the page is ordered, because an index the wrong way round on either key had SQLite sort every live vehicle for a page of twenty-five (27 seconds once under the suite). A local SQLite file pools its connections, so its page cache outlives a statement; the pool for that one file is emptied before the file is deleted. `StoreCatalogueTests` reads SQLite's own plan for every count and page shape and fails on a count that reads the table or a page that sorts its matches. Counts are read only when a number needs them: unfiltered, the total is the catalogue's size, so the default listing's first pages, the ones most visitors read, cost the store one page and no count. A typed word is looked for in `IX_Catalogue_Words`, the searchable text beside every column a word's count or page compares or orders by. On a SQLite file, whose planner has no statistics, a word's page reads its places off that index alone, with the schedule and order columns written as `+ 0` so no other index can draw the planner into reading every row of a range for its text, and then reads the page's rows by their place. On Cosmos DB every path a WHERE names is in the indexing policy, every two-key ORDER BY has its composite index, and a vehicle read by id is a point read: the synthetic id is a seed id and six characters, and the seed's two hundred makes give every id its partition, so nothing a visitor opens fans out (ADR: The partition key). An unfiltered listing does fan out, to the container's one physical partition, which that record measured at the cost of a pinned page at this size.

**A bid moves a price the store does not hold.** Everybody's bids and the room's answers stay in memory, as they were (BidService, MarketService). When a move can change which vehicles match or where they sort (a price filter, a price sort, the bid-count sort), the store leaves those vehicles out, they are worked out in memory from their raised figures, and the two ordered lists are merged at the page. Otherwise the store's page is the page and the moves are laid on its rows. A test holds every page, at every offset tried, to the page the full sort over the raised catalogue gives.

```csharp
// A bid or the room moves a price, and the store holds the dataset's figure. When the move
// can change which vehicles match or where they sort, the store leaves those vehicles out,
// they are worked out here from their raised figures, and the two ordered lists are merged
// at the page. Otherwise the store's page is the page, and the moves are laid on its rows.
bool overlayMatters = overlay is { Ids.Count: > 0 }
    && (sort is VehicleSort.PriceAsc or VehicleSort.PriceDesc or VehicleSort.MostBids
        || filter.PriceMin is not null || filter.PriceMax is not null);
if (!overlayMatters)
{
    var (total, rows) = await StorePageAsync(catalogue, resolved, sort, offset, limit);
    return new SearchResult(total, [.. rows.Select(entry => Finish(entry.Vehicle, warm, overlay))]);
}

var matches = filter.Compile(clock);
var moved = await FindEntriesAsync(catalogue, overlay!.Ids);
var raised = moved
    .Select(entry => entry with { Vehicle = overlay.Apply(entry.Vehicle) })
    .Where(entry => matches(entry.Vehicle))
    .OrderBy(entry => Key(entry, sort, resolved.SinceAnchorMs))
    .ToList();
int skip = Math.Max(0, offset - raised.Count);
int take = (int)Math.Min(int.MaxValue, (long)offset + limit - skip);
var (storeTotal, stored) = await StorePageAsync(catalogue, resolved with { Exclude = overlay.Ids }, sort, skip, take, moved);
return new SearchResult(
    storeTotal + raised.Count,
    // The raised vehicles already stand at their price and the store's rows are none of
    // the moved ones, so nothing is laid on twice.
    [.. Merge(stored, raised, sort, resolved.SinceAnchorMs, skip, offset, limit).Select(entry => Finish(entry.Vehicle, warm, null))]);
```
*Live from [`api/TheYard.Application/InventoryService.Search.cs`](https://github.com/SteveStout/TheYard/blob/7830983/api/TheYard.Application/InventoryService.Search.cs#L36-L63), region `overlay`, read from this build at 7830983.*

**The expansion is the seeder.** At warm-up the site counts what its store holds. Full, the store answers and nothing is loaded. Short by a few thousand, the warm-up fills it first. Short by more, the site answers from the expansion in memory while it fills its own store beside itself at the store's pace, then switches, lets the list go, and runs one compacting collection so the dial shows the memory the same minute. The other site watches and switches when the fill is done. `Catalogue__Keep` turns that on for the deploy and leaves every test host alone.

```csharp
/// <summary>
/// The store when it holds the whole catalogue; filled first when what is missing is small
/// enough to write before answering; otherwise the expansion in memory, with the store filled
/// beside the site when this site owns the fill. A store that cannot be read at all is the
/// same as an empty one that is not filled: the site keeps serving, and the Admin tab says why.
/// </summary>
private async Task<IVehicleCatalogue> ChooseAsync()
{
    if (_store is null)
    {
        return Bridge(await _seeder.LoadAsync());
    }

    int filled;
    try
    {
        filled = await _store.FilledAsync();
    }
    catch (Exception ex) when (ex is not OutOfMemoryException)
    {
        _note = $"the store's catalogue could not be read ({ex.GetType().Name}), so the expansion answers from memory";
        var unread = Bridge(await _seeder.LoadAsync());
        Watch(_store);
        return unread;
    }
    Volatile.Write(ref _filled, filled);
    int missing = _store.Target - filled;
    if (missing <= 0)
    {
        return _store;
    }

    var expansion = await _seeder.LoadAsync();
    if (missing <= _store.Pace.FillAtWarmUpTo)
    {
        await FillAsync(_store, expansion, filled, pause: false);
        return _store;
    }

    var bridge = Bridge(expansion);
    if (_upkeep == CatalogueUpkeep.Fill)
    {
        _note = $"the store holds {filled:N0} of {_store.Target:N0}; filling it beside the site";
        // Handed over through a field rather than captured by the lambda, so once the fill
        // lets go of the list nothing else in this object still holds it.
        _pending = expansion;
        var store = _store;
        Filling = Task.Run(() => FillBesideAsync(store, filled));
    }
    else
    {
        _note = $"the store holds {filled:N0} of {_store.Target:N0}, and this site does not fill it, so the expansion answers from memory";
        Watch(_store);
    }
    return bridge;
}

/// <summary>
/// For a site that does not fill this store: count it once a minute, for a day at most, and
/// switch to it the minute it holds the whole catalogue, which is when the other site's fill
/// finishes. Nothing when upkeep is off, which is every test host.
/// </summary>
private void Watch(IVehicleCatalogue store)
{
    if (_upkeep == CatalogueUpkeep.Off)
    {
        return;
    }
    Filling = Task.Run(async () =>
    {
        for (int minute = 0; minute < 24 * 60; minute++)
        {
            await Task.Delay(TimeSpan.FromMinutes(1));
            try
            {
                int filled = await store.FilledAsync();
                Volatile.Write(ref _filled, filled);
                if (filled >= store.Target)
                {
                    SwitchTo(store);
                    return;
                }
            }
            catch (Exception ex) when (ex is not OutOfMemoryException)
            {
                _note = $"the store's catalogue could not be counted ({ex.GetType().Name}); counting again in a minute";
            }
        }
    });
}

/// <summary>
/// The switch: the store answers, the list is let go, and one compacting collection hands its
/// memory back to the plan so the dial shows it the same minute rather than whenever the
/// collector next decides to give pages back.
/// </summary>
private void SwitchTo(IVehicleCatalogue store)
{
    _active = store;
    _bridge = null;
    _note = null;
    _counts.Clear();
    _pages.Clear();
    GC.Collect(2, GCCollectionMode.Aggressive, blocking: true, compacting: true);
}

private MemoryCatalogue Bridge(IReadOnlyList<Vehicle> vehicles)
{
    var bridge = new MemoryCatalogue(vehicles);
    _bridge = bridge;
    return bridge;
}
```
*Live from [`api/TheYard.Application/InventoryService.Warm.cs`](https://github.com/SteveStout/TheYard/blob/7830983/api/TheYard.Application/InventoryService.Warm.cs#L73-L184), region `choose`, read from this build at 7830983.*

**Every cache is bounded.** `BoundedCache` holds the listing's counts and pages for one five-second bucket of the clock and the vehicles read by id for ten minutes, each bounded by a count and by an estimate of its bytes.

```csharp
// Bounded by count and bytes, expiring after a bucket of the clock: a store's answer is reused
// for the five seconds a page of auctions cannot visibly change in, and never grows past the
// numbers here (ADR: The store does the work, rule b).
private readonly BoundedCache<string, SegmentCounts> _counts = new(512, 1L << 20, TimeSpan.FromSeconds(5), _ => 96);
private readonly BoundedCache<string, IReadOnlyList<CatalogueEntry>> _pages = new(64, 16L << 20, TimeSpan.FromSeconds(5), page => 64 + (page.Count * ExpectedVehicleBytes));
private readonly BoundedCache<string, CatalogueEntry> _vehicles = new(4_096, 8L << 20, TimeSpan.FromMinutes(10), _ => ExpectedVehicleBytes, comparer: StringComparer.Ordinal);
```
*Live from [`api/TheYard.Application/InventoryService.cs`](https://github.com/SteveStout/TheYard/blob/7830983/api/TheYard.Application/InventoryService.cs#L73-L78), region `listing-cache`, read from this build at 7830983.*

**A heap limit on each site.** `DOTNET_GCHeapHardLimitPercent` is `0x1E` on both .NET sites in `infra/appservice.bicep`. GC settings read from the environment are hexadecimal, so `30` would have been forty-eight per cent. With the limit, a regression is collected harder, and refused if it must be, inside its own share instead of taking memory from the other three apps on the plan.

## The audit

The eight rules of the Memory page, applied to every app on the plan:

| rule | before | after | status |
| --- | --- | --- | --- |
| a. A store is not a cache | the catalogue held whole in each process, both stores | the store holds it; the process holds 0 once the store answers | fixed by this change |
| b. Every cache bounded, every cache expires | the listing had no cache; the reset-mail limit and the self-test cooldown grew with every address and account; the memory keeps' hours grew forever | `BoundedCache` for the listing; both limits forget stale entries and the mail limit stops at 10,000; the hours trimmed to a month; a test names every collection field with its bound | fixed by this change |
| c. Page, never materialise | every listing walked the whole list | OFFSET and FETCH in the store, `AsNoTracking` on every read | fixed by this change |
| d. Streams over byte arrays past 85 KB | a picture's size read by loading the whole file, up to 941 KB; a document rendered on every read | the header only; each rendered document kept once, bounded | fixed by this change; the rendered documents are deliberately not streamed, because the live fences rewrite a document whole |
| e. Workstation GC, concurrent, and a heap limit | workstation and concurrent since ADR-094; no limit | `0x1E` on both sites | fixed by this change; The Shed is a .NET app with its own template and is the next one |
| f. Pool what is expensive | seven readers each with a static `HttpClient`; one `CosmosClient` per process already | one `SocketsHttpHandler` for every outbound reader, connections retired after two minutes | fixed, by the form Microsoft's guidelines name for .NET 5 and later rather than `IHttpClientFactory`; the loopback clients keep handlers of their own, named in the test |
| g. No static collection that grows with traffic | none found; the rings were already rings | the same, now held by the collection test | already met |
| h. The renderer measured the same way | Node heap against `--max-old-space-size=192` | read below, after the roll | measured |

```csharp
/// <summary>
/// Every collection field in the six production projects, by type and field, with what keeps it
/// from growing with traffic. A new one fails here until somebody writes its bound down; one
/// that is gone fails here until its line goes, so the list can only shrink to the truth.
/// Rows, documents and the records that cross the wire are values, built and dropped per
/// request, and are left out by their names.
/// </summary>
private static readonly Dictionary<string, string> Bounded = new(StringComparer.Ordinal)
{
    ["TheYard.Application.BoundedCache`2._map"] = "the bounded cache itself: a count and a byte limit, and an expiry",
    ["TheYard.Application.BoundedCache`2._recency"] = "the same cache's order of use, the same size as its map",
    ["TheYard.Application.BoundedCache`2._inflight"] = "the loads under way, one a key, each removed the moment it finishes",
    ["TheYard.Application.MemoryCatalogue._entries"] = "the list catalogue: the tests, the file fallback and a fill's bridge, let go when the store answers",
    ["TheYard.Application.MemoryCatalogue._slots"] = "one per vehicle of the list catalogue above",
    ["TheYard.Application.MemoryCatalogue._texts"] = "one per vehicle of the list catalogue above",
    ["TheYard.Application.MemoryCatalogue._byId"] = "one per vehicle of the list catalogue above",
    ["TheYard.Application.MemoryCatalogue._orders"] = "one sorted order per CatalogueOrder value, eight",
    ["TheYard.Application.MarketService._bids"] = "one per vehicle the room has raised: at most the catalogue, emptied by a reset",
    ["TheYard.Application.BidService._standing"] = "one per vehicle anybody bid on, the store's standing cached: at most the catalogue",
    ["TheYard.Application.BidService._byUser"] = "one per account with a bid, each holding that account's bids",
    ["TheYard.Application.MemoryResourceHistory._minutes"] = "a month of minutes, trimmed on every append",
    ["TheYard.Application.MemoryResourceHistory._hours"] = "a month of hours, trimmed on every append",
    ["TheYard.Application.MemoryPlanHistory._minutes"] = "a month of minutes, trimmed on every append",
    ["TheYard.Application.MemoryPlanHistory._hours"] = "a month of hours, trimmed on every append",
    ["TheYard.Domain.VehicleSearchIndex._byId"] = "one per vehicle of the catalogue it was built over; the site builds none",
    ["TheYard.Domain.ScheduleOrder._starts"] = "one per vehicle of the catalogue it was built over; the site builds none",
    ["TheYard.Domain.ScheduleOrder._ends"] = "one per vehicle of the catalogue it was built over; the site builds none",
    ["TheYard.Domain.ScheduleOrder._byEnd"] = "one per vehicle of the catalogue it was built over; the site builds none",
    ["TheYard.Domain.ScheduleOrder._byStart"] = "one per vehicle of the catalogue it was built over; the site builds none",
    ["TheYard.Api.LogRingBuffer._entries"] = "a ring of RingSizes.LogRing",
    ["TheYard.Api.RequestRingBuffer._entries"] = "a ring of RingSizes.RequestRing",
    ["TheYard.Api.SqlRingBuffer._entries"] = "a ring of two hundred statements",
    ["TheYard.Api.StoreRingBuffer._entries"] = "a ring of two hundred operations",
    ["TheYard.Api.ErrorRingBuffer._entries"] = "a ring of RingSizes.ErrorRing",
    ["TheYard.Api.MachineSampler._samples"] = "a ring of an hour of samples",
    ["TheYard.Api.StartupTimings._steps"] = "the startup's named steps, a handful",
    ["TheYard.Api.RegistrationLimit._taken"] = "the registrations inside the allowance's window, at most the allowance",
    ["TheYard.Api.ForgotLimit._last"] = "at most ForgotLimit.MaxAddresses, the stale ones forgotten first",
    ["TheYard.Api.SelfTestCooldown._last"] = "the accounts that ran the self-test in the last minute",
    ["TheYard.Api.ProofRunner._accounts"] = "the proof's own accounts, one per store, two",
    ["TheYard.Api.ActivityReportCache._entries"] = "one per named window, three",
    ["TheYard.Api.ActivityReportCache._building"] = "one per named window, three",
    ["TheYard.Api.CloudRecorder._hourRead"] = "one per resource card, six",
    ["TheYard.Api.CloudReader._cached"] = "one per card and window, six cards by four windows",
    ["TheYard.Api.CloudReader._month"] = "one per card, six",
    ["TheYard.Api.PlanHistoryReader._cached"] = "one per window, four",
    ["TheYard.Api.ActivityCollector._stores"] = "one per store, two",
    ["TheYard.Api.AdminKey._key"] = "the operator key's bytes, fixed at startup",
    ["TheYard.Api.VisitorTokens._key"] = "the visitor-token key's bytes, fixed at startup",
    ["TheYard.Api.Backends._all"] = "the two backends",
    ["TheYard.Api.CostHistoryReader._cached"] = "one per named window, four",
    ["TheYard.Api.MachineHistoryReader._cached"] = "one per named window, four",
    ["TheYard.Api.KeptRingReader._cache"] = "one per kept card and named window, each a known name",
    ["TheYard.Application.InventoryService._pending"] = "the expansion handed to a fill, taken by the fill the moment it starts",
    ["TheYard.Application.MemoryBidStore._bids"] = "the bid store of a site whose store is down: one row per buyer's bid, gone with the process",
    ["TheYard.Application.MemoryBidStore._standings"] = "the same store's standings, one per vehicle bid on",
    ["TheYard.Application.MemoryResetLinks._kept"] = "reset links, each expiring in an hour and swept on every mint",
    ["TheYard.Infrastructure.Cosmos.CosmosVehicleCatalogue._makeBySeedId"] = "the seed's two hundred ids and their makes",
    ["TheYard.Infrastructure.Cosmos.CosmosStore._physicalPartitions"] = "one per container, read at startup",
};

private static readonly string[] Projects =
    ["TheYard.Data", "TheYard.Domain", "TheYard.Application", "TheYard.Infrastructure", "TheYard.Infrastructure.Cosmos", "TheYard.Api"];

private static bool IsCollection(Type type) =>
    type != typeof(string)
    && (type.IsArray
        || (typeof(IEnumerable).IsAssignableFrom(type) && type.Namespace?.StartsWith("System.Collections", StringComparison.Ordinal) == true));

private static bool IsValue(Type type) =>
    type.Name.EndsWith("Row", StringComparison.Ordinal)
    || type.Name.EndsWith("Document", StringComparison.Ordinal)
    || type.IsDefined(typeof(System.Runtime.CompilerServices.CompilerGeneratedAttribute))
    || type.GetMethod("<Clone>$", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) is not null
    || type.Namespace == "TheYard.Data";

[Fact]
public void Every_collection_a_long_lived_object_keeps_is_named_with_its_bound()
{
    var found = Projects
        .SelectMany(name => Assembly.Load(new AssemblyName(name)).GetTypes())
        .Where(type => type.IsClass && !IsValue(type) && !(type.FullName ?? "").Contains('<', StringComparison.Ordinal))
        .SelectMany(type => type.GetFields(BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly)
            .Where(field => !field.IsLiteral && !field.Name.Contains('<', StringComparison.Ordinal) && IsCollection(field.FieldType) && !IsFrozen(field))
            .Select(field => $"{type.FullName}.{field.Name}"))
        .ToHashSet(StringComparer.Ordinal);

    var unnamed = found.Except(Bounded.Keys).Order().ToList();
    var gone = Bounded.Keys.Except(found).Order().ToList();
    Assert.True(unnamed.Count == 0, "collections with no bound written down: " + string.Join(", ", unnamed));
    Assert.True(gone.Count == 0, "bounds for collections that are gone: " + string.Join(", ", gone));
}

/// <summary>A static readonly table written once and never added to is a constant, whatever its type.</summary>
private static bool IsFrozen(FieldInfo field) =>
    field.IsStatic && field.IsInitOnly
    && (field.FieldType.Name.StartsWith("IReadOnly", StringComparison.Ordinal)
        || field.FieldType.Name.StartsWith("Frozen", StringComparison.Ordinal)
        || field.FieldType.IsArray
        || field.FieldType == typeof(HashSet<string>));

[Fact]
public void A_bounded_cache_keeps_its_count_its_bytes_and_its_expiry()
{
    var clock = new StepClock(new DateTimeOffset(2026, 10, 18, 12, 0, 0, TimeSpan.Zero));
    var cache = new BoundedCache<string, string>(3, 100, TimeSpan.FromSeconds(5), text => text.Length, clock, StringComparer.Ordinal);

    cache.Set("a", new string('a', 10));
    cache.Set("b", new string('b', 10));
    cache.Set("c", new string('c', 10));
    Assert.True(cache.TryGet("a", out _));
    cache.Set("d", new string('d', 10));
    Assert.Equal(3, cache.Count);
    Assert.False(cache.TryGet("b", out _));

    cache.Set("big", new string('x', 90));
    Assert.True(cache.Bytes <= 100);
    Assert.True(cache.TryGet("big", out _));
    cache.Set("too-big", new string('y', 101));
    Assert.False(cache.TryGet("too-big", out _));

    clock.Step(TimeSpan.FromSeconds(6));
    Assert.False(cache.TryGet("big", out _));
    cache.Clear();
    Assert.Equal((0, 0L), (cache.Count, cache.Bytes));
}

[Fact]
public async Task A_bounded_cache_asks_the_factory_once_while_the_answer_is_fresh()
{
    var cache = new BoundedCache<int, int>(10, 1_000, TimeSpan.FromMinutes(1), _ => 4);
    int asked = 0;

    int first = await cache.GetOrAddAsync(1, () => Task.FromResult(++asked));
    int second = await cache.GetOrAddAsync(1, () => Task.FromResult(++asked));

    Assert.Equal((1, 1, 1), (first, second, asked));
}

[Fact]
public async Task A_bounded_cache_loads_a_key_once_for_everybody_who_misses_together()
{
    var cache = new BoundedCache<int, int>(10, 1_000, TimeSpan.FromMinutes(1), _ => 4);
    var gate = new TaskCompletionSource<int>(TaskCreationOptions.RunContinuationsAsynchronously);
    int asked = 0;

    var waiting = Enumerable.Range(0, 8).Select(_ => cache.GetOrAddAsync(1, () => { Interlocked.Increment(ref asked); return gate.Task; })).ToList();
    gate.SetResult(42);
    int[] answers = await Task.WhenAll(waiting);

    Assert.Equal(1, asked);
    Assert.All(answers, answer => Assert.Equal(42, answer));
}

[Fact]
public async Task A_bounded_cache_keeps_no_failed_load_and_the_next_caller_tries_again()
{
    var cache = new BoundedCache<int, int>(10, 1_000, TimeSpan.FromMinutes(1), _ => 4);

    await Assert.ThrowsAsync<InvalidOperationException>(() => cache.GetOrAddAsync(1, () => Task.FromException<int>(new InvalidOperationException("the store is away"))));
    int answer = await cache.GetOrAddAsync(1, () => Task.FromResult(7));

    Assert.Equal(7, answer);
}

[Fact]
public void The_reset_limit_forgets_stale_addresses_before_it_refuses_a_new_one()
{
    var now = new DateTimeOffset(2026, 10, 18, 12, 0, 0, TimeSpan.Zero);
    var limit = new ForgotLimit(TimeSpan.FromMinutes(5), () => now);
    for (int i = 0; i < ForgotLimit.MaxAddresses; i++)
    {
        Assert.True(limit.TryTake($"buyer{i}@example.com"));
    }

    Assert.False(limit.TryTake("one-more@example.com"));
    Assert.False(limit.TryTake("buyer1@example.com"));
    now = now.AddMinutes(6);
    Assert.True(limit.TryTake("one-more@example.com"));
    Assert.True(limit.Count <= ForgotLimit.MaxAddresses);
}
```
*Live from [`api/TheYard.Tests/MemoryRulesTests.cs`](https://github.com/SteveStout/TheYard/blob/7830983/api/TheYard.Tests/MemoryRulesTests.cs#L41-L221), region `rule-b`, read from this build at 7830983.*

## What it measured

The before is the table under Context. The after is read once the roll has finished, each store's fill has finished and the dial has settled ten minutes, and it is written into the addendum below with the same readings, because a number on this page has to be one somebody read.

## Consequences

- A listing is now a store round trip or three, where it was a walk of a list in the same process, and the store bills for it: request units on Cosmos DB, DTU on SQL Database. The five-second cache is what keeps a busy minute from paying for the same page twice.
- The rule "nothing schedule-related is persisted" now has one exception, the slot, which is a copy of the hash's answer and is held to it by a test. CLAUDE.md says so.
- A site whose store is down still serves, from the expansion in memory, the way it did before this change. That is the one place the old cost comes back, and the Admin tab says when it has.
- `ScheduleOrder` and the search index are no longer used by the site. They stay in Domain, with their tests, because this change was held to leaving Domain as it was; they go with the next change that touches it.

## Where it sits

The port, `IVehicleCatalogue`, and the one plan for every listing, `InventoryService` and its parts, in `TheYard.Application`, beside the list catalogue the tests and the file fallback use. The two translations in the two adapter projects: `EfVehicleCatalogue` and the Catalogue table's row and model in `TheYard.Infrastructure`, `CosmosVehicleCatalogue` and its document in `TheYard.Infrastructure.Cosmos`. The schema in `TheYard.Database`, the container's policy in `infra/cosmos/`. The host wires one catalogue per store in `Composition/Catalogues.cs` and owns the outbound pool and the collector's reading. Domain does not change: the slot is the schedule's own answer on an anchor of zero.

What would change it: a catalogue too big for one physical partition or for the Basic tier's DTU, at which point the Cosmos key takes the make and the segment together and the SQL side takes a columnstore or a larger tier.

## Files

- [`api/TheYard.Application/Ports.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Application/Ports.cs) - `IVehicleCatalogue`, the five primitives
- [`api/TheYard.Application/CatalogueFilter.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Application/CatalogueFilter.cs) - the filter in the store's terms, the orders, the segments and the slot
- [`api/TheYard.Application/InventoryService.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Application/InventoryService.cs) and its parts `.Warm.cs`, `.Search.cs`, `.Moved.cs`, `.Find.cs` - the warm-up and the fill, the listing plan and the overlay's merge, the store's answer without the moved vehicles, the reads by id
- [`api/TheYard.Application/MemoryCatalogue.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Application/MemoryCatalogue.cs) - the list catalogue: the tests, the file fallback and a fill's bridge
- [`api/TheYard.Application/BoundedCache.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Application/BoundedCache.cs) - the one cache shape
- [`api/TheYard.Infrastructure/EfVehicleCatalogue.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Infrastructure/EfVehicleCatalogue.cs), [`CatalogueRows.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Infrastructure/CatalogueRows.cs) and [`YardDbContext.Catalogue.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Infrastructure/YardDbContext.Catalogue.cs) - the relational adapter
- [`api/TheYard.Database/Tables/Catalogue.sql`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Database/Tables/Catalogue.sql) - the table and one index per order
- [`api/TheYard.Infrastructure.Cosmos/CosmosVehicleCatalogue.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Infrastructure.Cosmos/CosmosVehicleCatalogue.cs) and [`Documents.Catalogue.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Infrastructure.Cosmos/Documents.Catalogue.cs) - the document adapter
- [`infra/cosmos/catalogue.json`](https://github.com/SteveStout/TheYard/blob/main/infra/cosmos/catalogue.json) - the indexed paths and the composite indexes
- [`api/TheYard.Api/Composition/Catalogues.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Api/Composition/Catalogues.cs) - one catalogue per store, and who fills
- [`api/TheYard.Api/OutboundHttp.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Api/OutboundHttp.cs) - the one outbound pool
- [`infra/appservice.bicep`](https://github.com/SteveStout/TheYard/blob/main/infra/appservice.bicep) - `Catalogue__Keep` and the heap limit
- [`api/TheYard.Tests/StoreCatalogueTests.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Tests/StoreCatalogueTests.cs) - the slot against the hash, and the relational adapter against the list
- [`api/TheYard.Tests/InventoryServiceTests.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Tests/InventoryServiceTests.cs) - the plan, the merge and the switch
- [`api/TheYard.Tests/MemoryRulesTests.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Tests/MemoryRulesTests.cs) - the memory rules a test can hold
- [`docs/best-practices/MEMORY.md`](https://github.com/SteveStout/TheYard/blob/main/docs/best-practices/MEMORY.md) - the eight rules, for a reader new to them

