Перейти к содержимому

CrewsForge — Шаг 3: Машины состояний

Статус: черновик на подтверждение · Шаг 3 из 9 Основание: ФТ разделы 5, 7.2, 8; схема шага 2; решения шагов 0–1

Две части: механизм (либа state-machine, один на все машины) и таблицы переходов каждой машины с акторами, guard’ами и эффектами. Внизу — четыре отклонения/уточнения канона, требующие подтверждения.


Существующий PROJECT_STATUS_TRANSITIONS: Record<Status, Status[]> отвечает только на вопрос «разрешён ли переход X→Y». Требования добавляют четыре измерения, которых он не умеет: кто имеет право (актор: роль платформы / сторона сделки / система / таймер), при каких условиях (guard: M-1 «нет холда — нет работы»), что происходит вместе с переходом (эффекты: журнал, событие, каскад) и как это фиксируется (L-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 напрямую. Производный статус — витрина, не источник истины

Executor — часть core-модуля домена. Кросс-доменные переходы в одном процессе идут через импорт core-модуля (пример: billing-api, обработав payment_intent.succeeded, вызывает MilestoneMachine.execute('milestone.fund-confirmed') из project-api-либы — та же транзакция, тот же коммит, без задержки relay). Это не нарушает O-1: запрещён импорт billing-модулей в admin-api, обратное направление безопасно.


#ПереходАкторGuardsЭффекты
P01DRAFT → AI_CONSULTATIONFOUNDERтред уже создан при создании проекта
P02AI_CONSULTATION → SPEC_READYFOUNDER (принял спеку)существует Specification PROPOSEDспека → ACCEPTED, прочие → SUPERSEDED
P03SPEC_READY → TEAM_MATCHINGFOUNDERбюджетный гейт: belowBudgetGate = false ИЛИ снят администраторомсобытие → задача TEAM_MATCHING администратору
P04TEAM_MATCHING → TEAM_PROPOSEDSYSTEM (каскад от Offer TEAM_ACCEPTED)P-1: фаундер впервые видит команду
P05TEAM_PROPOSED → TEAM_MATCHINGSYSTEM (каскад от Offer decline/withdraw)причина отказа сохранена в Offer (P-2)
P06TEAM_PROPOSED → CONTRACTINGSYSTEM (каскад от Offer ACCEPTED_BY_FOUNDER)нет конфликта интересов (10.3.6)teamId фиксируется; contractingStage = PLAN_DRAFTING; PlanVersion v1 из спеки
P07CONTRACTING → ACTIVESYSTEM (каскад: оба Contract SIGNED)K-5: оба договора подписаныматериализация Milestone из принятого PlanVersion сразу в PLAN_APPROVED (M-4, журнал на каждый); contractingStage = null; commissionRateBps снапшот
P08ACTIVE → COMPLETEDSYSTEM (каскад: последний этап PAID)все этапы в терминальных статусахP-5: проект read-only; артефакты фаундеру бессрочно
P09ACTIVE → DISPUTEDSYSTEM (каскад: открыт первый спор)производный статус; блокировка фондирования — через guard M-1b, не через статус
P10DISPUTED → ACTIVESYSTEM (каскад: закрыт последний спор)нет открытых Dispute
P11DRAFT…TEAM_PROPOSED → CANCELLEDFOUNDERни один Contract не существует/не подписансвободная отмена до контрактинга
P12CONTRACTING → CANCELLEDADMINISTRATORзадача по запросу фаундера; если Contract A подписан — его формальное расторжение⚠️ правило вне ФТ, см. раздел 9.3
P13ACTIVE / DISPUTED → CANCELLEDSYSTEM (каскад от Termination EXECUTED)X-4: артефакты оплаченных этапов остаются фаундеру

3. Суб-машина контрактинга (contractingStage, живёт только при CONTRACTING)

Заголовок раздела «3. Суб-машина контрактинга (contractingStage, живёт только при CONTRACTING)»
#ПереходАкторGuardsЭффекты
C01PLAN_DRAFTING → PLAN_INTERNAL_REVIEWTEAM (member role=ADMIN или canSign)PlanVersion: items непусты, суммы точные (целые), критерии заданы у каждого этапаPlanVersion → INTERNAL_REVIEW; задача PRICE_REVIEW администратору
C02PLAN_INTERNAL_REVIEW → PLAN_DRAFTINGADMINISTRATORвозврат на доработку цены; торг остаётся в internalNotes (F-1)
C03PLAN_INTERNAL_REVIEW → KYC_PENDINGADMINISTRATORцена согласованазадача KYC_DOCUMENTS; Party-записи создаются
C04KYC_PENDING → PLAN_SENTSYSTEM / ADMINISTRATORC-5: Party(TEAM) заполнена + PayoutAccount ≠ NOT_STARTED; Party(FOUNDER) kycState ∈ {NOT_REQUIRED, APPROVED}PlanVersion → SENT; фаундер видит план впервые (согласованный, без истории торга)
C05PLAN_SENT → SIGNINGFOUNDER (принял план, C-3 бинарно)PlanVersion → ACCEPTED; создаются Contract A и B (DRAFT); A → SENT_FOR_SIGNING команде (K-4)
C06PLAN_SENT → PLAN_DRAFTINGSYSTEM (фаундер отклонил)PlanVersion → REJECTED + причина; задача PLAN_MEDIATION (C-4: не тупик); новая версия → пред. SUPERSEDED, фаундеру виден diff (C-6)
C07внутри SIGNING: Contract A SIGNEDTEAM (member c canSign, K-7)подписант актуаленContract B → SENT_FOR_SIGNING фаундеру (K-4: последовательно)
C08внутри SIGNING: Contract B SIGNEDFOUNDERContract A SIGNEDкаскад → P07 (ACTIVE)

4. Машина этапа (канон + предложение CLOSED, см. 9.1)

Заголовок раздела «4. Машина этапа (канон + предложение CLOSED, см. 9.1)»
#ПереходАкторGuardsЭффекты
M01PLANNED → PLAN_APPROVEDOPERATOR (approve)только этапы, добавленные после старта (M-4); у первичных — авто при P07
M02PLANNED + rejectOPERATORO-2: reject ≠ переходстатус не меняется; задача PLAN_MEDIATION администратору
M03PLAN_APPROVED → FUNDEDSYSTEM (каскад от Hold HELD)M-1: hold.state = HELD; M-1b: нет открытых Dispute по проекту (7.5: новые этапы не стартуют) и нет активной Terminationтаймер дедлайна этапа взводится (если задан dueAt)
M04FUNDED → IN_PROGRESSTEAM (member EDITOR+)явный старт работ командой
M05IN_PROGRESS → SUBMITTEDTEAM (member c canSubmit, M-3)все критерии этапа в MET (самопроверка)MilestoneSubmission (attempt++, лимита нет — M-5); снапшот состояний критериев в metadata журнала; таймер FOUNDER_SILENCE
M06SUBMITTED → FOUNDER_APPROVEDFOUNDERотмена таймера молчания; критерии → CONFIRMED; задача SUBMISSION_VERIFICATION оператору (SLA 4 раб. часа)
M07SUBMITTED → REJECTEDFOUNDERR-1: ≥1 ReviewRejectionItem с привязкой к критериюAcceptanceReview создан; указанные критерии → DISPUTED; задача ACCEPTANCE_REVIEW; пауза дедлайна (R-3: deadlinePausedAt = now)
M08таймер молчания истёкTIMERэтап всё ещё SUBMITTEDне переход (8.3: не автовыплата): задача FOUNDER_SILENCE_DECISION администратору; факт молчания — в журнал
M09REJECTED → IN_PROGRESSADMINISTRATOR (вердикт CRITERIA_UNMET_RETURN)вердикт с обоснованием (L-2)доработка бесплатно; спорные критерии → UNCHECKED; дедлайн размораживается: dueAt += now − deadlinePausedAt
M10REJECTED → FOUNDER_APPROVEDADMINISTRATOR (вердикт CRITERIA_MET_NEW_SCOPE)R-2: новый этап PLANNED создаётся из требования (рост GMV — штатная операция); критерии → CONFIRMED; далее обычный путь M06-эффектов
M11FOUNDER_APPROVED → VERIFIEDOPERATOR (approve: заявленное приложено)артефакты присутствуютM-2: второе подтверждение; задача SETTLEMENT_EXECUTION администратору, basisType = DUAL_APPROVAL, basisId = id этого перехода в журнале
M12FOUNDER_APPROVED + rejectOPERATORO-2статус не меняется; задача разбора администратору
M13VERIFIED → PAIDSYSTEM (каскад от Hold RELEASED)M-2: только через FOUNDER_APPROVED → VERIFIEDесли последний этап → каскад P08
M14FUNDED…VERIFIED → DISPUTEDSYSTEM (каскад от Dispute open)S-1/M-6: спор только при холдеточечная заморозка (7.5): замирает этот этап; пауза дедлайна; каскад P09
M15DISPUTED → PAIDSYSTEM (Hold RELEASED, основание — решение арбитра в пользу команды)Dispute RESOLVED / TEAM_FAVORкаскад P10 если спор последний
M16DISPUTED → CLOSEDSYSTEM (Hold REFUNDED)Dispute RESOLVED / FOUNDER_FAVOR5.5: этап закрыт неоплаченным, проект → ACTIVE (S-5: не расторжение)
M17DISPUTED → IN_PROGRESSSYSTEM (Hold SPLIT, частичный исход с доработкой)Dispute RESOLVED / PARTIALпо решению арбитра; дедлайн размораживается
M18PLANNED / PLAN_APPROVED → CLOSEDSYSTEM (каскад от Termination EXECUTED)нефондированные этапы закрываются при расторжении
#ПереходАкторGuardsЭффекты
H01создание в PENDING_INFOUNDER (инициировал фондирование)этап PLAN_APPROVED; M-1b (нет спора/расторжения); PayoutAccount команды ≠ NOT_STARTEDPaymentIntent у провайдера; «платёж в пути» — состояние холда, не этапа (7.3)
H02PENDING_IN → HELDSYSTEM (вебхук payment_intent.succeeded, дедуп)idempotentTargetheldAt; каскад M03 (FUNDED)
H03PENDING_IN → VOIDSYSTEM (интент истёк/отменён) / FOUNDER (отменил)платёж не прошёлэтап остаётся PLAN_APPROVED; новое фондирование = новый Hold
H04HELD → RELEASINGSYSTEM (Settlement EXECUTED администратором)D-5: инициатор — только администратор; Settlement с основанием (D-7); PayoutAccount ACTIVE (Y-1)transfer у провайдера; «выплата в пути»: показывать команде «выплачено» запрещено до зачисления (7.3)
H05RELEASING → RELEASEDSYSTEM (вебхук payout/transfer confirmed)idempotentTargetкаскад M13 / M15
H06HELD → REFUNDEDSYSTEM (Settlement-refund исполнен)основание: арбитр в пользу фаундера / расторжениекаскад M16
H07HELD → SPLITSYSTEM (Settlement-split исполнен: transfer + refund)обе суммы обязательны (5.5); сумма компонент = сумме холда (CHECK)терминал после подтверждения обеих операций провайдером
#ПереходАкторGuardsЭффекты
D01создание OPENEDFOUNDER / TEAM (S-1: любая сторона)этап в FUNDED+ (M-6); нет другого открытого спора по этапукаскад M14 + P09; T-1: доказательства сторон взаимно скрыты до решения
D02OPENED → POSITIONS_GATHERINGSYSTEM (немедленно)таймер окна позиций (конфиг); обе стороны уведомлены
D03POSITIONS_GATHERING → UNDER_ARBITRATIONSYSTEM (обе позиции поданы) ИЛИ TIMER (окно истекло)S-2: молчание не блокирует; факт неответа — строка журнала с меткой (L-5); задача DISPUTE_ARBITRATION (SLA 5 дней)
D04UNDER_ARBITRATION → RESOLVEDARBITERoutcome + reasoning обязательны (L-2); при PARTIALteamShareBps обязателенA-1/S-3: решение = основание; задача SETTLEMENT_EXECUTION администратору (basisType = ARBITER_DECISION); движение денег арбитру недоступно
D05UNDER_ARBITRATION → ESCALATED_LCIAARBITERсумма холда ≥ порога (конфиг, 10.3.5)S-4: долгая пауза, не расторжение; система собирает пакет материалов; статусы этапа/проекта не меняются
D06ESCALATED_LCIA → RESOLVEDARBITER (фиксирует внешний исход)документ-основание приложендалее как D04

Разморозка этапа (M15/M16/M17) наступает после исполнения денег, не в момент D04: решение без исполнения не меняет состояние сделки (A-2: администратор исполняет, не переопределяя).

#ПереходАкторGuardsЭффекты
T01создание REQUESTEDFOUNDER / TEAM (X-1: подписанное заявление)проект ACTIVE/DISPUTED; заявление подписанопауза проекта (см. 9.4); таймер окна возражений (5 дней); уведомление второй стороне
T02возражение второй стороныFOUNDER / TEAMвнутри окнаX-5: создаётся Dispute (D01) по спорному предмету; Termination ждёт исхода
T03REQUESTED → UNDER_REVIEWTIMER (окно истекло) / SYSTEM (возражений нет)X-6: молчание ≠ согласие — факт неответа в журнал с меткойзадача TERMINATION_ASSESSMENT администратору
T04REQUESTED → WITHDRAWNинициатордо UNDER_REVIEWпроект возвращается к прежнему статусу
T05UNDER_REVIEW → EXECUTEDADMINISTRATORbasis установлен; при FOUNDER_INITIATIVE/TEAM_INITIATIVEcompletedCriteriaBps рассчитан по доле 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, остаток детерминированно платформе.

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. Отклонения и уточнения — на подтверждение»

Канон 10.1 (10 значений) не покрывает два обязательных исхода ФТ: «этап закрыт неоплаченным» при споре в пользу фаундера (5.5) и судьбу незавершённых этапов при расторжении (5.6). REJECTED не подходит — это вход в разбор (M-5), не терминал. Предложение: терминальный CLOSED (закрыт без оплаты / отменён), итого 11 значений. Основание закрытия — в журнале.

7.2 не отвечает, что происходит с холдом при неуспешном/отменённом платеже. REFUNDED семантически неверен (возвращать нечего — деньги не поступали). Предложение: VOID для PENDING_IN-холдов, чей интент истёк/отменён; повторное фондирование = новый Hold.

ФТ описывает расторжение только подписанных отношений (X-1). Правило для ранних стадий предлагаю: до появления договоров фаундер отменяет свободно (P11); в CONTRACTING — через задачу администратору (P12), с формальным расторжением Contract A, если он уже подписан командой.

«Проект на паузе» (5.6) уточняю так: блокируются фондирование новых этапов, сдачи и приёмки; работа IN_PROGRESS физически продолжается на риск команды (аналогия с точечной заморозкой 7.5, но шире — пауза всего движения по сделке, не денег в холдах). Дедлайны этапов ставятся на паузу (симметрично R-3).

  • Резолв актора «сторона сделки» и полная модель прав (включая O-1 в DTO) — шаг 4.
  • Маппинг «событие → тип задачи/таймер/уведомление» как декларативная таблица воркера — шаг 7 (эффектные колонки выше — источник для неё).
  • Идемпотентность исполнения Settlement на стороне Stripe (idempotency keys) — шаг 5.