Designs the relational schema from the SRS: DBMS, tables, keys, indexes, migrations, ER diagram. Use for «схема БД», «спроектируй базу», «какие таблицы нужны», «Postgres или MySQL». Not API or entities.
Resources
2Install
npx skillscat add idontlikedrinkpivo/web-dev-pipeline-codex-plugin/db-schema-design Install via the SkillsCat registry.
Codex runtime. Before using this skill, read
../executor-catalog/references/codex-runtime.md. It defines tool mappings, named-agent dispatch and stage-chat orchestration; use it for inherited Claude Code terms below.
Database Schema Design
Orchestrator (session)
The session writes no DBML, no migration file, and no ER .d2. The
dispatched writer settles them; a writer on Opus nests doc-typist to
print them, design-lite prints them itself (executor-catalog →
Nesting). A Mid or High report whose DELEGATED is not doc-typist comes
back until it is. The session dispatches the typist only for a print
defect or a check's lines (step 4).
- Stop if the SRS is missing — no actors, no use cases, no rules. Name
srs-writer. Do not wait forclean-architecture-design: it runs after this stage, so the writer
reads invariants from the SRS and decides each constraint itself (§4).
Noschema.mdbut the repository already runs a database — another
tool's migrations (alembic/,prisma/migrations/,drizzle/, SQL
files outsidedocumentation/) or ORM models: stop and ask whether to
document that schema as it is, in the namespace it lives in (public
included), or to design a new one beside it. The greenfield branch
would otherwise lay a second schema over a live one. Fromupgrade-docs
(MODE as-is) that is answered — as it is — and the code stands in for a
missing SRS (upgrade-docs/references/as-is.md). - When a schema document already exists, read
doc-versioning.
The writer runs as a subagent and cannot ask the user. Before dispatch,
settle what only the user can: the DBMS and its major version when no
file states them (a manifest or compose in the repo, the SRS, an
existingschema.md) — this stage owns that decision, and the
architecture takes it from here — and the classification of a field
that looks sensitive when the SRS does not say (§6). One question per
message (pipeline/references/question-format.md); each answer goes
into the packet's ANSWERS. On an increment, fill POPULATED for every
existing table the slice changes:rows: yeswhen the migration that
created it is in a released tag (git ls-tree -r --name-only v<last version> documentation/db/migrations/),rows: nowhen this
unreleased iteration created it; with no tag and a live database, ask. - Read
executor-catalogandexecutor-catalog/references/design-complexity.md. Dispatch
exactly one writer withexecutor-catalog/references/design-writer-prompt.md. Before theAgentcall, writeGrade | Executor | subagent_typefor the design
row the grade picked, then dispatch it without asking which model.
Follow the catalog dispatch contract:subagent_typeis<row>
and the call passes nomodel(the agent file holds it) unless the
user named one. A missing agent or a rejected alias is a stop. - Inspect the files on disk. Run
python3 "<folder of this SKILL.md>/scripts/table_docs.py" . --check: a
missing or name-echoing tableNoteor columnnote:is a print
defect — dispatchdoc-typistMODE revisewith oneSETTLEMENTline
per line it prints, the text taken from the SRS (executor-catalog→
Nesting; one or two lines you fix yourself), until none is left. Its migration lines (§0's schema header)
send the writer back for a file this slice wrote; on an older, committed
file they go into the report only, since that file is frozen. Then runpython3 "<folder of this SKILL.md>/../doc-versioning/scripts/doc_check.py" . --only documentation/db/:
eachmigrationsline (a file the main branch has, changed or deleted;migration:behind the newest file) sends the writer back — that change
is a new file. A missingdocumentation/db/diagrams/er.d2, or a slice
whose tables have no new file indocumentation/db/migrations/(not inMODE as-is: no migration is written there), sends the writer back.
When that.d2exists and the.svgdoes not, run the render command
inreferences/er-diagram.mdonce. Do not edit the.d2. Then
regenerate the table documents (Output, item 3):python3 "<folder of this SKILL.md>/scripts/table_docs.py". ABLOCKEDwith a question for the user is asked and sent back to the
same row; any otherBLOCKED/HARDER_THAN_EXPECTEDescalates one
design row (executor-catalog→ Escalation); every round goes on while
it makes progress (pipeline→references/convergence.md). - Close the stage by asking, per
pipeline→ "Asking before a
transition". First ask aboutdoc-review. Run: run it; it returns
without asking the next stage. Skip: name it in the report. Stop:
stop. After a run or a skip, ask the one next stage the table names. The writer does not run
those.doc-reviewedits the body and does not bump. Seedoc-versioning.
Writer (dispatched)
You settle the schema, then print it by the packet's PRINT
(executor-catalog → Nesting, packetexecutor-catalog/references/doc-typist-prompt.md): doc-typist nested,
or yourself as design-lite. The print writes the schema document, the
migration file(s), and the ER .d2 / .svg. A gap this skill calls
BLOCKED returns before printing. Read the files back. With the typist:
one correction dispatch, then the report; do not type the correction, and
do not paste the finished schema into its prompt.
Translate the SRS into a concrete relational schema, one slice at a
time (Context Required). ORM-agnostic, PostgreSQL-flavored SQL (principles apply to
MySQL/MariaDB). Table and column Note / COMMENT ON text is Russian.
Do not translate identifiers.
If the SRS you were handed has no actors, use cases, or rules for this
slice, write nothing: return STATUS BLOCKED with the blocker "SRS
missing — run srs-writer".
A table guessed from a feature name becomes a contract the API and the
code then have to honour.
Scope
Not for: a multi-tenant isolation strategy (tenant_id + RLS only
when the SRS calls for multi-tenancy); table partitioning (past ~100M
rows, not a day-one decision); diagnosing slow queries on a live database
(EXPLAIN ANALYZE, a production-debugging task).
Context Required
| Required | Source |
|---|---|
| Entities, stored state, and the rules that must survive a restart | SRS actors, use cases, business rules |
| What a use case accepts and returns, and whether a value may be absent, when that data is stored | SRS, that use case |
| Which reads the use cases name | SRS |
| DBMS and its major version (PostgreSQL 18, MySQL 8.4) | A file the packet names, or the answer in ANSWERS. Recorded once, in «Решения по базе» (Output) |
| PII / credential / secret classification, if any (§6) | SRS Security NFR rows, or the answer in ANSWERS |
| Whether an existing table already holds rows | POPULATED — it picks the safe migration sequence |
You cannot ask the user. If one of these is still unresolved, returnSTATUS BLOCKED with that one question; the session asks it and sends
the answer back.
Do not invent a layer, a port, or a file tree — those are architecture's
decisions, made after this schema.
Design the schema for one vertical slice's use cases, not every table
implied by the whole SRS. Repeat this skill per slice.
This skill runs before openapi-spec-generator. The names and types chosen
here are what the HTTP contract has to agree with, so the order is not
cosmetic: a spec published first forces either a rename here or a translation
layer between the two. When an OpenAPI spec already exists for this slice,
treat its property names as given and flag a mismatch instead of silently
picking a different column name.
Existing schema: increment, do not redesign
When a schema document already exists, read references/increment.md
before editing: the document grows in place and is never re-versioned
here, the database changes only by a new numbered migration, a committed
migration is never edited, and a populated table (POPULATED) takes the
safe sequence of references/migration-safety.md. A greenfield schema
needs none of it.
0. Schema (namespace), not public
Every table lands in a schema named after the project or service
(orders_service, hr_analyzer — snake_case), never in PostgreSQL's
default public: public names no owner, is writable by every role on
older servers, and collides as soon as a second service shares the
instance. Decide it before the first CREATE TABLE ships — moving tables
out of public later is a coordinated multi-step change across every
table, connection string, and migration config.
-- documentation/db/migrations/0001_init.sql opens with these two lines:
CREATE SCHEMA IF NOT EXISTS <project_name>;
SET search_path TO <project_name>;Every later file (0002_…) opens with the SET search_path line. The
statements after it stay unqualified for readability, and each file is
still correct on its own: the application's migration tool runs these
files as they are, straight from documentation/db/migrations/, and
without the SET line every unqualified table would land in public —
the exact mistake this section exists to prevent. The DBML names the
schema explicitly. Point the application at it both ways:
- Connection:
search_path=<project_name>on the connection string or
session (?options=-csearch_path%3D<project_name>forpostgresql://),
so unqualified names in code resolve here, not inpublic. - Migration tool: its version table in this schema too (Alembic
version_table_schema, Flywayschemas) — otherwise its history lives
inpublicand two services collide on that bookkeeping table.
deploy-topology carries the same schema name into every environment's
connection string; only the server changes per environment.
A database that already lives in public, documented as it is (the
user's answer in ANSWERS), stays there: moving it is a planned change of
its own, not a side effect of documenting it. Its tables are described,
not created — no CREATE TABLE for a table that exists.
1. From the SRS to tables
Map, don't invent.
| SRS | Schema |
|---|---|
| An entity whose state must survive a restart | One table, snake_case, plural (orders, not Order) — one per entity, not one per class |
| A value that belongs to that entity | Columns on that entity's table (not a separate table, unless it repeats) |
| A one-to-many the use cases store | FK column on the "many" side + index on that FK |
| A named error a use case returns | Usually not a table — it's a response shape, not stored state (unless the SRS says a rejected attempt is stored) |
| A read the use cases need as its own stored shape | Its own table or a view, named from that read |
A rule this skill classifies as CHECK / UNIQUE / FK |
That constraint — see §4. The later entity method copies it; the constraint is a defense, not a second owner |
| A rule this skill leaves unconstrained | No DB constraint — the later entity method is the only enforcement |
-- ✅ snake_case, plural
CREATE TABLE orders (...);
CREATE TABLE order_items (...);
-- ❌ singular, mixed case, abbreviations
CREATE TABLE Order (...);
CREATE TABLE tbl_usr_prof (...);Primary Keys
| Column | On which tables |
|---|---|
id bigint GENERATED ALWAYS AS IDENTITY — the primary key and every FK's target |
Every table, always |
public_id uuid NOT NULL UNIQUE (v7 by default; v4 when the SRS says creation time must not leak) — the id the API shows |
Added to every table whose rows appear in the API contract |
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, -- internal FK target
public_id uuid NOT NULL DEFAULT uuidv7() UNIQUE, -- PG 18+; see the note below for older versions
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now() -- the repository's UPDATE sets it; DEFAULT fires on insert only
);uuidv7() exists from PostgreSQL 18 — read the major from ANSWERS or the
file that states it. On an older version gen_random_uuid()
gives v4, which is not time-ordered: either the application generates the v7
id and the column has no default, or v4 is a decision written in the column's
note with the reason — never a silent swap.
How the API names keys. The contract never shows the internal bigint
key or a bigint reference. A table's public_id is the resource's id
in the API; a reference column <thing>_id (the referenced table in the
singular: room_id → rooms) keeps its name in the API and carries the
referenced row's public_id; a path parameter is <resource>_id
(/bookings/{booking_id}). openapi-spec-generator maps by this rule anddocs-consistency checks it; name every reference column so the rule
gives the API name.
Columns and types
NOT NULLby default; nullable only when the SRS says the value may
be absent — an unasked-forNULLis a third state every reader must
handle.created_aton every table.updated_atonly on a table this slice
updates, and its note names who maintains it — aBEFORE UPDATE
trigger emitted in the migration, or the repository'sUPDATE
(DEFAULT now()alone fires only on insert).- Amounts:
numeric(p,s)(or integer minor units), currency in its own
column — notmoney,float/real. - Text:
text; a length the SRS names isCHECK (char_length(col) <= n)
— notvarchar(n)/char(n)by default. - Instants:
timestamptz;datefor a calendar day with no time — nottimestampwithout time zone. - Ids and counters that can grow:
bigint(identity for keys) — notint/serial/bigserial.
Relationships and ON DELETE
Every reference is a real FOREIGN KEY with an explicit ON DELETE
(table below) and an index on the FK column — an unenforced reference
is an orphan waiting to happen. In DBML it is a standalone Ref: … [delete: …] line: an inline ref: cannot carry the action.
references/schema-patterns.md → Foreign keys in SQL holds a one-to-many and a junction-table example;
read it when the slice has a many-to-many.
ON DELETE |
When |
|---|---|
CASCADE |
Child is meaningless without the parent |
RESTRICT |
Deleting the parent while children reference it should be an error |
SET NULL |
The reference is optional and should just clear |
2. Normalization
Start at 3NF. Denormalize only with a measured reason (a real read:write
ratio problem you've profiled) — not by default, and not because a join felt
inconvenient.
If a read model needs a joined/aggregated shape, denormalize only when
the SRS names that read shape and a reason to store it (a list screen too
slow to join, a report over history). Give it its own table or
materialized view and say in its Note which SRS id asked for it; a
denormalization nobody asked for is two copies to keep in sync.
3. Indexing
Index only what this slice's declared queries need — not every column: the
WHERE, JOIN, and ORDER BY … LIMIT columns of those queries, composite in the
query's column order, partial when the hot query filters a small subset.
-- Composite: matches "WHERE user_id = ? AND status = ? ORDER BY created_at DESC"
CREATE INDEX idx_orders_user_status_created
ON orders(user_id, status, created_at DESC);
-- Partial: most rows are not 'pending', but that's the hot query
CREATE INDEX idx_orders_pending ON orders(created_at DESC) WHERE status = 'pending';4. Turning SRS rules into constraints
You decide the constraint. A rule that is a predicate SQL can check, a
uniqueness, or a reference becomes a real CHECK, UNIQUE, or FK —
not a comment. A rule that is a workflow, a permission, or a clock stays
out of the schema. The column note names which of the two it is, so
architecture can copy none or the constraint you wrote. Architecture's
invariant table copies your constraint; you decide it here.
-- A numeric bound
ALTER TABLE lots ADD CONSTRAINT lots_weight_positive CHECK (weight_kg > 0);
-- A named, closed set of states (see §5 for the enum-vs-text tradeoff)
ALTER TABLE orders ADD CONSTRAINT orders_status_valid
CHECK (status IN ('pending', 'confirmed', 'shipped', 'delivered', 'cancelled'));
-- Uniqueness across a combination of columns
ALTER TABLE order_items ADD CONSTRAINT order_items_unique_line
UNIQUE (order_id, product_id);Do not encode a procedural rule as SQL. That rule belongs to the
entity architecture will write later. A constraint and an entity method
for the same check will drift.
5. Status columns and other patterns
Enum-like status columns
-- Text + CHECK — easiest to migrate later. Default choice.
ALTER TABLE orders ADD COLUMN status text NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'confirmed', 'shipped', 'delivered', 'cancelled'));Use a native ENUM type only if the set of values is genuinely frozen —ALTER TYPE on a live enum is painful.
The closed list of codes is owned here, not in the architecture
composition table. For every status (or other enum-like) column, emit a
reader table ## Закрытые наборы значений with the columns
«Таблица.поле · Код · Подпись»: one row per allowed code. «Подпись» is the
human name of the code when a read shows one. Architecture and OpenAPI copy this column; they do
not keep a second list. Do not add a «нет» row for entities without a
status. Who may set which code is a later entity rule, not a CHECK,
unless the SRS states a predicate SQL can enforce.
Situational patterns
Read references/schema-patterns.md when the SRS asks for soft delete,
a row that may belong to one of several parent kinds, or free-form
attributes. Every field the SRS names (email, status, price) is a column,
never a JSONB key, because only a column can carry a type and a constraint.
6. Sensitive Data (PII, Secrets)
Only when the SRS flags a column as PII, a credential, or a secret — do
not invent classification the SRS never stated. A credential, token, or
API key is never stored raw: a leaked table must not leak the secret, so
store a hash (and a non-secret lookup prefix when one is needed).
Read references/sensitive-data.md when any column is flagged: it holds
the pattern per data kind, the hashed api_keys example, and the limits
on encryption and audit tables.
Output
Emit all four, and make sure they agree. The DBML and the ER live indocumentation/db/schema.md and documentation/db/diagrams/; the SQL
lives only in migration files — the document has no SQL block:
- DBML — table definitions with relationships, each table and column with its note, because the table documents
and every later stage read the meaning from these notes:- Every table has a table-level
Notethat opens with one or two plain sentences on what a row is — the entity
or use-case state it stores — before any detail. - Every column has a
note:whose first sentence is its description for readers — plain, whole on its own, ids
at its end — because the table documents show only that sentence; details (which trigger sets it, why there is no
index) come after it. - No note that echoes the name (
orders→ "orders",id→ "id") — it tells the reader nothing and counts as
missing. - Notes come from the SRS — do not invent a purpose the inputs did not decide.
- No UI data — a note says what the data means, never how a screen shows it, and no column exists only to drive
a widget or a layout (a menu order, a colour, an icon) unless the SRS requires it as data (doc-versioning→references/writing-rules.md→ What, not how it looks).
- Every table has a table-level
Table holds {
id bigint [pk, increment, note: 'Внутренний ключ соединения; в API не попадает']
public_id uuid [unique, not null, note: 'Идентификатор брони, который отдаёт контракт PlaceHold']
book_id bigint [not null, ref: > books.id, note: 'Книга, которую резервирует эта бронь']
starts_at timestamptz [not null, note: 'Начало брони']
ends_at timestamptz [not null, note: 'Срок брони; не позже starts_at + 7 дней — CHECK holds_period_max_7d']
Note: 'Одна бронь, созданная сценарием PlaceHold'
}Table holds [note: '...'] is the same contract as a Note: block inside
the table — pick one, not both.
Migration SQL — files in
documentation/db/migrations/, namedNNNN_<snake_slug>.sql, each opening with §0's header. Greenfield:0001_init.sql, then the actualCREATE TABLEstatements (with
constraints and indexes from §3–4) for one vertical slice. Increment:
the next number, holding only this slice's DDL (references/migration-safety.md
on a populated table). Carry the same note text asCOMMENT ON TABLE/COMMENT ON COLUMNso the files and the DBML agree, and give every namedCHECKaCOMMENT ON CONSTRAINT <name> ON <table> IS '<one plain sentence>'
— what the rule guarantees, in a reader's words, not the expression
(«Тип файла совпадает с его расширением: pdf, png, jpg, docx, xlsx, pptx»).
DBML has no place for it; the table documents show that sentence instead
of the SQL. No version and no
changelog in a migration file;schema.md's changelog row names it.Table documents — one
documentation/db/tables/<table>.mdper table, generated from the DBML and## Закрытые наборы значенийby this skill'sscripts/table_docs.py(columns: Поле, Тип данных,
Свойства, Значение по умолчанию, Связь, Допустимые значения, Описание; then the incoming references with
what deleting a row does). The orchestrator runs it after the files land (step 4), and the plugin's hook
reruns it after any edit underdocumentation/db/; the writer and the typist do not. Never edit those
files — edit the DBML; they go into the same commit.ER —
documentation/db/diagrams/er.d2ander.svg, embedded under## ER. Followreferences/er-diagram.md. The picture is a projection of this DBML: same tables, columns, and keys, nothing added. Render the SVG in the same write. Questions go indocumentation/db/open-questions.md, not in a section of the schema.
The wrapping documentation/db/schema.md opens with
frontmatter per doc-versioning → Frontmatter (version: the open
iteration's service version on a new document — on greenfield the first iteration's number, usually 0.1.0;migration: 0001, the last migration file the document covers;updated:; and sources: pinning the SRS by the version it was built
from — documentation/requirements/srs/srs.md@0.1.0), then H1, then the## Журнал изменений table from doc-versioning (newest row first;
a new document has the one row | <версия> | <дата> | первый выпуск | — | — |), then## Закрытые наборы значений when any table has a status (or other
closed-set) column, then ## Решения по базе, then the DBML and the ER —
and no other section. It always shows the whole
current schema — after an increment, the DBML reads as if every migration
so far had run.
## Решения по базе — what holds for the whole schema, a short list, one
line each with its reason. The first line is the DBMS and its major version
(«СУБД: PostgreSQL 18 — …»): decided at this stage, the one home of that
decision; the architecture's §1 «Стек» and repo-scaffold take it from
here. Then the namespace (§0) and who creates it;
how migrations run (the files in number order, each once, what setssearch_path — the runner itself is the stack's, named by the
architecture); what makes keys (uuidv7() on PostgreSQL 18+, else what the
note under Primary Keys decided); collation or extensions the schema relies on. Not here: which SRS
requirements the tables serve (the column notes and the table documents
carry them), what each migration changed (its changelog row and the header
of its file), and how a release cuts over or rolls back (the deploy
document, deploy-topology). Do not put the changelog at the end.
Headings and columns are Russian; DBML and ER stay — terms of the trade.
Field names in the DBML and the migration SQL must match the SRS use-case inputs
and outputs — the API-contract step reads directly off these names.
Before you finish
- Project schema, not
public: migration headers, connectionsearch_path, and version table as §0 sets them — §0 - This slice's DDL is a new numbered file in
documentation/db/migrations/(as-is: none —migrations:names the project's folder); no committed file edited;schema.mdhas no SQL block, shows the whole current schema, and itsmigration:names the last file — Output - One table per stored entity; PKs, FKs with
ON DELETE,NOT NULL, timestamps, and types as §1 says; reference columns named<thing>_idso the API names follow — §1 - «Решения по базе» opens with the DBMS and its major version — Output
- 3NF; any denormalization names its SRS id — §2
- An index for every filter, join, and sort this slice declares, and nothing more — §3
- Every SQL-expressible rule is a constraint; every other rule has none — §4
-
## Закрытые наборы значенийfor every closed-set column — §5 - No raw secret column — §6
- Every table and column has a note that says more than its name (
table_docs.py --checkprints no line); DBML, migration SQL, and ER agree; names match the SRS — Output - Every named
CHECKhas aCOMMENT ON CONSTRAINTsentence in its migration — Output - Each column note opens with a plain sentence a reader understands alone;
documentation/db/tables/regenerated (table_docs.py) and matches the DBML — Output
Closing
The report has exactly the packet's fields: FILE WRITTEN names the
schema document and each migration file written, DIAGRAM WRITTEN the ER .d2 (with svg: missing when
it did not render). A slice table left out, or a migration not written,
goes to CONCERNS. Do not run doc-review or pipeline — the orchestrator
closes the stage.