Use this skill when adding a new feature vertical to this template — a domain aggregate with its port, repository, application service, DTOs, endpoint, and tests. Trigger it for requests like "add an endpoint", "add a new entity", "build the X feature", or "wire up a new service".
Installs into .claude/skills of the current project.
Are you the author of Add Vertical?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/lamantinai-add-vertical)
---
name: add-vertical
description: Use this skill when adding a new feature vertical to this template — a domain aggregate with its port, repository, application service, DTOs, endpoint, and tests. Trigger it for requests like "add an endpoint", "add a new entity", "build the X feature", or "wire up a new service".
triggers: [vertical, endpoint, feature, aggregate, entity, wire]
minimal_read_set:
- AGENTS.md
- project/application/reference_task_service.py
- project/core/service_registration.py
validation_command: make quality-gates
---
# Add Vertical
This repository ships one worked vertical, `reference_task`, purely to be copied. Read its files
before writing your own — every constraint below is already satisfied in them.
## The twelve files of a vertical
| # | File | What it holds |
|---|------|---------------|
| 1 | `project/domain/<name>.py` | Frozen dataclass, business constants, and the field checks every writer meets (`check_title`, `check_status`). No framework imports. |
| 2 | `project/domain/ports.py` | A `Protocol` per aggregate, speaking only in domain types. |
| 3 | `project/infrastructure/persistence/orm_models.py` | The table, for Alembic's metadata only — nothing reads the ORM at runtime. |
| 4 | `alembic/versions/<rev>_add_<name>s.py` | Autogenerated from #3, then read before it is trusted. |
| 5 | `project/infrastructure/persistence/<name>_repository.py` | SQL, driver types, and the row → domain mapper. |
| 6 | `project/application/<name>_service.py` | Orchestration and business rules. Depends on the Protocol, never on the repository. |
| 7 | `project/application/<name>_dtos.py` | Request and response models — types only, the bounds are the domain's — plus `from_domain`. |
| 8 | `project/infrastructure/api/endpoints/<name>s.py` | Router and handlers. Parse, delegate, convert. |
| 9 | `tests/application/test_<name>_vertical.py` | What the service decides before writing — bounds, closed sets, an empty or null patch — over a stub of the port that stores nothing; and the wiring. No database. |
| 10 | `tests/db/test_<name>_repository.py` | The queries against a real PostgreSQL (the db tier, inside `make test`): the whole row back, filter, order, page, the write condition, id spellings, the spans. |
| 11 | `tests/db/test_<name>s_api.py` | HTTP in process over the real repository — status codes, a non-default filter, a staged race. |
| 12 | `tests/functional/src/test_<name>s_api.py` | One smoke path through the built image, in `make test-e2e`. |
Every file here is type-checked — `MYPY_TARGETS` covers every test suite, so a hand-written fake
that drifts from the Protocol fails `make gate-types` even though pytest cannot see the drift.
Copying the reference vertical keeps you clear of it.
Plus three wiring edits, all in the `caution` zone: `project/core/service_registration.py`,
`project/infrastructure/api/dependencies.py`, `project/infrastructure/api/router_registration.py`.
`tests/conftest.py` already provides `app_without_postgres` for file 9 — assemble against it for
ADR-006 (routes absent, service key present and `None`) instead of rebuilding the fixture.
No test lists the tables. A table whose migration is missing fails `alembic check`, which the db
tier runs inside `make test` (`tests/db/test_migrations_match_models.py`) — the claim a hand-kept
list of table names used to stand in for, and one it made every project edit on its first table.
Do not bring such a list back — the template's own test of its skill texts refuses one.
## Order of work
`make gate-fast` after each step below, not after each file — a half-written step fails on what its
next file supplies; it formats what you changed. `make quality-gates` once the steps are done.
1. Domain model and port.
2. The ORM model in `orm_models.py`. Before the migration, not after — autogeneration compares this
metadata against a database. Choose `ondelete` on every `ForeignKey(...)` here: autogenerate
copies whatever the model says and decides nothing on its own, so a bare `ForeignKey(...)` passes
silently, and a missing policy would otherwise surface only as a 500 from the driver.
`tests/infrastructure/test_persistence_models.py::TestForeignKeysDeclareOnDelete` reports one instead.
See `docs/adr/ADR-007-autocommit-and-explicit-transactions.md`, "A foreign key's deletion policy
is a domain decision".
3. **A running database, brought to head, before you autogenerate:**
```bash
docker compose -f docker-compose.yml -f docker-compose.postgres.yml up -d db
make migrate
make autogenerate-migration MSG="add <name>"
```
Refuses to start when the database is behind head. Read the generated file before trusting it,
`make migrate` again.
**Without a database** — write the revision by hand, modelled on
`alembic/versions/7300d4656a8d_add_updated_at_to_reference_tasks.py`, and check it offline with
`uv run alembic -c alembic.ini heads` and `... upgrade head --sql`. Neither compares
`orm_models.py` against a real schema. `make test` does, for real: its db tier starts a PostgreSQL
of its own and runs `alembic upgrade head` and `alembic check` there.
4. Repository, then `tests/db/test_<name>_repository.py` — the db tier is the one place in
`make test` where your queries actually run; `tests/db/conftest.py` gives it `db_pool`, every
table emptied. A mock of the pool proves only what its author imagined. Judge a query by the
rows it returns, on data chosen so a wrong one shows: rows inserted in the reverse of the
expected order, a page one smaller than the matches, and for each filter a second row that
differs only in the filtered column — another parent row at the same moment included. With two
constraints on one table, the repository's own test names the one that refused
(`pytest.raises(ConflictError, match=...)`): a bare 409 passes whichever fired. Wrap each method in
`with logger.span("db.<name>.<op>", ...)` and put the outcome in `span.output` (a row count, a
found/not-found flag), which only survives the success path — a driver error translated into a
domain one is asserted on `span.error`, not on an output line before `raise`.
5. Application service and DTOs. A field check is written once, in the domain, and the service
calls it on every path that writes or filters. A filter value that does not parse answers 422 or
matches nothing — never drops the condition and returns every row. The DTO declares types only. A bound repeated in
the DTO is a second copy that drifts, and the verticals bench2 measured copied the one the sample
used to carry. If the vertical has an update, copy
`ReferenceTaskService.update_task` whole: the conditional write stops a concurrent patch erasing
another one, and the `UNCHANGED` sentinel lets a caller clear a nullable field instead of `None`
meaning both "absent" and "null" — see `docs/adr/ADR-007-autocommit-and-explicit-transactions.md`.
That token only protects the one row it is read from. A rule spanning several rows — one open
task per title, no overlapping bookings — goes into the database, never into a check before
the write and never under an `asyncio.Lock`: copy `uq_reference_tasks_open_title` (a partial
unique index, its migration, and `_open_title_taken_is_a_conflict` in the repository, around
the insert and the update both — a write path left out answers the conflict as a 500) and its
two-process test in `tests/db/test_reference_task_repository.py`. A rule no constraint can
name — a total across rows, such as a weekly ceiling — takes `FOR UPDATE` and a re-check on
every path that writes, update included: "Where the single-row token does not reach" in the same ADR.
6. **The endpoint and all three wiring files in the same step.** The endpoint's last import is the
typed alias defined in `dependencies.py`. `service_registration.py` and `router_registration.py`
belong here too — the next step's `TestWiring` builds the application from both.
7. Unit tests.
8. `make quality-gates`, then `make test-e2e`.
## Two branches, one migration history
Two branches that each add a migration off the same parent produce two files naming the same
`down_revision` — a fork, not a broken chain. `scripts/validate_migrations.py` reports it, files
alone, as `migrations.multiple_heads`; `uv run alembic -c alembic.ini heads` shows it directly: two
lines means a fork. Merging the branches does not resolve it, it only puts both files in one
directory. Two ways out: `uv run alembic -c alembic.ini merge heads -m describe_merge` writes a revision whose
`down_revision` is both heads and changes nothing written already — right once either revision may
have run against a database somewhere. Re-parenting (editing the newer file's `down_revision` to the
true head) keeps one straight chain and is safe only while that revision has run nowhere else.
A different failure looks like the first and is not: `alembic upgrade head` fails with `Can't locate
revision identified by <id>` while your own `alembic/versions/` forms one clean chain — the database
is stamped past your branch by another worktree of this repository. The gate reports this as
`migrations.foreign_revision`. **Do not recreate a database more than one checkout can reach** — it
drops the tables another branch migrated. Catch up instead, or give this worktree its own:
`make db-up-worktree` / `make db-down-worktree`.
## Two tests the vertical file must contain, beyond the rules
`test_<name>_vertical.py` covers what the service decides before writing, over a stub of the port
that stores nothing, and the wiring; what needs a write lives in `tests/db`. Two shapes matter
beyond the obvious happy path.
**1. The arguments arrive.** Not "the repository was called" — *with what*.
```python
async def test_the_write_is_conditioned_on_the_timestamp_it_read() -> None:
port = _Port(_STORED)
await _service(port).update_task(_STORED.id, status="done")
((written, condition),) = port.writes
assert condition == _STORED.updated_at < written.updated_at # not "update was called"
```
Passing the new timestamp as the condition, or not moving it, still stores the row and returns it;
only the arguments tell. With a mock, a bare `assert_awaited_once()` has the same blindness, and
`validate_test_quality.py` reports it as `test.call_assertion_without_arguments`; a deliberate
exception carries `# no-assert-ok: <reason>`.
**2. Both sides of every boundary.** One case exactly on the constant, one case a step past it.
```python
@pytest.mark.parametrize("title", ["", " \t\n", "x" * (MAX_TITLE_LENGTH + 1)])
async def test_create_refuses_a_blank_or_overlong_title_before_writing(title: str) -> None:
port = _Port()
with pytest.raises(ValidationError):
await _service(port).create_task(title=title)
assert port.writes == []
async def test_create_accepts_a_title_of_exactly_the_maximum_length() -> None:
task = await _service(_Port()).create_task(title="x" * MAX_TITLE_LENGTH)
assert len(task.title) == MAX_TITLE_LENGTH
```
A test built from a comfortable middle value — `"a title"` — cannot see `<` silently become `<=`, or
`>` become `>=`.
## The three constraints that are enforced, not advised
1. **The service key stays in the registry dict literal even when its value is `None`.**
`validate_endpoint_wiring.py` reads the literal to learn the service exists; dropping the key
makes the dependency alias unresolvable and it reports `endpoint.wiring_chain_broken`.
2. **Endpoints never import, construct or annotate a service class, and never call `Depends(...)`
without a typed alias** — they take the service through its typed dependency alias. DTOs,
constants and values such as the service's `UNCHANGED` sentinel may be imported.
Importing the service, calling `Depends(get_...)` inline, or annotating a parameter with the
service type each has its own rule ID in `validate_endpoint_wiring.py`. The alias lives in
`dependencies.py` as a getter returning `_get_service(request, "<key>", <Type>)` plus an
`Annotated[...]` assignment; the validator recognises that shape, and a nullable getter
reading `services.get("<key>")`, and no other.
3. **Application modules must not import `project.infrastructure`.**
`scripts/validate_architecture.py` fails the gate on it. This is what forces the service to take
the Protocol and what lets its unit tests run without a database.
## A vertical whose adapter is a language model
Same shape as the storage verticals: a Protocol in `project/domain/ports.py`, an adapter in
`project/infrastructure/agents/`, a service that has never heard of a message. `LLMPort` fits a
prompt-in-text-out vertical; a tool-loop vertical declares its own port. Mock mode walks every bound
tool once before summarising (`tests/application/test_mock_agent_multi_tool_loop.py`) but cannot
invent a tool's argument shape (`bound._mock_tool_args = {...}` after `bind_tools(...)`). Call each
tool through `run_tool(tool, args, shown=(...))` from `project/infrastructure/agents/tool_runner.py`:
it opens the `agent.tool.<name>` span the trace shows, and turns arguments that miss the schema
into a `ToolArgumentsError` safe to log and to hand back to the model (`docs/tracing.md`). Declare a
tool's arguments on `ToolArgs`, not `CoreModel`: CoreModel's validation aliases drop every field from
the schema the provider receives, and `bind_tools` refuses such a tool. Test the
answer parser, the `isinstance`-before-`in VALID_X` check (model output can be a list, not a string),
and the loop's bookkeeping by hand — the last needs `tests/support/scripted_llm.py` and an adapter
typed against a Protocol, as `prompt_llm_adapter.py` declares `SupportsMessageCall`. Mock mode
calls a tool whether or not the messages would let a live model choose one, so two scripted tests
do what it cannot — ADR-003: `received` shows the first call carried the ids and context the tools
need, and a reply of text with no tool call is not reported as a check that passed. A provider error
reaches your vertical translated: `ExternalServiceError` or `UpstreamAuthenticationError`, both 502.
See ADR-009. What neither mock nor script can settle is the turn ceiling a production loop needs:
measure that against a live provider, and make the loop say it hit one — in the trace and in the
answer it returns — because a loop that stops silently reads as a loop that finished.
## Subsystems that can be off
A vertical needing PostgreSQL must survive `POSTGRES_ENABLED=false` (`make ci-local` runs that leg
too): either **routes absent** — what the reference vertical does — or **routes present, answering
503** via `-> MyService | None`. See `docs/adr/ADR-006-optional-postgres.md`.
## Deleting the reference vertical
In a project, `make init-project` does this once the identity is replaced
(`.agents/skills/initialize-project`, step 5): it keeps a copy in `scaffold/` next to this file,
appends the drop migration below and stages the result. What follows is what it does, and the way
by hand when it refuses — a file of the vertical already edited, or a vertical of your own in place.
Every file is named `reference_task*`, so removal is mechanical:
```bash
git rm project/domain/reference_task.py \
project/application/reference_task_service.py \
project/application/reference_task_dtos.py \
project/infrastructure/persistence/reference_task_repository.py \
project/infrastructure/api/endpoints/reference_tasks.py \
tests/application/test_reference_task_vertical.py \
tests/db/test_reference_task_repository.py \
tests/db/test_reference_tasks_api.py \
tests/functional/src/test_reference_tasks_api.py \
docs/mutations/reference_task.json \
scripts/run_mutations.py
```
The first ten are every file whose **name** carries the vertical; `scripts/run_mutations.py` is
the template's measuring stick for the reference tests and has nothing to measure once they go. Do not use
`find . -iname '*reference_task*'` as the completeness check — the references that break the build
live in files named after something else.
`alembic/versions/001_initial_reference_tasks_schema.py`,
`alembic/versions/7300d4656a8d_add_updated_at_to_reference_tasks.py` and
`alembic/versions/b5e2c1a9d4f0_one_open_reference_task_per_title.py` stay — all three have run on
every database created from this template. **Append** a drop migration (autogenerate it once the ORM
model is gone) rather than folding it into `001` or deleting `001` — your own first migration names
`001` as its parent.
These files carry the vertical too, without its name in theirs:
| File | What to remove |
|------|----------------|
| `project/core/service_registration.py` | `ReferenceTaskService` and repository imports, the builder's body and its registry entry |
| `project/infrastructure/api/dependencies.py` | getter and `Annotated` alias |
| `project/infrastructure/api/router_registration.py` | import and `include_router` call |
| `project/domain/ports.py` | `ReferenceTaskRepositoryPort` and its `ReferenceTask` import |
| `project/infrastructure/persistence/orm_models.py` | `ReferenceTaskORM` and its `MAX_TITLE_LENGTH` import |
| `project/infrastructure/persistence/__init__.py` | the worked-example sentence in the docstring |
Then the prose: `README.md`, `docs/agent_rules.md`, `PROJECT.md`, `docs/project_context.json`, the
docstring example in `tests/conftest.py::registered_paths`, the span-name example in
`project/core/logging/logger.py` (`db.reference_task.add`), and this file.
**Not `CLAUDE.md`/`AGENTS.md`** — the pre-edit hook refuses the write; both are generated from
`docs/agent_rules.md` by `scripts/sync_agent_docs.py`. Edit the bullet in `docs/agent_rules.md`'s
`Start here:` list and run `make refresh-agent-docs`. Keep its file count spelled the same way as
the `## The … files of a vertical` heading above — `tests/template/test_sync_agent_docs.py`
compares the two. And update `minimal_read_set` in this file's own front matter — it names
`project/application/reference_task_service.py`, and once that is gone
`scripts/validate_repository_metadata.py` reports `skills_frontmatter.broken_path`.
Finish with `make quality-gates && make test-e2e`, then sweep. Not
with `git grep -n reference_task` — case-sensitive and underscore-only, it misses `ReferenceTaskORM`
and every `/reference-tasks/` path. Use:
```bash
git grep -inE 'reference[-_]?task'
```
The sweep returns every survivor named below, plus three categories it does not spell out
file-by-file: **ADR documents** (`docs/adr/*.md` — the reasoning stays with the decision),
**migration history** (`alembic/versions/*` — append-only, see above), and **`CLAUDE.md` /
`AGENTS.md`** (both clear once `docs/agent_rules.md` is edited and `make refresh-agent-docs` runs).
`TestDeletionAccountsForEveryMatch` in `tests/template/test_skill_texts_match_reality.py` runs
the same sweep against this checkout and fails the day a file outside those three categories carries
the vertical without appearing below — trust that test, and the table it checks, over this prose.
| Survivor | Why it stays |
|---|---|
| `alembic/versions/001_initial_reference_tasks_schema.py` | history every database already ran |
| `alembic/versions/7300d4656a8d_add_updated_at_to_reference_tasks.py` | history; the update endpoint's optimistic-lock column |
| `alembic/versions/b5e2c1a9d4f0_one_open_reference_task_per_title.py` | history; the open-title rule's partial unique index |
| the drop migration you just appended | names what it drops |
| `.agents/skills/add-vertical/SKILL.md` | this file has to name what it deletes |
| `tests/template/test_skill_texts_match_reality.py` | pins the sweep spelling and runs this accounting check |
| `tests/template/test_validate_architecture.py` | a synthetic source line containing `ReferenceTaskORM` |
| `tests/application/test_trace_formatter_against_real_output.py` | mentions inside recorded log fixtures |
| `tests/application/test_logging_api.py` | the span name `db.reference_task.get` in logging fixtures |
| `scripts/validate_test_quality.py` | one query in a comment, illustrating a rule |
| `tests/template/test_sync_agent_docs.py` | asserts the generated Quick Start does **not** name the vertical |
| `validation_support/dynamic_imports.py` | a comment about `project/domain/reference_task.py`'s line count |
| `tests/application/test_client_errors_are_not_service_errors.py` | a fixture `POST /reference-tasks` |
| `tests/application/test_functional_request_helpers.py` | a fixture `GET /reference-tasks/<uuid>` |
| `tests/template/test_gate_recipes.py` | a synthetic `SELECT ... FROM reference_tasks` string |
| `tests/application/test_trace_formatter_failure_visibility.py` | a fixture span name and path in recorded NDJSON |
| `scripts/extract_reference_vertical.py` | the script that takes the vertical out; it removes itself |
| `scripts/check_product_from_template.py` | the template's check of a project made from it; removed with the vertical |
| `tests/template/test_extract_reference_vertical.py` | tests the extraction; `tests/template` goes with the vertical |
Every one of those is fixture text about a vertical, or a document that names the thing it removes —
not a use of the vertical. A file outside the table and the three categories means the prose step
above is unfinished — point it at your own vertical and `make refresh-agent-docs` if it was
`docs/agent_rules.md`.