# ADR: The API describes itself

Status: accepted, 2026-09-15, shipped as 1.0.0.137. Written on the day a reviewer said that
the first thing they wanted to see was how deep the REST API goes. The API
was forty-one endpoints and no document. This is the document, and the rules that keep it true.

## In plain words

The API publishes its own description in the standard OpenAPI format, and the site shows it as a page you can read and try requests from (API Reference in the sidebar). It is generated from the code, so it cannot drift from what the API really does, and the operator-only routes are left out.

What that is worth: a developer can call the API without reading the code or asking anyone, another team can generate a client from it, and the private admin routes stay off the public map.

## Context

Every other part of this project explains itself from inside the running app: the records, the
architecture pages, the code samples read from the build. The HTTP surface did not. A reader who
wanted to know what `/api/vehicles` accepts had to open Program.cs and read `VehicleQueryParams`,
and a reader who wanted to know what a bid answers had to read `HandleBid` to the end. The README
described features the code did not have and missed two it did, which is what a hand-written
description of an API becomes after a fortnight.

Two facts about the code shaped the answer. First, the endpoints were untyped on the way out: every
one answered `Results.Json(new { ... })` with an anonymous object, which a generator can only
describe as "an object". Second, fifteen of them sit under `/api/admin/` and are
operator surfaces, some of them behind a key: the SQL ring, the Azure state, the reset-link mint.
A generated document that listed those would be an index of the parts of the site a stranger has
the least business reading, and on a public site that is disclosure rather than untidiness.

## What was considered

| Option | What it buys | What it costs |
| --- | --- | --- |
| **`Microsoft.AspNetCore.OpenApi`, the framework's own generator** | Reads the routing table and the typed results directly; no attributes to keep in sync; the package ships with the runtime and moves with it | Newer than Swashbuckle, so fewer worked examples; the document is only as honest as the endpoint metadata |
| Swashbuckle | Fourteen years of examples and every filter anybody has needed | No longer in the template since .NET 9, a second model of the endpoints beside the framework's, and a UI that looks like every other API in the world |
| NSwag | Client generation in the same tool | The same second model, and nothing here needs a generated client |
| Write the document by hand | Total control of the wording | The README already proved what a hand-written description of this API turns into |

**Decision: the framework's generator, curated.** `AddOpenApi` builds one document from the
endpoints as they are mapped, and the work of this record is making the endpoints say enough for
that document to be worth reading.

For the page a person opens, **Scalar** rather than Swagger UI. Both render the same document;
Scalar renders it as a reference with a request builder and generated client code in the visitor's
language, the assets ship inside the package rather than from a content delivery network, and it
does not look like the default page of every API written in the last decade. It sits at
`/api/reference`, and the document it renders is at `/api/openapi/v1.json` rather than the
framework's default `/openapi/v1.json`, because `/api` is the one prefix the development server
proxies to the host: at the default address the page's request for its own document came back as
`index.html` on a developer's machine and in the browser suite, and the first run of that suite said
so. Both are served by the same container as everything else, and both are rows of an **API
Reference** section in the sidebar, right under App Architecture, so a reader finds the API without
opening anything (ADR: The sidebar, the addendum on the twelfth section).

## The four rules, and the test that holds each

**1. The admin surface is not in the document.** `OpenApiOptions.ShouldInclude` answers false for
any route under `/api/admin/`, in one place, so the document never carries an operator endpoint
however many are added. That is a filter, and a filter is the kind of rule that breaks silently a
year later when somebody maps an admin endpoint under a new prefix, so `ApiDocumentTests` reads the
served document and fails if any path in it starts with `/api/admin`, and it reads the routing
table's own description of the host to prove the filter is doing work rather than passing because
there was nothing to filter.

```csharp
/// <summary>
/// Whether an endpoint belongs in the document a stranger reads. One
/// prefix decides it: the fifteen routes under /api/admin/ are the SQL
/// ring, the Azure state, the reset-link mint and their kin, and a public
/// document that indexed them would be disclosure rather than untidiness.
/// ApiDocumentTests reads the served document and fails if the filter
/// ever lets one through.
/// </summary>
public static bool IsPublic(ApiDescription description) =>
    !(description.RelativePath ?? string.Empty)
        .TrimStart('/')
        .StartsWith(AdminPrefix, StringComparison.OrdinalIgnoreCase);
```
*Live from [`api/TheYard.Api/ApiDocument.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Api/ApiDocument.cs#L78-L89), region `public-surface`, read from this build at 1d6b50b.*

**2. Every public endpoint is in it, and nothing else is.** The same test compares the document's
paths and methods against every endpoint the host maps, minus the admin ones, in both directions.
An endpoint that somebody excludes from description by accident disappears from the document and
fails the build; a document that grew a path the host does not serve fails it too.

**3. Every operation carries an operation id, a summary, and the responses it can actually
answer.** The gate here is the one that stops the document rotting the way the README did. An
operation id, because a generated client and a search both key on it. A summary, because a list of
paths with no sentence beside each is a directory listing. And responses, including the failures:
a protected endpoint declares its 401, an endpoint with a route parameter declares its 404, and an
endpoint that reads a body declares its 400. A document that only shows 200s is the tell of a
generated document nobody read.

Declaring those responses honestly meant typing the endpoints. Every anonymous object became a named
record (`VehiclePage`, `BidResult`, `HealthReport` and the rest, in `Replies.cs`), the vehicle on
the wire became `VehicleView` rather than a JSON node with five fields added by hand, and the
handlers return `TypedResults` through `Results<...>` unions, so the generator reads the shape from
the type rather than inferring it. `TypedResults.Ok` rather than `TypedResults.Json`, because only
the first carries its type into the document; the second serialises the same bytes and declares
nothing. Where a handler answers text or a file, the endpoint says so with `Produces`. The wire did
not change: the same snake_case names, in the same order, with the derived auction facts last.

**4. The bearer scheme is declared, and only the protected operations require it.** The session is
a JWT that the browser carries in an httpOnly cookie and that a client can carry as a bearer header;
the handler reads either. The document declares both schemes, and an operation transformer marks
every endpoint that has `RequireAuthorization` as requiring one or the other, so the reference page
shows a lock on exactly the operations that will answer 401 without a session. The test reads the
routing table for the endpoints that carry authorization metadata and requires the set of
operations with a security requirement to be exactly that set.

```csharp
/// <summary>
/// What the generator cannot read off a route. The document transformer
/// names the document and declares the two ways a session travels; the
/// operation transformer marks every endpoint that carries authorization
/// metadata as requiring one of them, which is the same metadata the
/// authorization middleware answers 401 from, so the lock in the
/// reference page and the 401 on the wire cannot disagree.
/// </summary>
public static void Configure(OpenApiOptions options, string version, string commit, string? siteUrl)
{
    options.ShouldInclude = IsPublic;

    options.AddDocumentTransformer((document, _, _) =>
    {
        // The address a client should send requests to. The generator
        // fills this from the request's host, and behind the edge that
        // host is the origin container, which is not an address anybody
        // should be handed (ADR: Accounts and per-user bids, the addendum
        // on the reset link). The site's own configured address when there
        // is one; nothing otherwise, which means "the host you read this
        // from", the right answer for a developer's machine and a test.
        document.Servers = string.IsNullOrWhiteSpace(siteUrl)
            ? []
            : [new OpenApiServer { Url = siteUrl.TrimEnd('/') }];

        document.Info ??= new OpenApiInfo();
        document.Info.Title = Title;
        document.Info.Version = version;
        document.Info.Description =
            "A used-vehicle auction platform in .NET 10: a hundred thousand listings with server-derived "
            + "auction windows, bidding against a simulated room, accounts, and two stores behind one set of "
            + "ports. Every response is snake_case, every instant is milliseconds since the epoch, every "
            + "amount is whole dollars, and every failure is an RFC 9457 problem. Built from commit "
            + commit + ". The decisions behind the API are served beside it, under Decision Records.";

        document.Components ??= new OpenApiComponents();
        document.Components.SecuritySchemes ??= new Dictionary<string, IOpenApiSecurityScheme>(StringComparer.Ordinal);
        document.Components.SecuritySchemes[BearerScheme] = new OpenApiSecurityScheme
        {
            Type = SecuritySchemeType.Http,
            Scheme = "bearer",
            BearerFormat = "JWT",
            Description = "The session token, issued by register, login and reset, sent as a bearer header.",
        };
        document.Components.SecuritySchemes[CookieScheme] = new OpenApiSecurityScheme
        {
            Type = SecuritySchemeType.ApiKey,
            In = ParameterLocation.Cookie,
            Name = TokenIssuer.CookieName,
            Description = "The same token as the browser carries it: an httpOnly cookie set by register, login and reset.",
        };
        return Task.CompletedTask;
    });

    // A number is a number. The host's JSON options accept a quoted
    // number in a request body, which is a Web default rather than a
    // choice here, and the generator faithfully writes every integer and
    // double as "integer or string, matching this pattern". Nothing this
    // API answers is ever a quoted number, and a client that sends one is
    // outside the contract, so the document says the type and nothing else.
    options.AddSchemaTransformer((schema, _, _) =>
    {
        if (schema.Type is { } type
            && type.HasFlag(JsonSchemaType.String)
            && (type.HasFlag(JsonSchemaType.Integer) || type.HasFlag(JsonSchemaType.Number)))
        {
            schema.Type = type & ~JsonSchemaType.String;
            schema.Pattern = null;
        }
        return Task.CompletedTask;
    });

    options.AddOperationTransformer((operation, context, _) =>
    {
        var metadata = context.Description.ActionDescriptor.EndpointMetadata;
        bool guarded = metadata.OfType<IAuthorizeData>().Any() && !metadata.OfType<IAllowAnonymous>().Any();
        if (guarded)
        {
            // Two requirements, not one with two schemes: a list is "any
            // of these", and either the header or the cookie opens the door.
            operation.Security ??= [];
            operation.Security.Add(new OpenApiSecurityRequirement
            {
                [new OpenApiSecuritySchemeReference(BearerScheme, context.Document)] = [],
            });
            operation.Security.Add(new OpenApiSecurityRequirement
            {
                [new OpenApiSecuritySchemeReference(CookieScheme, context.Document)] = [],
            });
        }
        return Task.CompletedTask;
    });
}
```
*Live from [`api/TheYard.Api/ApiDocument.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Api/ApiDocument.cs#L93-L185), region `transformers`, read from this build at 1d6b50b.*

The whole of the test class is the enforcement, and the rules table in ADR: The rules a change has
to pass names it beside the other seventeen:

```csharp
/// <summary>One mapped endpoint, as the host describes it: the method, the path, and whether it needs a session.</summary>
private sealed record Mapped(string Method, string Path, bool Protected);

/// <summary>
/// Every endpoint the host maps, admin ones included, read from the same
/// description the generator reads. The path is the route template with a
/// leading slash, which is how the document keys it.
/// </summary>
private IReadOnlyList<Mapped> Endpoints() =>
    factory.Services.GetRequiredService<IApiDescriptionGroupCollectionProvider>()
        .ApiDescriptionGroups.Items
        .SelectMany(group => group.Items)
        .Select(description => new Mapped(
            description.HttpMethod ?? "",
            "/" + (description.RelativePath ?? "").TrimStart('/'),
            description.ActionDescriptor.EndpointMetadata.OfType<IAuthorizeData>().Any()
                && !description.ActionDescriptor.EndpointMetadata.OfType<IAllowAnonymous>().Any()))
        .ToList();

private static bool IsAdmin(string path) =>
    path.StartsWith("/" + ApiDocument.AdminPrefix, StringComparison.OrdinalIgnoreCase);

private async Task<JsonDocument> DocumentAsync()
{
    var response = await _client.GetAsync(ApiDocument.DocumentRoute);
    Assert.Equal(HttpStatusCode.OK, response.StatusCode);
    Assert.Equal("application/json", response.Content.Headers.ContentType?.MediaType);
    return JsonDocument.Parse(await response.Content.ReadAsStringAsync());
}

/// <summary>Every operation in the document as (method, path, operation), the method upper-cased the way the host reports it.</summary>
private static List<(string Method, string Path, JsonElement Operation)> Operations(JsonDocument document)
{
    var operations = new List<(string, string, JsonElement)>();
    foreach (var path in document.RootElement.GetProperty("paths").EnumerateObject())
    {
        foreach (var operation in path.Value.EnumerateObject())
        {
            operations.Add((operation.Name.ToUpperInvariant(), path.Name, operation.Value));
        }
    }
    return operations;
}

[Fact]
public async Task The_document_is_served_and_says_what_it_describes()
{
    using var document = await DocumentAsync();

    Assert.StartsWith("3.", document.RootElement.GetProperty("openapi").GetString(), StringComparison.Ordinal);
    Assert.Equal(ApiDocument.Title, document.RootElement.GetProperty("info").GetProperty("title").GetString());
    Assert.False(string.IsNullOrWhiteSpace(document.RootElement.GetProperty("info").GetProperty("description").GetString()));
}

/// <summary>
/// The rule that silently breaks a year later: an operator endpoint under
/// a new name, or the filter loosened by a refactor, and the public
/// document becomes an index of the parts of the site a stranger has the
/// least business reading. The second assertion is what makes the first
/// one worth having: a filter that passes because there is nothing to
/// filter has not been tested.
/// </summary>
[Fact]
public async Task No_admin_route_is_in_the_public_document()
{
    using var document = await DocumentAsync();

    var leaked = Operations(document)
        .Where(operation => IsAdmin(operation.Path))
        .Select(operation => $"{operation.Method} {operation.Path}")
        .ToList();
    Assert.True(leaked.Count == 0, "these operator endpoints are in the public document: " + string.Join(", ", leaked));

    int admin = Endpoints().Count(endpoint => IsAdmin(endpoint.Path));
    Assert.True(admin >= 10, $"only {admin} admin endpoints are mapped, so this test is not exercising the filter");
}

[Fact]
public async Task Every_public_endpoint_is_in_the_document_and_nothing_else_is()
{
    using var document = await DocumentAsync();

    var mapped = Endpoints()
        .Where(endpoint => !IsAdmin(endpoint.Path))
        .Select(endpoint => $"{endpoint.Method} {endpoint.Path}")
        .ToHashSet(StringComparer.Ordinal);
    var documented = Operations(document)
        .Select(operation => $"{operation.Method} {operation.Path}")
        .ToHashSet(StringComparer.Ordinal);

    // A floor, so a document that describes three endpoints of forty-one
    // cannot pass by matching a routing table that was read wrongly.
    Assert.True(mapped.Count >= 20, $"only {mapped.Count} public endpoints were read from the host");
    Assert.True(
        mapped.SetEquals(documented),
        "mapped but not documented: [" + string.Join(", ", mapped.Except(documented))
        + "]; documented but not mapped: [" + string.Join(", ", documented.Except(mapped)) + "]");
}

/// <summary>
/// The gate that keeps the document from rotting the way the README did:
/// a public endpoint cannot ship without a name, a sentence, and its
/// responses. A 2xx other than 204 declares what it carries; every
/// response is a numbered status rather than a default.
/// </summary>
[Fact]
public async Task Every_operation_carries_an_operation_id_a_summary_and_its_responses()
{
    using var document = await DocumentAsync();
    var wrong = new List<string>();
    var ids = new HashSet<string>(StringComparer.Ordinal);

    foreach ((string method, string path, JsonElement operation) in Operations(document))
    {
        string where = $"{method} {path}";
        string? id = operation.TryGetProperty("operationId", out var idElement) ? idElement.GetString() : null;
        if (string.IsNullOrWhiteSpace(id))
        {
            wrong.Add($"{where} has no operation id");
        }
        else if (!ids.Add(id))
        {
            wrong.Add($"{where} repeats the operation id {id}");
        }

        if (!operation.TryGetProperty("summary", out var summary) || string.IsNullOrWhiteSpace(summary.GetString()))
        {
            wrong.Add($"{where} has no summary");
        }

        if (!operation.TryGetProperty("responses", out var responses) || responses.ValueKind != JsonValueKind.Object)
        {
            wrong.Add($"{where} declares no responses");
            continue;
        }

        bool success = false;
        foreach (var response in responses.EnumerateObject())
        {
            if (!Regex.IsMatch(response.Name, @"^\d{3}$"))
            {
                wrong.Add($"{where} declares a response called '{response.Name}' rather than a status code");
                continue;
            }

            if (response.Name[0] != '2')
            {
                continue;
            }

            success = true;
            if (response.Name != "204"
                && (!response.Value.TryGetProperty("content", out var content) || !content.EnumerateObject().Any()))
            {
                wrong.Add($"{where} declares a {response.Name} with no content type or shape");
            }
        }

        if (!success)
        {
            wrong.Add($"{where} declares no success response");
        }
    }

    Assert.True(wrong.Count == 0, string.Join(Environment.NewLine, wrong));
}

/// <summary>
/// The failures, because a document that only shows 200s is the tell of a
/// generated document nobody read. The three are mechanical: a session is
/// checked before a protected handler runs, a route parameter names
/// something that can be missing, and a body can fail to parse or be
/// refused.
/// </summary>
[Fact]
public async Task Every_operation_declares_the_failures_it_can_answer()
{
    using var document = await DocumentAsync();
    var guarded = Endpoints()
        .Where(endpoint => endpoint.Protected && !IsAdmin(endpoint.Path))
        .Select(endpoint => $"{endpoint.Method} {endpoint.Path}")
        .ToHashSet(StringComparer.Ordinal);
    var wrong = new List<string>();

    foreach ((string method, string path, JsonElement operation) in Operations(document))
    {
        string where = $"{method} {path}";
        var declared = operation.GetProperty("responses").EnumerateObject().Select(response => response.Name).ToHashSet(StringComparer.Ordinal);

        if (guarded.Contains(where) && !declared.Contains("401"))
        {
            wrong.Add($"{where} needs a session and does not declare its 401");
        }

        if (path.Contains('{') && !declared.Contains("404"))
        {
            wrong.Add($"{where} takes a route parameter and does not declare its 404");
        }

        if (operation.TryGetProperty("requestBody", out _) && !declared.Contains("400"))
        {
            wrong.Add($"{where} reads a body and does not declare its 400");
        }
    }

    Assert.True(wrong.Count == 0, string.Join(Environment.NewLine, wrong));
}

/// <summary>
/// The session is a bearer token the browser carries in a cookie and a
/// client can carry in a header, so the document declares both, and marks
/// exactly the operations that will answer 401 without one. Exactly: a
/// lock on a public read would send a client looking for a token it does
/// not need, and a missing lock on a write is the document lying.
/// </summary>
[Fact]
public async Task The_schemes_are_declared_and_only_the_protected_operations_require_one()
{
    using var document = await DocumentAsync();

    var schemes = document.RootElement.GetProperty("components").GetProperty("securitySchemes");
    var bearer = schemes.GetProperty(ApiDocument.BearerScheme);
    Assert.Equal("http", bearer.GetProperty("type").GetString());
    Assert.Equal("bearer", bearer.GetProperty("scheme").GetString());
    var cookie = schemes.GetProperty(ApiDocument.CookieScheme);
    Assert.Equal("apiKey", cookie.GetProperty("type").GetString());
    Assert.Equal("cookie", cookie.GetProperty("in").GetString());
    Assert.Equal(TokenIssuer.CookieName, cookie.GetProperty("name").GetString());

    // The operator endpoints are protected too, and are not in the
    // document by rule 1, so they are not this rule's business.
    var guarded = Endpoints()
        .Where(endpoint => endpoint.Protected && !IsAdmin(endpoint.Path))
        .Select(endpoint => $"{endpoint.Method} {endpoint.Path}")
        .ToHashSet(StringComparer.Ordinal);
    var locked = Operations(document)
        .Where(operation => operation.Operation.TryGetProperty("security", out var security)
            && security.EnumerateArray().Any(requirement => requirement.TryGetProperty(ApiDocument.BearerScheme, out _)))
        .Select(operation => $"{operation.Method} {operation.Path}")
        .ToHashSet(StringComparer.Ordinal);

    Assert.True(guarded.Count >= 5, $"only {guarded.Count} protected endpoints were read from the host");
    Assert.True(
        guarded.SetEquals(locked),
        "protected but not locked in the document: [" + string.Join(", ", guarded.Except(locked))
        + "]; locked but not protected: [" + string.Join(", ", locked.Except(guarded)) + "]");
}

/// <summary>
/// Every amount here is whole dollars and every instant is milliseconds,
/// and a document that offered "integer or string" for each of them would
/// be describing a JSON option rather than the API. Read from every
/// schema in the document, the nested ones included.
/// </summary>
[Fact]
public async Task Every_number_in_the_document_is_a_number_and_nothing_else()
{
    using var document = await DocumentAsync();
    var wrong = new List<string>();

    void Walk(JsonElement element, string where)
    {
        if (element.ValueKind == JsonValueKind.Object)
        {
            if (element.TryGetProperty("type", out var type) && type.ValueKind == JsonValueKind.Array)
            {
                var names = type.EnumerateArray().Select(name => name.GetString()).ToList();
                if (names.Contains("string") && (names.Contains("integer") || names.Contains("number")))
                {
                    wrong.Add($"{where} is typed [{string.Join(", ", names)}]");
                }
            }

            foreach (var property in element.EnumerateObject())
            {
                Walk(property.Value, where + "/" + property.Name);
            }
        }
        else if (element.ValueKind == JsonValueKind.Array)
        {
            int index = 0;
            foreach (var item in element.EnumerateArray())
            {
                Walk(item, where + "/" + index++);
            }
        }
    }

    Walk(document.RootElement.GetProperty("components").GetProperty("schemas"), "schemas");
    Walk(document.RootElement.GetProperty("paths"), "paths");

    Assert.True(wrong.Count == 0, string.Join(Environment.NewLine, wrong));
}

/// <summary>
/// Behind the edge the container sees the origin's host, and a document
/// that named it would hand every reader an address they should not use.
/// With the site's address configured, that is the server; without one,
/// the document names none and a client uses the host it read it from.
/// </summary>
[Fact]
public async Task The_document_names_the_site_as_its_server_only_when_the_site_has_an_address()
{
    using var unnamed = await DocumentAsync();
    Assert.False(
        unnamed.RootElement.TryGetProperty("servers", out var none) && none.GetArrayLength() > 0,
        "with no Site:Url the document should name no server");

    using var named = factory.WithWebHostBuilder(host => host.UseSetting("Site:Url", "https://theyard.example/"));
    using var response = await named.CreateClient().GetAsync(ApiDocument.DocumentRoute);
    Assert.Equal(HttpStatusCode.OK, response.StatusCode);
    using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
    var servers = document.RootElement.GetProperty("servers");
    Assert.Equal(1, servers.GetArrayLength());
    Assert.Equal("https://theyard.example", servers[0].GetProperty("url").GetString());
}

[Fact]
public async Task The_reference_page_is_served_from_the_same_container()
{
    var response = await _client.GetAsync(ApiDocument.ReferenceRoute);

    Assert.Equal(HttpStatusCode.OK, response.StatusCode);
    Assert.Equal("text/html", response.Content.Headers.ContentType?.MediaType);
    Assert.Contains(ApiDocument.Title, await response.Content.ReadAsStringAsync(), StringComparison.Ordinal);
}
```
*Live from [`api/TheYard.Tests/ApiDocumentTests.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Tests/ApiDocumentTests.cs#L29-L354), region `document`, read from this build at 1d6b50b.*

## What changed on the wire, and what did not

Two behaviours moved, both from an implicit answer to a stated one, and both are recorded here so
that a reader of ADR: Error handling, one shape everywhere is not misled by its sentence about the
empty 404.

That record says an unknown vehicle "stays an empty 404". It stopped being empty when the status
code pages middleware arrived: a bare 404 with no body was already being filled in with a
ProblemDetails body on the way out, and nothing said so. From this version the endpoints that
answer 404 and 401 say it themselves, with a title and a one-sentence detail, in the same
ProblemDetails shape every other failure here uses. The status codes are unchanged; the difference
is that the body now names what was not found instead of the middleware naming nothing.

One thing the first build of the document taught, recorded because the next person to add a query
record will meet it: an `[AsParameters]` record is read through a wrapper that merges the
constructor parameter's attributes with the property's into a plain `Attribute` array whenever the
constructor parameter has any, and the generator then casts that array to a typed one and throws,
which answered the whole document with a 500 on the first run. `VehicleQueryParams` had carried its
`[FromQuery]` names on the constructor parameters since it was written. They target the properties
now, the wrapper hands back the property's own typed array, and the document builds.

The vehicle's wire shape is unchanged in every name and value. What changed is how it is built: a
record with the dataset's twenty-nine fields and the five derived facts, rather than a serialised
node with five keys appended. ADR: Program.cs, explained for a new developer carries an addendum on
this, because its explanation of `wireFormat` was written against the old path.

## What this record does not claim

The document describes the HTTP surface. It is not a promise of stability: this is a portfolio
with a changelog, not a versioned public API, and the document's version is the build's version
because that is the honest number. Nor does the document describe the two stores, the identity, or
the deploy; those have their own records, and the reference page's description points at them.

The admin endpoints are excluded from the document, not from the site. They answer as they did,
behind the key where they were behind it, and their tests are unchanged. Hiding a route from a
document is not a security measure and is not described as one here; ADR: The code is public and
the secrets are not is where that question is answered.

## Addendum, 2026-09-25 (1.0.3.21): the site's page, not a product's

The reference page came with Scalar's own extras: a telemetry call on every visit, an AI chat button, an MCP link, a developer toolbar on a local run, and a dark mode the stylesheet the site hands the page (the operator's look, 1.0.3.19) is not written in. All of them are off in the page's options, so it offers what the site offers and fetches from nothing but this host and the one font. `sidebar.spec` opens the page and holds it there: no Ask AI, no developer toolbar, no dark mode button, no request to any other host.

## Where it sits

The work sits in the host Api: ApiDocument.cs configures the framework's generator and its two transformers, Replies.cs and VehicleWire.cs hold the named records the endpoints answer with, and no inner ring changed. SOLID barely reaches this one; the nearest fit is single responsibility, meaning one reason to change: the public-surface filter and the bearer marking live in ApiDocument.cs alone. It cost a pass over every handler to swap anonymous objects for named records, and a newer generator with fewer worked examples than Swashbuckle. A promise of a stable, versioned API to outside teams would call for a version policy and a document per version.

## Files

- [`api/TheYard.Api/ApiDocument.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Api/ApiDocument.cs): the document's name, title and routes, the public-surface filter, and the two transformers.
- [`api/TheYard.Api/Replies.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Api/Replies.cs): the named shapes the endpoints answer with.
- [`api/TheYard.Api/VehicleWire.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Api/VehicleWire.cs): `VehicleView`, the vehicle as the wire carries it.
- [`api/TheYard.Api/Composition/ApiRegistration.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Api/Composition/ApiRegistration.cs) and [`api/TheYard.Api/Endpoints/ReferenceEndpoints.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Api/Endpoints/ReferenceEndpoints.cs): the generator and the reference page wired.
- [`api/TheYard.Api/Endpoints`](https://github.com/SteveStout/TheYard/tree/main/api/TheYard.Api/Endpoints): every public endpoint named, summarised and typed.
- [`api/TheYard.Api/TheYard.Api.csproj`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Api/TheYard.Api.csproj): the two packages, pinned.
- [`api/TheYard.Tests/ApiDocumentTests.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Tests/ApiDocumentTests.cs): the four rules, held.
- [`docs/decisions/ADR-075-the-rules-a-change-has-to-pass.md`](https://github.com/SteveStout/TheYard/blob/main/docs/decisions/ADR-075-the-rules-a-change-has-to-pass.md): the rules table, with this record's row.
