Data Model Spec
The data model is the one part of a project you cannot cheaply redo. Rip out a frontend and your data survives. Swap frameworks and your data survives. Get the tables wrong, let six months of real records pile up inside the wrong shape, and you are now doing surgery on a patient who is awake. Which is why the data model is the worst possible place to let an agent improvise, and exactly where an unspecced agent will improvise most, because every app it has ever seen had a slightly different schema and it will happily average them.
So you write the shape down before any code exists, and you write it twice. First in plain language: what things exist, what facts you store about each one, how they relate. This is the version you can actually check, because you know your own domain. Whether a customer can have two open invoices is not a database question. It is a question about your business, and you are the only one in the room who knows the answer. Then a SQL sketch, which is the same information made precise enough that nothing is left to interpretation: types, required-or-not, unique-or-not, what happens on delete.
The sections people skip are the rules and the sensitive-fields list, and they are the ones with teeth. Rules are the invariants the schema alone can't express, and unstated rules get discovered as production bugs. The sensitive-fields list is your privacy obligations written where the agent will actually see them, before it helpfully logs email addresses to the console.
# Data Model: [project name]
Database: [PostgreSQL 16 / SQLite]. [SQLite is a real production choice for
single-server apps; pick Postgres when you need concurrent writers or you're
deploying somewhere that hands you one anyway.]
## The things (plain language)
[One short paragraph per entity. What it represents in the real world, and
roughly how many you expect: 100 rows and 10 million rows want different
decisions.]
- **User**: a person with a login. Expect [hundreds].
- **Project**: a container a user owns. A user has many projects;
a project belongs to exactly one user. Expect [tens per user].
- **Task**: one item of work inside a project. Expect [hundreds per project].
## Relationships, stated in sentences
[One line each. If you can't write the sentence, you don't know the
relationship yet, and neither does the agent.]
- A User has many Projects. A Project belongs to one User.
- A Project has many Tasks. Deleting a Project deletes its Tasks (cascade),
because an orphaned task means nothing.
- A Task may be assigned to one User, or nobody. Deleting that User leaves
the task unassigned (set null), it does not delete work.
## Fields per table
### users
| field | type | rules |
|---------------|-----------|----------------------------------------------|
| id | uuid | primary key, generated by the database |
| email | text | required, unique, lowercased before storing |
| password_hash | text | required. The word "hash" is load-bearing. |
| display_name | text | required, 1-80 chars |
| created_at | timestamp | required, set by the database, UTC |
### projects
| field | type | rules |
|------------|-----------|-----------------------------------------|
| id | uuid | primary key |
| user_id | uuid | required, references users(id) |
| name | text | required, unique per user, not globally |
| archived | boolean | required, default false |
| created_at | timestamp | required, UTC |
### tasks
| field | type | rules |
|-------------|-----------|---------------------------------------------------|
| id | uuid | primary key |
| project_id | uuid | required, references projects(id), cascade delete |
| assignee_id | uuid | optional, references users(id), set null |
| title | text | required, 1-200 chars |
| status | text | required, one of: todo, doing, done. Enforced. |
| due_on | date | optional. A date, not a timestamp: "Friday" has |
| | | no time zone. |
| created_at | timestamp | required, UTC |
## SQL sketch
```sql
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
display_name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
archived BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (user_id, name)
);
CREATE TABLE tasks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
assignee_id UUID REFERENCES users(id) ON DELETE SET NULL,
title TEXT NOT NULL CHECK (length(title) BETWEEN 1 AND 200),
status TEXT NOT NULL DEFAULT 'todo'
CHECK (status IN ('todo', 'doing', 'done')),
due_on DATE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_tasks_project ON tasks (project_id);
CREATE INDEX idx_projects_user ON projects (user_id);
```
## Rules the schema can't express
[Invariants the agent must enforce in application code. Number them so
tests can reference them.]
1. A task's assignee must belong to... [state your rule: same account?
any user? This is a business decision, decide it here.]
2. Archived projects are read-only: no new tasks, no status changes.
3. [Add yours. Every unstated rule ships as a bug.]
## Sensitive fields
- `users.email`: personal data. Never log it, never put it in a URL.
- `users.password_hash`: never leaves the database. Not in API responses,
not in logs, not "temporarily for debugging."
- Deliberately NOT stored: [e.g. full names, addresses, payment details.
Data you don't hold is data you can't leak.]
## Instructions to the agent
- Build exactly these tables via a migration file, not by typing SQL into
a console. The migration is the record.
- If a feature needs a field or table not listed here, propose the schema
change and wait for approval before writing it. The model changes by
edit to this document, not by drift.Adaptation notes:
- The plain-language section is not decoration for beginners. It is where you catch modeling mistakes, because you can evaluate "a project belongs to one user" against reality and you cannot evaluate a foreign key clause against anything.
- The expected row counts sound optional and aren't: they drive indexing, pagination, and whether "load it all into memory" is a plan or a time bomb.
- For SQLite, swap
TIMESTAMPTZforTEXTin ISO 8601 UTC andgen_random_uuid()for app-generated ids; the shape of the document doesn't change. - Status columns and other short lists of allowed values get a CHECK constraint (or an enum). A
statuscolumn that accepts any string will, given time, contain any string. - The classic mistake is modeling for the imagined future: fifteen tables for a product with no users. Model what version one stores. Adding a table later is an afternoon; the migration-plan-spec template covers changing one that's live.