# ADR: One container, both stores

Status: accepted, 2026-09-08. One container runs the relational store and the
document store side by side, a visitor picks one with a toggle at the top of
every page, and the same request is served by whichever store the visitor
chose. The second container stays, running the same image with the other
store as its default. Parent: ADR: A second store on Cosmos DB, and what it
costs.

## In plain words

One running copy of the site (a container) now holds both databases at once (SQL and the document database, Azure Cosmos DB) and serves each request from one of them. A toggle at the top of every page lets a visitor pick the store.

What that is worth: a developer can measure the two stores against each other inside one process where only the store differs, and the organization gets a performance comparison with the network taken out.

## Context

Two containers, two tabs, and a card that read the other container through
its API (ADR: Backends, side by side) answered the first question: can the
same application run on a document store. Steve's next two asks changed the
shape. He wanted "an easy way to toggle between Cosmos and SQL at the top of
the page", and he wanted the two stores' performance proven the same.

A toggle that opens the other site is a link, and the other site has a plain
http address on an Azure hostname, so the demo changes address bars every
click. A proof measured across two containers measures two containers: the
network between them and the visitor, two processes with two request rings,
two cold starts on two machines. What both asks want is one process, one
address, one request, and only the store different.

## Decision

**A container runs every store it is configured for.** The relational store
is always there, SQL Server when the deploy gave one and SQLite otherwise; the
document store is there when `Cosmos:AccountEndpoint` is set. Each is brought
up on its own, timed on its own, and stands behind its own catalogue, bids,
room and accounts. Nothing is shared between them, and that is the point: a
listing served by the Cosmos DB backend was loaded from Cosmos DB, and the
cold start beside it is that store's own.

```csharp
/// <summary>
/// One store and everything that stands on it: the catalogue loaded from it,
/// the bids replayed out of it, the room bidding against those, the accounts
/// kept in it, and the numbers from bringing it up (ADR: One container, both
/// stores). A container holds one of these per store it is configured for,
/// and a request is served by exactly one of them.
///
/// <para>Nothing in here is shared between stores. Two catalogues cost twice
/// the memory, which the record prices, and buy the one thing the comparison
/// needs: a listing served by the Cosmos DB backend was loaded from Cosmos DB,
/// and the cold start beside it is that store's own.</para>
/// </summary>
public sealed class Backend
{
    /// <summary>The short name the header and the bar use: "sql" or "cosmos".</summary>
    public required string Key { get; init; }

    /// <summary>The store as it describes itself: "Azure SQL Database", "SQLite", "Azure Cosmos DB".</summary>
    public required string Name { get; init; }

    /// <summary>Whether the store came up, and why not if it did not; replaced when the store is attached.</summary>
    public required DatabaseState Database { get; set; }

    /// <summary>How long each step of bringing this store up took, for the Admin tab's startup card.</summary>
    public required StartupTimings Startup { get; init; }

    /// <summary>The catalogue this backend serves: the files until the store is attached, the store after.</summary>
    public required InventoryService Inventory { get; set; }

    /// <summary>The bids on this backend's catalogue, written through to the store once it is attached.</summary>
    public required BidService Bids { get; set; }

    /// <summary>The simulated competing bidders on this backend's catalogue, the room the buyer bids in.</summary>
    public required MarketService Market { get; init; }

    /// <summary>The document store, on the Cosmos DB backend only.</summary>
    public CosmosStore? Cosmos { get; init; }

    /// <summary>The relational contexts, on the SQL backend only, and only once it has come up.</summary>
    public IDbContextFactory<YardDbContext>? Contexts { get; set; }

    /// <summary>The same database without the statement interceptor, for the readings a minute that must not fill the SQL log with themselves.</summary>
    public IDbContextFactory<YardDbContext>? QuietContexts { get; set; }

    /// <summary>
    /// Where this store keeps the requests it served, for the Admin tab's
    /// activity card (ADR: Site activity, and the line an address does not
    /// cross). The null store when the backend did not come up: nothing kept,
    /// and the card says so.
    /// </summary>
    public IActivityStore Activity { get; set; } = NullActivityStore.Instance;

    /// <summary>Is the seed catalogue in the store right now. Two reads, timed by the health check.</summary>
    public required Func<Task<bool>> Probe { get; set; }

    /// <summary>
    /// Identity's store over this backend's accounts, built per request from
    /// the request's scope, or null when the store did not come up and there
    /// are no accounts to keep.
    /// </summary>
    public required Func<IServiceProvider, IUserStore<YardUser>?> UserStore { get; set; }

    /// <summary>
    /// The relational store's own reading of itself for the Machines card, read by the adapter
    /// that owns the database, or an absent reading with its reason on a store that keeps none.
    /// </summary>
    public Task<StoreLoad> ReadLoadAsync(int rows, CancellationToken cancellation, ILogger? logger = null) =>
        ResourceStats.ReadAsync(Contexts, Name, rows, cancellation, logger);

    /// <summary>Whether this backend's catalogue is in memory right now, for the Admin tab's memory reading.</summary>
    public bool CatalogueLoaded => Inventory.IsWarm;

    /// <summary>True on the document store, which logs each operation with its request charge.</summary>
    public bool LogsOperations => Cosmos is not null;

    /// <summary>True on an attached relational store, which logs each statement it runs.</summary>
    public bool LogsStatements => Contexts is not null;

    /// <summary>
    /// Whether this backend keeps accounts and bids across a restart. False is
    /// the fallback the relational store record describes: the catalogue is
    /// served from files, the bidding works, and nothing outlives the process.
    /// </summary>
    public bool Ready => Database.Ready;

    // #region attach
    /// <summary>
    /// The store, attached: everything that stands on it replaced in one call
    /// (ADR: The relational store, the addendum on the second chance). At
    /// startup this is called at once when the store came up; after startup
    /// the second chance calls it with a catalogue it has already warmed, so
    /// the first request after the swap is not the cold one. Each member is
    /// one reference written once, and a request in flight sees either the
    /// files or the store for each of them, both of which answer correctly;
    /// the swap happens at most once in a process's life.
    /// </summary>
    public void Attach(StoreAttachment attachment)
    {
        Database = attachment.Database;
        Contexts = attachment.Contexts;
        QuietContexts = attachment.QuietContexts;
        Inventory = attachment.Inventory;
        Bids = attachment.Bids;
        Activity = attachment.Activity;
        Probe = attachment.Probe;
        UserStore = attachment.UserStore;
    }
    // #endregion attach
}

/// <summary>What a store brings with it when it is attached to its backend: the state that says it came up, and the five things that stand on it.</summary>
/// <param name="Database">The state that says whether the store came up, and why not if it did not.</param>
/// <param name="Contexts">The relational contexts, on the SQL backend only; null otherwise.</param>
/// <param name="QuietContexts">The same database without the statement interceptor, for readings that must not fill the SQL log; null otherwise.</param>
/// <param name="Inventory">The catalogue service reading from this store.</param>
/// <param name="Bids">The bid service writing through to this store.</param>
/// <param name="Activity">Where this store keeps the requests it served.</param>
/// <param name="Probe">Checks whether the seed catalogue is in the store right now.</param>
/// <param name="UserStore">Builds Identity's account store for a request's scope, or returns null when there are no accounts to keep.</param>
public sealed record StoreAttachment(
    DatabaseState Database,
    IDbContextFactory<YardDbContext>? Contexts,
    IDbContextFactory<YardDbContext>? QuietContexts,
    InventoryService Inventory,
    BidService Bids,
    IActivityStore Activity,
    Func<Task<bool>> Probe,
    Func<IServiceProvider, IUserStore<YardUser>?> UserStore);
```
*Live from [`api/TheYard.Api/Backend.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Api/Backend.cs#L13-L140), region `backend`, read from this build at 1d6b50b.*

**Which store serves a request is decided per request.** As first shipped,
the toggle set a cookie for a year and the header won over the cookie over
the container's default; since 1.0.0.101 there is no cookie (the addendum
"the toggle moved to the sites" says why), so it is the header, for a
measurement that has no browser, and otherwise the container's default,
which the deploy names (`Store:Default`, `sql` on the live site and `cosmos`
on the second container). A value that names a store this container does not
have is the default and never an error.

```csharp
/// <summary>
/// The stores this container runs, and which one a request gets.
///
/// <para>The choice is a header, which is how a measurement asks for a store
/// without a browser; a request that names none gets the container's default,
/// which is what makes each site one store's site (ADR: One container, both
/// stores, the addendum on the toggle moving to the sites). A header naming a
/// store this container does not have is the default, never an error.</para>
///
/// <para>A cookie named yard-store chooses nothing. A browser may still carry
/// one, so the stores endpoint expires it on sight, and the browser lands
/// where the address bar says.</para>
/// </summary>
public sealed class Backends
{
    /// <summary>A cookie that chooses nothing and is read only to be expired.</summary>
    public const string LegacyCookieName = "yard-store";

    /// <summary>The request header that names a store: "sql" or "cosmos".</summary>
    public const string HeaderName = "X-Yard-Store";

    /// <summary>Every store this container runs, in the order they were brought up.</summary>
    private readonly Backend[] _all;

    /// <summary>The stores, the key of the one a request gets when it names none, and the other site's address if there is one.</summary>
    public Backends(IEnumerable<Backend> all, string? defaultKey, string? otherSite = null)
    {
        _all = all.ToArray();
        if (_all.Length == 0)
        {
            throw new ArgumentException("a container needs at least one store", nameof(all));
        }

        Default = Named(defaultKey) ?? _all[0];
        OtherSite = Uri.TryCreate(otherSite, UriKind.Absolute, out var site)
            && (site.Scheme == Uri.UriSchemeHttp || site.Scheme == Uri.UriSchemeHttps)
            ? site.GetLeftPart(UriPartial.Authority)
            : null;
    }

    /// <summary>Every store this container runs.</summary>
    public IReadOnlyList<Backend> All => _all;

    /// <summary>
    /// The other site, the container whose default is the other store, as a
    /// visitor should reach it (`Peer:Site`), so the bar can put both
    /// addresses on every page. The peer endpoint reads the other container
    /// by its origin; this is the address a person types, which behind the
    /// edge is a different one. Null when there is no other site, and only
    /// ever an http or https origin, so a setting cannot put anything else in
    /// a link.
    /// </summary>
    public string? OtherSite { get; }

    /// <summary>The backend a request gets when it names none.</summary>
    public Backend Default { get; }

    /// <summary>The store with this key, ignoring case, or null when there is none.</summary>
    public Backend? Named(string? key) =>
        key is null ? null : Array.Find(_all, backend => string.Equals(backend.Key, key, StringComparison.OrdinalIgnoreCase));

    /// <summary>The backend a request asked for: the header, or the default.</summary>
    public Backend For(HttpContext? context)
    {
        if (context is null)
        {
            return Default;
        }
        if (context.Request.Headers.TryGetValue(HeaderName, out var header) && Named(header) is { } byHeader)
        {
            return byHeader;
        }
        return Default;
    }

    /// <summary>
    /// Expire the toggle's old cookie if this request carries one. The page
    /// asks for the stores on every load, so one visit is enough to clean a
    /// browser that used the old toggle. Nothing else reads it.
    /// </summary>
    public static void ExpireLegacyCookie(HttpContext context)
    {
        if (context.Request.Cookies.ContainsKey(LegacyCookieName))
        {
            context.Response.Cookies.Delete(LegacyCookieName, new CookieOptions { Path = "/" });
        }
    }

    /// <summary>What the page is told about the stores, with the one this request would get marked.</summary>
    public StoresView Describe(HttpContext? context)
    {
        var current = For(context);
        return new StoresView(
            current.Key,
            _all.Select(backend => new StoreView(
                backend.Key,
                backend.Name,
                backend.Ready,
                ReferenceEquals(backend, Default))).ToArray(),
            OtherSite);
    }
}

/// <summary>
/// The backend serving this request, resolved once per request from the
/// request's own headers. Endpoints ask this rather than the
/// container, so the same handler serves whichever store the request named.
/// </summary>
public sealed class CurrentBackend(Backends backends, IHttpContextAccessor accessor)
{
    /// <summary>The store this request named, or the container's default.</summary>
    public Backend Backend { get; } = backends.For(accessor.HttpContext);

    /// <summary>
    /// The request's auction: the catalogue, the bids and the room composed by the Application
    /// ring. A new one per read is three references and nothing more, and it always sees the
    /// services the backend holds right now, including after the store is attached. The services
    /// themselves are not offered here, so an endpoint can only reach them through the auction.
    /// </summary>
    public Auction Auction => new(Backend.Inventory, Backend.Bids, Backend.Market);
}

// #region warm-before-reading
/// <summary>
/// The request's store, warm before any endpoint reads it.
///
/// <para>The host starts the default store's catalogue loading beside the
/// server and warms the others in the background on a deployed group, so a
/// request that arrives before a store's load has finished, any request in the
/// first minutes after a roll, a visitor following the toggle, or any request
/// to the other store in a test application where the background warm is off,
/// finds a catalogue still loading. <c>InventoryService</c>'s synchronous accessors then blocked the
/// request thread on the load, on a container with one vCPU and therefore one
/// thread pool worker to start with: the exact shape ADR: The ports learn to
/// wait argued against (ADR: Three readers with no memory of the project).</para>
///
/// <para>So the pipeline awaits the warm, once per request, before the
/// endpoint runs. After the first time it is an await on a task that finished
/// at startup, which is no wait at all. The bid replay is awaited by the
/// writing methods already and read by the reads as whatever has loaded, so
/// it is not gated here; a listing during the seconds a store's bids replay
/// shows the dataset's prices until the replay lands.</para>
/// </summary>
public static class Warmth
{
    /// <summary>Warms the backend's catalogue if it is not warm; a warm one is a task already finished.</summary>
    public static Task EnsureAsync(Backend backend) =>
        backend.Inventory.IsWarm ? Task.CompletedTask : backend.Inventory.WarmAsync();
}
// #endregion warm-before-reading

/// <summary>One store on the toggle: its key, its name, whether it came up, and whether it is the container's default.</summary>
/// <param name="Key">The store's key.</param>
/// <param name="Name">The store's display name.</param>
/// <param name="Ready">True when the store came up.</param>
/// <param name="Default">True when it is the store a request gets when it names none.</param>
public sealed record StoreView(string Key, string Name, bool Ready, bool Default);

/// <summary>The bar's answer: which store this request is on, the stores there are (one of them this site's default), and the other site if there is one.</summary>
/// <param name="Current">The key of the store this request is on.</param>
/// <param name="Stores">Every store on the toggle.</param>
/// <param name="OtherSite">The other site's http or https origin, or null when there is none.</param>
public sealed record StoresView(string Current, IReadOnlyList<StoreView> Stores, string? OtherSite);
```
*Live from [`api/TheYard.Api/Stores.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Api/Stores.cs#L14-L176), region `backends`, read from this build at 1d6b50b.*

**Endpoints ask the request, not the container.** Every endpoint that used to
take `InventoryService`, `BidService` and `MarketService` from the container
takes `CurrentBackend`, which is scoped to the request and resolved once from
its headers and cookies. The three services are no longer registered at all:
a singleton of any of them would be a singleton of one store. Identity gets
the same treatment one level down: `UserManager` is registered once, and the
store behind it is chosen per request, Identity's own tables on the
relational backend and one document per account on the document one.

```csharp
// The store behind UserManager, chosen by the request: Identity's own tables
// on the relational backend, one document per account on the document one
// (ADR: Accounts on a document store). One of the four scoped registrations
// in the application, and the reason the per-request context exists.
builder.Services.AddScoped<IUserStore<YardUser>>(services =>
    services.GetRequiredService<CurrentBackend>().Backend.UserStore(services)
        ?? throw new InvalidOperationException("this request's store keeps no accounts; RequestAccounts answers null before this is reached"));

// The handlers take RequestAccounts rather than UserManager, because UserManager is built
// over the request's store, and a store that did not come up has no accounts to build it
// over. RequestAccounts asks first and answers null, so no handler meets the throw above.
builder.Services.AddScoped(services => new RequestAccounts(() =>
    services.GetRequiredService<CurrentBackend>().Backend.Ready
        ? services.GetRequiredService<UserManager<YardUser>>()
        : null));
```
*Live from [`api/TheYard.Api/Composition/AuthRegistration.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Api/Composition/AuthRegistration.cs#L96-L110), region `user-store-per-request`, read from this build at 1d6b50b.*

**The toggle is a cookie and a reload.** `POST /api/stores/select` sets the
cookie and answers what `GET /api/stores` answers, which stores there are and
which one this request is on; the page then reloads itself, because every
number it holds was read from the other store and a cache of the wrong
store's answers is worse than a cold page. The toggle always shows both
families, so the choice reads as a choice: a family this container does not
run is drawn as not here, and a store that did not come up cannot be chosen
and says why. The server refuses the same choice with the same sentence
(`Backends.Choose`), so a stale page or a script cannot set a year-long
cookie for a store with no accounts behind it.

```ts
export async function fetchStores(signal?: AbortSignal): Promise<Stores | null> {
  try {
    const response = await fetch('/api/stores', { signal, credentials: 'same-origin' });
    if (!response.ok) return null;
    return (await response.json()) as Stores;
  } catch {
    // A page that cannot learn its stores is still a page; the bar just does
    // not draw, which is the honest shape for an API that is not answering.
    return null;
  }
}

/**
 * The two names a visitor sees on the bar, side by side, whichever site they
 * are on. Both families are always drawn so the choice reads as a choice; a
 * site this container cannot name is drawn as not here rather than left out,
 * because a bar with one segment is a label.
 */
export const FAMILIES = [
  { key: 'sql', label: 'SQL' },
  { key: 'cosmos', label: 'Cosmos DB' },
] as const;

export type Segment = {
  key: string;
  label: string;
  /** What the segment is: this site, the other site by its host, or a site that is not here. */
  title: string;
  /** Whether this segment is the site the visitor is on. */
  current: boolean;
  /** The other site at the visitor's own path and query, or null when the segment is not a link. */
  href: string | null;
};

/** Where the visitor is, as the bar needs it: the path and the query to carry to the other site. */
export type Here = { pathname: string; search: string };

/** The store this container serves by default, which is the store this site is named for. */
export function siteStore(stores: Stores): Store | undefined {
  return stores.stores.find((store) => store.default) ?? stores.stores[0];
}

/**
 * The sentence beside the bar: which site this is, which store is serving
 * the page, and that accounts and bids live in the store they were made in.
 * A store that did not come up still serves this page, from files, and the
 * bar says so rather than announcing a store with no accounts behind it as
 * though it were whole.
 */
export function note(stores: Stores): string {
  const current = stores.stores.find((store) => store.key === stores.current);
  if (!current) return '';
  if (!current.ready) {
    return `${current.name} did not come up on this container. The catalogue is served from files and there are no accounts on this store until it does.`;
  }
  const site = siteStore(stores);
  const family = FAMILIES.find((candidate) => candidate.key === site?.key);
  const which = family
    ? `This is the ${family.label} site, served from ${current.name}.`
    : `This page is served from ${current.name}.`;
  return `${which} Accounts and bids live in the store they were made in.`;
}

/**
 * The note's short form, under 1440 pixels wide: the site and the
 * store serving it, "SQL site · Azure SQL Database", so the band never cuts a
 * sentence with an ellipsis; the whole sentence stays the note's title.
 */
export function shortNote(stores: Stores): string {
  const current = stores.stores.find((store) => store.key === stores.current);
  if (!current) return '';
  if (!current.ready) return `${current.name} did not come up`;
  const family = FAMILIES.find((candidate) => candidate.key === siteStore(stores)?.key);
  return family ? `${family.label} site · ${current.name}` : current.name;
}

/** How many of the stores this container runs came up, for the words beside the bar's ring (A3). */
export function readyCount(stores: Stores): { up: number; of: number } {
  return { up: stores.stores.filter((store) => store.ready).length, of: stores.stores.length };
}

/**
 * The other site, when the server names one: its origin and the host to show,
 * because the host is what tells the two sites apart. An address the browser
 * cannot parse, or one that is not http or https, is not a link.
 */
export function otherSite(stores: Stores): { href: string; host: string } | null {
  if (!stores.other_site) return null;
  try {
    const url = new URL(stores.other_site);
    if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
    return { href: url.origin, host: url.host };
  } catch {
    return null;
  }
}

/**
 * The other site at the same place: `/?view=admin` on one site lands on
 * `/?view=admin` on the other, so a reader comparing the two Admin tabs is
 * one click apart, not one click and a navigation.
 */
export function otherSiteAt(stores: Stores, here: Here): string | null {
  const other = otherSite(stores);
  if (!other) return null;
  return `${other.href}${here.pathname}${here.search}`;
}

/** What each segment of the bar says and does, from the server's answer and the visitor's own address. */
export function segments(stores: Stores, here: Here): Segment[] {
  const site = siteStore(stores);
  const other = otherSite(stores);
  const href = otherSiteAt(stores, here);
  return FAMILIES.map((family) => {
    if (site && site.key === family.key) {
      return {
        key: family.key,
        label: family.label,
        title: `This site. Its store is ${site.name}.`,
        current: true,
        href: null,
      };
    }
    if (other && href) {
      return {
        key: family.key,
        label: family.label,
        title: `The ${family.label} site, ${other.host}, at this same page`,
        current: false,
        href,
      };
    }
    return {
      key: family.key,
      label: family.label,
      title: `There is no ${family.label} site here: this container names no other site`,
      current: false,
      href: null,
    };
  });
}
```
*Live from [`src/lib/stores.ts`](https://github.com/SteveStout/TheYard/blob/1d6b50b/src/lib/stores.ts#L32-L172), region `stores-seam`, read from this build at 1d6b50b.*

**Accounts live in the store they were made in.** An account is a row in one
store or a document in the other, so a session opened on Cosmos DB reads as
signed out on Azure SQL and signs back in when the toggle goes back. The bar
says so beside the toggle rather than pretending the two stores are one.

**The comparison card compares the stores in this container.** A container
running both puts them on the same rows it used to put two containers on, the
one serving this visit first, and says that the two columns share a process,
a region and a request ring. A container running one still compares itself
with its peer, so the second container and its card keep working unchanged.
The request ring records which store served each request, which is what lets
one ring be split two ways.

```csharp
// Every store this container runs, on the same rows the peer answers with, so the
// card compares two stores in one process the way it compares two containers: the cold
// start each one had, the requests each one served, and what those cost the one that
// can say. A store's charges show only on the store that logs operations, and its
// statements only on the store that logs statements.
backends = backends.All.Select(backend => new
{
    key = backend.Key,
    store = backend.Name,
    ready = backend.Ready,
    @default = ReferenceEquals(backend, backends.Default),
    startup = StartupView(backend, startedAt),
    requests = RequestsView(requests.Where(entry => entry.Store == backend.Key).ToArray()),
    store_metrics = backend.LogsOperations ? StoreMetrics.Of(backend.Name, operations) : null,
    store_by_route = backend.LogsOperations ? Routes.ChargesByRoute(operations) : Array.Empty<RouteCharge>(),
    sql = backend.LogsStatements ? SqlView(statements.Count, sqlDurations) : null,
}).ToArray(),
```
*Live from [`api/TheYard.Api/MetricsReport.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Api/MetricsReport.cs#L76-L92), region `backends-metrics`, read from this build at 1d6b50b.*

**The health check names every store.** One check per store, the default's
still called `database` for the deploy's Verify step and the Admin tab's card,
the other named by its key. Neither gates readiness, for the reason the
relational store record gives: a container with no database still serves the
catalogue, and the toggle shows that store as unavailable rather than the
site as down.

## What it looks like

The live site on 1.0.0.94, arriving on its default store. The bar sits above
the view on every page and says which store served it:

![The Store bar at the top of the live site: SQL selected, Cosmos DB beside it, and the sentence that this page is served from Azure SQL Database and that accounts and bids live in the store they were made in](/api/docs/images/toggle-sql-top.png#1280x420)

The same address after one click on Cosmos DB. The page reloaded itself on
the other store, `/api/stores` answered `cosmos` for the page's own request,
and the sentence changed with it:

![The same page after the toggle: Cosmos DB selected, and the sentence now says the page is served from Azure Cosmos DB](/api/docs/images/toggle-sql-switched.png#1280x420)

The second container arrives on the other segment and switches the other
way; its screenshot on arrival is the switched picture above, byte for byte,
because both containers run the same image against the same two stores. On a
phone the bar sits under the header and the sentence wraps beneath the
segments:

![The bar on a phone: the Store segments under the header, the sentence wrapped beneath them](/api/docs/images/toggle-phone.png#780x600)

And the comparison card on a container running both, which is where the
proof record's numbers come from: two stores in one process, one region and
one request ring, so what differs between the columns is the store and the
distance to it:

![Backends, side by side, on a container running both stores: cold start, store check, seed and catalogue load per store, then the visitor's paths with the request charge beside the document store's numbers, and the line that both stores run in this container](/api/docs/images/backends-one-process.png#960x836)

## What it costs

Two catalogues. Each store expands its two hundred seed vehicles to a hundred
thousand in memory, so a container running both holds two of everything the
inventory service holds. The cold start is not longer for it: the default
store is warmed before the container serves anything, as it always was, and
the other is warmed in the background right after, one store at a time,
because two expansions racing each other on one vCPU would both take longer
and neither number would be that store's own. That background warm-up is a
deploy setting (`Store:WarmOthers`) and is off everywhere else, because a
test run boots ten applications at once and ten second expansions nobody
asked for is memory the machine running the suite does not have to give; a
store nobody warmed warms itself on its first request, which is the Lazy the
inventory service has always had (ADR: The ports learn to wait).

Two more settings on each container group: the other store's, and the
default. The live site's group gains the Cosmos DB endpoint, the database name
and the credential kind, none of them a secret; the second container's group
gains the SQL Server connection string the deploy already composes for the
first, substituted by the same step. Both groups run the same image and differ
in three environment variables.

## What this is not

**Not a shared catalogue.** The two backends hold identical vehicles, because
both stores were seeded from the same file, and sharing the expansion would
halve the memory. It would also make the listing on the Cosmos DB side a
listing served from SQL Server's load, and the comparison would be comparing
one thing with itself.

**Not a migration path.** Nothing here moves a bid or an account from one
store to the other. A visitor who bids on one store and toggles sees no bids,
which is true, and the bar says why.

**Not a cluster.** Two containers now open the same two stores, and each
replays the bids into its own memory at startup, so a bid placed on one
container is not on the other's page until that container restarts, and a
vehicle bought outright on one site is still open on the other until then;
nothing in the store marks it sold, so the "sold, to everybody" rule holds
per process, not across the two. That is the one-container assumption ADR: The one write a stranger can make already
states for the rate limit, and the second container inherits it; accounts,
which are read from the store on every request, are shared between the two
containers at once.

## Consequences

- The toggle at the top of every page switches the store under the same
  address, and the second container still exists for the two-tab comparison.
- The performance proof can be run inside one process, against both stores,
  from the same request ring: identical container, identical request, only the
  store differs (ADR: Same performance, proven).
- `Program.cs` grew by a hundred and forty lines and lost three registrations;
  ADR: Program.cs, explained carries an addendum on the new shape.
- Every test that booted the application on Cosmos DB now boots it on both,
  with Cosmos DB as the default, which is the same suite exercising the
  selection as well.

## Addendum, 2026-09-09: the night's own code, reviewed

The second review of the day's code, the second pass in ADR: Reviewing my
own work, and what that found, covered the document store. This pass read
what the night added,
`Stores.cs`, `Proof.cs`, the bar and the card, after both had run live.

- The bar would not offer a store that did not come up, and the server took
  the choice anyway. A page loaded before a deploy, or a script, could set a
  year-long cookie for a store with no accounts behind it. The rule now lives
  beside the selection rule as `Backends.Choose`, the endpoint asks it, and a
  test walks a container whose document store did not come up.
- The bar's sentence said "served from Azure Cosmos DB" for a visitor whose
  cookie named a store that did not come up, which is true of the catalogue
  and false of everything else. It now says the store did not come up, that
  the catalogue is served from files, and that there are no accounts on it
  until it does (`note` in `src/lib/stores.ts`, with a test).
- The proof reads the container's two rings for what each of its requests
  caused, and the rings are the container's, so a visitor bidding during a
  run adds their statements to a sample's count. The times are the proof's
  own requests; the counts can carry a visitor's. The proof record says so
  now. Nothing else in `Proof.cs` needed to change: the pairing, the
  tolerance and the correction were read line by line against the numbers
  the live run produced and they agree.

## Addendum, 2026-09-09: the other site, on every page

Steve's answer to the priced question was the second option as well as the
first: a link between the two sites. The bar now carries it. `Peer:Site` is
the other container as a visitor reaches it, which for the live site is the
domain behind the edge and for the second container is its own origin, and
`/api/stores` answers it beside the stores as `other_site`, trimmed to an
http or https origin or left null. The bar shows the host as the link text,
after the sentence about the store serving the page, so both addresses are on
every page of both sites and a visitor on either can open the other. The
peer endpoint keeps reading the other container by its origin; the two
settings differ on the live site for the same reason a visitor and a
container reach it by different names.

The bar on the live site, and on the second container, on 1.0.0.98:

![The Store bar on the live site: SQL selected, and under the sentence a link to the other site, the second container's address](/api/docs/images/toggle-sql-linked.png#1280x90)

![The Store bar on the second container: Cosmos DB selected, and a link to the other site, the domain](/api/docs/images/toggle-cosmos-linked.png#1280x90)

Superseded the same morning by the addendum below, where the link becomes the
toggle itself.

## Addendum, 2026-09-09: the toggle moved to the sites

Steve's words on the morning after the night's work, having opened both
sites: "the button should toggle between the two sites, not refresh in React;
at the least, the URL should change". The in-place switch above was the first
of the two priced options and it worked, and it was not what he wanted: one
address serving either store, a cookie remembering which, and a page that
reloads itself to become the other store is a toggle a visitor cannot see in
the address bar and cannot send to anyone.

**The segments are the sites.** The Store bar still shows SQL and Cosmos DB
side by side. The segment for the site the visitor is on is marked current
and is not a control; the other is a link to the other site at the same path
and query, so `/?view=admin` on the live site lands on `/?view=admin` on the
second site. A full navigation: the address bar changes, nothing is cached
across a store, and the page that arrives is the other site's own, on its
default store. The other site's address is `other_site` from `/api/stores`,
which is the container's `Peer:Site`, and since ADR: A permanent address for
the second site both ends of the link are HTTPS. Where a container names no
other site, which is a developer's machine, CI and the ship gate, the other
segment is drawn as not here with a title that says so, the way a family the
container did not run was drawn before; never a dead button. The sentence
under the bar names the site, the store serving the page and where accounts
and bids live; the separate link under the sentence went, because the
segments are the links now.

```ts
export async function fetchStores(signal?: AbortSignal): Promise<Stores | null> {
  try {
    const response = await fetch('/api/stores', { signal, credentials: 'same-origin' });
    if (!response.ok) return null;
    return (await response.json()) as Stores;
  } catch {
    // A page that cannot learn its stores is still a page; the bar just does
    // not draw, which is the honest shape for an API that is not answering.
    return null;
  }
}

/**
 * The two names a visitor sees on the bar, side by side, whichever site they
 * are on. Both families are always drawn so the choice reads as a choice; a
 * site this container cannot name is drawn as not here rather than left out,
 * because a bar with one segment is a label.
 */
export const FAMILIES = [
  { key: 'sql', label: 'SQL' },
  { key: 'cosmos', label: 'Cosmos DB' },
] as const;

export type Segment = {
  key: string;
  label: string;
  /** What the segment is: this site, the other site by its host, or a site that is not here. */
  title: string;
  /** Whether this segment is the site the visitor is on. */
  current: boolean;
  /** The other site at the visitor's own path and query, or null when the segment is not a link. */
  href: string | null;
};

/** Where the visitor is, as the bar needs it: the path and the query to carry to the other site. */
export type Here = { pathname: string; search: string };

/** The store this container serves by default, which is the store this site is named for. */
export function siteStore(stores: Stores): Store | undefined {
  return stores.stores.find((store) => store.default) ?? stores.stores[0];
}

/**
 * The sentence beside the bar: which site this is, which store is serving
 * the page, and that accounts and bids live in the store they were made in.
 * A store that did not come up still serves this page, from files, and the
 * bar says so rather than announcing a store with no accounts behind it as
 * though it were whole.
 */
export function note(stores: Stores): string {
  const current = stores.stores.find((store) => store.key === stores.current);
  if (!current) return '';
  if (!current.ready) {
    return `${current.name} did not come up on this container. The catalogue is served from files and there are no accounts on this store until it does.`;
  }
  const site = siteStore(stores);
  const family = FAMILIES.find((candidate) => candidate.key === site?.key);
  const which = family
    ? `This is the ${family.label} site, served from ${current.name}.`
    : `This page is served from ${current.name}.`;
  return `${which} Accounts and bids live in the store they were made in.`;
}

/**
 * The note's short form, under 1440 pixels wide: the site and the
 * store serving it, "SQL site · Azure SQL Database", so the band never cuts a
 * sentence with an ellipsis; the whole sentence stays the note's title.
 */
export function shortNote(stores: Stores): string {
  const current = stores.stores.find((store) => store.key === stores.current);
  if (!current) return '';
  if (!current.ready) return `${current.name} did not come up`;
  const family = FAMILIES.find((candidate) => candidate.key === siteStore(stores)?.key);
  return family ? `${family.label} site · ${current.name}` : current.name;
}

/** How many of the stores this container runs came up, for the words beside the bar's ring (A3). */
export function readyCount(stores: Stores): { up: number; of: number } {
  return { up: stores.stores.filter((store) => store.ready).length, of: stores.stores.length };
}

/**
 * The other site, when the server names one: its origin and the host to show,
 * because the host is what tells the two sites apart. An address the browser
 * cannot parse, or one that is not http or https, is not a link.
 */
export function otherSite(stores: Stores): { href: string; host: string } | null {
  if (!stores.other_site) return null;
  try {
    const url = new URL(stores.other_site);
    if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
    return { href: url.origin, host: url.host };
  } catch {
    return null;
  }
}

/**
 * The other site at the same place: `/?view=admin` on one site lands on
 * `/?view=admin` on the other, so a reader comparing the two Admin tabs is
 * one click apart, not one click and a navigation.
 */
export function otherSiteAt(stores: Stores, here: Here): string | null {
  const other = otherSite(stores);
  if (!other) return null;
  return `${other.href}${here.pathname}${here.search}`;
}

/** What each segment of the bar says and does, from the server's answer and the visitor's own address. */
export function segments(stores: Stores, here: Here): Segment[] {
  const site = siteStore(stores);
  const other = otherSite(stores);
  const href = otherSiteAt(stores, here);
  return FAMILIES.map((family) => {
    if (site && site.key === family.key) {
      return {
        key: family.key,
        label: family.label,
        title: `This site. Its store is ${site.name}.`,
        current: true,
        href: null,
      };
    }
    if (other && href) {
      return {
        key: family.key,
        label: family.label,
        title: `The ${family.label} site, ${other.host}, at this same page`,
        current: false,
        href,
      };
    }
    return {
      key: family.key,
      label: family.label,
      title: `There is no ${family.label} site here: this container names no other site`,
      current: false,
      href: null,
    };
  });
}
```
*Live from [`src/lib/stores.ts`](https://github.com/SteveStout/TheYard/blob/1d6b50b/src/lib/stores.ts#L32-L172), region `stores-seam`, read from this build at 1d6b50b.*

**The cookie retired with the switch.** Each site is one store's site now, so
a store chosen by cookie would contradict the address bar, and the only thing
that ever set the cookie was the bar. Left in place it would have done real
harm to the one visitor most likely to carry it: anyone who toggled on
1.0.0.94 to 1.0.0.100, Steve included, would have landed on the live site
served from the document store with no control left to change it.
`Backends.For` is the header or the default; `GET /api/stores` expires the
old cookie the first time a request still carries one; `POST /api/stores/select`
is gone, and its tests went with it, replaced by one that holds the expiry
and the refusal (a 405, because the page's own fallback answers GET on every
path, so the path exists and the method does not). The header stays, because the proof (ADR: Same performance,
proven) and the measurements name a store for one request with it, which is
a different thing from pretending the site changed.

```csharp
/// <summary>
/// The stores this container runs, and which one a request gets.
///
/// <para>The choice is a header, which is how a measurement asks for a store
/// without a browser; a request that names none gets the container's default,
/// which is what makes each site one store's site (ADR: One container, both
/// stores, the addendum on the toggle moving to the sites). A header naming a
/// store this container does not have is the default, never an error.</para>
///
/// <para>A cookie named yard-store chooses nothing. A browser may still carry
/// one, so the stores endpoint expires it on sight, and the browser lands
/// where the address bar says.</para>
/// </summary>
public sealed class Backends
{
    /// <summary>A cookie that chooses nothing and is read only to be expired.</summary>
    public const string LegacyCookieName = "yard-store";

    /// <summary>The request header that names a store: "sql" or "cosmos".</summary>
    public const string HeaderName = "X-Yard-Store";

    /// <summary>Every store this container runs, in the order they were brought up.</summary>
    private readonly Backend[] _all;

    /// <summary>The stores, the key of the one a request gets when it names none, and the other site's address if there is one.</summary>
    public Backends(IEnumerable<Backend> all, string? defaultKey, string? otherSite = null)
    {
        _all = all.ToArray();
        if (_all.Length == 0)
        {
            throw new ArgumentException("a container needs at least one store", nameof(all));
        }

        Default = Named(defaultKey) ?? _all[0];
        OtherSite = Uri.TryCreate(otherSite, UriKind.Absolute, out var site)
            && (site.Scheme == Uri.UriSchemeHttp || site.Scheme == Uri.UriSchemeHttps)
            ? site.GetLeftPart(UriPartial.Authority)
            : null;
    }

    /// <summary>Every store this container runs.</summary>
    public IReadOnlyList<Backend> All => _all;

    /// <summary>
    /// The other site, the container whose default is the other store, as a
    /// visitor should reach it (`Peer:Site`), so the bar can put both
    /// addresses on every page. The peer endpoint reads the other container
    /// by its origin; this is the address a person types, which behind the
    /// edge is a different one. Null when there is no other site, and only
    /// ever an http or https origin, so a setting cannot put anything else in
    /// a link.
    /// </summary>
    public string? OtherSite { get; }

    /// <summary>The backend a request gets when it names none.</summary>
    public Backend Default { get; }

    /// <summary>The store with this key, ignoring case, or null when there is none.</summary>
    public Backend? Named(string? key) =>
        key is null ? null : Array.Find(_all, backend => string.Equals(backend.Key, key, StringComparison.OrdinalIgnoreCase));

    /// <summary>The backend a request asked for: the header, or the default.</summary>
    public Backend For(HttpContext? context)
    {
        if (context is null)
        {
            return Default;
        }
        if (context.Request.Headers.TryGetValue(HeaderName, out var header) && Named(header) is { } byHeader)
        {
            return byHeader;
        }
        return Default;
    }

    /// <summary>
    /// Expire the toggle's old cookie if this request carries one. The page
    /// asks for the stores on every load, so one visit is enough to clean a
    /// browser that used the old toggle. Nothing else reads it.
    /// </summary>
    public static void ExpireLegacyCookie(HttpContext context)
    {
        if (context.Request.Cookies.ContainsKey(LegacyCookieName))
        {
            context.Response.Cookies.Delete(LegacyCookieName, new CookieOptions { Path = "/" });
        }
    }

    /// <summary>What the page is told about the stores, with the one this request would get marked.</summary>
    public StoresView Describe(HttpContext? context)
    {
        var current = For(context);
        return new StoresView(
            current.Key,
            _all.Select(backend => new StoreView(
                backend.Key,
                backend.Name,
                backend.Ready,
                ReferenceEquals(backend, Default))).ToArray(),
            OtherSite);
    }
}

/// <summary>
/// The backend serving this request, resolved once per request from the
/// request's own headers. Endpoints ask this rather than the
/// container, so the same handler serves whichever store the request named.
/// </summary>
public sealed class CurrentBackend(Backends backends, IHttpContextAccessor accessor)
{
    /// <summary>The store this request named, or the container's default.</summary>
    public Backend Backend { get; } = backends.For(accessor.HttpContext);

    /// <summary>
    /// The request's auction: the catalogue, the bids and the room composed by the Application
    /// ring. A new one per read is three references and nothing more, and it always sees the
    /// services the backend holds right now, including after the store is attached. The services
    /// themselves are not offered here, so an endpoint can only reach them through the auction.
    /// </summary>
    public Auction Auction => new(Backend.Inventory, Backend.Bids, Backend.Market);
}

// #region warm-before-reading
/// <summary>
/// The request's store, warm before any endpoint reads it.
///
/// <para>The host starts the default store's catalogue loading beside the
/// server and warms the others in the background on a deployed group, so a
/// request that arrives before a store's load has finished, any request in the
/// first minutes after a roll, a visitor following the toggle, or any request
/// to the other store in a test application where the background warm is off,
/// finds a catalogue still loading. <c>InventoryService</c>'s synchronous accessors then blocked the
/// request thread on the load, on a container with one vCPU and therefore one
/// thread pool worker to start with: the exact shape ADR: The ports learn to
/// wait argued against (ADR: Three readers with no memory of the project).</para>
///
/// <para>So the pipeline awaits the warm, once per request, before the
/// endpoint runs. After the first time it is an await on a task that finished
/// at startup, which is no wait at all. The bid replay is awaited by the
/// writing methods already and read by the reads as whatever has loaded, so
/// it is not gated here; a listing during the seconds a store's bids replay
/// shows the dataset's prices until the replay lands.</para>
/// </summary>
public static class Warmth
{
    /// <summary>Warms the backend's catalogue if it is not warm; a warm one is a task already finished.</summary>
    public static Task EnsureAsync(Backend backend) =>
        backend.Inventory.IsWarm ? Task.CompletedTask : backend.Inventory.WarmAsync();
}
// #endregion warm-before-reading

/// <summary>One store on the toggle: its key, its name, whether it came up, and whether it is the container's default.</summary>
/// <param name="Key">The store's key.</param>
/// <param name="Name">The store's display name.</param>
/// <param name="Ready">True when the store came up.</param>
/// <param name="Default">True when it is the store a request gets when it names none.</param>
public sealed record StoreView(string Key, string Name, bool Ready, bool Default);

/// <summary>The bar's answer: which store this request is on, the stores there are (one of them this site's default), and the other site if there is one.</summary>
/// <param name="Current">The key of the store this request is on.</param>
/// <param name="Stores">Every store on the toggle.</param>
/// <param name="OtherSite">The other site's http or https origin, or null when there is none.</param>
public sealed record StoresView(string Current, IReadOnlyList<StoreView> Stores, string? OtherSite);
```
*Live from [`api/TheYard.Api/Stores.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Api/Stores.cs#L14-L176), region `backends`, read from this build at 1d6b50b.*

**What holds it.** `src/lib/stores.test.ts` holds the segment shape: the
current site follows the container's default store and not the store a
header put one request on, the other segment's address carries the path and
the query, and a container with no other site gets a segment that is not a
link. `tests/e2e/store-toggle.spec.ts` walks the bar in a browser, where on
the gate the other segment is not a link and the old switch endpoint refuses
the method. `StoreToggleTests` holds the rule and the expiry. The one place the real
thing is proven is the live check, `shot-night.mjs` in the lane's notes
outside the repository,
which opens each site, follows the other segment, and reads the host and the
server's own answer on arrival, both ways.

**What the live check read on 1.0.0.101, at 08:47 CDT on 2026-09-09.** On
`theyard.stevenstout.biz` the bar's current segment was SQL and the other
segment's address was `https://theyard-cosmos.stevenstout.biz/`; following
it landed on host `theyard-cosmos.stevenstout.biz`, where the server answered
`current=cosmos` for a container whose default is `cosmos` and the bar's
current segment was Cosmos DB; following that bar's SQL segment came back to
`theyard.stevenstout.biz` on `sql`. The same round trip the other way from
the second site. On `/?view=admin` the other segment's address on the live
site was `https://theyard-cosmos.stevenstout.biz/?view=admin`, the path and
the query carried across. And the old switch endpoint answered 405 on both
sites.

The live site on arrival, and the page the Cosmos DB segment lands on, which
is the other site (the second site's own arrival picture and the page its SQL
segment lands on are these two the other way round, byte for byte, so they
are not repeated):

![The live site on 1.0.0.101: the Store bar with SQL current, Cosmos DB beside it, and the sentence that this is the SQL site served from Azure SQL Database](/api/docs/images/toggle-site-sql.png#1280x420)

![After following the Cosmos DB segment: the second site, theyard-cosmos.stevenstout.biz, with Cosmos DB current and the sentence that this is the Cosmos DB site served from Azure Cosmos DB](/api/docs/images/toggle-site-sql-followed.png#1280x420)

The two bars on their own, one from each site:

![The Store bar on the live site: SQL current, Cosmos DB a link](/api/docs/images/toggle-site-sql-bar.png#1008x52)

![The Store bar on the second site: Cosmos DB current, SQL a link](/api/docs/images/toggle-site-cosmos-bar.png#1008x52)

And on a phone, where the sentence wraps under the segments as before:

![The bar on a phone on 1.0.0.101](/api/docs/images/toggle-site-phone.png#780x600)

## Addendum, 2026-09-20: two sites on one machine, and the second catalogue waits to be asked

Superseded in part on 2026-09-20, as 1.0.0.156. "What it costs" above describes two container
groups, each warming both stores right after it starts. The two sites are web apps on one App
Service plan now (ADR: One plan, two sites), and on the plan `Store:WarmOthers` is off: each site
warms the store it serves, and the other warms on its first request, which is the behaviour this
record already describes for everywhere that is not a deploy. The reason is measured. Four
catalogues of a hundred thousand vehicles on one 1.75 GB machine pressed each process's working
set below the size of its own managed heap; two fit, with the heaps at about 130 and 155 MB.

One process still runs both stores and still picks one per request. What changed is when the
second one pays for its catalogue: at the first proof run after a roll rather than at the roll.

## Addendum, 2026-09-20, shipped as 1.0.0.158: the second catalogue is given back

"Two catalogues" is still what this costs while both are in use, and no longer what it costs for
good. A store the site does not serve loads its catalogue on the first request that names it, and
since 1.0.0.158 gives it back when nothing has asked that store for anything in
`Store:ReleaseIdleMinutes`, ten on the plan both sites share (ADR: One plan, two sites). The default
store is never let go, a load in flight is never dropped, and the setting defaults to zero, which is
never, so a developer's machine and the suite behave as they always have.

## Addendum, 2026-10-04: both catalogues stay loaded

The give-back above is retired. The keep-warm loop keeps both stores' catalogues loaded on both
sites, so two catalogues is again what this costs for good, chosen so a visitor who switches stores
never meets a cold read (ADR: One plan, two sites, the addendum of 4 October).

## Where it sits

Most of this lives in the Api host, where `Backend.cs` holds one store's catalogue, bids and room, `Backends.For` in `Stores.cs` picks a store from the `X-Yard-Store` header or the default, and `CurrentBackend` hands each request an Application `Auction` composed over that backend's services; the front end's `src/lib/stores.ts` turns the choice into a link to the other site. A second store per process works because endpoints and `UserManager` receive the request's backend and its `IUserStore<YardUser>` and never name a store themselves. The cost is two catalogues of a hundred thousand vehicles in one process while both are in use, which is why the second now loads on first request and is let go after ten idle minutes, and bids placed in one process stay invisible to the other until it restarts. A site that needed one store only, or a machine with too little memory for two catalogues, would go back to one store per process and compare across sites instead.

## Files

- [`api/TheYard.Api/Stores.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Api/Stores.cs): the backends, the request's choice, and the warm before a read.
- [`api/TheYard.Infrastructure/ContextFactory.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Infrastructure/ContextFactory.cs): the relational context factory.
- [`api/TheYard.Api/Composition/StoreRegistration.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Api/Composition/StoreRegistration.cs): two stores brought up.
- [`api/TheYard.Api/Endpoints/VehicleEndpoints.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Api/Endpoints/VehicleEndpoints.cs) and [`api/TheYard.Api/Endpoints/BidEndpoints.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Api/Endpoints/BidEndpoints.cs): the endpoints on the request's store.
- [`api/TheYard.Api/Endpoints/HealthEndpoints.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Api/Endpoints/HealthEndpoints.cs): the health check per store.
- [`api/TheYard.Api/MetricsReport.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Api/MetricsReport.cs): the metrics per store.
- [`api/TheYard.Api/Endpoints/AdminEndpoints.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Api/Endpoints/AdminEndpoints.cs): the toggle's endpoints (region stores-endpoints).
- [`api/TheYard.Tests/StoreToggleTests.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Tests/StoreToggleTests.cs): the rule, the endpoints, the ring.
- [`src/lib/stores.ts`](https://github.com/SteveStout/TheYard/blob/main/src/lib/stores.ts) and [`src/components/layout/StoreBar/StoreBar.tsx`](https://github.com/SteveStout/TheYard/blob/main/src/components/layout/StoreBar/StoreBar.tsx): the toggle.
- [`tests/e2e/store-toggle.spec.ts`](https://github.com/SteveStout/TheYard/blob/main/tests/e2e/store-toggle.spec.ts): the toggle in a browser, on one store and on two.
- [`infra/aci-theyard.yaml`](https://github.com/SteveStout/TheYard/blob/main/infra/aci-theyard.yaml) and [`infra/aci-theyard-cosmos.yaml`](https://github.com/SteveStout/TheYard/blob/main/infra/aci-theyard-cosmos.yaml): the two groups, three variables apart.

## Addendum, 2026-10-03: endpoints ask the auction

An endpoint still takes `CurrentBackend`, as this record decided, and now reads the store through `CurrentBackend.Auction`, the Application type that composes the catalogue, the bids and the room for that store. The timing card's shape moved from the endpoint into `MetricsReport` (`api/TheYard.Api/MetricsReport.cs`), and the live block above follows it. The context factory moved to the relational adapter.
