# ADR: People who have visited, counted once

Status: accepted, 2026-10-23, at 1.0.4.41. The first screen and the Admin tab show one figure: the people who have visited this site, all time, each counted once, from the day this version shipped. Scanners, crawlers and the site's own reads are left out. The count is kept as one record per visitor under a keyed hash that does not rotate. The address and the user agent are never stored.

## Reading list

1. [ADR: Site activity, and the line an address does not cross](https://theyard.stevenstout.biz/?doc=adr-activity): the daily token this record could not count with, and the privacy line it keeps.
2. [ADR: One reading for the first screen](https://theyard.stevenstout.biz/?doc=adr-one-reading-for-the-first-screen): why the figure reaches the first screen through `/api/pulse` and nowhere else.
3. [ADR: A request is a query or a command](https://theyard.stevenstout.biz/?doc=adr-a-request-is-a-query-or-a-command): the count is one query with one handler, and the store splits into a reader and a writer.

## In plain words

On 23 October I asked for a count of the people who have visited the site, on the first screen and on the Admin tab, with no time limit. The site could not answer that from what it kept. Each visitor gets a token that changes every day, by design, so that nothing kept can follow a person from one day to the next. The same person on Monday and Tuesday is two tokens, and adding up the days counts them twice.

So the count keeps something new: one record per visitor, under a hash of the address and the browser that does not change from day to day. The hash cannot be turned back into the address. It is keyed with the site's secret, so nobody outside can work it out from an address they already have. The record says the first day the visitor was seen, the last day, how many days, and whether their requests read as a person or a scanner. That is all. A person who changes network or browser counts again, and the record says so rather than hiding it.

## Context

Measured at 1.0.4.40: the activity container keeps one document per visitor token per store per day, and the token is `HMACSHA256(key, day | address)`, cut to sixteen bytes. No field survives the day, so no all-time count of distinct people can be read from it. The Activity card's visitor-days are the only count. The first screen's pulse carried speed, cost and uptime and no visitors. The shared Cosmos DB database holds twenty-five containers, which is its limit, so `CosmosDefinitionTests` refuses a twenty-sixth.

## Decision

1. **The hash.** `VisitorTokens.PersonFor(address, userAgent)` is `HMACSHA256(key, "person|" + address + "|" + userAgent)`, cut to sixteen bytes. It uses the same key as the daily token, a label of its own, and no day. Neither input is kept. A read by the site's own tools carries no hash. The prompt named FNV-1a. I used the keyed HMAC the daily token already uses, because FNV-1a with a key mixed in is not a keyed hash in any sense an attacker respects.
2. **Who is a person.** One rule in Domain, `VisitorKinds.Of` in `VisitorKind.cs` at its root, where every Domain rule sits: the site's own network is self, a visitor whose every request looked like a bot is a scanner, and anyone else is a person. The Activity card's `ActivityWho` now reads the same rule, so the two surfaces cannot disagree. A link-preview fetcher is a bot by its agent and is never counted (ADR-108's line).
3. **Where it is kept.** In the activity container, in a partition of its own named `visitors`, under the kind `visitor-record`, because the database refuses a twenty-sixth container. Each document holds `id` (the hash), `first_seen`, `last_seen`, `days_seen` and `who`, with no time-to-live. The activity queries ask for their own two kinds by name, so they never read these. Without a document store, the records are kept in the process's memory, and the count's source says so.
4. **When it is written.** The activity collector already writes off the request path every five seconds. It hands each batch's visitors on as one sighting per hash per day. A hash already handed on today with the same kind is not handed on again, so a visitor costs one point read and at most one write a day.
5. **How it is read.** One query, `ReadPeopleCountQuery`, whose handler takes the reading side (`IVisitorReader`) and answers all time, new today, new this month, the scanners left out, the day counting began, the last thirty UTC days, and the source. The Admin tab reads it at `/api/admin/visitors/people`. The pulse reads it through the same handler and carries `people_all_time`, `people_new_by_day` and `counted_from`. The store's tally is read at most once a minute.

```csharp
/// <summary>
/// Reads the count from the kept visitors: all time, today, this month, the scanners left out, and the last thirty
/// days; and the visits by people from the kept activity, since the first day kept.
/// </summary>
/// <param name="visitors">The kept visitors, reading side.</param>
/// <param name="clock">The server's clock; the days are UTC days.</param>
/// <param name="history">The kept visits; none when the host keeps no activity.</param>
public sealed class ReadPeopleCountHandler(IVisitorReader visitors, TimeProvider clock, IVisitHistory? history = null) : IQueryHandler<ReadPeopleCountQuery, PeopleCount>
{
    /// <summary>How many days the by-day run covers, the first screen's bar run.</summary>
    public const int Days = 30;

    /// <summary>The count as of now.</summary>
    /// <param name="query">The query; it carries nothing.</param>
    /// <param name="cancellation">The request's cancellation.</param>
    public async Task<PeopleCount> HandleAsync(ReadPeopleCountQuery query, CancellationToken cancellation)
    {
        var today = DateOnly.FromDateTime(clock.GetUtcNow().UtcDateTime);
        var first = today.AddDays(1 - Days);
        var kept = history is null ? null : await history.ReadAsync(cancellation);
        var visits = kept is null
            ? null
            : new KeptVisitsRun(
                kept.Visits,
                kept.From,
                [.. Enumerable.Range(0, Days).Select(offset => first.AddDays(offset)).Select(day => new VisitsOnDay(Day(day), kept.ByDay.GetValueOrDefault(Day(day))))]);
        if (!await visitors.AvailableAsync(cancellation))
        {
            return PeopleCount.Unread(visitors.Source) with { Visits = visits };
        }
        var month = new DateOnly(today.Year, today.Month, 1);
        var since = month < first ? month : first;
        var tally = await visitors.TallyAsync(Day(since), cancellation);
        int On(DateOnly day) => tally.FirstSeenByDay.GetValueOrDefault(Day(day));
        int monthCount = Enumerable.Range(0, today.Day).Sum(offset => On(month.AddDays(offset)));
        return new PeopleCount(
            tally.People,
            On(today),
            monthCount,
            tally.Scanners,
            tally.CountedFrom,
            [.. Enumerable.Range(0, Days).Select(offset => first.AddDays(offset)).Select(day => new PeopleOnDay(Day(day), On(day)))],
            visitors.Source,
            visits);
    }

    private static string Day(DateOnly day) => day.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
}

/// <summary>Asks for the count of people who have visited; it carries nothing.</summary>
public sealed record ReadPeopleCountQuery;
```
*Live from [`api/TheYard.Application/Features/Visitors/ReadPeopleCountHandler.cs`](https://github.com/SteveStout/TheYard/blob/085b3b4/api/TheYard.Application/Features/Visitors/ReadPeopleCountHandler.cs#L11-L61), region `read-people-count`, read from this build at 085b3b4.*

```csharp
/// <summary>
/// The batch's visitors handed to the visitors' store, off the request path with the activity: one sighting a
/// hash a day, its kind read by the one Domain rule the Activity card counts by (VisitorKinds.Of), and none for
/// a read by the site's own tools, which carries no hash. A hash already handed on today with the same kind is
/// not handed on again, so a visitor costs one write a day at most.
/// </summary>
private async Task KeepVisitorsAsync(List<ActivityHit> batch, CancellationToken cancellation)
{
    if (_visitors is null)
    {
        return;
    }
    var sightings = batch
        .Where(hit => hit.Person is not null)
        .GroupBy(hit => (Hash: hit.Person!, Day: hit.At.UtcDay))
        .Select(group => new VisitorSighting(group.Key.Hash, group.Key.Day, VisitorKinds.Of(false, group.Count(), group.Count(hit => hit.Bot))))
        .Where(seen => !_keptToday.Contains((seen.Hash, seen.Day, seen.Kind)))
        .ToList();
    if (sightings.Count == 0)
    {
        return;
    }
    string today = sightings.Max(seen => seen.Day)!;
    _keptToday.RemoveWhere(kept => string.CompareOrdinal(kept.Day, today) < 0);
    try
    {
        await _visitors.KeepAsync(sightings, cancellation);
        foreach (var seen in sightings)
        {
            _keptToday.Add((seen.Hash, seen.Day, seen.Kind));
        }
    }
    catch (Exception ex)
    {
        _logger.LogWarning("{Count} visitor sightings were not kept: {Type}", sightings.Count, ex.GetType().Name);
    }
}
```
*Live from [`api/TheYard.Api/Shared/Activity/ActivityCollector.cs`](https://github.com/SteveStout/TheYard/blob/085b3b4/api/TheYard.Api/Shared/Activity/ActivityCollector.cs#L170-L206), region `keep-visitors`, read from this build at 085b3b4.*

## What holds it

`VisitorTests`: the kind rule, the hash (the same every day, different for another browser, never the address or the daily token), the collector's one sighting per visitor per day with none for the site itself, the count over a store in memory with today, this month and thirty UTC days, an unread store, and the route's 200 with every field.

```csharp
/// <summary>
/// People who have visited, counted once (ADR-116): the kind rule the Activity card and the count share, the
/// hash that does not rotate, the collector's one sighting a visitor a day, and the count's query over a
/// store in memory with the day boundaries in UTC, and the visits by people the kept history holds from before the
/// count began (the addendum of 1.0.4.48).
/// </summary>
public sealed class VisitorTests
{
    private static readonly DateTimeOffset Today = new(2026, 10, 23, 23, 30, 0, TimeSpan.Zero);

    [Fact]
    public void A_visitor_whose_every_request_looked_like_a_bot_is_a_scanner_and_the_site_itself_is_never_a_person()
    {
        Assert.Equal(VisitorKind.People, VisitorKinds.Of(false, 3, 2));
        Assert.Equal(VisitorKind.Scanners, VisitorKinds.Of(false, 3, 3));
        Assert.Equal(VisitorKind.Self, VisitorKinds.Of(true, 3, 0));
        Assert.Equal(VisitorKind.People, VisitorKinds.Stronger(VisitorKind.Scanners, VisitorKind.People));
        Assert.Equal(VisitorKind.Self, VisitorKinds.Stronger(VisitorKind.People, VisitorKind.Self));
        Assert.Equal(VisitorKind.Scanners, VisitorKinds.FromWord("anything else"));
    }

    [Fact]
    public void The_hash_is_the_same_every_day_and_is_neither_the_address_nor_the_daily_token()
    {
        var tokens = new VisitorTokens("test-key");
        string person = tokens.PersonFor("203.0.113.7", "Mozilla/5.0");

        Assert.Equal(person, tokens.PersonFor("203.0.113.7", "Mozilla/5.0"));
        Assert.NotEqual(person, tokens.PersonFor("203.0.113.7", "curl/8.0"));
        Assert.NotEqual(person, tokens.TokenFor("203.0.113.7", Today));
        Assert.DoesNotContain("203.0.113", person, StringComparison.Ordinal);
        Assert.Equal(32, person.Length);
    }

    [Fact]
    public async Task The_collector_hands_on_one_sighting_a_visitor_a_day_and_none_for_the_site_itself()
    {
        var visitors = new MemoryVisitorStore();
        var collector = new ActivityCollector(new Dictionary<string, IActivityStore>(StringComparer.Ordinal) { ["sql"] = NullActivityStore.Instance }, "sql", NullLogger<ActivityCollector>.Instance, visitors);

        collector.Offer(new ActivityHit(Today, "t1", "203.0.113.x", "/", "sql", false) { Person = "a" });
        collector.Offer(new ActivityHit(Today, "t1", "203.0.113.x", "/about", "sql", false) { Person = "a" });
        collector.Offer(new ActivityHit(Today, "t2", "198.51.100.x", "/wp-login.php", "sql", true) { Person = "b" });
        collector.Offer(new ActivityHit(Today, "t3", "self", "/api/version", "sql", true));
        await collector.DrainAsync(CancellationToken.None);
        collector.Offer(new ActivityHit(Today.AddMinutes(5), "t1", "203.0.113.x", "/", "sql", false) { Person = "a" });
        await collector.DrainAsync(CancellationToken.None);

        var tally = await visitors.TallyAsync("2026-10-01", CancellationToken.None);
        Assert.Equal(1, tally.People);
        Assert.Equal(1, tally.Scanners);
        Assert.Equal(1, tally.FirstSeenByDay["2026-10-23"]);
    }

    [Fact]
    public async Task The_count_is_all_time_people_once_each_with_today_this_month_and_thirty_UTC_days()
    {
        var visitors = new MemoryVisitorStore();
        await visitors.KeepAsync(
            [
                new("a", "2026-10-20", VisitorKind.People),
                new("a", "2026-10-23", VisitorKind.People),
                new("b", "2026-10-23", VisitorKind.Scanners),
                new("c", "2026-10-01", VisitorKind.People),
                new("d", "2026-09-30", VisitorKind.People),
                new("e", "2026-10-23", VisitorKind.People),
            ],
            CancellationToken.None);

        var count = await new ReadPeopleCountHandler(visitors, new Fixed(Today)).HandleAsync(new ReadPeopleCountQuery(), CancellationToken.None);

        Assert.Equal(4, count.PeopleAllTime);
        Assert.Equal(1, count.PeopleNewToday);
        Assert.Equal(3, count.PeopleNewMonth);
        Assert.Equal(1, count.ScannersLeftOut);
        Assert.Equal("2026-09-30", count.CountedFrom);
        Assert.Equal(ReadPeopleCountHandler.Days, count.PeopleNewByDay.Count);
        Assert.Equal(("2026-10-23", 1), (count.PeopleNewByDay[^1].Day, count.PeopleNewByDay[^1].People));
        Assert.Equal("2026-09-24", count.PeopleNewByDay[0].Day);
        Assert.Equal(1, count.PeopleNewByDay.Single(day => day.Day == "2026-10-20").People);
        Assert.Equal(visitors.Source, count.Source);
    }

    [Fact]
    public async Task The_count_runs_from_the_first_person_not_the_first_scanner()
    {
        var visitors = new MemoryVisitorStore();
        await visitors.KeepAsync([new("s", "2026-10-22", VisitorKind.Scanners)], CancellationToken.None);
        Assert.Null((await visitors.TallyAsync("2026-09-24", CancellationToken.None)).CountedFrom);

        await visitors.KeepAsync([new("p", "2026-10-23", VisitorKind.People)], CancellationToken.None);

        Assert.Equal("2026-10-23", (await visitors.TallyAsync("2026-09-24", CancellationToken.None)).CountedFrom);
    }

    [Fact]
    public async Task A_store_that_cannot_keep_visitors_is_unread_and_says_why()
    {
        var count = await new ReadPeopleCountHandler(new NoVisitorStore("no document store"), new Fixed(Today)).HandleAsync(new ReadPeopleCountQuery(), CancellationToken.None);
        Assert.Null(count.PeopleAllTime);
        Assert.Empty(count.PeopleNewByDay);
        Assert.Equal("no document store", count.Source);
    }

    [Fact]
    public async Task The_kept_history_counts_one_visit_a_person_a_day_and_leaves_out_scanners_and_the_site_itself()
    {
        static ActivityVisitor Row(string day, string token, string network, int requests, int bots) =>
            new("sql", day, token, network, Today, Today, requests, bots, new Dictionary<string, int>());
        ActivityVisitor[] rows =
        [
            // The same person on two days is two visits: the token rotates daily, so nothing kept can join them.
            Row("2026-09-24", "a1", "203.0.113", 4, 0),
            Row("2026-09-25", "a2", "203.0.113", 2, 1),
            // One person read through both stores the same day is one visit.
            Row("2026-09-25", "b1", "198.51.100", 1, 0),
            new("cosmos", "2026-09-25", "b1", "198.51.100", Today, Today, 3, 0, new Dictionary<string, int>()),
            // A scanner, the site's own tools, and two loopback rows that are one machine: none of them people.
            Row("2026-09-25", "s1", "192.0.2", 5, 5),
            Row("2026-09-25", "t1", Hits.SelfNetwork, 9, 0),
            Row("2026-09-26", "l1", "127.0.0", 1, 0),
            Row("2026-09-26", "l2", "127.0.0", 1, 0),
        ];
        var kept = VisitHistory.Count(rows);
        Assert.NotNull(kept);
        Assert.Equal((3, "2026-09-24"), (kept.Visits, kept.From));
        Assert.Equal(new Dictionary<string, int> { ["2026-09-24"] = 1, ["2026-09-25"] = 2 }, kept.ByDay);
        Assert.Null(VisitHistory.Count([]));

        var history = new VisitHistory(new RowsStore(rows), new Fixed(Today));
        Assert.Null(await history.ReadAsync(CancellationToken.None));
        await history.Taking;
        Assert.Equal(3, (await history.ReadAsync(CancellationToken.None))?.Visits);

        // A later count reads from the day before the newest day held and replaces those days: a day that grew is
        // counted again, a day before the read is kept as it was, and a window with no person in it counts as none.
        var held = new KeptVisits(3, "2026-09-24", new Dictionary<string, int> { ["2026-09-24"] = 1, ["2026-09-25"] = 2 });
        var later = new KeptVisits(5, "2026-09-25", new Dictionary<string, int> { ["2026-09-25"] = 3, ["2026-09-26"] = 2 });
        var merged = VisitHistory.Merge(held, later, new DateTimeOffset(2026, 9, 25, 0, 0, 0, TimeSpan.Zero));
        Assert.Equal((6, "2026-09-24"), (merged.Visits, merged.From));
        Assert.Equal(new Dictionary<string, int> { ["2026-09-24"] = 1, ["2026-09-25"] = 3, ["2026-09-26"] = 2 }, merged.ByDay);
        var quiet = VisitHistory.Merge(held, null, new DateTimeOffset(2026, 9, 25, 0, 0, 0, TimeSpan.Zero));
        Assert.Equal((1, "2026-09-24"), (quiet.Visits, quiet.From));
        Assert.Equal(new Dictionary<string, int> { ["2026-09-24"] = 1 }, quiet.ByDay);

        // The count carries them: the total, the first day, and the last thirty days with the kept days filled in.
        var count = await new ReadPeopleCountHandler(new MemoryVisitorStore(), new Fixed(new DateTimeOffset(2026, 9, 26, 12, 0, 0, TimeSpan.Zero)), history).HandleAsync(new ReadPeopleCountQuery(), CancellationToken.None);
        Assert.NotNull(count.Visits);
        Assert.Equal((3, "2026-09-24"), (count.Visits.AllTime, count.Visits.From));
        Assert.Equal(30, count.Visits.ByDay.Count);
        Assert.Equal([1, 2, 0], count.Visits.ByDay.TakeLast(3).Select(day => day.Visits));
        Assert.Equal("2026-09-26", count.Visits.ByDay[^1].Day);
        // Without the history, the count says nothing about visits rather than zero.
        Assert.Null((await new ReadPeopleCountHandler(new MemoryVisitorStore(), new Fixed(Today)).HandleAsync(new ReadPeopleCountQuery(), CancellationToken.None)).Visits);
    }

    [Fact]
    public void A_scanner_record_is_kept_ninety_days_and_a_person_for_good()
    {
        Assert.Equal(90 * 86_400, TheYard.Infrastructure.Cosmos.Features.Visitors.VisitorDocument.TtlFor(VisitorKind.Scanners));
        Assert.Equal(-1, TheYard.Infrastructure.Cosmos.Features.Visitors.VisitorDocument.TtlFor(VisitorKind.People));
        Assert.Equal(-1, TheYard.Infrastructure.Cosmos.Features.Visitors.VisitorDocument.TtlFor(VisitorKind.Self));
    }

    [Fact]
    public async Task Todays_visits_are_counted_again_every_minute_between_the_hourly_counts()
    {
        static ActivityVisitor Row(string day, string token) =>
            new("sql", day, token, "203.0.113", Today, Today, 1, 0, new Dictionary<string, int>());
        var rows = new List<ActivityVisitor> { Row("2026-10-22", "a1"), Row("2026-10-23", "b1") };
        var clock = new Stepping(Today);
        var history = new VisitHistory(new GrowingStore(rows), clock);
        await history.ReadAsync(CancellationToken.None);
        await history.Taking;
        Assert.Equal(2, (await history.ReadAsync(CancellationToken.None))?.Visits);

        // Two more people today, read a minute later: today is counted again without waiting the hour.
        rows.Add(Row("2026-10-23", "c1"));
        rows.Add(Row("2026-10-23", "d1"));
        clock.Advance(TimeSpan.FromSeconds(30));
        await history.ReadAsync(CancellationToken.None);
        await history.Taking;
        Assert.Equal(2, (await history.ReadAsync(CancellationToken.None))?.Visits);
        clock.Advance(VisitHistory.TodayEvery);
        await history.ReadAsync(CancellationToken.None);
        await history.Taking;
        var kept = await history.ReadAsync(CancellationToken.None);
        Assert.Equal(4, kept?.Visits);
        Assert.Equal(3, kept?.ByDay["2026-10-23"]);
        Assert.Equal(1, kept?.ByDay["2026-10-22"]);
    }

    /// <summary>A store whose rows can grow, answering only the rows from the day asked.</summary>
    private sealed class GrowingStore(List<ActivityVisitor> rows) : IActivityStore
    {
        public Task<ActivityAvailability> AvailabilityAsync(CancellationToken cancellation) => Task.FromResult(new ActivityAvailability(true, "kept"));

        public Task RecordAsync(IReadOnlyList<ActivityHit> hits, CancellationToken cancellation) => Task.CompletedTask;

        public Task<IReadOnlyList<ActivityHour>> HoursAsync(DateTimeOffset since, CancellationToken cancellation) => Task.FromResult<IReadOnlyList<ActivityHour>>([]);

        public Task<IReadOnlyList<ActivityVisitor>> VisitorsAsync(DateTimeOffset since, CancellationToken cancellation) =>
            Task.FromResult<IReadOnlyList<ActivityVisitor>>([.. rows.Where(row => string.CompareOrdinal(row.Day, since.UtcDateTime.ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture)) >= 0)]);
    }

    private sealed class Stepping(DateTimeOffset start) : TimeProvider
    {
        private DateTimeOffset _now = start;

        public override DateTimeOffset GetUtcNow() => _now;

        public void Advance(TimeSpan by) => _now += by;
    }

    private sealed class RowsStore(IReadOnlyList<ActivityVisitor> rows) : IActivityStore
    {
        public Task<ActivityAvailability> AvailabilityAsync(CancellationToken cancellation) => Task.FromResult(new ActivityAvailability(true, "kept"));

        public Task RecordAsync(IReadOnlyList<ActivityHit> hits, CancellationToken cancellation) => Task.CompletedTask;

        public Task<IReadOnlyList<ActivityHour>> HoursAsync(DateTimeOffset since, CancellationToken cancellation) => Task.FromResult<IReadOnlyList<ActivityHour>>([]);

        public Task<IReadOnlyList<ActivityVisitor>> VisitorsAsync(DateTimeOffset since, CancellationToken cancellation) => Task.FromResult(rows);
    }

    private sealed class Fixed(DateTimeOffset now) : TimeProvider
    {
        public override DateTimeOffset GetUtcNow() => now;
    }
}

/// <summary>The count's route through the real host (ADR-116), apart from the tests above so they build the handler with no host.</summary>
public sealed class VisitorRouteTests(WebApplicationFactory<Program> factory) : IClassFixture<WebApplicationFactory<Program>>
{
    [Fact]
    [Covers("GET", "/api/admin/visitors/people", 200)]
    public async Task The_count_answers_200_with_every_figure_and_its_source()
    {
        var response = await factory.CreateClient().GetAsync("/api/admin/visitors/people");
        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
        using var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
        foreach (string field in new[] { "people_all_time", "people_new_today", "people_new_month", "scanners_left_out", "counted_from", "people_new_by_day", "source", "visits" })
        {
            Assert.True(body.RootElement.TryGetProperty(field, out _), $"the count has no {field}");
        }
    }
}
```
*Live from [`api/TheYard.Tests/VisitorTests.cs`](https://github.com/SteveStout/TheYard/blob/085b3b4/api/TheYard.Tests/VisitorTests.cs#L10-L256), region `visitor-tests`, read from this build at 085b3b4.*

## What was considered

Counting distinct daily tokens. Rejected: it counts visitor-days, which the Activity card already shows, and calls them people.

Keeping the address, or a hash of it with no key. Rejected by ADR-071 and ADR-072: an address is the one thing this site never keeps. An unkeyed hash of a four-billion-value space can be reversed by trying every value.

A container of its own. Rejected: the shared database is at twenty-five, and the next container would fail to create. That is the failure the nightly card hit on 21 October.

A cookie. Rejected: it would need a consent banner, which the site does not have and does not want, and it counts browsers that keep cookies rather than people.

## Consequences

The count starts at zero on the day this version ships, and every surface says "counting from" that day. A person on two networks or two browsers is two visitors. A shared address behind one browser build, such as an office, is one visitor. Neither error can be fixed without keeping something this site does not keep. The figure is never graded. It is a count, not a target.

## Where it sits

Every ring, each holding its own part. The rule of who is a person is in Domain, so the Activity card and the count read one rule. The count's query and its two ports are in Application, the kept records are an adapter in the document store's ring, and the host's collector writes the sightings it already gathers. Single responsibility holds it: the collector gathers, the store keeps, and the query counts. It cost one point read and at most one write per visitor per day, and a partition in a container already paid for. What would change it: a second database with room for a container, or a consent banner, which would let a cookie count browsers more exactly than a hash of an address can.

## Files

- [`api/TheYard.Domain/Shared/Visitors/VisitorKind.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Domain/Shared/Visitors/VisitorKind.cs) - who a visitor was, the one rule
- [`api/TheYard.Application/Features/Visitors/`](https://github.com/SteveStout/TheYard/tree/main/api/TheYard.Application/Features/Visitors) - the reader and the writer, and the count's query
- [`api/TheYard.Infrastructure.Cosmos/Features/Visitors/CosmosVisitorStore.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Infrastructure.Cosmos/Features/Visitors/CosmosVisitorStore.cs) - the kept records
- [`api/TheYard.Api/Features/Visitors/`](https://github.com/SteveStout/TheYard/tree/main/api/TheYard.Api/Features/Visitors) - the slice's route
- [`api/TheYard.Api/Shared/Activity/ActivityCollector.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Api/Shared/Activity/ActivityCollector.cs) - the sightings, written with the activity
- [`src/components/landing/PulseBand/`](https://github.com/SteveStout/TheYard/tree/main/src/components/landing/PulseBand) - the first screen's fourth instrument
- [`src/components/admin/PeopleReadouts/`](https://github.com/SteveStout/TheYard/tree/main/src/components/admin/PeopleReadouts) - the Activity card's readouts and the new-people chart
- [`api/TheYard.Tests/VisitorTests.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Tests/VisitorTests.cs) - holds this record

## Addendum, 2026-10-23 (1.0.4.48): the kept visits, back to the first day, beside the count

The count began at zero on the evening this record shipped, so the first screen read "1 people, all time" on a site with six weeks of visitors behind it. I read it on my phone that evening and asked for the total to be backfilled from what the site had kept.

What can be backfilled is visits, not people. The activity kept before this record holds a token that rotates daily, so the same person on two days is two tokens and nothing kept can join them; that was ADR-071's line, and it stands. What that history does count exactly is one visit a person a day, by the same rule the Activity card and this count share (the site's own network is self, a visitor whose every request looked like a bot is a scanner, anyone else a person, every loopback row on a day one machine). So the one query now carries both: the people counted once since the day the count began, and the visits by people since the first day anything was kept.

- `VisitHistory` (the host's `Shared/Activity`) counts the keeper's visitor rows in the background and answers the Application port `IVisitHistory` with the last count: the total, the first day, and the visits on each kept day. The first count reads every kept day (one cross-partition query, about five thousand documents at this size); each count after it, once an hour, reads only from the day before the newest day it holds, because a day's rows can still be written at its boundary and today's are being written, so the hourly read stays two days of rows however long the site keeps them. A count that fails leaves the last one standing and waits five minutes before the next, rather than asking a store that is refusing every fifteen seconds.
- `ReadPeopleCountHandler` takes the port beside the visitors' reading side and answers `visits` (`all_time`, `from`, `by_day` for the last thirty UTC days) with the figures it answered before, so `/api/admin/visitors/people` and `/api/pulse` cannot say different numbers. The pulse carries them flat as `visits_all_time`, `visits_from` and `visits_by_day`.
- The first screen's instrument is labelled "Visits by people" and says "2,3xx visits since 13 Sep", with the visits of the last thirty days drawn under it; until the first hourly count lands it is "People who have visited" with the people counted once ("5 people since 23 Oct", "person" for one). The pulse never fails on the count: a tally the store cannot answer is an unread instrument, not a first screen that answers 500. The Activity card's readouts say "each counted once · since 23 Oct" for the people and gain a fifth, "Visits · one a person a day · since 13 Sep". The People tile says "since 23 Oct" where it said "all time".
- A public figure never says "all time" about a count that began that evening again: `unitOf` and `unitWords` (`pulse.ts`) choose every unit on the band, held at none, one and many, and the strip's tiles are one list, `TILE_KEYS`, that the browser specs count from, after 1.0.4.41's first gate went red on a spec that still counted eight tiles.

A review of the day's work from three seats, by a reader who had written none of it, found the first cut of this addendum's code reading the whole history every hour, retrying a failed count at once, failing the pulse when the tally failed, and labelling visits as people; each is fixed above. It also named what stays as it is: the person hash is keyed with the session signing key, so rotating that key starts the count again, and the tally reads every kept visitor record, scanners included, once a minute per process.

`VisitorTests.The_kept_history_counts_one_visit_a_person_a_day_and_leaves_out_scanners_and_the_site_itself` holds the rule, the merge of a later read into the held count, and the handler's thirty days; `VisitorRouteTests` and `PulseTests` hold the fields; `pulse.test.ts`, `people.test.ts` and `statTiles.test.ts` read an older server's answer as no visits and hold the words.

```csharp
/// <summary>
/// Counts the visits by people across every kept day of activity, at most once an hour, in the background, and
/// today's again every minute between, so today's visits never run behind today's people (one partition read).
/// A reader is handed the last count at once, or null before the first one finishes; the first read starts it.
/// The first count reads every kept day; each count after it reads only from the day before the newest day
/// it holds, because a day's rows can still be written at its boundary and today's are being written, and
/// every earlier day is as it was. So the hourly read stays two days of rows however long the site keeps them.
/// </summary>
/// <param name="store">The activity store that keeps the visitor rows with no expiry.</param>
/// <param name="clock">The server's clock.</param>
public sealed class VisitHistory(IActivityStore store, TimeProvider clock) : IVisitHistory
{
    /// <summary>How long a count is handed out before it is taken again.</summary>
    public static readonly TimeSpan Every = TimeSpan.FromHours(1);

    /// <summary>How long today's visits are handed out before today alone is counted again.</summary>
    public static readonly TimeSpan TodayEvery = TimeSpan.FromMinutes(1);

    /// <summary>How long after a count that failed the next is tried: sooner than an hour, never at once.</summary>
    public static readonly TimeSpan RetryAfter = TimeSpan.FromMinutes(5);

    /// <summary>Before the site kept any activity, so a read from here is the whole history.</summary>
    public static readonly DateTimeOffset Beginning = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);

    private readonly Lock _gate = new();
    private KeptVisits? _last;
    private DateTimeOffset _takenAt = DateTimeOffset.MinValue;
    private DateTimeOffset _todayAt = DateTimeOffset.MinValue;
    private Task? _taking;

    /// <summary>The count as last taken, starting a new one when it is older than <see cref="Every"/>.</summary>
    public Task<KeptVisits?> ReadAsync(CancellationToken cancellation)
    {
        lock (_gate)
        {
            var now = clock.GetUtcNow();
            if (_taking is not { IsCompleted: false })
            {
                if (now - _takenAt >= Every)
                {
                    _taking = Task.Run(() => TakeAsync(todayOnly: false, cancellation), CancellationToken.None);
                }
                else if (_last is not null && now - _todayAt >= TodayEvery)
                {
                    _taking = Task.Run(() => TakeAsync(todayOnly: true, cancellation), CancellationToken.None);
                }
            }
            return Task.FromResult(_last);
        }
    }

    /// <summary>The count being taken, while one is; a test awaits it.</summary>
    public Task Taking
    {
        get
        {
            lock (_gate)
            {
                return _taking ?? Task.CompletedTask;
            }
        }
    }

    /// <param name="todayOnly">Whether to ask the store for today alone or for every day since the last answer.</param>
    /// <param name="cancellation">The token of the request that STARTED this take, lent to it. A
    /// later reader waiting on the same take is not the one who began it and does not stop it, which
    /// is the rule shared once-per-process work follows here (ADR: The ports learn to wait).</param>
    private async Task TakeAsync(bool todayOnly, CancellationToken cancellation)
    {
        KeptVisits? held;
        lock (_gate)
        {
            held = _last;
        }
        try
        {
            var since = held is null ? Beginning
                : todayOnly ? new DateTimeOffset(clock.GetUtcNow().UtcDateTime.Date, TimeSpan.Zero)
                : DayBefore(held.ByDay.Keys.Max(StringComparer.Ordinal)!);
            var rows = await store.VisitorsAsync(since, cancellation);
            if (todayOnly && held is not null)
            {
                // Today alone: an empty answer is a quiet start of the day or a store that is not there, and either
                // way the held count stands; a person's row today replaces today's figure.
                var today = rows.Count == 0 ? held : Merge(held, Count(rows), since);
                lock (_gate)
                {
                    _last = today;
                    _todayAt = clock.GetUtcNow();
                }
                return;
            }
            if (rows.Count == 0 && held is not null)
            {
                // A store that answers nothing for two days it answered before is a store that is not there
                // (the activity port answers an empty list when it is unavailable): the held count stands,
                // and the next count waits RetryAfter rather than reading the held days as empty.
                lock (_gate)
                {
                    _takenAt = clock.GetUtcNow() - Every + RetryAfter;
                }
                return;
            }
            var fresh = Count(rows);
            var total = held is null ? fresh : Merge(held, fresh, since);
            lock (_gate)
            {
                _last = total ?? _last;
                _takenAt = clock.GetUtcNow();
                _todayAt = _takenAt;
            }
        }
        catch (Exception ex) when (ex is not OutOfMemoryException)
        {
            // The count is a figure on a page, never the page: the last one stands, and the next count waits
            // RetryAfter rather than starting at the next read, so a store that is refusing is not asked every
            // fifteen seconds for the whole history.
            lock (_gate)
            {
                if (todayOnly)
                {
                    _todayAt = clock.GetUtcNow() - TodayEvery + RetryAfter;
                }
                else
                {
                    _takenAt = clock.GetUtcNow() - Every + RetryAfter;
                }
            }
        }
    }

    /// <summary>The UTC day before a yyyy-MM-dd day, as the start of that day.</summary>
    private static DateTimeOffset DayBefore(string day) =>
        new(DateOnly.ParseExact(day, "yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture).AddDays(-1), TimeOnly.MinValue, TimeSpan.Zero);

    /// <summary>
    /// The held count with the days read again replaced by the fresh read, from <paramref name="since"/> on: a day
    /// read again with no person in it is a day with no visits, and the days before the read stay as they were.
    /// </summary>
    public static KeptVisits Merge(KeptVisits held, KeptVisits? fresh, DateTimeOffset since)
    {
        string from = since.UtcDateTime.ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture);
        var byDay = held.ByDay.Where(day => string.CompareOrdinal(day.Key, from) < 0).ToDictionary(day => day.Key, day => day.Value, StringComparer.Ordinal);
        foreach (var (day, visits) in fresh?.ByDay ?? new Dictionary<string, int>(StringComparer.Ordinal))
        {
            byDay[day] = visits;
        }
        return new KeptVisits(byDay.Values.Sum(), byDay.Keys.Min(StringComparer.Ordinal)!, byDay);
    }

    /// <summary>
    /// Visits by people in these rows: each day's visitors by the rule the Activity card counts with (the site's
    /// own network is self, a visitor whose every request looked like a bot is a scanner, anyone else a person,
    /// every loopback row on a day one machine), the people summed over the days. Null when no person was kept.
    /// </summary>
    public static KeptVisits? Count(IEnumerable<ActivityVisitor> rows)
    {
        var kinds = new Dictionary<string, VisitorKind>(StringComparer.Ordinal);
        foreach (var row in rows)
        {
            string name = row.Day + "|" + (Hits.IsLoopbackNetwork(row.Network) ? "loopback" : row.Visitor);
            var kind = VisitorKinds.Of(Hits.IsSelfNetwork(row.Network), row.Requests, row.Bots);
            kinds[name] = kinds.TryGetValue(name, out var seen) ? VisitorKinds.Stronger(seen, kind) : kind;
        }
        var byDay = new Dictionary<string, int>(StringComparer.Ordinal);
        foreach (var (name, kind) in kinds)
        {
            if (kind == VisitorKind.People)
            {
                string day = name[..name.IndexOf('|')];
                byDay[day] = byDay.GetValueOrDefault(day) + 1;
            }
        }
        return byDay.Count == 0 ? null : new KeptVisits(byDay.Values.Sum(), byDay.Keys.Min(StringComparer.Ordinal)!, byDay);
    }
}
```
*Live from [`api/TheYard.Api/Shared/Activity/VisitHistory.cs`](https://github.com/SteveStout/TheYard/blob/085b3b4/api/TheYard.Api/Shared/Activity/VisitHistory.cs#L11-L186), region `visit-history`, read from this build at 085b3b4.*

## Addendum, 2026-10-24 (1.0.4.50): my own browsing is not a person, and the count runs from the first person

The review of 24 October found two things the count said that were not so. First, my own phone and laptop were counted as people: a read was the site's own only by its tools' agent tag or App Service's own agents. Now a request that presents the operator's key leaves with a cookie, `yard_operator`, whose value is a keyed hash of a fixed label that nobody without the signing key can make, and a request carrying the cookie or the key is kept as the site reading itself, with no person hash. A browser counts as mine from the first time it opens the Admin tab with the key. A loopback request is self too, whatever agent it names, so the kept row and the Activity card no longer disagree on a request with no agent. The under-lines say what is left out in those words: my own tools' reads and my own browsing.

Second, "since 23 Oct" was the first scanner's day: the count's first day was the earliest first-seen over every kept record. It is the earliest first-seen over the people now, and none before the first person, in both adapters.

## Addendum, 2026-10-24 (1.0.4.51): a browser that updates itself is the same person, the tally is counted not read, and today's visits are to the minute

The person hash took the whole user agent, and Chrome's version changes every four weeks, so the same person on the same network became a new person each month. From 1.0.4.51 the hash takes the address and the agent's family and platform, the agent with every version number taken out (VisitorTokens.AgentFamily, held by a test over six real agents: Chrome on a desktop and on Android, Safari on an iPhone, Edge, Firefox and a crawler). The count of people resets once on the day this rolled, 24 October 2026: every person already counted is counted once more, the next time they visit. Two people on one network with the same browser on the same platform are one person, which the count already accepted for one browser.

The tally the Activity card and the first screen read once a minute read every kept visitor record, and scanners were kept for good, about 150 a day. Now it is counted, not read: one aggregate query by kind, the first person's day, and the first days of the people first seen in the thirty-day window. A scanner's record expires after ninety days (`ttl` on the scanner records only; a person's is -1, never), and the scanners left out of the count are a running number on one `visitors-summary` document in the same partition, so the figure keeps its history after the records go. If the store refuses the aggregate read, the whole read it replaced still answers. The request-unit cost of one tally before and after was not read from the response headers in this lane; the before is every visitor document (over a hundred, growing), the after three aggregate answers and one point read.

The visits were counted once an hour, so for up to an hour "new today" could read more people than "visits today". VisitHistory counts today alone again every minute between the hourly counts, one partition, and the readout says "today to the minute".


## Addendum, 2026-10-25 (1.0.4.67): who a visitor was is shared

`VisitorKind.cs` stood in the Domain ring's Visitors slice, and the Admin tab's Activity card reads it too. Once rule 1 of the slices held across the rings (ADR-107, the addendum of 1.0.4.67), the Admin slice reaching into Visitors was one slice using another. A rule two slices read is a Shared concern, so it moved to `api/TheYard.Domain/Shared/Visitors/VisitorKind.cs`. The rule is unchanged and is still the one place that says who a person is.
