diff --git a/.claude/agents/deep-reasoner.md b/.claude/agents/deep-reasoner.md
new file mode 100644
index 0000000..c6c61a4
--- /dev/null
+++ b/.claude/agents/deep-reasoner.md
@@ -0,0 +1,36 @@
+---
+name: deep-reasoner
+description: >
+ Use for reasoning-heavy phases: architecture, debugging complex issues,
+ algorithm design, tricky trade-offs. Think thoroughly, return a concise
+ conclusion the orchestrator can act on.
+model: opus
+effort: high
+color: purple
+---
+
+# Deep Reasoner
+
+You are the orchestrator's reasoning specialist. You are handed the hard
+thinking: architecture decisions, complex debugging, algorithm design,
+non-obvious trade-offs.
+
+## Operating stance
+
+- Think thoroughly before answering. Explore the problem, consider
+ alternatives, check your reasoning for holes.
+- Do the deep work internally. What you return to the orchestrator is a
+ **concise conclusion it can act on** — not a transcript of your thinking.
+- State assumptions explicitly. If a decision hinges on something you
+ couldn't verify, say so and give your best recommendation anyway.
+- When there are real trade-offs, name them briefly, then commit to a
+ recommendation. The orchestrator wants a decision, not a menu.
+
+## Output
+
+Lead with the conclusion. Then, only if it adds value:
+- the key reasoning in a few bullets,
+- risks or open questions the orchestrator should know about,
+- concrete next steps.
+
+Keep it tight. Your value is the quality of the conclusion, not its length.
diff --git a/.claude/agents/fast-worker.md b/.claude/agents/fast-worker.md
new file mode 100644
index 0000000..22caccb
--- /dev/null
+++ b/.claude/agents/fast-worker.md
@@ -0,0 +1,31 @@
+---
+name: fast-worker
+description: >
+ Use for mechanical tasks: boilerplate, tests, formatting, simple edits,
+ batch and file operations, running checks. Execute efficiently and
+ return only the result.
+model: sonnet
+color: green
+---
+
+# Fast Worker
+
+You are the orchestrator's execution specialist. You are handed mechanical,
+well-scoped work: boilerplate, tests, formatting, simple edits, batch and
+file operations, running lint/checks.
+
+## Operating stance
+
+- Execute efficiently. The task is usually clear — do it, don't re-plan it.
+- Follow existing conventions in the codebase: match surrounding style,
+ naming, and structure. Read a neighbouring file if unsure.
+- If the task is genuinely ambiguous or you hit a real blocker, stop and
+ report back with the specific question — don't guess on decisions that
+ belong to the orchestrator.
+- Read noisy input/output yourself and return only the distilled result,
+ so the orchestrator's context stays clean.
+
+## Output
+
+Return just what the orchestrator needs: what you did, and any result it
+must see (a summary, a diff of note, a failing check). Skip narration.
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..0a2241f
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,7 @@
+COMPOSE_PROJECT_NAME=clickstream-data-platform
+CLICKHOUSE_IMAGE=clickhouse/clickhouse-server:26.3.17.56
+CLICKHOUSE_01_HTTP_PORT=28123
+CLICKHOUSE_01_TCP_PORT=29000
+CLICKHOUSE_02_HTTP_PORT=28124
+CLICKHOUSE_02_TCP_PORT=29001
+
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..2593eb6
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,24 @@
+COMPOSE := docker compose
+
+.PHONY: up down clean ps logs smoke smoke-guards
+
+up:
+ $(COMPOSE) up --detach --wait --wait-timeout 180
+
+down:
+ $(COMPOSE) down --remove-orphans
+
+clean:
+ $(COMPOSE) down --volumes --remove-orphans
+
+ps:
+ $(COMPOSE) ps
+
+logs:
+ $(COMPOSE) logs --follow
+
+smoke:
+ ./scripts/clickhouse-smoke.sh
+
+smoke-guards:
+ ./tests/smoke-guards.sh
diff --git a/README.md b/README.md
index e833cc9..478277f 100644
--- a/README.md
+++ b/README.md
@@ -8,9 +8,64 @@ clickhouse-keeper), Airflow, Superset. Преемник учебного сте
Репозиторий строится по спеке
[«Боевой реализм стенда (v2)»](docs/specs/2026-07-30-stand-v2-realism.md).
-Работающего стенда пока нет: здесь только спека, исследование формата данных и
-контракт работы ([AGENTS.md](AGENTS.md)). Быстрый старт появится вместе с
-этапом «Каркас стенда».
+Сейчас готов первый элемент каркаса: кластер ClickHouse из двух шардов и
+отдельного clickhouse-keeper.
+
+## Быстрый старт
+
+Нужны Docker с Compose и свободные порты `28123`, `28124`, `29000`, `29001`.
+Настройки портов и образа лежат в `.env` и не попадают в git:
+
+```bash
+cp .env.example .env
+make up
+make smoke
+```
+
+`make up` поднимает единственный режим стенда: два шарда ClickHouse по одной
+реплике и отдельный keeper. Команда ждёт здорового состояния всех трёх
+контейнеров. Нода `clickhouse-01` служит инициатором DDL и позже будет точкой
+подключения Airflow. К `clickhouse-02` позже подключится Superset.
+
+`make smoke` одной командой проверяет описание кластера, макросы нод, связь с
+keeper, создание `ReplicatedMergeTree` и `Distributed` через `ON CLUSTER`,
+путь в keeper из макроса, вставку через первую ноду и чтение со второй,
+очередь распределённых DDL и удаление временных таблиц. Скрипт печатает каждый
+шаг и зелёный результат по-русски.
+
+`make smoke-guards` проверяет саму smoke-команду: аварийную очистку, обработку
+прерывания, подсказку о незапущенном стенде и окружение keeper.
+
+Остановить контейнеры можно командой `make down`. Для холодного старта с
+удалением данных используйте `make clean`.
+
+HTTP-интерфейсы доступны только с локальной машины:
+
+- нода 1 — `http://127.0.0.1:28123`;
+- нода 2 — `http://127.0.0.1:28124`.
+
+У локального учебного кластера нет пароля: ноды используют общего пользователя
+`default` для запросов `Distributed`. Порты поэтому привязаны к `127.0.0.1` и
+не открыты во внешнюю сеть.
+
+В бою перед репликами ClickHouse обычно был бы балансировщик. Здесь в каждом
+шарде одна реплика, поэтому балансировать нечего. Балансировщик и топология
+2×2 намеренно не входят в стенд.
+
+Конфигурация сверена 30 июля 2026 года с официальной документацией ClickHouse:
+[настройками сервера](https://clickhouse.com/docs/operations/server-configuration-parameters/settings),
+[Keeper](https://clickhouse.com/docs/guides/oss/deployment-and-scaling/keeper/),
+[ReplicatedMergeTree](https://clickhouse.com/docs/engines/table-engines/mergetree-family/replication),
+[ON CLUSTER](https://clickhouse.com/docs/sql-reference/distributed-ddl) и
+[Distributed](https://clickhouse.com/docs/engines/table-engines/special/distributed).
+Описание кластера задаётся через `remote_servers`, макросы — через `macros`,
+подключение к keeper — через `zookeeper`; путь `ReplicatedMergeTree` содержит
+`{shard}` и `{replica}`, а `Distributed` получает имя кластера, базу, локальную
+таблицу и ключ шардирования. Макросы выбраны, чтобы один DDL через `ON CLUSTER`
+создавал отдельный путь каждого шарда без вписанных вручную значений. Для
+образа зафиксирован точный текущий
+[LTS-выпуск 26.3.17.56](https://github.com/ClickHouse/ClickHouse/releases/tag/v26.3.17.56-lts);
+серверы и keeper используют один образ.
## Что здесь будет
diff --git a/compose.yaml b/compose.yaml
new file mode 100644
index 0000000..64e711c
--- /dev/null
+++ b/compose.yaml
@@ -0,0 +1,72 @@
+name: ${COMPOSE_PROJECT_NAME:-clickstream-data-platform}
+
+x-clickhouse-nofile: &clickhouse-nofile
+ nofile:
+ soft: 262144
+ hard: 262144
+
+x-clickhouse-common: &clickhouse-common
+ image: ${CLICKHOUSE_IMAGE:-clickhouse/clickhouse-server:26.3.17.56}
+ restart: unless-stopped
+ environment:
+ # Стенд локальный: нодам нужен общий пользователь без пароля для Distributed.
+ CLICKHOUSE_SKIP_USER_SETUP: "1"
+ depends_on:
+ clickhouse-keeper:
+ condition: service_healthy
+ ulimits: *clickhouse-nofile
+ mem_limit: 1g
+ healthcheck:
+ test: ["CMD-SHELL", "clickhouse-client --host 127.0.0.1 --query 'SELECT 1' >/dev/null 2>&1"]
+ interval: 5s
+ timeout: 3s
+ retries: 30
+ start_period: 10s
+
+services:
+ clickhouse-keeper:
+ image: ${CLICKHOUSE_IMAGE:-clickhouse/clickhouse-server:26.3.17.56}
+ restart: unless-stopped
+ user: clickhouse
+ ulimits: *clickhouse-nofile
+ entrypoint:
+ - clickhouse
+ - keeper
+ - --config-file=/etc/clickhouse-keeper/keeper_config.xml
+ volumes:
+ - ./infra/clickhouse/keeper/keeper_config.xml:/etc/clickhouse-keeper/keeper_config.xml:ro
+ - clickhouse_keeper_data:/var/lib/clickhouse
+ mem_limit: 512m
+ healthcheck:
+ test: ["CMD-SHELL", "clickhouse keeper-client --host 127.0.0.1 --port 9181 --query ruok | grep -q imok"]
+ interval: 5s
+ timeout: 3s
+ retries: 30
+ start_period: 10s
+
+ # Инициатор DDL и будущее подключение Airflow.
+ clickhouse-01:
+ <<: *clickhouse-common
+ ports:
+ - "127.0.0.1:${CLICKHOUSE_01_HTTP_PORT:-28123}:8123"
+ - "127.0.0.1:${CLICKHOUSE_01_TCP_PORT:-29000}:9000"
+ volumes:
+ - ./infra/clickhouse/config.d/cluster.xml:/etc/clickhouse-server/config.d/cluster.xml:ro
+ - ./infra/clickhouse/config.d/macros-01.xml:/etc/clickhouse-server/config.d/macros.xml:ro
+ - clickhouse_01_data:/var/lib/clickhouse
+
+ # Будущее подключение Superset.
+ clickhouse-02:
+ <<: *clickhouse-common
+ ports:
+ - "127.0.0.1:${CLICKHOUSE_02_HTTP_PORT:-28124}:8123"
+ - "127.0.0.1:${CLICKHOUSE_02_TCP_PORT:-29001}:9000"
+ volumes:
+ - ./infra/clickhouse/config.d/cluster.xml:/etc/clickhouse-server/config.d/cluster.xml:ro
+ - ./infra/clickhouse/config.d/macros-02.xml:/etc/clickhouse-server/config.d/macros.xml:ro
+ - clickhouse_02_data:/var/lib/clickhouse
+
+volumes:
+ clickhouse_keeper_data:
+ clickhouse_01_data:
+ clickhouse_02_data:
diff --git a/infra/clickhouse/config.d/cluster.xml b/infra/clickhouse/config.d/cluster.xml
new file mode 100644
index 0000000..3c32c5f
--- /dev/null
+++ b/infra/clickhouse/config.d/cluster.xml
@@ -0,0 +1,36 @@
+
+
+
+
+
+ true
+
+ clickhouse-01
+ 9000
+
+
+
+ true
+
+ clickhouse-02
+ 9000
+
+
+
+
+
+
+
+ clickhouse-keeper
+ 9181
+
+
+
+
+ /clickhouse/task_queue/ddl
+
+
+
diff --git a/infra/clickhouse/config.d/macros-01.xml b/infra/clickhouse/config.d/macros-01.xml
new file mode 100644
index 0000000..6c524d7
--- /dev/null
+++ b/infra/clickhouse/config.d/macros-01.xml
@@ -0,0 +1,7 @@
+
+
+ 01
+ clickhouse-01
+
+
+
diff --git a/infra/clickhouse/config.d/macros-02.xml b/infra/clickhouse/config.d/macros-02.xml
new file mode 100644
index 0000000..2197442
--- /dev/null
+++ b/infra/clickhouse/config.d/macros-02.xml
@@ -0,0 +1,7 @@
+
+
+ 02
+ clickhouse-02
+
+
+
diff --git a/infra/clickhouse/keeper/keeper_config.xml b/infra/clickhouse/keeper/keeper_config.xml
new file mode 100644
index 0000000..50c8df1
--- /dev/null
+++ b/infra/clickhouse/keeper/keeper_config.xml
@@ -0,0 +1,27 @@
+
+
+
+ information
+ true
+
+ 0.0.0.0
+
+
+ 9181
+ 1
+ /var/lib/clickhouse/coordination/log
+ /var/lib/clickhouse/coordination/snapshots
+
+ 10000
+ 30000
+
+
+
+ 1
+ clickhouse-keeper
+ 9234
+
+
+
+
+
diff --git a/scripts/clickhouse-smoke.sh b/scripts/clickhouse-smoke.sh
new file mode 100755
index 0000000..3de982b
--- /dev/null
+++ b/scripts/clickhouse-smoke.sh
@@ -0,0 +1,178 @@
+#!/usr/bin/env bash
+set -Eeuo pipefail
+
+readonly ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+readonly CLUSTER="clickstream_cluster"
+readonly LOCAL_TABLE="smoke_replicated_local"
+readonly DISTRIBUTED_TABLE="smoke_distributed"
+
+compose() {
+ docker compose --project-directory "$ROOT_DIR" "$@"
+}
+
+query() {
+ local service="$1"
+ local sql="$2"
+ compose exec -T "$service" clickhouse-client --query "$sql"
+}
+
+query_with_timeout() {
+ local timeout_seconds="$1"
+ local service="$2"
+ local sql="$3"
+ timeout --foreground "${timeout_seconds}s" \
+ docker compose --project-directory "$ROOT_DIR" exec -T "$service" \
+ clickhouse-client --query "$sql"
+}
+
+fail() {
+ printf 'ОШИБКА: %s\n' "$1" >&2
+ exit 1
+}
+
+assert_equal() {
+ local expected="$1"
+ local actual="$2"
+ local message="$3"
+ [[ "$actual" == "$expected" ]] || fail "$message: ожидалось «$expected», получено «$actual»"
+}
+
+ensure_stand_running() {
+ local running_services
+ local service
+ running_services="$(compose ps --status running --services 2>/dev/null || true)"
+ for service in clickhouse-keeper clickhouse-01 clickhouse-02; do
+ if ! grep -qx "$service" <<<"$running_services"; then
+ fail "стенд запущен не полностью; выполните make up и дождитесь здорового состояния контейнеров"
+ fi
+ done
+}
+
+cleanup_tables() {
+ local cleanup_failed=0
+ query_with_timeout 5 clickhouse-01 "DROP TABLE IF EXISTS default.${DISTRIBUTED_TABLE} ON CLUSTER ${CLUSTER} SYNC" >/dev/null 2>&1 || cleanup_failed=1
+ query_with_timeout 5 clickhouse-01 "DROP TABLE IF EXISTS default.${LOCAL_TABLE} ON CLUSTER ${CLUSTER} SYNC" >/dev/null 2>&1 || cleanup_failed=1
+ return "$cleanup_failed"
+}
+
+on_exit() {
+ local status=$?
+ trap - EXIT
+ if [[ "$status" -ne 0 ]]; then
+ printf 'Сбой проверки: выполняется быстрая очистка временных таблиц...\n' >&2
+ fi
+ cleanup_tables || printf 'ПРЕДУПРЕЖДЕНИЕ: очистка не завершена; после восстановления стенда повторите make smoke.\n' >&2
+ exit "$status"
+}
+
+on_signal() {
+ trap - EXIT INT TERM
+ printf 'Проверка прервана пользователем: выполняется быстрая очистка временных таблиц...\n' >&2
+ cleanup_tables || printf 'ПРЕДУПРЕЖДЕНИЕ: очистка не завершена; после восстановления стенда повторите make smoke.\n' >&2
+ exit 130
+}
+
+assert_ddl_queue_completed() {
+ local service="$1"
+ local phase="$2"
+ local queue_state
+ local total
+ local unfinished
+ queue_state="$(query "$service" "SELECT count(), countIf(status NOT IN ('Finished', 'Removing')) FROM system.distributed_ddl_queue FORMAT TSV")"
+ total="${queue_state%%$'\t'*}"
+ unfinished="${queue_state##*$'\t'}"
+ [[ "$total" -gt 0 ]] || fail "на ${service} очередь DDL пуста ${phase}"
+ assert_equal "0" "$unfinished" "на ${service} есть незавершённые DDL ${phase}"
+}
+
+ensure_stand_running
+trap on_exit EXIT
+trap on_signal INT TERM
+
+printf 'Проверка 1/8: описание кластера одинаково на обеих нодах...\n'
+cluster_sql="SELECT cluster, shard_num, replica_num, host_name, port FROM system.clusters WHERE cluster = '${CLUSTER}' ORDER BY shard_num, replica_num FORMAT TSV"
+cluster_01="$(query clickhouse-01 "$cluster_sql")"
+cluster_02="$(query clickhouse-02 "$cluster_sql")"
+expected_cluster=$'clickstream_cluster\t1\t1\tclickhouse-01\t9000\nclickstream_cluster\t2\t1\tclickhouse-02\t9000'
+assert_equal "$expected_cluster" "$cluster_01" "неверная топология на первой ноде"
+assert_equal "$expected_cluster" "$cluster_02" "неверная топология на второй ноде"
+printf 'ЗЕЛЁНО: обе ноды видят ожидаемые два шарда: clickhouse-01 и clickhouse-02.\n'
+
+printf 'Проверка 2/8: у нод разные макросы shard и replica...\n'
+macros_sql="SELECT macro, substitution FROM system.macros WHERE macro IN ('shard', 'replica') ORDER BY macro FORMAT TSV"
+macros_01="$(query clickhouse-01 "$macros_sql")"
+macros_02="$(query clickhouse-02 "$macros_sql")"
+assert_equal $'replica\tclickhouse-01\nshard\t01' "$macros_01" "неверные макросы первой ноды"
+assert_equal $'replica\tclickhouse-02\nshard\t02' "$macros_02" "неверные макросы второй ноды"
+[[ "$macros_01" != "$macros_02" ]] || fail "макросы нод не должны совпадать"
+printf 'ЗЕЛЁНО: clickhouse-01=(shard 01, replica clickhouse-01), clickhouse-02=(shard 02, replica clickhouse-02).\n'
+
+printf 'Проверка 3/8: keeper отвечает обеим нодам...\n'
+query clickhouse-01 "SELECT name FROM system.zookeeper WHERE path = '/' ORDER BY name FORMAT Null"
+query clickhouse-02 "SELECT name FROM system.zookeeper WHERE path = '/' ORDER BY name FORMAT Null"
+printf 'ЗЕЛЁНО: system.zookeeper доступна с обеих нод.\n'
+
+cleanup_tables || fail "не удалось очистить объекты предыдущего запуска"
+
+printf 'Проверка 4/8: ReplicatedMergeTree создаётся через ON CLUSTER...\n'
+query clickhouse-01 "
+ CREATE TABLE default.${LOCAL_TABLE} ON CLUSTER ${CLUSTER}
+ (
+ ClientID UInt64,
+ value String
+ )
+ ENGINE = ReplicatedMergeTree(
+ '/clickhouse/tables/{shard}/${LOCAL_TABLE}',
+ '{replica}'
+ )
+ ORDER BY ClientID
+" >/dev/null
+tables_sql="SELECT name, engine FROM system.tables WHERE database = 'default' AND name = '${LOCAL_TABLE}' FORMAT TSV"
+assert_equal $'smoke_replicated_local\tReplicatedMergeTree' "$(query clickhouse-01 "$tables_sql")" "локальная таблица не создана на первой ноде"
+assert_equal $'smoke_replicated_local\tReplicatedMergeTree' "$(query clickhouse-02 "$tables_sql")" "локальная таблица не создана на второй ноде"
+printf 'ЗЕЛЁНО: ReplicatedMergeTree видна в system.tables на обеих нодах.\n'
+
+printf 'Проверка 5/8: путь в keeper собран из макроса shard...\n'
+path_sql="SELECT zookeeper_path, replica_name FROM system.replicas WHERE database = 'default' AND table = '${LOCAL_TABLE}' FORMAT TSV"
+assert_equal "/clickhouse/tables/01/${LOCAL_TABLE}"$'\t'"clickhouse-01" "$(query clickhouse-01 "$path_sql")" "неверные путь или имя реплики на первой ноде"
+assert_equal "/clickhouse/tables/02/${LOCAL_TABLE}"$'\t'"clickhouse-02" "$(query clickhouse-02 "$path_sql")" "неверные путь или имя реплики на второй ноде"
+printf 'ЗЕЛЁНО: пути собраны из shard (/01/ и /02/), имя реплики собрано из макроса replica.\n'
+
+printf 'Проверка 6/8: Distributed создаётся ON CLUSTER и передаёт данные между нодами...\n'
+query clickhouse-01 "
+ CREATE TABLE default.${DISTRIBUTED_TABLE} ON CLUSTER ${CLUSTER}
+ AS default.${LOCAL_TABLE}
+ ENGINE = Distributed(
+ '${CLUSTER}',
+ 'default',
+ '${LOCAL_TABLE}',
+ cityHash64(ClientID)
+ )
+" >/dev/null
+distributed_sql="SELECT engine FROM system.tables WHERE database = 'default' AND name = '${DISTRIBUTED_TABLE}' FORMAT TSVRaw"
+assert_equal "Distributed" "$(query clickhouse-01 "$distributed_sql")" "Distributed-таблица не создана на первой ноде"
+assert_equal "Distributed" "$(query clickhouse-02 "$distributed_sql")" "Distributed-таблица не создана на второй ноде"
+query clickhouse-01 "INSERT INTO default.${LOCAL_TABLE} VALUES (42, 'из первой ноды')"
+read_back="$(query clickhouse-02 "SELECT ClientID, value FROM default.${DISTRIBUTED_TABLE} WHERE ClientID = 42 FORMAT TSV")"
+assert_equal $'42\tиз первой ноды' "$read_back" "вторая нода не прочитала вставленную строку"
+query clickhouse-01 "INSERT INTO default.${DISTRIBUTED_TABLE} SETTINGS distributed_foreground_insert = 1 SELECT number + 1000, 'через Distributed' FROM numbers(16)"
+sharding_sql="SELECT countIf(_shard_num != cityHash64(ClientID) % 2 + 1), uniqExact(_shard_num) FROM default.${DISTRIBUTED_TABLE} WHERE value = 'через Distributed' FORMAT TSV"
+assert_equal $'0\t2' "$(query clickhouse-02 "$sharding_sql")" "Distributed использует неверный ключ шардирования"
+printf 'ЗЕЛЁНО: локальная строка первой ноды читается со второй; ключ cityHash64(ClientID) разложил строки по двум шардам.\n'
+
+printf 'Проверка 7/8: в очереди распределённых DDL нет незавершённых заданий...\n'
+assert_ddl_queue_completed clickhouse-01 'после CREATE'
+assert_ddl_queue_completed clickhouse-02 'после CREATE'
+printf 'ЗЕЛЁНО: очередь содержит задания CREATE, незавершённых среди них нет.\n'
+
+printf 'Проверка 8/8: временные таблицы удаляются через ON CLUSTER...\n'
+query clickhouse-01 "DROP TABLE default.${DISTRIBUTED_TABLE} ON CLUSTER ${CLUSTER} SYNC" >/dev/null
+query clickhouse-01 "DROP TABLE default.${LOCAL_TABLE} ON CLUSTER ${CLUSTER} SYNC" >/dev/null
+remaining_sql="SELECT count() FROM system.tables WHERE database = 'default' AND name IN ('${LOCAL_TABLE}', '${DISTRIBUTED_TABLE}') FORMAT TSVRaw"
+assert_equal "0" "$(query clickhouse-01 "$remaining_sql")" "временные таблицы остались на первой ноде"
+assert_equal "0" "$(query clickhouse-02 "$remaining_sql")" "временные таблицы остались на второй ноде"
+assert_ddl_queue_completed clickhouse-01 'после DROP'
+assert_ddl_queue_completed clickhouse-02 'после DROP'
+trap - EXIT INT TERM
+printf 'ЗЕЛЁНО: временные таблицы удалены; проверены завершённые задания CREATE и DROP.\n'
+printf 'ИТОГ: все 8 проверок кластера ClickHouse прошли.\n'
diff --git a/tests/smoke-guards.sh b/tests/smoke-guards.sh
new file mode 100755
index 0000000..17e1a2b
--- /dev/null
+++ b/tests/smoke-guards.sh
@@ -0,0 +1,139 @@
+#!/usr/bin/env bash
+set -uo pipefail
+
+readonly ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+readonly LOCAL_TABLE="smoke_replicated_local"
+readonly DISTRIBUTED_TABLE="smoke_distributed"
+passed=0
+failed=0
+
+compose() {
+ docker compose --project-directory "$ROOT_DIR" "$@"
+}
+
+query() {
+ local service="$1"
+ local sql="$2"
+ compose exec -T "$service" clickhouse-client --query "$sql"
+}
+
+pass() {
+ passed=$((passed + 1))
+ printf 'ЗЕЛЁНО: %s.\n' "$1"
+}
+
+fail() {
+ failed=$((failed + 1))
+ printf 'ОШИБКА: %s.\n' "$1" >&2
+}
+
+wait_for_local_table() {
+ local attempt
+ for attempt in $(seq 1 100); do
+ if [[ "$(query clickhouse-01 "SELECT count() FROM system.tables WHERE database = 'default' AND name = '${LOCAL_TABLE}' FORMAT TSVRaw" 2>/dev/null || true)" == '1' ]]; then
+ return 0
+ fi
+ sleep 0.05
+ done
+ return 1
+}
+
+restore_stand() {
+ make --no-print-directory -C "$ROOT_DIR" up >/dev/null
+ query clickhouse-01 "DROP TABLE IF EXISTS default.${DISTRIBUTED_TABLE} ON CLUSTER clickstream_cluster SYNC" >/dev/null 2>&1 || true
+ query clickhouse-01 "DROP TABLE IF EXISTS default.${LOCAL_TABLE} ON CLUSTER clickstream_cluster SYNC" >/dev/null 2>&1 || true
+}
+
+check_failure_guards() {
+ local failure_log
+ local failure_pid
+ local failure_status
+ local failure_elapsed
+ local interrupt_log
+ local interrupt_pid
+ local interrupt_status
+ local remaining_tables
+ local started_at
+ local bounded_failure_ok=0
+ local interrupt_ok=0
+ interrupt_status=999
+ remaining_tables='не проверено'
+
+ failure_log="$(mktemp)"
+ started_at="$(date +%s)"
+ setsid make --no-print-directory -C "$ROOT_DIR" smoke >"$failure_log" 2>&1 &
+ failure_pid=$!
+ if wait_for_local_table; then
+ compose kill clickhouse-02 >/dev/null
+ wait "$failure_pid"
+ failure_status=$?
+ failure_elapsed=$(( $(date +%s) - started_at ))
+ if [[ "$failure_status" -eq 2 ]] && [[ "$failure_elapsed" -lt 20 ]]; then
+ bounded_failure_ok=1
+ fi
+ else
+ kill -TERM -- "-$failure_pid" >/dev/null 2>&1 || true
+ wait "$failure_pid" >/dev/null 2>&1 || true
+ fi
+ rm -f "$failure_log"
+
+ restore_stand
+
+ interrupt_log="$(mktemp)"
+ setsid env --default-signal=INT,TERM "$ROOT_DIR/scripts/clickhouse-smoke.sh" >"$interrupt_log" 2>&1 &
+ interrupt_pid=$!
+ if wait_for_local_table; then
+ kill -INT -- "-$interrupt_pid"
+ wait "$interrupt_pid"
+ interrupt_status=$?
+ remaining_tables="$(query clickhouse-01 "SELECT count() FROM clusterAllReplicas('clickstream_cluster', system.tables) WHERE database = 'default' AND name IN ('${LOCAL_TABLE}', '${DISTRIBUTED_TABLE}') FORMAT TSVRaw")"
+ if [[ "$interrupt_status" -eq 130 ]] && [[ "$remaining_tables" == '0' ]]; then
+ interrupt_ok=1
+ fi
+ else
+ kill -TERM -- "-$interrupt_pid" >/dev/null 2>&1 || true
+ wait "$interrupt_pid" >/dev/null 2>&1 || true
+ fi
+ rm -f "$interrupt_log"
+
+ if [[ "$bounded_failure_ok" -eq 1 ]] && [[ "$interrupt_ok" -eq 1 ]]; then
+ pass 'аварийная очистка ограничена по времени, SIGINT возвращает 130 и удаляет временные таблицы'
+ else
+ fail "нарушена аварийная семантика smoke: bounded=${bounded_failure_ok}, interrupt=${interrupt_ok}, status=${interrupt_status}, tables=${remaining_tables}"
+ fi
+}
+
+check_keeper_runtime() {
+ local keeper_user
+ local keeper_nofile
+ local keeper_owner
+ keeper_user="$(compose exec -T clickhouse-keeper id -un)"
+ keeper_nofile="$(compose exec -T clickhouse-keeper awk '$1 == "Max" && $2 == "open" && $3 == "files" {print $4}' /proc/1/limits)"
+ keeper_owner="$(compose exec -T clickhouse-keeper stat -c '%U:%G' /var/lib/clickhouse/coordination)"
+ if [[ "$keeper_user" == 'clickhouse' ]] && [[ "$keeper_nofile" -ge 262144 ]] && [[ "$keeper_owner" == 'clickhouse:clickhouse' ]]; then
+ pass 'keeper работает от clickhouse с nofile 262144 и своим каталогом данных'
+ else
+ fail "неверное окружение keeper: user=${keeper_user}, nofile=${keeper_nofile}, owner=${keeper_owner}"
+ fi
+}
+
+check_preflight_hint() {
+ local output
+ local status
+ compose kill clickhouse-02 >/dev/null
+ output="$("$ROOT_DIR/scripts/clickhouse-smoke.sh" 2>&1)"
+ status=$?
+ if [[ "$status" -ne 0 ]] && grep -q 'выполните make up' <<<"$output"; then
+ pass 'неполный стенд получает русскую подсказку выполнить make up'
+ else
+ fail 'нет русской подсказки для незапущенного стенда'
+ fi
+ make --no-print-directory -C "$ROOT_DIR" up >/dev/null
+}
+
+check_failure_guards
+check_keeper_runtime
+check_preflight_hint
+
+printf 'ИТОГ: пройдено %d, ошибок %d\n' "$passed" "$failed"
+[[ "$failed" -eq 0 ]]