Перейти к содержанию

DECISIONS — журнал архитектурных решений (ADR)

Append-only. Каждое значимое решение — короткая запись: контекст → решение → последствия. Новые решения добавлять снизу. Если решение отменяется — не удалять, а пометить СТАТУС: заменено ADR-NNNN.

Шаблон:

## ADR-NNNN: Заголовок
СТАТУС: принято | заменено ADR-XXXX
ДАТА: YYYY-MM-DD
КОНТЕКСТ: что заставило принимать решение.
РЕШЕНИЕ: что выбрали.
ПОСЛЕДСТВИЯ: что из этого следует (плюсы/минусы/ограничения).


ADR-0001: Своя БД как источник правды, а не Fitbit/Apple Health

СТАТУС: принято КОНТЕКСТ: Ни Fitbit/Google Health, ни Apple Health не хранят метрики InBody (висцеральный жир, посегментные мышцы) и не структурируют тренировки «тренажёр + вес + подходы». РЕШЕНИЕ: Центральное хранилище — своя Postgres у core-service. Внешние сервисы — лишь источники/витрины. ПОСЛЕДСТВИЯ: Полный контроль над форматом под ИИ-анализ; нужен слой воркеров, которые тянут данные в core.

ADR-0002: core-service на Go + gRPC, бот на Python

СТАТУС: принято КОНТЕКСТ: Нужен надёжный центр и удобный UI-слой; у пользователя приоритет Go для сервисов. РЕШЕНИЕ: core-service — Go + gRPC; telegram-bot и inbody-parser — Python (богаче экосистема для Telegram и PDF). XML Apple Health и синки — Go. ПОСЛЕДСТВИЯ: Разделение ответственности по сильным сторонам языков; браузер не умеет в gRPC напрямую → фронт ходит через бот.

ADR-0003: Точное время подхода нельзя получить из готовых приложений

СТАТУС: принято КОНТЕКСТ: Проверены FitNotes/Hevy/Strong/Jefit и интервальные таймеры. Логгеры дают время только на уровне всей тренировки (+ настройку отдыха), таймеры не знают про «упражнение/вес/повторы». Гибрида с экспортом нет. РЕШЕНИЕ: Логирование подходов делаем сами (кнопки «начал/закончил» → реальные таймстампы). Внешний логгер не нужен. ПОСЛЕДСТВИЯ: Это и есть ядро ценности проекта; точность абсолютная, экспорт не нужен — данные сразу свои. УТОЧНЕНО: где именно ставится таймстамп — решено в ADR-0013 (на клиенте, сервер валидирует), а не на сервере, как предполагалось здесь изначально.

ADR-0004: wger как движок тренировок (основа), а не написание с нуля

СТАТУС: принято КОНТЕКСТ: Нужны справочник упражнений, хранение сессий/подходов и REST API. Писать это с нуля — лишняя работа. РЕШЕНИЕ: Берём форк wger-project/wger как движок. Подходы хранит wger, мы тянем их в core через REST. ПОСЛЕДСТВИЯ: Экономим разработку; принимаем чужой стек (Django) и его модель данных.

ADR-0005: Свой тонкий мини-апп вместо форка React-фронта wger

СТАТУС: принято ДАТА: 2026-06-30 КОНТЕКСТ: Выяснилось, что фронтенд wger — это React/TypeScript (отдельный репозиторий wger-project/react), а не Vue. Форкать и переделывать тяжёлый upstream-React под наш сценарий «начал/закончил подход» дорого и хрупко. РЕШЕНИЕ: Делаем тонкий собственный мини-апп (HTML/JS/TS) поверх REST API wger. wger остаётся движком/бэкендом, его React-фронт не трогаем. ПОСЛЕДСТВИЯ: Полный контроль над UX таймера подходов и форматом таймстампов; меньше конфликтов при подтягивании upstream. Альтернатива (форк React-фронта wger) остаётся запасной, если захочется его полноценный UI.

ADR-0006: Nutrition в wger скрываем, а не удаляем

СТАТУС: заменено ADR-0020 ДАТА: 2026-06-30 КОНТЕКСТ: Диета/калории не нужны. Соблазн — выпилить nutrition из INSTALLED_APPS. РЕШЕНИЕ: Скрыть (убрать из навигации/меню, не публиковать эндпоинты), но не удалять приложение. ПОСЛЕДСТВИЯ: Не ломаем миграции и upstream-мерджи; чуть «мёртвого» кода остаётся, но форк остаётся обновляемым. ОТМЕНЕНО ADR-0020: upstream больше не отслеживается, единственное основание прятать вместо удаления отпало — nutrition удаляется.

ADR-0007: Целимся в Google Health API v4, не в старый Fitbit Web API

СТАТУС: принято КОНТЕКСТ: Fitbit Air работает через Google Health; старый Fitbit Web API отключается ~сентябрь 2026. РЕШЕНИЕ: google-health-sync использует Google Health API v4 (intraday-точки без отдельного intraday-разрешения). ПОСЛЕДСТВИЯ: Долгоживущее решение; силовые «подход+вес» этот API не даёт — они из wger.

ADR-0008: Выход наружу через обратный SSH-туннель + Caddy на VPS

СТАТУС: принято ДАТА: 2026-06-30 КОНТЕКСТ: Cloudflare Tunnel заблокирован в РФ (см. LESSONS-0003). WireGuard — лишняя головная боль в текущих условиях. Naiveproxy — forward-прокси, не подходит для входящего трафика. У мини-ПК нет белого IP; на VPS мало RAM. РЕШЕНИЕ: Вся система — на мини-ПК. На VPS — Caddy (TLS по домену) + конец обратного SSH-туннеля (autossh в systemd на мини-ПК). ПОСЛЕДСТВИЯ: Сервер не тащит ничего тяжёлого; HTTPS валиден (нужно для Mini App); Caddy уже стоит (серверная часть naiveproxy) — добавляется один сайт-блок.

ADR-0009: Этапность — сначала контур тренировок

СТАТУС: принято КОНТЕКСТ: Строить все 8 сервисов сразу — слишком много для пет-проекта. РЕШЕНИЕ: Сначала wger + мини-апп + бот (ввод тренировок), затем core + wger-sync-worker, затем остальные источники. Детали — ROADMAP.md. ПОСЛЕДСТВИЯ: Раннее работающее ядро; остальные источники подключаются инкрементально.

ADR-0010: Монорепо + wger как git submodule

СТАТУС: заменено ADR-0012 ДАТА: 2026-06-30 КОНТЕКСТ: Нужны общие доки и единое дерево CLAUDE.md; при этом wger — внешний проект, который хочется обновлять из upstream. РЕШЕНИЕ: Монорепо health-platform; форк wger подключается как submodule в third_party/wger. ПОСЛЕДСТВИЯ: Одно место для документации/памяти и docker-compose; возможность тянуть upstream wger. Мульти-репо со «общим репо доков» — запасной вариант.

ADR-0011: Claude Code tooling — self-hosted GitLab MCP, per-service субагенты

СТАТУС: принято ДАТА: 2026-08-12 КОНТЕКСТ: Проект — монорепо из 7+ сервисов на разных языках; нужна возможность параллельно вести работу над независимыми сервисами и заводить/деплоить задачи через GitLab, не переключаясь руками между репозиториями и UI. РЕШЕНИЕ: - GitLab — self-hosted CE на http://gitlab.local (homelab, см. ~/infra/homelab-infra), проект artemii/mybit. MCP — community @zereight/mcp-gitlab (не нативный GitLab Duo MCP — не проверяли доступность в текущей редакции GitLab CE), токен только в локальном (--scope local, негитованном) конфиге, никогда в .mcp.json. - Playwright MCP — два сервера: playwright-extension (extension-режим, водит реальный Chrome пользователя, локальный токен) для ad hoc браузерных задач, и обычный playwright (headless, project-scope в .mcp.json, без секретов) — для будущих E2E-тестов miniapp-frontend. - LSP — официальные плагины Anthropic (gopls-lsp, pyright-lsp, typescript-lsp из маркетплейса anthropics/claude-plugins-official) вместо самодельной интеграции. - Субагенты по сервисам в .claude/agents/ — scoping через system prompt (инструкция не выходить за пределы своей директории), не через инструмент-level sandboxing — Claude Code этого не даёт из коробки. - Скиллы: council (сторонний, tsenart/council-skill, для сложных решений), свои task-to-issue (анализ задачи → issue в GitLab) и deploy (триггер/ мониторинг pipeline через GitLab MCP). ПОСЛЕДСТВИЯ: Параллельная работа над сервисами через Agent tool; заведение и ведение задач в GitLab без ручного переключения контекста. Ограничение: scoping субагентов не является жёсткой изоляцией (Bash внутри агента технически может выйти за пределы директории) — это дисциплина промпта, не sandbox. .gitlab-ci.yml пайплайны сознательно не создавались — рано, кода в сервисах ещё нет.

ADR-0012: Группа GitLab с отдельными репозиториями вместо монорепо

СТАТУС: принято ДАТА: 2026-08-12 КОНТЕКСТ: В монорепо (ADR-0010) любой merge в main, даже точечный (например, правка telegram-bot/), формально задевает единый пайплайн — сложнее гарантировать, что на стейдже передеплоится только изменившийся сервис, а не всё разом. Сервисы и так деплоятся на разные хосты (docs/ARCHITECTURE.md), общий релизный цикл им не нужен. ПОПРАВКА (2026-08-13): посылка «сервисы деплоятся на разные хосты» неверна — все микросервисы живут в одной VM pet (ADR-0015). Решение об отдельных репозиториях остаётся в силе, но по другой причине: это осознанный выбор микросервисной архитектуры с независимыми пайплайнами, а не следствие раздельного деплоя. РЕШЕНИЕ: Одна группа GitLab mybit, в ней 10 отдельных репозиториев: platform (доки/CLAUDE.md/compose/.claude), proto, core-service, telegram-bot, miniapp-frontend, wger-sync-worker, google-health-sync, inbody-parser, applehealth-import, wger (форк — отдельный репозиторий с upstream-remote, не submodule). Локально — соседние директории под одной рабочей папкой, каждая со своим .git; platform — git-репозиторий рабочей папки, гитигнорит остальные. Старый монорепо-проект artemii/mybit (2 коммита-заглушки, без реального кода) удалён, а не мигрирован — сохранять было нечего. ПОСЛЕДСТВИЯ: Каждый сервис — свой пайплайн, свой merge не задевает остальные; чище история/issues на сервис. Цена — нет единого коммита на кросс-сервисную правку (например, смена gRPC-контракта в proto/ + его использования в core-service/) — такие правки теперь идут отдельными MR в разных репозиториях, proto/ версионируется отдельно и потребляется остальными как зависимость, а не правится «заодно». docker-compose.yml для локального подъёма всех сервисов живёт в platform/ и ссылается на соседние директории по относительным путям — предполагает, что все репозитории склонированы рядом.

ADR-0013: Таймстампы подхода фиксирует клиент, сервер их валидирует

СТАТУС: принято ДАТА: 2026-08-13 КОНТЕКСТ: Точное время подхода — главная ценность проекта (PROJECT.md), и SERVICES.md §3 оставлял открытым вопрос: ставить время на сервере в момент прихода запроса или доверять клиенту. В зале связь нестабильна: запрос может уйти с задержкой или вообще только после возвращения сигнала. РЕШЕНИЕ: Мини-апп фиксирует время нажатия и шлёт его явно (client_started_at / client_finished_at). Сервер эти значения не перезаписывает, но валидирует правдоподобность (не в будущем дальше CORE_MAX_CLOCK_SKEW, не старше CORE_MAX_BACKDATE, конец не раньше начала) и отдельно пишет server_received_at. ПОСЛЕДСТВИЯ: Время подхода соответствует физическому событию, а не сетевой задержке — иначе сопоставление с интрадей-пульсом теряет смысл. Открывается offline-логирование. Цена — доверие к часам телефона, поэтому границы обязательны: устройство со сбитыми часами иначе тихо испортит основную метрику. В мини-аппе для отправки берётся Date.now() (должно совпадать с часами watch), а для отображаемой длительности — performance.now(), чтобы коррекция часов посреди подхода не дала отрицательную длительность.

ADR-0014: proto подключается как git submodule, сгенерированный код коммитим

СТАТУС: принято ДАТА: 2026-08-13 КОНТЕКСТ: После перехода на мульти-репо (ADR-0012) контракты живут в отдельном mybit/proto, и его надо как-то потреблять. Вариант «Go-модуль gitlab.local/mybit/proto» упирается в то, что локальный GitLab работает без TLS: понадобились бы GOPRIVATE + GOINSECURE + .netrc на каждой машине и в CI. РЕШЕНИЕ: proto подключается git submodule'ом в ./proto по относительному URL ../proto.git; в go.modreplace gitlab.local/mybit/proto => ./proto. Сгенерированный код (Go и Python) коммитится в proto. ПОСЛЕДСТВИЯ: Работает и локально, и в CI без токенов и без protoc у потребителей; относительный URL позволяет CI клонировать submodule по CI_JOB_TOKEN. Цена — сгенерированный код в git (CI проверяет, что он не устарел) и необходимость добавлять каждого потребителя в job-token allowlist проекта proto (см. LESSONS-0007).

ADR-0015: Рантайм — VM pet в Proxmox; «мини-ПК» из ARCHITECTURE.md — это она

СТАТУС: принято ДАТА: 2026-08-13 КОНТЕКСТ: ARCHITECTURE.md описывал абстрактный «мини-ПК». Фактически это Proxmox 192.168.1.100 с VM gitlab/home/openclaw и остановленным LXC 103 petproject, который задумывался рантаймом пет-проектов, но не разворачивался. РЕШЕНИЕ: LXC 103 удалён, вместо него VM 103 pet (Ubuntu 24.04 из cloud-образа + cloud-init, 4 ядра, 4096 МБ с ballooning от 2048, 60 ГБ, тот же IP 192.168.1.103). Именно она — рантайм mybit. VPS 144.124.250.219 с уже стоящим Caddy остаётся точкой выхода наружу (ADR-0008), но туннель пока не поднят. ПОСЛЕДСТВИЯ: Полноценная VM вместо контейнера (нормальный Docker, свои ядро и systemd). Нода загружена: остальные VM держат 22 ГБ из 30 ГБ, поэтому pet реально живёт на 2 ГБ. Увеличить можно только за счёт других VM — это отдельное решение, а не умолчание. Инфраструктура управляется в ~/infra/homelab-infra, все прогоны — только с --limit pet.

ADR-0016: Export — server-streaming, а не одно сообщение

СТАТУС: принято ДАТА: 2026-08-13 КОНТЕКСТ: DATA_MODEL.md §3 оставлял выбор между «отдать blob» и «отдать ссылку/поток». РЕШЕНИЕ: CoreService.Export — server-streaming, чанками, с полем dataset в каждом чанке. ПОСЛЕДСТВИЯ: Год интрадей-пульса — это порядка 500 тысяч точек, ~20 МБ CSV, что намного больше дефолтного лимита gRPC-сообщения в 4 МБ; blob пришлось бы чинить поднятием лимита и ростом памяти. Бот собирает файл по чанкам и может отдавать несколько датасетов одним потоком.

ADR-0017: Маппинг telegram → wger хранится в конфиге бота

СТАТУС: заменено ADR-0021 ДАТА: 2026-08-13 КОНТЕКСТ: DATA_MODEL.md §4 оставлял выбор места для связки telegram_user_id → wger token: таблица в core, таблица у бота или конфиг. РЕШЕНИЕ: Переменные окружения бота: ALLOWED_TELEGRAM_ID и WGER_TOKEN. Таблицы нет. ПОСЛЕДСТВИЯ: Система однопользовательская (PROJECT.md), таблица на одну строку — лишняя сущность и лишняя миграция. Бот отказывает всем, кроме одного id. Если пользователей когда-нибудь станет больше, это придётся переделать — осознанный размен.

ADR-0018: Сборка образов на рантайм-хосте, без container registry

СТАТУС: принято ДАТА: 2026-08-13 КОНТЕКСТ: Нужно было, чтобы merge в main одного сервиса собирал и деплоил только его. Раннер на VM gitlab не мог собирать образы (не privileged, без docker.sock), registry в GitLab был выключен, а включение registry без TLS потребовало бы insecure-registries на каждом Docker-клиенте. РЕШЕНИЕ: Group-раннер mybit поднят на самом pet; job-контейнеры получают /var/run/docker.sock. CI собирает образ демоном того же хоста, на котором сервис и запускается; деплой — docker compose up -d <service> тем же демоном. Registry, insecure-registries и SSH-ключ деплоя не нужны вовсе. ПОСЛЕДСТВИЯ: Минус три узла инфраструктуры. Все джобы обязаны нести tags: [pet] (у раннера run_untagged=false), иначе уедут на instance-раннер VM gitlab. В compose.pet.yml обязателен pull_policy: never — иначе compose пойдёт искать mybit/* на Docker Hub. Цена: любая CI-джоба получает контроль над докером рантайм-хоста (для личного проекта приемлемо), и образы живут только на pet — откат делается пересборкой из нужного коммита. Появится второй хост — понадобится registry.

ADR-0019: Единица анализа — эпизод усилия, а не подход

СТАТУС: принято ДАТА: 2026-08-13 КОНТЕКСТ: Совет (docs/COUNCIL-2026-08-13.md) разобрал центральную посылку проекта и показал, что она сформулирована неверно — не ложна, но нереализуема как записано. Два независимых физических аргумента: 1. Пульс после тяжёлого подхода достигает пика в первые 5–20 секунд отдыха. Запрос «пульс внутри [T1, T2]» промахивается мимо пика по построению. 2. Fitbit Air пишет точку раз в ~5 секунд и отбрасывает точки при низком качестве оптического сигнала. Низкое качество наступает ровно при хвате штанги. На подход в 40 секунд приходится 0–8 точек, и уцелевшие смещены к спокойным моментам — это смещённая выборка, а не просто разреженная. Дополнительно: даже если бы датчик был идеален, пульс за подход может полностью предсказываться упражнением, весом, повторами и предыдущим отдыхом — тогда число точное, но бесполезное. РЕШЕНИЕ: - Единица анализа — эпизод усилия [client_started_at, client_finished_at + 60s], фаза нагрузки и фаза восстановления хранятся и агрегируются раздельно. - Рядом с каждой агрегацией обязательно хранятся hr_point_count и покрытие окна. Это не метрика, а флаг валидности строки: агрегат с покрытием ниже порога помечается непригодным, а не усредняется молча. - Критерий проверки гипотезы — остаточная дисперсия пульса сверх уже залогированных переменных, а не совпадение с эталонным датчиком. Сравнение с нагрудным ремнём проверяет датчик, а не полезность величины. - Гипотеза фальсифицируется продакшн-данными после первых реальных сессий, а не отдельной церемонией валидации до начала работы. ПОСЛЕДСТВИЯ: Формулировка цели в PROJECT.md и задача S3-04 переписаны. Схема и health.proto должны нести провенанс (confidence, recording_method, data_source, начало/конец точки) — это единственное, что невозможно восстановить задним числом, поэтому добавляется до первого ингеста (задача на доске). Проект при этом не останавливается: точные тайминги подхода имеют самостоятельную ценность (отдых, время под нагрузкой, плотность сессии) и не зависят от носимого датчика вовсе — провал гипотезы убивает одну задачу, а не проект.

ADR-0020: wger — хард-форк, upstream не отслеживаем

СТАТУС: принято ДАТА: 2026-08-13 КОНТЕКСТ: ADR-0004 брал wger как движок, а ADR-0006 требовал скрывать nutrition, а не удалять, — ровно ради того, чтобы форк оставался мерджабельным с upstream. Совет назвал этот ребейз главной постоянной ценой проекта. Уточнение владельца: форк делается один раз, дальше репозиторий развивается только под себя, никаких мерджей из upstream не планируется. Плюс wger используется не как «склад, из которого синкаем», а как полноценный интерфейс, который открывается из Telegram Mini App. РЕШЕНИЕ: mybit/wger — хард-форк. Upstream не отслеживаем, обратной совместимости с ним не держим. Менять wger можно свободно, включая удаление ненужных модулей. ПОСЛЕДСТВИЯ: Исчезает вечный ребейз — главная претензия к этой части архитектуры. ADR-0006 теряет основание: nutrition теперь удаляется, а не скрывается (задача S1-04 переписана). Цена: обновления безопасности Django/wger придётся переносить руками и осознанно, автоматического пути больше нет. Задача S1-01 («подключить upstream») сохраняется только как разовый ориентир на источник, не как рабочий процесс.

ADR-0021: Закладываем мультипользовательность в схему сразу

СТАТУС: принято ДАТА: 2026-08-13 КОНТЕКСТ: ADR-0017 хранил связку telegram_user_id → wger token в переменных окружения, обосновывая это тем, что пользователь один. Уточнение владельца: проект остаётся личным, но возможность нескольких пользователей лучше заложить заранее. Ключевой момент — стоимость момента: пока таблицы пустые, user_id добавляется почти бесплатно; после наполнения это миграция с бэкфиллом и переделка всех натуральных ключей. РЕШЕНИЕ: Все таблицы core-service получают user_id до первой реализации персистентности. Натуральные ключи расширяются: UNIQUE (user_id, source, external_id), UNIQUE (user_id, source, metric_type, measured_at) и так далее. Связка пользователей выносится из env в таблицу. Аутентификация при этом остаётся однопользовательской по факту (бот пускает только известные id) — закладывается модель данных, а не механизм регистрации. ПОСЛЕДСТВИЯ: Заменяет ADR-0017. Дешёвая страховка сейчас вместо дорогой миграции потом. Минус — чуть больше полей в каждом запросе и джойне на этапе, когда пользователь фактически один.

ADR-0022: Без брокера сообщений

СТАТУС: принято ДАТА: 2026-08-13 КОНТЕКСТ: Обсуждалась Kafka для обмена между микросервисами. Все сервисы живут в одной VM (pet), объёмы личные: сотни подходов в месяц, сотни тысяч точек пульса в год. РЕШЕНИЕ: Брокера нет. Воркеры пишут в core-service по gRPC; надёжность даётся идемпотентным upsert по натуральному ключу плюс курсором у воркера — это уже at-least-once с дедупликацией, переживающее падения и перезапуски. ПОСЛЕДСТВИЯ: Минус один постоянно живущий процесс, его память и обслуживание. Kafka решает развязку между командами и пропускную способность с реплеем — здесь нет ни того, ни другого. Если позже понадобится асинхронная развязка, первым шагом берём LISTEN/NOTIFY в Postgres или таблицу-outbox: это не добавляет инфраструктуры. Заводить брокер — только когда появится потребитель, который не должен блокировать продюсера, и тогда сначала смотреть на NATS, а не на Kafka.

ADR-0023: Telegram-бот — единственная точка входа

СТАТУС: принято ДАТА: 2026-08-13 КОНТЕКСТ: Раньше бот описывался как auth-прокси между мини-аппом и wger (ADR-0005, SERVICES.md §2). Решение владельца: вся коммуникация с системой идёт через бота — регистрация, открытие Mini App с wger, отправка отчёта InBody, подключение аккаунта Google Health, выгрузки. Отдельного веб-входа, страницы регистрации или ручной настройки на сервере не будет. РЕШЕНИЕ: telegram-bot — не прокси, а входная система. На нём: - регистрация: /start заводит пользователя в core-service и создаёт связанный аккаунт в wger; никакой ручной подготовки учёток; - запуск Mini App (и своего экрана логирования, и интерфейса wger); - брокер OAuth для Google Health: бот выдаёт ссылку, принимает callback, кладёт токены пользователя в core-service; - приём файлов: InBody PDF, Apple Health export.xml; - выдача /export и статистики. ПОСЛЕДСТВИЯ: Бот становится самым нагруженным по смыслу сервисом, а не тонкой прослойкой — это надо учитывать при оценке задач. Он же становится единой точкой отказа и главной поверхностью безопасности: компрометация токена бота открывает доступ ко всему. Отсюда — проверка initData обязательна на каждом запросе от Mini App (уже реализована и покрыта тестами), а токены пользователей не должны быть доступны браузеру ни в каком виде. Заменяет описание роли бота в SERVICES.md §2.

ADR-0024: Учётные данные пользователей живут в core-service, а не в переменных окружения

СТАТУС: принято ДАТА: 2026-08-13 КОНТЕКСТ: Сейчас WGER_TOKEN, GOOGLE_REFRESH_TOKEN и ALLOWED_TELEGRAM_ID — переменные окружения в двух compose-файлах и в конфигах сервисов. Это работает ровно для одного пользователя, заведённого руками. С регистрацией через бота (ADR-0023) и мультипользовательской схемой (ADR-0021) такая модель ломается: у каждого пользователя свой токен wger и свой refresh-токен Google. РЕШЕНИЕ: Токены хранятся в таблице пользователей в core-service, привязанные к user_id. В окружении остаются только системные секреты: токен самого бота, OAuth client id/secret приложения, пароли БД. google-health-sync перестаёт быть однопользовательским: он обходит пользователей с подключённым Google-аккаунтом и синхронизирует каждого по его токену. ПОСЛЕДСТВИЯ: Правятся конфиги telegram-bot и google-health-sync (сейчас там обязательные env-переменные, из-за которых сервис без них не стартует) и оба compose-файла. Токены пользователей — это секреты в БД: нужно решить их шифрование до того, как в базе появится первый живой refresh-токен, а не после. Отдельный риск: истёкший или отозванный refresh-токен теперь должен приводить к внятному сообщению пользователю в боте, а не к молчаливой остановке синка.

ADR-0025: Публичный HTTPS — предусловие, а не последний спринт

СТАТУС: принято ДАТА: 2026-08-13 КОНТЕКСТ: Туннель на VPS с валидным TLS стоял в Спринте 4 (ADR-0008, задача S4-01). Совет уже отметил, что Спринт 1 из-за этого недостижим. С ADR-0023 выяснилось, что от HTTPS зависит не одно, а два независимых механизма, и оба проверены: 1. Telegram открывает Mini App только по валидному TLS. 2. Google требует HTTPS в redirect URI; исключение только localhost, а пользователь жмёт ссылку с телефона, так что localhost неприменим. Флоу с ручным копированием кода (OOB) Google полностью отключил в январе 2023, запасного пути нет. РЕШЕНИЕ: Поднятие туннеля и TLS переносится в первый спринт и становится предусловием всего остального. Порядок спринтов пересобран: вход → логирование → хранение → источники (ROADMAP.md). ПОСЛЕДСТВИЯ: Первый спринт начинается с инфраструктуры, а не с видимой функции — это осознанная цена: без неё ни Mini App, ни подключение Google Health не работают даже в демо-режиме. Заодно снимается претензия совета про «недостижимый Спринт 1» (issue #31) — она закрывается этим решением, а не отдельным фолбэком на url-кнопку.

ADR-0026: Туннель на VPS не должен ставить под удар VPN

СТАТУС: принято ДАТА: 2026-08-18 КОНТЕКСТ: VPS существует не ради mybit. Его основная работа — naiveproxy (VPN) и SOCKS5 (danted), то есть повседневный доступ в интернет. Проект въезжает туда гостем, и цена ошибки несимметрична: сломав конфиг, мы уроним не проект, а VPN. РЕШЕНИЕ. Четыре правила, все обязательные: 1. Существующий блок Caddy не редактируется вообще. Добавляется отдельный именованный блок mybit.arvberezin.online. Текущий блок — :443, cdn.arvberezin.online, где :443 это catch-all; Caddy маршрутизирует по Host, именованный блок выигрывает по специфичности для своего домена, всё остальное по-прежнему достаётся naiveproxy. 2. caddy validate до применения, reload вместо restart. Reload при плохом конфиге оставляет работать старый; restart уронит VPN. 3. Ключ туннеля ограничен до одного порта: restrict,port-forwarding, permitlisten="127.0.0.1:9080". Компрометация pet не должна давать шелл на сервере, через который ходит весь личный трафик. 4. Порт 9080, привязка только к 127.0.0.1. Заняты 22, 80, 443, 1080, 1081 и 40000–40100/udp. Наружу порт не торчит: снаружи всё идёт через Caddy. Плюс RestartSec=15 у autossh — на VPS активен fail2ban, шторм переподключений может забанить домашний адрес. ПОСЛЕДСТВИЯ: Правки на VPS обратимы одной командой (бэкап конфига обязателен), а проверку живости VPN и SOCKS после изменений делает владелец — у Claude нет настроенных клиентов (задача T-05). Осталось открытым: делить ли вообще один IP между VPN и проектом. Публичное имя проекта на том же адресе — это общая судьба при блокировке и лишняя поверхность для фингерпринтинга naiveproxy. Решение за владельцем, задача T-00.

ADR-0027: Границы подхода восстанавливаются из пульса, а не из нажатий

СТАТУС: принято ДАТА: 2026-08-18 ЗАМЕНЯЕТ: ADR-0013 в части, где нажатия объявлены источником времени подхода. КОНТЕКСТ: ADR-0013 строил всё на кнопках «начал» и «закончил»: два нажатия дают точные T1/T2. Владелец описал, как он реально тренируется, и это другая модель: телефон во время подхода не трогают вообще. Отработал — отметил, вбил вес и повторы когда удобно, дальше отдых. Требование — максимальная свобода ввода, никакой обязательной дисциплины нажатий. РЕШЕНИЕ: Приложение не утверждает, что знает время начала подхода. Оно пишет: - marked_at — отметка сразу после подхода (может отсутствовать), - saved_at — момент отправки формы, - entry_lag_seconds — разрыв между ними, это данные, а не шум. Где именно был подход, восстанавливается из пульса при анализе: подход лежит где-то до marked_at, а насколько раньше — показывает подъём ЧСС. ПОСЛЕДСТВИЯ (главное — это чинит гипотезу, а не ослабляет её): - Совет забраковал исходную посылку так: датчик пишет раз в ~5 секунд и отбрасывает точки при плохом сигнале, а сигнал портится ровно при хвате штанги, поэтому измерить пик за 40 секунд по 0–3 точкам нельзя (ADR-0019). Но теперь пульс нужен не для измерения пика, а для обнаружения подхода. Подъём отчётливее всего как раз на восстановлении — когда рука уже спокойна и датчик работает нормально. Для обнаружения хватает 3–5 точек, а ошибка ±10 ударов не мешает увидеть переход с 90 на 140. Самый ненадёжный участок сигнала перестал быть нужным. - Время под нагрузкой больше не измеряется — цена свободы ввода. Если понадобится, возвращается опциональной кнопкой, но обязательной она не станет. - device_clock_offset_ms становится ещё важнее: всё сопоставление идёт по времени между телефоном и часами. - Класс решения, ради которого собираются данные: если к началу следующего подхода пульс не вернулся к базе предыдущего — увеличить отдых или снизить вес; если восстановление стабильно быстрее, чем длится отдых — отдых сократить. Опирается на уровень ЧСС перед подходом, который Fitbit измеряет надёжно, а не на пик во время, который он не измеряет.

ADR-0028: Совместимость контракта проверяется от поставленного тега, а не от main

СТАТУС: принято ДАТА: 2026-08-20 КОНТЕКСТ: Джоба buf breaking сравнивала каждую ветку с main и валила MR с контрактами: Ack.rejected сменил тип, скаляры получили явное присутствие, client_started_at стал marked_at. Формально всё верно. Фактически ломать было нечего: персистентности нет, 0001_init.sql ни разу не применялась, ни одна сохранённая строка не использует эти номера полей, ни один задеплоенный клиент этот контракт не говорит. Проверка, которая срабатывает, когда защищать нечего, лишь подталкивает гнуть контракт под проверку — а не наоборот. РЕШЕНИЕ: Сравнение идёт против тега contract-baseline. Тега нет — сравнивать не с чем, джоба проходит и печатает, как его поставить. Постановка тега — это явный акт «с этого момента совместимость имеет значение». Ставим его, когда core-service запишет первую строку; после этого тег не двигается. ПОСЛЕДСТВИЯ: Пока контракт не зарелижен, схема правится свободно и честно, без обходных полей и без allow_failure, который прятал бы и настоящие поломки. Цена — дисциплина: если забыть поставить тег после первого ингеста, проверка так и останется холостой. Поэтому постановка тега — часть задачи про персистентность (#12), а не отдельное «когда-нибудь».