Skip to content

Built with AI

Built with AI

TheYard was built by one engineer using AI as a force multiplier. This page says what that meant in practice: what the AI wrote and what I decided. It also covers what governed the result and what it cost, and then what went wrong. Everything here links to the record or the file that proves it.

In plain words

This page says how the site was built: one engineer used AI to write most first drafts and kept the choices a tool cannot make. It also lists what the work cost and what went wrong, and links each item to the record or file that proves it.

What that is worth: a developer sees which parts a person decided and which tests held the AI's output, and the organization gets a way of using AI where every claim points at its proof.

What the AI wrote

The first draft of most of the code and most of the decision records. The deployment scripts and the tests too. Commits carry the co-author trailer and the repository carries CLAUDE.md, the standing instructions the AI worked under. None of that is hidden and no history has been rewritten.

What I decided

The choices a tool cannot make, each with its record.

  • The domain and the fresh repository, dropping a take-home's lineage for a platform I own.
  • The hosting path under a free-trial subscription: Container Instances behind an edge for TLS for the first three weeks, then both sites on one App Service plan once the memory and the bill were on the same page and the quota wall turned out to be a region, with Front Door still designed and still refused (HOSTING.md, ADR-004, ADR-007, ADR-079).
  • A second store on Cosmos DB beside Azure SQL, the same image on both, and the bar it had to clear: as fast as SQL Server on a different data structure at the lowest cost (ADR-058, ADR-059).
  • One container running both stores with a store toggle at the top of every page, then a permanent address for the second site (ADR-066, ADR-069).
  • The sidebar over dropdowns and the palette, then the rule that every diagram opens on its own page (ADR-013, ADR-016, ADR-020).
  • The one gate: every check once per version, on both stores, before anything rolls (ADR-068 and its addendum).
  • Every test, specified before the code it holds was written (test driven development).
  • Every GO. Nothing rolled to Azure without a written go from me.

What governed it

The test gate. Every version runs all three suites once, in the ship's gate: 1797 xUnit tests, 589 Vitest tests at 1.0.4.132 and 196 Playwright tests. The ship gate runs the API suite against both stores and was measured on 9 September at 275 seconds green on a quiet machine, 302 to 342 seconds with the developer's browser open, and 372 seconds cold after a restart (ADR-068); the gate on 1.0.0.182 took 493 seconds, and the gate on 1.0.3.59, with both store passes carried forward from 1.0.3.58, took 641. A push that fails the gate does not roll.

The records. One hundred and twenty-three decision records, each carrying the decision, the trade-off and the number behind it, with a Files section pointing at the code it governs. Code shown in a record is read from the running build, so a record cannot drift from the code it describes.

The reviews. A self review before one ship found eleven defects, four of them serious: a bid acceptable below the going rate, a lost update on concurrent bids, a poll erasing a just-placed bid, and the simulated room hammering three cars (ADR-027). Later, three readers with no memory of the project, an interviewer, a junior developer and an architect, reviewed the checkout cold, and every finding got a decision: fixed with its version, designed with the version it ships in, or accepted with its reason (ADR-070).

The measurements. On 3 of 8 paths the two stores answer in the same time. On the other 5 the difference is the round trip to the store, 39 ms to Azure SQL Database against 2 ms to Azure Cosmos DB, and taking one round trip per operation off each side leaves them the same (ADR-067). A sign-in costs 2.00 request units, a bid write 6.52, a raise 11.29, a registration 13.04 (ADR-064, ADR-059). Leaving the indexing policy at the default cost 16.07 request units a document on the bulk seed against 8.84 tuned, 21 minutes against 13, and 8,407 documents refused by throttling that the tuned seed never saw (ADR-065).

What it cost

  • The Cosmos DB account on the free tier: $0.00 a month, 1000 RU/s shared, with local auth disabled so no key exists (ADR-058, ADR-059).
  • The compute: two container groups at $34.44 a month each at list price until 20 September, one App Service plan at $12.41 a month for both sites from then, and the same plan at $24.82 since 8 October, when the rendering service joined the two sites on it, drawn from trial credit either way (ADR-059, ADR-079, ADR-096).
  • The edge: Netlify's free plan allots 300 credits a month, and 11 production deploys had consumed 165 of them at 15 each while serving the site cost almost nothing. Application pushes no longer redeploy the edge, so they cost zero credits (ADR-007).

What went wrong

  • 1.0.0.90 took the live site to files. A transitive package bump broke managed-identity token acquisition for SQL inside the container. The Azure SQL container came up on files twice, once on the roll and once on a restart, against a database that was demonstrably online, with a SqlException: A task was canceled wrapping a TaskCanceledException in the Azure retry pipeline both times. Rolled back by hand, the package pinned with the reason in the project file, and the lesson recorded: pin shared Azure packages across projects (ADR-059 addendum).
  • Warming both catalogues before serving turned a two-minute suite into a thirty-minute crawl that looked like a hang. Only the default store warms before serving now (ADR-066).
  • A per-commit changelog line once invented a version that never shipped, because two commits pushed together are one deploy. The version is read from the changelog's top line now (ADR-031).

How to check any of this yourself

  • Open the Admin tab on either site: live health checks per store, the paired-round comparison card, the last container events, and the log of every store operation.
  • Open any record: the Files section links the code it governs, and the live blocks are read from the container's own source at request time.
  • Run the gate yourself, or read the CI runs linked from CICD.md.
  • Read the Performance page beside this one for what the measurements add up to on the smallest machine that will hold it.

Files

  • CLAUDE.md: the standing instructions the AI worked under, kept in the repository rather than hidden.
  • .github/workflows/ci.yml: the three suites on a pull request, and the coverage annotation. The jobs, read from this build:
# Six jobs on a pull request: style first, then one per suite, the SQL
# project behind it, and the job hunt's Function. The suites are separate so a failing
# browser test does not hide a failing unit test, and so the slowest one (the
# end-to-end job, which installs Chrome) does not gate the other two. The
# frontend job also typechecks and builds: a type error should fail before a
# test does.
jobs:
  # #region style-job
  # Format drift fails the build rather than getting argued about in review.
  # It runs first and on its own because it is the cheapest job here: a style
  # failure should not cost the time of a browser suite to discover.
  #
  # dotnet format checks three things at once against .editorconfig: whitespace,
  # using order, and unused usings. Prettier owns the TypeScript and the CSS.
  # oxlint owns the rules a formatter cannot see. The typecheck in the frontend
  # job already enforces noUnusedLocals and noUnusedParameters, so the linter is
  # not asked to repeat it (ADR-028).
  style:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: 24
          cache: npm
      - uses: ./.github/actions/dotnet
      - run: npm ci
      - run: npm run format:check
      - run: npm run lint
      - run: npm run format:api:check
  # #endregion style-job

  frontend:
    # The style job's comment says it runs first so a missing semicolon does not
    # cost a browser suite to discover. That was a description of an intention
    # until this line existed: without `needs` these all start at once and the
    # claim was decoration (the staff review, 2026-09-03).
    needs: style
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: 24
          cache: npm
      - run: npm ci
      - run: npx tsc -b
      # The home page's Tech stack table is generated from the repository; a stale table fails here.
      - run: npm run stack:check
      - run: npm test
      - run: npm run build

  api:
    needs: style
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v7
      - uses: ./.github/actions/dotnet
      # #region coverage
      # The suites are counted as well as run.
      #
      # A repository can say "276 tests" and mean very little by it. What a
      # reader wants to know is what they touch, and coverlet is already a
      # dependency of the test project, so the number costs one flag.
      #
      # The floor is a ratchet rather than a target. It sits just under where
      # the suites actually are, so a change that quietly stops testing
      # something fails here, and it is not set so high that somebody is
      # tempted to write a test that executes a line without asserting on it.
      # A number chased for its own sake buys nothing.
      # Output kept, and named on a failure, for the same reason the browser
      # suite does it: a red job whose only public word is "exit code 1" costs
      # somebody a session, and the full log needs a sign-in this repository
      # cannot assume its reader has. This job went red once on a commit that
      # changed nothing but documentation, and there was no way to say which
      # test without one.
      # The transcript goes to the runner's temp directory, not into the
      # checkout. A test in this suite reads every text file in the repository
      # and holds it to the house rule about em dashes; the console logger this
      # job asks for prints each test's name with its parameters, one of which
      # is the em dash that rule forbids, so a transcript written beside the
      # source made the suite fail on itself, on the runs where the line was
      # flushed before the scan reached it and not on the others
      # (ADR: Where a gate lives, addendum).
      # Everything except the tests that need the real Cosmos DB account. This
      # runner has no Azure credential and is not getting one, so those run on
      # the ship gate on Steve's machine as the signed-in principal; here they
      # are filtered out by their trait rather than skipped, because a skipped
      # test is a green check that checked nothing (ADR: A second store on
      # Cosmos DB, and what it costs).
      # And the coverage floor measures what this runner runs. The document
      # store's adapters are exercised by those filtered tests and by the whole
      # suite booted on the real account on the ship gate, none of which can
      # happen here, so that one assembly is left out of the number this job
      # holds to its floor. The first run with it counted went red at 83.2 per
      # cent of lines for code that was tested an hour earlier on a machine
      # with a credential. Lowering the floor would have hidden the same gap in
      # every other project; naming the assembly hides only the one this runner
      # cannot reach, with the reason beside it.
      # And code a source generator writes is left out by its GeneratedCode
      # attribute: the regular expressions and the OpenAPI comment readers are
      # written by the compiler at build time, nobody here writes or reviews
      # them, and they were a third of every branch counted. Counted, they held
      # the number down; dropped, the floor measures the code people write
      # (ADR: Counting what the tests cover, the addendum on generated code).
      # And The Shed's assembly is its own suite's to count. TheYard's host has
      # referenced it since the three .NET web apps became one, which put it in
      # this run's number at 15 per cent of its lines: only the mount reaches
      # it from here. Its 153 tests run on the ship gate (gate.yml), so it is
      # left out of the number this job holds to its floor, as the document
      # store's adapters are (ADR: Counting what the tests cover, the addendum
      # on The Shed).
      - name: Run the .NET suites
        run: |
          set -o pipefail
          dotnet test api --collect:"XPlat Code Coverage" --results-directory TestResults \
            --filter "Store!=cosmos" \
            --logger 'console;verbosity=normal' \
            -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Exclude="[TheYard.Infrastructure.Cosmos]*,[TestProject]*" \
               DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.ExcludeByAttribute="GeneratedCodeAttribute" \
            2>&1 | tee "$RUNNER_TEMP/dotnet-output.txt"
      - name: Say which test failed, on the run page
        if: failure()
        run: |
          if [ ! -f "$RUNNER_TEMP/dotnet-output.txt" ]; then
            echo "::error::the .NET suites left no output to report"
            exit 0
          fi
          # Three lines after each marker, because the marker is the label and
          # the detail is underneath it: "Assert.Contains() Failure" says which
          # assertion and the line below it says what was actually in the
          # collection, and that second line is the one somebody needs.
          grep -E -A 3 '^[[:space:]]*(Failed |Error Message:)' "$RUNNER_TEMP/dotnet-output.txt" \
            | grep -v '^--$' \
            | head -n 24 \
            | while IFS= read -r line; do
                printf '::error::%s\n' "$(printf '%s' "$line" | tr -d '\r' | cut -c1-400)"
              done
          # dotnet writes its totals two different ways depending on the
          # logger: one 'Passed! - Failed: 0, Passed: 281 ...' line, or a
          # four-line 'Test Run Failed. / Total tests: ...' block. The
          # console logger this job asks for produces the second, so a
          # pattern that only knew the first printed nothing at all.
          grep -E '^[[:space:]]*(Passed!|Failed!|Test Run (Failed|Succeeded)\.|Total tests:|(Passed|Failed|Skipped):[[:space:]]+[0-9]+)' "$RUNNER_TEMP/dotnet-output.txt" \
            | head -n 6 | while IFS= read -r line; do
            printf '::error::%s\n' "$(printf '%s' "$line" | tr -d '\r' | cut -c1-400)"
          done
          {
            echo '## The .NET suites failed'
            echo
            echo '```'
            grep -E -A 6 '^[[:space:]]*Failed ' "$RUNNER_TEMP/dotnet-output.txt" | head -n 80
            echo '```'
          } >> "$GITHUB_STEP_SUMMARY"
      - name: Count what the tests cover
        if: always()
        run: python3 .github/coverage.py TestResults 90.0 75.0
      # #region quality-ratchet
      # The quality numbers, beside the coverage ones and held the same way. Coverage says
      # how much of the code a test touches; these say what shape the code is in: how many
      # declarations carry no summary, how many service classes take six dependencies, how
      # many I/O paths take no cancellation token, how many places read the clock behind
      # the composition root's back.
      #
      # They are a ratchet and not a target. The file holds the count this repository
      # actually had when the line was drawn, so the step is green on the day it lands and
      # red the moment a change makes any of them worse. A fix lowers its number in the
      # same commit, which is the only way the line ever moves, and the step says so in a
      # notice when a number has fallen and the file has not followed it down.
      #
      # It reads the checkout and builds nothing, so it costs a few seconds and runs even
      # when the suites above went red: a reader looking at a red run should see both.
      # The coverage floor above is not this file's to touch (ADR: The numbers that may
      # only fall).
      - name: The quality ratchet
        if: always()
        run: python3 .github/quality.py . --ratchet .github/quality-ratchet.json
      # #endregion quality-ratchet

  # #region functions-job
  # The job hunt's Function and its tests (functions/jobhunt, functions/jobhunt.Tests). The
  # Function shares this repository and nothing in the site's image, so the api job's
  # `dotnet test api` never built it, and the tracker merger's rules, the template check that
  # keeps MERGER on, and the health route's arithmetic ran only on the laptop that wrote them
  # (ADR: The tracker merger runs in Azure). The Function targets .NET 10, the runtime its Flex
  # Consumption app runs and the one every other job installs (ADR-089, the addendum of 1.0.4.71).
  # The parity test runs the laptop's PowerShell merger beside the new one; it needs Windows and a
  # copy of the real tracker, so here it reports "not run" by design and the rest must pass.
  functions:
    needs: style
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v7
      - uses: ./.github/actions/dotnet
      - name: Run the job hunt Function's tests
        run: dotnet test functions/jobhunt.Tests --logger 'console;verbosity=normal'
  # #endregion functions-job

  # #region database-job
  # The database is compiled like everything else. api/TheYard.Database is the
  # authority for the SQL Server schema (ADR: Data first, and the database in
  # source control), and Microsoft.Build.Sql turns its .sql files into a DACPAC,
  # so a column with a type that does not exist or a foreign key pointing at
  # nothing fails here rather than during a publish.
  #
  # It is a job of its own and not a step in the api job, because `dotnet build`
  # on the solution does not build a SQL project. That was found the hard way:
  # a solution build reported success, the DACPAC on disk was the previous one,
  # and a schema change was published that did not contain the change.
  database:
    needs: style
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v7
      - uses: ./.github/actions/dotnet
      - run: dotnet build api/TheYard.Database/TheYard.Database.sqlproj -c Debug
      - name: The build has to have produced a package
        run: test -f api/TheYard.Database/bin/Debug/TheYard.Database.dacpac
      # The campaign's database (ADR-118, Decision 2) is compiled the same way, so its tables cannot drift
      # from the statements the Function sends them.
      - run: dotnet build functions/JobHunt.Database/JobHunt.Database.sqlproj -c Debug
      - name: The campaign's build has to have produced a package
        run: test -f functions/JobHunt.Database/bin/Debug/JobHunt.Database.dacpac
  # #endregion database-job

  e2e:
    needs: style
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: 24
          cache: npm
      - uses: ./.github/actions/dotnet
      - run: npm ci
      - run: npx playwright install --with-deps chrome
      # #region e2e-evidence
      # Keep the output, and on a failure put it on the run page.
      #
      # The report artifact below is the full evidence, and downloading it needs
      # a GitHub login. The run page does not. A red build whose only public
      # words are "Process completed with exit code 1" costs somebody a session
      # to find out what broke, which is the same problem the html reporter was
      # added to solve, one step further out.
      #
      # pipefail so that tee does not swallow the exit code and turn a failing
      # suite into a passing step.
      - name: Run the browser suite
        run: |
          set -o pipefail
          npx playwright test 2>&1 | tee "$RUNNER_TEMP/playwright-output.txt"
      - name: Say what failed, on the run page
        if: failure()
        run: |
          # Two places, because they are readable by different people.
          #
          # The step summary is the nice one and it renders only for somebody
          # signed in. Annotations are the ones that survive: they come back
          # from the public API and show at the top of the run page, which is
          # what a person with no login to this repository can actually read.
          # The first attempt at this wrote only a summary and taught us that.
          if [ ! -f "$RUNNER_TEMP/playwright-output.txt" ]; then
            echo "::error::the browser suite left no output to report"
            exit 0
          fi
          # '[Tt]imeout of' rather than 'Timeout of': a test that runs out
          # of time says 'Test timeout of 90000ms exceeded.', which the
          # capital-only spelling missed, and that line is the whole
          # finding when the failure is a hang.
          grep -E '^[[:space:]]+[0-9]+\) |Error: |[Tt]imeout of |Timeout: |Locator: |Expected: |Received' "$RUNNER_TEMP/playwright-output.txt" \
            | head -n 20 \
            | while IFS= read -r line; do
                # One annotation per line, trimmed: a control character or a
                # newline inside one silently truncates the whole annotation.
                printf '::error::%s\n' "$(printf '%s' "$line" | tr -d '\r' | cut -c1-400)"
              done
          tail -n 4 "$RUNNER_TEMP/playwright-output.txt" | while IFS= read -r line; do
            printf '::error::%s\n' "$(printf '%s' "$line" | tr -d '\r' | cut -c1-400)"
          done
          {
            echo '## The browser suite failed'
            echo
            echo '```'
            tail -n 150 "$RUNNER_TEMP/playwright-output.txt"
            echo '```'
          } >> "$GITHUB_STEP_SUMMARY"
      # #endregion e2e-evidence
      - uses: actions/upload-artifact@v7
        if: failure()
        with:
          name: playwright-report
          path: playwright-report/

Live from .github/workflows/ci.yml, region ci-jobs, read from this build at 9094953.

Store

The Yard, home

Welcome to The Yard

Steven Stout’s showcase: a used-vehicle auction on .NET and React, built and run by one engineer, live on Azure, with its decisions written down.

Answering in

unread

0255075100

server time at p95, the usual five minutes of the last day.

Azure this month

unread

the bill, by resource, read from Azure. The Admin tab shows every line.

Since the last roll

unread

 

rolled blue and green, with no downtime.

People who have visited

unread

30 days agotoday

Scanners, my own tools' and my own reads left out.

  •    
  •    
  •    

Tech stack

Read from the build, never typed by hand

What TheYard runs on

What we use, where it runs, the version as built, and the file that proves it.

  • .NET 1014 projects
  • React 19.3TypeScript 7.0
  • Azure6 Bicep files
  • 9GitHub Actions workflows
LayerWhat we useWhere it runsVersionProof
Backendthe API
Backendthe API.NET and ASP.NET CoreApp Service, the API container10.0TheYard.Api.csproj
Entity Framework Core (SQL Server)App Service, talking to Azure SQL10.0.12TheYard.Infrastructure.csproj
Identity with JWT bearer authApp Service, the API container10.0.12TheYard.Api.csproj
OpenAPI with Scalar API docsApp Service, /api docs2.17.13TheYard.Api.csproj
Frontendthe page
Frontendthe pageReactYour browser19.3package.json
TypeScriptYour browser7.0package.json
ViteBuild step, in the container image8.3package.json
Server-side rendering on NodeApp Service, the renderer container24render/Dockerfile
Datatwo stores, one port
Datatwo stores, one portAzure SQL DatabaseAzure, managed SQLEF Core 10.0.12TheYard.Infrastructure.csproj
Azure Cosmos DBAzure, NoSQLSDK 3.63.2TheYard.Infrastructure.Cosmos.csproj
Azureinfrastructure as code
Azureinfrastructure as codeApp Service and Container Registry (Linux container)Azure App Service and ACRlive resourceappservice.bicep
Front Door (written, switched off)Off: the subscription refuses Front Door, so the Netlify edge stands in front todayoffmain.bicep
Application Insights with OpenTelemetryAzure Monitor1.6.0TheYard.Api.csproj
Communication Services emailAzure Communication Services1.1.0TheYard.Api.csproj
Azure Functions, Flex Consumption (clock, snapshot, tracker merger, Sheet mirror, ATS sweep, daily card)func-jobhunt-ss, Flex Consumption.NET 10 isolatedJobHunt.Functions.csproj
Bicep for every resource it deploysDeployed from the repository6 Bicep filesinfra/
Edge
EdgeNetlify edge in front of the siteNetlify edge networklive resourcenetlify.toml
Quality gatesevery push
Quality gatesevery pushxUnitGitHub Actions runners2.9.3TheYard.Tests.csproj
NetArchTest architecture rulesGitHub Actions runners1.4.5TheYard.Tests.csproj
VitestGitHub Actions runners5.0package.json
Playwright with axe accessibility checksGitHub Actions runners, against the built site1.63package.json
oxlint and PrettierGitHub Actions runners1.86 and 3.9package.json
Delivery and AIhow it ships
Delivery and AIhow it shipsGitHub Actions (azure-read, blog-post, ci, deploy-function, deploy-render, deploy, edge-branch, gate, nightly)GitHub9 workflowsworkflows/
Built with Claude Code under the test gateDeveloper machine, then the gateevery pushgate.yml
Claude Code on GitHub Actions runs the job hunt's nightly lanesGitHub Actions runners, on the Azure share4 AM and every 4 hnightly.yml

Generated, not typed. A build step reads the project files, package.json, the Dockerfiles, the Bicep and the workflows; a test fails CI when this table drifts from the repository.

How it is built

How it is run

Who and why