CrewsForge — Шаг 3: Машины состояний
Статус: черновик на подтверждение · Шаг 3 из 9 Основание: ФТ разделы 5, 7.2, 8; схема шага 2; решения шагов 0–1
Две части: механизм (либа state-machine, один на все машины) и таблицы переходов каждой машины с акторами, guard’ами и эффектами. Внизу — четыре отклонения/уточнения канона, требующие подтверждения.
1. Механизм: либа @crewsforge-back/apis/shared/state-machine
Заголовок раздела «1. Механизм: либа @crewsforge-back/apis/shared/state-machine»1.1 Почему не текущий паттерн
Заголовок раздела «1.1 Почему не текущий паттерн»Существующий PROJECT_STATUS_TRANSITIONS: Record<Status, Status[]> отвечает только на вопрос «разрешён ли переход X→Y». Требования добавляют четыре измерения, которых он не умеет: кто имеет право (актор: роль платформы / сторона сделки / система / таймер), при каких условиях (guard: M-1 «нет холда — нет работы»), что происходит вместе с переходом (эффекты: журнал, событие, каскад) и как это фиксируется (L-2: автор, время, основание).
1.2 Декларация машины
Заголовок раздела «1.2 Декларация машины»interface TransitionDef<S extends string> { name: string; // 'milestone.submit' — стабильный идентификатор from: S | S[]; to: S; actors: ActorSpec[]; // { role: PlatformRole } | { side: DealSide, authority?: 'canSign'|'canSubmit' } | 'SYSTEM' | 'TIMER' guards?: GuardFn[]; // чистые проверки, read-only, внутри транзакции effects?: EffectFn[]; // доменные записи в той же транзакции, включая каскадные переходы idempotentTarget?: boolean; // повторный вызов при current == to → success no-op (для вебхуков)}
interface MachineDef<S extends string> { subjectType: SubjectType; statusField: string; // колонка статуса transitions: TransitionDef<S>[];}1.3 Алгоритм исполнителя (одна CLS-транзакция)
Заголовок раздела «1.3 Алгоритм исполнителя (одна CLS-транзакция)»execute(machine, subjectId, transitionName, ctx {actor, basisType?, basisId?, reason?, metadata?}) 1. Найти TransitionDef; резолв актора (роль из JWT / сторона через членство в проекте) 2. CAS-захват: UPDATE <table> SET status = to WHERE id = :subjectId AND status IN (:from) → count count = 0: если idempotentTarget и текущий статус == to → вернуть OK (идемпотентный повтор) иначе → 409 TransitionConflict (конкурентный переход или неверное состояние) 3. Guards (после захвата строки, read-only). Провал guard → откат транзакции, 422 с кодом guard'а 4. Effects — в той же транзакции: доменные записи, каскадные execute() других машин 5. INSERT StateTransition {subjectType, subjectId, from, to, actorType, actorUserId, actorRole, basisType, basisId, reason, metadata, createdAt} 6. INSERT OutboxEvent {eventType: 'milestone.status.changed', payload: {…, transition: name}} 7. Commit → relay доставит событие подписчикам (задачи, таймеры, уведомления, интеграции)Ключевые свойства:
| Свойство | Решение |
|---|---|
| Конкурентность | CAS через updateMany WHERE status IN (from) — двум параллельным запросам одну строку не перевести дважды. Никаких advisory-локов |
| Идемпотентность вебхуков | Дедуп WebhookEvent на входе + idempotentTarget на переходе: повторная доставка payment_intent.succeeded не породит второй переход и второе событие (требование 7.3) |
| Порядок guard’ов | Guards выполняются после CAS-захвата строки — читают консистентное состояние, гонка «guard прошёл на устаревших данных» исключена |
| Каскады | Эффект может вызвать executor другой машины (спор открыт → этап DISPUTED → проект DISPUTED) — всё в одной транзакции, атомарно. Глубина каскада ограничена (защита от циклов), каждый шаг каскада пишет свою строку журнала с actorType: SYSTEM и basisId = id исходного перехода |
| События | Автоматически из шага 6, руками события не публикуются нигде. Тип события — {subject}.status.changed + имя перехода в payload; подписчикам (шаг 7) этого достаточно для маршрутизации |
| Производные статусы | Проектный DISPUTED — каскадный, но ни один guard не читает производный статус: guard «нельзя фондировать при открытом споре» проверяет таблицу Dispute напрямую. Производный статус — витрина, не источник истины |
1.4 Владение и вызовы между сервисами
Заголовок раздела «1.4 Владение и вызовы между сервисами»Executor — часть core-модуля домена. Кросс-доменные переходы в одном процессе идут через импорт core-модуля (пример: billing-api, обработав payment_intent.succeeded, вызывает MilestoneMachine.execute('milestone.fund-confirmed') из project-api-либы — та же транзакция, тот же коммит, без задержки relay). Это не нарушает O-1: запрещён импорт billing-модулей в admin-api, обратное направление безопасно.
2. Машина проекта (10 статусов)
Заголовок раздела «2. Машина проекта (10 статусов)»| # | Переход | Актор | Guards | Эффекты |
|---|---|---|---|---|
| P01 | DRAFT → AI_CONSULTATION | FOUNDER | — | тред уже создан при создании проекта |
| P02 | AI_CONSULTATION → SPEC_READY | FOUNDER (принял спеку) | существует Specification PROPOSED | спека → ACCEPTED, прочие → SUPERSEDED |
| P03 | SPEC_READY → TEAM_MATCHING | FOUNDER | бюджетный гейт: belowBudgetGate = false ИЛИ снят администратором | событие → задача TEAM_MATCHING администратору |
| P04 | TEAM_MATCHING → TEAM_PROPOSED | SYSTEM (каскад от Offer TEAM_ACCEPTED) | — | P-1: фаундер впервые видит команду |
| P05 | TEAM_PROPOSED → TEAM_MATCHING | SYSTEM (каскад от Offer decline/withdraw) | — | причина отказа сохранена в Offer (P-2) |
| P06 | TEAM_PROPOSED → CONTRACTING | SYSTEM (каскад от Offer ACCEPTED_BY_FOUNDER) | нет конфликта интересов (10.3.6) | teamId фиксируется; contractingStage = PLAN_DRAFTING; PlanVersion v1 из спеки |
| P07 | CONTRACTING → ACTIVE | SYSTEM (каскад: оба Contract SIGNED) | K-5: оба договора подписаны | материализация Milestone из принятого PlanVersion сразу в PLAN_APPROVED (M-4, журнал на каждый); contractingStage = null; commissionRateBps снапшот |
| P08 | ACTIVE → COMPLETED | SYSTEM (каскад: последний этап PAID) | все этапы в терминальных статусах | P-5: проект read-only; артефакты фаундеру бессрочно |
| P09 | ACTIVE → DISPUTED | SYSTEM (каскад: открыт первый спор) | — | производный статус; блокировка фондирования — через guard M-1b, не через статус |
| P10 | DISPUTED → ACTIVE | SYSTEM (каскад: закрыт последний спор) | нет открытых Dispute | — |
| P11 | DRAFT…TEAM_PROPOSED → CANCELLED | FOUNDER | ни один Contract не существует/не подписан | свободная отмена до контрактинга |
| P12 | CONTRACTING → CANCELLED | ADMINISTRATOR | задача по запросу фаундера; если Contract A подписан — его формальное расторжение | ⚠️ правило вне ФТ, см. раздел 9.3 |
| P13 | ACTIVE / DISPUTED → CANCELLED | SYSTEM (каскад от Termination EXECUTED) | — | X-4: артефакты оплаченных этапов остаются фаундеру |
3. Суб-машина контрактинга (contractingStage, живёт только при CONTRACTING)
Заголовок раздела «3. Суб-машина контрактинга (contractingStage, живёт только при CONTRACTING)»| # | Переход | Актор | Guards | Эффекты |
|---|---|---|---|---|
| C01 | PLAN_DRAFTING → PLAN_INTERNAL_REVIEW | TEAM (member role=ADMIN или canSign) | PlanVersion: items непусты, суммы точные (целые), критерии заданы у каждого этапа | PlanVersion → INTERNAL_REVIEW; задача PRICE_REVIEW администратору |
| C02 | PLAN_INTERNAL_REVIEW → PLAN_DRAFTING | ADMINISTRATOR | — | возврат на доработку цены; торг остаётся в internalNotes (F-1) |
| C03 | PLAN_INTERNAL_REVIEW → KYC_PENDING | ADMINISTRATOR | цена согласована | задача KYC_DOCUMENTS; Party-записи создаются |
| C04 | KYC_PENDING → PLAN_SENT | SYSTEM / ADMINISTRATOR | C-5: Party(TEAM) заполнена + PayoutAccount ≠ NOT_STARTED; Party(FOUNDER) kycState ∈ {NOT_REQUIRED, APPROVED} | PlanVersion → SENT; фаундер видит план впервые (согласованный, без истории торга) |
| C05 | PLAN_SENT → SIGNING | FOUNDER (принял план, C-3 бинарно) | — | PlanVersion → ACCEPTED; создаются Contract A и B (DRAFT); A → SENT_FOR_SIGNING команде (K-4) |
| C06 | PLAN_SENT → PLAN_DRAFTING | SYSTEM (фаундер отклонил) | — | PlanVersion → REJECTED + причина; задача PLAN_MEDIATION (C-4: не тупик); новая версия → пред. SUPERSEDED, фаундеру виден diff (C-6) |
| C07 | внутри SIGNING: Contract A SIGNED | TEAM (member c canSign, K-7) | подписант актуален | Contract B → SENT_FOR_SIGNING фаундеру (K-4: последовательно) |
| C08 | внутри SIGNING: Contract B SIGNED | FOUNDER | Contract A SIGNED | каскад → P07 (ACTIVE) |
4. Машина этапа (канон + предложение CLOSED, см. 9.1)
Заголовок раздела «4. Машина этапа (канон + предложение CLOSED, см. 9.1)»| # | Переход | Актор | Guards | Эффекты |
|---|---|---|---|---|
| M01 | PLANNED → PLAN_APPROVED | OPERATOR (approve) | только этапы, добавленные после старта (M-4); у первичных — авто при P07 | — |
| M02 | PLANNED + reject | OPERATOR | O-2: reject ≠ переход | статус не меняется; задача PLAN_MEDIATION администратору |
| M03 | PLAN_APPROVED → FUNDED | SYSTEM (каскад от Hold HELD) | M-1: hold.state = HELD; M-1b: нет открытых Dispute по проекту (7.5: новые этапы не стартуют) и нет активной Termination | таймер дедлайна этапа взводится (если задан dueAt) |
| M04 | FUNDED → IN_PROGRESS | TEAM (member EDITOR+) | — | явный старт работ командой |
| M05 | IN_PROGRESS → SUBMITTED | TEAM (member c canSubmit, M-3) | все критерии этапа в MET (самопроверка) | MilestoneSubmission (attempt++, лимита нет — M-5); снапшот состояний критериев в metadata журнала; таймер FOUNDER_SILENCE |
| M06 | SUBMITTED → FOUNDER_APPROVED | FOUNDER | — | отмена таймера молчания; критерии → CONFIRMED; задача SUBMISSION_VERIFICATION оператору (SLA 4 раб. часа) |
| M07 | SUBMITTED → REJECTED | FOUNDER | R-1: ≥1 ReviewRejectionItem с привязкой к критерию | AcceptanceReview создан; указанные критерии → DISPUTED; задача ACCEPTANCE_REVIEW; пауза дедлайна (R-3: deadlinePausedAt = now) |
| M08 | таймер молчания истёк | TIMER | этап всё ещё SUBMITTED | не переход (8.3: не автовыплата): задача FOUNDER_SILENCE_DECISION администратору; факт молчания — в журнал |
| M09 | REJECTED → IN_PROGRESS | ADMINISTRATOR (вердикт CRITERIA_UNMET_RETURN) | вердикт с обоснованием (L-2) | доработка бесплатно; спорные критерии → UNCHECKED; дедлайн размораживается: dueAt += now − deadlinePausedAt |
| M10 | REJECTED → FOUNDER_APPROVED | ADMINISTRATOR (вердикт CRITERIA_MET_NEW_SCOPE) | — | R-2: новый этап PLANNED создаётся из требования (рост GMV — штатная операция); критерии → CONFIRMED; далее обычный путь M06-эффектов |
| M11 | FOUNDER_APPROVED → VERIFIED | OPERATOR (approve: заявленное приложено) | артефакты присутствуют | M-2: второе подтверждение; задача SETTLEMENT_EXECUTION администратору, basisType = DUAL_APPROVAL, basisId = id этого перехода в журнале |
| M12 | FOUNDER_APPROVED + reject | OPERATOR | O-2 | статус не меняется; задача разбора администратору |
| M13 | VERIFIED → PAID | SYSTEM (каскад от Hold RELEASED) | M-2: только через FOUNDER_APPROVED → VERIFIED | если последний этап → каскад P08 |
| M14 | FUNDED…VERIFIED → DISPUTED | SYSTEM (каскад от Dispute open) | S-1/M-6: спор только при холде | точечная заморозка (7.5): замирает этот этап; пауза дедлайна; каскад P09 |
| M15 | DISPUTED → PAID | SYSTEM (Hold RELEASED, основание — решение арбитра в пользу команды) | Dispute RESOLVED / TEAM_FAVOR | каскад P10 если спор последний |
| M16 | DISPUTED → CLOSED | SYSTEM (Hold REFUNDED) | Dispute RESOLVED / FOUNDER_FAVOR | 5.5: этап закрыт неоплаченным, проект → ACTIVE (S-5: не расторжение) |
| M17 | DISPUTED → IN_PROGRESS | SYSTEM (Hold SPLIT, частичный исход с доработкой) | Dispute RESOLVED / PARTIAL | по решению арбитра; дедлайн размораживается |
| M18 | PLANNED / PLAN_APPROVED → CLOSED | SYSTEM (каскад от Termination EXECUTED) | — | нефондированные этапы закрываются при расторжении |
5. Машина холда (7.2 + предложение VOID, см. 9.2)
Заголовок раздела «5. Машина холда (7.2 + предложение VOID, см. 9.2)»| # | Переход | Актор | Guards | Эффекты |
|---|---|---|---|---|
| H01 | создание в PENDING_IN | FOUNDER (инициировал фондирование) | этап PLAN_APPROVED; M-1b (нет спора/расторжения); PayoutAccount команды ≠ NOT_STARTED | PaymentIntent у провайдера; «платёж в пути» — состояние холда, не этапа (7.3) |
| H02 | PENDING_IN → HELD | SYSTEM (вебхук payment_intent.succeeded, дедуп) | idempotentTarget | heldAt; каскад M03 (FUNDED) |
| H03 | PENDING_IN → VOID | SYSTEM (интент истёк/отменён) / FOUNDER (отменил) | платёж не прошёл | этап остаётся PLAN_APPROVED; новое фондирование = новый Hold |
| H04 | HELD → RELEASING | SYSTEM (Settlement EXECUTED администратором) | D-5: инициатор — только администратор; Settlement с основанием (D-7); PayoutAccount ACTIVE (Y-1) | transfer у провайдера; «выплата в пути»: показывать команде «выплачено» запрещено до зачисления (7.3) |
| H05 | RELEASING → RELEASED | SYSTEM (вебхук payout/transfer confirmed) | idempotentTarget | каскад M13 / M15 |
| H06 | HELD → REFUNDED | SYSTEM (Settlement-refund исполнен) | основание: арбитр в пользу фаундера / расторжение | каскад M16 |
| H07 | HELD → SPLIT | SYSTEM (Settlement-split исполнен: transfer + refund) | обе суммы обязательны (5.5); сумма компонент = сумме холда (CHECK) | терминал после подтверждения обеих операций провайдером |
6. Машина спора (5.5)
Заголовок раздела «6. Машина спора (5.5)»| # | Переход | Актор | Guards | Эффекты |
|---|---|---|---|---|
| D01 | создание OPENED | FOUNDER / TEAM (S-1: любая сторона) | этап в FUNDED+ (M-6); нет другого открытого спора по этапу | каскад M14 + P09; T-1: доказательства сторон взаимно скрыты до решения |
| D02 | OPENED → POSITIONS_GATHERING | SYSTEM (немедленно) | — | таймер окна позиций (конфиг); обе стороны уведомлены |
| D03 | POSITIONS_GATHERING → UNDER_ARBITRATION | SYSTEM (обе позиции поданы) ИЛИ TIMER (окно истекло) | — | S-2: молчание не блокирует; факт неответа — строка журнала с меткой (L-5); задача DISPUTE_ARBITRATION (SLA 5 дней) |
| D04 | UNDER_ARBITRATION → RESOLVED | ARBITER | outcome + reasoning обязательны (L-2); при PARTIAL — teamShareBps обязателен | A-1/S-3: решение = основание; задача SETTLEMENT_EXECUTION администратору (basisType = ARBITER_DECISION); движение денег арбитру недоступно |
| D05 | UNDER_ARBITRATION → ESCALATED_LCIA | ARBITER | сумма холда ≥ порога (конфиг, 10.3.5) | S-4: долгая пауза, не расторжение; система собирает пакет материалов; статусы этапа/проекта не меняются |
| D06 | ESCALATED_LCIA → RESOLVED | ARBITER (фиксирует внешний исход) | документ-основание приложен | далее как D04 |
Разморозка этапа (M15/M16/M17) наступает после исполнения денег, не в момент D04: решение без исполнения не меняет состояние сделки (A-2: администратор исполняет, не переопределяя).
7. Машина расторжения (5.6)
Заголовок раздела «7. Машина расторжения (5.6)»| # | Переход | Актор | Guards | Эффекты |
|---|---|---|---|---|
| T01 | создание REQUESTED | FOUNDER / TEAM (X-1: подписанное заявление) | проект ACTIVE/DISPUTED; заявление подписано | пауза проекта (см. 9.4); таймер окна возражений (5 дней); уведомление второй стороне |
| T02 | возражение второй стороны | FOUNDER / TEAM | внутри окна | X-5: создаётся Dispute (D01) по спорному предмету; Termination ждёт исхода |
| T03 | REQUESTED → UNDER_REVIEW | TIMER (окно истекло) / SYSTEM (возражений нет) | X-6: молчание ≠ согласие — факт неответа в журнал с меткой | задача TERMINATION_ASSESSMENT администратору |
| T04 | REQUESTED → WITHDRAWN | инициатор | до UNDER_REVIEW | проект возвращается к прежнему статусу |
| T05 | UNDER_REVIEW → EXECUTED | ADMINISTRATOR | basis установлен; при FOUNDER_INITIATIVE/TEAM_INITIATIVE — completedCriteriaBps рассчитан по доле MET/CONFIRMED критериев (X-2, произвольная оценка запрещена); при DISPUTE_DECISION — исполняется решение арбитра, повторной оценки нет; reasoning обязателен (L-2) | Settlements по каждому активному холду (basisType = TERMINATION_ASSESSMENT); X-3: комиссия не возвращается при инициативе фаундера без вины команды; оба Contract → TERMINATED (K-6); нефондированные этапы → M18; каскад P13 |
Расчёт раздела при FOUNDER_INITIATIVE: команде — amountMinor × completedCriteriaBps / 10000 минус комиссия по правилам X-3, остальное фаундеру; TEAM_INITIATIVE — симметрично; TEAM_FAULT — холды полностью фаундеру. Округление — либа money, остаток детерминированно платформе.
8. Малые машины
Заголовок раздела «8. Малые машины»Offer: SENT_TO_TEAM → TEAM_ACCEPTED (TEAM admin; guards: PayoutAccount ≠ RESTRICTED — Y-2; нет конфликта интересов) → ACCEPTED_BY_FOUNDER (FOUNDER) — каскады P04/P06. Отказы DECLINED_BY_* с обязательной причиной (P-2) — каскад P05. WITHDRAWN администратором.
PlanVersion: DRAFT → INTERNAL_REVIEW → SENT → ACCEPTED | REJECTED; новая версия → предыдущая SUPERSEDED. Управляется эффектами суб-машины контрактинга (C01–C06), собственных акторов не имеет.
Contract: DRAFT → SENT_FOR_SIGNING → SIGNED (вебхук провайдера подписания, дедуп, idempotentTarget) → TERMINATED (только через Termination EXECUTED либо P12). Подписант: сторона TEAM — member с canSign (K-7), сторона FOUNDER — владелец проекта.
PayoutAccount: NOT_STARTED → PENDING → ACTIVE ⇄ RESTRICTED — целиком вебхук-управляемая (account.updated), idempotentTarget. RESTRICTED: блокирует только приём офферов (Y-2), фондированные этапы дорабатываются (Y-3), уведомление команде немедленно (Y-4). Выплата — только при ACTIVE (Y-1, guard H04).
9. Отклонения и уточнения — на подтверждение
Заголовок раздела «9. Отклонения и уточнения — на подтверждение»9.1 Одиннадцатый статус этапа: CLOSED
Заголовок раздела «9.1 Одиннадцатый статус этапа: CLOSED»Канон 10.1 (10 значений) не покрывает два обязательных исхода ФТ: «этап закрыт неоплаченным» при споре в пользу фаундера (5.5) и судьбу незавершённых этапов при расторжении (5.6). REJECTED не подходит — это вход в разбор (M-5), не терминал. Предложение: терминальный CLOSED (закрыт без оплаты / отменён), итого 11 значений. Основание закрытия — в журнале.
9.2 Технический статус холда: VOID
Заголовок раздела «9.2 Технический статус холда: VOID»7.2 не отвечает, что происходит с холдом при неуспешном/отменённом платеже. REFUNDED семантически неверен (возвращать нечего — деньги не поступали). Предложение: VOID для PENDING_IN-холдов, чей интент истёк/отменён; повторное фондирование = новый Hold.
9.3 Отмена проекта до ACTIVE (P11/P12)
Заголовок раздела «9.3 Отмена проекта до ACTIVE (P11/P12)»ФТ описывает расторжение только подписанных отношений (X-1). Правило для ранних стадий предлагаю: до появления договоров фаундер отменяет свободно (P11); в CONTRACTING — через задачу администратору (P12), с формальным расторжением Contract A, если он уже подписан командой.
9.4 Семантика паузы при TERMINATION_REQUESTED
Заголовок раздела «9.4 Семантика паузы при TERMINATION_REQUESTED»«Проект на паузе» (5.6) уточняю так: блокируются фондирование новых этапов, сдачи и приёмки; работа IN_PROGRESS физически продолжается на риск команды (аналогия с точечной заморозкой 7.5, но шире — пауза всего движения по сделке, не денег в холдах). Дедлайны этапов ставятся на паузу (симметрично R-3).
10. Что уезжает в следующие шаги
Заголовок раздела «10. Что уезжает в следующие шаги»- Резолв актора «сторона сделки» и полная модель прав (включая O-1 в DTO) — шаг 4.
- Маппинг «событие → тип задачи/таймер/уведомление» как декларативная таблица воркера — шаг 7 (эффектные колонки выше — источник для неё).
- Идемпотентность исполнения Settlement на стороне Stripe (idempotency keys) — шаг 5.