Previously, in Part 9, we built a real test harness — and left one test green for the wrong reasons, with a note attached. Today we cash that note in. Two real bugs, both shipped by the AI in earlier parts, both fixed the same way: by describing symptoms and making the AI reason from the spec.

The method: symptoms, not files

When you already suspect where a bug lives, the tempting prompt is a work order: "the bug is in GetSnapshotAsync, change the OrderBy." Resist it, for two reasons. First, you've smuggled your own hypothesis into the prompt — the AI will politely confirm whatever you pointed at, and if your hypothesis is wrong you've both wasted a round. Second, you've done the diagnosis yourself, which is precisely the part a reasoning model is best at.

The alternative: describe the symptoms, in the plain language the person who hit the bug would use, and ask the AI to reason from the spec and the code. Don't name files. Don't propose causes. Make it read, form a hypothesis, and show its work. Both fixes in this part were prompted exactly this way, and both prompts are commit messages in the companion repo (tag part-10) — so you can check that we're not tidying up history.

Model pick: Opus, high effort, for both hunts. Diagnosis is exactly the work we save the expensive model for: holding a spec, two services and a test suite in mind at once and saying which of them is lying. The fixes themselves were small; the finding was the product.

Bug 1: the queue calls the wrong patient

The complaint arrives from reception, and it's beautifully concrete: a patient with a 15:45 appointment showed up early, checked in at the kiosk, and got called in before the 15:00 patient who'd checked in a few minutes later. The waiting room noticed. Here's the prompt, verbatim:

"Reception says someone with a 15:45 appointment checked in early
and got called before the 15:00 patient. Read docs/spec.md and tell me what
the queue order SHOULD be, then check whether the code and the Part 9 test
actually implement it."

Note what that prompt does: it hands over a symptom, then forces a three-way comparison — spec versus code versus test. The AI's diagnosis came back in three clean parts. The spec says the queue order is "appointment slot first, check-in time as tiebreaker." The code orders by CheckedInAt alone — first come, first served. And the Part 9 test passes, because it was written by reading the code, not the spec. The AI had walked into Part 9's tautological-test trap, and — given the right question — walked itself back out: a test that mirrors the implementation can only ever agree with it.

So the fix starts where the trap started: rewrite the test from the spec, before touching the code. Same scenario — late slot checks in first — opposite assertion:

[Fact]
public async Task Waiting_list_is_ordered_by_slot_then_check_in()
{
    // Part 9's version of this test was written FROM THE IMPLEMENTATION and
    // blessed first-come-first-served. The spec says otherwise (docs/spec.md):
    // slot time first, check-in as tiebreaker. Tests come from the spec.

    // ...books 15:45 for Test Patient G, 15:00 for Test Patient H...

    // The late-slot patient checks in FIRST — and still waits their turn.
    await queue.CheckInAsync(lateSlot.Appointment!.ConfirmationCode);
    await queue.CheckInAsync(earlySlot.Appointment!.ConfirmationCode);

    // ...

    Assert.True(earlyIndex < lateIndex, "the 15:00 appointment is served before the 15:45 one");
}

Run it: red. That red is the payoff of the whole exercise — the first honest signal this behavior has ever produced. Part 9's test could not fail against the bug; this one can't help it. Now, and only now, the code. The fix lands in two places, GetSnapshotAsync and CallNextAsync, and it's two lines of intent:

// Part 7 — what the AI wrote, and what its own test blessed:
var waiting = entries
    .Where(q => q.CalledAt == null)
    .OrderBy(q => q.CheckedInAt)

// Part 10 — what the spec always said:
var waiting = entries
    .Where(q => q.CalledAt == null)
    .OrderBy(q => q.Appointment.StartsAt)   // slot first
    .ThenBy(q => q.CheckedInAt)             // check-in breaks ties

Run it again: green — and this time the green means something, because we watched the same test fail. Red-then-green from the spec is the closing move on the tautological-test trap: the test now guards the rule, not the code, and no future prompt can quietly reintroduce first-come-first-served without tripping it.

Bug 2: the clinic lives on the server's clock

The second complaint is stranger. A clinic east of UTC reports that in the evening, the booking page starts offering tomorrow's date as today. And the slot labels are wrong all day — shifted by exactly the clinic's UTC offset. Symptom-first again, and this time the prompt also sets a quality bar for the fix:

"A clinic east of UTC reports two symptoms: after early evening the
booking page shows TOMORROW's date as today, and every slot label is shifted
by the UTC offset. Find every place we compute 'today' or build slot times,
and fix the model properly — no sprinkling of AddHours."

The root cause traces back to Part 6, where the AI built the slot calendar — confidently, plausibly, and wrong. "Today" was DateTime.UtcNow.Date, and the 09:00–17:00 clinic day was constructed with DateTimeKind.Utc:

// Part 6 — the clinic's day, as the AI originally built it:
var dayStart = DateTime.UtcNow.Date;                                    // whose "today"?
var first = date.ToDateTime(new TimeOnly(OpeningHour, 0), DateTimeKind.Utc);  // 09:00 *UTC*

In a demo near UTC, this behaves perfectly — which is exactly why it survived four parts, a live browser walkthrough and a test suite. Once the clinic is in, say, Auckland, both symptoms fall straight out: local evening is already tomorrow in UTC (wrong "today"), and 09:00 UTC is nowhere near 09:00 on the clinic's wall (shifted labels).

What the AI got wrong: AI code that compiles and demos fine can still be quietly wrong about time. Time zones are a top AI blind spot for a structural reason: the happy path hides them. Training data is full of UtcNow.Date that worked, dev machines sit near the demo timezone, and nothing fails until a real user lives somewhere real. Any time AI-written code touches "today", ask it: whose today?

Note what the prompt forbade: AddHours sprinkled wherever a symptom shows up. That's the classic timezone non-fix — it patches labels while the model underneath stays confused. The proper fix is a model fix: one place in the codebase that knows what timezone the clinic lives in, and a firm rule everywhere else. Meet ClinicTime, quoted in full because it's small enough to be:

/// The one place that knows what timezone the clinic lives in.
///
/// The bug this class fixes (Part 10): "today" and "09:00" were computed straight
/// from UTC. A clinic east of Greenwich saw yesterday's slots in the local evening,
/// and every slot was labelled with UTC times. Rule: store UTC, but decide
/// "which day is it?" and "what does 9am mean?" in the CLINIC's zone.
public class ClinicTime(IConfiguration config)
{
    public TimeZoneInfo Zone { get; } =
        TimeZoneInfo.FindSystemTimeZoneById(config["Clinic:TimeZone"] ?? "UTC");

    /// Today, as the clinic's wall calendar sees it.
    public DateOnly Today => DateOnly.FromDateTime(TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, Zone));

    /// A clinic-local date + wall time as the UTC instant it happens at.
    public DateTime ToUtc(DateOnly date, TimeOnly time) =>
        TimeZoneInfo.ConvertTimeToUtc(date.ToDateTime(time, DateTimeKind.Unspecified), Zone);

    /// UTC bounds of one clinic-local day: [start, end).
    public (DateTime StartUtc, DateTime EndUtc) DayBoundsUtc(DateOnly date) =>
        (ToUtc(date, TimeOnly.MinValue), ToUtc(date.AddDays(1), TimeOnly.MinValue));

    /// Render a stored UTC instant as clinic wall time.
    public string Local(DateTime utc, string format = "HH:mm") =>
        TimeZoneInfo.ConvertTimeFromUtc(DateTime.SpecifyKind(utc, DateTimeKind.Utc), Zone).ToString(format);
}

The zone comes from one line of configuration — Clinic:TimeZone in appsettings — and the working rule is three clauses long: store UTC, think in clinic wall time, render with Local(). Timestamps in PostgreSQL stay timestamptz UTC instants, exactly as Part 4 designed. But "which day is it?" and "what does 9am mean?" are now answered in the clinic's zone, and every page renders stored instants through Clinic.Local(...). The slot builder, the free-slot query, check-in's "today only" rule — all rewired through ClinicTime, no AddHours anywhere.

And because Part 9 taught us how green earns trust, the fix ships with a test that would have caught the bug on day one — book a day in Auckland and prove that 9am local is not 9am UTC:

[Fact]
public void Slots_mean_the_clinic_wall_clock_not_utc()
{
    // Part 10's timezone fix: 09:00 in Auckland is NOT 09:00 UTC.
    var auckland = TimeZoneInfo.FindSystemTimeZoneById("Pacific/Auckland");
    var slots = BookingService.AllSlotsFor(new DateOnly(2026, 8, 10), auckland).ToList();

    var firstLocal = TimeZoneInfo.ConvertTimeFromUtc(slots[0], auckland);
    Assert.Equal(new TimeOnly(9, 0), TimeOnly.FromDateTime(firstLocal));
    Assert.NotEqual(new TimeOnly(9, 0), TimeOnly.FromDateTime(slots[0])); // the UTC instant differs
}

Two bugs, one shape

The Part 10 bug ledger
Bug 1: queue orderBug 2: timezone
Shipped inPart 7, blessed by Part 9's testPart 6
The symptom"The 15:45 got called before the 15:00""Tomorrow's slots after evening; labels shifted"
Root causeOrdered by check-in alone; spec says slot firstClinic hours treated as UTC; server's "today"
The fixTest rewritten from spec (red), then OrderBy + ThenBy (green)A model fix: ClinicTime, not AddHours
Now guarded byWaiting_list_is_ordered_by_slot_then_check_inSlots_mean_the_clinic_wall_clock_not_utc

Same shape both times: a symptom in plain language, the AI reasoning from spec plus code, and a test that pins the truth before the fix lands. One caveat for fairness — symptom-first works when the codebase is small enough for the AI to hold the suspects in view. When it isn't, and you can't even say which commit went wrong, Git will happily do the narrowing for you: our Git series covers exactly that hunt in the rescue kit's bisect walkthrough. Bisect finds the commit; symptoms-not-files finds the cause.

The meter: ≈ $4.90 total so far. Both hunts together — diagnosis, test rewrite, two fixes, one new service — added about $0.80 with Opus doing the thinking. Two production bugs for the price of a samosa; the afternoon they'd have cost by hand is the real number.

Checkpoint: git checkout part-10 in the repo and dotnet test: 9 green, now including the spec-derived queue test and the Auckland proof. Set Clinic:TimeZone to your own zone and watch the slot grid follow your wall clock.

ClinicLive now does the right thing. Whether it's safe is a different question — and next time we make the AI answer it about its own code, wearing its least friendly hat: Part 11: The hardening pass — AI as a hostile reviewer.