# ADR: The resume has an address

Status: accepted, 2026-10-15 (1.0.3.133). The resume is served at `/resume`, the locked file byte for byte, inline in the browser under his name and the lock's date. A link may name the door it was handed out through, `?src=linkedin`, and the activity card counts the opens by door without ever recording the query.

## In plain words

Steve sends a link to his resume now instead of attaching the file. A link has to be short enough to read aloud and stable enough to print, and it should tell him which of his channels recruiters actually open it from. `/resume` is that link. `https://theyard.stevenstout.biz/resume?src=linkedin` opens the same PDF the site has always served, and the visit is counted as a LinkedIn open.

What that is worth: one address in every signature, profile and form field, and a count of which door works, from a site that already counts its visitors without knowing who they are.

## Context

Until this record the resume was reachable at `/api/docs/resume`, which reads like an API call, and at `/docs/resume.pdf`. On 14 October Steve ruled that messages under his name carry a link, not an attachment. The activity card already walks every visitor-day through four steps to the resume, and on 14 October it counted 112 visitor-days that reached the resume in 30 days, with no way to say where they came from: the referring host is recorded, but a link opened from an email or a PDF carries none.

## Decision

**The address is an API route, not an edge redirect.** `GET /resume` answers 200 with the PDF, so the address bar keeps `/resume` and one open counts once. A Netlify redirect to `/api/docs/resume` would cost an edge deploy and count every open twice. The handler serves the same file as `/api/docs/resume` with `Content-Disposition: inline` and the filename `StevenStoutResume_2026-09-29.pdf`, his own naming for the file he sends, and the pipeline's no-cache header, so a new lock is what the next open gets.

```csharp
// The resume at an address a person can type and a link can carry (ADR: The resume has an
// address): the same file as /api/docs/resume, inline, under his name and the lock's date.
// The door in ?src= becomes one allowed word for the activity hook; the query is dropped.
app.MapGet("/resume", ResumeAtItsAddress)
    .WithName("GetResumeAtItsAddress")
    .WithTags("Documents")
    .WithSummary("The resume, inline, at an address a link can carry")
    .WithDescription("The same PDF as /api/docs/resume, opened in the browser under the author's name. "
        + "An optional ?src= names the door the link was handed out through (linkedin, application, email, signature, post); "
        + "it is counted and never stored, and any other value counts as other.")
    .Produces<byte[]>(StatusCodes.Status200OK, "application/pdf");
```
*Live from [`api/TheYard.Api/Endpoints/DocumentationEndpoints.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Api/Endpoints/DocumentationEndpoints.cs#L82-L92), region `resume-address`, read from this build at 1d6b50b.*

**The door is one word from a short list, recorded as a path.** `src` may be linkedin, application, email, signature or post; anything else is other. The handler leaves `/resume/<door>` on the request and the activity hook records that instead of the path, so the row stays a path with nothing about the visitor in it and the query string is never recorded at all (ADR-071). No schema changes on either store, and the funnel's resume step counts everything under `/resume/`.

```csharp
/// <summary>
/// The resume at its own address, /resume, and the door a visitor came through
/// (ADR: The resume has an address). A link Steve hands out carries
/// <c>?src=linkedin</c>, <c>?src=email</c> and so on, so the activity card can
/// say which door recruiters open the resume from. The query string is never
/// recorded (ADR-071): the handler turns it into one word from a short allow
/// list, and the activity hook records that word as part of the path,
/// <c>/resume/linkedin</c>, so the row stays a path and nothing about the visitor.
/// </summary>
public static class ResumeDoor
{
    /// <summary>The doors a link may name. Anything else, or nothing, is <c>other</c>.</summary>
    public static readonly IReadOnlySet<string> Doors =
        new HashSet<string>(["linkedin", "application", "email", "signature", "post"], StringComparer.Ordinal);

    /// <summary>The key the handler leaves on the request for the activity hook to record instead of the path.</summary>
    public const string ItemKey = "activity-path";

    /// <summary>The prefix every door's path starts with, which the funnel's resume step counts.</summary>
    public const string PathPrefix = "/resume/";

    /// <summary>The name a browser saves the file under: his name and the locked resume's date, the way he names the file he sends.</summary>
    public static string FileName => $"StevenStoutResume_{LockDate:yyyy-MM-dd}.pdf";

    /// <summary>The day the served resume was locked; a new resume moves it with the file and its hash.</summary>
    public static readonly DateOnly LockDate = new(2026, 9, 29);

    /// <summary>The door a <c>src</c> value names: one of <see cref="Doors"/> in lower case, or <c>other</c>.</summary>
    public static string Of(string? src)
    {
        string word = (src ?? "").Trim().ToLowerInvariant();
        return Doors.Contains(word) ? word : "other";
    }

    /// <summary>The path the activity row carries for a visit through a door: <c>/resume/linkedin</c>.</summary>
    public static string PathFor(string? src) => PathPrefix + Of(src);
}
```
*Live from [`api/TheYard.Api/ResumeDoor.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Api/ResumeDoor.cs#L4-L40), region `resume-door`, read from this build at 1d6b50b.*

**The served file is held to the lock.** `wwwroot/docs/resume.sha256` carries the locked resume's hash, and `ResumeAddressTests` hashes what `/resume` serves against it, so a new resume that forgets the copy fails the gate. The same tests hold the inline header and the filename, the same bytes as `/api/docs/resume`, every door word, and a visit with an email address in its query that reaches the request ring as `/resume/linkedin` with no at sign anywhere.

```csharp
[Fact]
public async Task Resume_answers_200_application_pdf_inline_with_his_name_and_the_lock_date()
{
    var response = await _client.GetAsync("/resume");

    Assert.Equal(HttpStatusCode.OK, response.StatusCode);
    Assert.Equal("application/pdf", response.Content.Headers.ContentType?.MediaType);
    var disposition = response.Content.Headers.ContentDisposition;
    Assert.NotNull(disposition);
    Assert.Equal("inline", disposition.DispositionType);
    Assert.Equal("StevenStoutResume_2026-09-29.pdf", disposition.FileName?.Trim('"'));
}

[Fact]
public async Task Resume_bytes_equal_the_lock_sha256()
{
    byte[] body = await _client.GetByteArrayAsync("/resume");
    string served = Convert.ToHexStringLower(SHA256.HashData(body));
    string locked = File.ReadAllText(Path.Combine(Repo.Root(), "api", "TheYard.Api", "wwwroot", "docs", "resume.sha256")).Trim();

    Assert.Equal(locked, served);
}

[Fact]
public async Task Resume_and_api_docs_resume_serve_the_same_bytes()
{
    byte[] address = await _client.GetByteArrayAsync("/resume?src=linkedin");
    byte[] document = await _client.GetByteArrayAsync("/api/docs/resume");

    Assert.Equal(document, address);
}

[Theory]
[InlineData("linkedin", "linkedin")]
[InlineData("LinkedIn", "linkedin")]
[InlineData(" post ", "post")]
[InlineData("application", "application")]
[InlineData("x", "other")]
[InlineData("", "other")]
[InlineData(null, "other")]
public void A_known_src_becomes_its_door_and_an_unknown_src_is_other(string? src, string door)
{
    Assert.Equal(door, ResumeDoor.Of(src));
    Assert.Equal("/resume/" + door, ResumeDoor.PathFor(src));
}

[Fact]
public async Task The_query_string_never_reaches_the_activity_row()
{
    await _client.GetAsync("/resume?src=linkedin&email=someone@example.com");

    // The request ring records the same path the activity hook does.
    string metrics = await _client.GetStringAsync("/api/admin/metrics");
    Assert.Contains("\"/resume/linkedin\"", metrics);
    Assert.DoesNotContain("@", metrics);
    Assert.DoesNotContain("src=", metrics);
}
```
*Live from [`api/TheYard.Tests/ResumeAddressTests.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Tests/ResumeAddressTests.cs#L19-L75), region `resume-address`, read from this build at 1d6b50b.*

## What was considered

A Door column on the activity row is just as correct and costs a day across both stores, the EF model, the Cosmos document and the DACPAC; the path carries the same word for nothing. A wrapper page with a download button adds a click and a second request to what a phone already does with a PDF. Both stay available if the bare PDF shows a blank frame on a phone.

## Addendum, 2026-10-15 (1.0.3.134): the opens by door, on the activity card

The door is only worth recording if somebody reads it. The activity report now carries `doors`, one entry per door and one for the site itself (the resume opened from the page's own links, with no door), each with the visitor-days and the requests that opened it, people only so a scanner walking the links does not count as interest. The Site activity card draws them as bars beside the path to the resume, which answers the question the address was made for: which of Steve's channels recruiters actually open the resume from.

```csharp
/// <summary>
/// The resume's opens by the door the link named (ADR: The resume has an address): for
/// each door, and for "site", the resume opened from the site itself with no door, how
/// many visitor-days opened it and how many requests that was, people only. A door is
/// one of a handful of words, so nothing here says anything about who opened it.
/// </summary>
public static List<object> Doors(IReadOnlyList<ActivityVisitor> rows)
{
    var kinds = Tokens(rows);
    var people = rows.Where(row => kinds[NameOf(row)] == Kind.People).ToList();
    var doors = ResumeDoor.Doors.Order(StringComparer.Ordinal).Append("other")
        .Select(door => (door, paths: new[] { ResumeDoor.PathPrefix + door }))
        .Append((door: "site", paths: new[] { "/api/docs/resume", "/docs/resume.pdf", "/resume.pdf" }));
    return doors.Select(entry =>
    {
        var opened = people.Where(row => entry.paths.Any(row.Paths.ContainsKey)).ToList();
        return (object)new
        {
            door = entry.door,
            visitor_days = opened.Select(NameOf).Distinct(StringComparer.Ordinal).Count(),
            requests = opened.Sum(row => entry.paths.Sum(path => row.Paths.GetValueOrDefault(path))),
        };
    }).ToList();
}
```
*Live from [`api/TheYard.Api/ActivityWho.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Api/ActivityWho.cs#L193-L216), region `doors`, read from this build at 1d6b50b.*

## Where it sits

The API layer only: one route in `Endpoints/`, one small static class beside it, and two one-line hooks in the activity path. Single responsibility: `ResumeDoor` decides what a door is, the endpoint serves the file, and the activity code counts paths as it always has. What would change it is a second document that wants the same treatment, at which point the door list belongs to the link rather than to the resume.

## Files

- [`api/TheYard.Api/ResumeDoor.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Api/ResumeDoor.cs): the doors, the path a door is recorded as, and the filename.
- [`api/TheYard.Api/Endpoints/DocumentationEndpoints.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Api/Endpoints/DocumentationEndpoints.cs): the route and its handler.
- [`api/TheYard.Api/Composition/ObservabilityRegistration.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Api/Composition/ObservabilityRegistration.cs): the activity hook reads the door's path when the handler left one.
- [`api/TheYard.Api/ActivityWho.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Api/ActivityWho.cs): the resume step counts everything under `/resume/`.
- [`api/TheYard.Tests/ResumeAddressTests.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Tests/ResumeAddressTests.cs) and [`tests/e2e/resume.spec.ts`](https://github.com/SteveStout/TheYard/blob/main/tests/e2e/resume.spec.ts): the address, the bytes, the doors and the query that never lands.

## Addendum, 2026-10-16 (1.0.3.142): in the API document

The address shipped excluded from the API document, the way `/about` is, because it serves a file rather than JSON. ADR-075's rule is wider than that: every public endpoint is in the document with its name, a sentence and its responses, and `ApiDocumentTests` holds the document to the routing table. `/resume` is now described there: GetResumeAtItsAddress (GetResume is `/api/docs/resume`, the same file under `/api`), under Documents, answering `application/pdf`, with `src` explained as a door that is counted and never stored. `/about` stays out: it is a page for search engines, not an endpoint a tool calls.
