food-market/docs/RUNBOOK.md
nns cf760fab10
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
feat(s26): flaky-test detection + observability dashboards (8/8 ✓ 10/10 cert)
После 24 спринтов regress-suite разросся; нестабильность блокирует
доверие. Этот спринт: ловит flaky тесты, добавляет observability
(Grafana + Prometheus alerts + RUNBOOK), сертифицирует 10× cert-прогон.

1. tests/regression/find-flaky.sh — 10× прогон + JSON-агрегатор →
   docs/flaky-tests.md (per-test pass/fail sequence + reproduce).
2. OrgFactory.signupWithRetry теперь honors Retry-After header
   (api-client.ts:ApiError.retryAfterSec). Stage rate-limit поднят:
   RATE_SIGNUP_HOUR=5000, RATE_PER_IP_MIN=5000 (~/food-market-stage/deploy/.env).
3. fullyParallel=true + workers=4 = тесты идут в недетерминированном
   порядке; isolation работает (OrgFactory per-test).
4. workers=4 даёт **2.4× ускорение** (66.6s → 27.7s). Worker-scoped
   фикстура lib/worker-org.ts добавлена как opt-in.
5. deploy/grafana/dashboards/quality-watchdog.json (10 панелей:
   smoke success ratio 7d, incidents, multi-tenant violations,
   current emoji, p95 by endpoint, step failures, RPS, DB p95,
   docs posted, disk free) + dashboards/README.md.
   quality-watchdog.sh пишет Prometheus textfile экспорт в
   ~/.fm-watchdog/textfile/quality_watchdog.prom для node_exporter.
6. deploy/prometheus/alerts.yml — 10 правил, 4 группы (uptime,
   errors, database, quality-watchdog). MultiTenantViolation = P0.
   deploy/prometheus/prometheus.yml — reference config.
7. docs/RUNBOOK.md +178 строк: action per alert (api-down,
   rps-drop, http-errors-spike/growing, doc-posting-errors,
   db-p95-high, disk-free-low, watchdog-red, multi-tenant-violation,
   watchdog-incident). Junior-friendly с конкретными командами.

**Cert-прогон (10× workers=4):** 420/420 passed, 0 flaky, avg 30.1s/run,
total 300.6s (< 5min budget).

Изменения вне репо:
- ~/food-market-stage/deploy/.env — RATE_* limits bumped.
- ~/quality-watchdog.sh — добавлен .prom textfile экспорт.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 14:44:19 +05:00

29 KiB
Raw Permalink Blame History

Runbook — операционные процедуры food-market

Что делать, когда что-то идёт не так, или когда нужно сделать неавтоматическую операцию.

Контактные точки

Что Где
Stage URL https://test.admin.food-market.kz
Prod URL https://admin.food-market.kz (план, ещё не задеплоен)
Stage VM nns@192.168.1.190 (через ssh, prod-vm в локалке)
Dev VM (этот хост) nns@<this> — здесь крутится локальный API/Postgres + локальный Forgejo + локальный Docker registry
Forgejo (primary git) http://127.0.0.1:3000/nns/food-market.git
GitHub (mirror) https://github.com/nurdotnet/food-market (только зеркало)
Local Docker registry 192.168.1.193:5001 (memory: local_docker_registry)
Hangfire Dashboard (stage) https://test.admin.food-market.kz/hangfire — только SuperAdmin
Swagger (stage) https://test.admin.food-market.kz/swagger

Health-чеки

Endpoint Что значит Что делать при 503
GET /health Процесс отвечает Контейнер живёт, проблема в обвязке (nginx/cert/DNS).
GET /health/live Процесс жив (без проверок) То же.
GET /health/ready БД отвечает + миграции применены См. ниже «Health/ready упал».
GET /metrics Prometheus exposition Если 404 — приложение не стартануло.

/health/ready упал

  1. ssh nns@192.168.1.190 'docker logs --tail 100 food-market-stage-api' — стек ошибки на старте.
  2. Типичные причины:
    • Миграция упала: ищем Failed executing DbCommand / relation "..." already exists. Решение: миграция конфликтует со снапшотом БД. Возможно её надо переписать с IF NOT EXISTS (см. Phase6e_RetailSaleReturns.cs как пример «defensive migration»).
    • OpenIddict cert pass mismatch: переменная OpenIddict__CertPassword в docker-compose env'е не совпадает с паролем PFX-файла → CryptographicException: PKCS12 password incorrect.
    • Connection refused: Postgres контейнер не успел подняться. depends_on.condition: service_healthy должно это покрывать, но если healthcheck не успел — docker compose restart api.
  3. Если фикс требует кода — ~/deploy-stage.sh после правки.

Деплой на stage

~/deploy-stage.sh

Скрипт делает:

  1. docker build api и web с локальным registry в качестве кеша.
  2. docker push обоих образов в 192.168.1.193:5001.
  3. ssh nns@192.168.1.190docker compose -p food-market-stage pull api webup -d --force-recreate.
  4. Ждёт https://test.admin.food-market.kz/health/ready до 30с.

Важно: проект docker compose называется food-market-stage (флаг -p food-market-stage). См. инцидент ниже про project name.

Бэкап и восстановление

Расписание

systemd-таймер food-market-backup.timer (см. deploy/) запускается каждый день в 03:00 локального времени prod-vm. Запускается через OnCalendar=*-*-* 03:00:00 + Persistent=true (догоняет пропущенные если сервер был выключен).

Скрипт food-market-backup.sh:

  • pg_dump -Fc из контейнера food-market-postgresdb-<TS>.dump.
  • tar czf каталога /opt/food-market-data/uploadsuploads-<TS>.tgz.
  • Удаляет файлы старше 30 дней (FM_BACKUP_RETENTION_DAYS).

Папка: /opt/food-market-data/backups/.

Ручной бэкап

ssh nns@192.168.1.190 'sudo /opt/food-market/deploy/food-market-backup.sh'

Или из репо разработчика:

deploy/backup.sh --remote 192.168.1.190:5434   # PG в Docker exposed на 5434

Recovery drill (RTO ≈ 25 секунд на сегодняшних данных)

Sprint 15 — verified восстановление stage'а в свежий PG-контейнер на dev-vm:

Шаг Время
pg_dump -Fc из stage-postgres ~2 секунды (на 1.5k чеков / 200 продуктов)
Создать чистый Docker-контейнер postgres:16-alpine ~1 сек
pg_restore --clean --if-exists --no-owner --no-privileges ~4 секунды
Поднять API против восстановленной БД ~19 сек (cold-start dotnet + migrations)
/health/ready{"status":"Healthy"} подтверждено
Всего RTO (single-instance) ~25 секунд

Команды, выполненные в drill'е:

# 1. Снять бэкап со stage'а
ssh nns@192.168.1.190 'docker exec food-market-stage-postgres-1 \
  pg_dump -U food_market -d food_market -Fc -f /tmp/drill.dump'
ssh nns@192.168.1.190 'docker cp food-market-stage-postgres-1:/tmp/drill.dump /tmp/drill.dump'
scp nns@192.168.1.190:/tmp/drill.dump /tmp/drill.dump

# 2. Чистый PG
docker run -d --name drill-pg \
  -e POSTGRES_DB=food_market \
  -e POSTGRES_USER=food_market \
  -e POSTGRES_PASSWORD=drill_pass \
  -p 127.0.0.1:5499:5432 postgres:16-alpine

# 3. Восстановление
docker cp /tmp/drill.dump drill-pg:/tmp/drill.dump
docker exec drill-pg pg_restore -U food_market -d food_market \
  --clean --if-exists --no-owner --no-privileges /tmp/drill.dump

# 4. Проверка: API на восстановленной БД
ASPNETCORE_ENVIRONMENT=Production \
  ConnectionStrings__Default="Host=localhost;Port=5499;Database=food_market;Username=food_market;Password=drill_pass" \
  Hangfire__Enabled=false \
  ASPNETCORE_URLS="http://127.0.0.1:5099" \
  RateLimiting__Enabled=false \
  dotnet run --project src/food-market.api

curl http://127.0.0.1:5099/health/ready
# → {"status":"Healthy", checks:[{"name":"database","status":"Healthy",
#    "description":"БД доступна, миграции применены."}]}

# Очистка
docker rm -f drill-pg

Для прод-данных большего объёма (50k+ чеков) RTO будет ~2-5 минут — но порядок остаётся: pg_restore линейно по данным + API startup константный.

Восстановление БД из дампа

⚠️ Перезаписывает данные. Сначала остановить API.

ssh nns@192.168.1.190
cd /opt/food-market

# 1. Остановить API/Web, оставить Postgres
docker compose -p food-market-stage stop api web

# 2. Применить дамп
DUMP=/opt/food-market-data/backups/db-YYYYMMDD-HHMMSS.dump
docker exec -i food-market-stage-postgres \
    pg_restore -U food_market -d food_market \
    --clean --if-exists --no-owner --no-privileges \
    < "$DUMP"

# 3. Поднять API обратно — миграции применятся автоматически (idempotent)
docker compose -p food-market-stage up -d api web

# 4. Проверить
curl https://test.admin.food-market.kz/health/ready

Восстановление uploads

ssh nns@192.168.1.190
cd /opt/food-market-data
sudo tar xzf backups/uploads-YYYYMMDD-HHMMSS.tgz
# Содержимое восстанавливается в текущий каталог (uploads/...)

Полный disaster-recovery (новый сервер)

  1. Поднять Docker, склонировать репо в /opt/food-market.
  2. Скопировать бэкапы в /opt/food-market-data/backups/.
  3. Запустить пустой стек:
    cd /opt/food-market/deploy
    docker compose -p food-market-stage up -d postgres
    docker compose -p food-market-stage exec postgres pg_isready
    
  4. Применить дамп (см. выше).
  5. Восстановить uploads.
  6. Запустить остальное: docker compose -p food-market-stage up -d.
  7. Поднять nginx + сертификат (см. docs/stage-access.md).
  8. Включить таймер бэкапов:
    sudo cp deploy/food-market-backup.{service,timer} /etc/systemd/system/
    sudo systemctl daemon-reload
    sudo systemctl enable --now food-market-backup.timer
    

Перенос на другой сервер

  1. На старом — снять свежий бэкап вручную.
  2. На новом — поднять Docker, склонировать репо, восстановить (см. выше).
  3. Обновить DNS A-запись admin.food-market.kz на новый IP.
  4. Дождаться распространения DNS (TTL).
  5. Старый сервер — выключить через 24 часа (для гарантии).

Смена SDK-версии

⚠️ global.json фиксирует 8.0.417 с rollForward: latestFeature. Менять только когда вышел новый patch и Microsoft анонсировал EOL текущего. memory: НЕ переключать systemwide postgres версию.

  1. На dev-машине: dotnet --list-sdks — проверить что новая версия установлена.
  2. Обновить global.json → новый patch.
  3. dotnet build + dotnet test.
  4. deploy/Dockerfile.api — обновить FROM mirror/dotnet-sdk:X.Y (если тэг изменился).
  5. ~/deploy-stage.sh — задеплоить, проверить /health/ready.
  6. Verify-suite (Playwright или вручную smoke).
  7. Только после этого — менять на prod-машине.

Логи

Где Что
docker logs food-market-stage-api (по контейнеру) Console JSON Serilog.
/opt/food-market-data/api-logs/ (Docker volume) Файлы Serilog rolling.
journalctl -u food-market-backup.service --no-pager Логи бэкапа.
Hangfire Dashboard /hangfire Состояние фоновых джобов, истории, ошибки.

Формат JSON-логов — структурированный, каждая запись содержит CorrelationId, OrgId, UserId (через LogEnrichmentMiddleware). Поиск по CorrelationId восстанавливает полный trace запроса.

Метрики

Prometheus scrape: GET /metrics (без auth). Локально в проекте нет prometheus-сервера — на stage его тоже пока нет; план — поднять prometheus + grafana отдельным compose'ом и proxy через nginx.

Ключевые метрики (food-market.api/Infrastructure/Observability/AppMetrics.cs):

  • food_market_posted_total{document_type="..."} — счётчик post'ов.
  • food_market_unposted_total{document_type="..."} — счётчик unpost'ов.
  • food_market_db_query_duration_seconds_* — гистограмма EF-запросов (interceptor).
  • Стандартные prometheus-net: http_requests_received_total, http_request_duration_seconds, dotnet_collection_count_total, etc.

Известные инциденты

Инцидент 1: docker-compose project name

Симптом (наблюдался при первой миграции на новый stage):

  • docker compose pull && up -d создавали контейнеры с именами deploy-api-1 вместо ожидаемых food-market-stage-api.
  • Healthcheck'и depends_on отрабатывали по новым именам, но nginx configurated на старые — 502 Bad Gateway.

Причина: docker compose берёт project name из имени каталога, если не указан -p. Каталог deploy/ → project=deploy → контейнеры с префиксом deploy-. Старые контейнеры с префиксом food-market-stage- оставались стопнутыми, новые поднялись параллельно (Docker не считает их дубликатами потому что имена разные).

Решение: всегда передавать -p food-market-stage. Сделано в ~/deploy-stage.sh. На prod ставить аналогичный wrapper-скрипт, не запускать docker compose голым из /opt/food-market/deploy.

Превенция: в будущем — COMPOSE_PROJECT_NAME=food-market-stage в /etc/environment на серверах, чтобы голый docker compose тоже не промахивался.

Инцидент 2: GHCR network flakiness

Симптом: docker push/pull в ghcr.io периодически зависает на 2-5 минут или падает по TCP-таймауту.

Причина: исходящая сеть с dev-vm к github.com нестабильна (memory: network_github_flaky).

Решение: используем локальный Docker registry на 192.168.1.193:5001 как primary, ghcr только как mirror (для external CI/CD когда понадобится). Stage compose тянет с локального (REGISTRY=192.168.1.193:5001). См. memory local_docker_registry.

Инцидент 3: OpenIddict cert rotation

Симптом: после docker compose down -v (с удалением volume api-data) OpenIddict не может расшифровать существующие refresh-токены → все пользователи разлогинены.

Причина: keys из App_Data/oidc-keys/ пропали вместе с volume.

Решение / превенция:

  • НИКОГДА не делать down -v на stage/prod без явного намерения.
  • Хранить App_Data volume отдельно: volumes: api-data: с external: true (план).
  • Бэкап App_Data вместе с БД (TODO: добавить в food-market-backup.sh).

Инцидент 4: rate-limiter eager-config

Симптом (в integration-тестах): тесты падают с 429 Too Many Requests после ~5 signup'ов.

Причина: RateLimiting:Enabled=true (default) читается ЭАГЕРНО при регистрации сервисов; ConfigureAppConfiguration в WebApplicationFactory применяется позже и не успевает override'нуть.

Решение: в integration-тестах ставим RateLimiting__Enabled=false через переменную окружения до создания factory. Сделано в ApiFactory static-конструкторе. Memory: test_suites_setup.

Инцидент 5: Telegram chat-id привязка

Симптом: владелец org вводит chat_id, сервер тестирует отправку → 403 Forbidden от Telegram API.

Причина: пользователь не отправил /start боту перед привязкой, бот не может писать первым.

Решение / превенция: UI показывает инструкцию «1. Откройте бота → /start. 2. Получите chat_id у @userinfobot. 3. Введите.» Это идёт сверху на странице привязки. Бэкенд возвращает ошибку с понятным текстом.

Инцидент 6: Identity password policy

Симптом: signup-форма принимает пароль 12345, потом /connect/token отшивает «Invalid credentials» — потому что Identity сам не разрешил создать пользователя с таким паролем, но контроллер проглотил ошибку.

Превенция: контроллер AuthController.Signup теперь возвращает IdentityResult.Errors массивом → фронт показывает причину.

Troubleshooting на стороне БД

Большой org_audit_log

prune-audit-log каждый день чистит >180 дней; если каталог-tenant делал массовый импорт (10к товаров за раз), таблица может вырасти на порядок. Проверка:

SELECT pg_size_pretty(pg_total_relation_size('org_audit_log'));
SELECT count(*) FROM org_audit_log WHERE created_at < now() - interval '30 days';

Ручная чистка:

DELETE FROM org_audit_log WHERE created_at < now() - interval '90 days';
VACUUM ANALYZE org_audit_log;

Stock-агрегат расходится с движениями

Инвариант: stocks.quantity = SUM(stock_movements.quantity) per (product_id, store_id). Если разошёлся (баг где-то не вызвали IStockService.ApplyMovementAsync):

-- найти расхождения
SELECT s.product_id, s.store_id, s.quantity AS cached,
       COALESCE(SUM(m.quantity), 0) AS actual
FROM stocks s
LEFT JOIN stock_movements m
    ON m.product_id = s.product_id AND m.store_id = s.store_id
GROUP BY s.product_id, s.store_id, s.quantity
HAVING s.quantity <> COALESCE(SUM(m.quantity), 0);

-- пересчитать всё (под maintenance window!)
UPDATE stocks s SET quantity = COALESCE((
    SELECT SUM(quantity) FROM stock_movements m
    WHERE m.product_id = s.product_id AND m.store_id = s.store_id
), 0);

__EFMigrationsHistory рассинхрон

Бывает после ручной правки миграции после её применения.

SELECT * FROM "__EFMigrationsHistory" ORDER BY 1 DESC LIMIT 5;

Если в коде есть миграция, которой нет в таблице — db.Database.Migrate() попытается её применить (что обычно и нужно). Если в таблице есть запись, а файла нет — обратное направление (миграция была удалена) — Migrate() не упадёт, но фокус с EF Tools перестанет работать, см. memory feedback_ef_migrations.

Sprint 26 — Alert response (deploy/prometheus/alerts.yml)

Каждый alert в alerts.yml имеет runbook label — anchor сюда. Junior-разработчик находит alert в Telegram, кликает runbook_url, попадает на соответствующий раздел.

api-down

Alert: ApiDownup{job="food-market-api"} == 0 1 минуту.

Что значит: Prometheus не может scrap'нуть /metrics. API либо упал, либо порт недоступен.

Действия:

  1. curl -fsS https://test.admin.food-market.kz/health/ready — подтверди что API недоступен (или вернулся).
  2. ssh nns@192.168.1.190 'docker ps | grep food-market-stage-api' — контейнер живой?
  3. Если контейнер в Restarting: docker logs --tail 200 food-market-stage-api-1 — стек ошибки старта (часто миграция / OpenIddict cert mismatch).
  4. Если контейнер OK, но не отвечает: docker exec food-market-stage-api-1 curl -s http://localhost:8080/health (внутренний порт). Если внутри отвечает, проблема в nginx/proxy цепочке.
  5. Recovery: cd ~/food-market-stage/deploy && docker compose -p food-market-stage up -d --force-recreate api.
  6. Если не помогло: ~/deploy-stage.sh с локального dev-vm (полный build+push+restart).

rps-drop

Alert: RpsDropped50Percent — RPS за 5 мин <50% от того же окна час назад, при условии что было >0.5 rps.

Действия:

  1. curl https://test.admin.food-market.kz/health/ready — API живой?
  2. ssh nns@192.168.1.190 'docker logs --tail 50 food-market-stage-api-1' — внезапные ошибки на старте/в логе.
  3. Проверь DNS из дома/мобильной сети: dig admin.food-market.kz — возможно сломалась запись.
  4. Откати последний deploy: cd ~/food-market-stage/deploy && git log -1 --format=%H && docker compose pull && docker compose up -d. Если откат на предыдущий image помог — баг в новом коде, см. логи.

http-errors-spike

Alert: HttpErrorsSpike — доля 5xx >10% уже 5 минут.

Действия:

  1. Logs: ssh nns@192.168.1.190 'docker logs --tail 200 food-market-stage-api-1 | grep -iE "(error|exception)"'.
  2. Какая ручка валится: curl https://test.admin.food-market.kz/metrics | grep 'http_requests_received_total.*code="5' — топ-3 контроллеров.
  3. Hangfire-failed: curl -u admin /hangfire/jobs/failed (нужен SuperAdmin login).
  4. Часто — БД упала. См. db-p95-high раздел ниже.
  5. Если баг локален (только одна ручка валится): найди фикс, deploy.

http-errors-growing

Alert: HttpErrorRateGrowing — производная 5xx растёт >10%/min 10 мин.

Действия: Постепенная деградация, не emergency. Часто — память течёт или коннекшен-пул исчерпывается.

  1. Memory: docker stats food-market-stage-api-1 (ratio %).
  2. PG connections: psql ... -c "SELECT count(*), state FROM pg_stat_activity GROUP BY state" — если idle in transaction много, есть leak.
  3. Restart api: docker compose -p food-market-stage restart api — куплено время.
  4. Найди корень в логах — кто часто получает Exception.

doc-posting-errors

Alert: DocumentPostingErrors — типа документов > 0.05 ошибки/сек 5 мин.

Действия:

  1. docker logs food-market-stage-api-1 | grep "Posting failed" — ищи название документа в логе.
  2. Hangfire failed: документы постятся через Hangfire-job — посмотри /hangfire/jobs/failed.
  3. Stock-инвариант: Posting failed: stock would be negative означает попытку списать больше чем есть. Это бизнес-уровневая ошибка, не баг. Сообщи владельцу org. Если ошибок много — возможно баг в pre-validate.
  4. Concurrent posting: Posting failed: serialization conflict — это Sprint 23 SerializationConflictMiddleware ловит и возвращает 409. Если не возвращает 409 а 500 — middleware сломался, проверь deploy.

db-p95-high

Alert: DbQueryP95High — p95 DB-запросов >500ms 10 минут подряд.

Действия:

  1. Самые медленные запросы:
    SELECT calls, mean_exec_time, total_exec_time, query
    FROM pg_stat_statements ORDER BY mean_exec_time DESC LIMIT 10;
    
  2. Если статистика устарела: ANALYZE всей БД (vacuum-top-tables Hangfire-job делает это раз в неделю, см. DatabaseMaintenanceJobs).
  3. Lock'и: SELECT * FROM pg_locks WHERE NOT granted; — заблокирована ли какая-то таблица.
  4. Disk: см. disk-free-low ниже — если IO упирается в диск.
  5. Если новый медленный запрос в логе после deploy — откати relevant контроллер.

disk-free-low

Alert: DiskFreeLow — < 5 ГБ свободно на mount.

Действия:

  1. df -h — какой mount упал.
  2. Logs: du -sh /var/lib/docker/containers/*/. Логи Docker'a иногда разрастаются. Truncate: truncate -s 0 /var/lib/docker/containers/*/.log.
  3. БД growth: psql -c "SELECT pg_size_pretty(pg_database_size('food_market'))". Если >50 ГБ — запусти PruneStockMovements + VACUUM FULL под maintenance (см. ниже).
  4. Quality-watchdog test orgs: PruneQualityTestOrgs Hangfire-job (cron 02:30 UTC) удаляет старые quality-{epoch}-* org'и (см. [[sprint25_done]]). Если не отработал: trigger вручную через /hangfire.
  5. Очисти ~/.fm-watchdog/quality.log.* старее 14 дней (auto-ротация уже есть).

watchdog-red

Alert: WatchdogLastRunRed — quality-watchdog последний прогон красный

5 мин.

Действия:

  1. Открой docs/quality-status.md в репо — сразу видно какой шаг упал.
  2. Тот же шаг сам и воспроизведёшь:
    /home/nns/quality-watchdog.sh   # сразу прогоняет всё
    tail -50 ~/.fm-watchdog/quality.log   # детали последнего шага
    
  3. Дальше зависит от шага:
    • health — см. api-down.
    • auth_me / products / signalr — см. http-errors-spike.
    • multi_tenant — см. multi-tenant-violation (КРИТИЧНО).
    • perf — см. db-p95-high.
    • ui_flow — Playwright-тест. Прогон вручную: cd tests/regression && pnpm exec playwright test flows/03-catalog.spec.ts --grep "3.1" --headed.

multi-tenant-violation 🚨

Alert: MultiTenantViolation — шаг multi_tenant упал в последнем часу.

ЭТО P0. Org B видит данные A — это утечка между арендаторами.

Действия (немедленные):

  1. Останови stage'е приём новых signup'ов (косвенно — поставь RateLimiting__SignupPerIpPerHour=0, redeploy api).
  2. tail -100 ~/.fm-watchdog/quality.log — детали leak'а (get_code, list_total).
  3. В коде проверь AppDbContext.ApplyTenantQueryFilter (см. [[sprint22_done]]):
    grep -n "ApplyTenantQueryFilter\|IgnoreQueryFilters" src/food-market.infrastructure/Persistence/
    
    Кто-то добавил IgnoreQueryFilters() где не надо? Это самая частая причина leak'ов.
  4. Воспроизведи руками: создай 2 org'и (curl POST /api/auth/signup × 2), токен для каждого, попробуй cross-access. Если воспроизводится — фикс ASAP.
  5. После фикса — ~/deploy-stage.sh, дождись зелёного watchdog'a.
  6. Если на prod уже катилось: notify владельцев (через Telegram-summary job), audit-log за последние 48ч (/api/admin/audit?since=...) на подозрительные cross-org операции.

watchdog-incident

Alert: WatchdogIncidentCreated — 2+ подряд красных прогона ⇒ incident-файл.

Действия:

  1. ls -lt ~/.fm-watchdog/incident-*.txt | head -3 — последние инциденты.
  2. cat ~/.fm-watchdog/incident-{...}.txt — описание + действия.
  3. Server-Claude автоматически получит этот файл в очередь (через ~/fm-watchdog.sh → ротацию queue). Не дублируй — он начнёт фикс сам.
  4. Если хочешь форсировать вмешательство: тот же файл sent'нул в ~/.fm-watchdog/queue/0000-incident-XXX.txt (uname-prefix 0000 → первый в очереди).

Что НЕ делать

  • НЕ менять global.json без явного решения (CLAUDE.md).
  • НЕ переключать systemwide postgres версию через brew (поломает смежные проекты в ~/Documents/devprojects/).
  • НЕ запускать docker compose down -v на stage/prod (потеря volume).
  • НЕ делать миграции через dotnet ef migrations add — снапшот в репо не синхронный с моделью, генератор выдаст ерунду. Пишем руками.
  • НЕ редактировать тот же файл одновременно с Mac-Claude (memory: feedback_serialize_edits).