The skeleton from Part 5 runs, logs in and holds a schema — but nobody can book an appointment. Today ClinicLive grows its first real feature: free slots, the booking form, confirmation codes, cancellation, and the double-booking race. More importantly, this is the part where the prompt → review → refine loop stops being theory and starts being how the work actually gets done.

Follow along: git checkout part-06 in the companion repo — the commit message carries the full prompt below.

Model pick: Sonnet, medium effort. CRUD is bread-and-butter — a well-specified booking service is squarely inside what a mid-tier model does reliably. We're saving Opus for Part 7's hubs, where design decisions ripple.

A prompt that specifies behavior, not code

Here is the entire prompt, verbatim from the commit "Build the public booking flow and staff appointment list":

Build booking end to end. A BookingService using IDbContextFactory
(explain in a comment why interactive Blazor components must not share
one scoped DbContext): free-slot listing for a date (15-minute slots,
09:00-17:00, hide past and taken slots), BookAsync that finds-or-creates
the patient by phone and handles the slot race by catching the
unique-index violation with a friendly message, and CancelAsync by
confirmation code. Pages: /book (date -> slot grid -> details form ->
big confirmation code), /cancel, and /staff/appointments behind
[Authorize]. Replace the template's Counter/Weather demo pages.
Confirmation codes: 6 chars, no ambiguous letters — they get read aloud
at a kiosk.

Read it again and notice what it contains: behavior (15-minute slots, hide past and taken), edge cases (the slot race, find-or-create by phone), experience (a big confirmation code, friendly error message), and real-world constraints (codes get read aloud at a kiosk). And notice what it doesn't contain: a single line of implementation. No LINQ, no method bodies, no "use a try/catch". When you specify behavior, the AI's job is to satisfy it and your job is to verify it — a clean division of labor. When you specify code, you're just typing slowly with extra steps.

The free-slot calendar

What came back in BookingService is compact and readable — query what's taken, generate the full grid, subtract:

public async Task<List<DateTime>> GetFreeSlotsAsync(DateOnly date)
{
    await using var db = await dbFactory.CreateDbContextAsync();

    var dayStart = date.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc);
    var taken = await db.Appointments
        .Where(a => a.StartsAt >= dayStart
                 && a.StartsAt < dayStart.AddDays(1)
                 && a.Status != AppointmentStatus.Cancelled
                 && a.Status != AppointmentStatus.NoShow)
        .Select(a => a.StartsAt)
        .ToListAsync();

    var takenSet = taken.ToHashSet();
    var now = DateTime.UtcNow;

    return AllSlotsFor(date)
        .Where(slot => !takenSet.Contains(slot) && slot > now)
        .ToList();
}

Cancelled and no-show appointments don't block their slot — which mirrors, exactly, the filter on the partial unique index from Part 4. That's not a coincidence; it's the same business rule stated twice, once for reading and once for enforcement. Speaking of which:

The database is the last line of defense

Here's the race. Two patients are looking at the same free 10:15 slot. Both pass every C# check — the slot was free when each of them looked. Both submit. No amount of "check if the slot is taken first" in C# fixes this: between the check and the insert there is a gap, and the other booking lives in that gap. The only referee that sees both inserts is the database, and the partial unique index is how it rules:

try
{
    await db.SaveChangesAsync();
    return new BookingResult(true, Appointment: appointment);
}
catch (DbUpdateException)
{
    // The partial unique index on starts_at fired — someone beat us to the slot
    // (or, far less likely, a confirmation-code collision). Either way: retry-able.
    return new BookingResult(false, "Sorry — that slot was just taken. Please pick another.");
}

The loser of the race doesn't get a stack trace; they get "that slot was just taken" and a refreshed grid. This is the pattern to internalize: C# checks are for good error messages; constraints are for correctness. If the AI ever offers you a check-then-insert as the whole answer to a uniqueness problem, that's a review comment — and remember from Part 4 that the partial index itself had to come from us. The AI defended the rule beautifully once it existed; it didn't invent it.

The booking page

/book is a straightforward interactive-server page with three beats worth showing. First, the date picker reloads the grid the moment the date changes — that's @bind-Value:after, binding plus a callback in one attribute:

<InputDate id="date" class="form-control"
           @bind-Value="_date" @bind-Value:after="LoadSlotsAsync" />

Second, the details form is a plain EditForm with data annotations — required name, required phone, optional email — with validation messages beside each field. (If Blazor forms are new territory, our forms and validation walkthrough covers every piece used here.) Third, the payoff screen: on success the page swaps to a confirmation panel whose entire job is to make one string enormous:

<p class="mb-0">
    Your confirmation code is
    <span class="fs-2 fw-bold font-monospace">@_confirmed.ConfirmationCode</span>
</p>

That code is the patient's only credential — the spec's no-accounts rule means it books, cancels and checks in. Which is why the prompt's oddest-looking requirement ("no ambiguous letters — they get read aloud at a kiosk") produced the smallest, most user-respecting class in the codebase:

public static class ConfirmationCode
{
    // No 0/O, 1/I/L — codes get read aloud and typed on a kiosk.
    private const string Alphabet = "ABCDEFGHJKMNPQRSTUVWXYZ23456789";

    public static string NewCode(int length = 6) =>
        string.Create(length, Random.Shared, static (span, rng) =>
        {
            for (var i = 0; i < span.Length; i++)
            {
                span[i] = Alphabet[rng.Next(Alphabet.Length)];
            }
        });
}

"Is zero or the letter O?" is a real conversation that happens at real front desks. A 31-character alphabet makes it impossible to have.

When to re-prompt, and when to just edit

The review of this feature produced both kinds of fixes, and the split is worth making explicit because it's the habit that keeps an AI-built repo honest:

The re-prompt vs hand-edit rule of thumb
ChangeHowWhy
One-line mechanical fixes — a rename, a missing Trim(), a wrong CSS class Edit it yourself Round-tripping a one-liner through a prompt costs more than it teaches
Behavioral changes — a new rule, a changed edge case, different error handling Go back through a prompt The prompt becomes the commit message: the intent is recorded, not just the diff

That second row is the quiet superpower of this series' workflow. Six months from now, "why does CancelAsync only cancel Booked appointments?" is answered by the commit that asked for it — in English.

What the AI got wrong: this one's big, and we are deliberately not fixing it today. Look back at the slot code: "today" is DateOnly.FromDateTime(DateTime.UtcNow), and the 09:00–17:00 grid is built with DateTimeKind.Utc. The clinic's opening hours are being treated as UTC wall time. On a demo machine sitting near UTC, everything works: slots look right, booking works, the checkpoint below passes. The AI wrote it confidently, it compiles, and it demos perfectly — and it is quietly wrong in a way that no compiler, no demo, and no happy-path test will catch. We didn't notice either. Keep this part's code in mind; Part 10 is coming.

The meter: the full booking flow — service, three pages, staff list, review passes — added about $0.70 on Sonnet, for a running total of ≈ $1.80. Under two dollars for a working, schema-backed booking system; the review time remains the dominant cost, and that's the correct place for it.

Checkpoint: book a slot in the browser and get a readable six-character code; cancel with that code on /cancel; re-book the freed slot (the partial index filter earning its keep); and in a second browser, race yourself for the same slot — the loser sees "that slot was just taken", not an exception page. /staff/appointments asks for a login first.

Patients can now book — but the waiting room is still a room full of people staring at a door. Next, the part this app was pitched on: a kiosk check-in that appears on the waiting-room board in under a second, without anyone refreshing anything. Our own SignalR hub, groups and reconnects await in Part 7: going real-time.