# ADR: Three readers with no memory of the project

Status: accepted, 2026-09-09. At the end of a day of shipping, Steve asked
for the code to be read with no context, by a devil's advocate in three seats:
an interviewer, a junior developer learning from it, and an architect
reviewing it. The review was run that afternoon, its
findings were verified against the code, and the decisions taken on each are
this record. Three versions carry them: 1.0.0.109, 1.0.0.110 and 1.0.0.111.

## In plain words

Three reviewers with no memory of the project (separate agents) each read the code as a different kind of reader. Eight of their findings held up against the code, and this page records what was done about each one, from a sale shown to its buyer only to a sign-in key invented on every restart.

What that is worth: a developer sees how a stranger reads this code and which defects that reading caught, and the organization gets a review step that finds what the authors cannot see.

## Context

Every review this project had run before was made by the session that wrote
the code, or by a session that had read every record first. Both know what
the system is meant to do, which is exactly the knowledge that hides what it
does. The ask was for the opposite: readers who would meet the checkout the
way a stranger meets it.

Three were run, as separate agents with no memory of any session, each given
the checkout at 1.0.0.108 and one persona. A reviewer screening the repository before a final round. A junior developer a year into a first job,
told to learn from it. A principal architect deciding whether it becomes a
team's reference. None was told what the others were asked. Their reports
are kept outside the repository; every claim in them was then read against
the code by the session that had written it, and the ones that held are
below, in the order of what they cost.

The verdicts, in one line each. The interviewer would advance the candidate
on the strength of the process, on condition the interview tests whether the
candidate understands the auction the code implements rather than the one
the records describe. The junior developer learned Cosmos DB, the onion and
the test shapes from it and would copy six things, and found four documents
giving four different answers to where a bid goes. The architect would keep
the ports, the schema authority, managed identity and the readiness
discipline, and strike four things before anyone copied them: authoritative
state in process memory in front of two writers, an auction clock the caller
chooses, an anonymous write on the Admin tab, and a signing key each process
invents.

## What they found, and what was decided

Eight findings held. Each got one of three answers: fixed, with the version;
decided and designed, with the version it ships in; or accepted as it is,
with the reason.

**1. A purchase sold the vehicle to its buyer only.** Two of the three put
this first. Buy Now recorded the sale on the buyer's own bid and nowhere the
rules or the wire could see it, so a second account saw a live auction at
the Buy Now price, bid it, and was told it had won the same vehicle. Fixed in
1.0.0.110: the rules take `sold` and check it before everything, the bid
service supplies it from the standing it already kept, every vehicle on the
wire says `sold`, and the page shows Sold to strangers (ADR: Accounts and
per-user bids, the addendum on the second buyer).

**2. The auction clock is decided by the caller.** The page sends its own
local midnight as `anchor_ms`, the server derives every window from it, and
the schedule record and the CLAUDE.md rule both describe that as the design.
So two visitors in different zones are in different auctions, and a client
that sends yesterday's midnight bids on a vehicle that ended for everyone
else. Decided here: the clock is the server's. The anchor becomes the
current UTC day's midnight, computed per request on the server and sent to
nobody; `anchor_ms` leaves the API and the page; the windows re-seed at
00:00 UTC rather than at each visitor's midnight, which on the live site is
seven in the evening for its owner; and the browser keeps doing what it does
now, formatting the instants the server sends and counting down to them. One
auction and one clock: no request can name a day. It ships as its own
version with the tests that hold it, and this record gets the addendum; until
then the CLAUDE.md rule stands as written.

**3. Starting the performance proof was anonymous and registered two
accounts a run.** With the one-minute cooldown, one request a minute spent
the whole hour's allowance of registrations, so a stranger could stop real
visitors registering; and the limiter took its slot before Identity had
validated the request, so failed registrations spent it too. Fixed in
1.0.0.109: starting a run takes a signed-in visitor, the card says so, the
proof's two accounts are made once per process and reused, and a slot the
identity refuses goes back (ADR: The one write a stranger can make and ADR:
Same performance, proven, their addenda).

**4. The signing key was invented at process start and nothing configured
it.** Every roll signed every visitor out of a seven-day cookie, and the
comment and the accounts record said the process warned when it did, which
no log line did. Fixed in two halves. 1.0.0.109 added the warning. 1.0.0.111
makes the key configuration the deploy hands to both containers from one
repository secret, `YARD_AUTH_SIGNING_KEY`, substituted into the container
spec at roll time the way the connection strings are, so a session survives a
roll and a token minted by one container reads on the other. A roll with no
secret keeps the placeholder, and a placeholder, an empty value or a key too
short to sign with all count as no key: the process invents one and says so,
which is every roll's behaviour until the secret exists. The only secret in
the pipeline, masked by GitHub in every log, never read by anything but the
roll.

```csharp
/// <summary>
/// The fewest bytes HMAC-SHA256 will sign with. A shorter key is refused by
/// the token handler on the first sign-in, which is a 500 on a page that
/// was working a minute ago; better to treat it as no key at all.
/// </summary>
public const int MinimumKeyBytes = 32;

/// <summary>
/// The configured signing key, or null when there is nothing usable in the
/// setting: no value, a placeholder a failed deploy substitution left
/// behind (anything starting with two underscores, the rule every other
/// substituted setting follows), or a value too short to sign with. Null
/// means the host invents a key and says so (ADR: Accounts and per-user
/// bids) (ADR: Three readers with no memory of the project).
/// </summary>
public static string? ConfiguredKey(string? configured)
{
    if (string.IsNullOrWhiteSpace(configured) || configured.StartsWith("__", StringComparison.Ordinal))
    {
        return null;
    }
    return Encoding.UTF8.GetByteCount(configured) < MinimumKeyBytes ? null : configured;
}
```
*Live from [`api/TheYard.Api/Tokens.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Api/Tokens.cs#L84-L106), region `configured-key`, read from this build at 1d6b50b.*

**5. The retry that overwrites.** Both bid stores catch a concurrency
conflict on the buyer's row and write the same intended state again; the
comments in both adapters and in the DDL said the answer was to start again
from what was there, and the rules were not re-run. With two containers
writing one store, and the header making a cross-container write routine,
that is how two buyers could hold one vehicle. Two things were true of it on
reading. The row a conflict guards is one buyer's, so the race it catches is
the same account bidding from two containers at once, and re-applying the
later bid is a defensible answer to that. And two buyers racing each other
are two rows, so the token never sees that race at all: what they race for
is the vehicle's standing, which lives in each container's memory. 1.0.0.111
makes the three comments say exactly that. The design that closes the gap is
decided here and not yet built: a standing per vehicle that the store owns,
one row or document carrying the amount, the count, the high bidder, the
sale and a version; a bid writes the buyer's row and then the standing with a
compare-and-set on the version; a conflict re-reads the standing and runs the
rules against it before a second attempt, so a bid that no longer clears the
minimum is refused with the sentence it would have got a moment later; and
the in-memory index becomes what it should have been, a cache of the store's
standing, refreshed on conflict. On the relational store that is one
transaction; on the document store the buyer's document and the standing
live in different partitions, so it is two writes and a repair on replay. It
is a schema change on both stores and a record of its own when it ships.

**6. Documents describing systems that no longer run.** All three tripped on
it first: an architecture page saying no authentication and one SQLite file,
a data flow page with one anonymous buyer, CLAUDE.md with five projects and
decimal money, a README calling the site free and putting accounts out of
scope, and a dozen smaller ones across the records. Fixed in 1.0.0.109, and
the README's own bidding paragraph, which that pass missed, in 1.0.0.110.
The live-sample mechanism protects fenced code and nothing else, which is the
honest reason prose drifted while the code in the same pages could not.

**7. Numbers that inflate.** The search claim credited a test with a
stopwatch's number; the test asserts only that the indexed path is not
slower. Fixed in 1.0.0.109, with the weaker claim the test makes. The
interviewer's count that about a quarter of the xUnit tests test the
documentation apparatus rather than the product is accepted as a fact about
this repository, and left as one: those tests are what keep the pages the
site serves from lying, which is the failure the three readers found first.

**8. The smaller ones.** A `Lazy` kept a failed background warm for the life
of the process while the host promised a retry: fixed in 1.0.0.109, the next
caller retries (ADR: The ports learn to wait, addendum). A request that
reached a store whose catalogue was still loading blocked its thread on the
load, the shape that record argued against and said the host did not do, and
did do for the seconds after a roll before the other store had warmed and
for every request to the other store in a test application: fixed in
1.0.0.111, the pipeline awaits the warm before any endpoint reads the store.

```csharp
/// <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();
}
```
*Live from [`api/TheYard.Api/Stores.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Api/Stores.cs#L137-L162), region `warm-before-reading`, read from this build at 1d6b50b.*

A session opened on one store could bid on the other, because the header
puts one request on the other store and nothing checked that the account
was there: the relational store's foreign key answered with a 500, the
document store with nothing. Fixed in 1.0.0.111: the token names the store
that opened it, and the two bid writes refuse a session from another store
with a sentence, at the cost of no lookup.

```csharp
/// <summary>
/// Whether the session was opened on <paramref name="backend"/>. A token
/// from one store used against the other, which the header allows in one
/// request, would write a bid under an account the other store does not
/// have: on the relational side the foreign key refused it as a 500, on
/// the document side nothing refused it at all (ADR: Three readers with no
/// memory of the project). A token with no store claim, which every token
/// minted here carries, is on no store.
/// </summary>
public static bool SessionIsOn(this HttpContext context, Backend backend) =>
    string.Equals(context.User.FindFirstValue(TokenIssuer.StoreClaim), backend.Key, StringComparison.Ordinal);
```
*Live from [`api/TheYard.Api/Tokens.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Api/Tokens.cs#L248-L258), region `session-per-store`, read from this build at 1d6b50b.*

A reset cleared memory before it asked the store, the opposite of the bid
path's rule, so a store that refused left the page saying the bids were gone
while the rows came back at the next start: fixed in 1.0.0.111, the store
first.

Accepted as they are, each with its reason. Identity's lockout count can
lose an increment when two failed sign-ins on one account race, on either
store; that is the framework's shape, the lockout still trips, and a
per-account brute force has to be parallel against itself to gain by it. The
SQL and store logs live in the Application project although their vocabulary
is a store's; they are the application's own record of what its ports did,
and moving them would move the Admin tab's reading of them too. The smoke
suite registers an account per test, which is isolation by construction and
spends a handful of the hour's allowance on a machine that is not the live
site. And an account deleted
while its cookie is still valid can still post a bid that the relational
store's foreign key refuses as a 500, which the page cannot reach because
`/api/auth/me` reads that account as signed out; named here rather than
fixed, because the fix is the same account lookup per bid that the store
claim was chosen to avoid.

## What did not hold, so the next review knows the shape

Little. One number in the verification pass was wrong rather than any of
theirs: it said the proof's tolerance would let a 12 ms row and a 29 ms row
read the same, and it would not; the allowance is fifteen milliseconds or
fifteen per cent of the slower row, whichever is more, which is fifteen here
against a difference of seventeen. The architect's wider point stands: eight
samples a path and a correction that subtracts one round trip per
operation assume much of what they conclude, and the proof record says as
much about throughput and contention. Nothing any of the three called broken
was found to be working.

## Consequences

- A stranger's reading of the checkout is now a documented step, not a
  hope. Three of the four things the architect would strike are gone or
  designed; the fourth, the clock, is decided above.
- The wire carries two new facts, `sold` on every vehicle and a `store`
  claim in every token, and both are refusals the server makes rather than
  facts the browser is trusted with.
- One secret exists in the pipeline where none did. It is a session key that
  lives in GitHub's secret store and the container's environment and
  nowhere else, and a roll without it degrades to what every roll did before.
- Two designs were owed and named: the server's clock (finding 2), which
  shipped the same evening as 1.0.0.112 (the addendum below), and the
  standing the store owns (finding 5), still owed. Until that one ships, the
  rule against a second writer is what it was, and this record says so
  plainly rather than the older records pretending otherwise.

## Addendum, 2026-09-09: the clock, shipped

Finding 2 shipped as 1.0.0.112, the same evening. The anchor is the server's
now: `AuctionClock.Utc` takes a moment and answers that moment and the UTC
midnight that began its day, `Clocks.Now()` is the one place the host asks,
and every endpoint that used to resolve a caller's `anchor_ms` asks that
instead. The parameter is gone from the listing, the detail, both bid writes
and the room's tick; the page no longer computes a midnight or sends one, and
its cache key carries the UTC day instead, because the server re-seeds the
windows at 00:00 UTC and a page cached a minute before must not be served a
minute after. A page from before this version that still sends the parameter
is not read.

```csharp
/// <summary>The clock for a moment: that moment, and the UTC midnight that began its day.</summary>
public static AuctionClock Utc(DateTimeOffset utcNow)
{
    var midnight = new DateTimeOffset(utcNow.UtcDateTime.Date, TimeSpan.Zero);
    return new AuctionClock(utcNow.ToUnixTimeMilliseconds(), midnight.ToUnixTimeMilliseconds());
}
```
*Live from [`api/TheYard.Domain/AuctionClock.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Domain/AuctionClock.cs#L16-L21), region `utc`, read from this build at 1d6b50b.*

What changes for a visitor: the day's auctions turn over at 00:00 UTC rather
than at each visitor's own midnight, which on the live site is seven in the
evening for its owner, and two visitors anywhere see the same auction end at
the same instant. What changes for a caller with curl: nothing it sends can
move an auction's window, so a bid on an ended vehicle is refused whoever
asks. The tests that held the old contract were rewritten to hold the new
one: the domain test that anchored the host's own zone now shows Toronto at
noon and London late in the evening sharing one anchor and Toronto at eight
in the evening already on the next day's; the API test that expected a 400
for an implausible anchor now shows the same vehicle answering the same
window with no anchor, yesterday's, and a nonsense one.

```csharp
/// <summary>
/// The auction is the server's, whoever asks. Until 1.0.0.112 a request
/// carried its own local midnight and the server derived every window from
/// it, so a visitor in another zone was in a different auction and a client
/// that named yesterday's midnight could bid on one that had ended for
/// everyone else; an implausible anchor was a 400 and a plausible wrong
/// one was accepted. Now the same vehicle has the same window for every
/// request, an anchor a request still sends is not read, and the window is
/// the one the domain derives from the UTC midnight that began the day
/// (ADR: Three readers with no memory of the project, the addendum on the
/// clock).
/// </summary>
[Fact]
public async Task The_schedule_does_not_depend_on_who_asks()
{
    using var page = await GetAsync("/api/vehicles?limit=1");
    string id = page.RootElement.GetProperty("vehicles")[0].GetProperty("id").GetString()!;
    long yesterday = TheYard.Domain.AuctionClock.Utc(DateTimeOffset.UtcNow.AddDays(-1)).AnchorMs;

    using var plain = await GetAsync($"/api/vehicles/{id}");
    using var withYesterday = await GetAsync($"/api/vehicles/{id}?anchor_ms={yesterday}");
    using var withNonsense = await GetAsync($"/api/vehicles/{id}?anchor_ms=12345");

    long endsAt = plain.RootElement.GetProperty("auction_ends_at").GetInt64();
    Assert.Equal(endsAt, withYesterday.RootElement.GetProperty("auction_ends_at").GetInt64());
    Assert.Equal(endsAt, withNonsense.RootElement.GetProperty("auction_ends_at").GetInt64());
    var expected = TheYard.Domain.AuctionSchedule.Window(
        id, TheYard.Domain.AuctionClock.Utc(DateTimeOffset.UtcNow).AnchorMs);
    Assert.Equal(expected.EndsAtMs, endsAt);
}
```
*Live from [`api/TheYard.Tests/ApiIntegrationTests.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Tests/ApiIntegrationTests.cs#L254-L283), region `one-clock`, read from this build at 1d6b50b.*

The measurement script and the proof stopped sending the anchor too, so the
next proof run measures the same requests the page makes.

## Where it sits

This review reached four rings: Domain gained AuctionClock.Utc and the sold check at the top of BidRules, Application's BidService now asks the store before it clears a reset, the host Api keeps the signing key and the store claim in Tokens.cs, and the front end's data.ts stopped sending a midnight. Single responsibility, one reason to change per class, is the principle behind the clock fix, since Clocks.Now is the one place the host asks the time and AuctionClock is the one class that decides when a day begins. The price was a repository secret to manage and an auction day that turns over at 00:00 UTC, seven in the evening for the owner, while each vehicle's standing still lives in each container's memory. More than one container writing bids at real volume would force the store-owned standing that finding 5 designed and left unbuilt.

## Files

- [`api/TheYard.Api/Tokens.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Api/Tokens.cs): the configured key's rule, the store claim, and the session-per-store check.
- [`api/TheYard.Api/Stores.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Api/Stores.cs): the warm before any read.
- [`api/TheYard.Api/Composition/AuthRegistration.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Api/Composition/AuthRegistration.cs): the key from configuration.
- [`api/TheYard.Api/Composition/RequestPipeline.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Api/Composition/RequestPipeline.cs): the warming middleware (region warm-before-reading).
- [`api/TheYard.Api/Endpoints/BidEndpoints.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Api/Endpoints/BidEndpoints.cs): the bid endpoints' refusal (region session-per-store).
- [`api/TheYard.Application/BidService.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Application/BidService.cs): the reset, store first.
- [`infra/aci-theyard.yaml`](https://github.com/SteveStout/TheYard/blob/main/infra/aci-theyard.yaml), [`infra/aci-theyard-cosmos.yaml`](https://github.com/SteveStout/TheYard/blob/main/infra/aci-theyard-cosmos.yaml), [`.github/workflows/deploy.yml`](https://github.com/SteveStout/TheYard/blob/main/.github/workflows/deploy.yml) and [`.github/workflows/deploy-cosmos.yml`](https://github.com/SteveStout/TheYard/blob/main/.github/workflows/deploy-cosmos.yml): the one secret, substituted at roll time.
- [`api/TheYard.Tests/WarmthTests.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Tests/WarmthTests.cs), [`api/TheYard.Tests/AuthTests.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Tests/AuthTests.cs) and [`api/TheYard.Tests/BidServiceTests.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Tests/BidServiceTests.cs): the three holds.
- [`api/TheYard.Domain/AuctionClock.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Domain/AuctionClock.cs), [`api/TheYard.Api/Clocks.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Api/Clocks.cs) and [`src/lib/data.ts`](https://github.com/SteveStout/TheYard/blob/main/src/lib/data.ts): the clock, the server's, and the page that stopped sending one.
