diff --git a/docs/clustering.md b/docs/clustering.md
index ed55d48481fe9e32ea36cb40707c243c6ee1dbad..eabe14cab772db5e501cd597741d34deb53ca125 100644
--- a/docs/clustering.md
+++ b/docs/clustering.md
@@ -56,7 +56,7 @@ picodata run --instance-id iN --listen iN --peer i1
 
 Логика функции `postjoin()` одинакова для всех инстансов. К этому моменту для инстанса уже инициализированы корректные пространства хранения в БД и могут быть накоплены записи в журнале Raft. Инстанс инициализирует узел Raft и проверяет, что данные синхронизированы (`read barrier` получен) и журнал Raft актуален. Из журнала становятся известны параметры репликации, и инстанс начинает синхронизацию данных уровне репликационных групп Tarantool.
 
-Следующим шагом инстансу необходимо актуализировать свой статус (`UpdatePeerRequest{ grade: Online, failure_domain }`), и дождаться коммита этой записи в Raft.
+Следующим шагом инстансу необходимо актуализировать свой статус (`UpdatePeerRequest{ target_grade: 50_Online, failure_domain }`), и дождаться коммита этой записи в Raft.
 
 Теперь узел Raft готов к использованию.
 
@@ -100,8 +100,14 @@ struct Peer {
     peer_address: String,
     failure_domain: FailureDomain,
 
-    /// Loading / Online / Offline
-    grade: Grade,
+    /// 0_Offline (current / target)
+    /// 10_RaftSynced (current)
+    /// 20_BoxSynced (current)
+    /// 30_VshardInitialized (current)
+    /// 50_Online (current / target)
+    /// 60_Expelled (current / target)
+    target_grade: Grade,
+    current_grade: Grade,
 
     /// Индекс записи в Raft-журнале. Препятствует затиранию
     /// более старыми записями, по мере применения Raft-журнала.
@@ -113,7 +119,7 @@ struct Peer {
 
 - `JoinRequest` отправляет всегда неинициализированный инстанс.
 - В зависимости от того, содержится ли в запросе `instance_id`, проводится анализ его корректности (уникальности).
-- В процессе обработки запроса в Raft-журнал добавляется запись `op::PersistPeer{ peer }`, который помимо всевозможных айдишников содержит поле `grade: Loading`, которое играет важную роль в обеспечении надежности кластера. [TODO](## "Сейчас в коде Online вместо Loading, но это надо исправить.")
+- В процессе обработки запроса в Raft-журнал добавляется запись `op::PersistPeer{ peer }`, который помимо всевозможных айдишников содержит поля `current_grade: 0_Offline`, `target_grade: 50_Online`, которые играет важную роль в обеспечении надежности кластера.
 - Обработка запроса также включает в себя добавление инстанса в Raft-группу в роли `Learner` (процедура также известная как `raft::ConfChangeV2`). Raft не позволяет изменять топологию, пока предыдущее изменение не было применено. Поэтому в целях оптимизации обработка идет в отдельном потоке (т.н. `raft_conf_change_loop`) и выполняется группами.
 - Прежде чем отвечать на запрос, инстанс дожидается применения изменений конфигурации. Это произойдет после того как он выйдет из состояния `joint state` ([подробнее](https://web.stanford.edu/~ouster/cgi-bin/papers/OngaroPhD.pdf), §4.3). [TODO](## "этот тезис в коде пока не реализован")
 - В ответ выдаётся всегда новый `raft_id`, никому другому ранее не принадлежавший.
@@ -127,7 +133,7 @@ struct Peer {
 
 Поток представляет собой бесконечный цикл. На каждой итерации выполняется проверка, что состав `voters` / `learners` соответствует состоянию инстансов, и при необходимости эти изменения пачкой записываются в Raft-журнал:
 
-- Инстансы в статусе `grade: Loading` довавляются как неголосующие.
+- Инстансы с `target_grade: 50_Online` довавляются как неголосующие.
 - Если есть возможность, `Offline` инстансы передают право голоса другим `Online`. [TODO](## "Сейчас offline инстанс демоутится безусловно, даже если других онлайн кандидатов нет. Не надо так делать.")
 - Если общее количество голосующих инстансов оказывается меньше целевого, `Online` инстансы получают право голоса.
 
@@ -154,10 +160,6 @@ struct Peer {
 
 Была у нас однажды такая история - шла разработка graceful shutdown. Тест (`test_joining.py::test_deactivation`) останавливал один из двух инстансов и проверял, что тот (назовем его i2) перстал быть голосующим. Иногда тест проходил нормально, но иногда падал - `i2` завершал работу раньше, чем `i1` получал от него подтверждение. При этом критерий остановки включал в себя ожидание коммита, но только локально на `i2`, а не на `i1`. Из-за этого `i1` терял кворум.
 
-# Кто и когда выполняет box.cfg и vshard.cfg?
-
-Отличный вопрос. Ответ на него нам предстоит найти.
-
 # Graceful shutdown
 
 Чтобы выключение прошло штатно и не имело негативных последствий необходимо следующее:
@@ -165,4 +167,48 @@ struct Peer {
 - Инстанс не должен оставаться воутером, пока есть другие онлайн кандидаты.
 - Инстанс не должен оставаться лидером.
 
-Чтобы этого добиться, каждый инстанс на `on_shutdown` триггер отправляет лидеру запрос `UpdatePeerRequest{ grade: Offline }`. Непосредственно изменением роли `voter` -> `learner` занимается отдеьный поток на лидере (тот самый `raft_conf_change_loop`), инстанс только дожидается его применения.
+Чтобы этого добиться, каждый инстанс на `on_shutdown` триггер отправляет лидеру запрос `UpdatePeerRequest{ target_grade: 0_Offline, graceful: true }`. Непосредственно изменением роли `voter` -> `learner` занимается отдеьный поток на лидере (тот самый `raft_conf_change_loop`), инстанс только дожидается его применения.
+
+# Описание состояний кластера
+
+В отличие от других кластерных решений (например, того же Tarantool Cartridge) Picodata не использует понятие "состояния" для отдельных инстансов. Вместо этого мы говорим об их "грейдах". Грейд инстанса — это лишь синоним слова "состояние", но измениться спонтанно он не может. Мы вводим два конкретных термина: `current_grade` и `target_grade`.
+
+Инициировать изменение `current_grade` может только лидер при поддержке кворума, что гарантирует консистентность принятого решения (и внушает доверие по части отказоустойчивости всей системы).
+
+Инициировать изменение `target_grade` может кто угодно — это может быть сам инстанс (при добавлении), или админ командой `picodata expel`, или нажав Crtl+C на клавиатуре. `target_grade` - это желаемое состояние инстанса, в которое тот должен прийти.
+
+Приведением действительного к желаемому занимается специальный файбер на лидере - `topology_governor` <!-- или лучше grade_manager?  -->. Он управляет всеми инстансами сразу.
+
+На основе совокупности грейдов `topology_governor` на каждой итерации бесконечного цикла придумывает ~~дурацкие менеджерские~~ активности (activity) и пытается их организовать. Пока не организует, никаких других изменений в текущих грейдах не произойдет (но могут измениться целевые). Если активности сфейлятся, то на следующей итерации они будут перевычислены с учетом новых целей.
+
+![Instance states](fsm.svg "Возможные переходы состояний инстанса")
+
+Какие бывают активности? Давайте перечислим.
+
+### 1. Обновить состав воутеров / лернеров
+
+Надо сгенерировать `ConfChangeV2`. Если он пустой, переходим к следующиему шагу. Нет — отправляем его в рафт. Если не получилось - начинаем новую итерацию и перевычисляем активности. Получилось - ждем события `JointStateLeave`.
+
+### 2. Обработать target_grade 0_Offline и 60_Expelled.
+
+По большей части активности сводятся к удалению инстанса из воутеров. Этот шаг уже пройден, поэтому всех желающий стать `0_Offline` можно сразу обновлять. Для `target_grade: 60_Expelled` требуется также подчистить `box.space.cluster` у его оставшихся реплик. Также, если это последний сторадж в репликасете, ему надо выставить вес в 0. Дожидаться ребалансировки на этом шаге не требуется (да и не получится — слишком долгая блокировка), для этого есть отдельный пункт.
+
+### 3. Запромоутить 0_Offline в 10_RaftSynced
+
+Операция выполняется для одного инстанса за раз (потом ее можно будет распараллелить). Чтобы это произошло, достаточно взять текущий commit_index на лидере и дождаться, пока выбранный инстанс его к себе применит. Как только это произошло, инстансу можно присваивать 10 грейд.
+
+### 4. Запромоутить 10_RaftSynced в 20_BoxSynced
+
+Данная активность включает в всебя выполнение `box.cfg({replication})` на всех инстансах выбранного репликасета. Перед выполнением все инстансы дожидаются применения `commit_index`, который сообщит им лидер. При успешном результате сразу несколько инстансов могут быть запромоучены.
+
+### 5. Запромоутить 20_BoxSynced в 30_VshardInitialized
+
+Это уже веселее. Эта активность выполняется на всем кластере. В первую очередь надо проверить, всем ли репликасетам назначено хоть какое-то значение vshard_weight. Если нет — простоавить 0 и дождаться коммита. По итогу надо убедиться, что все инстаны выполнили `vshard.router.cfg()` и `vshard.storage.cfg()`. Как и в предыдущем случае, грейд дается нескольким инстансам сразу.
+
+### 6. Запромоутить 30_VshardInitialized в 50_Online
+
+Данная активность аналогична предыдущей, только vshard_weight должен быть 1. Потом необходимо еще раз убедиться, что все инстансы в кластере выполнили `vshard.router.cfg()` и `vshard.storage.cfg()`.
+
+### 7. Возвращаясь к 60_Expelled
+
+Наименьший приоритет имеет активность, связанная с ожиданием окончания ребалансироки. В конце концов инстанс должен быть удален из `box.space.cluster` на всех оставшихся репликах и из `vshard.router.cfg` и `vshard.storage.cfg` на всем кластере.
diff --git a/docs/fsm.svg b/docs/fsm.svg
new file mode 100644
index 0000000000000000000000000000000000000000..128dfd5e1ab9ba541e3c86ed974855e1ba60256e
Binary files /dev/null and b/docs/fsm.svg differ