In Part 3 the AI interviewed us and wrote a one-page spec whose data sketch names five entities and zero column types. Today that sketch becomes real PostgreSQL DDL — and along the way we catch the AI making two classic mistakes, and discover one rule it could never have known without us. That rule is the thesis of this whole series.
Prompt with requirements, not table lists
The instinct, when asking an AI for a schema, is to dictate tables and columns — at which point you're just typing DDL slowly, with extra steps. The better move is to state requirements and invariants and let the model do the translation it's genuinely good at. Here's the actual prompt, verbatim from the part-04 commit:
Turn docs/spec.md's data-model sketch into PostgreSQL 18 DDL.
Requirements: identity primary keys, timestamptz for every timestamp,
a uniqueness rule so two active appointments can never share a slot
but a cancelled slot can be re-booked, and the confirmation code must
be unique. For every non-obvious decision add one sentence saying
WHY. Do not create tables the spec doesn't need.
Read what's in there. Not one column name — but four hard requirements: identity primary
keys, timestamptz everywhere, uniqueness for the confirmation code, and one
genuinely subtle business rule about cancelled slots that we'll spend a whole section on.
Plus two guard rails from lessons already learned: "say WHY" (so the schema arrives with
its reasoning attached, reviewable), and "no tables the spec doesn't need" (Part 3
taught us the AI's appetite for scope).
The DDL
The full result is docs/schema.md in the companion repo; here's the heart
of it — the two tables that carry the business:
CREATE TABLE patient (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
full_name varchar(200) NOT NULL,
phone varchar(30) NOT NULL,
email varchar(200),
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX ix_patient_phone ON patient (phone);
CREATE TABLE appointment (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
patient_id bigint NOT NULL REFERENCES patient(id),
starts_at timestamptz NOT NULL,
status varchar(20) NOT NULL DEFAULT 'Booked',
confirmation_code varchar(6) NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX ix_appointment_patient ON appointment (patient_id);
CREATE UNIQUE INDEX ix_appointment_slot ON appointment (starts_at)
WHERE status NOT IN ('Cancelled', 'NoShow');
CREATE UNIQUE INDEX ix_appointment_code ON appointment (confirmation_code);
Two more tables complete the set: queue_entry (created at kiosk check-in,
one per appointment, with checked_in_at and called_at) and
chat_message (sender, body, sent time). A patient is created the first time
a phone number books — no login, ever; the six-character confirmation code is the
patient's only credential, which is exactly why it gets a unique index of its own.
The index the AI would never suggest
Look again at the middle index — it deserves its own section:
CREATE UNIQUE INDEX ix_appointment_slot ON appointment (starts_at)
WHERE status NOT IN ('Cancelled', 'NoShow');
That's a partial unique index: uniqueness enforced only over rows
matching the WHERE clause. Left to itself, the AI proposed the obvious
thing — a plain UNIQUE on starts_at. Perfectly reasonable, and
quietly catastrophic: once any appointment ever existed at 10:15, no one could
ever book 10:15 again — a cancelled appointment would block its own slot forever. The
business rule is that two active appointments can't share a slot, but a
cancelled or no-show one frees it. The partial index says exactly that, in one
statement, enforced by the database itself no matter what application code does.
Here's the part to sit with: that rule had to come from us. It's in the prompt above — "a cancelled slot can be re-booked" — because no amount of model intelligence could have derived how our clinic treats cancelled slots. The AI knew the syntax instantly once the rule was stated; it could never have known the rule. AI knows syntax. You know the rules. That's the division of labour this whole series runs on, and this index is its cleanest exhibit. (It also pays off twice: in Part 6, catching this index's violation is how the booking code survives two patients racing for the same slot.)
What the schema reviewer caught
So the AI nailed the partial index once told the rule. It also, in the same breath, made two of the oldest mistakes in the book.
What the AI got wrong: the first draft used timestamp
— without time zone — for every timestamp column, despite the prompt saying
timestamptz. And it declared the foreign key
appointment.patient_id but created no index on it, which means every
"this patient's appointments" query scans the whole table. Both are textbook,
both are quiet, and both sailed by on first read because the DDL looks
professional. We caught them by pasting the draft into a reviewer before accepting it.
Trust, but verify — with tools, not vibes: paste any
CREATE TABLE draft into our free
schema reviewer before you accept it. It flagged
both mistakes above in seconds: missing FK indexes, timestamp without
time zone, and a dozen other habits AI-generated DDL loves. AI output reviewed by
another tool beats AI output reviewed by a good feeling.
If the timestamp-versus-timestamptz distinction is new to you,
our translation table on
PostgreSQL data types covers it
properly — the short version is that timestamptz stores an unambiguous
instant while timestamp stores a wall-clock reading with no idea where the
wall is. And if indexes themselves feel fuzzy,
our indexes explainer is the
prerequisite: this part leans on it twice.
Every decision, defended
Because the prompt demanded a WHY for every non-obvious choice, the schema arrived with its reasoning attached — which made reviewing it a conversation instead of an archaeology dig. The decisions worth keeping:
| Decision | Why |
|---|---|
timestamptz everywhere | The waiting room doesn't care what timezone the server thinks it's in. Store UTC, render local — Part 10 shows exactly what happens when you forget. |
Partial unique index on starts_at | Two active bookings can't share a slot, but a cancelled one frees it — a plain UNIQUE would block re-booking forever. |
Unique confirmation_code | It's the patient's only credential; a collision would check in the wrong person. Generated from an unambiguous alphabet — no 0/O, no 1/I — because it gets read aloud at a kiosk. |
queue_entry split from appointment | "The schedule" and "the queue" are different ideas: the board subscribes to queue changes only, and the schedule page doesn't re-render when someone checks in. |
varchar status, not a PG enum | EF Core maps string enums painlessly; a CHECK constraint arrives in Part 11's hardening pass to guard against raw UPDATEs. |
Model pick: Opus, high effort — same as Part 3, same reason turned up louder: schema mistakes compound. Every feature from Part 5 onward builds on these tables, so this is a think-hard step; the premium model on a half-page of DDL costs less than one wrong migration later.
The meter: our build so far: ≈ $0.60. Spec plus schema, interviews and revisions included. The two most consequential documents in the repository, for less than a coffee.
To read the design document exactly as this part left it — DDL, reasoning and all — check out the tag:
git checkout part-04
As always in the companion repo, the commit message contains the full prompt, and the mistakes are recorded right alongside it.
Checkpoint: before Part 5 you should have docs/schema.md
committed: DDL for your entities with identity keys and timestamptz
throughout, run past the schema reviewer, and —
most importantly — you should be able to explain what every index in it is
for. If any index has no story, it doesn't belong yet.
We have a spec and a schema, and still not one line of C#. That changes now: in Part 5 the AI scaffolds the Blazor Server solution, swaps SQLite for PostgreSQL, and turns this DDL into EF Core entities and a first migration — under the rule that makes AI scaffolding safe: never ship a file you haven't read.