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

Журнал строительных сессий

Append-only. Новые записи добавляются вниз. Чужие записи не правятся и не удаляются. Протокол — START-HERE.md. Статусы единиц — ROADMAP.md.

Читать при старте сессии: последние 2–3 записи (tail -n 120) плюс запись той единицы, от которой ты зависишь, — там сказано, что осталось недоделанным и где лежат заглушки.


Протокол и нарезка сменились после F1. Роадмап перекроен с 35 технических единиц на 27 срезов продукта; начиная с F2 сессия идёт по скиллу crewsforge-session: бизнес-бриф с гейтом до захвата, без отдельной спеки, без остановки на плане, одно итоговое ревью и обязательный сквозной HTTP-e2e. Запись F1 сделана по прежнему протоколу — это нормально, переписывать её не нужно. Соответствие старых и новых ID — в записи «Сессия 0-бис».

Открывающая запись (пишется на шаге 3 протокола, до создания ветки):

## <ID> — <название единицы>
**Открыта:** YYYY-MM-DD · **Статус записи:** в работе
**Зависимости на момент захвата:** <ID>: DONE, <ID>: DONE
**Ветка:** feat/<id>-<slug>
### Бизнес-бриф (утверждён Stark'ом)
**Задача:** <что станет возможно в продукте>
**Сущности:** <списком>
**API для фронта:** <таблица: метод, путь, кто вызывает, назначение>
**Можно собрать на фронте:** <экраны или куски сценария>
**Чего ещё нельзя:** <честная граница>
**Правки скоупа при утверждении:** <нет / что изменил Stark>

Закрывающая запись (дописывается в тот же блок на шаге 11):

**Закрыта:** YYYY-MM-DD · **Статус записи:** закрыта · **PR:** [#31](https://github.com/crewsforge/crewsforge-back/pull/31)
### Сделано
- <что реально появилось в коде: либы, модули, машины, эндпоинты, миграции>
### Решения реализации
- <решение>: <выбранный вариант> — <обоснование в одну строку>
- <оппонент: CONFIRMED / что исправлено после возражений>
### Приёмка
- <критерий из карточки> — <чем доказан: имя теста / команда и её вывод>
- HTTP-e2e: <имя теста> — сценарий и результат
- Swagger: <путь> · маршруты из брифа на месте: да / расхождения
- Ревью: <тир> — <вердикты> · отклонённые замечания и почему
### Отклонения от проектных документов
- <нет> либо: <какое расхождение, какой step-документ поправлен, кем подтверждено>
### Заглушки и долги
- <нет> либо: <что заглушено, где TODO, какая единица снимает>
### Хендофф следующей сессии
- <что важно знать тому, кто возьмёт зависимую единицу: контракты, имена, подводные камни>

Если сессия закрылась без завершения единицы (блокер), вместо закрывающей записи:

**Прервана:** YYYY-MM-DD · **Статус записи:** блокер · **Статус единицы:** BLOCKED
### Что успела
- <...>
### Блокер
- <в чём упёрлась; ссылка на пункт OPEN-QUESTIONS.md>
### Состояние ветки
- <что закоммичено, что не закончено, безопасно ли продолжать поверх>

Открыта: 2026-08-21 · Закрыта: 2026-08-21 · Статус записи: закрыта · PR:

  • Создан контур управления стройкой: docs/delivery/START-HERE.md, ROADMAP.md, SESSION-LOG.md, OPEN-QUESTIONS.md.
  • 20 эпиков шага 9 разложены на 35 единиц работы; все L-эпики (F3, A1, A2, B2, B3, B5, C4) разбиты на подъединицы с явными границами и критериями приёмки.
  • Зафиксирован протокол сессии: захват → ветка → brainstormingwriting-planssubagent-driven-development → ревью → документация → PR.
  • Порядок подъединиц A1 изменён на хронологический: A1.2 — Offer и мэтчинг, A1.3 — контрактинг (контрактинг каскадно порождается принятым оффером). Содержание эпика A1 из шага 9 не изменилось.
  • Уточнена граница F2 / C1: модель и транзакционная запись outbox — в F2, запуск relay-процесса — в C1. Шаг 9 упоминал relay в обоих эпиках.
  • Добавлена зависимость F3.2 → F5: резолвер актора требует claim platformRole. В шаге 9 эпик F3 зависел только от F1, F2.
  • Первые готовые к захвату единицы: F1 и F4 — обе без зависимостей, обе на критическом пути. Разумный порядок — F1, затем F4, затем F2 и F5 (параллелизуются), затем F3.1.
  • Репозиторий на момент записи не содержит ни billing-api, ни platform-worker, ни team-либ, ни либы state-machine. Всё это создаётся с нуля в волне 1–2.
  • Ветка на момент записи — feat/docs-rules.

Открыта: 2026-08-21 · Статус записи: в работе Зависимости на момент захвата: — (единица без зависимостей) Ветка: feat/f1-shared-core-libs

Закрыта: 2026-08-21 · Статус записи: закрыта · PR: #29

  • Новая группа Nx-проектов libs/apis/core/ — три либы без единой зависимости, в том числе от NestJS: ни модулей, ни провайдеров, ни DI.
  • api-core-moneycalculateCommissionFee, splitSettlement на bigint минорных единицах; порядок шага 5 §4: доля команды вниз, разница фаундеру, комиссия ceil от доли команды.
  • api-core-domain-events — 12 routing keys, четыре payload’а, карта «ключ → payload», union’ы ActorType / SubjectType / TimerKind / WebhookProvider, структурный запрет денег в payload.
  • api-core-platform-calendarisWorkingTime, addWorkingHours, addWorkingDays, addCalendarDays на голом Date в UTC, календарь параметром с дефолтом-константой.
  • Сумма компонент сплита всегда равна холду — splitSettlement invariants, 539 комбинаций сумм, ставок и долей (включая shareBps = 3333, нечётные остатки, 2^100 + 3).
  • Комиссия по ceil, не roundrounds the commission up, never down: подмена ceil на floor валит 230 случаев прогона (проверено мутацией).
  • Пятница 17:00 UTC + 4 рабочих часа → понедельник 13:00 — carries the remainder over the weekend.
  • 5 рабочих дней не проскакивают выходные — steps over the weekend instead of counting it.
  • Денежных полей в payload нет структурно — 13 типовых утверждений плюс гейт CatalogIsMoneyFree: денежный payload в каталоге даёт Test Suites: 1 failed (проверено поломкой).
  • npx nx affected -t lint,test,build --base=main --skip-nx-cache — зелёный, 1634 + 31 + 2 теста.
  • Шаг 2 §9, TimerKind — добавлены ENVELOPE_EXPIRY и MILESTONE_DEADLINE: шаг 7 §3 описывает семь таймеров, шаг 2 объявлял пять. Подтверждено Stark’ом, врезка о ревизии внесена в step-документ, дельта — в карточку F4.
  • Шаг 2 §9, SubjectType — добавлено CONCIERGE_THREAD: консьерж-тред введён шагом 7 §5 и публикует concierge.escalated, а OutboxEvent.aggregateType типизирован SubjectType. Подтверждено Stark’ом, дельта — в карточку F4.
  • Шаг 2 §7, проза об округлении — «метод наибольших остатков» заменён порядком шага 5 §4. Вопрос был уже решён карточкой F1, поэтому правка внесена без отдельного согласования.
  • Payload шины дополнен subjectType (им типизирован OutboxEvent.aggregateType) и необязательным actorRole (шаг 2 §9 хранит роль отдельно от ActorType). Карточка F1 дописана.
  • Схема имён Nx-проектов расширена до api-core-<name>; строка добавлена в таблицу coding-rules.md §1.2.
  • Заглушек нет. Отложено осознанно, с указанием единицы: конвертация bigint → number для Stripe — B2.1; денежный линтер и depConstraints на границу api-core-moneyC4.1; подграф core в mermaid-диаграмме architecture.mdF2, вместе с первым ребром.
  • docs/DOC.zip по ошибке уехал в историю ветки коммитом 327c31f (git add -A docs/). Файл возвращён в untracked, *.zip добавлен в .gitignore, но блоб остаётся достижимым из того коммита: вычистить его можно только переписыванием ветки, и это решение Stark’а.
  • F4 — Prisma-enum’ы ActorType (3), SubjectType (11), TimerKind (7), WebhookProvider (3) обязаны совпасть с union’ами @crewsforge-back/apis/core/domain-events значение в значение. Требование записано в карточку F4.
  • B3.1founderRefundMinor из SettlementSplit ложится в колонку founder_amount_minor схемы: имена намеренно разные, маппинг тривиален.
  • A3 / B3.1splitSettlement принимает один shareBps. Допущение: teamShareBps и completedCriteriaBps — два источника одной доли, а не сомножители. Если окажется иначе, правится вызывающий, не либа.
  • Проверок build и typecheck у либ нет — Nx выводит только lint и test, а eslint не типизирован. Из-за этого компиляционные гейты domain-events подключены к спеке side-effect-импортом. Единице, которая заведёт настоящий typecheck-таргет, стоит проверить, не стал ли этот приём лишним.

Сессия 0-бис — перекрой роадмапа на срезы продукта

Заголовок раздела «Сессия 0-бис — перекрой роадмапа на срезы продукта»

Открыта: 2026-08-30 · Закрыта: 2026-08-30 · Статус записи: закрыта · PR:Ветка: feat/delivery-vertical-recut

  • Роадмап перекроен с 35 технических единиц на 27 срезов продукта. Причина: единица, нарезанная по слою (либа, машина, схема), закрывается формально зелёными тестами, не давая ничего вызываемого из фронта. Каждый срез, кроме F1, F2 и G1G3, обязан закончиться работающими эндпоинтами.
  • Инфраструктура перестала быть отдельными единицами: state-machine строится в T2 вместе с первой настоящей машиной (статус команды), очередь задач в O1 — вместе с первой задачей, уведомления в O2 — вместе с первым уведомлением.
  • Каждая карточка отвечает на пять вопросов до захвата: бизнес-задача · сущности · что появится для фронта · чего ещё нельзя · чем доказывается.
  • Обязательный сквозной HTTP-e2e по реальной БД введён в критерии приёмки всех единиц с фронтовой поверхностью. Мокать транзакции, CAS-переходы, констрейнты БД и guard’ы авторизации запрещено; внешних провайдеров — можно.
  • Протокол сессии переведён на скилл crewsforge-session: бизнес-бриф с гейтом до захвата, без отдельной спеки, без остановки на плане, решения реализации проверяет субагент-оппонент, ревью одно итоговое с двумя тирами.

Сопоставлены 32 модели, 10 машин, 4 консьюмера и 6 поверхностей шагов 2–7 со списком эпиков шага 9. Три расхождения, все внесены в step-документы:

  • Эпик «Домен Team» отсутствовал в шаге 9. Спроектирован в шаге 2 §3, присутствует в таблице задач шага 7 (TEAM_ONBOARDING) и в SubjectType, но эпика не имел — при том что от него зависят оффер, подписант с canSign, сдача с canSubmit и выплатной аккаунт. Заведена единица T1; шаг 9 дополнен эпиком F6.
  • Машина статуса команды не имела таблицы переходов. Объявлена в T2: ONBOARDING → ACTIVE ⇄ SUSPENDED → ARCHIVED.
  • ExternalPaymentDispute не объявлен в схеме шага 2. Требуется шагом 5 §6; модель вводит D3.
  • Шаг 9 дополнен разделом «Дополнение 2026-08-30» (эпик F6, машина команды, модель chargeback’а) и разделом «Актуальная нарезка исполнения», отсылающим к ROADMAP.md. Волны и критический путь шага 9 остаются основанием, но исполнение идёт по срезам.

Сессия сначала прочитала ROADMAP.md и SESSION-LOG.md из рабочего дерева, находясь на ветке feat/f1-shared-core-libs, созданной до закрывающих коммитов F1. На той ветке F1 всё ещё IN_PROGRESS, а закрывающей записи нет — они в main. В результате сессия сообщила Stark’у, что F1 не закрыта, и построила первую версию перекроя поверх устаревшей копии: слияние откатило бы F1 в IN_PROGRESS и удалило бы 60 строк закрывающей записи. Исправлено перебазированием на main до коммита.

Следствие для протокола: фаза 0 скилла обязана читать контур с main, а не из рабочего дерева, и останавливаться, если текущая ветка не main. Требование усилено в crewsforge-session явной командой сверки.

Хендофф F1 ссылается на старые ID. Соответствие:

Старый IDНовый IDЧто переехало
F4 baseline-схемаF2сверка Prisma-enum’ов с union’ами domain-events, дельты TimerKind и SubjectType
F2 либа outboxT2outbox вместе с executor’ом машин
B2.1 Hold и CheckoutM2конвертация bigint → number для Stripe
C4.1 операторское местоM3денежный линтер и depConstraints на границу api-core-money
B3.1 SettlementM4founderRefundMinor → колонка founder_amount_minor
A3 спор и расторжениеD1 и D2допущение об одном shareBps в splitSettlement
F2 (подграф core в mermaid)T2подграф добавляется вместе с первым ребром
  • Заглушек нет — единица документарная.
  • Открытый долг из F1: docs/DOC.zip достижим из коммита 327c31f в истории смерженной ветки. Вычистить можно только переписыванием истории — решение Stark’а, не сессии.
  • Открытый долг из F1: у либ libs/apis/core/ нет таргетов build и typecheck, поэтому компиляционные гейты domain-events подключены к спеке side-effect-импортом. Единице, которая заведёт настоящий typecheck, стоит проверить, не стал ли приём лишним.
  • Следующая единица — F2 (целевая схема, сиды, платформенные роли). Зависимостей нет, F1 закрыта. Обязательна сверка enum’ов с @crewsforge-back/apis/core/domain-events.
  • Первая единица с фронтовой поверхностью — T1 (команды). Она же закрывает дыру шага 9.
  • Сессия запускается командой /crewsforge-session; она сама остановится на бизнес-брифе.

F2 — Целевая схема, сиды, платформенные роли

Заголовок раздела «F2 — Целевая схема, сиды, платформенные роли»

Открыта: 2026-08-30 · Статус записи: в работе Зависимости на момент захвата: нет (F1: DONE) Ветка: feat/f2-baseline-schema

Задача: база перестаёт быть базой прошлой версии продукта — в ней появляются все 32 модели сделочного контура (команды, план, этапы, холды, расчёты, споры, расторжения, очередь задач, таймеры, уведомления, консьерж). Платформа получает три рабочих места вместо одного «админа»: оператор, администратор, арбитр — с запретом совмещения. Логин под каждым работает, и по токену видно, куда пускать.

Сущности. Все из шага 2, группами:

  • Команда: Team, TeamMember (оси rolecanSign/canSubmit), TeamInvitation
  • Проект и план: переработанный Project (новый ProjectStatus 10 значений, contractingStage, валюта, бюджетные вилки, commissionRateBps), Specification, PlanVersion
  • Работа: Milestone, AcceptanceCriterion, Artifact, MilestoneSubmission, AcceptanceReview, ReviewRejectionItem
  • Контрактинг: Party, PartyDocument, Contract (+ templateVersion/documentHash, шаг 6 §2), Offer
  • Деньги: PayoutAccount, Hold, Settlement (денормализованный hold_amount_minor под CHECK), WebhookEvent
  • Спор и расторжение: Dispute, DisputePosition, DisputeEvidence, Termination
  • Операционный контур: StateTransition, OutboxEvent, QueueItem, Timer, Notification
  • Консьерж и трекер: ConciergeThread, ConciergeMessage, TrackerLink
  • Роли: RoleType расширяется на OPERATOR и ARBITER

API для фронта:

МетодПутьКто вызываетНазначениеВозвращает
POST/api/v1/auth/admin/login + /login/verifyсотрудник платформысуществующий 2FA-логин, теперь кладёт в JWT claim platformRoleпара токенов; в payload platformRole: OPERATOR|ADMIN|ARBITER
GET/api/v1/operator/meоператоркто я и какое у меня рабочее место{ userId, email, platformRole: OPERATOR }
GET/api/v1/administrator/meадминистраторто же для администратора{ ..., platformRole: ADMIN }
GET/api/v1/arbiter/meарбитрто же для арбитра{ ..., platformRole: ARBITER }

Плюс: существующие защищённые маршруты admin-api (/api/v1/projects, /api/v1/users) получают AdministratorGuard — до этого туда пускал любой валидный admin-токен, что с появлением оператора и арбитра стало бы дырой O-1.

Можно собрать на фронте: экран входа в бэк-офис и роутинг после логина — три рабочих места по одному ответу /me, с честными 403 при попытке зайти не в своё.

Чего ещё нельзя: ничего доменного. Ни команд, ни проектов нового вида, ни этапов, ни очереди задач, ни денег — таблицы существуют пустыми, писать в них некому до T1. ProjectStatus в базе меняется на новый набор из 10 значений: ломающее изменение, заявленное шагом 8.

Правки скоупа при утверждении: скоуп не менялся. Stark подтвердил три решения, вынесенные в бриф: (1) тесты по реальной БД гоняются на локальном docker-контейнере PostgreSQL, который сессия поднимает сама; (2) billing-api и админка появятся сильно позже, поэтому запрет ArbiterGuard в billing-api доказывается тестом «нигде, кроме admin-api» с расширением в T3; (3) навешивание AdministratorGuard на существующие админские маршруты фронт не ломает — админки ещё нет.

Закрыта: 2026-08-30 · Статус записи: закрыта · PR: #31

  • Целевая схема шага 2 одной baseline-миграцией. apps/core-api/prisma/schema.prisma — 61 модель (30 прежних + 31 новая) и 42 enum’а; 14 прежних миграций схлопнуты в 20260830000000_baseline. Дельты на месте: templateVersion/documentHash (шаг 6 §2), ConciergeThread/ConciergeMessage (шаг 7 §5), частичный индекс дедупа QueueItem (шаг 7 §2).
  • Четыре инварианта на уровне БД: settlements_amounts_sum_to_hold (CHECK), queue_items_open_dedup, users_on_roles_single_platform_role, roles_type_key. Последний добавлен по итогам ревью: без уникальности типа роли предыдущий индекс обходился второй строкой roles того же типа с другим id.
  • Политика удаления: Restrict на всей сделочной цепочке, Cascade — только у профильных сателлитов, сессий, приглашений и тредов Walrider; список каскадов закреплён allow-list-тестом.
  • Сиды baseline: пять ролей по детерминированным id, три платформенных пользователя, фаундер, команда с разведёнными осями rolecanSign/canSubmit, два проекта (DRAFT, TEAM_MATCHING). Сиды идемпотентны и подхватывают пользователя, уже занявшего фикстурный email.
  • Три платформенные роли в admin-JWT: claim platformRole выводится из UserOnRole, перевыводится при каждом выпуске пары, включая refresh, и сверяется с базой на каждом запросе — снятая роль перестаёт действовать сразу, а не через 15 минут.
  • Три guard’а (OperatorGuard, AdministratorGuard, ArbiterGuard) и три маршрута GET /api/v1/{operator|administrator|arbiter}/me в новой библиотеке admin-api-feature-workspace. Существующие /api/v1/projects и /api/v1/users закрыты AdministratorGuard.
  • Снята старая машина статусов проекта (подтверждено Stark’ом): PROJECT_STATUS_TRANSITIONS обеих копий, ProjectUpdateStatusDto, founder-маршрут PATCH .../status, поле status в AdminProjectUpdateDto. Возвращается переходами машины в P1.
  • Обвес тестов по живой БД: docker-compose.test.yml (PostgreSQL 16 на 55432 + Redis на 56379), npm-скрипты test:db:up/down, таргеты integration у core-api-e2e и admin-api-e2e, таргет flows у admin-api-e2e, таргет test у core-api с зависимостью от prisma:schema:generate.

Полный блок — в плане. Проверен субагентом crewsforge-decision-reviewer в два круга: первый вернул 6 возражений (все приняты, блок переписан), второй — 4 пропуска (логин не-ADMIN, потеря claim’а при refresh, unit-спеки снятой поверхности, свежесть сгенерированного клиента); все закрыты до реализации.

Ключевые: squash в один baseline (решение №31) · @@map и @map по coding-rules §1.11 · запрет совмещения платформенных ролей индексом БД, а не только сервисом (решение №19) · claim несёт значения RoleType (ADMIN), карта PLATFORM_ROLE_BY_ROLE_TYPE переводит их в PlatformRole схемы (ADMINISTRATOR) · конфиг-модуль параметров платформы не заводится — нет потребителя · сверка enum’ов сравнивает три источника (текст схемы, сгенерированный клиент, union’ы шины).

Критерий карточкиЧем доказан
migrate reset даёт целевую схему, validate зелёныйprisma migrate deploy на чистом контейнере + migrate diff --from-url … --to-schema-datamodel-- This is an empty migration.
Удаление проекта со сделочной цепочкой падает в БДdeal-chain-deletion.integration.spec.ts — P2003 с именем FK, строка остаётся
Settlement не сходится в холд → отказ БДsettlement-amounts.integration.spec.ts — raw INSERT мимо сервиса, ассерт на имя констрейнта
Кросс-ролевые 403; вторая платформенная роль не выдаётсяplatform-role-access.integration.spec.ts (матрица 3×5 по живому HTTP), platform-role-uniqueness.integration.spec.ts
Сверка enum’ов роняет прогон при расхожденииapps/core-api/prisma/schema-enums.spec.ts — 12 утверждений; доказано мутацией схемы и протухшим клиентом
ArbiterGuard не регистрируется вне admin-apiarbiter-guard-isolation.spec.ts — краснеет при добавлении импорта в другой сервис (проверено)
Чек-лист шага 8 §3инвариант-тесты ✓, SQL-проверка отсутствия CASCADE ✓ (deal-chain-foreign-keys, cascade-allowlist), удаление старой машины статусов ✓; пункт «журнал + outbox на смоук-переходах» невыполним до T2 — машин ещё нет

HTTP-e2e: admin-api-e2e:integration — 35 тестов, включая сквозной вход оператора, администратора и арбитра из сидов через настоящий Redis и выдачу пары, и матрицу доступа. core-api-e2e:integration — 28 тестов. admin-api-e2e:flows — 7. Swagger admin-api содержит все три маршрута брифа.

Ревью: усиленный тир, два crewsforge-unit-reviewer параллельно. Оба вернули FAIL — 22 находки, 20 исправлены (в том числе три дыры: claim жил до истечения токена, индекс платформенных ролей обходился второй строкой справочника, сид падал на занятом email). Две отклонены с обоснованием — см. «Отклонённые замечания» в теле PR.

  • Шаг 8 §3 правлен: удаление PROJECT_STATUS_TRANSITIONS / ProjectUpdateStatusDto / PATCH-маршрутов статуса перенесено из эпика A1 (= P1) в F2. Основание: смена enum’а не оставляет старой таблице переходов возможности скомпилироваться. Подтверждено Stark’ом 2026-08-30.
  • Шаг 8 §2, «Конфиги» не исполнен: карточка F2 конфигов не называет, потребителя в единице нет, календарь уже живёт константой api-core-platform-calendar (F1). Параметры приезжают со своими потребителями (O1, M2, M4); таблицы под них не заводилось — её нет в схеме шага 2.
  • Шаг 4 §1 против шага 2 §9: claim несёт ADMIN, enum схемы — ADMINISTRATOR. Документы не правились: множества разные по назначению (токен против QueueItem.targetRole), перевод делает карта в shared, тип claim’а назван PlatformRoleClaim, чтобы имена не сталкивались.
  • Представление денег в HTTP-ответах — целое число минорных единиц. Правила в step-документах нет; введено по подтверждению Stark’а 2026-08-30 после того, как GET /api/v1/projects начал отдавать 500 на BigInt.
  • GeoSeeder не работает: внешний источник restcountries v3.1 объявлен устаревшим и отдаёт заглушку вместо массива. Сбой справочников больше не роняет прогон (роли и фикстуры создаются), в development код возврата 0, на прочих окружениях — 1. TODO(F2) в init.seed.ts. Снимет — единица, которой понадобятся гео-справочники (P1), либо отдельная задача на миграцию источника.
  • Границы инвариантов БД названы честно в docs/05-data/database-schema.md: CHECK сверяет расчёт с денормализованным hold_amount_minor, а не с холдом; число расчётов по холду не ограничено; actor-ссылки (executed_by_user_id и др.) — uuid без FK. Всё это транзакционные инварианты M4/M5.
  • apps/core-api/.env.development указывает на удалённую базу (188.137.179.235:6432). nx run core-api:prisma:seed:run без явного POSTGRES_URL засеет её, и теперь ещё и dev-фикстурами. Решение — за Stark’ом.
  • Открытый долг из F1 не снят: docs/DOC.zip достижим из коммита 327c31f.
  • Следующая готовая единица — T1 (команды: состав, приглашения, полномочия). Зависимость F2 закрыта: Team, TeamMember, TeamInvitation есть в схеме, роли и guard’ы работают, dev-фикстура команды с разведёнными осями полномочий уже в сидах.
  • Прогон nx affected -t lint,test,build красный по предсуществующим долгам, не по этой ветке: часть проектов имеет таргет test без единого спека и падает с «No tests found» (проверено на main), остальное — нарушения границ Nx (admin-project, admin-user, admin-api-e2e, user-api-feature-*auth-api-feature-*). Ветка их не добавила и у admin-project уменьшила число ошибок с 7 до 1. Разгрести стоит отдельной задачей: пока прогон красный, он не сигнал.
  • T3 обязана расширить arbiter-guard-isolation.spec.ts явным упоминанием billing-api, когда сервис появится: сейчас тест доказывает «нигде, кроме admin-api».
  • api-core-money использует BigInt-литералы, поэтому любой проект, чей спек её импортирует, обязан иметь target: es2021 в своём tsconfig.spec.json (база держит es2015). Уже поднято у prisma-client, workspace, token, обоих *-e2e.
  • Интеграционные прогоны не входят в affected — их надо гонять руками: npm run test:db:up + nx run core-api-e2e:integration + nx run admin-api-e2e:integration.

T1 — Команды: состав, приглашения, полномочия

Заголовок раздела «T1 — Команды: состав, приглашения, полномочия»

Открыта: 2026-08-30 · Статус записи: в работе Зависимости на момент захвата: F2: DONE Ветка: feat/t1-teams

Задача: сотрудник регистрирует команду, зовёт людей по email, раздаёт им доступ и отдельно — право подписывать договоры и сдавать работу; администратор платформы верифицирует новые команды. После единицы команда существует как субъект: у неё есть состав и известно, кто вправе подписать договор и сдать этап. На это опираются оффер (P2), контрактинг (P3P5), сдача (M3) и выплатной аккаунт (T3).

Сущности:

  • Team — имя, уникальный слаг, публичное описание, внешний опыт (externalExperience), статус ONBOARDING → ACTIVE, штамп verifiedAt.
  • TeamMember — членство с двумя независимыми осями: role (ADMIN/EDITOR/VIEWER) — доступ внутри команды; canSign/canSubmit — полномочия по 2.2 ФТ. VIEWER с canSign: true — легальная комбинация. Ушедший участник → status: REMOVED, строка не удаляется.
  • TeamInvitation — приглашение на email: одноразовый токен, срок, статусы PENDING/ACCEPTED/DECLINED/EXPIRED/REVOKED.

API для фронта:

МетодПутьКто вызываетНазначениеВозвращает
POST/api/v1/teamsemployeeсоздать команду; создатель — ADMIN с обоими полномочиямикарточка команды
GET/api/v1/teams/:teamIdучастникпрофиль командыкарточка
PATCH/api/v1/teams/:teamIdADMIN командыправка имени, описания, внешнего опытакарточка
GET/api/v1/teams/:teamId/membersучастниксостав с ролями и полномочиямисписок членств
PATCH/api/v1/teams/:teamId/members/:memberIdADMIN командысменить роль и/или canSign/canSubmitчленство
DELETE/api/v1/teams/:teamId/members/:memberIdADMIN командыубрать участника (→ REMOVED)204
DELETE/api/v1/teams/:teamId/members/meучастниквыйти из команды204
GET/api/v1/teams/:teamId/invitationsADMIN командыприглашения команды, фильтр по статусусписок
POST/api/v1/teams/:teamId/invitationsADMIN командыпригласить по email с назначенной рольюприглашение + токен
DELETE/api/v1/teams/:teamId/invitations/:invitationIdADMIN командыотозвать (→ REVOKED)204
GET/api/v1/team-invitations/:tokenemployeeчто за приглашение: команда, роль, сроккраткая карточка
POST/api/v1/team-invitations/:token/acceptemployeeпринятьсозданное членство
POST/api/v1/team-invitations/:token/declineemployeeотклонить204
GET/api/v1/employees/me/teamsemployeeмои членства (мультичленство разрешено)список
GET/api/v1/administrator/teamsадминистраторсписок команд, фильтр по статусу, пагинация{ data, meta }
GET/api/v1/administrator/teams/:teamIdадминистраторкарточка команды с составомкарточка
POST/api/v1/administrator/teams/:teamId/verifyадминистраторONBOARDING → ACTIVE, штамп verifiedAtкарточка

Первые 14 — в user-api (решение №08: домен команд — либы team-* в user-api). Последние три — в admin-api под префиксом администратора (шаг 4 §8), отдельная либа-фича поверх того же домена.

Можно собрать на фронте: онбординг команды целиком — создание, профиль с правкой, экран состава с матрицей «роль × canSign × canSubmit», отправка и отзыв приглашений, страница приёма приглашения по ссылке, переключатель между своими командами. Админский экран: список команд с фильтром «на верификации» и карточка с кнопкой «верифицировать».

Чего ещё нельзя: письмо с приглашением не уходит (рассылка — O2; токен возвращается в ответе и передаётся приглашающим вручную) · задача TEAM_ONBOARDING администратору не заводится (очередь — O1; новая команда видна фильтром status=ONBOARDING) · статус меняется прямо, без журнала переходов и outbox, реализован единственный переход ONBOARDING → ACTIVE (SUSPENDED/ARCHIVED и журнал — T2) · команда не подключает выплаты (T3) и не получает офферы (P2).

Правки скоупа при утверждении: скоуп не менялся. Stark задал вопрос о размещении домена — почему либы в user-api, а не отдельный team-api; после разбора оснований (одна БД на монорепозиторий, employee-JWT и EmployeeGuard уже в user-api, guard «активное членство» в горячем пути авторизации, седьмой деплоймент при нулевом сегодняшнем выигрыше) решение №08 подтверждено без изменений: выделение в отдельный сервис — после беты, триггер «свой релизный цикл или своё масштабирование».

Закрыта: 2026-08-31 · Статус записи: закрыта · PR: #34

  • user-api-feature-team — доменное ядро команд: TeamService (создание с членством создателя, профиль, список с пагинацией, верификация), TeamMemberService (состав, две независимые оси, удаление, выход, реактивация ушедшего), TeamMembershipGuard + декоратор @TeamRoles, три контроллера (teams, teams/:teamId/members, employees/me/teams).
  • user-api-feature-team-invitation — приглашения: криптотокен, ленивое истечение, адресность приёма, пипа токена, контроллеры teams/:teamId/invitations и team-invitations/:token.
  • admin-api-feature-admin-team — поверхность администратора поверх того же доменного ядра: список с фильтром и пагинацией, карточка с составом, верификация ONBOARDING → ACTIVE.
  • user-api-data-access — DTO и константы домена; либа получила теги Nx, благодаря чему admin-api переиспользует её DTO вместо копии.
  • api-shared — валидатор IsAtLeastOneFieldDefined (пустое тело PATCH — 400).
  • apps/user-api-e2e — обвяз интеграционных прогонов (таргет integration по образцу F2) и два доменных спека; apps/admin-api-e2e — спек админской поверхности.

17 маршрутов из утверждённого брифа на месте, все в Swagger.

Полный блок — в плане. Проверен субагентом crewsforge-decision-reviewer в два круга: первый вернул три возражения по существу (скоуп вложенных ресурсов :memberId/:invitationId, контракт externalExperience, видимость строк REMOVED в списках) — все приняты и закрыты; второй — CONFIRMED по 52 решениям.

Ключевые: домен — либы в user-api (решение №08), админская поверхность переиспользует то же ядро · 403 за :teamId, 404 за вложенный ресурс своей команды · выборка вложенных ресурсов только по паре (teamId, id) · инвариант последнего администратора под блокировкой строки команды (SELECT … FOR UPDATE) · подбор слага снаружи транзакции · ленивое истечение приглашений · приглашение адресное на приёме и отклонении, предпросмотр открыт держателю токена.

Критерий карточкиЧем доказан
Сквозной HTTP-e2e: создать → пригласить → принять → выдать canSign → отказ последнему админу → убрать → строка REMOVEDteam-lifecycle.integration.spec.ts — 10 тестов по живой БД, шаг в шаг по карточке
Оси независимы: VIEWER с canSign: trueтот же спек (выдача canSign не трогает роль) + карточка команды в administrator-team.integration.spec.ts
Приглашение по истёкшему или использованному токену отвергаетсяteam-access.integration.spec.ts: 410 + перевод строки в EXPIRED (в том числе при приёме), 409 на принятом и отозванном, 404 на неизвестном
Чужой teamId — 403, а не 404матрица «9 маршрутов × чужая команда» в team-access.integration.spec.ts; доказана мутацией (замена на 404 роняет 9 тестов)
Swagger содержит все маршруты; примеры в PRдамп OpenAPI обоих сервисов; пары запрос-ответ в теле PR

HTTP-e2e: user-api-e2e:integration — 50 тестов, admin-api-e2e:integration — 58 (включая 35 из F2). Unit: 263 теста в пяти проектах. nx affected -t lint,test,build красный на 16 задачах — ровно тех же, что падают на чистом main (проверено прогоном в отдельном worktree); ветка не добавила ни одной новой.

Ревью: обычный тир, один crewsforge-unit-reviewer (СООТВЕТСТВИЕ) — PASS с десятью находками. Восемь исправлены, две закрыты документацией; ни одна не отклонена. Существенные: утечка пути к файлу и модели Prisma при нечисловом teamId, выдача полномочий участнику со статусом REMOVED, Swagger админского списка объявлял массив вместо { data, meta }, отсутствие 409/404 в контракте маршрутов состава.

  • Шаг 4 §7 даёт администратору платформы «R/W (онбординг, передача admin)» — передача роли в T1 не реализована: это расширение поверхности против утверждённого брифа. Помечено TODO(T2) в admin-team.controller.ts и описано в docs/03-services/admin/admin-api.md. Step-документ не правился: возможность отложена, а не отменена.
  • Шаг 9, дополнение 2026-08-30 объявляет машину ONBOARDING → ACTIVE ⇄ SUSPENDED → ARCHIVED. В T1 реализован единственный переход ONBOARDING → ACTIVE прямой записью — так сказано в карточке T1 («журнал придёт в T2»); расхождения с документом нет.
  • Письмо с приглашением не отправляется (TODO(O2) в TeamInvitationService.saveOne): токен возвращается в ответе на создание и в списке приглашений, фронт передаёт ссылку вручную. Снимет O2.
  • Задача TEAM_ONBOARDING администратору не заводится (TODO(O1) в TeamService.saveOne): очереди задач нет. Новая команда видна фильтром status=ONBOARDING. Снимет O1.
  • Передача роли администратора команды из бэк-офисаTODO(T2), см. «Отклонения».
  • PrismaExceptionFilter отдаёт сырой exception.message в fallback-ветке (например для P2023) — это утечка внутренностей на любом маршруте любого сервиса, где сырой запрос получает битый идентификатор. В T1 закрыто на входе guard’ом; сам фильтр — предсуществующий долг shared.
  • Три markdown-файла (docs/03-services/user/user-api.md, docs/03-services/admin/admin-api.md, README либы приглашений) переформатированы prettier’ом: репозиторий markdown не форматирует, и около 200 строк их дифа — переносы, а не смысл.
  • Следующая готовая единица — T2 (машины состояний и события). Зависимости F1 и T1 закрыты. T2 обязана: перевести смену статуса команды на журнал переходов и outbox (сейчас прямая запись), добавить переходы SUSPENDED/ARCHIVED и снять TODO(T2) в admin-team.controller.ts (передача роли администратора команды).
  • Контракты, на которые можно опираться: TeamCoreModule экспортирует TeamService и TeamMemberService — это единственная точка изменения состава; TeamMembershipGuard + @TeamRoles(...) закрывают любой маршрут вида /teams/:teamId/...; полномочия canSign и canSubmit уже разведены с ролью и ждут потребителей в P5 и M3.
  • Полномочия читать только у активных участников: строки REMOVED остаются в базе и хранят последние значения canSign/canSubmit на момент ухода. Любой потребитель обязан фильтровать по status: ACTIVE — домен свои записи закрыл, но чужие выборки об этом не знают.
  • Интеграционные прогоны не входят в affected, гонять руками: npm run test:db:up + nx run user-api-e2e:integration + nx run admin-api-e2e:integration + nx run core-api-e2e:integration.
  • Теги Nx проставлены у user-api-data-access. У auth-api/features/token и остальных либ их по-прежнему нет — из-за этого admin-api не может импортировать token, и AdminTokenPayload берётся из api-shared. Разгрести стоит отдельной задачей.

Открыта: 2026-08-31 · Статус записи: в работе Зависимости на момент захвата: F1: DONE, F2: DONE, T1: DONE Ветка: feat/t2-state-machines

Задача: администратор получает полный жизненный цикл команды — верифицировать, приостановить с указанием причины, вернуть в строй, архивировать. Каждое действие оставляет неудаляемую строку журнала (кто, когда, на каком основании) и в той же транзакции порождает доменное событие в outbox. Команда видит, что приостановлена, и по какой причине. Механизм, делающий это атомарно и без гонок, строится здесь и принимается на первой настоящей машине, а не на синтетическом тесте.

Сущности: новых моделей и миграции нет — F2 уже завёл обе таблицы.

  • StateTransition — журнал переходов, append-only; пишет только либа state-machine.
  • OutboxEvent — доменное событие в той же транзакции; никем ещё не читается.
  • Машина статуса команды ONBOARDING → ACTIVE ⇄ SUSPENDED → ARCHIVED.
  • Новый routing key team.status.changed в каталоге событий F1: SubjectType.TEAM в каталоге есть, ключа для команды нет, и компиляционный гейт CatalogCoversEveryRoutingKey заставляет добавить его осознанно.

API для фронта:

МетодПутьКто вызываетНазначениеВозвращает
POST/api/v1/administrator/teams/:teamId/verifyадминистраторONBOARDING → ACTIVE — существует с T1, переписывается на машинукарточка команды
POST/api/v1/administrator/teams/:teamId/suspendадминистраторACTIVE → SUSPENDED, reason обязательнакарточка команды
POST/api/v1/administrator/teams/:teamId/reinstateадминистраторSUSPENDED → ACTIVE, reason опциональнакарточка команды
POST/api/v1/administrator/teams/:teamId/archiveадминистратор→ ARCHIVED из любого статуса, reason обязательнакарточка команды
GET/api/v1/administrator/teams/:teamId/transitionsадминистратористория: откуда, куда, кто, когда, основание, причина{ data, meta }
POST/api/v1/administrator/teams/:teamId/members/:memberId/grant-adminадминистраторвыдать роль ADMIN активному участнику (снимает TODO(T2) из T1)карточка участника
GET/api/v1/teams/:teamIdучастник командырасширяется блоком suspension: { reason, since } | nullкарточка команды

Коды отказов, единые для всех переходов: 409 TRANSITION_CONFLICT (состояние не то либо кто-то опередил), 422 с кодом guard’а, 403 ACTOR_NOT_ALLOWED с записью попытки в журнал (metadata.denied).

Можно собрать на фронте: админский экран команды целиком — четыре кнопки действий с формой причины, состояние кнопок выводится из текущего статуса, вкладка «История» с лентой «администратор X приостановил 12 августа, основание: …», передача роли администратора команды. В интерфейсе команды — баннер приостановки с причиной и датой.

Чего ещё нельзя: события ложатся в outbox и никем не читаются — relay и консьюмеры в O1; письмо о приостановке не уходит (O2); журнал переходов не отдаётся ни команде, ни фаундеру — вообще ни одним клиентским маршрутом (L-4, решение №19); задача TEAM_ONBOARDING по-прежнему не заводится (O1); приостановка технически ничего не блокирует, кроме самой себя — блокировать нечего, проектов и денег ещё нет.

Правки скоупа при утверждении: сессия вынесла на гейт три развилки, Stark согласился с рекомендацией по каждой.

  1. Архивация из любого статуса, а не только из SUSPENDED. Шаг 3 и карточка дают цепочку ONBOARDING → ACTIVE ⇄ SUSPENDED → ARCHIVED, из которой буквально следует архивация только из SUSPENDED; тогда брошенную на онбординге команду-спам убрать нечем. Реализуется один переход с from: [ONBOARDING, ACTIVE, SUSPENDED] — ни один инвариант не ослаблен.
  2. Передача роли admin команды взята в скоуп — расширение поверхности против карточки T2, но прямое требование шага 4 §7 и незакрытый TODO(T2) из T1: команда, потерявшая единственного администратора, сейчас нередактируема навсегда.
  3. Резолвер стороны сделки не реализуется, объявляется только вариант { side, authority } в типе ActorSpec. У машины команды актора-стороны нет — все переходы делает администратор платформы; реализация без потребителя непроверяема. Резолв приедет с P1/P2.