From 4068ffad25d21fd5f8b777545841ed3dfec233da Mon Sep 17 00:00:00 2001 From: Ryazanov Alexander <45588152+RyazanovAlexander@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:45:17 -0500 Subject: [PATCH] Plan E2 as a minimal slice that measures Gemini on free-form solutions; move the closed beta to E15 --- CLAUDE.md | 3 +- README.md | 2 +- RUN.md | 2944 +++++++++-------------------------------- docs/decisions.md | 17 +- docs/plan/E1.md | 1303 ++++++++++++++++++ docs/plan/E2-draft.md | 1082 +++++++++++++++ docs/stack.html | 2 +- 7 files changed, 3014 insertions(+), 2339 deletions(-) create mode 100644 docs/plan/E1.md create mode 100644 docs/plan/E2-draft.md diff --git a/CLAUDE.md b/CLAUDE.md index 257b5c4..5bc1519 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,6 +43,7 @@ The documents under `docs/` and RUN.md are in Russian (R25); they are cited by s - [docs/decisions.md](docs/decisions.md) — the decision log, R01…: what was decided, why, and what was rejected. Read it before proposing a design change. - [docs/stack.html](docs/stack.html) — the stack by architecture layer: each technology with its version, status and the section, decision or task behind it. A summary, not a source: where it differs from spec, the decision log or RUN.md, they win. - [RUN.md](RUN.md) — the implementation plan, in epics (R44). Table 1 holds the current epic's tasks, run one at a time; table 2 holds later epics, broken into tasks only when their turn comes; table 3 holds the questions the author has not answered yet, and table 4 the technical debt. Full executor rules are in its «Как пользоваться» section. +- `docs/plan/` — the archive of done epics, a file per epic with its tasks as they were when done (R59); and `E2-draft.md`, the old draft of the beta's tasks, written before the LLM-first turn and never run: the raw material for splitting E15, which also keeps the pre-split text of the six tasks E2 took over (T16, T18, T19, T34, T35, T48). Not read whole: a task cited by number, "RUN.md, T11" included, that RUN.md no longer holds is found by its heading, `grep -n '^#### T11\.' RUN.md docs/plan/*.md`. - `docs/live/` — reports of the spikes and of live runs in the cloud (from T12). Every report follows one format: - its status, in the first line under the title; - no identifier of a person or an account — no email, UID, invite code, billing account or token: the repository is public; @@ -145,7 +146,7 @@ infra/terraform/ the Google Cloud project as code — not to be confused with i infra/bootstrap.sh the one-time step that makes what the pipeline signs in with: project, state bucket, WIF web/ the SPA: React and Konva (R13); src/gen/ holds the generated TypeScript firebase.json what Firebase Hosting serves, and with which headers (spec 15.5) -docs/ the documents; live/ holds the reports of spikes and live runs +docs/ the documents; live/ holds the reports of spikes and live runs, plan/ the done epics draft/ the screen mockup: a proposal, not a spec .devcontainer/ justfile .github/workflows/ ``` diff --git a/README.md b/README.md index 8f51a4e..023dd15 100644 --- a/README.md +++ b/README.md @@ -42,4 +42,4 @@ The documents are in Russian. - [docs/decisions.md](docs/decisions.md) — the decision log: what was decided, why, and what was rejected. - [docs/self-hosting.md](docs/self-hosting.md) — how it is deployed: the one-time bootstrap, the delivery of every release, rollback and return. - [docs/stack.html](docs/stack.html) — the technology stack, layer by layer, with versions and what is still to be chosen. Open the file from a checkout in a browser: GitHub shows only its source. -- [RUN.md](RUN.md) — the implementation plan, in epics: the current epic broken into small tasks, run and reviewed one at a time, and the later epics. +- [RUN.md](RUN.md) — the implementation plan, in epics: the current epic broken into small tasks, run and reviewed one at a time, and the later epics. The done epics, with their tasks, are archived in [docs/plan/](docs/plan/). diff --git a/RUN.md b/RUN.md index d8d8df9..a45e369 100644 --- a/RUN.md +++ b/RUN.md @@ -38,7 +38,8 @@ - `just tf-check` — всегда (с T10); - `just ci-workflows` — всегда (с T11); - `just ci-web` — если задача трогает `web/` (с T05); - - `just load-lint` и `just load-test` — всегда; `just ci-load-smoke` — если задача трогает код сервера; `just ci-proto` — если трогает `proto/`; `just ci-integration` — если трогает хранилище, права, миграции или путь запроса. Эти рецепты приносят задачи эпика MVP (E2). + - `just ci-proto` — если задача трогает `proto/` (с T18); + - `just load-lint` и `just load-test` — всегда; `just ci-load-smoke` — если задача трогает код сервера; `just ci-integration` — если трогает хранилище, права, миграции или путь запроса. Эти рецепты приносят задачи эпика беты (E15). Затем любая задача — с кодом или без — проходит `/code-review high`. Порядок и когда его можно пропустить — в CLAUDE.md (How we work). 8. Не коммитить. Коммит делаю я после проверки. @@ -57,12 +58,14 @@ 3. только потом применяет остальное. Секреты репозитория я заношу до мержа; задача называет, какие нужны. -14. **Нагрузка идёт вместе с кодом — начиная с задачи, которая введёт инструмент нагрузки (эпик MVP).** Как сервис держит контекст, блокировки, пулы и ожидания, закладывается, когда пишется код. Поэтому задача, которая добавляет или меняет ручку, адаптер, транзакцию, блокировку или пул, в том же PR приносит: +14. **Нагрузка идёт вместе с кодом — начиная с задачи, которая введёт инструмент нагрузки (эпик беты, E15).** Как сервис держит контекст, блокировки, пулы и ожидания, закладывается, когда пишется код. Поэтому задача, которая добавляет или меняет ручку, адаптер, транзакцию, блокировку или пул, в том же PR приносит: - **сценарий** в `tools/load/` или шаг существующего — с ожиданием: что должно получиться и каких ответов быть не должно. Его приносит задача с ручкой; адаптер или задание без своей ручки приносит страж, а в сценарий его вводит задача, которая первой зовёт его из ручки; - **страж** — тест конкурентности под `-race`, в `ci-test` или `ci-integration`. Вызовов в нём больше, чем соединений, слотов или строк под блокировкой. Каждый вызов получает свой ответ; лишние получают понятный отказ за ограниченное время, а не висят; после прогона соединения, слоты и горутины возвращаются; - **предел каждого ожидания:** соединения из пула, блокировки строки, слота поставщика, публикации. Ожидание без срока — ошибка, даже если сегодня оно короткое. - Короткий вариант сценария входит в `ci-load-smoke`, если помещается в его бюджет. Размеры пулов и слотов и сроки пока предварительные — их сведёт калибровка в конце эпика MVP. Задача, которая не трогает путь запроса, ничего из этого не приносит. + Короткий вариант сценария входит в `ci-load-smoke`, если помещается в его бюджет. Размеры пулов и слотов и сроки пока предварительные — их сведёт калибровка в конце E15. Задача, которая не трогает путь запроса, ничего из этого не приносит. + + В E2 правило не действует: базы и пулов в нём нет, а платную ручку зовёт один автор (Д20). **Статусы:** `[ ]` — не начата, `[x]` — выполнена (отмечает исполнитель), `[-]` — не делаем: решение автора, причина — в задаче, `[?]` — отложена: решение автора, в задаче — причина и когда вернуться. Если при проверке что-то не так, возвращаю задачу в `[ ]` и пишу, что поправить. @@ -86,20 +89,14 @@ - «Когда» — если задачу нельзя начать сразу: после чего или не раньше какого срока. - Исполнитель после выполнения дописывает «Сделано (дата)» и «Открыто» (правило 10). -**Нужно для E1:** -- Docker и VS Code с расширением Dev Containers — с T01; -- настройки репозитория на GitHub, аккаунты SonarCloud (организация `mathtrail`), Codecov и OpenSSF Best Practices — с T07; -- биллинг-аккаунт Google Cloud и свой домен — с T09; -- Firebase в проекте Google Cloud с биллингом — с T10. - -Что нужно следующим эпикам, сказано в их описании. +Что нужно эпику — аккаунты, устройства, доступы, — сказано в его описании в таблице 2 и переезжает с ним в таблицу 1. **Ссылки.** Задачи ссылаются: - на разделы документов — «spec 8.4», «product 5.4», «privacy 7»; - на решения canvas-api — «R16»; - на решения mathtrail-standalone — «R38 standalone», а списком — «standalone: R26, R29». У двух журналов свои номера: R32 canvas-api и R32 standalone — разные решения. -Открытые вопросы — О-… (product 15.2), Т-… (spec 20), П-… (privacy 12); спайки — S1–S4 (spec 20); эпики — E1… (этот файл). +Открытые вопросы — О-… (product 15.2), Т-… (spec 20), П-… (privacy 12); спайки — S1–S4 (spec 20); эпики — E1… (этот файл, сделанные — `docs/plan/`). ## Как устроен план: эпики @@ -111,50 +108,78 @@ - Вместе с Claude в режиме планирования продумываю его и дроблю на задачи. Каждая задача — на одну проверку, ≈ 30–60 минут чтения. - Формат — «Тело задачи» выше, тот же, что у задач E1. - Задачи встают в таблицу 1 и в тело этого файла, а строка эпика уходит из таблицы 2. -- **Сделанный эпик.** Его строки уходят из таблицы 1, в списке «Сделанные эпики» остаётся одна строка. Тела задач остаются в файле: на них ссылаются CLAUDE.md и журнал решений. -- **Номера задач** сквозные и не переиспользуются. Задача из черновика E2 (приложение), которая пережила разбиение, сохраняет свой номер. Новая задача получает номер, свободный в момент записи: параллельная сессия может занять следующий. +- **Сделанный эпик уходит в архив** — свой файл `docs/plan/<эпик>.md` (R59): + - туда дословно переезжает всё, что есть об эпике в таблице 1 и в теле файла: описание («Результат», «Нужно», «Почему такой порядок»), таблица задач и тела задач с «Сделано» и «Открыто»; + - здесь от эпика остаётся строка в списке «Сделанные эпики» со ссылкой на архив; + - вопросы и долги эпика остаются в таблицах 3 и 4, пока их не закроют. + + Здесь остаётся только живое: правила, текущий эпик и таблицы 2–4. Поэтому файл не растёт вместе с продуктом. Архив целиком не читается: из него берут задачу, на которую есть ссылка. +- **Ссылка на задачу** — её номер: «T11», «RUN.md, T11». Задачу, которой здесь нет, ищут в архиве по заголовку: `grep -n '^#### T11\.' RUN.md docs/plan/*.md`. +- **Номера задач** сквозные и не переиспользуются. Задача из черновика, которая пережила разбиение, сохраняет свой номер: так T16, T18, T19, T34, T35 и T48 перешли в E2. Остаток черновика — в архиве [docs/plan/E2-draft.md](docs/plan/E2-draft.md), и при разбиении E15 его задачи переносятся так же. Новая задача получает номер, свободный в момент записи: параллельная сессия может занять следующий. -**Сделанные эпики:** пока нет. +**Сделанные эпики:** +- E1. Настройка репозитория: CI/CD и первый деплой hello world — T00–T12, T59, T60; сделан 2026-10-14. Архив — [docs/plan/E1.md](docs/plan/E1.md). -## Таблица 1. Текущий эпик — E1. Настройка репозитория: CI/CD и первый деплой hello world +## Таблица 1. Текущий эпик — E2. Минимальный срез: Gemini проверяет решение с листа **Результат:** -- каждый PR проходит проверки Go и web, уязвимостей, лицензий, секретов и образа, CodeQL и SonarCloud; -- зелёный CI на `main` публикует подписанный релиз, и выкладка разворачивает сервис и SPA из его тега (R45); -- `/health` в prod называет релиз и коммит; -- правила работы и версии инструментов сверены с образцом от 2026-10-11. +- в prod работает срез на шести задачах, по одной на класс 1–6: ребёнок решает где угодно на листе — рисует, считает, пишет пометки — и там же пишет итоговый ответ. По Send Answer один вызов Gemini разбирает лист, а программа сверяет итоговый ответ с ключом. Ребёнок видит, что прочитано, при ошибке — рамку и вопрос, а затем — следующую задачу; +- каждая проверка измерена — время клиента, сервера и Gemini, токены, цена — и размечена автором; журнал выгружается файлом; +- `just eval` прогоняет журнал на Flash-Lite, Flash и Pro, с разными настройками картинки и размышлений, с ключом в промпте и без; +- отчёты `docs/live/` отвечают, насколько хорошо модель понимает задачу и свободное решение, верно ли проверяет ответ, сколько длится и стоит проверка; решения для беты (E15) записаны. -Продуктового кода в эпике нет: выкатываются простейший сервер (T02) и заглушка SPA (T05). +Родителей, согласия и базы в эпике нет: только детский экран и панель автора. Почему — в начале раздела «Эпик E2» ниже. + +**Нужно:** +- доступ к моделям Gemini на Agent Platform в `us-east4` — с T35. Pro может оказаться только на глобальном адресе: это проверяется до eval (T76); +- вход в gcloud с ADC в контейнере — для живых вызовов и eval (T35, T74, T76, T77); +- компьютер с мышью или графическим планшетом — с T48; +- повтор `just bootstrap` — в T64; «Proto» в обязательных проверках — после T18. + +Секретов репозитория эпик не требует: ключ доступа генерирует `ci-tf-apply`. | Статус | ID | Задача | Кто | План | Зависит от | |---|---|---|---|---|---| -| [x] | T00 | Документы и план | Claude | — | — | -| [x] | T01 | CLAUDE.md | Claude, проверяю я | — | T00 | -| [x] | T02 | Простейший сервер | Claude | — | T01 | -| [x] | T03 | Devcontainer | Claude | да | T02 | -| [x] | T59 | Правила: сверка с образцом 2026-10-11 | Claude, проверяю я | — | T03 | -| [x] | T60 | Devcontainer: версии и наши отличия от образца | Claude | — | T03 | -| [x] | T04 | Проверки Go и образ сервиса | Claude | — | T59, T60 | -| [x] | T05 | Web-тулчейн | Claude | да | T04 | -| [x] | T06 | CI | Claude | — | T04, T05 | -| [x] | T07 | Анализ кода и витрина качества | я + Claude | — | T06 | -| [x] | T08 | Релиз | Claude | — | T06 | -| [x] | T09 | Решения для поставки | я + Claude | — | T01 | -| [x] | T10 | Terraform | Claude | да | T04, T09 | -| [x] | T11 | CD | Claude | да | T06, T07, T08, T10 | -| [x] | T12 | Первый деплой | я + Claude | — | T11 | - -**Почему такой порядок.** Начало задал я. Сначала правила (T01), простейший сервер (T02) и сразу вся DevX-часть (T03–T12). К первой строчке продукта уже работают проверки, релиз и выкладка, и каждая следующая задача проходит через тот же конвейер. - -Перед T04 — сверка с образцом от 2026-10-11 (T59, T60). Правила работы и версии инструментов должны быть свежими до того, как по ним пойдёт первый код: T03 и задачи T04–T12 писались по снимку образца от 2026-09-28, а за две недели образец поменял релиз и выкладку (R45), версии и практики. +| [ ] | T61 | Документы: журнал решений и CLAUDE.md | Claude, проверяю я | — | — | +| [ ] | T62 | Документы: product, privacy, spec, stack | Claude, проверяю я | — | T61 | +| [ ] | T16 | Каркас сервиса | Claude | — | T61 | +| [ ] | T63 | Terraform: провайдеры 8.7.0 | Claude | — | — | +| [ ] | T64 | Облако E2: секрет ключа, Agent Platform, бюджет | Claude | да | T63 | +| [ ] | T65 | Доступ: CORS и ключ | Claude | да | T16, T64 | +| [ ] | T19 | Шесть задач | Claude, проверяю я | да | T16 | +| [ ] | T66 | Проверка ответа: нормализация и вердикт | Claude | — | T19 | +| [ ] | T34 | Проверка вопроса: утечка и запасной текст | Claude | — | T66 | +| [ ] | T67 | Лист: геометрия, картинка, рамка | Claude | да | T61 | +| [ ] | T18 | Контракты API и список задач | Claude | да | T19, T65, T67 | +| [ ] | T68 | SPA: оболочка — ключ, задачи, адрес API | Claude | да | T18 | +| [ ] | T48 | SPA: лист | Claude | да | T68 | +| [ ] | T35 | Gemini: адаптер и промпт | Claude | да | T19, T64, T67 | +| [ ] | T69 | Ход проверки | Claude | да | T35, T66, T34 | +| [ ] | T70 | Ручка проверки, лог и подделка разбора | Claude | да | T18, T69 | +| [ ] | T71 | SPA: Send Answer, результат, Next | Claude | да | T48, T70 | +| [ ] | T72 | SPA: журнал и выгрузка | Claude | да | T71 | +| [ ] | T73 | SPA: панель автора и разметка | Claude | да | T72 | +| [ ] | T74 | Eval: журнал на других моделях | Claude | да | T70, T73 | +| [ ] | T75 | Живой прогон: образцы и замеры в prod | я + Claude | — | T73, T74 | +| [ ] | T76 | Выводы: модели, скорость, цена | я + Claude | да | T74, T75 | +| [ ] | T77 | Исследование: PNG или векторы | я + Claude | да | T74, T75 | + +**Почему такой порядок.** +- Документы — первыми: иначе каждая задача упрётся в поле ответа, которого больше нет, и остановится по правилу 2. +- Облако и доступ — до первой платной ручки: каждый мерж уходит в prod, и ручка Gemini не должна оказаться открытой ни на одну выкладку. +- Задачи — до домена ответа и вопроса: их виды ответа задают белый список, слова утечки и промпт. +- Контракт со списком задач — до SPA, а лист пробуется на экране раньше, чем ручка проверки закрепит геометрию. +- Адаптер Gemini — до хода проверки: настоящий ответ модели виден раньше, чем контракт проверки его опишет. +- Журнал и eval — до живого прогона: образцы и разметка сразу собираются в форме, которую читает eval. +- Выводы — на образцах живого прогона; векторы — последними, как решил автор: «позже, отдельной задачей». ## Таблица 2. Эпики на потом | ID | Эпик | Результат | Основа | |---|---|---|---| -| E2 | MVP: закрытая бета | Ребёнок решает пять задач на холсте, наставник проверяет ответ и разбирает черновик; родитель входит, даёт согласие, видит прогресс и удаляет данные; первые семьи в бете. Подробности — ниже | product, spec, privacy; R37–R43 | +| E15 | Закрытая бета | Ребёнок решает задачи на листе с проверкой, какой её сделали выводы E2; родитель входит, даёт согласие, ведёт профили детей, видит прогресс и удаляет данные; первые семьи в бете. Подробности — ниже | product, spec, privacy; R37–R43; выводы E2 (T76, T77) | | E3 | Вторая среда (dev) | Отдельные проекты GCP и Firebase, тот же Terraform со своим `*.auto.tfvars`. Решено, как делится конвейер (`main` → dev, релиз → prod или иначе) и принимает ли dev JSON для отладки, как локальный запуск. Инструмент нагрузки идёт через сеть — как Cloud Run job в проекте dev, как генератор S3: раннер не видит сети до Cloud Run, Neon и Upstash и нескольких экземпляров. Облачный сервис нагрузки — только если понадобятся распределённая нагрузка или тысячи WebSocket | R32; spec 8.1 | -| E4 | Каталог задач и подбор под ребёнка | Новые темы и подбор задач вместо пяти статичных | product 14; R40 | +| E4 | Каталог задач и подбор под ребёнка | Новые темы и подбор задач вместо шести статичных | product 14; R40 | | E5 | Вход ребёнка со своего устройства | Вход по коду или QR от родителя | product 14; R07 | | E6 | Учителя и классы | Группы, ход решения учеников, сводка частых ошибок | product 14; R06 | | E7 | Подписка и тарифы | Три тарифа и их квоты. Здесь же решается, подтверждать ли верный ответ, когда кончились разборы, но остались чтения | product 14; R05, R35, R36 | @@ -166,44 +191,39 @@ | E13 | Периметр и подпись образа | Global External HTTP(S) Load Balancer и Cloud Armor перед обоими сервисами — готового Terraform для них в репозиториях MathTrail нет (Т-7). Подпись образа — вместе со standalone, где вопрос открыт (R40 standalone) | spec 2; Т-7 | | E14 | Переезд на платформу MathTrail | k8s + EDA при росте нагрузки | R01; spec 19 | -### E2. MVP: что известно до разбиения +### E15. Закрытая бета: что известно до разбиения + +До 2026-10-15 это был эпик E2. Автор вынес бету в отдельный эпик, а E2 сделал минимальным срезом с замером LLM: прежде чем строить бету, увидеть, как модель понимает свободное решение ребёнка, и узнать скорость и цену проверки (раздел «Эпик E2» ниже). - **Результат.** Закрытая бесплатная бета для семей. - - Ребёнок решает одну из пяти задач (R40) на холсте и пишет ответ в поле Answer. - - Наставник читает ответ, сверяет его с ключом, а при ошибке разбирает черновик (R37–R39). + - Ребёнок решает задачи на листе без поля ответа и отправляет его кнопкой Send Answer. Как устроена проверка — один вызов или два, какая модель и с какими настройками, — решают выводы E2 (T76, T77). + - Наставник читает ответ, сверяет его с ключом, а при ошибке разбирает черновик (R37–R39 с поправками E2). - Родитель входит, даёт согласие, ведёт профили детей, видит прогресс и удаляет данные. - **Готово, когда:** - выполнены критерии приёмки product 12; - юридические условия беты закрыты, и первые семьи получили инвайты (product 3; privacy 2, 6). -- **Основа** — product, spec и privacy в редакции после поворота 2026-10-11. Разбивать эпик на задачи — только когда их переработка закончена. +- **Основа** — product, spec и privacy с поправками E2 (T61, T62) и выводы его эксперимента (T75–T77). - **Блоки, из которых сложатся задачи:** - - решения по открытым вопросам О-, Т- и П-, которые остались после T09; - - спайки S1–S4 (spec 20); - - каркас из standalone, телеметрия и инструмент нагрузки; - - контракты `proto/`; - - пять задач в `tasks/`; - - данные и доступ: Postgres и миграции, OpenFGA, вход и App Check, бета, согласие и PIN, профили детей; - - проверка ответа: страница и картинки, сравнение с ключом, чтение (Gemini), приём проверки и аренда; - - разбор черновика (Gemini), наставник и realtime (Centrifugo); + - решения по открытым вопросам О-, Т- и П-, которые остались после E2; + - спайки S3 и S4 (spec 20); S1 и S2 заменил эксперимент E2; + - телеметрия и инструмент нагрузки (черновики T17, T16a, T16b, T46a): каркас, контракты и задачи уже есть из E2; + - данные и доступ: Postgres и миграции, OpenFGA, вход и App Check вместо ключа E2, бета, согласие и PIN, профили детей; + - проверка ответа: приём проверки и аренда (spec 8.4), лимиты R35 вместо потолка E2, лестница подсказок (R39); + - realtime (Centrifugo); - приватность и эксплуатация: удаление, очистка, лимиты, наблюдаемость, калибровка нагрузки, ревью кода; - - SPA: холст и поле ответа, проверка и подсказки, тексты для родителей, раздел родителя, e2e; - - туннель cloudflared до планшета (R48, T05): - - Vite нужен `server.allowedHosts` и HMR через туннель; - - рецепт открывает туннель, только если `npm audit` вместе с dev-зависимостями чист на уровне high. В этот момент dev-сервер виден снаружи, а `ci-vuln` проверяет только то, что раздаётся; - - первый мок — первый интерфейс в `.mockery.yaml`: - - mockery 3.8.0 не грузит пакеты на Go 1.27.2, его надо поднять (T04); - - заданию `mocks` в `ci.yml` вернуть кеш Go — шаг `*go-cache`. Пока моков нет, задание ничего не собирает и кеша не держит (R49, T06); + - SPA: хранение листа в IndexedDB (R42), детский режим без панели автора, тексты для родителей, раздел родителя, e2e; + - туннель cloudflared до планшета — долг Д4 (R48, T05); + - первый мок — долг Д3 (R49, T04, T06); - замеры и приёмка: задержка и холодный старт, ревью безопасности, приёмка; - публичные документы и запуск беты. +- **Отложено из E2** — его «Не входит» (раздел «Эпик E2» ниже) и долги Д16–Д21 таблицы 4. - **Порядок, который уже решён:** - открытые вопросы, которые блокируют код, решаются до кода; - - спайки S1 и S2 идут параллельно каркасу: их итогов ждут только адаптеры чтения и разбора; - - нагрузка — с каркаса, а не в конце (правило 14): как сервис держит контекст, блокировки, пулы и ожидания, потом дорого переделывать (standalone: R122, R123); + - нагрузка — первой задачей E15, до первой ручки с базой, а не в конце (правило 14): как сервис держит контекст, блокировки, пулы и ожидания, потом дорого переделывать (standalone: R122, R123); - сервер доводится до конца раньше SPA: клиент пишется против готового контракта и работающих API, в том числе удаления; - S3 и S4 идут после того, что они меряют: настоящего Centrifugo и настоящей проверки ответа; - удаление, очистка и лимиты — до приёмки: это её критерии 5 и 6 (product 12). - **Нужно:** - - доступ к моделям Gemini на Agent Platform; - Neon и Upstash на бесплатных тарифах; - Apple Developer Program для входа через Apple ($99 в год); - iPad с Apple Pencil и Android-планшет. @@ -220,21 +240,19 @@ - атака без вызовов проваливается (R124 standalone); - новые факты к Т-12: Cloud Run держит запрос и процессор, пока не вернулся обработчик, когда клиент ушёл после ответа (R255 standalone); уход посреди проверки ещё не проверен (spec 20); - интеграционные тесты с контейнерами идут на раннере: в образе тулчейна нет docker-клиента. -- **Черновик разбиения** — приложение в конце файла: задачи T13–T58, написанные до поворота R37–R43. +- **Черновик разбиения** — архив [docs/plan/E2-draft.md](docs/plan/E2-draft.md): задачи T13–T58, написанные до поворота R37–R43. T16, T18, T19, T34, T35 и T48 разбиты в E2; их текст до разбиения — там же, с пометкой «до разбиения E2». - Устарели целиком и пишутся заново: - T14 и T15 — спайки Mathpix и пути Б; - - T26–T30, T32, T33 и T35 — строки, движок, Mathpix, приём дельт, проверка пути А, подсказки по кодам ошибок; + - T26–T30, T32 и T33 — строки, движок, Mathpix, приём дельт, проверка пути А; - T40–T42 — путь Б; - T49 — протокол штрихов в SPA. - - Остальные задачи переносятся только после сверки с R37–R43: следы старой схемы есть почти в каждой. Например: - - T19 — две задачи и путь Б; + - Остальные задачи переносятся только после сверки с R37–R43 и выводами E2: следы старой схемы есть почти в каждой. Например: - T31 — штрихи и `last_seq` с сервера; - - T34 — лестница по `ladder_key` и пример движка; - T52 и T54 — задачи А и Б, Mathpix. ## Таблица 3. Вопросы автору без ответа -Собраны 2026-10-14 из «Открыто» задач T00–T11. Открытые вопросы документов — Т- (spec 20), О- (product 15.2) и П- (privacy 12) — живут в своих разделах и решаются при разбиении E2. +Собраны 2026-10-14 из «Открыто» задач T00–T11. Открытые вопросы документов — Т- (spec 20), О- (product 15.2) и П- (privacy 12) — живут в своих разделах. Те, что касаются E2, решают его задачи: Т-1 — T61, Т-16 — T67, Т-5 — T76; остальные — при разбиении E15. | ID | Вопрос | Откуда | Предлагаю | |---|---|---|---| @@ -249,1361 +267,135 @@ | ID | Долг | Откуда | Чем грозит, пока не закрыт | Когда вернуться | |---|---|---|---|---| | Д1 | Отдельный аккаунт плана на PR, только для чтения. Аккаунт Terraform остаётся только окружению `prod` | T11; решение автора 2026-10-14: «да, но не сейчас» | План на PR идёт от аккаунта Terraform: PR из ветки репозитория может поменять задание плана и сделать всё, что может этот аккаунт. Открыть такой PR может только автор | До того, как писать в репозиторий сможет кто-то ещё, или со второй средой (E3) | -| Д2 | Ревизия выходит без трафика, с тегом; smoke — по адресу тега, и только затем трафик | T11; решение автора 2026-10-14: «да, но не сейчас» | Трафик переходит до smoke, и упавший smoke оставляет новую ревизию в работе до ручного отката. Её `/health` проверен на том же образе до push, а startup probe не даёт трафик ревизии, которая не отвечает | До первых семей в бете (E2) | -| Д3 | Первый мок: поднять mockery — 3.8.0 не загружает пакеты на Go 1.27.2 — и вернуть заданию `mocks` кеш Go | T04, T06 | Ничем, пока моков нет: их рецепты ничего не делают | С первым интерфейсом в `.mockery.yaml` (E2) | -| Д4 | Туннель до планшета: `server.allowedHosts` и HMR Vite через cloudflared; туннель открывается, только если `npm audit` вместе с dev-зависимостями чист | T05 | Ничем, пока туннеля нет | Когда SPA понадобится на планшете (E2) | -| Д5 | Провайдеры `google` и `google-beta` 8.3.0 отстают от 8.7.0 (на 2026-10-13). Поднимаются руками, вместе с lock для amd64 и arm64 | T10 | Исправления провайдеров не доходят: Dependabot их не поднимает | При следующей правке Terraform | +| Д2 | Ревизия выходит без трафика, с тегом; smoke — по адресу тега, и только затем трафик | T11; решение автора 2026-10-14: «да, но не сейчас» | Трафик переходит до smoke, и упавший smoke оставляет новую ревизию в работе до ручного отката. Её `/health` проверен на том же образе до push, а startup probe не даёт трафик ревизии, которая не отвечает | До первых семей в бете (E15) | +| Д3 | Первый мок: поднять mockery — 3.8.0 не загружает пакеты на Go 1.27.2 — и вернуть заданию `mocks` кеш Go (шаг `*go-cache`) | T04, T06 | Ничем, пока моков нет: их рецепты ничего не делают | С первым интерфейсом в `.mockery.yaml` | +| Д4 | Туннель до планшета: `server.allowedHosts` и HMR Vite через cloudflared; туннель открывается, только если `npm audit` вместе с dev-зависимостями чист на уровне high: пока туннель открыт, dev-сервер виден снаружи, а `ci-vuln` проверяет только то, что раздаётся | T05 | Ничем, пока туннеля нет | Когда SPA понадобится на планшете (E15): эксперимент E2 идёт на компьютере | +| Д5 | Провайдеры `google` и `google-beta` 8.3.0 отстают от 8.7.0 (на 2026-10-13). Поднимаются руками, вместе с lock для amd64 и arm64 | T10 | Исправления провайдеров не доходят: Dependabot их не поднимает | T63 (E2) — до Terraform эпика, отдельной правкой | | Д6 | `ci-toolchain-image` пишет вывод push через `tee /dev/stderr`. В `ci-image-push` это уже сделано через файл | T11 | Если stderr перенаправлен в файл, файл обрезается. В CI stderr — труба, и поломки нет | При следующей правке рецепта | | Д7 | `imagetools inspect`, упавший по сети, `ci-toolchain-image` читает как «образ не опубликован» | T08 | Лишняя пересборка образа тулчейна | При следующей правке рецепта | | Д8 | `release-artifacts` сверяет версию только после сборки SPA | T08 | Неверная версия отказывает поздно, после сборки | При следующей правке рецепта | | Д9 | `cosign verify` повторён в двух рецептах: `ci-toolchain-image` и `ci-toolchain-sign` | T08 | Проверку подписи легко поправить в одном месте и забыть в другом | При следующей правке проверки подписи | | Д10 | Задание «Toolchain image signature» идёт на каждом push `main`, даже когда подписывать нечего | T08 | Лишний короткий прогон | Если прогоны `main` начнут ждать раннеров | -| Д11 | Черновик T35 пишет, что API Agent Platform включила T10. Его включит задача, которая выключает кеш входов Gemini (privacy 5.2) | T10 | Ничем, пока черновик не стал задачей | При разбиении E2 | +| Д11 | Черновик T35 пишет, что API Agent Platform включила T10. Его включит задача, которая выключает кеш входов Gemini (privacy 5.2) | T10 | Ничем: черновик T35 — в архиве, а API включает T64 | T64 (E2): API включается вместе с выключением кеша; строка уходит с ней | | Д12 | shellcheck скриптов composite actions в `.github/actions/` — в CI. actionlint берёт у action только объявленные входы, а скрипты не читает | T11 | Ошибка в скрипте `deployment-target`, `gcloud`, `firebase` или `docker-mirror` проявится только в прогоне после мержа. Сейчас их проверяют shellcheck руками, при правке | Со следующей правкой этих action | | Д13 | actionlint закреплён руками, 1.7.12 — последняя на 2026-10-14; Dependabot образы из `justfile` не читает. Новая версия, которая знает `queue`, снимет исключение в `.github/actionlint.yaml` | T11 | Новые проверки actionlint не приходят, а исключение может пережить свою причину | При выходе actionlint новее 1.7.12 | | Д14 | Выкладка ждёт только токен нового аккаунта (`ci-identity-ready`), но не роли, которые тот же apply ему выдаёт: запись в реестр, роль деплоя на сервисе, роли Hosting. В задании SPA Firebase CLI получает токен заново, не тот, что дождался gcloud | T12 | На свежем развёртывании или с новым аккаунтом push, выкатка или публикация могут получить 403, пока IAM не разошёлся по своим серверам. Лечит «Re-run failed jobs» | Если повтор понадобится снова: проверять права шага через `testIamPermissions`, прежде чем шаг их потребует | | Д15 | Выкладка собирает образ релиза заново, а время сборки и время файла сервера в слое дают новый digest: повторная выкладка того же релиза — возврат кнопкой, «Re-run failed jobs» — переносит тег и добавляет ещё одну версию образа | T12, вторая выкладка | Образ прошлого релиза может раньше времени выпасть из пяти `keep-recent`, а через 30 дней его удалит `delete-old`; откат на ревизию с удалённым образом не поднимет экземпляр | До ближайшего отката с возвратом кнопкой. Проще всего — выкатывать образ, который уже называет тег релиза, если он есть; иначе собирать воспроизводимо: `SOURCE_DATE_EPOCH` и `rewrite-timestamp` вместе | - ---- - -## Эпик E1. Настройка репозитория: CI/CD и первый деплой hello world - -### Часть 1. Правила и первый код - -Выполняется на хосте: контейнера ещё нет. - -#### T00. Документы и план - -- **Сделано при создании плана:** - - `docs/product.md`, `docs/spec.md`, `docs/decisions.md` и `docs/privacy.md` собраны 2026-09-27 из черновика автора и исправлены по итогам ревью документов; - - написан этот файл; - - мои решения: - - о средах: в MVP только prod, вторая среда — после успешного MVP (R32); - - о выкладке: только после зелёного CI и только из `main` (R33); - - о секретах провайдеров входа: Secret Manager под управлением Terraform (R34). - - Поправлены spec 8.1, 12.1, 13, 14.4, 15.1, 15.2, 15.5 и product 9. -- **Открыто:** вопросы О-9…О-17, Т-1…Т-9 и П-1…П-9 разложены по задачам ниже. - -#### T01. CLAUDE.md - -- **Цель:** правила работы в репозитории — те же, что в mathtrail-standalone, приложенные к canvas-api. С этой задачи каждая сессия Claude начинается с них. -- **Сделать:** - - **Копия образца** — `reference/mathtrail-standalone/`: снимок рабочего дерева `../mathtrail-standalone`. - - Без `.git`, `node_modules`, `prototype/`, `reference/`, `research/`, `draft/`, `bin/`, `dist/`, `coverage.out` и `.terraform/`. - - `/reference/` — в `.gitignore`. - - Копия нужна потому, что из devcontainer соседний каталог хоста не виден. - - **`CLAUDE.md`** на английском, по разделам `reference/mathtrail-standalone/CLAUDE.md`: - - *Your role* — без изменений. - - *The project* — что строим (product 1) и технический облик (spec 1–2): - - Go-сервис canvas-api на Cloud Run с сайдкаром OpenFGA; - - Centrifugo, Neon, Upstash; - - Firebase Auth и App Check; - - Mathpix, Gemini на Agent Platform; - - SPA на React и Konva на Firebase Hosting; - - контракты `canvasapi.v1` на protobuf; - - сначала детерминированный движок, модель — только для подсказки и неоднозначного рисунка. - - *Documents* — четыре файла `docs/`, этот план и `docs/live/` (отчёты спайков и живых прогонов). Контекст живёт в этих файлах, а не в истории чата. - - *How we work* — пункты образца: - - одна задача за раз; прочитать задачу и разделы; - - дыры — О-, Т- и П-вопросами; - - `/code-review high` с тем же порядком: два раунда, `git add -N` для новых файлов, разбор находок; - - отметка в таблице, отчёт по-русски, не коммитить; - - стоимость — до начала; - - не предлагать отвергнутое в `docs/decisions.md`; - - API — по документации закреплённой версии. - - *Hard rules*: - - **devcontainer only.** T00–T03 выполняются на хосте; маркер — `MATHTRAIL_DEVCONTAINER`; - - **точные версии** — как в образце; `github.com/tdewolff/canvas` без тегов закрепляется псевдоверсией (spec 3.2); - - **язык** — правило 11 этого файла; - - **данные детей** (privacy 1, 4, 5): - - о ребёнке — только псевдоним, аватар из набора и класс; - - в логах, метриках и трассах — ни прямых идентификаторов, ни LaTeX, ни текста подсказок (spec 13, 16); - - во внешний ИИ — только математика, без идентификаторов и псевдонима; - - детские образцы почерка никогда не попадают в репозиторий и `testdata/`; там только синтетика и образцы взрослых (spec 17); - - до согласия `verified` данных ребёнка нет (R26); - - **ответ задачи** не уходит клиенту до решения (spec 8.6, 13), а подсказка проходит проверку на утечку (spec 7.3); - - **секреты** — только Secret Manager, переменные окружения и локальный `.env`; - - **лицензии** — MIT; только совместимые зависимости; список третьих сторон актуален. - - *Architecture and code standards* — текст образца с поправками: - - раскладка — spec 18; - - порты домена — `Recognizer`, `Tutor`, `Authorizer`, `Notifier`, `Store` (spec 1); Firebase, Mathpix, Gemini, OpenFGA, Centrifugo и Postgres — адаптеры в `internal/infra/`; - - правило зависимостей — с примером из canvas-api: движок не ходит в Mathpix; - - config — viper, только переменные окружения, префикс `CANVAS_` (spec 15.4), порт из `PORT`; dev-переключатели не стартуют при `K_SERVICE`; - - logging — zap с ключами Cloud Run; в строке — UUID, коды, тайминги, `prompt_version` и ID модели (spec 16); - - errors: - - формат `apierror` `{code, message, details}` и заголовок `X-Request-Id` (R11, spec 8.3); - - сбой Mathpix, Gemini или движка — не HTTP-ошибка, а вердикт `UNCHECKED` или шаблонная подсказка (spec 9.9); - - context — тайм-ауты внешних вызовов из spec: Mathpix 3 с, Gemini 4 и 8 с; - - tests — правила образца, плюс: - - fuzz у всего, что разбирает пришедшее извне: LaTeX от Mathpix, JSON модели, дельты штрихов, JSON задач; - - свойства на gopter у инвариантов движка (spec 17); - - записанные ответы Mathpix и Gemini в `testdata/` — только синтетические; - - Postgres и OpenFGA в интеграционных тестах — в контейнерах; - - tooling and images — как в образце, плюс buf для `proto/` и Playwright из закреплённого образа. - - *Reference copies* — `reference/mathtrail-standalone/`: - - только для чтения, никогда не правится; - - по R11 из неё переносятся каркас (`app`, `config`, `logger`, `telemetry`, `apierror`, `version`), devcontainer, justfile, CI/CD, Terraform и настройки качества — с поправками под canvas-api; - - ripgrep копию не видит — читать явными путями; - - команда, которой копия обновляется на хосте. - - *Environment* — заготовка в одну строку; раздел заполнит T03. - - **`README.md`** — вход и навигация (R25): что это (product 1), ссылки на документы и этот план. +| Д16 | Доступ к ручкам по ключу вместо входа Firebase и App Check | E2, решение автора 2026-10-15 | Ключ один и живёт в браузере автора. Утёк — платные вызовы доступны любому, пока ключ не сменят; держат дневной потолок и бюджет | E15: вход родителя и App Check (spec 8.1, 12.1) | +| Д17 | Ответ проверки несёт разбор модели целиком, а модель видела ключ и может его назвать | E2, решение автора 2026-10-15 | В детском режиме ребёнок увидел бы ответ до решения | E15: разбор не уходит детскому экрану; он нужен только панели автора и eval | +| Д18 | Дневной потолок — счётчик в памяти экземпляра, а не лимиты R35 на ребёнка и родителя | E2, план 2026-10-15 | Экземпляр засыпает при простое, и счётчик обнуляется: это потолок на жизнь экземпляра, а не на сутки. При `max-instances` больше 1 он умножается | E15: лимиты spec 14.5 в базе | +| Д19 | Повтор проверки после обрыва — новый вызов Gemini: нет ни идемпотентности по `client_check_id`, ни аренды (R42) | E2, план 2026-10-15 | Лишняя оплата и два ответа на одну попытку | E15: протокол проверки spec 8.4 | +| Д20 | Правило 14 и телеметрия в E2 не действуют: нет инструмента нагрузки, стражей конкурентности и трасс | E2, план 2026-10-15 | Сроки, потолок и вызовы модели не проверены нагрузкой; задержку видно только в журнале автора и в логах | E15, первой задачей — до первой ручки с базой | +| Д21 | Перо и ладонь на планшете (spec 3.1) не проверены: эксперимент идёт на компьютере | E2, решение автора 2026-10-15 | На iPad и Android ладонь может рисовать, а перо — вести себя иначе, чем мышь | E15, вместе с Д4 | + +## Эпик E2. Минимальный срез: Gemini проверяет решение с листа + +**Почему эпик такой.** Решение автора 2026-10-15. Главное допущение продукта — что LLM в GCP понимает свободное решение ребёнка и верно проверяет его ответ — так и не проверено: спайки S1 и S2 не прошли. Прежде чем строить бету с родителями, согласием и правами, автор хочет увидеть, как модель справляется, и узнать скорость и цену проверки. Поэтому E2 — кратчайший путь к живой проверке в prod и к цифрам: без базы, входа, прав и realtime. Продукт будет меняться по ходу: выводы E2 решают, какой будет бета (E15). + +**Решено автором (2026-10-15).** В журнал решений и документы их переносят T61 и T62; задачи ссылаются на них как «решение N E2». +1. **Поля ответа нет.** Ребёнок пишет где угодно на листе: рисует, считает, оставляет пометки. Итоговый ответ тоже на листе, и находит его модель. Промпт подсказывает: обычно он написан последним, часто с «Answer:» или «=», подчёркнут или обведён. +2. **Send Answer.** Кнопка Check становится Send Answer, как в макете. Лист уходит на сервер только по ней; проверки по паузе нет. +3. **Один вызов Gemini с ключом.** Модель получает картинку листа, условие, класс, ключ и решение. Она возвращает итоговый ответ, как он написан, — разборчив ли он и однозначен ли, — ход рассуждения по шагам, первую ошибку (`box_2d`), один наводящий вопрос и свою оценку. **Вердикт выносит программа** по прочитанному ответу и ключу: R37 в силе. Ключ во входе чтения — отступление от R38; насколько он «подсказывает» чтение, меряет eval вариантом промпта без ключа. +4. **Результат.** Верно — «Correct! I read “7”.» и решение. Неверно — «Not yet. I read “6”.», рамка по штрихам ребёнка (R43) и вопрос, прошедший проверку утечки; если не прошёл — текст ловушки или шаблон. Ответ задачи не показывается. Затем — Next task. Неразборчиво или сбой — Send again: это не вердикт. Лестницы подсказок нет. +5. **Шесть задач, по одной на класс 1–6, пишет Claude.** Разнообразные — разные виды ответа и способы решения, — чтобы лучше проверить модель. Заменяет R40. +6. **Доступ — по ключу.** Случайный ключ генерирует `ci-tf-apply` в Secret Manager; автор вводит его в SPA, сервер сверяет. Плюс дневной потолок вызовов Gemini. Вход через Firebase и App Check — в E15. +7. **На сервер уходит PNG листа, как в R42, — до исследования.** «PNG или векторы» решает отдельная исследовательская задача (T77): это пересмотр того, что R42 отверг, по решению автора. Журнал хранит и штрихи, чтобы исследованию не понадобилось менять приложение. +8. **Замеры — журнал и eval.** Панель автора, разметка автора, журнал в IndexedDB с выгрузкой вне репозитория, `just eval` на Flash-Lite, Flash и Pro. В живом приложении по умолчанию — Flash. +9. **Панель автора показывает разбор модели целиком.** Это исключение из правила «ответ скрыт до решения» только на время E2: доступ — по ключу, детей нет. В E15 разбор не уходит детскому экрану (Д17). +10. **Эксперимент идёт на компьютере** — мышью или пером графического планшета. Планшет, ладонь и туннель — в E15. +11. **Бюджет — $20 в месяц** на время эксперимента; был $1. +12. **Прежний E2 — теперь E15 «Закрытая бета»**, первым в таблице 2. Черновик T13–T58 — в архиве `docs/plan/E2-draft.md`. + +**Решено при планировании** (план утверждён автором 2026-10-15; разбиение проверили два обзора — по правилам репозитория и по устройству эксперимента): +- **Т-1 — gin**, как в каркасе standalone. **Контракт — protobuf (R12)**; JSON — только локально (spec 8.1). +- **Модель возвращает итог дважды:** как написан (`written` — с меткой и единицей) и голым значением (`value`); ещё — другие кандидаты и «ответа нет». Программа принимает `value`, только если он виден в `written`, а остальное там — метка, единица или `=`; иначе `UNREADABLE`. Кандидаты и «ответа нет» — свои причины с подсказкой «Circle your answer». Своя оценка модели вердикт не решает. Так модель не может молча «дочитать» ключ. +- **У eval два обязательных контрольных варианта:** промпт без ключа — модель ещё и решает задачу сама, это мера понимания задачи; и «только чтение» — первый вызов двухвызовной схемы R38, его цена и задержка. +- **Базы в E2 нет.** Сервер ничего не хранит; журнал — на устройстве автора. Повтор после сбоя — новый вызов (Д19). +- **После шестой задачи — снова первая;** автор может и сам выбрать задачу 1–6, чтобы решать одну в разных вариантах. +- **Дневной потолок** — счётчик в памяти экземпляра (`max-instances: 1`); исчерпан — `UNCHECKED` · `limit` (spec 5.4). Экземпляр засыпает при простое, и счётчик обнуляется: это потолок на жизнь экземпляра, а не на сутки (Д18). +- **Лог** — событие проверки с таблицей «событие → разрешённые поля» и тестом аудита, как в образце (standalone: R141, R169): ID, коды, тайминги, токены, цена, модель, `prompt_version` — без прочитанного ответа и текстов. +- **Телеметрии, инструмента нагрузки и e2e в E2 нет** — они в E15. Правило 14 в E2 не действует (Д20). +- **Кеш Gemini выключает ресурс Terraform** `google_vertex_ai_cache_config` — он есть в провайдере 8.3.0, — а не рецепт, как писал spec 15.2. Сверить с документацией в T64. +- **Облачная часть ключа и Gemini — одна задача (T64):** автор повторяет bootstrap один раз. Переменную ключа сервис получает следующей задачей (T65): к её выкладке доступ к секрету уже разошёлся по IAM (как в Д14). +- **Адрес API в SPA** — константа в `web/src`: `.env.*` — в `.gitignore`. Выкладка сверяет её с `service_url`. +- **Геометрия листа — один источник:** константы Go, клиент получает их в `ListTasksResponse`. +- **Выгрузка журнала — версионированный контракт** из `proto/`, с разбором на Go и fuzz. +- **Образцы автора** — в `/samples/`, он в `.gitignore` и `.dockerignore`: вне репозитория, как велит privacy 4. +- **Номера.** Задачи черновика, которые пережили разбиение, сохранили номера: T16, T18, T19, T34, T35, T48. Новые — с T61. +- **Архив черновика** — всё приложение дословно, а у шести выживших — их текст до разбиения под заголовком «#### T16 (до разбиения E2).». Так поиск по номеру находит одну задачу, а E15 видит исходный текст: перо, ладонь, лестница. Это отступление от буквы R59, где сказано «задачи, которые не пережили разбиение». + +**Не входит — переходит в E15:** вход родителя и App Check, согласие, PIN, профили детей, OpenFGA, Centrifugo, Neon, лимиты R35, лестница подсказок и «решить заново», хранение листа между перезагрузками (R42), удаление и очистка, телеметрия, нагрузка, e2e, планшет и туннель. + +### Часть 1. Решения и облако + +#### T61. Документы: журнал решений и CLAUDE.md + +- **Цель:** журнал решений и правила работы говорят то же, что решил автор 2026-10-15. Следующие задачи E2 не упираются в поле ответа, которого больше нет. +- **Почему:** по правилу 2 задача, которая противоречит документам, останавливается. Без этой правки остановилась бы каждая задача E2. +- **Сделать:** + - **`docs/decisions.md`** — записи со следующими свободными номерами (сейчас — с R60) по «Решено автором» и «Решено при планировании» этого раздела. Близкие решения можно собрать в одну запись — например, о рамках E2 и E15 с таблицей «решение → в E2 → в E15». В каждой — почему и что отвергнуто, как в R37–R43: + - E2 — минимальный срез и эксперимент; бета — E15; + - лист без поля ответа, кнопка Send Answer, итоговый ответ находит модель; + - один вызов с ключом и вердикт программы; итог прочитан дважды и сверяется программой; контрольные варианты eval; + - шесть задач по одной на класс, их пишет Claude, — заменяет R40; + - доступ по ключу и дневной потолок; вход Firebase и App Check — в E15; + - панель автора и журнал — исключение из «ответ скрыт до решения» на время E2; образцы автора — вне репозитория; + - векторы штрихов — исследование T77: пересмотр отвергнутого в R42 по решению автора; + - Т-1 — gin. + - **Поправки** на месте, с пометкой «Поправка 2026-10-15»: + - R22 — модели выбирает eval E2; + - R23 — в E2 всё приходит одним HTTP-ответом, без Centrifugo; + - R24 — запасной текст в E2 без ступеней; + - R35 — в E2 вместо лимитов потолок экземпляра; + - R38 — поля ответа нет, в E2 ключ во входе; + - R39 — в E2 нет второго вызова и лестницы; + - R40 — заменено; + - R42 — одна картинка листа; векторы — T77; + - R44 — E2 разбит 2026-10-15, бета — E15; + - R59 — архив черновика держит и текст шести выживших задач до разбиения, а остаток черновика — не только история, но и сырьё для разбиения E15. + - **`CLAUDE.md`:** + - *The project* — лист без поля ответа, один вызов, E2 и E15; в E2 вызов несёт ключ доступа, а не токен App Check и ID token (сейчас там «every API call … carries a Firebase App Check token»); + - *Hard rules* — что получает Gemini в E2, ключ во входе в E2, исключение панели автора; + - *Layout* — `cmd/eval`, промпт листа; порядок middleware в E2 — CORS, затем ключ; + - *The dependency rule* — проверка в E2 объявляет один порт разбора вместо `Recognizer` и `Tutor`, а `Authorizer`, `Notifier` и `Store` ей в E2 не нужны; + - *Context and docs* — вместо 4 с на чтение и 8 с на разбор один вызов E2 с тайм-аутом, который выбирает T35 в пределах запроса; + - упоминания «MVP epic» — Т-1, таблица событий лога, `ci-integration` — становятся E2 или E15 по смыслу. + - **`README.md:19`, `SECURITY.md:39`** — без поля ответа и с одним вызовом. - **Как проверить:** - - каждый раздел образца либо перенесён, либо отличие объяснено ссылкой на документ canvas-api (R-номер, раздел spec или privacy); - - CLAUDE.md не противоречит документам; - - `git status` показывает только `CLAUDE.md`, `README.md`, `.gitignore` и отметку в этом файле; копия лежит на месте, но git её не видит. -- **Не делать:** код и конфиги инструментов (T02 и дальше). -- **Разделы:** spec 1–3, 13, 16–18; privacy 1, 4, 5; R02, R11, R25, R26. - -#### T02. Простейший сервер - -- **Цель:** первый Go-код, на котором дальше строятся devcontainer, проверки, CI, релиз и CD. -- **Сделать:** - - `go.mod`: `module github.com/MathTrail/canvas-api`, `go 1.27.1` (spec 3.2), без зависимостей; - - `cmd/server/main.go` — только стандартная библиотека: - - порт из `PORT`, по умолчанию 8080; - - `GET /health` → 200 (R30: не `/healthz`); - - `http.Server` с тайм-аутами чтения заголовков, чтения, записи и простоя; - - остановка по SIGINT и SIGTERM: `signal.NotifyContext` и `Shutdown` с тайм-аутом; - - `cmd/server/main_test.go`: `/health` отвечает 200 через `httptest`, неизвестный путь — 404. -- **Как проверить.** На хосте Go нет, поэтому проверка идёт в образе Go с тем же тегом и digest, что в `reference/mathtrail-standalone/Dockerfile`: - - `docker run --rm -v "$PWD":/src -w /src golang:1.27.1-trixie@sha256:… sh -c 'test -z "$(gofmt -s -l .)" && go vet ./... && go test ./... && go build ./cmd/server'`; - - тот же образ с `-p 8080:8080` и `go run ./cmd/server` → `curl localhost:8080/health` отвечает 200. -- **Не делать:** config, logger, gin — это каркас T16; Dockerfile сервиса — T04. -- **Разделы:** spec 3.2, 8.2, 15.3; R30. - -> **Точка проверки части 1.** Правила записаны, первый Go-код собирается и отвечает на `/health`. - ---- - -### Часть 2. DevX: окружение, качество, поставка - -Всё, что в standalone складывалось по ходу работы, здесь ставится сразу, до кода продукта. Каждая задача называет файлы образца, которые переносит. - -#### T03. Devcontainer - -- **Цель:** воспроизводимое окружение, как в standalone. Сама задача выполняется на хосте; все следующие сессии Claude идут внутри контейнера. -- **Сделать:** перенести `.devcontainer/` из `reference/mathtrail-standalone/` и приспособить. - - **`Dockerfile`** — те же две стадии и те же правила: - - `toolchain`: Go, Node.js, just, golangci-lint, mockery; - - `devcontainer`: gopls, dlv, cloudflared, gh, Docker Compose, Claude Code, Terraform, gcloud. cloudflared нужен, чтобы открывать SPA на планшете через туннель; gcloud ставится только на x86_64 (R28 standalone); - - версии — `ARG` с теми же значениями, что в образце на момент переноса; Go — 1.27.1, как в `go.mod`; - - сборка под любой builder, загрузки через `fetch()` с запретом понижения протокола (standalone: R35, R36). - - **`devcontainer.json`:** - - имя и сеть `canvas-api` (`initializeCommand` и `runArgs`); - - тома `canvas-api-claude`, `canvas-api-gcloud`, `canvas-api-gh` — входы в Claude, gcloud и gh переживают пересборку; - - `containerEnv`: `MATHTRAIL_DEVCONTAINER=1`, `CLAUDE_CONFIG_DIR`, `DISABLE_AUTOUPDATER=1`; - - feature docker-in-docker той же версии, с записью в `devcontainer-lock.json`; - - `forwardPorts`: 8080 (API) и 5173 (Vite); - - расширения VS Code образца точных версий, кроме пакета Python — в canvas-api Python нет; - - SonarQube for IDE с выключенной телеметрией (R81 standalone). - - **Скрипты:** - - `host-network.sh` — сеть с MTU хоста; - - `post-start.sh` — `core.hooksPath .githooks`, если папка есть, и тот же MTU для docker внутри контейнера. - - **Остальные файлы:** - - `.gitattributes` — как в образце; - - `.dockerignore` — контекст сборки образа — корень репозитория; из него исключаются `.git`, `.env`, `reference/`, `docs/` и всё, что образу не нужно; - - минимальный `justfile`: `default` (`just --list`) и `claude-update` — CLI и расширение Claude Code это одна версия в двух файлах. - - **Документация:** раздел Environment в `CLAUDE.md` по образцу; раздел для разработчиков в `README.md` — как открыть проект в контейнере. -- **Как проверить:** - - «Reopen in Container»; - - внутри работают `go version`, `node --version`, `just --version`, `golangci-lint version`, `mockery version`, `claude --version`, `gh --version`, `terraform version`, `gcloud --version` и `docker run --rm hello-world`; версии совпадают с `ARG`; - - `go test ./...` из T02 зелёный; - - `echo $MATHTRAIL_DEVCONTAINER` → `1`; - - пересборка без кеша даёт те же версии. -- **Не делать:** - - конфиги линтеров и рецепты сборки — T04; - - `web/` — T05; - - CI — T06; - - buf, firebase-tools и Playwright — их приносят задачи, которым они нужны. -- **Риски:** - - features devcontainer и установщик Claude Code плавают по версиям — закреплять версию и digest явно; - - туннель на хосте режет большие загрузки — поэтому сеть с MTU хоста (R36 standalone). -- **Разделы:** spec 3.4; R11. - -#### T59. Правила: сверка с образцом 2026-10-11 - -- **Цель:** правила работы и отчёта — те же, что образец выработал к 2026-10-11, и они действуют до первого кода. - - CLAUDE.md и правила этого файла переносились со снимка от 2026-09-28. - - С тех пор образец добавил практики, которые делают проверку правки строже. -- **Сделать** — только документы, по `reference/mathtrail-standalone/`: `CLAUDE.standalone.md`, `RUN.md`, `docs/decisions.md`. - - **`CLAUDE.md`, How we work:** - - проверки и ревью идут в чистой копии дерева, только с этой правкой: чужая незакоммиченная работа в них не попадает; - - флейк — тест, который падает под нагрузкой и проходит отдельно, — называется в отчёте и в «Открыто». Задание не перезапускают, пока не позеленеет (R207 standalone); - - отчёт называет, сколько проверок выключалось, чтобы доказать тесты, и все ли пойманы, а также сколько шёл fuzz; - - находку анализатора (CodeQL, SonarCloud, Scorecard) закрывают правкой кода. Пометка «ложное» — крайняя мера: со ссылкой на решение и с тестом, который это доказывает (standalone: R35, R111, R50); - - то, что журнал образца отверг для перенесённых практик, без новых фактов не предлагается. Например, мутационные инструменты (R39 standalone) и гейт по SonarCloud (R31 standalone); - - на время живого прогона Claude получает узкое разрешение в `.claude/settings.local.json`. После прогона файл удаляется; - - номера R и T берутся свободными в момент записи: параллельная сессия может занять следующий. - - **`CLAUDE.md`, Tests:** под `-race` тест с часами даёт время ×10 при тех же шагах, а не повтор и не тайм-аут (R207 standalone). - - **`CLAUDE.md`, Logging:** лог проверяется по таблице «событие → разрешённые поля», а не по списку запретов (standalone: R141, R169). Саму проверку приносит эпик MVP. - - **`CLAUDE.md`, Documents** — формат отчёта живого прогона в `docs/live/`: - - статус — первой строкой; - - без идентификаторов: репозиторий публичный; - - версия и коммит; - - роли; - - стоимость: оценка и факт; - - ход по UTC; - - «Открыто»; - - «Как повторить». - - **Правила этого файла:** - - правило 3: - - через `gh` проверить, что выкладка, которая несла прошлую правку, прошла (с T11); - - находку анализатора сначала чинить кодом; - - правило 6 — документацию читать с датой обращения. Если она расходится с закреплённой версией, эталон — сама библиотека (R06 standalone); - - правила 9 и 10 и формат задачи: - - исполнитель пишет в тело задачи «Сделано (дата)» и «Открыто»; - - при планировании в задачу пишутся «Почему», «Решено автором (дата)», «Выяснилось при планировании», «Мой шаг», «Когда»; - - статусы `[-]` — не делаем, причина в задаче; `[?]` — отложена, в задаче причина и когда вернуться; - - столбец «План» говорит, как начинать задачу, а не о её объёме. -- **Не переносить:** - - автономный режим исследовательских задач: он идёт вразрез с порядком «задача — проверка автором»; - - «English everywhere»: документы `docs/` и этот файл пишутся по-русски (R25); - - ручные значения секретов из R26 standalone: у нас секреты кладёт Terraform (R34). -- **Как проверить:** - - каждый пункт выше перенесён, или его отличие объяснено ссылкой на решение canvas-api; - - CLAUDE.md и правила этого файла не противоречат друг другу. -- **Сделано (2026-10-12):** - - `CLAUDE.md`: - - How we work — все семь пунктов задачи. Сюда же повторены правила 6 и 10 этого файла, а в перечень правил RUN.md добавлены Scorecard и проверка выкладки через `gh`; - - Tests — часы ×10 под `-race`; Logging — таблица «событие → разрешённые поля» со ссылкой на spec 16, где она уже названа; Documents — формат отчёта в `docs/live/`; - - правила этого файла: 3, 6, 9, 10, статусы `[-]` и `[?]`, столбец «План», абзац «Тело задачи». Формат в «Следующем эпике» теперь ссылается на этот абзац. Фраза «новые задачи получают номера с T59» заменена правилом свободного номера; - - «Не переносить» не перенесено: CLAUDE.md об автономном режиме молчит, язык и секреты уже держат R25 и R34; - - сверх текста задачи, на проверку: - - правило анализатора дополнено находкой, которую правка закрыть не может: она остаётся открытой с записанной причиной (R51 standalone). Например, находка Scorecard, которой нужен второй человек. Так T07 и T08 и записывают находки Scorecard и `release.yml`; - - копия дерева, если в нём есть чужая работа, — `git worktree` от `HEAD` вне репозитория. Туда идут только правки этой задачи, новые файлы тоже, ссылка на `reference/` и `web/node_modules` из lockfile; проверки и ревью запускаются изнутри копии. Исправления после ревью делаются в дереве и переносятся в копию, а копию убирает `git worktree remove --force`: без `--force` git отказывается удалять копию с правками. Механизма образец не описывает; - - проверка выкладки через `gh` — «когда T11 подключит CD», а не «с T11»: до T11 выкладки нет, и правило 3 так же говорит «с T08, когда T07 подключит»; - - разрешение на живой прогон даёт автор: так в образце (`docs/live/09-load.md`), и Claude не выдаёт его себе сам; - - флейк лечится в его тесте, как R207 standalone вылечил часы; - - из R207 standalone взяты ещё две вещи: множитель живёт в одном помощнике с тегом `race`, а не в константе каждого теста, а тест о самих часах ставит свои; - - ссылка на документацию с датой обращения пишется в отчёт и в решение или документ, которые на неё опираются; - - в «Теле задачи» есть «Нагрузка (правило 14)»: этот раздел уже стоит в 17 задачах черновика E2; - - статусы `[-]` и `[?]` — «решение автора», как в образце. Причина — в задаче, а не «в начале фазы»: у нас не фазы, а эпики (R44); - - проверки: кода в правке нет, рецепты правила 7 появятся с T04. `gofmt`, `go vet` и `go test ./...` зелёные, код не тронут. Выключенных проверок и fuzz нет. Дерево держит только эту правку, поэтому ревью шло в нём самом; - - ревью `/code-review high`, два круга по 10 замечаний. - - Первый круг. Исправлено: неполный механизм копии дерева (два замечания); потерянное исключение R207 для теста о самих часах; фраза «номера с T59», которая спорила с правилом свободного номера; «принять» с обязательным решением — ужесточение сверх задачи, которое расходилось с T07 и T08; не сказано, куда пишется дата обращения. Отброшено: T59 ещё не отмечена `[x]` (отметка ставится после ревью); строка в `.gitignore` в этой правке (правка только документов, а `.gitignore` принадлежит T04); дублирование правил в CLAUDE.md и RUN.md (зеркальные пункты были и раньше, правка держит их в согласии). Вопрос к автору — ниже. - - Второй круг, все десять исправлены: `git worktree remove` без `--force`; новые файлы в копии; проверки и ревью изнутри копии; возвращена оговорка о чужой работе в пункте о дефектах — дерево может перестать быть чистым уже после начала задачи; «с T11» на одну задачу раньше, чем есть выкладка; кто даёт разрешение на прогон; лечение флейка; «Нагрузка» в «Теле задачи»; формулировка про `.gitignore`; фраза о номерах всё ещё называла T59. -- **Открыто:** - - вопрос к автору: после прогона удаляется весь `.claude/settings.local.json`, как велит задача. Если там лежат и другие настройки автора, они пропадут. Убирать ли тогда только правило прогона? - - `.claude/settings.local.json` git не игнорирует: файл разрешения живого прогона виден в `git status`, и коммит может его забрать. В `.gitignore` образца такой строки нет. Предлагаю добавить её в T04, которая пишет `.gitignore`. -- **Разделы:** CLAUDE.md (How we work, Tests, Logging, Documents); правила 3, 6, 9 и 10 этого файла; R25, R34; standalone: R06, R31, R35, R39, R50, R111, R141, R169, R207. - -#### T60. Devcontainer: версии и наши отличия от образца - -- **Цель:** контейнер — на тех же версиях, что образец от 2026-10-11, а наши намеренные улучшения записаны и не откатятся при следующей сверке. -- **Сделать:** - - **Версии:** - - Go 1.27.1 → 1.27.2: `ARG GO_VERSION` в `.devcontainer/Dockerfile`, директива `go` в `go.mod`, spec 3.2 и `docs/stack.html`; - - golangci-lint 2.13.2 → 2.14.0: `ARG` и `docs/stack.html`. - - Остальные `ARG`, features, расширения VS Code и digest совпадают с образцом — сверено 2026-10-11. - - **Решение в `docs/decisions.md`** (номер — свободный на момент записи): чем devcontainer canvas-api намеренно отличается от образца. Без записи следующая сверка может это откатить: - - `host-network.sh` находит интерфейс через `ip route get` и MTU только понижает; - - `post-start.sh` пишет настройку docker, только если она изменилась, не трогает `docker0` и просит перезапустить контейнер; - - Dockerfile чистит кеши Go и npm в конце стадии; - - в `.gitattributes` правило `* text=auto` стоит первым; - - `.dockerignore` исключает `**/.env`; - - `just claude-update` принимает версию и проверяет оба пина; - - Python и uv образца не ставятся: в canvas-api Python нет (T03). -- **Как проверить:** - - Rebuild Container; внутри `go version` и `golangci-lint version` совпадают с `ARG`; - - `go test ./...` зелёный. -- **Сделано (2026-10-12):** - - версии: `ARG GO_VERSION=1.27.2` и `ARG GOLANGCI_LINT_VERSION=2.14.0` в `.devcontainer/Dockerfile`, `go 1.27.2` в `go.mod`, spec 3.2 и пять строк `docs/stack.html`. Сверено заново: все `ARG` Dockerfile, кроме uv и Python, и digest базового образа совпадают с образцом, а features и расширения VS Code — кроме пакета Python; - - решение R46 — в разделе «Архитектура и инфраструктура»; к R11 — пометка со ссылкой на R46, во вступление журнала — строка о R46; - - в подвале `docs/stack.html` среди сделанных теперь T59 и T60, в источниках — R01–R46; - - сверх текста задачи, на проверку: - - в R46 есть третье отличие `host-network.sh`, которого нет в списке задачи: сеть, созданную без опции MTU, скрипт читает как сеть с MTU 1500. Образец в этом случае пытается создать сеть заново и падает. Проверено на docker в контейнере: `docker network inspect` даёт пустую строку, повторный `docker network create` выходит с кодом 1; - - `claude-update` образца тоже принимает версию. Отличие в том, что наш рецепт берёт её из окружения, а не вставкой в текст скрипта; так R46 и пишет; - - остальные отличия R46 называет коротко: постоянные — имена, порт 5173, комментарии, а временные — то, что принесут следующие задачи E1; - - строку источников spec 20 «проверено … на Go 1.27.1» и историю T02 и T03 не трогал: это записи о том, что было сделано на той версии; - - проверки: образ собран из нового Dockerfile так же, как его собирает Rebuild Container (`docker build -f .devcontainer/Dockerfile --target devcontainer .`). Внутри `go version` — go1.27.2, `golangci-lint version` — 2.14.0, gopls 0.23.0 и dlv 1.27.2 собрались. В этом образе `gofmt -s`, `go vet ./...`, `go test ./...` и `go test -race ./...` зелёные. Выключенных проверок и fuzz нет: кода в правке нет. Дерево держит только эту правку, поэтому ревью шло в нём самом; - - ревью `/code-review high`, первый круг: 8 замечаний. Исправлено: R46 не называла временное отличие — фразу о `.sonarlint/connectedMode.json`, которая вернётся с T07; вывод о `docker0` подан как факт без ссылки на T03, откуда он взят; у R46 не было «Отвергнуто»; здесь не было итога ревью; в «Разделах» не было R46. Отброшено: `go 1.27.2` ломает Go в нынешнем контейнере — так велит задача, а Rebuild Container — её шаг проверки (ниже, в «Открыто»); команды T02 и T03 на 1.27.1 — это тела сделанных задач, запись того, что было (правило 1); подвал `docs/stack.html` называет T60 сделанной раньше отметки `[x]` — отметка ставится после ревью; - - второй круг: 5 замечаний. Исправлено: R46 обещала, что фразу о `.sonarlint/connectedMode.json` вернёт T07, а тело T07 о `devcontainer.json` молчит — теперь R46 говорит только, что фраза возвращается вместе с файлом, а предложение для T07 — ниже; R46 не называла временными короткий `.dockerignore` (его дополнит T04) и рецепты `justfile`; R46 пересказывала комментарии скриптов — теперь в ней только отличия и их причины; у находки S8431 не было названо, на что ссылаться. Отброшено: устаревший MTU в `daemon.json` после ухода туннеля — `post-start.sh` берёт MTU интерфейса самого контейнера, а он меняется, только если пересоздать сеть `canvas-api` на хосте. Старый контейнер после этого не стартует, а у нового нет старого `daemon.json`. -- **Открыто:** - - Rebuild Container — шаг автора. До него в нынешнем контейнере (Go 1.27.1, `GOTOOLCHAIN=local`) `go test ./...` отказывает: `go.mod requires go >= 1.27.2`; - - `.sonarlint/connectedMode.json` приносит T07, но её тело не называет `devcontainer.json`, а правило 1 не даёт трогать файлы, которых задача не называет. Предлагаю дописать в T07: вернуть в комментарий SonarQube for IDE фразу о файле, как в образце (R46); - - SonarQube for IDE отмечает `docker:S8431` («тег или digest, не оба») на `FROM` базового образа. Когда T07 подключит SonarCloud, эта находка останется открытой по правилу 3: убрать тег или digest — значит нарушить правило точных версий (CLAUDE.md, spec 1), на него и ссылаться. -- **Разделы:** spec 3.2; R11, R46; standalone: R05. - -#### T04. Проверки Go и образ сервиса - -- **Цель:** те же локальные проверки качества и тот же образ сервиса, что в standalone, — до того, как появится CI. -- **Сделать:** перенести из `reference/mathtrail-standalone/` и приспособить. - - **`justfile`, Go-часть:** - - рецепты `fmt`, `fmt-check`, `lint`, `test`, `build`, `run`, `mocks`, `ci-lint`, `ci-test`, `ci-mocks-check`, `release-artifacts`, `docker-build`, `docker-run`; - - `ci-test` — с `-race`, покрытие в `coverage.out`; - - переменные сборки и ldflags — как в образце, модуль `github.com/MathTrail/canvas-api`. - - **`.golangci.yml`** — файл образца: формат v2, тот же набор линтеров и настроек, включая `forbidigo`, `gocognit`, `interfacebloat` и `hugeParam`. Поменять: - - depguard `domain-stays-pure` для `internal/domain/**` и пакетов сценариев (`internal/check/**` и соседних, spec 18): без `net/http`, gin, Firebase, OpenFGA, genai, pgx, `database/sql` и Centrifugo; - - вместо запрета pgx везде — pgx только в `internal/infra/postgres` и `cmd/migrate`; - - `goimports.local-prefixes` — `github.com/MathTrail/canvas-api`; - - убрать `run.build-tags: [analytics]` — это продукт образца; - - `hugeParam.sizeThreshold`: порог образца 88 держится на его продуктовой причине (R47 standalone). Взять умолчание 80 или записать свою причину. - - **Прочие конфиги:** - - `.mockery.yaml` — как в образце, с пустым `packages: {}`: первый мок появится с первым интерфейсом, которому он нужен (R43 standalone); - - `.githooks/pre-commit` — как в образце: форматирование, сборка, тесты; без тулчейна — предупреждение и пропуск; - - `.vscode/launch.json` — две конфигурации образца: запуск сервера и отладка теста под курсором. Третья, «as Cloud Run sees it» с `K_SERVICE`, придёт вместе с каркасом в эпике MVP. - - **`internal/version`:** `Version`, `Commit` и `Date` через `-ldflags -X`. Тесты — как в образце: один собирает бинарник с ldflags и читает значения, другой проверяет, что Dockerfile и justfile ставят те же символы. - - **`cmd/server`:** - - `/health` отвечает `{"status":"ok","version":…,"commit":…}` с `Cache-Control: no-store` — по коммиту CD узнает, что выехала нужная ревизия (T11); - - при старте — строка лога с версией. - - **`Dockerfile` сервиса:** - - сборка на `golang:1.27.2-trixie` по digest образца — та же версия, что в `go.mod` (T60): `CGO_ENABLED=0`, `-trimpath`, ldflags с версией; - - образ запуска — `gcr.io/distroless/static-debian13` по digest, `USER nonroot:nonroot`. - - **`.gitignore`** — по образцу: `bin/`, `dist/`, покрытие, `node_modules/`, Terraform, `.scannerwork/`; `reference/` остаётся. - - В правилах Terraform сразу исключения `!infra/terraform/prod.auto.tfvars` и `!infra/terraform/backend.hcl`: эти файлы коммитятся (T10). - - **`.dockerignore`** — явный список, как в образце. К нынешним `.git`, `**/.env`, `.devcontainer`, `reference`, `docs`, `draft`, `*.md` добавить `.github`, `.vscode`, `.sonarlint`, `infra`, `testdata`, `tools`, `bin`, `dist`, `coverage.*`, `**/node_modules`. - - **`CLAUDE.md`, Environment** — как в образце: - - пункты «Git hooks» и «The runtime image»; - - список рецептов, которые обязательно зелёные: `ci-lint`, `ci-test`, `ci-mocks-check`. Его дополняют T05, T06, T08 и T10; - - строка «Build context» — по новому `.dockerignore`. -- **Тесты:** `/health` (код, тело, заголовок); `internal/version`. -- **Как проверить:** - - `just ci-lint`, `just ci-test` и `just ci-mocks-check` зелёные; - - `just docker-build && just docker-run` → `curl localhost:8080/health` показывает коммит; - - pre-commit останавливает `git commit` с неотформатированным файлом. -- **Не делать:** config, logger и gin — каркас эпика MVP; web — T05; CI — T06. -- **Сделано (2026-10-12):** - - `justfile`: тринадцать рецептов Go-части, переменные сборки и ldflags — как в образце, с модулем `github.com/MathTrail/canvas-api`. `build` и `release-artifacts` не собирают виджет: SPA в бинарник не входит (R10). `docker-run` — без ключа печати образца; - - `.golangci.yml` — файл образца с правками задачи и ревью: - - `domain-stays-pure` держит весь `internal/`, кроме `app`, `infra`, `telemetry` и `transport`. Так под правило попадают и `check`, и соседние сценарии, которых ещё нет (spec 18); - - запрещены `net/http`, gin и chi, Firebase, OpenFGA, genai, pgx, `database/sql` и Centrifugo; - - `pgx-stays-in-postgres` пускает pgx только в `internal/infra/postgres` и `cmd/migrate`; - - `goimports.local-prefixes` — `github.com/MathTrail/canvas-api`, `run.build-tags` убран, у `hugeParam` умолчание 80 с объяснением; - - `.mockery.yaml` с `packages: {}`, `.githooks/pre-commit` и `.vscode/launch.json` с двумя конфигурациями — как в образце, с поправками ревью (ниже); - - `internal/version` и два теста образца; `cmd/server`: - - `/health` отвечает `{"status","version","commit"}` с `Cache-Control: no-store`; - - при старте пишет одну JSON-строку с ключами, по которым Cloud Logging читает запись: `severity`, `message: startup`, версия, коммит, дата и адрес. Логгер — каркас эпика MVP, поэтому строку пишет `encoding/json`; - - `Dockerfile` сервиса: `golang:1.27.2-trixie` по digest образца, `CGO_ENABLED=0`, `-trimpath`, ldflags с версией; запуск — `gcr.io/distroless/static-debian13` по digest, `USER nonroot:nonroot`. `COPY go.* ./` вместо `go.mod go.sum`: зависимостей нет, и `go.sum` тоже; - - `.gitignore` по образцу, с исключениями `prod.auto.tfvars` и `backend.hcl`; `.dockerignore` — явный список задачи; - - `CLAUDE.md`, Environment: рецепты на каждый день и обязательные `ci-lint`, `ci-test`, `ci-mocks-check`; «Git hooks», «The runtime image», «Build context»; - - сверх текста задачи, на проверку: - - `fmt`, `fmt-check` и хук форматируют только файлы самого репозитория. `gofmt -s -w .` образца зашёл бы в `reference/`, а копию править нельзя. Сегодня все 611 Go-файлов копии отформатированы, но новый gofmt или обновлённый снимок могут это изменить; - - правило `domain-stays-pure` задано от обратного, чтобы не перечислять сценарии, которых ещё нет. Под ним оказываются и `config`, `logger`, `version`, `apierror`: таких импортов им тоже не нужно. Новый пакет ввода-вывода вписывается в исключения по имени; - - chi запрещён рядом с gin: роутер ещё не выбран (Т-1); - - `.claude/settings.local.json` в `.gitignore` — по предложению из «Открыто» T59; - - `ci-mocks-check` добавлен в обязательные и в правило 7, и в CLAUDE.md (How we work), чтобы три места называли один список; - - в `docs/stack.html` статус «в репозитории» получили depguard, `internal/version`, образ сервиса и gofmt; в подвале — T00–T04; - - Dockerfile и CLAUDE.md объясняют, почему образ запуска закреплён только digest: у distroless нет тегов с версией. Исключение вписано и в Hard rules; - - проверки — в образе стадии `toolchain` того же Dockerfile (Go 1.27.2, golangci-lint 2.14.0): контейнер ещё на Go 1.27.1. - - `golangci-lint config verify`, `just ci-lint` — 0 замечаний, `just ci-test` — покрытие `cmd/server` 81,1 %, `just ci-mocks-check`. - - depguard в копии дерева: отклонил `net/http` в домене, в `check`, в соседнем сценарии и в `version`, `database/sql` в `check`, pgx в домене и в `infra/gemini`; пропустил `net/http` в `infra`, `transport`, `app`, `telemetry` и pgx в `infra/postgres` и `cmd/migrate`. - - `just docker-build && just docker-run`: `curl localhost:8080/health` → `{"status":"ok","version":"dea01ca-dirty","commit":"dea01ca"}` с `no-store`, строка старта называет версию и коммит, образ — 16,2 МБ. - - Хук в одноразовом репозитории: - - останавливает `git commit` с неотформатированным файлом; - - пропускает коммит, когда неопрятный файл лежит только в `reference/`; - - останавливает коммит, где поменялся только justfile, если тот ломает путь ldflags (хук образца такой коммит пропускает); - - без Go предупреждает и пропускает проверки; - - `just fmt` файл в `reference/` не тронул. - - 14 поломок новых проверок в копиях дерева — четыре по `/health`, шесть по строке старта, четыре по `internal/version`: все пойманы. Fuzz нет: внешний ввод здесь никто не разбирает. - - ревью `/code-review high`, первый круг: 9 замечаний. - - Исправлено, и всё это — тоже отличия от образца: - - пресет `comments` глушил `exported` и `package-comments` у revive, то есть правило doc-комментариев из CLAUDE.md не проверялось. В копии с пресетом — 0 замечаний, без него — 3; наш код чист и без пресета. Пресет убран; - - `template: mockery` и `with-expecter` mockery v3.8.0 отвергает: шаблона с таким именем нет, а у testify нет такой опции. Теперь `template: testify`, и mock с `EXPECT()` получается — проверено на Go 1.27.1; - - условие хука `**/*.go` без `globstar` видит только один уровень, поэтому у нас хук пропускал все коммиты без `.go`. Условие переписано под нашу раскладку; - - в `domain-stays-pure` добавлены `google.golang.org/api` и `cloud.google.com/go`: в образце первый тоже запрещён; - - локальный `go.work` попадал в сборку образа — `.dockerignore` его исключает. Ещё он теперь исключает каждый `testdata/`, `web/`, `.claude/`, `.githooks/`, а комментарий говорит правду. - - Отброшено: - - хук смотрит рабочее дерево, а не индекс — так устроен хук образца: дешёвая первая проверка, а то, что закоммичено, проверяет CI; - - `just test` и хук без `-race` — так в образце: детектор гонок — дело `ci-test` (CLAUDE.md, Environment), а R207 standalone опирается на прогон без него; - - нет кеша сборки в образе, и `DATE` сбрасывает слой — кеш через `--mount` отвергнут (R36 standalone), а сборка с датой пересобирается по замыслу. - - второй круг: 10 замечаний. - - Исправлено: - - хук прятал, какой тест упал: теперь при провале показывает вывод `go test`; - - в строке старта не было ключа `time`, который CLAUDE.md (Logging) называет рядом с `severity` и `message`. Добавлен, тест проверяет момент; - - правило «тег плюс digest» в Hard rules не знало исключения для distroless — исключение вписано; - - тест строки старта ждал только строку, и ранний выход `run()` выглядел как тайм-аут на 5 с. Теперь тест сразу называет ошибку; - - `.dockerignore` пропускал `*.out`, `*.test`, `*.prof` и `.scannerwork` — добавлены; - - `find` заходил в `node_modules` и `.terraform` — теперь не заходит, а хук и justfile ссылаются друг на друга, чтобы список не разошёлся; - - комментарий к `domain-stays-pure` обещал больше, чем правило проверяет. Теперь он называет ровно то, что запрещено; - - `.gitignore` по образцу потерял строки шаблона GitHub (`*.so`, `*.dylib`, `*.dll`, `*.exe~`, `*.coverprofile`, `profile.cov`) — возвращены. - - Отброшено: - - хук блокирует коммит, когда Go старше `go.mod`: причину называет сам `go build`. На хосте Go нет, а хук включится тем стартом контейнера, который принесёт Go 1.27.2; - - `**/internal/**` совпадёт со всем деревом, если клон лежит в каталоге `internal`: depguard сверяет абсолютные пути и не умеет привязываться к корню модуля. Такой клон маловероятен, а провал был бы громким. -- **Открыто:** - - Rebuild Container — шаг автора (T60). До него в контейнере не работают команды Go, а хук `post-start` включит только при следующем старте контейнера; - - mockery 3.8.0 не загружает пакеты на тулчейне Go 1.27.2: «export data version 5 is greater than maximum supported version 4». Так и с `go 1.27.1` в модуле, и с пустым кешем, а на Go 1.27.1 всё работает. Внутри mockery — golang.org/x/tools v0.49.0. Пока `packages: {}`, рецепты моков ничего не делают, но к первому моку (эпик MVP) mockery придётся поднять. В образце те же версии; - - отличия проверок от образца — пресет `comments`, шаблон testify, условие хука, отказ `gofmt` от `reference/`, правило depguard от обратного — объяснены в комментариях файлов, но не в журнале решений. Предлагаю записать их решением, как R46 для devcontainer: иначе следующая сверка может их откатить; - - SonarQube for IDE отмечает `docker:S8431` и на `FROM` сборщика в `Dockerfile` сервиса — отвечать вместе с находкой из T60, когда T07 подключит SonarCloud. -- **Разделы:** spec 3.2, 18; R30; standalone: R05, R20, R35, R43, R47. - -#### T05. Web-тулчейн - -- **Цель:** у SPA с первого дня те же проверки, что у виджета standalone: формат и линт, типы, тесты, сборка, лицензии и уязвимости npm. -- **Почему:** продуктового кода в E1 нет, но CI (T06), релиз (T08) и выкладка на Hosting (T11) должны собирать и проверять SPA с первого дня. Поэтому web приходит заглушкой, сразу с тулчейном и проверками виджета standalone (standalone: R84, R85). -- **Решено автором (2026-10-12):** - 1. **JSX собирает `@vitejs/plugin-react` 6.1.2: нужен Fast Refresh в dev-сервере.** - - Плагин под MIT, его единственная зависимость — `@rolldown/pluginutils` (MIT), peer — `vite ^8.0.0`. - - Babel он больше не тянет: Babel и React Compiler — только необязательные peer. - - Тот же плагин ставит официальный шаблон `create-vite` для React. - - Образец отверг `@preact/preset-vite` (R84 standalone): Babel ради горячей перезагрузки, которой нет у чат-хоста. Здесь не верен ни один из двух доводов. - - Отвергнуто: - - сборка по tsconfig без плагина, как в образце: работает, но правка компонента перезагружает страницу; - - `@vitejs/plugin-react-swc`: тянет нативный `@swc/core`, а рядом с Oxc в Vite 8 он лишний; - - `@vitejs/plugin-react-oxc`: peer только `vite ^6.3 || ^7`, и он влит в plugin-react. - - Записано решением R47. - 2. **`ci-licenses` и `ci-vuln` обязательны всегда**, как `ci-lint`, `ci-test` и `ci-mocks-check`. Это записано в правиле 7 и в CLAUDE.md (How we work, Environment). - 3. **В `go.mod` — `ignore node_modules`** (директива Go 1.25+). - - Сегодня ни в одном пакете дерева `web/` нет файлов `.go`, но npm-пакеты с Go бывают: например, `flatted` 3.4.4 несёт `golang/pkg/flatted/flatted.go`. - - Без директивы `./...` сделал бы такой файл пакетом модуля, и его разбирали бы линтер, тесты, govulncheck и go-licenses. - - `GO_FILES` и хук уже пропускают `node_modules` для gofmt. - - В образце директивы нет; отличие записано в R47. -- **Выяснилось при планировании (2026-10-12):** - - **Vite 8.3.2** (документация тега v8.3.2): - - сам читает `jsx` и `jsxImportSource` из tsconfig; - - цель сборки по умолчанию — `chrome111`, `edge111`, `firefox114`, `safari16.4`, `ios16.4`. Product 10 требует «последние версии», поэтому `build.target` не задаётся; - - CSS по умолчанию минифицирует `lightningcss`; - - `server.allowedHosts` пускает только `localhost`, `*.localhost` и IP-адреса. Туннелю cloudflared до планшета понадобится разрешение — это дело E2. - - **React и типы:** React 19.3.0 — `latest`. У `@types/react` и `@types/react-dom` 19.3.0 тег `ts7.0`. `IS_REACT_ACT_ENVIRONMENT` они не объявляют, поэтому тест ставит его через `vi.stubGlobal`. - - **JSX в тестах.** Как в образце, `vitest.config.ts` плагинов не грузит, а при нём Vitest не читает `vite.config.ts`. Значит: - - тесты компилируют JSX по tsconfig; - - dev и сборка — через плагин, а он сам ставит автоматический runtime из `react` (его `dist/index.js`). - - Это те же настройки, что `react-jsx` в tsconfig. Плагину JSX-опций не передают, чтобы оба места стояли на общем умолчании; правка JSX-настроек — правка обоих. - - **Biome 2.5** сам включает рекомендованные правила домена React, когда в зависимостях есть `react` (biomejs.dev/linter/domains). - - **Ignore-файлы.** `.dockerignore` уже исключает весь `web` и `**/node_modules` (T04), а `.gitignore` держит `node_modules/` и `dist/` на любой глубине. Новая строка одна — `/web/coverage/`. - - **`THIRD_PARTY_LICENSES`** появляется здесь, а не в T06: без него `ci-licenses` не зелёный. T06 только запускает рецепт в CI. - - **Vite 8.3.3 и 8.3.4** вышли 2026-10-06 и 2026-10-08, после пина образца. - - В 8.3.3 — правки `server.fs` dev-сервера, перенесённые в тот же день в 8.2.4 и 8.1.6. - - Опубликованного GHSA на 8.3.2 нет (GitHub Advisory API). - - Сначала пин остался за образцом, как велела задача. После отчёта его подняли до 8.3.4 (см. «Сделано»). - - Пока dev-сервер наружу не открыт: он слушает только `127.0.0.1` и отвечает только своим хостам (`allowedHosts`). Туннель до планшета (E2) это снимет: cloudflared подключается изнутри контейнера, а `npm audit --omit=dev` dev-зависимость vite не видит. Как это закрыто — R48 и E2. -- **Сделать:** - - **`web/`** — минимальное приложение: страница-заглушка с названием и версией сборки, один тест. - - **Стек** — spec 3.1 и R84 standalone: - - React 19, Vite 8; - - TypeScript 7 только для проверки типов — сам он ничего не компилирует; - - Biome — формат и линт; Vitest с happy-dom; - - версии точные, `package-lock.json`, `.npmrc` с `save-exact` и `ignore-scripts`; - - версии берутся из `web/package.json` образца, а не из `docs/stack.html`: Biome 2.5.15, Vitest и `@vitest/coverage-v8` 5.0.3, TypeScript 7.0.2, Vite 8.3.2, happy-dom 20.14.5, `@types/node` 24.19.1. `docs/stack.html` поправить. - - **React.** Готового шаблона в образце нет: там Preact, JSX собирается без плагина Vite (R84 standalone). При планировании решить, как собирать JSX: - - через tsconfig, без плагина; - - через `@vitejs/plugin-react` или `-swc` — проверить лицензию и совместимость с Vite 8 и решить, нужен ли быстрый перезапуск компонентов. - - **Конфиги:** - - два tsconfig — для браузера (DOM) и для Node — и скрипт `typecheck`; - - отчёт покрытия Vitest — `lcovonly` с `projectRoot: ".."`, чтобы Codecov и SonarCloud нашли файлы (T07); - - dev-сервер Vite слушает `host: "127.0.0.1"`, `port: 5173`, `strictPort: true`. Если Vite сам выберет `localhost`, он слушает только IPv6, и проброшенный порт до него не дойдёт. - - **`web/scripts/licenses.ts`** — как в образце (R85 standalone): - - проверка лицензий всех пакетов из lockfile, включая dev, и строки для `THIRD_PARTY_LICENSES`; - - `lightningcss=MPL-2.0` — единственное исключение из списка: его тянет Vite 8, без исключения `ci-licenses` упадёт. Исключение записать решением в `docs/decisions.md`, как R85 standalone: CLAUDE.md пускает только зависимости, совместимые с MIT. Довод — пакет только собирает CSS и в сборку не попадает, а MPL-2.0 — copyleft на уровне файла, наш код он не задевает. - - **Рецепты:** - - `web-install`, `web-build`, `web-fmt`, `web-lint`, `web-test` и `web-dev` — dev-сервер Vite; - - `ci-web` — Biome, типы, тесты с покрытием в `web/coverage/lcov.info`; - - `licenses` и `ci-licenses` — Go и npm, список разрешённых лицензий образца; - - `ci-vuln` — `govulncheck` и `npm audit --omit=dev --audit-level=high`. - - SPA в бинарник не встраивается: её раздаёт Firebase Hosting (R10). Поэтому `build` и Dockerfile сервиса web не трогают. Текст npm-раздела в `THIRD_PARTY_LICENSES` говорит то же. - - `.gitignore` и `.dockerignore`: `web/node_modules`, `web/dist`, `web/coverage`. - - **`CLAUDE.md`, Environment** — список обязательных рецептов дополняется `ci-web`, `ci-licenses` и `ci-vuln`. -- **Тесты:** один тест компонента в happy-dom. -- **Как проверить:** - - `just ci-web`, `just ci-licenses` и `just ci-vuln` зелёные; - - `just web-build` кладёт сборку в `web/dist`; - - `just web-dev` в контейнере открывается на проброшенном порту 5173. -- **Не делать:** Konva, protobuf, centrifuge-js и Firebase SDK — их приносят задачи контрактов и SPA. -- **Риски:** TypeScript 7 и Vite 8 молоды — версии сверить с их release notes. -- **Сделано (2026-10-12):** - - **`web/`:** - - стек: React 19.3.0, Vite 8.3.4 с `@vitejs/plugin-react` 6.1.2, TypeScript 7.0.2 (только типы, две программы), Biome 2.5.15, Vitest 5.0.3 с happy-dom 20.14.6 и `@vitest/coverage-v8`. Пины образца — Vite 8.3.2, happy-dom 20.14.5 и `@types/node` 24.19.1 — подняты после отчёта (ниже); - - `.npmrc` образца, lockfile на 118 пакетов, файлов `.go` в `node_modules` нет; - - страница-заглушка `App`: название и версия сборки из `VITE_VERSION` (`buildVersion`, иначе `dev`); - - тесты: компонент в happy-dom (`createRoot` и `act` из `react`, флаг act через `vi.stubGlobal`), версия, `scripts/licenses.ts`. Последний — файл образца, сменены только слова «the widget and the site» → «the SPA»; - - **`go.mod`:** `ignore node_modules` (решение 3); - - **`justfile`:** - - переменные: пины govulncheck v1.8.0 и go-licenses v2.0.1 образца, `ALLOWED_LICENSES`, `NPM_LICENSE_EXCEPTIONS`; - - рецепты: `licenses`, `_license-list`, `web-install`, `web-build`, `web-fmt`, `web-lint`, `web-test`, `web-dev`, `ci-web`, `ci-vuln`, `ci-licenses`; - - SPA в сборку сервиса не входит (R10): `build`, `release-artifacts` и `Dockerfile` web не собирают. Кавычки в `build`, `docker-build` и `release-artifacts` поправлены после отчёта — ниже; - - **`THIRD_PARTY_LICENSES`:** `std` go1.27.2 и `react`, `react-dom`, `scheduler`; - - **`.gitignore`:** `/web/coverage/`. `.dockerignore` не менялся; - - **документы:** - - решения R47 (тулчейн SPA) и R48 (лицензии npm); - - CLAUDE.md: How we work, Tooling and images, Environment; - - правило 7 (решение 2); - - `docs/stack.html`: легенда, версии и статусы клиента и проверок, подвал; - - **сверх текста задачи, на проверку** — каждое пришло из ревью или проверок, а в R47 записано всё, чем мы отличаемся от образца: - - `tsconfig.node.json` — `lib: ["ES2022"]` без DOM: в образце программа Node наследует DOM, и `document` в скрипте проходил проверку; - - `web-build` сначала удаляет `web/dist`. Vite чистит выход, только когда начинает писать, поэтому упавшая сборка оставляла прежнюю — проверено; - - `_license-list` показывает вывод go-licenses, если отчёт упал. Образец его глотает: там та же поломка печатает только «recipe failed» — проверено; - - `web-dev` принимает версию так же, как `web-build`, через окружение. Версия, вставленная в текст команды, выполнила бы `$(…)` из имени тега — показано на пробном justfile; - - к тестам `licenses.ts` добавлен перебор всех выражений до пяти токенов из семи — около 20 тысяч, 0,2 с под покрытием. Свойства: пустой список ничего не пропускает, длинный пропускает не меньше короткого, и принимается только предложение (третье свойство добавлено ревью доработок, ниже); - - `index.html` с `meta charset` и viewport (без него Safari на iPad раскладывает страницу на 980 px); имена компонентов — в PascalCase, как в образце и create-vite; - - **проверки:** - - зелёные: `just ci-lint`, `just ci-test` (`cmd/server` — 81,1 %), `just ci-mocks-check`, `just ci-web` (53 теста; покрытие web — 95,7 % строк, в `licenses.ts` не покрыт только запуск из командной строки), `just ci-licenses`, `just ci-vuln`, `just docker-build`; - - `just web-build v9.9.9` кладёт сборку в `web/dist`, и бандл называет `v9.9.9`; пути в `lcov.info` — от корня (`SF:web/...`); - - `just web-dev` слушает только `127.0.0.1:5173`, IPv6 не отвечает, страница называет версию дерева; - - Fast Refresh работает: в `index.html` есть преамбула, `/@react-refresh` отдаётся, а в `App.tsx` есть `$RefreshReg$`; - - **выключенные проверки** — 19, все покраснели или показали отказ: - - версия убрана из `App`; - - запасное `dev`: `??` вместо `||`, а потом без запаса вовсе; - - в `licenses.ts`: `AND` как `OR`; исключение для поставляемого пакета; `WITH` принят; непарсимое принято — последние два ловит перебор; - - лицензия GPL-3.0 в lockfile; устаревший `THIRD_PARTY_LICENSES`; - - формат; условный хук (домен React в Biome включён); - - `process` и `Buffer` в коде страницы; `document` в скрипте; ошибка типа в `vite.config.ts`; - - битый импорт (сборка); `plugins: []` — преамбула и `$RefreshReg$` пропадают; - - `minimist@1.2.5` в копии `web/`: в `dependencies` audit красный, в `devDependencies` зелёный; - - Go-файл в `web/node_modules`: без `ignore` его видит `go list`, и `go vet` красный; с `ignore` зелёные `go list`, `go vet`, `ci-lint` и `ci-test`; - - Biome без `useIgnoreFile` — 3084 ошибки на `dist/`. - - **fuzz:** Go-кода в правке нет; разбор SPDX держат таблица образца и перебор. Флейков не было; - - **документация, прочитана 2026-10-12:** - - npm registry: версии, лицензии, peer; - - Vite v8.3.2 (features, build и server options) и changelog 8.3.3–8.3.4. Для v8.3.4 те же страницы сверены заново: по сути они не изменились; - - README и CHANGELOG plugin-react 6.1.x; - - CHANGELOG React 19.3.0; - - release notes TypeScript 7.0: убраны `baseUrl`, `moduleResolution` node10 и classic, target ES5 — ни одной из этих опций у нас нет; - - biomejs.dev (домены линтера) и схема Biome 2.5.15; - - go.dev/ref/mod (`ignore`); - - SPDX 2.3 (операторы чувствительны к регистру); - - GitHub Advisory API; - - **ревью `/code-review high`:** - - первый круг, 8 замечаний: - - исправлено: «слушает 127.0.0.1» подавалось как защита dev-сервера, а туннель её обходит; «tsconfig — единственный источник JSX» неверно — плагин сам ставит автоматический runtime из `react`; `web-dev` вставлял `{{ VERSION }}` в текст команды; `_license-list` глотал ошибки go-licenses; - - отброшено: подвал `stack.html` называет T05 сделанной раньше `[x]` — отметка ставится после ревью, как в T04 и T60; строчные `or`/`and` в лицензии — по SPDX 2.3 операторы чувствительны к регистру, отказ безопасен; go-licenses идёт дважды — так в образце, это секунды, а один проход переписал бы проверку go-licenses; - - вопрос к автору — ниже; - - второй круг, 7 замечаний: - - исправлено: комментарий `web-build` обещал, что упавшая сборка не оставит прежнюю, — неверно, проверено; `web-dev` и `web-build` получали версию по-разному; у разбора SPDX не было проверки свойств; `tsconfig.node.json` наследовал DOM; - - отброшено: в T05 нет «Открыто» — оно ниже; подвал `stack.html` — как в первом круге; `!coverage` в `biome.json` дублирует `.gitignore` — так в образце, и Biome обходит покрытие, даже если файл игнора не читается (без него он проверял `dist/`); - - исправления второго круга прошли проверки, третьего ревью нет (CLAUDE.md). - - **после отчёта, по поручению автора (2026-10-12):** открытые вопросы автор оставил на усмотрение исполнителя. Образец — только отправная точка: после переноса практики можно улучшать под canvas-api. - - Уязвимости dev-зависимостей (R48): - - `ci-vuln`, как и прежде, проверяет только то, что раздаётся, — иначе совет по инструменту сборки останавливал бы выкладку; - - инструменты разработки видят алерты Dependabot, они помечают dev-зависимости меткой Development (документация GitHub); - - dev-сервер проверяется там, где его открывают наружу: рецепт туннеля в E2 запускается, только если `npm audit` вместе с dev-зависимостями чист. Требование записано в E2; - - Пины подняты до свежих патчей своих линий: Vite 8.3.4 (в 8.3.3 — правки `server.fs`), happy-dom 20.14.6, `@types/node` 24.19.2. - - Мажоры happy-dom 21 и `@types/node` 26 не взяты: `@types/node` идёт за Node 24 из контейнера, а смену мажора оставляем Dependabot. - - Дерево по-прежнему 118 пакетов, лицензии и `THIRD_PARTY_LICENSES` не изменились. - - Рецепты T04 `build`, `docker-build` и `release-artifacts` больше не вставляют версию в текст команды: `build` и `docker-build` передают её через `quote()`, `release-artifacts` — через окружение. - - Проверено: `just build` и `release-artifacts v0.0.1-test` ставят версию, суммы сходятся, `/health` образа называет версию и коммит. - - Враждебная версия `v1$(touch…)` ничего не выполнила. - - В `biome.json` убрано `files.includes`: исключения берутся из одного места, `.gitignore`. Проверено: неотформатированный `.js` в `web/coverage/` Biome пропускает, а тот же файл в `src/` ловит. - - Ревью доработок `/code-review high`, первый круг, 9 замечаний: - - исправлено: - - разбор SPDX пропускал выражение, где после `WITH` стоит не имя исключения: `MIT WITH ) OR ISC`, `MIT WITH OR OR ISC`, `MIT WITH ( OR ISC` проходили проверку. Ошибка пришла из образца. Добавлены случаи в таблицу и третье свойство перебора — принимается только предложение, по независимому от парсера суждению о том, что за чем идёт. До исправления краснели три случая и перебор, который сам нашёл ещё `WITH WITH`; - - тег образа в `docker-build` и `docker-run` теперь тоже через `quote()`; - - `web-install` переустанавливает пакеты и тогда, когда `package.json` новее установки. Иначе правка версии без `npm install` проходила локально и падала в CI; теперь `npm ci` падает и локально — проверено; - - `release-artifacts` отказывает версии с пробелом, `/` или `$(…)` и говорит почему: раньше такая версия ломала сборку невнятной ошибкой; - - вложенный `just` вызывается через `just_executable()`, а не первый `just` на `PATH`; - - R48 опирался на алерты Dependabot, которые никто не включал: они — настройка репозитория, и шаг внесён в «Я» T07; - - в «Сделано» строка о «не тронутых» `build` и `release-artifacts` переписана; - - отброшено: - - вернуть исключение `coverage` в `biome.json` на случай, если `.gitignore` не прочитан: Biome запускают только из репозитория — рецепты и CI, — где `.gitignore` есть всегда, а `dist/` на него опирался и раньше; - - `ci-web` повторяет вызовы `web-lint` и `web-test`: команды живут в скриптах `package.json`, и оба рецепта зовут одни и те же скрипты. - - Второй круг, 4 замечания: - - исправлено: - - перебор шёл 1,3 с под покрытием без явного тайм-аута — на нагруженном раннере это был бы флейк. Он сокращён до пяти токенов, 0,2 с, и по-прежнему ловит все три поломки разбора; - - `.gitignore` скрывал только `.env`, а Vite читает ещё `web/.env.local`, `.env.production` и прочие. Теперь игнорируются все `.env.*`, кроме `.env.example`. Варианты не попадают и в контекст образа: в `.dockerignore` добавлено `**/.env.*` — проверено пробной сборкой; - - устаревшие цифры в «Сделано» поправлены; - - отброшено: лишний `npm ci` после правки `package.json`, которая меняет только формат. Из кэша npm это около секунды (замерено 0,9 с), а сравнение содержимого добавило бы скрипт ради этой секунды; - - исправления второго круга прошли проверки, третьего ревью нет (CLAUDE.md). -- **Открыто:** - - `server.allowedHosts`, HMR через cloudflared и проверка `npm audit` перед туннелем — когда понадобится планшет (E2, записано в его блоках); - - пункт `THIRD_PARTY_LICENSES` в T06 уже сделан здесь; T06 только запускает `ci-licenses` в CI; - - **шаг автора:** открыть `http://localhost:5173` с хоста после `just web-dev`. Из контейнера проверено только `curl`. -- **Разделы:** spec 3.1, 18; R10, R13; standalone: R84, R85. - -#### T06. CI - -- **Цель:** каждая правка проверяется автоматически, теми же рецептами и в том же образе, что локально. -- **Сделать:** перенести из `reference/mathtrail-standalone/.github/` и приспособить. - - **`.github/workflows/ci.yml`** — на PR и `main`, `ubuntu-24.04`, actions по SHA, минимальные права, `timeout-minutes`. - - **Окружение, как в образце:** - - concurrency: на PR — группа по ветке, с отменой прежнего прогона; на `main` — группа по коммиту, чтобы свой прогон получил каждый коммит. GitHub держит в группе только один ожидающий прогон и вытесняет прежний даже без отмены. При общей группе на `main` промежуточные коммиты остались бы без CI. Здесь canvas-api отличается от образца; - - `safe.directory` через `GIT_CONFIG_*`; - - кеш Go в `/tmp` и `actions/cache` по хешам `go.sum`; - - `defaults.run.shell: bash` — для pipefail, когда вывод идёт через `tee`; - - `persist-credentials: false`, где задание ничего не пушит. - - **Задания:** - - `toolchain` — образ стадии `toolchain` публикуется в GHCR с тегом по хешу `.devcontainer/Dockerfile` и дальше используется по digest (`just ci-toolchain-image`, R20 standalone). Ссылка на образ сначала читается в переменную, потом идёт в выход; - - в контейнере этого образа: - - `lint` — `just ci-lint`; - - `test` — `just ci-test`, покрытие артефактом; - - `web` — `just ci-web`, покрытие артефактом; - - `mocks` — `just ci-mocks-check`; - - `dependencies` — `just ci-vuln` и `just ci-licenses`; - - `secrets` — `just ci-secrets`: gitleaks по всей истории, поэтому `fetch-depth: 0`; - - `image` — на раннере, ему нужен Docker. Сначала action `docker-mirror`, затем один вызов `just ci-image-smoke … ci-image-scan …`: образ собирается один раз и с одной датой сборки. - - `ci-image-smoke` пишется сами: в образце его нет. Основа — ожидание `/health` из `ci-image-widget` образца: образ стартует и отвечает на `/health`, при сбое печатает журнал. - - `ci-image-scan` — Trivy, `HIGH,CRITICAL`, `--ignore-unfixed`. Исключение в `.trivyignore` — только с причиной; находку в stdlib Go чинит новая версия Go (R38 standalone). - - **Обязательная проверка никогда не приходит из workflow с фильтром `paths:`.** Workflow, который не стартовал, оставляет обязательную проверку висеть (R231 standalone). Условную работу отсекает шаг внутри задания. - - **Composite actions**, версии которых берутся из `ARG` в Dockerfile devcontainer: - - `.github/actions/just`; - - `.github/actions/node`; - - `.github/actions/docker-mirror` — образы Docker Hub тянутся через `mirror.gcr.io`. Анонимный pull с общего IP раннера ловит 429 из-за чужого лимита. По R33 красный CI на `main` — это несостоявшаяся выкладка. - - **`.github/dependabot.yml`** — как в образце: - - экосистемы: gomod; npm `/web`; github-actions (`/` и `/.github/actions/*`); docker (`/` и `/.devcontainer`); - - раз в неделю; версии остаются точными; - - группа `codeql-action`: части CodeQL обновляются вместе; - - правило образца `ignore` для `node` не переносить: оно держится на Node-стадии в Dockerfile сервиса, которой у canvas-api нет. - - **`.gitleaks.toml`** пока не нужен: исключения образца продуктовые. Файл появится с первой ложной тревогой, с узким исключением: правило, путь и regex. - - **`THIRD_PARTY_LICENSES`** — пишет `just licenses`; CI сверяет его с графом зависимостей (R21 standalone). - - **`CLAUDE.md`, Environment:** - - проверки CI идут в стадии `toolchain` из GHCR по digest; - - список обязательных рецептов дополняется `ci-secrets`, `ci-image-smoke` и `ci-image-scan`. -- **Я:** пакет тулчейна в GHCR — публичный, как в образце. PR из форка тулчейн не публикует, а берёт готовый образ (R20 standalone). + - `grep -n -i 'answer box' CLAUDE.md README.md SECURITY.md` находит только места, где сказано, что поля больше нет; + - каждое решение этого раздела есть в журнале; + - CLAUDE.md не противоречит журналу. +- **Не делать:** product, privacy, spec и `docs/stack.html` — это T62. +- **Разделы:** раздел эпика E2; R22–R24, R35, R37–R44, R59. + +#### T62. Документы: product, privacy, spec, stack + +- **Цель:** продукт, приватность и спецификация описывают лист без поля ответа и эксперимент E2, а бета ссылается на E15. +- **Сделать:** + - **product** — каждое место с полем ответа (сейчас 1, 3, 5, 6, 7, 10), и ещё: + - 5.1–5.7 — экран без поля ответа, Send Answer, цикл E2 «проверка → результат → следующая задача», фразы E2 (в том числе «Circle your answer»); лестница — в E15; + - 6 — С2–С4 без поля ответа; + - 7 — объём E2 и E15; + - 12 — критерии приёмки относятся к E15; + - 14 — E15 в дорожной карте; + - 15 — О-7 и О-10: шесть задач по классам. + - **privacy** — каждое место с полем ответа (сейчас 1, 4, 5.1, 5.3, 7, 8), и ещё: + - 4 — образцы автора — в `samples/`, вне репозитория; журнал эксперимента — на устройстве автора; + - 5.1 — что получает Gemini в E2: картинку листа, условие, класс, ключ и решение — и ничего о человеке; + - 11; + - 12 — П-10 решается при разбиении E15. + - **spec:** + - плашка в начале разделов, которые переписывают задачи E2: «E2: лист без поля ответа, один вызов — решения E2; раздел переписывает Txx» — 4 — T67, 5 — T66, 6 — T34, 7 — T35, 8 — T70; + - плашка «E2 — без поля ответа; раздел описывает бету (E15)» — в 3.1, 9 и 12.2: они остаются для беты; + - 1 и 2 — один вызов в E2; 3.2 — gin; + - 15.2 — кеш Gemini выключает ресурс Terraform, а не рецепт; сверяет T64; + - 20 — Т-1 решён, Т-16 открыт для листа E2 (T67); S1 и S2 заменил эксперимент E2 (T74–T76). + - **`docs/stack.html`** — по строкам, а не заменой подряд: бета — E2 → E15; что решает E2, — на свои задачи: Т-1 — T61, Т-5 — T76, Т-16 и лист без поля ответа — T67; S1 и S2 — заменены экспериментом E2. - **Как проверить:** - - PR с намеренно сломанным форматированием падает в `lint`; - - чистый PR проходит все задания; - - лог gitleaks чистый; - - повторный прогон берёт образ тулчейна из GHCR, а не собирает его заново. -- **Не делать:** CodeQL, SonarCloud, Codecov и Scorecard — T07; релиз — T08; деплой — T11. -- **Сделано (2026-10-13):** - - **`.github/workflows/ci.yml`** — восемь заданий с точными именами для правил мержа T07: «Toolchain image», «Formatting and lint», «Tests», «SPA», «Generated mocks», «Vulnerabilities and licenses», «Secrets», «Runtime image». - - Окружение — как велит задача: concurrency на PR по его ссылке с отменой, на `main` по коммиту; `safe.directory` через `GIT_CONFIG_*`; кеш Go в `/tmp`; `shell: bash`; `persist-credentials: false` у каждого checkout; `timeout-minutes` у каждого задания. `packages: write` есть только у `toolchain`, фильтров `paths:` нет. - - Покрытие — артефакты `coverage-go` и `coverage-web`, для T07. - - **Composite actions** `just`, `node` и `docker-mirror` — из образца, версии из `ARG`. `node` пока никто не зовёт: оно нужно T08 и T11. - - **`.github/dependabot.yml`** — экосистемы и группа `codeql-action` образца; правила для `node` и группы MCP нет. - - **`justfile`:** `TOOLCHAIN_IMAGE`, `GITLEAKS` v8.30.1, `TRIVY_IMAGE` 0.75.0 по digest; рецепты `ci-toolchain-image`, `ci-secrets`, `ci-image-smoke`, `ci-image-scan`. - - **`.gitleaks.toml`** появился здесь же: первая ложная тревога нашлась в T06. Правило `generic-api-key` читает как ключ `idempotency_key` в примерах spec 10.7 — вид события и UUID. Исключение держит правило, путь и форму значения вместе. - - **Документы:** решение R49 — CI и его отличия от образца; вопрос Т-17 в spec 20; CLAUDE.md — How we work, Tooling and images, Environment; правило 7; блок E2 о первом моке; `docs/stack.html`. - - **Сверх текста задачи, на проверку** — всё записано в R49: - - тег тулчейна — хеш только стадии `toolchain`: новый Claude Code его не пересобирает. Собирается без контекста, и `COPY` в стадии падает громко; - - у каждого задания свой кеш Go, ключ — `go.mod`, `go.sum`, `justfile` и ссылка на образ тулчейна. Контейнер и шаг кеша записаны один раз YAML-якорями; у `mocks` кеша нет, пока нет моков; - - `ci-image-smoke` требует, чтобы `/health` назвал версию и коммит этой сборки, и не принимает сборку, которую git назвать не смог; - - Trivy читает копию образа (`docker save`, `--input`) без сокета Docker и видит `.trivyignore` — рецепт образца файла не видит; - - `ci-secrets` отказывает мелкому клону, называет находки (`--verbose`, значение скрыто) и сканирует ещё не закоммиченное — в своём индексе и хранилище объектов, не трогая репозиторий; - - Dependabot: правило для `golang` (новый Go — руками в трёх местах), явный `cooldown` три дня; - - пины свежее образца: `upload-artifact` v7.0.2, `setup-node` v7.1.0, Trivy 0.75.0. - - **Проверки:** - - в дереве зелёные `just ci-lint`, `just ci-test` (`cmd/server` — 81,1 %), `just ci-mocks-check`, `just ci-licenses`, `just ci-vuln`, `just ci-secrets`, `just ci-web` и `just ci-image-smoke canvas-api:dev ci-image-scan canvas-api:dev` — так их зовёт задание `image`; - - задания CI повторены на чистом клоне с этой правкой: в образе тулчейна, собранном рецептом и опубликованном в локальный registry, под root и с окружением workflow — все зелёные. `ci-toolchain-image`: первый прогон собирает и публикует, второй берёт готовое за 0,15 с; - - actionlint 1.7.12 вместе с shellcheck, схемы SchemaStore (`check-jsonschema` 0.34.1) для `ci.yml`, `dependabot.yml` и трёх actions, shellcheck рецептов, раскрытие якорей `yq` — без замечаний. Скрипты actions `just` и `node` прогнаны с поддельным окружением раннера; - - **выключенные проверки** — 16 поломок, все покраснели или отказали: - - `ci-secrets`, 8: токен в коммите; форма ключа идемпотентности в другом файле; другой ключ в `docs/spec.md`; без `.gitleaks.toml` — три находки; мелкий клон; репозиторий, который git не читает — gitleaks без стража говорит «0 commits, no leaks», с ключом в истории; незакоммиченная правка с токеном; новый файл с токеном. Для сравнения: в мелком клоне gitleaks без отказа не видит удалённого ключа, а `.env` и разрешённая форма в spec проходят, как и должны; - - `ci-image-smoke`, 4: сервер не стартует; стартует и сразу выходит — проверка печатает его журнал; версия теряется в Dockerfile, тест `internal/version` этого не видит; дерево без `.git` — до правки ревью проверка здесь проходила; - - `ci-image-scan`, 1: образ на Go 1.25.0 — 25 уязвимостей stdlib. Тот же список через сокет Docker; `.trivyignore` их снимает, а рецепт образца с тем же файлом остаётся красным; - - `ci-toolchain-image`, 3: публикация отказала; registry не назвал digest; `COPY` в стадии `toolchain`; - - fuzz нет: кода Go в правке нет. Флейков не было; - - **документация, прочитана 2026-10-13:** - - docs.github.com: синтаксис workflow и управление concurrency, в том числе `queue`; обязательные проверки и пропущенные задания; Dependabot в Actions — права токена; справочник `dependabot.yml` — `directories`, `groups`, `ignore`, `cooldown` и его умолчание; публикация пакетов из Actions; видимость и доступ пакетов; YAML-якоря — changelog 2025-09-18; - - `action.yml` каждого action на его SHA; release notes `upload-artifact` v7.0.2 и `setup-node` v7.1.0; - - Trivy 0.75.0: changelog, `trivy image --help` из образа, страница баз данных; README trivy-db — база собирается каждые 6 часов; - - gitleaks v8.30.1: README (конфиг и `[[allowlists]]`) и `sources/git.go` — команда `git log` по умолчанию; - - **ревью `/code-review high`:** - - первый круг, 9 замечаний: - - исправлено: smoke проходил без `.git` — у сборки и у бинарника без версии одинаковые запасные имена; сообщение о провале публикации всегда винило форк; комментарий к `--verbose` умалчивал об авторе находки; пять копий шага кеша и шесть копий контейнера — теперь якоря; пустой кеш у `mocks` предупреждал в каждом прогоне; - - отброшено: подвал `docs/stack.html` называет T06 сделанной раньше `[x]` — отметка ставится после ревью, как в T04, T05 и T60; кеш базы Trivy — база нужна свежей в каждом прогоне; - - пины `justfile` вне Dependabot — записаны во втором круге; - - вопрос к автору — Т-17, ниже; - - второй круг, 7 замечаний: - - исправлено: `ci-secrets` не видел незакоммиченного, то есть самой правки до коммита; ключ кеша не знал версии Go из образа — теперь в нём ссылка на образ; `cooldown` не был записан; сборка тулчейна с контекстом; ручные пины записаны в R49 и в `dependabot.yml`; - - кеш `mocks` с первым моком — в блоках E2; - - отброшено второй раз: кеш базы Trivy. База собирается каждые 6 часов, кеш потребовал бы своего правила свежести, а Trivy сам берёт базу из `mirror.gcr.io`, а при отказе — из `ghcr.io`. Записано в «Отвергнуто» R49; - - исправления второго круга прошли проверки, третьего ревью нет (CLAUDE.md). Последнее из них — своё: первый вариант скана незакоммиченного оставлял висячие объекты в `.git/objects`, теперь у скана своё хранилище. -- **Открыто:** - - **шаги автора:** - - после первого прогона CI сделать пакет `canvas-api/toolchain` в GHCR публичным: новый пакет закрыт, и PR из форка его не скачает; - - «Как проверить» на GitHub: PR с нарочно сломанным форматированием падает в «Formatting and lint»; чистый PR проходит все восемь заданий; лог gitleaks чистый; повторный прогон берёт тулчейн из GHCR — в логе «Toolchain image» нет строки «This environment is not published yet»; - - **вопрос к автору — Т-17** (spec 20): кто публикует образ тулчейна. Права GHCR — на пакет, а не на тег, поэтому PR этого репозитория, публикуя образ, может переписать и тег, по которому идёт CI `main`. Рекомендация — подпись cosign на `main` и её сверка перед проверками, вместе с cosign из T08; - - **для T07:** имя «Toolchain image» должно быть среди обязательных проверок. Если задание упало, задания после него пропускаются, а пропущенное задание может не держать мерж (документация GitHub); - - **для T08:** у concurrency есть `queue: max` — до 100 ожидающих прогонов в группе (документация GitHub, 2026-10-13). С ним релизы при быстрых мержах не вытесняли бы друг друга, а T08 исходит из одного ожидающего; - - в `.git/objects` этого клона остались висячие объекты от первых прогонов скана незакоммиченного; их уберёт `git gc`. -- **Разделы:** spec 15.5, 17, 20 (Т-17); privacy 9; R33, R49; standalone: R20, R21, R38, R231. - -#### T07. Анализ кода и витрина качества - -- **Цель:** те же внешние проверки и та же витрина, что у standalone: статический анализ, покрытие, цепочка поставок, порядок сообщения об уязвимостях. -- **Сделать:** - - **CodeQL** — `.github/workflows/codeql.yml`: Go и TypeScript, `security-extended`, на PR, `main` и раз в неделю. В `paths-ignore` — `draft` (бандл макета со скриптами) и `docs/stack.html`. - - **SonarCloud:** - - `sonar-project.properties` — ключ `MathTrail_canvas-api`, организация `mathtrail`, покрытие Go и web. В `exclusions` — `draft/**`, `docs/stack.html`, `**/mocks/**`, `tools/**`; - - задание `sonar` в `ci.yml` на раннере, с артефактами покрытия и `fetch-depth: 0`. Оно ничего не гейтит, а PR без токена пропускает (R31 standalone); - - `.sonarlint/connectedMode.json` для SonarQube for IDE, с `"region": "EU"` (R81 standalone). - - **Codecov** — загрузка обоих отчётов из `ci.yml`, `fail_ci_if_error: false`. `codecov.yml`: - - патч не ниже 90 %, проект — `auto` с порогом 1 %; - - `ignore` для пакетов-помощников тестов; - - `comment` с `require_changes`. - - **Scorecard** — `.github/workflows/scorecard.yml`: OpenSSF Scorecard с публикацией результатов. - - **Что блокирует мерж, а что только сообщает** — как R38 standalone: - - блокируют патч Codecov, CodeQL и Trivy; - - сообщают SonarCloud, Scorecard и общее покрытие. - - Записать решением в `docs/decisions.md`. - - **Красный SonarCloud на `main` останавливает релиз и выкладку** (R45): она начинается только после зелёного CI. Так бывает, если на `main` нет токена или анализ упал. Как не дать гейтом стать тому, что «ничего не гейтит», решить в задаче и записать решением. - - **`SECURITY.md`** на английском: сообщать только через private vulnerability reporting; что сервис хранит и чего не хранит (privacy 4); какие проверки стоят до мержа. - - **`CONTRIBUTING.md`** на английском: - - порядок работы и devcontainer; - - раздел «What a change has to satisfy», как в образце: зелёные рецепты из Environment; патч не ниже 90 %; тест, который краснеет без проверки; ничего личного в логах; секреты проверяются по всей истории. - - **`docs/openssf-best-practices.md`** — ответы на критерии уровня passing с доказательствами, по образцу, но по-русски (R25). - - **`README.md`** — плашки: CI, CodeQL, версия Go, Codecov, OpenSSF Best Practices, Scorecard, лицензия и пять плашек SonarCloud. Плашку с номером релиза добавит T08, плашку статуса «Release and deploy» — T11. -- **Я:** - - проект в SonarCloud с выключенным Automatic Analysis и `SONAR_TOKEN`; - - репозиторий в Codecov и `CODECOV_TOKEN`; - - оба токена — ещё и в секреты Dependabot: без токена Codecov у PR Dependabot нет обязательного патч-статуса; - - ruleset «main»: - - только через PR, список обхода пуст; - - одобрения не нужны; - - ветка перед мержем актуальна; - - обязательные проверки — точные `name:` заданий CI и `codecov/patch`. Переименование задания ломает правило; - - private vulnerability reporting; - - Dependabot alerts в настройках безопасности репозитория. По ним видны уязвимости и dev-зависимостей, которые `ci-vuln` не проверяет (R48, T05); - - проект на bestpractices.dev. -- **Как проверить:** - - PR показывает результаты CodeQL, SonarCloud и Codecov; - - PR, который нарочно роняет одну блокирующую проверку, нельзя смержить; - - Scorecard опубликован; - - плашки живые и ведут на этот репозиторий; - - находки Scorecard, которые остаются открытыми, записаны с причиной (как R51 standalone). -- **Риски:** у публичного репозитория запуск по расписанию выключается после 60 дней без активности, а запуски по расписанию приходят с опозданием на часы. -- **Сделано (2026-10-13)** — часть Claude; шаги автора — в «Открыто»: - - **CodeQL** — `.github/workflows/codeql.yml`: задания «Analyze Go» (сборка Go версии из `go.mod`) и «Analyze TypeScript», `security-extended`, на PR, `main` и по понедельникам. В `paths-ignore` — `draft` и `docs/stack.html`. - - **SonarCloud:** - - `sonar-project.properties`: тесты, исключения задачи плюс сгенерированный buf код (`gen/`, `web/src/gen/`), покрытие Go и SPA; - - задание «Static analysis» в `ci.yml` — на раннере, с артефактами покрытия и `fetch-depth: 0`. PR без токена пропускает, права — только `contents: read`; - - `.sonarlint/connectedMode.json` с `"region": "EU"`. - - **Codecov:** загрузка обоих отчётов из заданий «Tests» и «SPA» с `fail_ci_if_error: false`. CLI загрузчика закреплён `version: v11.3.1`, иначе action скачивает `latest`. `codecov.yml`: патч 90 %, проект `auto` с порогом 1 %, статус — только после обеих загрузок (`after_n_builds: 2`), `ignore` для моков и кода buf, `comment` с `require_changes`. - - **Scorecard** — `.github/workflows/scorecard.yml` с публикацией, scorecard-action v2.5.0. - - **Решения:** - - R50 — что держит мерж, а что только сообщает. Сообщающее не останавливает и релиз: у задания SonarCloud `continue-on-error: true` на уровне задания, поэтому упавший анализ красит задание, а прогон CI остаётся зелёным (R45); - - R51 — находки, которые правка закрыть не может: `docker:S8431`; Scorecard `Code-Review`, `Branch-Protection` и `Pinned-Dependencies` про npm-установку; находки, которые пройдут со временем. - - **Документы:** `SECURITY.md`, `CONTRIBUTING.md`, `docs/openssf-best-practices.md` — по-русски, со статусом каждого критерия на сегодня. `README.md` — плашки и ссылки на оба файла. `docs/stack.html`. - - **Сверх текста задачи, на проверку:** - - scorecard-action v2.5.0 вышел 2026-10-12, раньше трёх дней ожидания: версии до него тянут образ из выведенного gcr.io и падают (release notes Scorecard v5.6.0); - - триггера `branch_protection_rule` в `scorecard.yml` нет, хотя в образце он есть. По документации GitHub событие идёт только для классических правил защиты, а `main` защищает ruleset; - - concurrency CodeQL на `main` — по коммиту, как в CI (R49); - - `permissions: contents: read` у заданий «Static analysis» и «Runtime image»; - - пины свежее образца: codeql-action v4.38.3, download-artifact v8.0.2; - - в `devcontainer.json` вернулась фраза о `.sonarlint/connectedMode.json`. Её обещал R46, а тело задачи файл не называет (предложение из «Открыто» T60). R46 поправлен на месте: временных отличий не осталось. - - **Проверки:** - - зелёные `just ci-lint`, `just ci-test`, `just ci-mocks-check`, `just ci-licenses`, `just ci-vuln`, `just ci-secrets`, `just ci-web` и `just ci-image-smoke canvas-api:dev ci-image-scan canvas-api:dev`; - - actionlint 1.7.12 с shellcheck и схемы SchemaStore (`check-jsonschema` 0.34.1) для трёх workflow; валидатор Codecov для `codecov.yml`; - - `yq explode`: права и `continue-on-error` каждого задания после раскрытия якорей. - - **Выключенные проверки:** кода с проверками в правке нет. Что держит мерж, проверяется на GitHub (ниже). Fuzz нет, флейков не было. - - **Документация, прочитана 2026-10-13:** - - docs.github.com: `continue-on-error` задания; правила ruleset и «Require code scanning results» с порогами; языки CodeQL и `paths-ignore`; события `branch_protection_rule`; видимость пакетов; - - `action.yml` каждого action на его SHA и release notes новых версий; - - README и `action.yaml` scorecard-action v2.5.0 — ограничения workflow при публикации, адрес плашки; - - release notes Scorecard v5.6.0; - - README codecov-action v7.1.1 — загрузка из форков без токена; API `cli.codecov.io` — последняя версия CLI v11.3.1; - - README sonarqube-scan-action v8.3.0; - - criteria bestpractices.dev. - - **Ревью `/code-review high`:** - - первый круг, 10 замечаний: - - исправлено: CLI Codecov качался как `latest`; статус патча мог прийти по одной загрузке из двух; критерии криптографии отмечены Met для кода, которого ещё нет, — теперь N/A; триггер `branch_protection_rule` ruleset не видит; concurrency CodeQL теряла коммиты `main`; R50 неверно говорил о PR из форка; R46 спорил сам с собой; CONTRIBUTING говорил, что все проверки идут в образе тулчейна; - - записано: плашка OpenSSF Best Practices ждёт ID проекта (ниже); правка `devcontainer.json` вне текста задачи — по R46 (выше); - - второй круг, 8 замечаний: - - исправлено: код buf не был исключён из Codecov и SonarCloud; R50 называл обязательными все задания CI, хотя «Static analysis» только сообщает; три доказательства в `openssf-best-practices.md` не держали свой ответ; `SECURITY.md` выдавал будущую выкладку за действующую; лишние права у «Static analysis» — и у «Runtime image»; - - плашка OpenSSF Best Practices — снова, ниже; - - исправления второго круга прошли проверки, третьего ревью нет (CLAUDE.md). -- **Открыто** — шаги автора по «Я»: - - SonarCloud: импортировать репозиторий в организацию `mathtrail` с ключом `MathTrail_canvas-api` и выключить Automatic Analysis. `SONAR_TOKEN` — в секреты Actions и Dependabot; - - Codecov: `CODECOV_TOKEN` — в секреты Actions и Dependabot; - - ruleset «main» (R50): - - только через PR, список обхода пуст, одобрения не нужны, ветка перед мержем актуальна; - - обязательные проверки: «Toolchain image», «Formatting and lint», «Tests», «SPA», «Generated mocks», «Vulnerabilities and licenses», «Secrets», «Runtime image», «Analyze Go», «Analyze TypeScript», `codecov/patch`. «Static analysis» — не обязательная; - - правило «Require code scanning results»: CodeQL, Security alerts — High or higher, Alerts — Errors; - - включить private vulnerability reporting и Dependabot alerts; - - зарегистрировать проект на bestpractices.dev и заполнить анкету по `docs/openssf-best-practices.md`. Затем — плашка в `README.md` после Codecov: `[![OpenSSF Best Practices](https://www.bestpractices.dev/projects//badge)](https://www.bestpractices.dev/projects/)`. ID появится только после регистрации; - - «Как проверить» на GitHub: PR показывает CodeQL, SonarCloud и Codecov; PR, который роняет блокирующую проверку, не мержится; Scorecard опубликован; плашки живые; - - по правилу 3 перед T08: сверить R51 с первыми прогонами SonarCloud, CodeQL и Scorecard и дописать новое; - - предложение: CodeQL для workflow (язык `actions`) — после T08 и T11. Их `workflow_run` и выкладка — ровно то, что он проверяет, но его находки держали бы мерж уже в самих этих задачах. -- **Разделы:** privacy 9; R25, R45, R46, R50, R51; standalone: R31, R38, R51, R81. - -#### T08. Релиз - -- **Цель:** зелёный `main` публикует подписанный релиз — как в standalone, без ручных тегов. Из тега этого релиза потом идёт выкладка (R45, T11). -- **Сделать:** - - **`.github/workflows/release.yml`** — по образцу (standalone: R40, R149, R154): - - **запуск:** - - `workflow_run` после успешного CI на `main`, только от `push` из этого репозитория — защита от форка с веткой `main`; - - кнопка с необязательной версией — только на `main` и только если CI этого коммита зелёный. Сначала кнопка убеждается, что прогон CI этого коммита завершился успешно, иначе отказывает; способ — `gh` или API запусков, сверить с документацией GitHub. Здесь canvas-api строже образца, где кнопка не ждёт CI (R33, R45); - - **concurrency** `release`, без отмены: релизы идут по одному, чтобы один номер линии не достался двум. GitHub держит в группе только один ожидающий прогон, поэтому при быстрых мержах подряд промежуточный коммит может остаться без своего релиза. Выкатывается всё равно только вершина `main`; - - **номер** — по линии: `RELEASE_LINE: "0.1"` в workflow и рецепт `just release-version` (R149 standalone). Рецепт: - - берёт следующий patch линии; - - отказывает, если линия старше новейшего тега, тег уже есть или номер не `vX.Y.Z`; - - если у коммита уже есть тег `v*`, релиз не публикуется: повтор кнопкой или перезапуск CI не вешает на тот же коммит второй тег; - - аргументы передаются аргументами, а не вставляются в текст скрипта; - - **checkout** с `fetch-depth: 0` коммита `workflow_run.head_sha`, а при запуске кнопкой — `github.sha`, как в образце. Тот же коммит идёт в `--target`. Go и Node — без кеша, чтобы подписанная сборка не бралась из кеша; - - **`just release-artifacts <версия>`:** - - `canvas-api-server__linux_amd64` и `…_arm64` — `CGO_ENABLED=0`, `-trimpath`, версия в ldflags; - - архив сборки SPA `canvas-web_.tar.gz`; - - `SHA256SUMS`; - - **подпись** каждого файла до публикации — `cosign sign-blob --bundle`, без ключей, через OIDC; cosign — v3.1.3, как в образце; - - **публикация** — `gh release create --target <коммит> --generate-notes --notes-start-tag <прошлый тег>`. Прошлый тег — новейший из `git tag --list 'v[0-9]*' --sort=-v:refname`; если тегов ещё нет, флага нет. Так в образце; - - **выход `newest`** — `main` всё ещё стоит на этом коммите. Выкладка идёт, только если это так (T11, R45). - - В `docs/decisions.md` — что входит в релиз canvas-api и почему в нём есть сборка SPA: её раздаёт Hosting, а не бинарник. - - **Ожидаемые находки на `release.yml`** — записать с причиной, как в образце: Scorecard Dangerous-Workflow на `workflow_run` и SonarCloud `githubactions:S7631`. - - Команда `cosign verify-blob` для проверки подписи — в `SECURITY.md`. cosign в devcontainer нет: проверку запускать из образа cosign по digest. - - **`README.md`** — плашка с номером последнего релиза (shields.io), как в образце. Плашку статуса «Release and deploy» добавит T11. - - **`CLAUDE.md`, Environment** — список обязательных рецептов дополняется `release-version` и `release-artifacts`. -- **Как проверить:** - - после мержа появляется релиз `v0.1.0` с файлами, суммами и `.sigstore.json`; - - `cosign verify-blob` проходит; - - `/health` локальной сборки показывает версию из `git describe`; - - кнопка релиза на другой ветке ничего не публикует. -- **Сделано (2026-10-14):** - - **`.github/workflows/release.yml`** («Release», задание «Build, sign and publish»): `workflow_run` после успешного CI на `main` только от push этого репозитория; кнопка — только на `main` и только при успешном прогоне CI этого коммита (первый шаг, API запусков, `actions: read`). Concurrency `release`, без отмены, `queue: max`. Сборка на раннере без кешей, подпись `cosign sign-blob --bundle` (v3.1.3), `gh release create --target … --generate-notes [--notes-start-tag …]`, `--latest` и выход `newest` — по одному чтению вершины `main`. Коммит с тегом `v*` второй раз не выпускается. - - **`justfile`:** `release-version` (линия из `release.yml` по умолчанию, строгий SemVer, отказы — по задаче); `release-artifacts` — `canvas-api-server__linux_{amd64,arm64}`, `canvas-web_.tar.gz` (детерминированный архив), `SHA256SUMS`. - - **Т-17 по решению автора — R53:** `ci-toolchain-image signed` на `main` принимает только образ с подписью `ci.yml@refs/heads/main` и аннотацией хеша стадии, иначе собирает заново; digest — из вывода своего `docker push`; `ci-toolchain-sign` и задание «Toolchain image signature» (только `main`, `continue-on-error`). - - **`ci.yml`:** cosign в «Toolchain image» на `main`, задания «Toolchain image signature» и «Release build». - - **Документы:** R52, R53, дополнены R49 и R51; spec 15.5 и 20 (Т-17 решён); `SECURITY.md` — проверка релиза из образа cosign по digest; плашка релиза в `README.md`; `CLAUDE.md`, `CONTRIBUTING.md`, правило 7 — `release-version` и `release-artifacts`; `docs/stack.html`, `docs/openssf-best-practices.md`, `.github/dependabot.yml`. - - **Проверки:** зелёные `ci-lint`, `ci-test`, `ci-mocks-check`, `ci-licenses`, `ci-vuln`, `ci-secrets`, `ci-web`, `ci-image-smoke`, `ci-image-scan`, `release-version`, `release-artifacts`. actionlint 1.7.12 и схема SchemaStore — единственное замечание на `queue: max`, которого они ещё не знают (R52). `release-version` проверен в пробном репозитории на всех отказах и на `$(…)` в версии; рецепты тулчейна — на локальном реестре с поддельным cosign во всех ветках; архив SPA из двух сборок совпал; команда `verify-blob` из `SECURITY.md` проверена на релизе образца и отвергла подменённый файл. - - **Документация, прочитана 2026-10-14:** docs.github.com — `concurrency` (`queue`), события `workflow_run` и `workflow_dispatch`, REST «List workflow runs for a workflow»; `gh api --help`, `gh release create --help` (gh 2.101.0); `cosign … --help` из образа v3.1.3, его исходники и release notes v3.0.1 и v3.1.3; `action.yml` cosign-installer v4.1.2. - - **Ревью `/code-review high`:** первый круг — 10 замечаний; исправлено: подпись тулчейна не была привязана к его определению (старый подписанный образ можно было подставить под новый тег), старый коммит становился Latest, права файлов в архиве зависели от umask, устаревшие комментарии в `ci.yml` и R49, неточность о `cancel-in-progress` в R52, комментарий о кнопке; отброшено: `v*` и выражение прошлого тега — так в тексте задачи; разница окружений «Release build» и релиза — намеренная (Т-17), комментарий поправлен. Второй круг — 10; исправлено: два чтения вершины `main`, дата в openssf-документе, это тело задачи; остальное — ниже, третьего ревью нет. -- **Открыто:** - - шаги автора: внести «Release build» в обязательные проверки ruleset «main»; после мержа — релиз `v0.1.0` с файлами и `.sigstore.json`, `verify-blob` из `SECURITY.md`, кнопка на другой ветке ничего не публикует; Scorecard `Dangerous-Workflow` и SonarCloud `githubactions:S7631` на `release.yml` ожидаемы (R51); - - предложение: включить неизменяемые релизы и правило ruleset на теги `v*`; - - из второго круга ревью: версия с кнопки выше линии (например, `v1.0.0` при `0.1`) потом роняет «Release build», пока не поднят `RELEASE_LINE` — так и в образце (R149 standalone), но стоит решить, отказывать ли ей; `imagetools inspect`, упавший по сети, читается как «не опубликован» и ведёт к пересборке; `release-artifacts` сверяет версию после сборки SPA; вызов `cosign verify` повторён в двух рецептах; «Toolchain image signature» идёт на каждом push `main`, даже когда подписывать нечего. -- **Разделы:** R33, R45; standalone: R40, R149, R154. - -#### T09. Решения для поставки - -- **Цель:** до Terraform решено, где и под какими адресами живёт сервис. -- **Сделать:** разбираем по одному; я решаю, исполнитель переносит решение в документы. - - **Т-3 и П-4 — регион всего контура:** Cloud Run, Gemini, Neon, Upstash. Вводные: - - в бете только семьи из США (product 3); - - domain mapping Cloud Run есть не во всех регионах (R27 standalone); - - Gemini и выбранные модели должны быть доступны в регионе (spec 7.1). - - **О-9 — рабочее название и адреса:** - - адрес SPA на Firebase Hosting; - - адрес API: domain mapping Cloud Run или прямой адрес `run.app` (spec 2, «Периметр»); - - что уходит в CORS и в `client.allowed_origins` Centrifugo. - - **Записи:** решения — в `docs/decisions.md` со следующими свободными номерами; поправки — в product 15, spec 15 и 20, privacy 12. -- **Как проверить:** Т-3, П-4 и О-9 переехали в решённые; у T10 есть все вводные. -- **Сделано (2026-10-14)** — решения автора, перенесены в документы: - - **Т-3 и П-4 → R54:** весь контур в `us-east4`. Gemini — региональный адрес `us-east4`, не глобальный; Neon — `aws-us-east-1`; Upstash — Северная Вирджиния. Данные беты — в США; ЕС и Великобритания — отдельным решением (SCC/DPF, П-2, П-3). - - **О-9 → R55:** название «MathTrail Canvas»; canvas-api и Centrifugo — по своим `*.run.app`; SPA — `https://.web.app`, он один в CORS и `client.allowed_origins` (локально ещё `http://localhost:5173` при `CANVAS_ENV=local`). - - **Документы:** product 15 (О-9 в решённых, шапка), spec 7.1, 15.1 и 20 (Т-3 решён), privacy 5 и 12 (П-4 решён), `docs/stack.html`. - - **По правилу 3:** релиз T08 `v0.1.0` вышел (прогон `release.yml` успешен); новых алертов CodeQL нет, алерт Scorecard Dangerous-Workflow на `release.yml` ожидаем (R51), остальные — из списка R51. - - **Документация, прочитана 2026-10-14:** Cloud Run «Mapping custom domains» (регионы, Preview), «Generative AI locations» (Gemini 3.x по регионам, глобальный адрес), Neon «Regions». - - **Ревью `/code-review high`**, 9 замечаний: исправлено — примеры `CANVAS_ALLOWED_ORIGINS` и `CANVAS_GEMINI_LOCATION` в spec 15.4, CORS в spec 8.1, место хранения в уведомлении (privacy 6), «рабочее название» в CLAUDE.md, локальный origin Centrifugo и `authDomain` Firebase Auth в R55; отброшено — шапка журнала решений не перечисляет каждую запись (R52–R53 тоже не там), «docs/stack.html» в списке верно — из него ушёл Т-3; картинка README (`docs/design/app.png`) — отдельная просьба автора, не T09. -- **Открыто:** - - ID проекта GCP и Firebase — в T10; из него выводятся точные адреса. Решено в T10: `mathtrail-canvas-prod` (R55); - - `authDomain` и адрес обработчика входа у Google и Apple — задача эпика MVP, что приносит вход (R55); - - регион Upstash (AWS `us-east-1` или GCP `us-east4`) сверить в консоли Upstash, когда его создаёт задача эпика MVP; - - для T10: бакет журналов `_Default` и хранилище трасс — в США, а не `global` (R54, privacy 4) — сделано в T10 (R57); вход по redirect требует, чтобы rewrites Hosting не перехватывали `/__/` (R55) — это T11; - - `docs/stack.html`: строки Cloud Run, Neon, Upstash и Hosting не называют регион и адреса — дописано в T10; -- **Разделы:** product 3, 15; spec 2, 15.1, 20; privacy 12. - -#### T10. Terraform - -- **Цель:** вся инфраструктура prod описана кодом, как в standalone (его R26, R29, R32, R33). -- **Сделать:** перенести `infra/` из образца и приспособить. - - **`infra/bootstrap.sh`** — разовый шаг человека: - - проект и биллинг, бакет состояния; - - федеративный пул и провайдер GitHub; - - сервисный аккаунт Terraform: роли образца — только те, что нужны ресурсам этого эпика, и права на Firebase. Роли под аналитику образца (`logging.configWriter`, `bigquery.admin`) не переносить; - - печатает строки для `infra/ci.env`. - - **`infra/terraform/`:** - - `versions.tf` — Terraform и провайдеры точных версий: `google` 8.3.0, как в образце; `google-beta` — только если без него не обойтись ресурсам Firebase; `.terraform.lock.hcl`; - - `backend.hcl` и `prod.auto.tfvars` лежат в репозитории, без секретов и персональных данных (spec 15.2, R33 standalone); - - `apis.tf`. Spec 15.2 перечисляет API всего MVP; этот эпик включает из них только нужные ему — Cloud Run, Artifact Registry, IAM и WIF, Firebase, бюджеты и Monitoring для `alert.tf`. Остальные включат задачи эпика MVP, которым они нужны (правило 13): Secret Manager — с первым секретом, Agent Platform — вместе с выключением кеша входов Gemini на уровне проекта (privacy 5.2), reCAPTCHA Enterprise — с App Check; - - `registry.tf` — Artifact Registry с автоочисткой; - - `service_api.tf` — Cloud Run canvas-api: - - пока один контейнер; сайдкар OpenFGA добавит эпик MVP; - - `min 0`, `max 1`, параллельность 40, тайм-аут 30 с, startup CPU boost, probe `GET /health` (spec 15.3). Boost и startup probe в образце не настроены — писать по spec 15.3; - - CPU, память и параллельность — переменные в `variables.tf`, как в образце: их будут читать нагрузочные прогоны и править калибровка эпика MVP; - - образ — начальный; его изменения игнорируются (`lifecycle.ignore_changes`), образ выкатывает CD; - - `iam.tf` — по spec 15.2: сервисные аккаунты сервиса и деплоя, WIF для репозитория, своя узкая роль деплоя на сервис. В образце то же лежит в `deploy.tf`; - - `firebase.tf` — Firebase в проекте и сайт Hosting; вход и App Check добавит эпик MVP; - - `budget.tf` — бюджет с оповещениями на 50, 90 и 100 % (spec 14.4); - - `alert.tf` — один бесплатный алерт, как в образце (R266 standalone): - - условие — число запросов к сервису, которое считает сама платформа; порог — как в образце, 300 в минуту в среднем за 5 минут, пока нагрузка не даст свой; - - алерт пишет письмо оператору. Почта — прямой идентификатор, поэтому в публичном `prod.auto.tfvars` её нет. Она приходит секретом репозитория `TF_VAR_operator_email`; переменная и адрес в канале оповещений помечены `sensitive`. По умолчанию переменная пустая, и тогда алерт не создаётся — секрет заносит автор в T12; - - аккаунту Terraform нужны роли на Monitoring — их приносит bootstrap; - - `variables.tf` с валидацией, `outputs.tf`. Вывод плана уходит в публичный summary PR, поэтому переменные и атрибуты с секретами помечаются `sensitive` (R34). - - **Рецепты** — по образцу: `tf-fmt`, `tf-check`, `bootstrap`, `ci-tf-plan`, `ci-tf-apply`, `ci-tf-outputs`. - - `just tf-check` — ещё и в CI, отдельным заданием на раннере. В образе тулчейна Terraform нет, поэтому он ставится `hashicorp/setup-terraform` с версией из `ARG`, как в `deploy.yml`. Задание идёт на каждом PR, без фильтра путей (T06). В образце `tf-check` в CI не запускается вовсе. - - **`docs/self-hosting.md`** — первая версия, по-русски: - - что делается руками, порядок применения, переменные, стоимость; - - раздел «Когда меняется bootstrap»: план на PR зелёный, а apply получает 403, пока bootstrap не выдаст аккаунту Terraform новую роль. - - **`CLAUDE.md`, Environment** — список обязательных рецептов дополняется `tf-fmt` и `tf-check`. -- **Как проверить:** `just tf-check` зелёный; `terraform plan` на моём проекте проходит без ошибок. `apply` делает пайплайн (T11–T12). -- **Стоимость:** ожидается $0 в бесплатных лимитах; назвать всё, что может стоить денег. -- **Не делать:** Neon, Upstash, секреты, сайдкар, Centrifugo и jobs — их добавляют задачи, которым они нужны. -- **Сделано (2026-10-14)** — по утверждённому плану. Решения автора в планировании: ID проекта `mathtrail-canvas-prod`, бюджет $1, журналы и трассы в `us-east4` уже в T10 (R57), одна задача без разбиения. - - **`infra/bootstrap.sh`** — по образцу: - - проект «MathTrail Canvas», привязка к платёжному аккаунту, API — вместе с Observability, заранее; - - бакет состояния в `us-east4`; - - пул `canvas-api-github` и провайдер `github`. Условие: репозиторий, ID владельца 261630499 и единственный workflow — выкладка `deploy.yml` (T11), так что до T11 не войдёт никто. Повтор приводит условие к записанному (`update-oidc`); - - аккаунт `canvas-api-tf`: 12 ролей проекта, `storage.objectAdmin` на бакете, `billing.costsManager` на платёжном аккаунте; - - сверка валюты аккаунта с `budget_currency`; ID платёжного аккаунта скрипт не печатает; - - всё создаваемое ищется перед каждой попыткой (`ensure`), остальные вызовы повторяются: на свежем проекте права доходят не сразу. - - **`infra/terraform/`:** - - `versions.tf` — `google` и `google-beta` 8.3.0, `user_project_override`; lock для linux amd64 и arm64; - - `backend.hcl` и `prod.auto.tfvars`; - - `apis.tf`, `registry.tf`, `iam.tf`; - - `service_api.tf` — по spec 15.3: масштаб 0–1, параллельность 40, тайм-аут 30 с, boost, startup probe `/health` раз в секунду, `GOMEMLIMIT`; - - `firebase.tf`, `budget.tf`, `alert.tf` и `logging.tf` — sink `_Default` в бакет `default-us-east4`, место трасс `us-east4`; - - `variables.tf`, `outputs.tf`; - - 12 тестов `terraform test` на mock-провайдерах. - - **Рецепты:** `tf-fmt`, `tf-check`, `bootstrap`, `ci-tf-plan` (без блокировки состояния), `ci-tf-apply` (ждёт блокировку до 5 минут), `ci-tf-outputs`, `_tf-redact` (маскирует ID платёжного аккаунта и адрес оператора). - - **CI:** задание «Terraform» на раннере; `.github/actions/terraform` читает `ARG TERRAFORM_VERSION`. Экосистемы `terraform` в Dependabot нет — причина в шапке `.github/dependabot.yml` и в R56. - - **Документы:** - - `docs/self-hosting.md` — первая версия; - - R56, R57; R50 — «Release build» и «Terraform» среди обязательных проверок; R54; R55 — точные адреса; - - spec 15.1, 15.2, 15.4, 16, 18; privacy 4, 7, 8; - - `CLAUDE.md`, `CONTRIBUTING.md`, `SECURITY.md`, правило 7, `docs/stack.html`. - - **Сверх текста задачи, утверждено планом:** - - `tf-fmt` — повседневный рецепт, а в списке обязательных — только `tf-check`; - - журналы и трассы — сразу в `us-east4`; - - тесты конфигурации на mock-провайдерах; - - маскировка вывода плана; - - правка T12 ниже. - - **Живой прогон (2026-10-14).** Стоимость названа (≈ $0) и подтверждена автором. Автор вошёл в gcloud и положил ID платёжного аккаунта в `.env`, а bootstrap и план запускал Claude. - - Первый запуск: `INVALID_ARGUMENT` на чтении валюты — в `.env` был шаблон из инструкции. Вместо него подставлен единственный открытый аккаунт, ID не печатался. - - Второй: проект, биллинг, API и бакет созданы, затем PERMISSION_DENIED на создание пула. На свежем проекте права владельца доходят не сразу, и в скрипт добавлены повторы. - - Третий прошёл. Четвёртый, повторный, не создал ничего. Пятый — после ревью — положил на провайдер суженное условие пула; оно ACTIVE, ролей по-прежнему 12. - - У аккаунта Terraform ровно 12 перечисленных ролей проекта; провайдер ACTIVE. - - План: `27 to add, 0 to change, 0 to destroy`, ID платёжного аккаунта в выводе нет. С подставным адресом оператора — 29, и адрес показан как `(sensitive value)`. - - **Проверки:** - - зелёные `just ci-lint`, `ci-test`, `ci-mocks-check`, `ci-licenses`, `ci-vuln`, `ci-secrets`, `release-version`, `release-artifacts`, `ci-image-smoke canvas-api:dev ci-image-scan canvas-api:dev` и `tf-check`; - - actionlint 1.7.12 и `check-jsonschema` 0.34.1 для `ci.yml`, action и `dependabot.yml`; shellcheck 0.11.0 для bootstrap; - - bootstrap с поддельным `gcloud`: создание, повтор без создания, отказы, повтор привязки после ошибки, ID нигде не печатается; - - скрипт action — с поддельным окружением раннера. - - **Выключенные проверки** — 17, каждая сломана в копии и краснит: - - `count` алерта, порог в секунду; - - валидации образа, аккаунта, адреса и имени; - - доля `GOMEMLIMIT`, единицы `Mi`; - - период probe, порог бюджета 0.9, фильтр `_Default` (без строки и с опечаткой); - - имя сервиса короче шести символов у его аккаунтов и с дефисом на конце; - - формат файла, битая ссылка, провайдер без lock. - - Fuzz нет, флейков не было. - - **Ревью `/code-review high`:** - - первый круг, 10 замечаний. Исправлено: - - подсказка про `.env`, который никто не читает; - - ID платёжного аккаунта в ошибках gcloud — теперь маскируется; - - тест фильтра `_Default` считал вхождения, а не сверял текст; - - комментарий о сайте Hosting не учитывал Firebase до 2026-10-15; - - запятая в CLAUDE.md, фраза о цене алертов; - - две копии `init` в рецептах — теперь `_tf-init`; - - имя бакета в тексте алерта — теперь ссылкой. - - Отброшено: кеш провайдеров в CI и общий шаг чтения `ARG` (причины — в «Отвергнуто» R56); - - второй круг, 9 замечаний. Исправлено: - - условие пула пускало любой workflow репозитория, а задание релиза держит токен личности и собирает SPA сторонними пакетами; теперь только `deploy.yml`; - - комментарий о силе `serviceAccountAdmin`; - - повторы для привязки биллинга, API и бакета; создание через поиск перед каждой попыткой; - - валидация имени сервиса: длина его аккаунтов и дефис на конце. - - Отброшено: - - `unique_writer_identity = false` у `_Default` не ломает apply: у живого sink writer identity пустой (проверено), и `false` ничего не меняет; - - кеш провайдеров и общий шаг `ARG` — повтор первого круга; - - вложенный `just` для маскировки — миллисекунды против секунд Terraform, а его backtick-переменные с запасными значениями упасть не могут. - - Исправления второго круга прошли проверки, третьего ревью нет (CLAUDE.md). - - **Документация, прочитана 2026-10-14:** - - исходники и документация провайдеров `google` и `google-beta` 8.3.0 (Cloud Run v2, `logging_project_sink`, `logging_project_bucket_config`, Observability, `billing_budget`, Firebase); - - Cloud Run: health checks, адреса, масштаб; - - Cloud Logging: маршрутизация и фильтр `_Default`; Observability: место по умолчанию, роли, регионы; - - Cloud Billing API (`currencyCode`); Firebase Hosting: сайт по умолчанию; - - `gcloud` 585.0.0 `--help`; - - `action.yml` setup-terraform v4.0.1; исходник `cloud-run-hello`; - - цены Cloud Run и Cloud Monitoring. -- **Открыто:** - - **шаги автора:** - - «Terraform» — в обязательные проверки ruleset «main», и «Release build», если его там ещё нет (R50); - - секрет `TF_VAR_billing_account` — до мержа T11: её мерж — первая выкладка. `TF_VAR_operator_email` — в T12; - - в `.env` лежит ID платёжного аккаунта; файл в `.gitignore`; - - **для T11:** - - `roles/resourcemanager.projectIamAdmin` аккаунту Terraform: роль выкладки Hosting выдаётся только на проект. Это правка bootstrap и его повтор; - - `.github/actions/terraform` — переиспользовать; - - сайт Hosting — в `firebase.json`, rewrites не перехватывают `/__/` (R55); - - `TF_VAR_operator_email` — и в план, и в apply; - - WIF по окружению — решение автора, тоже повтор bootstrap. Сейчас условие пула пускает `deploy.yml` с любой ветки, в том числе из PR, который его поменял; - - имя `deploy.yml` — часть условия пула: другое имя требует правки bootstrap; - - **для T12:** после первой выкладки `just ci-tf-plan` пуст — это доказательство для `ignore_changes` и `traffic`. Первый apply может потребовать одного повтора. Firebase может потребовать принять условия, если addFirebase откажет; - - T35 (черновик E2) пишет, что API Agent Platform включила T10. Это не так: его включит задача, которая выключает кеш входов (privacy 5.2). Поправить при разбиении E2; - - SonarCloud начнёт смотреть `infra/**` — по правилу 3 перед T11; - - провайдеры 8.3.0 отстают от 8.7.0 (2026-10-13) — поднять руками, вместе с lock. -- **Разделы:** spec 3.4, 14.4, 15.1–15.3; R32, R34; standalone: R26, R29, R32, R33, R266. - -#### T11. CD - -- **Цель:** зелёный CI на `main` сам доводит изменение до prod — релиз, инфраструктуру, образ и SPA — без ручных шагов (R32). Выкатывается ровно подписанный релиз T08, из его тега (R45). -- **Сделать:** перенести из образца `.github/workflows/deploy.yml` и actions `deployment-target` и `docker-mirror`, и приспособить. - - **Цепочка** (R45, R154 standalone): CI на `main` → `release.yml` (T08) → его задание `deploy` вызывает `deploy.yml` через `workflow_call` с номером релиза и `secrets: inherit`. - - Вызов идёт, только если выход `newest` релиза истинный: `main` всё ещё на этом коммите. Иначе перезапуск CI старого коммита откатил бы prod. - - Сам `deploy.yml` в начале каждого запуска ещё раз проверяет, что коммит тега — вершина `main`; если нет, ничего не выкатывает. При «Re-run failed jobs» выход `newest` берётся из прежнего прогона и может устареть. - - **Триггеры `deploy.yml`:** - - `workflow_call` с обязательной версией; - - кнопка — только на `main`. Она выкатывает релиз, чей тег стоит на вершине `main`, а если такого нет — ничего; непроверенный коммит она не собирает. Если тегов на вершине несколько, берётся старший по версии. Брать «новейший по номеру» нельзя: перезапуск CI старого коммита выпускает его под бо́льшим номером. В образце кнопка работает с любой ветки (R33); - - `pull_request` по `infra/**`, `deploy.yml` и `justfile` — только для задания `plan`. - - **`plan`** — `just ci-tf-plan` в summary PR. Задание читает состояние Terraform, а в нём секреты входа (R34), поэтому оно запускается: - - только для веток этого репозитория, как в образце, — не для форков; - - не для PR Dependabot: он меняет версии actions, которые иначе выполнились бы под аккаунтом Terraform до ревью; - - в своей группе concurrency: план не должен вытеснить ожидающую выкладку. - - Писать в репозиторий могу только я. - - **`infra`** — checkout тега релиза, `just ci-tf-apply`, выходы — в `GITHUB_OUTPUT`. - - **`deploy`** — от имени аккаунта деплоя: - - checkout тега релиза с `fetch-depth: 0`, затем action `docker-mirror`; - - `just ci-image-push`: - - собирает образ с `VERSION` = номер релиза; - - до push проверяет его через `ci-image-smoke`; - - пушит и печатает ссылку по digest — сначала в переменную; - - `just ci-deploy` — отказывает ссылке без digest. После обновления возвращает трафик на latest (`update-traffic --to-latest`): после отката трафик закреплён за старой ревизией, новая иначе его не получит, и smoke покраснеет. Флаги — по документации Cloud Run; - - `just ci-smoke` — с повторами и `--proto-redir =https`: `/health` называет релиз и коммит этого прогона. - - **`hosting`:** - - сборка SPA из тега и `firebase deploy --only hosting`; `firebase.json` — в репозитории; - - версия firebase-tools закреплена `ARG` в Dockerfile devcontainer, и `deployment-target` читает её так же, как версии Terraform и gcloud. Новый `ARG` — это пересборка контейнера. - - **Доступ** — только WIF, без ключей сервисных аккаунтов. Выкладка идёт в группе concurrency `cd`, без отмены. - - **Порядок шагов** — R31 с поправкой R45: релиз → инфраструктура → образ → сервис → Hosting → smoke. Миграции и Centrifugo встанут в него задачами эпика MVP. - - **Повтор** упавшей выкладки — только «Re-run failed jobs» в прогоне релиза, как в образце. - - **Откат** — трафик на прошлую ревизию (`gcloud run services update-traffic --to-revisions=<ревизия>=100`) и прошлый выпуск Hosting, а не `terraform apply` (R29 standalone; spec 15.5). Способ отката Hosting — по документации Firebase с датой обращения. - - После такого закрепления новые ревизии не получают трафик, пока его не вернут на latest. Следующая выкладка делает это сама (`ci-deploy`), а поведение сверить с документацией Cloud Run с датой обращения. - - Команды отката и возврата — в `docs/self-hosting.md`: на них опирается R32. - - **Решает автор:** окружение `prod`, разрешённое только для `main`, и WIF по subject окружения. Тогда «только `main`» обеспечивает сама платформа. Задание `plan` на PR входит с другим subject, поэтому ему нужна своя привязка WIF к аккаунту Terraform — иначе план на PR перестанет входить. - - **Плашка CD** — в `README.md`: статус `release.yml` («Release and deploy»), потому что прогоны вызванного workflow принадлежат вызывающему. Плашка с номером релиза из T08 остаётся рядом — она показывает версию, а не статус. -- **Как проверить:** - - PR с правкой `infra/` показывает план, а PR Dependabot его не запускает; - - после мержа CI, релиз и выкладка зелёные; `/health` называет релиз и коммит; - - страница SPA открывается на адресе Hosting; - - коммит с красным CI не выпускается и не выкатывается; кнопка на другой ветке ничего не применяет и не выкатывает; - - перезапуск CI старого коммита ничего не выкатывает; - - откат по записанным командам возвращает прошлую ревизию и прошлую SPA, и `/health` называет её релиз; возврат на latest работает. -- **Разделы:** spec 15.5; R31, R32, R33, R34, R45; standalone: R29, R30, R32, R33, R154. -- **Сделано (2026-10-14)** — по утверждённому плану. Решения автора в планировании (R58): - - окружения `prod` и `prod-hosting`, только для `main`, и WIF по их subject; - - выкатываются подписанные файлы релиза, а не сборка из тега; - - у выкладки SPA свой аккаунт; - - firebase-tools — автономный бинарник. - - Что сделано: - - **`deploy.yml`:** - - `target` выбирает релиз без учётных данных: переданный номер или, по кнопке, старший релиз на вершине `main`. Устаревший релиз — зелёный прогон с notice, кнопка вне `main` падает; - - `infra` и `deploy` — в окружении `prod`, `hosting` — в `prod-hosting`. На повторной попытке каждое из них сначала проверяет, что `main` стоит на коммите релиза; - - `plan` — на PR веток репозитория, кроме PR Dependabot; план — в summary; - - concurrency: `cd` без отмены, у PR — своя группа с отменой. `id-token: write` есть только у четырёх заданий. - - **`release.yml`** — «Release and deploy». Задание `deploy` вызывает `deploy.yml`, если `newest`; секреты передаются по имени. - - **Actions:** `deployment-target` грузит только `infra/ci.env`; `gcloud` и `firebase` читают версии из `ARG`. - - **Рецепты:** - - `ci-release-target` и `ci-release-current` — какой релиз и свежесть; - - `ci-release-fetch` — подпись `SHA256SUMS` с коммитом тега, затем строка файла; - - `ci-image-push` — стадия `release`, health до push, digest из вывода push; - - `ci-deploy` — только digest, затем `update-traffic --to-latest`; - - `ci-smoke`, `ci-hosting-deploy`, `ci-hosting-check`; - - общий `_image-health`: его зовёт и `ci-image-smoke`; - - `ci-tf-outputs` — ещё `project_id` и `hosting_service_account`. - - **Dockerfile** — стадии `runtime` и `release`. Образ CI остаётся последней стадией, сборки CI и `docker-build` не меняются. В `.dockerignore` и `.gitignore` — `firebase.json`, следы Firebase CLI и файл учётных данных action `auth`. - - **`firebase.json`** — `web/dist`; `no-cache` на странице, год и `immutable` на `/assets/**`; rewrites нет. - - **Terraform и bootstrap** (правило 13): - - переменная `github_oidc_subject_prefix` с валидацией; - - аккаунт деплоя — по subject `prod`; - - новый аккаунт `canvas-api-hosting`: две роли проекта и subject `prod-hosting`; выход `hosting_service_account`; - - четыре новых теста, всего их 16: последний сверяет роли проекта со списком условия в bootstrap; - - bootstrap: аккаунт Terraform — по subject `prod` и `pull_request`, привязка «весь репозиторий» снимается, `projectIamAdmin` выдаётся с условием на две роли. Под другим условием или без условия роль сначала снимается, а политику скрипт читает с повторами. - - **Devcontainer:** `ARG FIREBASE_TOOLS_VERSION=15.33.0`, бинарник на x86_64. Хеш toolchain не изменился, образ CI не пересобирается. Нужна пересборка контейнера автором. - - **Документы:** - - R58; поправки R31, R45, R46, R56; spec 15.2, 15.5, 18; - - `docs/self-hosting.md` — выкладка, окружения, откат и возврат, «Когда меняется bootstrap»; - - `SECURITY.md`; README — плашка «Release and deploy» и ссылка на `self-hosting.md`; `docs/stack.html`; `CLAUDE.md`; - - комментарий задания «Terraform» в `ci.yml`; - - T12 — проверка отката на второй выкладке. - - **Сверх плана:** - - подпись `SHA256SUMS` привязана к коммиту тега (`--certificate-github-workflow-sha`): подписанные файлы другого релиза не пройдут; - - `firebase.json` без `ignore`: сайт — весь архив; - - `deployment-target` требует обе строки `ci.env` вместо проверки на `REPLACE`: заглушек в этом файле не бывает. - - **Проверки без облака:** - - `ci-release-target` — на настоящем репозитории и на подставном remote: лёгкие и аннотированные теги, несколько тегов на вершине, `v1.0.0-rc1`, устаревший релиз, нет релиза; - - `ci-release-fetch` — на настоящем v0.1.2; подставной `gh` отдаёт испорченный бинарник, переписанные суммы, бандл другого файла, суммы без бандла и подписанный релиз образца — всё отвергнуто, и в папку ничего не попадает; - - `ci-image-push` собрал образ из подписанного бинарника v0.1.2 и отправил его в локальный registry (`gcloud` подставной). Образ называет v0.1.2 и 701cb47, в registry лежит один манифест, а не индекс; - - `ci-deploy` с подставным `gcloud` и `ci-smoke` против контейнера из этого образа; - - `firebase.json` и `ci-hosting-check` — на эмуляторе Hosting из того же бинарника: заголовки как задуманы, `/__/` свободен, схема v15.33.0 принимает файл и отвергает испорченный (check-jsonschema 0.34.1); - - bootstrap — с подставным `gcloud`, который хранит политики в файлах. Сценарии: свежий проект; повтор, который ничего не трогает; роль под старым условием и без условия; два неудачных чтения; чтения, которые не проходят вовсе; - - actionlint 1.7.12 со shellcheck: новых находок нет; shellcheck 0.11.0 — bootstrap, скрипты actions и рецептов. - - **Выключенные проверки** — 18, каждая выключена в копии или сломана и возвращена, и её плохой случай проходит: - - подпись сумм, личность подписавшего, коммит в подписи, сумма файла; - - свежесть переданной версии и на повторной попытке; у кнопки — старшая версия, только релизы и коммит аннотированного тега; - - версия и коммит в health образа, коммит в smoke; - - выкладка только по digest; - - страница сайта — та, что в `web/dist`, и без кеша; - - в bootstrap: чтение политики с повторами — без него снятие старой привязки молча пропускается, а скрипт пишет «Done»; роль под одним условием — без этого рядом ложится вторая привязка; - - в Terraform: роли SPA совпадают со списком условия bootstrap — тест краснеет, если убрать роль из bootstrap или добавить её в `iam.tf`. - - Fuzz нет, флейков не было. - - **Ревью `/code-review high`:** - - первый круг, 8 находок. Исправлено: - - снятие привязки «весь репозиторий» зависело от одного чтения без повтора, и неудачное чтение молча его пропускало. Теперь чтение повторяется (`retry_read`), а сбой останавливает скрипт; - - новый список ролей в условии `projectIamAdmin` ложился второй привязкой рядом со старой. Теперь bootstrap держит роль под одним условием; - - грамматика тега релиза была скопирована в четыре рецепта. Теперь она одна — `RELEASE_TAG`; - - R58 утверждал, что «только `main`» держит платформа и для аккаунта Terraform. Уточнено: у него есть ещё план на PR. - - Отброшено: - - общий action для чтения версии из `ARG` — отвергнут в R56, а пропущенная копия падает с ошибкой, а не молча; - - общий шаг проверки свежести для трёх заданий: три шага по четыре строки рядом с комментарием проще абстракции (CLAUDE.md, «Simplicity beats purity»); - - `rm -rf web/dist` и `gcloud auth configure-docker` в рецептах: `web/dist` — игнорируемая сборка, которую называет `firebase.json`, а запись помощника docker идемпотентна и та же, что в образце. - - Два вопроса — автору, в «Открыто». - - второй круг, 6 находок. Исправлено: - - кнопка на другой ветке вставала в общую группу `cd` и вытесняла бы ждущую выкладку `main`. Теперь она ждёт в группе своей ветки; - - роли SPA записаны и в `iam.tf`, и в условии bootstrap, и расхождение проявилось бы 403 после мержа. Теперь их сверяет тест `terraform test`; он краснеет, если убрать роль из bootstrap или добавить её в `iam.tf`; - - `--proto-redir` без `-L` ничего не делал, а комментарий обещал защиту от понижения. Флаг убран, комментарии говорят как есть: редиректы не выполняются; - - переменная `condition` в bootstrap держала и условие пула, и путь к файлу условия роли. Теперь файл — `condition_file`. - - Отброшено: «`ci-hosting-check` примет старую страницу, если `index.html` не изменился». Страница называет скрипт по хешу содержимого, а скрипт несёт номер релиза, поэтому одинаковых страниц у двух релизов не бывает: у v0.1.2 — `index-D48AILHJ.js`, у сборки дерева — `index-CTruYdVY.js`. Это записано в комментарий рецепта. - - Вопрос — автору: контракт `release.yml` ↔ `deploy.yml` до мержа не проверяется. Исправления второго круга прошли проверки, третьего ревью нет. - - **После отчёта (2026-10-14), по решению автора:** - - три вопроса ревью: отдельный аккаунт плана только для чтения и выкладка ревизии без трафика — «да, но не сейчас», они в таблице 4 (Д1, Д2); actionlint в CI — сейчас; - - **actionlint в CI:** рецепт `just ci-workflows` — actionlint 1.7.12 из образа по digest, с shellcheck 0.11.0 из того же образа — и задание «Workflows» на раннере, с `docker-mirror`. Находка `queue: max` снята в `.github/actionlint.yaml` — только эта и только для `release.yml`. Рецепт вписан в CLAUDE.md, CONTRIBUTING.md и правило 7; поправлены R50, R52 и R58, в `docs/stack.html` — строка actionlint; - - **комментарии без ссылок на план.** Журнал решений будет удалён, и ссылки на него в коде стали бы мусором. Поэтому из комментариев кода и конфигов, описаний Terraform, текста `THIRD_PARTY_LICENSES` и сообщения теста убраны номера решений и задач, разделы spec, product и privacy, вопрос Т-17 и ссылки на разделы CLAUDE.md. Где ссылка несла смысл, фраза переписана. Тест `the_service_is_sized_as_the_spec_says` переименован в `the_service_size_and_start_stay_as_chosen`. Правило записано в CLAUDE.md (Hard rules, Language) и в правило 11. Пометка находки анализатора теперь называет причину словами, а не ссылается на решение (CLAUDE.md, правило 3); - - **таблицы 3 и 4** — вопросы автору без ответа и технический долг, из «Открыто» задач T00–T11. Как их вести, говорят правило 10, «Как устроен план» и раздел Documents в CLAUDE.md; - - проверки: зелёные `ci-lint`, `ci-test`, `ci-mocks-check`, `ci-licenses`, `ci-vuln`, `ci-secrets`, `ci-web`, `release-version`, `release-artifacts`, `tf-check` (16 тестов), `ci-workflows`, `ci-image-smoke` и `ci-image-scan`, `just --fmt --check`; shellcheck bootstrap; - - выключенные проверки `ci-workflows` — 6, и каждая краснит рецепт: нет файла исключения; `queue: max` в другом workflow; `release.yml` не передаёт обязательный секрет; `deploy.yml` переименовал вход; передан секрет, которого `deploy.yml` не объявляет; замечание shellcheck в скрипте шага. Fuzz нет, флейков не было; - - ревью `/code-review high`: - - первый круг, 9 находок. Исправлено: - - `ci-workflows` отдавал чужому образу всё дерево, `.env` тоже, и с сетью. Теперь он получает копию `.github` и пустой `.git`, без сети; - - правило «без ссылок» нигде не было записано, а CLAUDE.md требовал от пометки находки ссылаться на решение; - - исключение actionlint не говорило, чем доказано и когда уходит; - - CLAUDE.md обещал shellcheck и для скриптов composite actions, а actionlint их не читает: формулировка поправлена, проверка в CI — долг Д12; - - в «Открыто» пропала сверка окружений и привязок Claude после шагов автора; - - `docs/stack.html` называл actionlint держащим мерж, пока «Workflows» не в ruleset. - - Отброшено: - - ссылки на spec, privacy и RUN.md убраны зря — автор велел убрать все ссылки на требования и задачи; - - описание условия роли не дойдёт до проекта, где роль уже есть: у аккаунта Terraform в prod роли `projectIamAdmin` нет (`get-iam-policy`, 2026-10-14), первый повтор bootstrap запишет новое описание. Комментарий скрипта теперь говорит, что сверяется только выражение; - - ссылка на R25 в CONTRIBUTING.md — документы в просьбу автора не входили, это вопрос В5; - - второй круг, 7 находок. Исправлено: - - ссылки на разделы CLAUDE.md в комментариях `main.go`, `.golangci.yml`, `.mockery.yaml`, `devcontainer.json` и `.gitignore`; - - новое правило в CLAUDE.md выдавало удаление журнала за решённое, а другие правила велят его вести: оговорка убрана, вопрос остаётся В5; - - исключение actionlint уйдёт, только если кто-то поднимет версию руками: долг Д13; - - рваный перенос строк там, где ссылка уходила из середины абзаца: такие абзацы перенесены заново по 80 знаков. - - Отброшено: ссылки на R в SECURITY.md и CONTRIBUTING.md — документы, это В5; описание условия роли — повтор первого круга; предложение из `scorecard.yml` о том, где отвечены находки, удалено, а не переписано, — оно указывало на журнал решений, другого места нет (В5). Исправления второго круга прошли проверки, третьего ревью нет. - - **Живые шаги до мержа (2026-10-14), ≈ $0, с подтверждения автора.** - - Автор создал окружения `prod` и `prod-hosting` (только `main`) и секрет `TF_VAR_billing_account` и повторил bootstrap. Claude сделать это не смог: auto-режим Claude Code не пускает выдачу прав без разрешения автора. - - Claude сверил только чтением: - - у обоих окружений одно правило — ветка `main`; - - аккаунт Terraform одалживают только subject `:environment:prod` и `:pull_request`, привязка «весь репозиторий» снята; - - `projectIamAdmin` — одна привязка, под условием на две роли и с новым описанием; - - ролей проекта у аккаунта 13. - - Первый прогон `plan` на PR #15 упал только на пустом секрете: вход прошёл по старой привязке «весь репозиторий». - - На коммите 0538208 `plan` зелёный. Это первое живое доказательство subject PR и условия пула: старой привязки уже нет. План — `31 to add`; лишний против первого прогона — бюджет, который без ID платёжного аккаунта не планировался. ID в логе нет: `***` и `(sensitive value)`. - - Задание «Workflows» зелёное. «Release build» и «Terraform» автор внёс в обязательные проверки ruleset «main». -- **Открыто:** - - «Workflows» — в обязательные проверки ruleset «main»: до первого прогона GitHub его не предлагал (R50) — внесено автором 2026-10-14 (T12); - - мерж — первая выкладка. «Как проверить» после мержа сверяется в T12, откат и возврат — на второй выкладке (T12); - - пересборка devcontainer автором: в нём появится `firebase`; - - `tee /dev/stderr` в `ci-toolchain-image` — в таблице 4 (Д6); неизменяемые релизы и правило на теги — в таблице 3 (В1); - - concurrency вызванного workflow держится на опыте сообщества, а не на документации — сверить на первых выкладках (T12) — сверено в T12: кнопка ждала, пока выкладка релиза не кончится. - -#### T12. Первый деплой - -- **Цель:** сервис и SPA живут на своих адресах, и разворачивает их пайплайн, а не руки. -- **Сделать:** - - **Я** — дела без API, каждое один раз: - - bootstrap сделан в T10, его строки уже в `infra/ci.env`. Повторить `just bootstrap` — только если скрипт менялся (`docs/self-hosting.md`); - - секрет репозитория `TF_VAR_billing_account` и окружения `prod` и `prod-hosting` — уже в T11, до её мержа: её мерж — первая выкладка; - - секрет репозитория `TF_VAR_operator_email` — почта для алерта (T10). Без неё алерт не создаётся; - - Firebase: принять условия, если этого не может Terraform; - - своих доменов нет (R55): адреса — `run.app` и `web.app`, DNS не нужен. - - Первый прогон — мерж в `main`: CI, затем релиз и выкладка (R45). - - **Claude:** - - чек-лист первой поставки в `docs/self-hosting.md`, по образцу: - - строка старта в журнале называет версию и коммит; - - автоочистка Artifact Registry действует, а не в пробном режиме (`cleanupPolicyDryRun = false`); - - оповещения бюджета и алерт называют проект; - - рецепт `just usage`, как в образце (R169 standalone): vCPU-секунды, ГиБ-секунды и запросы сервиса против бесплатного лимита Cloud Run. Так «$0» становится фактом, а не оценкой; - - отчёт `docs/live/01-first-deploy.md` в формате T59; - - на время живого прогона — узкое разрешение в `.claude/settings.local.json`, после прогона файл удаляется (T59). -- **Как проверить:** - - все задания CI, релиза и выкладки зелёные; - - `https://<адрес API>/health` называет выкаченный релиз и коммит; - - SPA открывается по своему адресу; - - бюджетные оповещения и алерт настроены; - - в Artifact Registry действует автоочистка; - - откат и возврат по командам `docs/self-hosting.md` возвращают прошлую ревизию и прошлую SPA, `/health` называет её релиз, а возврат — снова новый. Проверяется на второй выкладке: после первой прошлая ревизия — образ `hello`, а прошлого выпуска SPA нет (T11); - - `just usage` показывает расход в бесплатных лимитах. -- **Стоимость:** $0 в бесплатных лимитах. -- **Риски:** проект и бакет состояния не пересоздавать. -- **Разделы:** spec 15; R32, R45; standalone: R169. -- **Решено автором (2026-10-14, в планировании):** - - первая выкладка уже прошла — мерж T11. Вторая выкладка — мерж этой задачи, поэтому задача в две части: до мержа — код, документы и отчёт о первой выкладке; после — очередь `cd`, откат и возврат, затем второй PR с итогом; - - задержку IAM, на которой упала первая выкладка, чинить здесь: задания ждут, пока их аккаунт сможет войти; - - очередь `cd` проверить кнопкой Deploy во время второй выкладки; - - откат и возврат выполняет Claude по узкому разрешению. Перед этим автор пересобирает контейнер ради `firebase`. -- **Сделано (2026-10-14) — часть 1, до мержа:** - - **по правилу 3:** - - SonarCloud — только три находки из R51, хотспотов нет; - - code scanning — только алерты Scorecard из R51; - - Dependabot alerts — 0; - - выкладка T11 прошла со второй попытки (ниже); - - **первая выкладка** — отчёт `docs/live/01-first-deploy.md`, ход по UTC: - - релиз v0.1.3, apply на 31 ресурс; - - 403 у аккаунта деплоя через 5 с после его привязки; - - «Re-run failed jobs» по разрешению автора, и выкладка зелёная; - - **шаги автора:** условий Firebase принимать не понадобилось; своего домена нет. `TF_VAR_operator_email` — до мержа, тогда алерт создаст вторая выкладка; - - **ожидание входа** — рецепт `ci-identity-ready`: - - повторяет `gcloud auth print-access-token` до 8 минут по часам: и отказ, пока привязка не вошла в силу, и минутный сбой. Сразу показывает только то, что отвергает сам обмен токена GitHub: нет пула или провайдера, токен не проходит условие пула. Аккаунт, которого нет, получает тот же отказ, что ещё не одолженный, и ждётся вместе с ним; - - в `deploy.yml` стоит после входа в заданиях `deploy` и `hosting`. Задание SPA ради него ставит gcloud: в задании деплоя это занимает 17 с; - - ждёт только привязку, которая одалживает аккаунт, а роли, которые тот же apply выдаёт новому аккаунту, — нет. Это долг Д14; - - сроки — по документации IAM: обычно 2 минуты, иногда 7 и дольше (прочитана 2026-10-14); - - R58, «Следствие», поправлен; - - **адрес сервиса** — выход `service_url` берёт из `urls` адрес по номеру проекта, как его называет R55, а не адрес со случайной частью из `uri`. Тест `the_service_url_is_the_one_by_project_number` подставляет оба адреса через `override_resource` с `override_during = plan` (документация Terraform о моках, прочитана 2026-10-14). Если Cloud Run такого адреса не сообщит, предусловие выхода остановит apply с объяснением, а не отдаст выкладке пустой адрес; тест `a_service_without_its_regional_address_is_refused`; - - **sink `_Default`** — `unique_writer_identity = true`: - - пустой план после выкладки, проверка из «Открыто» T10, нашёл вечную правку `true → false`; - - провайдер 8.3.0 читает sink без идентичности как уникальный (исходник), а у sink с бакетом в своём проекте идентичности не бывает (справочник Cloud Logging API, прочитан 2026-10-14); - - тест держит значение. С исправлением план не меняет ни одного ресурса, только выход `service_url`; - - вывод T10 «`false` ничего не меняет» был верен для apply, но не для плана; - - **`just usage [since] [service]`** — перенос рецепта образца; проект он читает из `prod.auto.tfvars` сам, без помощника образца `_project`, у которого был бы один потребитель: - - метрики Cloud Run из Monitoring API v3 через `curl` и токен `gcloud`; токен идёт заголовком из дескриптора; - - лимит месяца при оплате за запросы — 180 000 vCPU-с, 360 000 ГиБ-с, 2 млн запросов (Google Cloud Free Tier, прочитана 2026-10-14); - - вид, единицы и задержка метрик (120 с) — по их дескрипторам из самого API, прочитаны 2026-10-14; - - сверх образца: - - окно не больше месяца: за месяц по минутам выходит 44 640 точек, а страница ответа держит 100 000 (справочник `timeSeries.list`); - - имя сервиса проверяется до того, как встанет в фильтр; - - точка без значения считается нулём, а не роняет рецепт; - - заготовке `REPLACE…` вместо проекта рецепт отказывает, как bootstrap; - - числа пишутся с точкой при любой локали (`LC_ALL=C`): `printf` в локали с запятой не принял бы ответ jq. Доказать выключением здесь нельзя — таких локалей в контейнере нет; - - вложенный `just` — через `just_executable()`; - - **чек-лист** «Первая выкладка: чек-лист» в `docs/self-hosting.md` — восемь пунктов с командами. Каждая команда прогнана на v0.1.3 ровно как записана. ID платёжного аккаунта и адрес канала алерта в вывод не попадают; - - **README** — раздел Design перед The MathTrail core, отдельная просьба автора. Прошёл ревью вместе с T12. Его первая фраза повторяла подпись к картинке выше, поэтому осталось только правило: «Where the design differs from the documents, the documents win.»; - - `CLAUDE.md` (`just usage`), `docs/stack.html`; - - **живые чтения на v0.1.3** — ≈ $0, только чтение. Все пункты чек-листа, кроме алерта (адреса ещё нет), `ci-tf-plan` вручную и `just usage 1d`: 12,8 vCPU-с — 0,007 % лимита месяца; - - **выключенные проверки** — 14. Каждая выключена в копии, и её плохой случай проходит: - - адрес сервиса — 3 поломки: `uri`, первый адрес из `urls`, без предусловия; - - sink — `false`; - - ожидание входа — 2 поломки: без повтора, без быстрого отказа на чужую ошибку; - - `usage` — 8 поломок: окно, месяц, имя сервиса, `int64Value` строкой, точка без значения, сумма вместо максимума экземпляров, токен в аргументах `curl`, заготовка `REPLACE…`. - - Проверки Terraform — выбор адреса, предусловие и sink — держат тесты `terraform test`. Проверки рецептов доказаны руками, на поддельных `gcloud` и `curl`: тестов рецептов в репозитории нет, как и в T08–T11. Fuzz нет, флейков не было. - - **ревью `/code-review high`:** - - первый круг, 9 находок. Исправлено: - - ожидание входа считало попытки, а не время — теперь 8 минут по часам; - - оно же пережидало любой отказ — теперь сразу показывает отказ обмена токена; - - выход `service_url` мог стать пустым — теперь предусловие; - - `_project` не отказывал заготовке `REPLACE…`; - - точка без значения роняла `usage`; - - запись о проверках выдавала ручные доказательства за тесты; - - запись о README говорила, что ревью не было, а фраза раздела Design повторяла подпись к картинке; - - документы обещали, что выкладка ждёт все новые привязки, — теперь только токен, а роли записаны долгом Д14. - - Отброшено: ставка gcloud в задании SPA (17 с замера) — тот же закреплённый SDK Google, что в задании деплоя. - - второй круг, 9 находок. Исправлено: - - быстрый отказ касался и минутных сбоев 5xx — теперь сразу показывается только отказ самого обмена токена (`invalid_grant`, `invalid_target`, `unauthorized_client` и подобные), остальное пережидается до срока; - - комментарий обещал быстрый отказ для аккаунта, которого нет, — его отказ неотличим от ещё не одолженного; - - `_project` с одним потребителем встроен в `usage`; - - ошибка `gcloud billing budgets` в чек-листе назвала бы платёжный аккаунт — теперь вывод маскируется; - - `usage` в локали с десятичной запятой писал бы неверные числа — `LC_ALL=C`; - - Firebase CLI в задании SPA получает токен заново — дописано в Д14. - - Отброшено: - - номер проекта будто бы не назван в `infra/` — он в `infra/ci.env`; - - ставка gcloud в задании SPA — повтор первого круга; - - пять запросов `usage` по очереди — рецепт запускают руками, а параллельные запросы усложнили бы разбор ошибок. - - Исправления второго круга прошли проверки, третьего ревью нет. -- **Сделано (2026-10-14) — часть 2, вторая выкладка:** - - **шаги автора:** - - секрет `TF_VAR_operator_email`; - - Rebuild Container — в нём `firebase` 15.33.0; - - «Workflows» — в обязательные проверки ruleset «main»; - - узкое разрешение в `.claude/settings.local.json` на кнопку, `update-traffic` и `hosting:clone`; - - мерж PR #16. После прогона Claude файл разрешения удалил; - - **вторая выкладка v0.1.4 (09938cd)** — зелёная с первой попытки: - - apply — 2 ресурса, алерт и канал; - - `ci-identity-ready` в обоих заданиях получил токен с первой попытки; - - smoke идёт по адресу по номеру проекта; - - **очередь `cd` проверена.** - - Кнопка Deploy нажата в 20:29:17 UTC, во время выкладки релиза. Её прогон стоял в `pending` и начал первое задание в 20:31:23 — через секунду после последнего задания выкладки релиза. - - Затем кнопка выкатила тот же v0.1.4. Её apply — `0 added, 0 changed, 0 destroyed`: это проверка `ignore_changes`, `traffic` и исправления sink; - - **чек-лист на v0.1.4** — «да» по всем пунктам. Алерт `canvas-api load` и канал `canvas-api operator` включены; - - **откат и возврат** — командами `docs/self-hosting.md`: - - ревизия `canvas-api-00002-p84` и выпуск Hosting v0.1.3: `/health` и скрипт страницы назвали v0.1.3; - - затем `--to-latest` и выпуск v0.1.4 — снова v0.1.4; - - около полуминуты в каждую сторону; - - **находка:** `hosting:clone` пишет выпуск `ROLLBACK` без сообщения — дописано в `docs/self-hosting.md`; - - **расход:** `just usage 1d` — 31,1 vCPU-с за сутки, 0,017 % лимита месяца; - - **отчёт** `docs/live/01-first-deploy.md` — «закрыто»; в «Открыто» T11 закрыты «Workflows» и очередь `cd`; - - **ревью `/code-review high` итоговой правки** — первый круг, 8 находок, все исправлены: - - повторная выкладка того же релиза меняет digest образа и тратит место автоочистки — находка 5 отчёта и долг Д15; - - список выпусков Hosting не показывал вид выпуска и брал 5 строк — теперь вид и 20 строк; - - в RUN.md стояла заглушка вместо итога ревью, а «эпик E1 закрыт» спорил с правилом о сделанных эпиках; - - в отчёте — команда `hosting:clone` без проекта квоты, «выпуск» вместо версии, строка старта без версии и времени, два разных времени начала заданий кнопки; - - **второй круг, 8 находок, все исправлены:** у Д15 названа и вторая причина нового digest — время файла в слое — и повод вернуться: возврат кнопкой и «Re-run» тоже повторные выкладки; отчёт больше не говорит, что это «не вредит»; токен в списке выпусков Hosting — заголовком из дескриптора, а не аргументом `curl`; выпуск без версии не роняет `jq`; полное имя раздела чек-листа; шаг автора с «Workflows» вписан; лишняя пустая строка в списке. Исправления второго круга прошли проверки, третьего ревью нет. -- **Открыто:** - - из «Как проверить» T11 вживую не проверено: план на PR Dependabot, коммит с красным CI, кнопка на другой ветке, перезапуск CI старого коммита. Их держат условия workflow и проверки без облака; - - письмо алерта не проверено: такой нагрузки не было; - - что откат не отменяет следующий apply, вживую не проверялось: трафика в конфигурации нет; - - долги Д14 — роли нового аккаунта выкладка не ждёт, и Д15 — повторная выкладка того же релиза даёт новый образ; - - все задачи таблицы 1 отмечены. Перенести E1 в «Сделанные эпики» и выбрать следующий эпик — шаг автора («Как устроен план»). - -> **Точка проверки эпика E1.** -> - Правила работы и версии сверены с образцом от 2026-10-11. -> - Каждый PR проходит проверки Go и web, уязвимостей, лицензий, секретов и образа, CodeQL и SonarCloud. -> - Зелёный CI на `main` публикует подписанный релиз, и выкладка разворачивает сервис и SPA из его тега. -> - `/health` в prod называет релиз и коммит. - ---- - -## Приложение. Черновик разбиения E2 (MVP) до поворота - -> **Черновик, не исполняется.** Задачи T13–T58 написаны до поворота к LLM-first (R37–R43, 2026-10-11) и в таблицу 1 не входят. Их ссылки на разделы документов ведут к редакции до поворота — коммит 2796f48 в `main`. Что в черновике устарело, сказано в описании E2 под таблицей 2. Раздел «Доработки», на который ссылается черновик, разошёлся по эпикам E3, E13 и E14 таблицы 2. Доводы о порядке MVP, на которые черновик ссылается как на «Почему такой порядок», теперь в описании E2 — «Порядок, который уже решён». Когда E2 разбивается на задачи, выжившие задачи переезжают в таблицу 1 со своими номерами и поправками, а их тела — из приложения в тело файла (R44). - -### Фаза 2. Решения и спайки - -Спайки пишут одноразовый код в `spike/<имя>/`. Его удаляет задача, которая пишет настоящий адаптер; полезное переезжает в отчёт в `docs/live/`. - -#### T13. Решения по открытым вопросам - -- **Цель:** до кода решены вопросы, которые блокируют каркас, движок и задачи. -- **Сделать:** разбираем по одному. Я решаю, исполнитель переносит решение в документ, где жил вопрос, и в `docs/decisions.md`. - - **Продукт:** - - О-10 — какие две задачи (формат spec 8.6, eval-наборы); - - О-12 — лестница подсказок; О-13 — лимиты; - - О-14 — что видит родитель; О-15 — отмечать ли верный шаг; - - О-16 — что после решения задачи; О-17 — как собирать сигналы о ложных тревогах. - - **Техника:** - - Т-1 — роутер (рекомендация: gin, как каркас standalone); - - Т-2 — разбор LaTeX (рекомендация: свой Pratt-парсер); - - Т-4 — оплата Mathpix; Т-8 — сегментация строк в MVP; Т-9 — срок хранения штрихов; - - Т-11…Т-15 — поведение под нагрузкой: бюджет запроса дельты, уход клиента после приёма, строки «в проверке» после сбоя, слоты к поставщикам и рендеру, пулы соединений. Решается устройство; числа пока предварительные, их сводит T46a. - - Для Т-12 до решения — живая проверка: даёт ли Cloud Run при оплате за запросы процессор запросу, клиент которого ушёл. Одноразовый сервис из `spike/cancel/` в проекте из T12, развёрнутый и удалённый моими учётными данными gcloud, как спайки фазы 2; код удаляется в этой же задаче, итог — в записи решения. Стоимость — $0 в бесплатных лимитах; запуск — после моего подтверждения (правило 5). - - **Приватность:** П-5 — как подтвердить, что пользователь взрослый; П-6 — сроки хранения. - - Если решения меняют задачи — поправить этот файл. -- **Как проверить.** В product 15.2, spec 20 и privacy 12 остались только вопросы, которые явно ничего не блокируют до своей задачи: - - Т-5 — решают T14 и T38; Т-6 — T15; Т-7 — «Доработки»; - - П-1 — публичный запуск; П-2 и П-3 — T58; П-7 и П-8 — T50 и T58; П-9 — T46. -- **Разделы:** product 15; spec 20; privacy 12. - -#### T14. Спайк S1: распознавание почерка - -- **Цель:** до адаптера распознавания измерить, держится ли выбор Mathpix (R19). Против Gemini Flash-Lite меряем точность на почерке строк задачи А, долю ложных тревог при порогах spec 5.7, задержку и цену. -- **Сделать:** - - **`spike/recognition/`:** - - страница записи штрихов на тулчейне T05: линовка страницы (4.1), запись строк в формате 4.2, выгрузка в JSON с разметкой «что написано»; - - Go-прогонщик: - - Mathpix v3/strokes по R18 и spec 10.3: `confidence_threshold: 0`, `improve_mathpix: false`; - - Gemini Flash-Lite — по картинке строки, в проекте из T12, с моими учётными данными gcloud; - - сравнение с разметкой через нормализацию 5.2. - - **Набор** — 200+ строк задачи А: - - образцы взрослых; - - детские — только по отдельному письменному согласию родителей, вне репозитория и только в Mathpix (privacy 4, 5.1). - - **Метрики:** - - точность распознавания; - - доля верных строк, которые при порогах 5.7 стали бы ошибкой; - - p95 задержки, цена за строку. - - Отсюда же — подбор порогов 5.7. - - **Отчёт** `docs/live/02-spike-recognition.md`: таблица по поставщикам, выбранные пороги, вывод по R19 и по модели для текста (Т-5). -- **Я:** аккаунт Mathpix, активация, отказ от хранения данных; пишу строки сам и собираю образцы взрослых. -- **Как проверить:** критерий S1 (spec 20) — Mathpix даёт ≥ 95 % точности, ложных тревог ≤ 2 % при порогах 5.7 и p95 ≤ 500 мс. Иначе — вопрос о пересмотре R19. -- **Стоимость:** активация Mathpix $19.99 (стартовый кредит $29 покрывает запросы); Gemini — центы. -- **Не делать:** адаптер в `internal/` — это T30; хранить образцы в репозитории. -- **Разделы:** spec 4.1–4.3, 5.2, 5.7, 6.4, 10.3, 20; product 9, 13; privacy 4, 5; R18, R19, R22. - -#### T15. Спайк S2: vision в пути Б - -- **Цель:** узнать, насколько верно Gemini vision читает неоднозначные стрелки и сходится ли номер штриха по `box_2d` — с сеткой и без (Т-6). -- **Сделать:** - - **`spike/vision/`:** - - 50+ неоднозначных рисунков на задаче Б (spec 8.6): концы между объектами, отдельные наконечники, штрихи внутри колонки. Рисую я, на странице записи из T14; - - рендер PNG по 6.3 на `github.com/tdewolff/canvas` (псевдоверсия, spec 3.2): одна единица — один пиксель, номера штрихов, сетка A–J и 1–N; - - вызов `gemini-3.8-flash` со схемой ответа из 10.9, `thinkingLevel: LOW`, `maxOutputTokens: 4096` — в проекте из T12, с моими учётными данными gcloud; - - сверка номера штриха по `box_2d` (4.4). - - **Отчёт** `docs/live/03-spike-vision.md`: - - точность интерпретаций с сеткой и без; - - доля несошедшихся номеров, задержка, цена; - - решение по Т-6 и по модели (Т-5). -- **Как проверить:** критерий S2 — верная интерпретация ≥ 90 %, номер сходится по `box_2d`. -- **Стоимость:** центы (≈ $0.004 за вызов до 31.12.2026). -- **Разделы:** spec 4.4, 6.2, 6.3, 10.9, 20; R21. - -> **Точка проверки фазы 2.** Вопросы, блокирующие код, решены. Выбор Mathpix подтверждён или пересмотрен, пороги 5.7 подобраны, решение о сетке для пути Б принято. - ---- - -### Фаза 3. Каркас и контракты + - `grep -n 'поле ответа\|поля ответа' docs/product.md docs/privacy.md docs/spec.md` находит поле ответа только под плашкой E2 или там, где сказано, что поля больше нет. Записи журнала решений не правятся, поэтому `docs/decisions.md` здесь не в счёт; + - product 7 и 12 не противоречат таблице 1. +- **Не делать:** переписывать spec 4–8 целиком — это задачи, названные в плашках. +- **Разделы:** product 1, 5–7, 12, 14, 15; privacy 1, 4, 5, 11, 12; spec 1–8, 15.2, 20. #### T16. Каркас сервиса @@ -1618,17 +410,18 @@ - `server.go` с тайм-аутами и бюджетом остановки. - **`internal/config`:** - viper, только переменные окружения, только объявленные ключи; - - переменные: `PORT`, `CANVAS_ENV`, `CANVAS_LOG_LEVEL`, `CANVAS_LOG_FORMAT`, тайм-ауты HTTP и остановки; - - `CANVAS_ENV` — `local` или `prod`; `dev` оставлено для второй среды после MVP (spec 8.1, R32); + - переменные: `PORT`, `CANVAS_ENV` (`local` или `prod`), `CANVAS_LOG_LEVEL`, `CANVAS_LOG_FORMAT`, тайм-ауты HTTP и остановки; - `Validate()` называет переменную; - при `K_SERVICE` консольный формат логов и dev-переключатели не стартуют. - **`internal/logger`** — zap, JSON с ключами Cloud Run `severity`, `message`, `time`; `request_id` в контексте. - - **`internal/apierror`** — плоский `{code, message}` с необязательным `details` (R11, spec 8.3) и кодами из таблицы 8.3. - - **`internal/transport/http`** на gin (Т-1): + - **`internal/apierror`** — плоский `{code, message}` с необязательным `details` и теми кодами spec 8.3, что нужны E2. + - **`internal/transport/http`** на gin: - роутер; `/health` на GET и HEAD; - middleware в порядке образца: `LastResort`, `SecurityHeaders`, `RequestID` с заголовком `X-Request-Id`, логирование, восстановление после паники; - - место для CORS, App Check, ID token и лимитов (spec 8.1) пока пустое — его займут T22 и T45. - - **`.vscode/launch.json`** — с переменными `CANVAS_*`. + - место для CORS и ключа займёт T65. + - **То, что выкладка уже проверяет, остаётся:** ответ `/health` в форме `HEALTH_MATCHES` (`justfile`) и строка `startup` с версией и коммитом (чек-лист `docs/self-hosting.md`). + - **Облако** — `CANVAS_ENV=prod` в `service_api.tf`, если конфиг без неё не стартует (правило 13). + - **`.env.example`** с переменными `CANVAS_*`; **`.vscode/launch.json`** — с ними же; `just licenses`. - **Тесты:** - конфиг: умолчания, ошибка называет переменную, защита dev-режима; - логгер — ключи Cloud Run; `apierror` — формат и `details`; @@ -1638,964 +431,445 @@ - **Как проверить:** - `just run` и `curl -i localhost:8080/health`; - строки лога — JSON с `severity`; - - после мержа CD выкатывает каркас, `/health` отвечает коммитом. -- **Не делать:** телеметрию — T17; protobuf — T18; вход — T22. + - после мержа CD выкатывает каркас, `/health` отвечает релизом и коммитом. +- **Не делать:** телеметрию — E15 (черновик T17); protobuf — T18; CORS и ключ — T65. - **Разделы:** spec 8.1–8.3, 15.4, 16, 18; R11, R30; standalone: R18, R19, R74, R77. -#### T16a. Нагрузка: инструмент, первые сценарии и проверки на PR - -- **Цель:** с первой ручки есть чем нагрузить сервис и поймать регрессию конкурентности — в той задаче, которая пишет код, а не перед бетой. Почему здесь — в «Почему такой порядок» и правиле 14. -- **Сделать:** перенести `tools/load/` из `reference/mathtrail-standalone/` (T52a.2 и T52a.3 standalone) и приспособить. Если этих частей в копии ещё нет — остановиться и попросить обновить копию. - - **Отдельный модуль** `tools/load/go.mod`, `module github.com/MathTrail/canvas-api/tools/load`: - - продукт — через `replace … => ../..`: тесты инструмента поднимают настоящий сервис, а типы у инструмента и сервиса одни; - - `github.com/tsenart/vegeta/v12` той же версии, что в образце, — только темп (`vegeta.Pacer`) и перцентили (`vegeta.Metrics`); - - зависимости инструмента не попадают ни в сервис, ни в `THIRD_PARTY_LICENSES`. Корневые `go build ./...` и `golangci-lint run ./...` модуль не видят, поэтому у него свои рецепты. - - **Клиент canvas-api** — на месте сессии MCP образца: - - свой срок на каждый вызов; `X-Request-Id` каждого ответа — в отчёт; - - вид каждого ответа: `ok`, `api:<код>` из `apierror` (spec 8.3), `http:<статус>`, `no_answer:timeout|refused|reset`, `mismatch` — ответ чужому холсту или чужому `seq`; - - в этой задаче клиенту хватает `/health`. Остальное приносят задачи, которые вводят ручки (правило 14): тело protobuf на типах из `gen/` — с первой ручки `/v1` после T18, как у SPA; протокол дельт (8.4) — так же, как его ведёт SPA (3.1): пауза, один запрос в полёте на холст, повтор с тем же `client_event_id`, перенумерация после `SEQ_CONFLICT`, снимок после `RESYNC_REQUIRED`. - - **`scenario`** — бегун с постоянным темпом и `Launch`, единственной абстракцией: - - сервис в том же процессе — в тестах инструмента: `config` + `app.NewContainer` + настоящий сервер каркаса `app.NewServer(…).Run` на свободном порту. Не голый `httptest`, как в образце: под нагрузкой важны тайм-ауты сервера и бюджет остановки T16; - - по адресу — `-url`, например против `just run`. Из командной строки в этой задаче — только так, как в образце: без `-url` прогон не идёт (код 2); - - образом — T16b. - - **`report`** — перцентили и доли по видам ответа, жёсткие факты, Markdown. Коды выхода: 0 — чисто, 1 — есть жёсткий факт, 2 — прогнать не удалось. - - **Жёсткие факты** — то, что красит прогон. Задачи дополняют список: - - ответ 500 и паника; - - `UNAVAILABLE` и `LIMIT_EXCEEDED`, которых сценарий не ждёт. В сценариях насыщения и лимитов они ожидаемы и только считаются; - - запрос без ответа в срок, ответ чужому запросу; - - потерянный штрих: после прогона на сервере нет штриха, приём которого клиент получил (product 10); - - `ERROR` на строке, которую сценарий написал верно, — ложная тревога (product 5.6); - - ответ задачи в любом ответе (spec 13); - - вызовов поставщика больше, чем ждёт сценарий (product 9); - - `UNCHECKED` с причиной `engine_error`; - - против образа (T16b): OOM, самовольный выход контейнера, пик памяти выше `-memory-ceiling`; - - провал проверки восстановления: после прогона `/health` не отвечает 200, а с T33 — новый ребёнок не решает задачу А. - - Задержки и пропускная способность печатаются, но гейтом не служат: раннер общий, и абсолютная задержка на нём — флейк. - - **Сценарии каркаса:** - - `health` — `/health` в постоянном темпе; ожидание — все ответы `ok`; - - `shutdown` — остановка посреди нагрузки: начатые запросы доходят, сервер закрывается в бюджет остановки T16. В процессе её даёт отмена контекста `Run` — тот же путь, что SIGTERM; против образа (T16b) — `docker stop`. Пока у каркаса нет медленных ручек, медленный запрос даёт маршрут, которым тест оборачивает `Router` контейнера; с T33 сценарий идёт посреди `lesson`. - - **Рецепты:** - - `just load <сценарий> [флаги]`; - - `just load-test` — тесты модуля под `-race`; - - `just load-lint` — `go mod tidy -diff` с подсказкой `just load-tidy`, `gofmt`, `golangci-lint` с корневым конфигом, лицензии по корневому списку, сверка версий модулей, общих с продуктом; - - `just load-tidy`. - - **CI:** `just load-lint` — в задании lint, `just load-test` — в test; ключ кэша Go — по обоим `go.sum`; SonarCloud и Codecov исключают `tools/**`; dependabot — gomod для `/tools/load`, только vegeta; `.dockerignore` — `tools`. - - **Документы:** - - запись в `docs/decisions.md` — устройство инструмента. Отвергнуто: - - облачный сервис нагрузки: он достаёт только prod (R32), где настоящие Mathpix и Gemini стоят денег, а входу нужны настоящие аккаунты и debug-токены App Check; - - k6, Locust и Gatling: протокол дельт и protobuf пришлось бы вести на втором языке; - - сырой `Attacker` vegeta: он шлёт открытым потоком и не видит ответа, а дельте нужен замкнутый цикл по холсту; - - модуль без продукта: слабые тесты; - - spec 17 — раздел о нагрузке: что гоняется на PR, что раз в неделю, что красит сборку; - - spec 18 и Layout в `CLAUDE.md` — строка `tools/load/`: раскладка CLAUDE.md повторяет spec 18; - - `CLAUDE.md` — ещё рецепты, `just load-tidy` после правки `go.mod` продукта, правило 14 в How we work и стражи в Tests; `CONTRIBUTING.md` — две строки. -- **Тесты:** - - клиент — против подделки через `httptest`: вид каждого ответа, срок вызова, заголовки; - - бегун держит темп при любых ответах; - - `health` и `shutdown` — против настоящего сервиса в процессе; - - `report` — «что жёстко, что нет»; `main` — умолчания, неизвестный сценарий даёт код 2. -- **Как проверить:** - - `just load-lint`, `just load-test`; - - `just run` в одном терминале и `just load health -url http://localhost:8080` в другом — отчёт без жёстких фактов; - - убрать ожидание начатых запросов при остановке — `shutdown` краснеет. -- **Разделы:** spec 3.1, 8.3, 8.4, 13, 14, 15.3, 17, 18; product 5.6, 9, 10; R11, R23, R32; standalone: T52a. - -#### T16b. Нагрузка: образ, холодный старт и прогоны в CI - -- **Цель:** сценарии идут против настоящего образа с ограничениями экземпляра Cloud Run, и отчёт знает, что израсходовал контейнер. Каждый PR проходит короткую нагрузку, раз в неделю — полную. -- **Сделать:** перенести из `reference/mathtrail-standalone/` (T52a.4 и T52b standalone) и приспособить. - - **Пакет `instance`:** - - `docker run` образа с CPU и памятью контейнера из переменных Terraform (`variables.tf` и `prod.auto.tfvars`, T10) — там же, где их правит T46a. Без свопа, как в Cloud Run; с T21 — вместе с сайдкаром OpenFGA, каждый в своих пределах. Числа читаются из Terraform, а не повторяются в инструменте, — расхождение ловит тест; - - адрес — `127.0.0.1` и порт из `docker port`; - - cgroup v2 раз в 250 мс: `cpu.stat`, `memory.current`, `memory.peak`, `oom_kill`; - - остановка строго по порядку: последнее чтение cgroup, `inspect`, `docker stop`, журнал (паники, `engine_error`), `docker rm -f` — всегда; - - OOM, самовольный выход и пик памяти выше `-memory-ceiling` — жёсткие факты (T16a); - - сеть: контейнер входит в сеть compose, где с T20 стоят Postgres и OpenFGA, и видит подделки инструмента на хосте — например, через `--add-host host.docker.internal:host-gateway`. Подделки слушают адрес, видимый из контейнера, а адреса зависимостей в окружении контейнера указывают туда. - - `just load <сценарий>` без `-url` сначала собирает образ (`just docker-build`) и гонит сценарий против него; с `-url` — как в T16a, против запущенного сервиса. - - **Сценарий `cold`:** 5 запусков; время от `docker run` до первого `/health` и пик памяти после первого вызова. - - **Стоимость в отчёте:** - - оплачиваемая — время, пока идёт хотя бы один запрос, × vCPU × ГиБ экземпляра — против бесплатного лимита Cloud Run (product 9); - - израсходованная — из cgroup; - - с T30 — ещё вызовы поставщиков на задачу по ценам product 9. - - **`just ci-load-smoke`** — в задании `image` на каждом PR, после `ci-image-smoke`: - - образ под ограничениями экземпляра, короткие варианты сценариев; - - гейт — только жёсткие факты (T16a); отчёт — в summary задания; - - бюджет — около 3 минут: сценарий, который в него не помещается, идёт только в `load.yml`; - - задание `image` идёт на раннере ради docker, и Go там нет. Инструмент собирается Go из образа тулчейна (T06), как в остальных заданиях, а не Go раннера: бинарником из задания test или в контейнере тулчейна с docker раннера — способ выбирает задача. - - CD ждёт CI этого коммита (R33), так что выкладка в prod ждёт и нагрузку. - - **`.github/workflows/load.yml`:** - - раз в неделю на `main` и по кнопке с любой ветки: облака он не касается; - - все сценарии с полной длительностью; отчёт — в summary и артефактом; - - красит сборку только жёсткое; - - actions по SHA, минимальные права, `timeout-minutes`. - - Плашка нагрузки в `README.md`; дополнение записи T16a в `docs/decisions.md`. -- **Тесты:** путь cgroup для docker и systemd, отказ для cgroup v1; разбор файлов cgroup; паники в журнале; аргументы `docker run`; образ и `-url` вместе не принимаются; числа экземпляра совпадают с Terraform. -- **Как проверить:** - - `just load health` и `just load cold` — код 0; - - `just ci-load-smoke` зелёный; - - подложенная регрессия — `/health`, который держит память, — даёт OOM и код 1; - - ручной запуск `load.yml` на ветке кладёт отчёт в summary. -- **Стоимость:** $0 — раннеры GitHub для публичного репозитория; облака прогон не касается. -- **Разделы:** spec 14.2–14.4, 15.3, 15.5, 17; product 9; R33; standalone: T52a.4, T52b. - -#### T17. Телеметрия - -- **Цель:** трассы и метрики, пока кода мало и инструментировать его дёшево (spec 16). -- **Сделать:** перенести `internal/telemetry` из образца (T28a и R49 standalone). - - **Провайдеры и экспорт:** - - провайдеры трасс и метрик, ресурс с версией и определением Cloud Run; - - экспорт OTLP на `telemetry.googleapis.com`; - - сэмплирование `ParentBased(TraceIDRatioBased)`; - - сброс до ответа: после ответа Cloud Run почти не даёт CPU. - - **Вне развёртывания экспорт выключен:** `just run` и тесты не требуют учётных данных Google. - - **Инструментирование:** - - `otelgin` без собственных метрик; `/health` не трассируется; - - адрес клиента и user agent затираются; - - `logging.googleapis.com/trace` — в строке лога. - - **Что можно в атрибутах спанов и метках метрик** — только UUID, коды, маршруты и закрытые словари (spec 16). - - **Terraform:** API телеметрии, роли `telemetry.writer` и `serviceUsageConsumer` аккаунту сервиса; права аккаунта Terraform в `infra/bootstrap.sh` — если нужны (как T28a.3 standalone). -- **Тесты:** - - экспортёр против `httptest`, говорящего OTLP; - - сбой экспорта не меняет ответ и не роняет процесс; - - выключенная телеметрия не делает исходящих запросов; - - страж на утечку: в атрибутах и метках нет псевдонима, LaTeX, текста подсказок и токенов. -- **Нагрузка (правило 14):** `health` идёт и с экспортом в подделку OTLP с задержкой: сброс до ответа укладывается в свой срок и не копит запросы. -- **Как проверить:** после деплоя в Cloud Trace спан сервиса висит под спаном Cloud Run, а строка лога раскрывается в ту же трассу. -- **Стоимость:** в бесплатных лимитах Cloud Trace и Monitoring; назвать, что может стоить. -- **Разделы:** spec 16; privacy 4; R49 standalone. - -#### T18. Контракты API - -- **Цель:** контракт клиента и сервера — один источник правды в `proto/`, из которого генерируются Go и TypeScript (R12). -- **Сделать:** - - **`proto/canvasapi/v1/*.proto`** — сообщения spec 8.4 и ресурсы 8.2: запрос и ответ каждого метода; `buf.yaml`, `buf.gen.yaml`. - - **Генерация** — тот же генератор, что в `contracts` (spec 3.1); сгенерированное коммитится: - - Go — в `gen/` (`protoc-gen-go`); - - TS — в `web/src/gen/` (`@bufbuild/protobuf`, `protoc-gen-es`). - - **Инструменты:** buf и `protoc-gen-go` — `ARG` в стадии `toolchain`; `protoc-gen-es` — в `web/package.json`; расширение VS Code для buf точной версии. - - **Рецепты:** - - `proto` — генерация; - - `ci-proto` — `buf lint`, `buf breaking` против `main` и генерация без расхождений с закоммиченным; задание в `ci.yml`. - - **`internal/transport/http`:** - - разбор и ответ в `application/x-protobuf`; - - JSON по правилам protojson — только при `CANVAS_ENV=local` (spec 8.1) и никогда при `K_SERVICE`; - - ошибки — `apierror`. -- **Тесты:** protobuf и JSON при `CANVAS_ENV=local` дают одно и то же; JSON при `K_SERVICE` отклоняется; fuzz на разборе тела. -- **Как проверить:** `just ci-proto`; PR, ломающий совместимость поля, падает на `buf breaking`. -- **Разделы:** spec 3.1, 3.2, 8.1–8.4, 19; R12. - -#### T19. Статичные задачи - -- **Цель:** две задачи MVP (О-10) живут в коде, проверяются при старте и никогда не отдают ответ. -- **Сделать:** - - **`tasks/*.json`** — две задачи по формату spec 8.6, выбранные в T13. Картинки объектов пути Б — в `web/public/tasks/{task_id}/`: свои SVG под MIT; правая колонка перемешана. - - **Загрузка через `go:embed` и проверка при старте:** - - схема и согласованность страницы с 4.1; - - путь Б — объекты не пересекаются, пары ссылаются на существующие объекты разных колонок; - - путь А — условие разбирается и ответ ему удовлетворяет; эту проверку добавит T28, когда появится движок; - - битая задача — сервис не стартует. - - **Представление задачи для клиента** — без `answer` (spec 13). Отдают его ручки T31, уже за входом; своей ручки у этой задачи нет, чтобы в prod не появился открытый маршрут (правило 13). -- **Тесты:** - - каждая ошибка формата даёт отказ старта с понятной причиной; - - fuzz на разборе JSON; - - страж: ни в одной форме ответа нет поля с ответом задачи. -- **Как проверить:** я вычитываю задачи и картинки. -- **Разделы:** spec 8.2, 8.6, 13; product 5, 7; R04. - -> **Точка проверки фазы 3.** Каркас standalone работает в prod с трассами. Контракт `proto/` и задачи на месте. Инструмент нагрузки гоняет каркас на каждом PR и раз в неделю. - ---- - -### Фаза 4. Данные и доступ +#### T63. Terraform: провайдеры 8.7.0 -#### T20. Postgres и миграции - -- **Цель:** данные приложения лежат в Postgres со схемой spec 11, миграциями и интеграционными тестами. Хранилище — как в mentor-api, проверки — как в standalone. -- **Сделать:** - - **`migrations/`** — SQL-миграции goose по модели данных spec 11: - - таблицы, индексы, `ON DELETE CASCADE` — кроме `parent_usage_daily`; - - миграции совместимы со старым кодом: «сначала добавить, потом убрать» (15.5). - - **`cmd/migrate`** — отдельная команда (spec 11); тот же образ собирает и её бинарник. - - **`internal/infra/postgres`:** - - пул pgx, транзакции, закрытие в контейнере; - - хранилище за портами домена — узкие интерфейсы по потребителям, а не один `Store` на всё. - - **Локальный Postgres** — `docker compose` с образом по тегу и digest; рецепты `db-up`, `db-down`, `migrate`. - - **Интеграционные тесты** — отдельная метка сборки, рецепт `ci-integration`, задание в `ci.yml` с Postgres в контейнере. - - **Dependabot** — для файла compose. - - **Облако** — мерж выкатывается в prod, и без базы сервис не стартует (правило 13): - - Neon — проект в регионе T09 и база `canvas`. Через провайдер Terraform, если он есть и его лицензия подходит; иначе руками, по записанной инструкции; - - секрет с URL базы в Secret Manager и его передача сервису в `service_api.tf`; - - job `canvas-migrate` (наш образ, `/migrate`) и шаг CD по R31: миграции на новом образе до выкладки сервиса; - - разделы о Neon и секретах в `docs/self-hosting.md`. -- **Я:** аккаунт Neon; URL базы — в секрет репозитория до мержа (правило 13). -- **Тесты:** - - миграции вверх и вниз на чистой базе; - - каскадное удаление ребёнка стирает холсты, дельты, штрихи, шаги, подсказки и счётчики, но не `parent_usage_daily`; - - транзакция откатывается при ошибке. -- **Нагрузка (правило 14):** - - пул pgx и сроки на стороне базы — по решению Т-15; ожидание соединения ограничено, исчерпанный пул — `UNAVAILABLE` (spec 8.3); - - страж: ожиданий больше, чем соединений, — каждое кончается в срок; отменённый контекст откатывает транзакцию; после прогона соединения вернулись; - - сервису в процессе теперь нужна база. Тесты инструмента против настоящего сервиса получают метку сборки интеграционных тестов и идут в `just ci-integration`, а `just load-test` оставляет тесты самого инструмента — клиента, бегуна, отчёта. `ci-integration` гоняет их и в модуле `tools/load`: корневой `go test ./...` его не видит. `ci-load-smoke` поднимает Postgres из того же compose и применяет миграции. -- **Как проверить:** `just ci-integration`; `just migrate` на пустой базе; после мержа CD зелёный, а миграции прошли на новом образе до выкладки сервиса. -- **Стоимость:** Neon — бесплатный тариф. -- **Риски:** договоры с обработчиками нужны до данных первого ребёнка (П-3). До T58 в prod — только мои тестовые данные. -- **Разделы:** spec 3.2, 11, 13, 15.2, 15.5, 17; privacy 3, 12; R01, R08, R31. - -#### T21. OpenFGA - -- **Цель:** права по модели spec 12.3 проверяет сайдкар OpenFGA, а сервис сам держит модель актуальной (R28). -- **Сделать:** - - **Модель** — `deploy/openfga/model.fga` из 12.3: - - тесты модели (`.fga.yaml`) гоняет CLI OpenFGA из закреплённого образа; рецепт и задание в CI; - - при сборке модель переводится в JSON и встраивается в бинарник. - - **Порт `Authorizer`** в домене и адаптер `internal/infra/openfga` на `github.com/openfga/go-sdk`: - - `Check`; - - запись и удаление кортежей пачками до 100, отсутствующие пропускаются. - - **При старте:** - - найти или создать store `canvas`; - - сравнить последнюю модель со встроенной и при расхождении записать новую (R28, 12.3); - - ждать готовности сайдкара: повтор каждые 200 мс, до 5 с (14.2). - - **Сайдкар в compose** — с настройками 12.3: `127.0.0.1`, без playground, кеш проверок, JSON-логи, отдельная база `openfga`; `openfga migrate` перед стартом. - - **Интеграционные тесты** — против сайдкара в контейнере. - - **Облако** — без сайдкара сервис не стартует (правило 13): - - база `openfga` в том же проекте Neon и секрет с её URL; - - сайдкар OpenFGA по digest в `service_api.tf` с настройками 12.3: параллельный старт контейнеров, startup probe каждую секунду с `failureThreshold: 30` (14.2); - - job `openfga-migrate` в CD — до выкладки сервиса (R31). -- **Я:** URL базы `openfga` — в секрет репозитория до мержа (правило 13). -- **Тесты:** - - родитель управляет своим ребёнком и пишет в его холст, чужой — нет; - - учитель без кортежей ничего не видит; - - повторный старт не пишет модель заново, а два одновременных старта безвредны; - - удаление пачками идемпотентно. -- **Нагрузка (правило 14):** клиент OpenFGA держит соединения под параллельность экземпляра (Т-15); CPU и память сайдкара — тоже переменные Terraform (T10), и в прогоне образа сайдкар стоит рядом с сервисом в этих пределах; страж: одновременных `Check` больше, чем соединений, — каждый кончается в срок. -- **Как проверить:** `just ci-integration`; тесты модели зелёные; после мержа CD зелёный, холодный старт сервиса с сайдкаром — на глаз около 3 с (замер — T54). -- **Разделы:** spec 12.3, 14.2, 15.3, 15.5; R06, R09, R28, R31. - -#### T22. Вход: Firebase Auth и App Check - -- **Цель:** каждый запрос проходит цепочку spec 8.1 в правильном порядке, а необратимые действия требуют свежего входа. +- **Цель:** провайдеры подняты отдельной правкой до того, как Terraform E2 добавит ресурсы: lock и план каждой задачи читаются по отдельности. Закрывает Д5. - **Сделать:** - - **Адаптер `internal/infra/firebase`** на Admin SDK (R14): `VerifyIDToken`, `VerifyIDTokenAndCheckRevoked`, `appcheck.VerifyToken`; позже — `RevokeRefreshTokens` и `DeleteUser`. - - **Middleware по порядку 8.1:** - 1. CORS — первым: только домен SPA, свой ответ на `OPTIONS`, `Access-Control-Max-Age: 7200`; - 2. App Check; - 3. ID token — с проверкой отзыва везде, кроме горячего пути: штрихов и `/v1/realtime/token`; - 4. место для лимитов (T45). - - **Свежий вход** — `auth_time` не старше 5 минут, иначе `REAUTH_REQUIRED`. - - **Dev-заглушка** входа и App Check для локальной работы — не стартует при `K_SERVICE`. - - **Субъект доступа** — всегда родитель (R07); в логах — только uid. - - **Облако** — `CANVAS_FIREBASE_PROJECT_ID` и `CANVAS_ALLOWED_ORIGINS` в `service_api.tf`, чтобы после мержа сервис стартовал в prod (правило 13). + - `google` и `google-beta` 8.3.0 → 8.7.0 или новее на день задачи — в `versions.tf`; + - `.terraform.lock.hcl` заново: `terraform providers lock -platform=linux_amd64 -platform=linux_arm64`; + - заметки о выпусках между версиями — что меняется для наших ресурсов: Cloud Run v2, Firebase, Logging, бюджет, алерт; + - строка Д5 уходит из таблицы 4. +- **Как проверить:** `just tf-check`; после мержа CD зелёный, а `just ci-tf-plan` пуст. +- **Стоимость:** $0. +- **Разделы:** spec 15.2; R56; таблица 4 (Д5). + +#### T64. Облако E2: секрет ключа, Agent Platform, бюджет + +- **Цель:** в проекте есть всё, что нужно двум платным решениям E2 — ключу доступа и вызову Gemini, — а bootstrap повторяется один раз. Код, который это читает, приходит следующими задачами (T65, T35): к их выкладке права уже разошлись по IAM (как в Д14). +- **Сделать:** + - **`infra/bootstrap.sh`** — роли аккаунта Terraform: + - Secret Manager — как в образце (`reference/mathtrail-standalone/infra/bootstrap.sh`); + - роль вызова моделей — в условие `projectIamAdmin` (`infra/bootstrap.sh`, `admin_role`): её Terraform выдаёт аккаунту сервиса на проект; + - право на настройку кеша Agent Platform — наименьшая роль по документации; + - тест `the_publisher_holds_what_bootstrap_lets_the_configuration_grant` (`infra/terraform/tests/configuration.tftest.hcl`) обобщается: каждая роль, которую конфигурация выдаёт на проект, есть в условии bootstrap. + - **Terraform:** + - API Secret Manager и Agent Platform (`apis.tf`); + - `secrets.tf` — секрет ключа доступа без версии и право аккаунта сервиса его читать; + - роль вызова моделей аккаунту сервиса; + - кеш Gemini выключен для проекта ресурсом `google_vertex_ai_cache_config`, если документация провайдера его подтверждает. Иначе — шагом рецепта, и тогда spec 15.2 возвращается к рецепту (privacy 5.2). Закрывает Д11; + - бюджет — $20 в месяц (`prod.auto.tfvars`), пороги прежние. + - **`ci-tf-apply`** — двумя проходами, как в образце (`reference/mathtrail-standalone/justfile`, рецепт `ci-tf-apply`): + 1. API, секрет и право на него; + 2. версия ключа, если её ещё нет: `head -c 32 /dev/urandom | base64`; ключ нигде не печатается; + 3. всё остальное. + + В задании `infra` выкладки (`deploy.yml`) появляется gcloud (`./.github/actions/gcloud`); `_project` возвращается, как в образце. + - **`just access-key`** — печатает ключ автору, его учётными данными gcloud, чтобы ввести ключ в SPA. + - **`docs/self-hosting.md`** — ключ: где лежит, как прочитать и сменить (новая версия, прежняя уничтожается); бюджет; повтор bootstrap; что стоит денег. +- **Мой шаг:** повторить `just bootstrap` до мержа (`docs/self-hosting.md`, «Когда меняется bootstrap»). - **Тесты:** - - порядок middleware: preflight без токенов проходит; без App Check — `APP_CHECK_FAILED`; без токена — `UNAUTHENTICATED`; - - отозванный токен отклоняется везде, кроме горячего пути; - - свежий вход; - - заглушка при `K_SERVICE` не стартует; - - Firebase подделан через `httptest`, без сети. -- **Нагрузка (правило 14):** заглушка различает родителей, как R123 standalone: `Bearer <имя>` входит как uid `dev-<имя>`, а токен, который не имя, — как один `dev`. Имя — от 1 до 64 букв, цифр, точек, дефисов и подчёркиваний: uid попадает в логи и в кортежи OpenFGA. Нагрузке нужны десятки родителей на одном экземпляре, а настоящий ID token выдаёт только вход через Google или Apple. Клиент инструмента подписывает запросы заглушкой. -- **Как проверить:** `just run` с заглушкой и запрос с её токеном. -- **Разделы:** spec 8.1, 8.3, 12.1, 13; R07, R14. - -#### T23. Родитель: бета, согласие, PIN - -- **Цель:** родитель попадает в бету по инвайту и отмечает уведомление, а согласие подтверждает оператор (R26, R29). -- **Сделать:** - - **`GET /v1/me`** — родитель, дети, статус согласия и беты; всегда 200. - - **`POST /v1/me/invite:redeem`** — сверка SHA-256 кода, привязка к uid, одноразовость, срок. До погашения — `NOT_IN_BETA`. - - **`POST /v1/me/consent`** — статус `pending` и версия уведомления; метод согласия клиент не передаёт. - - **`PUT /v1/me/pin` и `POST /v1/me/pin:verify`:** - - argon2id; - - 5 попыток за 15 минут, счётчик и блокировка — в `parents`; - - смена забытого PIN — после свежего входа (spec 12.2). - - **`cmd/admin`** — запускается как Cloud Run job (15.5): - - `invite create` — печатает код один раз и хранит только хеш; - - `consent verify --uid … --method signed_form` — метод, время и кто подтвердил. - - Email сервис не читает и не хранит (12.1). + - `terraform test`: секрет без версии, право на него только у аккаунта сервиса, роль вызова моделей, кеш выключен, бюджет — 20; + - рецепт с поддельным gcloud: ключ генерируется один раз, повтор ничего не меняет, значение нигде не печатается. +- **Как проверить:** `just tf-check`; после мержа CD зелёный, `just access-key` печатает ключ, а повторная выкладка не создаёт новой версии. +- **Стоимость:** Secret Manager — 1 активная версия из 6 бесплатных на платёжный аккаунт; Agent Platform без вызовов — $0. +- **Не делать:** переменные ключа и Gemini в сервисе — их приносят T65 и T35. +- **Разделы:** spec 13, 15.2, 15.4; privacy 5.2; R34, R56, R58; standalone: R26, R29; таблица 4 (Д11, Д14). + +#### T65. Доступ: CORS и ключ + +- **Цель:** SPA с `web.app` зовёт API на `run.app`, а всё, что за `/v1`, отвечает только тому, у кого ключ. Каждый мерж уходит в prod, и платная ручка не должна оказаться открытой ни на одну выкладку (решение 6 E2). +- **Сделать:** + - **CORS — первым в цепочке** (spec 8.1): + - разрешённые origin — `CANVAS_ALLOWED_ORIGINS`: в prod — адрес SPA (R55), локально — `http://localhost:5173` как dev-переключатель; + - свой ответ на `OPTIONS`, `Access-Control-Max-Age: 7200`; + - разрешены `Authorization` и `Content-Type` protobuf; наружу — `X-Request-Id`, его показывает панель автора. + - **Ключ — следом:** `Authorization: Bearer <ключ>`, сравнение за постоянное время; нет ключа или он неверный — 401 `UNAUTHENTICATED`; `/health` и preflight идут без ключа. + - **Конфиг:** `CANVAS_ACCESS_KEY` обязателен вне `CANVAS_ENV=local`; локально ключ берётся из `.env`. Значение не попадает ни в лог, ни в ошибку. + - **Облако:** `CANVAS_ACCESS_KEY` из секрета T64 и `CANVAS_ALLOWED_ORIGINS` — в `service_api.tf` (правило 13). + - **`SECURITY.md`** — что держит платную ручку в E2: ключ и потолок; вход и App Check — в E15. - **Тесты:** - - повторное погашение и чужой код; - - согласие без инвайта; - - `verified` ставится только командой; - - PIN: блокировка и её снятие по времени; параметры argon2id — из документации пакета. -- **Нагрузка (правило 14):** - - сценарий `family`: инвайт, согласие, подтверждение командой `cmd/admin`, `GET /v1/me`. Команду инструмент запускает сам: в процессе — её кодом, против образа — её бинарником из того же образа в сети compose; с `-url` сценарий без адреса базы не идёт (код 2); - - стражи: одновременное погашение одного кода — ровно один успех; параллельные `pin:verify` не дают больше 5 попыток за 15 минут. -- **Как проверить:** `just ci-integration`; сценарий 9.1 через HTTP с заглушкой входа — до шага «оператор подтверждает». -- **Разделы:** spec 8.2, 9.1, 11, 12.1, 12.2; privacy 6; R26, R29. - -#### T24. Профили детей - -- **Цель:** родитель с подтверждённым согласием создаёт и меняет профили детей, и OpenFGA сразу знает о правах на них. -- **Сделать:** - - **`POST /v1/children`:** - - только при `verified`, иначе `CONSENT_REQUIRED`; - - псевдоним, аватар из набора, класс 1–6; - - лимиты О-13: не больше 3 детей и 3 новых профилей в день (`parent_usage_daily.children_created`, `LIMIT_EXCEEDED`). - - **Кортеж** `user:{uid} parent child:{id}` пишется внутри транзакции Neon до `COMMIT` (9.1). «Висящий» кортеж безвреден; его убирает очистка (T44). - - **`PATCH /v1/children/{child_id}`** — с проверкой `can_manage`. - - Псевдоним не попадает в логи, трассы и во внешний ИИ. -- **Тесты:** без согласия — отказ; лимиты; сбой записи кортежа откатывает транзакцию; чужой ребёнок — `PERMISSION_DENIED`. -- **Нагрузка (правило 14):** `family` создаёт детей; запись кортежа внутри транзакции — со своим сроком (Т-15); страж: параллельные `POST /v1/children` не выходят за 3 детей и 3 новых профиля в день. -- **Как проверить:** `just ci-integration`. -- **Разделы:** spec 8.2, 9.1, 11, 12.3, 14.5; product 6; privacy 1. - -#### T25. Облако: вход и администрирование - -- **Цель:** в prod работает настоящий вход родителя через Google и Apple с App Check, а оператор выполняет административные команды — всё описано в Terraform (R34). Данные уже в облаке: базы и сайдкар принесли T20 и T21. -- **Сделать:** - - **`firebase.tf`:** провайдеры входа Google и Apple, App Check с reCAPTCHA Enterprise (ключ с оценкой риска), разрешённые домены. - - **Секреты провайдеров входа (R34):** - - `secrets.tf` — секреты для закрытого ключа Apple и секрета клиента Google; - - значения приходят из секретов репозитория: `ci-tf-apply` кладёт версию, если её ещё нет, — как секрет клиента Google в образце. В публичный `prod.auto.tfvars` они не попадают; - - провайдеры Google и Apple в `firebase.tf` берут значения из Secret Manager; в консоли Firebase руками ничего не настраивается; - - ротация — отдельная команда в `docs/self-hosting.md`, как ротация ключа в образце: - 1. новое значение — сначала в секрет репозитория; - 2. затем новая версия в Secret Manager; - 3. прежняя версия уничтожается, только когда Terraform уже читает новую, — чтобы число активных версий не росло (spec 13); - - если ресурс Terraform не умеет нужную настройку провайдера — остановиться и спросить: R34 требует полного IaC. - - **Job `admin`** для `cmd/admin` (spec 15.5): оператор запускает его с аргументами, а не через публичный API. - - **Документация:** разделы о провайдерах входа, их секретах и ротации в `docs/self-hosting.md`; отчёт `docs/live/04-sign-in.md`. -- **Я** — дела без API, каждое один раз: - - OAuth-клиент Google (Web) в консоли Google Cloud: client id — в `prod.auto.tfvars`, секрет — в секрет репозитория; - - Apple Developer Program: Services ID и ключ для входа через Apple; идентификаторы — в `prod.auto.tfvars`, закрытый ключ — в секрет репозитория. -- **Как проверить:** - - CD зелёный; - - вход через Google и Apple в prod работает с провайдерами из Terraform; `terraform plan` после `apply` пуст — ручных правок нет; - - инвайт, выпущенный job `admin`, гасится в prod. -- **Стоимость:** Apple Developer — $99 в год; reCAPTCHA Enterprise — в бесплатном лимите; секреты — по расчёту spec 13. -- **Разделы:** spec 12.1, 13, 15.2, 15.5; R14, R29, R34. + - порядок middleware: preflight без ключа проходит; чужой origin разрешения не получает; без ключа — 401; + - ключ, отличный в одном символе или по длине, — 401; + - пустой ключ в prod — сервис не стартует; + - ключа нет ни в одной строке лога. +- **Как проверить:** `just run` и `curl` с ключом и без; в prod — с T18, на `GET /v1/tasks`. +- **Не делать:** ручки `/v1` — первую приносит T18; потолок — T69. +- **Разделы:** spec 8.1, 8.3, 13, 15.4; R55; решения 6 и 9 E2. -> **Точка проверки фазы 4.** В prod, с сайдкаром OpenFGA, родитель входит, гасит инвайт и отмечает уведомление. Оператор подтверждает согласие, после чего родитель создаёт профиль ребёнка. +> **Точка проверки части 1.** Документы говорят то же, что решил автор. Каркас standalone работает в prod. Ключ лежит в Secret Manager, Agent Platform включена, кеш Gemini выключен, бюджет — $20. SPA с `web.app` проходит CORS, а без ключа за `/v1` не пройти. --- -### Фаза 5. Холст и путь А - -Домен — чистый Go, тесты, $0. Правила тестов — в CLAUDE.md. - -#### T26. Холст: страница, штрихи, строки - -- **Цель:** геометрия холста — чистая функция, одинаковая на всех устройствах (R15). -- **Сделать:** `internal/domain/canvas`. - - **Логическая страница (spec 4.1):** 1000 × 1600, область условия до y = 200, 14 строк по 100. - - **Штрих и точка (4.2).** Проверки пределов (13): - - до 2000 точек в штрихе; - - координаты в пределах страницы; - - строки фиксированной длины; - - до 300 штрихов на холсте. - - **Сегментация (4.3):** - - строка — по медиане y через `math.Floor`; - - штрихи в области условия не распознаются; - - медиана ≥ 1600 — в 14-ю строку; - - какие строки изменились. - - **Рамки (4.4):** рамка штрихов строки с отступом 10, рамка одного штриха, перевод `box_2d` из 0..1000 в логические координаты. -- **Тесты:** - - таблица границ: 199, 200, 1599, 1600, отрицательные; - - свойства на gopter: перевод рамки туда и обратно; строка штриха не зависит от порядка точек; - - fuzz на проверке пределов. -- **Разделы:** spec 4, 13; R15, R16, R20. - -#### T27. Движок: разбор записи - -- **Цель:** LaTeX, который Mathpix выдаёт на школьную запись, разбирается в дерево, а незаконченная строка узнаётся как незаконченная. -- **Сделать:** `internal/domain/mathstate`. - - **Нормализация (spec 5.2):** пробелы, `\left`, `\right`, `\,`, юникодный минус, `\times`, `\cdot`, `\div`. - - **Парсер** — по решению Т-2 (рекомендация — свой Pratt). Поддерживает: - - числа и одну переменную; - - `+ - × · ÷ / :` и неявное умножение; - - скобки, `\frac`, `=`. - - **Недописанное и неподдерживаемое:** - - незавершённый ввод (`2x=11-`, `2x=`) — признак `INCOMPLETE`, а не ошибка разбора; - - нелинейное и неподдерживаемое — `UNRECOGNIZED`. -- **Тесты:** - - таблица записей из 5.2 и 5.6; - - fuzz на парсере: LaTeX от Mathpix — недоверенный ввод, и паника здесь роняет запрос; - - свойство: печать дерева и повторный разбор дают то же дерево. -- **Разделы:** spec 5.1–5.3, 20 (Т-2). +### Часть 2. Задачи, домен проверки и лист -#### T28. Движок: арифметика и вердикты +Домен — чистый Go, тесты, $0. Лист пишется на компьютере — мышью или пером графического планшета (решение 10 E2); перо и ладонь на iPad и Android — в E15. -- **Цель:** шаг проверяется точно, без плавающей точки, и движок никогда не роняет запрос. -- **Сделать:** - - **Точная арифметика (spec 5.3):** вычисления в `big.Rat`; стороны приводятся к `a·x + b`; шаги эквивалентны, если совпадают множества решений; выражения — по значению. - - **Деление на ноль** — `ERROR` с кодом `DIVISION_BY_ZERO`, в том числе когда знаменатель стал нулём после вычисления. - - **Вердикты (5.4):** `CORRECT`, `INCOMPLETE`, `UNRECOGNIZED`, `ERROR`, `FOLLOWS_ERROR`, `UNCHECKED`; признак «задача решена» — `x = v` или `v = x`. - - **Паника** перехватывается на границе пакета → `UNCHECKED` с причиной `engine_error`. - - **Проверка задач пути А при старте (T19):** условие разбирается, ответ ему удовлетворяет. -- **Тесты:** - - вся таблица 5.6; - - свойства на gopter из spec 17: - - случайные линейные уравнения и верные преобразования дают `CORRECT`; - - ни одно верное преобразование не даёт `ERROR`; - - выражения с делением, в том числе на ноль, не паникуют. -- **Разделы:** spec 5.3–5.6, 17. +#### T19. Шесть задач -#### T29. Движок: типовые ошибки и пороги - -- **Цель:** неверный шаг получает код и место ошибки, подсказка — запрещённые фрагменты и пример. Сомнение в распознавании даёт тишину, а не ложную тревогу. +- **Цель:** шесть задач, по одной на класс 1–6, живут в коде, проверяются при старте и никогда не отдают ответ. Они разнообразны, чтобы проверить, как модель понимает разные записи решения (решение 5 E2). - **Сделать:** - - **Библиотека ошибок (spec 5.5):** `SIGN_ON_TRANSFER`, `ARITHMETIC`, `WRONG_INVERSE_OPERATION`, `COPY_ERROR`, `DIVISION_BY_ZERO`, `UNCLASSIFIED`. Каждое «ошибочное правило» применяется к последней верной строке и говорит, что передать в подсказку. - - **Ключ лестницы** `ladder_key` — из кода ошибки и её места (7.2). - - **Запрещённые фрагменты** для проверки подсказки (7.3): ответ, исправленная строка, исправленный член. - - **Пример третьей ступени** — на других числах, не совпадающих с запрещёнными (7.2, R24). - - **Пороги 5.7** — параметры конфигурации, значения по итогам T14: - - ниже порога оценки — `UNRECOGNIZED`; - - `ERROR` показывается только выше порога ошибки; - - между порогами — `UNRECOGNIZED`. -- **Тесты:** - - свойство: внедрённая ошибка каждого типа классифицируется своим кодом (spec 17); - - пример третьей ступени никогда не содержит запрещённых чисел; - - таблица порогов на границах. -- **Разделы:** spec 5.5–5.7, 7.2, 7.3, 17; R20, R24. - -#### T30. Распознавание: Mathpix + - **Список задач — сначала на вычитку автору,** до JSON. Например: + 1. счёт предметов с рисунком — целое с единицей; + 2. вычитание столбиком с переходом через разряд; + 3. столбы и промежутки — ловушка «на один меньше»; + 4. логика с именами — ответ-вариант (`choice`); + 5. дроби — ответ вида `5/12`; + 6. десятичные дроби или проценты. -- **Цель:** строка рукописи превращается в LaTeX и уверенность, а любой сбой поставщика — в `UNCHECKED`, а не в ошибку. -- **Сделать:** - - **Порт `Recognizer`** в домене; адаптер `internal/infra/mathpix` на `net/http`. - - **Запрос — строго по R18 и spec 10.3:** - - `strokes.strokes.x/y` — массивы массивов целых, без времени и идентификаторов; - - `metadata.improve_mathpix: false`; - - `confidence_threshold: 0`, `confidence_rate_threshold: 0`. - - **Ответы и сбои:** - - тайм-аут 3 с; - - `error_info` о низкой уверенности — `UNRECOGNIZED`; - - тайм-аут, 5xx и прочие ошибки — `UNCHECKED` с причиной `recognizer_unavailable` (9.9). - - **Конфиг:** `CANVAS_MATHPIX_APP_ID`, `CANVAS_MATHPIX_APP_KEY`; ключ не попадает в логи. - - **Облако:** секрет ключа Mathpix в Secret Manager и его передача сервису в `service_api.tf` — тем же путём, что секрет базы в T20. - - **Спайк:** удалить `spike/recognition/` — полезное уже в отчёте T14. + У каждой — условие на английском (R02), класс, тема, ключ и вид ответа, решение, ловушки с текстами, подсказка формы, `leak_phrases`. Ни один текст, который ребёнок видит до решения, не называет ключ (R24). + - **`tasks/*.json`** — формат spec 8.6 без `simpler_case`: лестницы в E2 нет. Поправка spec 8.6. + - **Загрузка через `go:embed` и проверка при старте:** схема, уникальные `id`, класс 1–6, обязательные поля; битая задача — сервис не стартует. Проверку ключа и ловушек белым списком добавит T66, текстов автора — T34. + - **Открытый вид задачи `TaskView`** — без ответа, решения, ловушек и `leak_phrases` (spec 8.6, 13). Своей ручки у задачи нет: список отдаст T18, уже за ключом. +- **Мой шаг:** вычитываю список и тексты задач. - **Тесты:** - - запрос сверяется с записанным синтетическим эталоном в `testdata/`; - - записанные ответы через `httptest`: успех, низкая уверенность, ошибка, мусор; - - fuzz на разборе ответа; - - страж: в запросе нет ничего, кроме координат. -- **Нагрузка (правило 14):** - - подделка Mathpix в инструменте: задержки по spec 14.1, 5xx, 429, тайм-ауты, мусор; - - адрес Mathpix — из конфигурации; с переопределённым адресом сервис при `K_SERVICE` не стартует, как прочие dev-переключатели; - - слоты вызовов — по решению Т-14; страж: распознаваний больше, чем слотов, — лишние получают `UNCHECKED` в срок, а не висят. -- **Я:** отказ от хранения данных на уровне аккаунта Mathpix (privacy 5.1); ключ — в секрет репозитория до мержа (правило 13). -- **Разделы:** spec 3.2, 5.7, 6.4, 9.9, 10.3; privacy 5.1; R18, R19, R20. - -#### T31. Задачи и холсты - -- **Цель:** ребёнок выбирает задачу и начинает её, а после перезагрузки SPA получает холст обратно — без ответа задачи. -- **Сделать:** - - **`GET /v1/tasks`** — список задач без `answer` (spec 8.2, 13), по ID token. - - **`POST /v1/canvases`** — `can_manage` на ребёнка и согласие `verified`: - - прежний активный холст этой задачи становится `abandoned`; - - новый холст и кортеж `child:{id} owner canvas:{cid}` — в одной транзакции (spec 9.2); - - ответ — холст и задача без `answer`; токены Centrifugo добавит T36. - - **`GET /v1/canvases/{canvas_id}`** — `can_read`: штрихи, вердикты и `last_seq` для восстановления (8.4). - - `canvas_id` берётся только из пути (13). -- **Тесты:** - - второй холст той же задачи бросает первый; - - чужой холст — `PERMISSION_DENIED`; - - страж: ответ задачи не уходит ни в одной ручке. -- **Нагрузка (правило 14):** `family` открывает холст и восстанавливает его через `GET /v1/canvases/{canvas_id}`; страж: два одновременных `POST /v1/canvases` одной задачи оставляют один активный холст. -- **Разделы:** spec 8.2, 8.4, 9.2, 9.7, 11, 12.3, 13. - -#### T32. Приём дельт - -- **Цель:** протокол дельт не теряет штрихи ребёнка — ни при второй вкладке, ни при обрыве сети, ни при сбое посреди запроса (R16). -- **Сделать:** `POST /v1/canvases/{canvas_id}/strokes`, часть приёма. - - **Пределы (spec 13):** тело до 512 КБ, до 300 штрихов в запросе, до 2000 точек в штрихе, до 300 штрихов на холсте (`PAYLOAD_TOO_LARGE`, `CANVAS_FULL`). - - **Короткая транзакция** под `SELECT … FOR UPDATE` (8.4): сверка `seq`, сохранение штрихов, запись в `canvas_deltas` со статусом `accepted`, сдвиг `last_seq`. - - **Таблица «порядок и повторы» 8.4 — целиком:** - - повтор получает сохранённый ответ; - - прерванная проверка в статусе `accepted` перезапускается; - - `SEQ_CONFLICT` с `details.last_seq`; - - `RESYNC_REQUIRED` с `details.expected_seq`. - - **Снимок** `full_snapshot` заменяет штрихи холста. Ластик ставит `removed_at`, а не удаляет строку. - - **Одна проверка на холст за раз:** строки «в проверке» (`steps` со статусом `pending` и `revision`) параллельный запрос не берёт. - - **Распознавание и проверка** идут вне блокировки (T33). Ответ дельты сохраняется в `canvas_deltas` со статусом `done`. + - каждая ошибка формата даёт отказ старта с понятной причиной; + - fuzz на разборе JSON; + - страж: в `TaskView` нет поля с ответом. +- **Как проверить:** я вычитываю задачи; `just ci-test`. +- **Разделы:** spec 8.6, 13; product 5; R02, R24; решение 5 E2. + +#### T66. Проверка ответа: нормализация и вердикт + +- **Цель:** верен ли ответ, решает программа — по тому, что модель прочла на листе, и по ключу задачи: точно, без ложной тревоги и без «дочитывания» ключа моделью (R37, R38; решение 3 E2). +- **Сделать:** `internal/domain/answer` — чистая логика. + - **Нормализация и белый список формы** — spec 5.2: `integer`, `number` (десятичная дробь с точкой и простая дробь), `choice`; `big.Rat` — только после белого списка. + - **Итог прочитан дважды:** `written` — как написано, с меткой, единицей и `=`; `value` — голое значение. `value` принимается, только если он виден в `written`, а остальное в `written` — метка ответа, единица задачи или `=`; иначе `UNREADABLE` · `ambiguous`. + - **Кандидаты и «ответа нет»:** другой кандидат с иным значением и «ответа нет» — свои причины `UNREADABLE` с подсказкой «Circle your answer» (product 5.5, T62). + - **Сравнение с ключом и ловушки** — spec 5.3. Ответ-ловушка с единицей («6 gaps») — `INCORRECT`, а не ошибка формы. + - **Вердикты** — таблица spec 5.4 для листа: пусто, неразборчиво, неоднозначно, не та форма, верно, неверно, сбой и потолок. Своя оценка модели вердикт не решает (R37). + - **Задачи при старте:** ключ и ловушки каждой задачи T19 проходят белый список своего вида; ловушки не равны ключу и друг другу. + - **Spec 5** — переписать под лист без поля ответа. - **Тесты:** - - все сценарии «Протокол» из spec 17: повтор, `SEQ_CONFLICT`, снимок после `RESYNC_REQUIRED`, прерванный запрос в статусе `accepted`, параллельные запросы к одному холсту, отклонённый штрих; - - fuzz на разборе дельты. -- **Нагрузка (правило 14):** - - сценарий `second_tab` — две вкладки пишут в один холст: `SEQ_CONFLICT`, перенумерация, ни одного потерянного штриха; - - сценарий `flaky_network` — клиент бросает запрос по своему сроку и повторяет тот же `client_event_id`: ни дублей, ни потерь; - - страж: параллельные дельты одного холста ждут блокировку не дольше срока (Т-15). Пока идёт проверка — здесь её заменяет медленная подделка, — ни соединение, ни блокировка не заняты (Т-12, Т-13). -- **Как проверить:** `just ci-integration`. -- **Разделы:** spec 3.1, 8.3, 8.4, 11, 13, 17; R16. - -#### T33. Проверка пути А - -- **Цель:** принятая дельта проверяется по правилам spec 4.3, 5.4 и 6.1. Верный шаг проходит молча; ошибка показывается только в законченной строке и только при уверенном распознавании. -- **Сделать:** - - **Цепочка:** распознаются только изменившиеся строки (Mathpix) → движок сравнивает строку с последней верной выше; первую строку — с условием. - - **Что проверяется (8.4):** - - `TRIGGER_PAUSE` — изменившиеся строки и строки ниже изменённой; нижние пересчитываются по сохранённому LaTeX, без распознавания; - - `TRIGGER_CHECK_BUTTON` — все строки без окончательного вердикта. - - **«Строка закончена» (4.3):** ребёнок начал другую строку, нажал Check или 6 секунд не трогал холст. До этого `ERROR` сохраняется, но не публикуется. - - **Маркеры и вердикты:** - - маркер снимается (`clear: true`), когда строку исправили или стёрли; - - `FOLLOWS_ERROR` для строк, верно выведенных из ошибочной; - - неизменившаяся строка получает сохранённый вердикт — повторный Check не гоняет её по кругу (5.3). - - **Завершение (9.7):** `completed`, `completed_at`, `task_completed`. - - **Реплики по Check** — по таблице 5.4: шаблонные фразы, без модели. - - **Ответ** — `SubmitStrokesResponse`; подсказок пока нет, их добавит T35. + - таблица примеров spec 5.6 — для листа; + - свойства на gopter из CLAUDE.md: нормализация устойчива; число в любой принятой записи равно ключу того же значения и только ему; белый список не берёт приставок систем счисления, экспонент, подчёркиваний и лишних нулей; никакой вход не паникует; + - свойство: `value`, которого нет в `written`, никогда не даёт `CORRECT`; + - fuzz. +- **Как проверить:** `just ci-test`; сверка `value` с `written`, выключенная в копии, краснит свой тест. +- **Разделы:** spec 5, 8.6, 17; product 5.5, 5.6; R37, R38; решения 1 и 3 E2. + +#### T34. Проверка вопроса: утечка и запасной текст + +- **Цель:** наводящий вопрос модели ребёнок видит, только если в нём нет ключа; иначе — текст автора или шаблон (R24; решение 4 E2). +- **Сделать:** `internal/domain/tutor`, минимум для E2. + - **Проверки вопроса** — пункты 2 и 4 spec 7.3: + - не больше двух предложений и 200 символов, без ссылок и разметки; + - нормализация: числа словами — в цифры, юникодный минус — в `-`; + - числовые токены сравниваются по значению: ключ запрещён в любой записи; запрещены и `leak_phrases`. + - **Запасной текст:** ловушка автора для этого ответа, иначе шаблон ступени 1 (spec 7.3). Лестницы и сверки с прошлыми вопросами в E2 нет. + - **При старте:** тексты ловушек, подсказки формы и шаблон проходят те же проверки для каждой задачи T19, иначе сервис не стартует. + - **Spec 6** — переписать под E2: разбор черновика — часть единственного вызова (spec 7, T35); маршрута после вердикта, второго вызова и лестницы нет; запасной текст — ловушка или шаблон. - **Тесты:** - - сценарии 9.3, 9.5 и строки таблицы 5.6 через HTTP, с записанными ответами Mathpix; - - ошибка в незаконченной строке не показывается, а после дописывания становится `CORRECT`; - - исправленная строка снимает маркер и пересчитывает нижние; - - сбой Mathpix — `UNCHECKED` и повтор при следующем триггере. -- **Нагрузка (правило 14):** - - `lesson` — путь А на синтетических штрихах от первой строки до `x = 3`. Подделка Mathpix узнаёт строку только по координатам, как их получает Mathpix (privacy 5.1): сценарий пишет каждую строку по шаблону штрихов, однозначно связанному с её LaTeX, и подделка сверяет шаблон — без побочного канала с id ребёнка. Ожидание — задача решена, ложных тревог нет, распознаваний на задачу не больше оценки product 9; - - `saturation` — детей больше, чем помещается в экземпляр: `UNAVAILABLE` и `UNCHECKED` считаются, жёстких фактов нет; - - `shutdown` — посреди `lesson`: начатые проверки доходят до `done` или остаются `accepted` и доводятся повтором (Т-12, Т-13); - - бюджет запроса — по решению Т-11. -- **Как проверить:** `just ci-integration`; задача А решается на записанных ответах от первой строки до `x = 3`. -- **Разделы:** spec 4.3, 5.4, 6.1, 8.4, 9.3, 9.5, 9.7, 9.9; product 5.2–5.7; R16, R17, R20. - -> **Точка проверки фазы 5.** Задача А решается через HTTP от первой строки до ответа. Ошибки из таблицы 5.6 находятся, ложных тревог на записанных ответах нет. - ---- - -### Фаза 6. Наставник и realtime - -#### T34. Наставник: лестница, проверки, шаблоны - -- **Цель:** подсказка, откуда бы она ни пришла, проходит одни и те же проверки, а при любом сбое ребёнок получает заготовленный вопрос (R24). -- **Сделать:** `internal/domain/tutor`. - - **Маршрутизация (spec 6.1):** когда нужна подсказка, а когда хватит шаблонной фразы. - - **Лестница (7.2):** ступень считается по `ladder_key` и числу подсказок на холсте; после третьей — `offer_help`. - - **Проверки (7.3):** - - схема ответа; `target_element` — из переданных; - - не больше 2 предложений и 200 символов, без ссылок и разметки; - - нормализация — числа словами в цифры, «take away 5» → `-5` — и сравнение числовых токенов с запрещёнными фрагментами. - - **Шаблоны (7.3):** по коду ошибки и ступени; шаблон третьей ступени — с примером движка; источник `template`. -- **Тесты:** - - утечки словами и цифрами ловятся (`minus five`, `three`), а `3` не совпадает с `13` и `3x`; - - пример третьей ступени проходит проверку; + - утечки словами и цифрами ловятся (`seven`, `minus five`, `5/12` против `10/24`), а `3` не совпадает с `13` и `3x`; - свойства на gopter для нормализации; - fuzz на проверке текста модели. -- **Разделы:** spec 6.1, 7.2, 7.3; product 5.4; privacy 10; R24. - -#### T35. Подсказки: Gemini - -- **Цель:** при ошибке ребёнок получает короткий наводящий вопрос от модели, а если модель не справилась — шаблон. Сам запрос не уносит ничего, кроме математики. -- **Сделать:** - - **Порт `Tutor`**; адаптер `internal/infra/gemini` на `google.golang.org/genai` с бэкендом `BackendVertexAI`, регион — из T09. - - **Модель и параметры (spec 7.1):** - - ID модели — из конфигурации; выбор (Т-5) — по итогам T14 и T38; - - `thinkingLevel: MINIMAL`, `maxOutputTokens: 1024`, тайм-аут 4 с; - - `responseMimeType` и `responseJsonSchema` (10.5); - - `MAX_TOKENS` — брак. - - **Промпт:** - - системная инструкция — файл `internal/domain/tutor/prompts/hint.system.md` с номером `prompt_version`; - - пользовательская часть собирается из результата движка — без идентификаторов и псевдонима (7.2, privacy 5.2). - - **Порядок внутри запроса (R23):** - 1. вердикт; - 2. вызов модели; - 3. проверки T34; - 4. сверка `revision` цели — подсказка к изменённой строке отбрасывается; - 5. запись в `hints` (модель, `prompt_version`, `latency_ms`); - 6. ответ. - - **Terraform:** роль вызова моделей аккаунту сервиса; API Agent Platform включила T10. Кеширование входов модели в проекте выключено (privacy 5.2) — способ сверить с документацией. - - **В лог** — `prompt_version` и ID модели, но не текст (spec 16). +- **Как проверить:** `just ci-test`; сравнение по значению, выключенное в копии, краснит свой тест; задача с ключом в тексте ловушки не стартует. +- **Разделы:** spec 6, 7.3, 17; product 5.4; privacy 10; R24. + +#### T67. Лист: геометрия, картинка, рамка + +- **Цель:** лист E2 — один экран, на котором решают где угодно, — одинаково понимают сервер, SPA и модель. Картинку сервер проверяет до вызова модели, а рамку модели переводит в координаты листа (R15, R43). +- **Сделать:** `internal/domain/canvas` — чистая логика. + - **Геометрия листа E2 (Т-16)** — в плане задачи: один экран без прокрутки, альбомный; размеры в логических единицах, размер PNG (одна единица — один пиксель) и толщина линии. Константы — один источник; клиенту их отдаёт `ListTasksResponse` (T18). + - **Проверка картинки** — spec 4.3 для одной картинки: + - предел объёма; + - `png.DecodeConfig`: точный размер, 8 бит; + - `png.Decode`; + - перекодирование в серый на белом фоне, без вспомогательных чанков; + - признак «лист пуст». + - **Рамка** — spec 4.4: `box_2d` 0..1000 → логический прямоугольник листа; годная — четыре целых 0..1000, `min < max`, площадь не больше порога. + - **Spec 4** — переписать под лист E2; Т-16 — в решённые. - **Тесты:** - - записанные синтетические ответы Gemini через `httptest`: хороший, с утечкой, с двумя вопросами, обрезанный JSON, `MAX_TOKENS`, тайм-аут; - - страж: в запросе к модели нет UUID и псевдонима; - - fuzz на разборе ответа. -- **Нагрузка (правило 14):** - - подделка Gemini в инструменте: задержки, `MAX_TOKENS`, утечки, тайм-ауты. Адрес модели переопределяется только вне Cloud Run; способ — по документации `genai` закреплённой версии; - - `lesson` со строкой-ошибкой: подсказка или шаблон приходят в бюджете запроса (Т-11), утечек нет; слоты вызовов — по Т-14. -- **Как проверить:** один живой вызов в prod-проекте — после предупреждения о стоимости. -- **Стоимость:** доли цента за вызов. -- **Разделы:** spec 7, 8.4, 9.4, 9.9, 10.5, 10.6; privacy 5.2, 10; R22, R23, R24. + - fuzz на проверке картинки — без паник и с ограниченной памятью; + - свойства на gopter: годный `box_2d` даёт прямоугольник внутри листа, рамка больше порога отбрасывается; + - снимок перекодированной картинки — с `-update`, новый diff — часть ревью. +- **Как проверить:** `just ci-test`; проверка размера и порог площади рамки, выключенные в копии, краснят свои тесты; числа геометрии — в spec 4 и в Т-16. +- **Разделы:** spec 4, 13, 17, 20 (Т-16); R15, R42, R43. -#### T36. Realtime: Centrifugo +#### T18. Контракты API и список задач -- **Цель:** маркер появляется быстрее секунды, подсказка догоняет его по WebSocket, и при разрыве ничего не теряется. +- **Цель:** контракт клиента и сервера — один источник правды в `proto/`, из которого генерируются Go и TypeScript (R12). Первая ручка `/v1` — список задач, за ключом. - **Сделать:** - - **`deploy/centrifugo/config.json`** — формат v6: - - пространство `canvas` из spec 8.5: история 20 событий, 600 с, `history_meta_ttl` 3600 с, `force_recovery`; - - без `allow_*`; `client.allowed_origins` — домен SPA. - - **Порт `Notifier`**, адаптер `internal/infra/centrifugo`: HTTP API `/api/publish` с `idempotency_key` по версии цели. Сбой публикации пишется в лог и не роняет ответ (9.9). - - **Токены (12.4):** - - HS256 — подключение и подписка на `canvas:{canvas_id}`, подписка только после `can_read`; - - выдаются в `POST /v1/canvases` и `POST /v1/realtime/token`. - - **Публикация по R23:** `verdict` — сразу после движка, `hint` — после модели, затем `task_completed`. HTTP-ответ повторяет всё. - - **Локально** — Centrifugo и Redis в compose по digest; интеграционный тест с настоящим сервером. - - **Облако:** - - Upstash в регионе T09, `rediss://`; - - сервис `service_centrifugo.tf` (15.3): 1 vCPU, 512 МиБ, параллельность 1000, тайм-аут 3600 с, session affinity; - - секреты: API-ключ, HMAC и URL Upstash; - - выкладка образа по digest в CD. + - **`proto/canvasapi/v1/*.proto`:** `TaskView` и `ListTasksResponse` с геометрией листа (T67). `CreateCheck*` придут с T70 — добавлением, без поломки совместимости. `buf.yaml`, `buf.gen.yaml`. + - **Генерация** — сгенерированное коммитится: + - Go — в `gen/` (`protoc-gen-go`); + - TS — в `web/src/gen/` (`@bufbuild/protobuf`, `protoc-gen-es`). + - **Инструменты:** buf и `protoc-gen-go` — `ARG` в стадии `toolchain`: хеш образа меняется, CI соберёт и подпишет его заново, контейнер пересобирается; `protoc-gen-es` — в `web/package.json`; расширение VS Code для buf точной версии. + - **Рецепты:** `proto` — генерация; `ci-proto` — `buf lint`, `buf breaking` против `main` и генерация без расхождений с закоммиченным; задание в `ci.yml`. + - **Сгенерированное не трогают:** Biome и покрытие Vitest — без `web/src/gen`; depguard не пускает protobuf и `gen/` в домен и ход проверки; SonarCloud и Codecov `gen` уже исключают — проверить. + - **`internal/transport/http`:** разбор и ответ `application/x-protobuf`; JSON по правилам protojson — только при `CANVAS_ENV=local` и никогда при `K_SERVICE`; ошибки — `apierror`. + - **`GET /v1/tasks`** за ключом T65 — открытый вид шести задач и геометрия листа. + - **Документы:** spec 8.2 — ресурсы E2; CLAUDE.md — `ci-proto` среди обязательных рецептов (правило 7 его уже называет); `just licenses`. +- **Мой шаг:** «Proto» — в обязательные проверки ruleset «main». - **Тесты:** - - токены: срок, `sub`, канал; на чужой холст подписаться нельзя; - - порядок событий; - - Centrifugo недоступен — ответ всё равно 200 с вердиктами. -- **Нагрузка (правило 14):** подделка HTTP API Centrifugo в инструменте — с задержкой и сбоями: ответ не ждёт публикацию дольше её срока (Т-11). `load.yml` гоняет ещё и настоящий Centrifugo из compose. -- **Я:** аккаунт Upstash; его URL — в секрет репозитория до мержа. API-ключ и HMAC-секрет Centrifugo генерирует `ci-tf-apply` (правило 13). -- **Как проверить:** `just ci-integration`; в prod `centrifuge-js` из консоли браузера получает `verdict` раньше HTTP-ответа. -- **Стоимость:** Upstash — бесплатный тариф. Cloud Run считает инстанс с открытым WebSocket активным (14.4); оценку даст T39. -- **Разделы:** spec 2, 8.5, 9.4, 9.9, 12.4, 14.3, 14.4, 15.2–15.5; R08, R10, R23. - -#### T37. Прогресс ребёнка - -- **Цель:** родитель видит минимум из О-14: какие задачи начаты и решены, сколько было подсказок, какие типы ошибок встречались, когда было последнее занятие. -- **Сделать:** `GET /v1/children/{child_id}/progress` с проверкой `can_manage`: - - агрегаты из `canvases`, `steps` и `hints`; - - пометку «дневной лимит исчерпан» (spec 14.5) добавит T45; - - без текстов подсказок и LaTeX. -- **Тесты:** агрегаты на холстах в разных статусах; чужой ребёнок — отказ. -- **Нагрузка (правило 14):** `family` читает прогресс; запрос агрегатов укладывается в свой срок и не держит соединение дольше него (Т-15). -- **Разделы:** product 6 (С6), 15 (О-14); spec 8.2, 11. - -#### T38. Eval-набор подсказок - -- **Цель:** модель и промпт подтверждены на наборе ситуаций, а не на глаз: подсказка не раскрывает ответ, в ней один вопрос, она по возрасту и по делу (spec 7.4). -- **Сделать:** - - **Набор** — синтетические ситуации «код ошибки × ступень × задача» с ожиданиями, в `testdata/eval/hints/`. - - **Прогон `just eval-hints`** — настоящая модель, автоматические проверки 7.3 и лист для ручной рубрики. В `ci-test` не входит. - - **Выбор** модели (Т-5) и `prompt_version`. - - **Отчёт** `docs/live/05-eval-hints.md` — доли по рубрике и утечкам. -- **Я:** ручная рубрика по выборке. -- **Как проверить:** критерии product 12 — не меньше 95 % подсказок проходят рубрику, ни одна не раскрывает ответ. -- **Стоимость:** ≈ $0.001 за вызов; сотни вызовов — меньше доллара. -- **Разделы:** spec 7.4, 17, 20 (Т-5); product 12; R22, R24. - -#### T39. Спайк S3: Centrifugo на Cloud Run - -- **Цель:** до беты узнать, во что обходятся открытые WebSocket и служебные команды Upstash. -- **Сделать:** - - **Генератор трафика** в `spike/realtime/` — подключения, подписки, переподключения и публикации по сценарию занятия. - - **Среда одна — prod (R32), поэтому генератор изолирован от данных:** - - ходит прямо в Centrifugo: токены подписывает сам секретом HMAC, публикует через HTTP API; - - работает как Cloud Run job в том же проекте и берёт секреты из Secret Manager, как сервисы: на машину разработчика они не попадают; - - canvas-api, Neon и данные родителей не трогает; - - каналы `canvas:spike-…` исчезают сами по сроку истории — 10 минут, мета-ключи — через час (spec 8.5); - - у генератора — потолок подключений и публикаций. Расход команд Upstash считается до запуска: бесплатные лимиты и бюджетное оповещение общие для всего платёжного аккаунта. - - **Неделя работы в prod.** - - **Отчёт** `docs/live/06-spike-realtime.md`: стоимость инстанса, расход команд Upstash, восстановление истории. -- **Как проверить:** критерий S3 (spec 20) — стоимость и команды в пределах бюджета беты. Иначе — меры из 14.4 и вопрос. -- **Стоимость:** назвать до запуска; ожидается в пределах бесплатных лимитов и бюджета. -- **Разделы:** spec 14.3, 14.4, 20. - -> **Точка проверки фазы 6.** При ошибке маркер приходит по WebSocket, следом — подсказка. Eval подсказок пройден, стоимость realtime измерена. - ---- - -### Фаза 7. Путь Б - -#### T40. Путь Б: разбор связей - -- **Цель:** стрелки между объектами разбирает программа — точнее, дешевле и быстрее модели (R21). -- **Сделать:** `internal/domain/diagram` по spec 6.2. - - **Отбор штрихов:** отбрасываются короче 40, лежащие внутри зоны одного объекта и отдельные наконечники до 120. - - **Концы и объекты:** рамка объекта, зона +30; при разнице расстояний меньше 20 конец неоднозначен. - - **Связи:** однозначные связи и неоднозначные штрихи. - - **Сравнение с `answer.pairs`:** `WRONG_CONNECTION`, `DUPLICATE_CONNECTION`. - - **Для подсказки:** маркер — рамка штриха; `ladder_key` — по объекту (7.2); запрещённый фрагмент — правильная пара объекта (7.3). + - protobuf и JSON при `CANVAS_ENV=local` дают одно и то же; + - JSON при `K_SERVICE` отклоняется; + - fuzz на разборе тела; + - в ответе `GET /v1/tasks` нет ни одного поля с ответом задачи. +- **Как проверить:** `just ci-proto`; PR, ломающий совместимость поля, падает на `buf breaking`; после мержа `GET /v1/tasks` без ключа — 401, с ключом — шесть задач. +- **Разделы:** spec 3.1, 3.2, 8.1–8.3, 8.6, 13, 19; R12. + +#### T68. SPA: оболочка — ключ, задачи, адрес API + +- **Цель:** SPA в prod знает, где API, пускает автора по ключу и показывает задачи. Это каркас, в котором дальше появятся лист, проверка и журнал. +- **Сделать:** + - **Клиент API** на типах из `web/src/gen/` (T18): protobuf, `Authorization` с ключом; на 401 — снова спросить ключ. + - **Ввод ключа** — один раз; ключ лежит в `localStorage`, каждое чтение и запись — в `try/catch`. + - **Задачи** из `GET /v1/tasks`: условие и класс, переход к следующей. + - **Адрес API:** + - в сборке для prod — константа в `web/src`: адрес уже назван в spec 15.1, а `.env.*` — в `.gitignore`. Выкладка сверяет его с `service_url`: неверный адрес не уходит зелёным, ведь `ci-hosting-check` сверяет только страницу; + - в `just web-dev` — `http://localhost:8080`, `just run` (по `import.meta.env.DEV`): prod CORS пускает только `web.app`, а локальный сервис — `http://localhost:5173` (T65). + - **`firebase.json`** — CSP: `connect-src` — адрес API, `frame-ancestors 'none'`. + - Зависимости — точных версий, их лицензии — в списке (`just licenses`). - **Тесты:** - - таблица случаев из 6.2 на задаче spec 8.6; - - свойства на gopter: сдвиг конца внутри объекта не меняет связь, направление стрелки не важно; - - fuzz на штрихах. -- **Разделы:** spec 6.1, 6.2, 7.2, 7.3; R21. + - клиент на подделанном `fetch`: ключ в заголовке, 401 просит ключ заново; + - ключ переживает перезагрузку, а сбой `localStorage` не роняет страницу. +- **Как проверить:** `just ci-web`; `just web-dev` против `just run`; после мержа SPA в prod с ключом показывает шесть задач. +- **Разделы:** spec 3.1, 8.1, 15.1, 15.5; R10, R13, R47, R55; решение 6 E2. -#### T41. Путь Б: рендер и vision +#### T48. SPA: лист -- **Цель:** неоднозначный рисунок распознаёт vision-модель, вердикт выносит программа, а сбой модели даёт тишину, а не ложную ошибку (R21). +- **Цель:** на компьютере пишут на листе как в тетради — мышью или пером графического планшета, — лист одинаково ложится на любом экране и рисуется в PNG так, как его ждёт сервер. - **Сделать:** - - **Рендер** — `internal/infra/render` на `github.com/tdewolff/canvas` (псевдоверсия): PNG области задачи по spec 6.3, сетка — по решению Т-6 из T15. - - **Адаптер vision** в `internal/infra/gemini`: - - `gemini-3.8-flash`, `thinkingLevel: LOW`, `maxOutputTokens: 4096`, тайм-аут 8 с; - - схема ответа с перечислениями (10.9); эталон в запрос не попадает. - - **Сверка и память:** номер штриха сверяется по `box_2d` (4.4); интерпретация сохраняется в `strokes.interpretation`, и повторно модель не зовут. - - **Сбой** — `UNCHECKED` с причиной `vision_failed`, повтор при следующей проверке; задача не засчитывается (6.1, 9.9). - - **Оркестрация** — строки пути Б таблицы 6.1; подсказка идёт тем же путём, что в T35. - - **Спайк:** удалить `spike/vision/`. + - **Konva:** слой записи и слой подсказок (product 5.1); подсказки не закрывают запись. + - **Лист по геометрии сервера** (T67, из `ListTasksResponse`): логические единицы, масштаб одинаковый по обеим осям, поля по краям (spec 4.1). + - **Pointer Events:** мышь и перо; нажим меняет только толщину на экране; `touch-action: none`. Перо и ладонь на iPad и Android — E15 (Д21). + - **Инструменты:** перо, ластик целым штрихом (R42), очистка. Штрих длиннее предела закрывается, и начинается новый; координаты прижимаются к листу. + - **PNG из штрихов** — spec 4.3: белый фон, чёрные линии постоянной толщины, одна единица — один пиксель. Что рисовать — размер, линии и их толщина — считает чистая функция, отдельно от холста: в happy-dom, где идут тесты SPA, `getContext` без адаптера возвращает `null`, и пиксели там не проверить. + - Зависимости: `konva` и `react-konva` точных версий, их лицензии — в списке. - **Тесты:** - - записанные синтетические ответы vision: верный, номер не сошёлся, обрезанный JSON, тайм-аут; - - рендер — снимком, которым владеет сам сервис: golden с `-update`, новый diff — часть ревью; - - страж: в запросе нет ответа задачи. -- **Нагрузка (правило 14):** сценарий `diagram` — путь Б с неоднозначными штрихами и подделкой vision: пик памяти рендера под пределом контейнера; слоты рендера и vision — по Т-14. -- **Разделы:** spec 4.4, 6.1, 6.3, 9.6, 9.9, 10.9; R21. - -#### T42. Eval-набор пути Б - -- **Цель:** точность интерпретаций на неоднозначных рисунках подтверждена набором (spec 17). -- **Сделать:** набор из T15, дополненный рисунками, на которых vision ошибался; прогон `just eval-diagram`; отчёт `docs/live/07-eval-diagram.md`. -- **Как проверить:** не меньше 90 % верных интерпретаций (S2); ни одной ложной `WRONG_CONNECTION` из-за модели. -- **Стоимость:** центы. -- **Разделы:** spec 17, 20. + - пересчёт координат туда и обратно; + - ластик удаляет штрих целиком; длинный штрих режется; + - картинка проверки — размер и постоянная толщина линии (spec 17) — на чистой функции рисования. Проверять ли сами пиксели — адаптером canvas для happy-dom или в браузере — решает план задачи. +- **Как проверить:** `just ci-web`; лист в prod — мышью и пером графического планшета. +- **Разделы:** spec 3.1, 4.1–4.3, 17; product 5.1, 8; R13, R15, R42. -> **Точка проверки фазы 7.** Задача Б решается. Неоднозначные рисунки разбирает vision, а сбой модели не даёт ложной ошибки. +> **Точка проверки части 2.** В prod SPA по ключу показывает шесть задач и лист, на котором пишут мышью или пером графического планшета. Домен проверки — ответ, вопрос, картинка и рамка — покрыт тестами, контракт `proto/` на месте. --- -### Фаза 8. Приватность и эксплуатация - -#### T43. Удаление профиля и аккаунта - -- **Цель:** родитель удаляет профиль ребёнка или весь аккаунт в один шаг, и повтор после любого сбоя доводит удаление до конца (R27). -- **Сделать:** - - **`DELETE /v1/children/{child_id}`** — `can_manage` и свежий вход. Порядок spec 9.8: - 1. Neon — каскадом; - 2. кортежи холстов и ребёнка — пачками до 100, отсутствующие пропускаются. - - Повтор отвечает 204, а не 404. - - **`DELETE /v1/me`:** - 1. всё то же для каждого ребёнка; - 2. `parents`; - 3. `RevokeRefreshTokens`; - 4. `DeleteUser`. - - `parent_usage_daily` не удаляется вместе с ребёнком (14.5). +### Часть 3. Проверка и журнал + +Деньги здесь стоят только живые проверки T35, T70, T71 и T73 — по одной-две, каждая после оценки и моего подтверждения (правило 5). + +#### T35. Gemini: адаптер и промпт + +- **Цель:** один вызов Gemini читает весь лист — находит итоговый ответ, описывает ход рассуждения и первую ошибку, задаёт вопрос — и сообщает, сколько это заняло и стоило (решения 3 и 8 E2). +- **Сделать:** + - **Порт** объявляет потребитель — `internal/check`: здесь только интерфейс и его типы, сам ход проверки — T69. Адаптер `internal/infra/gemini` на `google.golang.org/genai` — бэкенд Vertex AI, регион `us-east4` (R54) — возвращает этот порт (CLAUDE.md, Interfaces). + - **Три варианта промпта — сразу**, файлами со своим `prompt_version`: + - с ключом — живое приложение; + - без ключа — модель ещё и решает задачу сама: мера понимания задачи; + - только чтение — первый вызов двухвызовной схемы R38: его цена и задержка. + + Где лежат промпты, решает план задачи. + - **Схема ответа** (`responseJsonSchema`): + - шаги: вид (вычисление, рисунок, пометка, зачёркнутое), текст, смысл, оценка (верно, неверно, неясно), `box_2d`; + - итог: `written`, `value`, `box_2d`, разборчивость, другие чтения, другие кандидаты, «ответа нет»; + - первая ошибка: шаг, тип, `box_2d`, объяснение; + - вопрос, своя оценка, замеченная надпись-инструкция; в варианте без ключа — свой ответ модели. + - **Параметры:** + - ID модели — из конфигурации, точный (R22); в живом приложении — Flash; + - уровень размышлений — всегда явно: у Pro нет `minimal`; + - `media_resolution`; + - лимит выхода ≈ 8192 токенов — чтобы `MAX_TOKENS` не выглядел ошибкой модели; + - тайм-аут — ниже 30 с запроса Cloud Run (`variables.tf`), с запасом на остальное. + - **Учёт:** `usage_metadata` целиком — вход (и токены картинки), выход, размышления, `cachedContentTokenCount`, равный 0 при выключенном кеше (T64); `modelVersion`, `finishReason`. Цена — по датированной таблице цен моделей; размышления — по цене выхода. + - **Конфигурация:** `CANVAS_GEMINI_*` — проект, регион, модель, размышления, картинка, вариант промпта — в `internal/config` и Terraform (правило 13). + - **Документы:** spec 7 и примеры 10.3–10.6 — под один вызов; `just licenses`. - **Тесты:** - - сбой на каждом шаге 9.8 и повтор, который доводит удаление до конца (spec 17); - - старый токен после удаления аккаунта не создаёт `parents` заново — работает проверка отзыва. -- **Нагрузка (правило 14):** `family` в конце удаляет ребёнка, а затем аккаунт; страж: удаление ребёнка во время приёма дельты его холста — ни 500, ни осиротевших строк и кортежей. -- **Как проверить:** `just ci-integration`; сценарий 9.8 через HTTP. -- **Разделы:** spec 9.8, 12.1, 17; privacy 7; R14, R27. - -#### T44. Ежедневная очистка - -- **Цель:** данные живут не дольше сроков privacy 8 — без ручной работы. -- **Сделать:** - - **`cmd/cleanup`** (spec 11, privacy 8): - - помечает брошенные холсты — 24 часа без изменений; - - удаляет штрихи и дельты старше срока (Т-9, по умолчанию 30 дней после `completed_at` или `abandoned_at`); - - удаляет счётчики старше 30 дней и истёкшие инвайты; - - удаляет «висящие» кортежи OpenFGA без строк в Neon; - - удаляет записи `changelog` базы `openfga` старше суток; - - удаляет профили, неактивные 12 месяцев. - - **Cloud Run job `cleanup` и Cloud Scheduler** — в Terraform; образ приносит CD. - - **Идемпотентность:** повторный прогон ничего не ломает. -- **Тесты:** каждое правило на подготовленных данных; граница сроков; повторный прогон. -- **Нагрузка (правило 14):** страж: очистка идёт во время приёма дельт — ни одного потерянного подтверждённого штриха, а блокировку холста очистка ждёт не дольше срока (Т-15). -- **Как проверить:** `just ci-integration`; ручной запуск job в prod — в логе число удалённого по видам, без идентификаторов. -- **Разделы:** spec 9.7, 11; privacy 7, 8, 12 (П-6); R27. - -#### T45. Лимиты - -- **Цель:** бета не выходит за бюджет: дневные лимиты на ребёнка и родителя и лимит частоты проверок (spec 14.5, О-13). -- **Сделать:** - - **Счётчики** `usage_daily` и `parent_usage_daily` (сутки — UTC) — для распознаваний, подсказок и vision. Потолок родителя считается по всем детям и не сбрасывается удалением ребёнка. - - **Исчерпанный лимит не отклоняет дельту и не прерывает занятие (R35):** - - штрихи сохраняются; - - строки получают `UNCHECKED` с причиной `limit`; - - ребёнок решает дальше сам, без проверки, и видит фразу из таблицы spec 5.4. - - **Частота** — не больше 2 проверок в секунду на холст: счётчик в памяти инстанса, `LIMIT_EXCEEDED`; middleware — на своём месте в цепочке 8.1. - - **Значения** — `CANVAS_LIMIT_*` (15.4). - - **Прогресс** — пометка «дневной лимит исчерпан» в `GET /v1/children/{child_id}/progress` (T37, spec 14.5). + - записанные синтетические ответы через `httptest`: хороший, без итога, с двумя кандидатами, обрезанный JSON, `MAX_TOKENS`, тайм-аут; + - страж: в запросе нет ничего, кроме картинки и данных задачи (privacy 5.1); + - fuzz на разборе ответа; + - цена считается по таблице. +- **Как проверить:** один живой вызов — после оценки стоимости и моего подтверждения (правило 5): учётными данными gcloud из контейнера, на синтетической картинке. Доступность Flash в `us-east4` — по документации на день задачи. +- **Стоимость:** доли цента за вызов. +- **Разделы:** spec 3.2, 7, 10.3–10.6, 15.4, 16, 17; privacy 5; R22, R38, R39, R54; решения 3 и 8 E2. + +#### T69. Ход проверки + +- **Цель:** одна проверка от картинки до результата идёт через порты, без ввода-вывода в самом пакете, а сбой модели или своего кода никогда не превращается в «неверно» (product 5.6). +- **Сделать:** `internal/check`. + - **Порядок** — как spec 5.4 и 8.4: + 1. картинка (T67): негодная — отказ, потолок не тронут; + 2. исчерпан потолок — `UNCHECKED` · `limit`, и с пустым листом тоже; + 3. пустой лист — `UNREADABLE` без вызова и без резерва; + 4. резерв и вызов модели (T35); + 5. вердикт (T66); при `INCORRECT` — рамка (T67) и проверка вопроса (T34); + 6. результат. + - **Дневной потолок** — счётчик в памяти экземпляра, как `internal/ratelimit` образца: резерв берётся до вызова и возвращается, если вызова не было; сделанный вызов засчитывается и при сбое (R35); исчерпан — `UNCHECKED` · `limit`. Значение приходит из конфигурации (T70). + - **Метрики:** время каждого этапа и модели, токены, цена, модель, `prompt_version`; признак первого запроса экземпляра — холодный старт виден в журнале. + - **Границы паник:** паника в `answer` — `UNCHECKED` · `internal`; в `tutor`, после вердикта, — запасной текст (CLAUDE.md, Errors). + - **depguard** держит пакет без `net/http`, gin, `genai` и `gen/`. +- **Тесты** — на рукописных подделках порта: + - каждый путь таблицы вердиктов; + - пустой лист и потолок модель не зовут; + - потолок под параллельными проверками не превышается и возвращает резерв (`-race`); + - сбой модели — `UNCHECKED`, а не `INCORRECT`; + - паника в каждом пакете. +- **Как проверить:** `just ci-test`; резерв, который в копии не возвращается, краснит тест потолка, а вызов модели на пустом листе — свой тест; `just ci-lint` — depguard не пускает в пакет ввод-вывод. +- **Разделы:** spec 5.4, 5.5, 6.1, 8.4, 14.5; product 5.6; R35, R37; решения 3 и 6 E2. + +#### T70. Ручка проверки, лог и подделка разбора + +- **Цель:** `POST /v1/checks` в prod принимает PNG листа и отвечает вердиктом программы, рамкой, вопросом, разбором модели и метриками, а лог проверки говорит только то, что ему разрешено. +- **Сделать:** + - **Контракт** — добавлением в `proto/` (T18): `CreateCheckRequest` (`client_check_id`, `task_id`, PNG листа) и `CreateCheckResponse` (вердикт, подсказка с рамкой, разбор модели для панели автора, метрики). + - **Ручка** за ключом: тело — не больше своего предела (spec 4.3, 13); ошибки — `INVALID_ARGUMENT` и `PAYLOAD_TOO_LARGE`; сбой модели — не HTTP-ошибка, а вердикт (CLAUDE.md, Errors). + - **Сборка в контейнере:** адаптер Gemini (T35), задачи (T19), потолок; `CANVAS_LIMIT_CHECKS_DAILY` — в `internal/config` и Terraform (правило 13). + - **Подделка разбора** — dev-переключатель, который не стартует при `K_SERVICE`: локальная проверка без трат, на ней разрабатывается SPA (T71–T73). + - **Лог:** событие проверки — ID проверки и задачи, вердикт и причина, тайминги, токены, цена, модель, `prompt_version`. Таблица «событие → разрешённые поля» и тест аудита — как `internal/report/events.go` образца (standalone: R141, R169): поле, о котором никто не решал, валит тест. + - **Spec 8.2, 8.4 и 16** — ручка и лог E2. - **Тесты:** - - каждый лимит на границе; - - удаление и пересоздание ребёнка не снимает потолок родителя; - - дельта при исчерпанном лимите сохраняется. Любой исчерпанный лимит — распознаваний, подсказок или vision, ребёнка или родителя — останавливает проверку целиком: ни Mathpix, ни Gemini, ни маркера, ни подсказки, ни `task_completed`; - - после обновления лимита следующая проверка, в том числе по паузе, начинает со строк `UNCHECKED` с причиной `limit`, сверху вниз; - - прогресс показывает пометку об исчерпанном лимите. -- **Нагрузка (правило 14):** сценарий `limits` — жадный ребёнок и остальные: у остальных всё проходит, у жадного — `LIMIT_EXCEEDED` и `UNCHECKED` с причиной `limit`; страж: счётчики `usage_daily` и `parent_usage_daily` не теряют прибавлений при параллельных проверках. -- **Разделы:** spec 5.4, 8.4, 13, 14.5, 15.4; product 5.6, 9; R16, R35. - -#### T46. Наблюдаемость и аудит логов - -- **Цель:** метрики MVP видны, алерты настроены, а в логах и трассах нет персональных данных (product 12, критерий 7). -- **Сделать:** - - **Метрики** spec 16 — с закрытыми словарями меток; спаны внешних вызовов: OpenFGA, Neon, Mathpix, Gemini, Centrifugo. - - **Алерты** spec 16 в Terraform — 5xx, p95 до маркера, доля шаблонных подсказок, отказы Mathpix, бюджет. С оценкой стоимости политик на метриках. - - **Тест аудита:** полный сценарий 9.1–9.8 с перехватом логов → нет email, псевдонима, LaTeX, текстов подсказок и токенов. - - **Журналы запросов Cloud Run с IP-адресами** — решение по П-9 и его настройка в Terraform. - - **`just report`** — агрегаты из Cloud Logging для метрик беты (product 12). -- **Как проверить:** - - после прогона сценария метрики видны в Cloud Monitoring; - - алерт срабатывает на тестовом условии; - - аудит зелёный. -- **Стоимость:** назвать цену алертов на метриках. -- **Разделы:** spec 13, 16; product 12; privacy 4, 12 (П-9). - -#### T46a. Нагрузка: калибровка чисел развёртывания - -- **Цель:** числа экземпляра, пулов, слотов и сроков сведены друг с другом на готовом сервере — до SPA и беты (как T52b standalone). -- **Сделать:** - - **Прогон** — все сценарии против образа под ограничениями экземпляра. Между сервисом и Postgres — прокси с задержкой, как у Neon (spec 14.1): без неё блокировка холста и пул выглядят лучше, чем будут в prod. - - **Свести числа, которые до сих пор друг друга не видели:** - - параллельность 40 и `max-instances` (spec 15.3) — с пулом pgx, пулом OpenFGA и лимитом соединений Neon, прямых и через pooler (Т-15); - - слоты Mathpix, Gemini, vision и рендера и их ожидания (Т-14) — с квотами поставщиков и памятью экземпляра; - - CPU и память api и openfga внутри 1 vCPU и 1 ГиБ: худший законный расход рендера на слот, умноженный на число слотов; - - бюджет запроса дельты — с тайм-аутом Cloud Run и сроком клиента (Т-11). - - **Результат** — в `infra/terraform/variables.tf`, в умолчаниях `CANVAS_*` (spec 15.4), в spec 14 и 15.3 и записью в `docs/decisions.md`. Отчёт с числами — `docs/load.md`, на него ссылается T54. Не в `docs/live/`: прогон идёт на раннере, а не в облаке. -- **Как проверить:** подложенная регрессия ловится — транзакция приёма дельты, растянутая на распознавание, даёт `UNAVAILABLE` от исчерпанного пула в `saturation` и запросы без ответа в `second_tab`. -- **Стоимость:** $0. -- **Разделы:** spec 8.4, 14, 15.3, 15.4; product 9; R23; standalone: T52b. - -#### T47. Ревью кода сервера - -- **Цель:** код сервера прочитан на ошибки и по стандартам CLAUDE.md до того, как на нём начнёт строиться SPA (как T39a standalone). -- **Сделать:** по каждому пакету: - - **`/code-review high <папка пакета>`** — пакет целиком, с тестами; не больше трёх прогонов на пакет. - - **Таблица «правило CLAUDE.md → соблюдено или где нарушено»:** - - правило зависимостей, интерфейсы, простота; - - имена и размер, ошибки, контекст, документация; - - тесты — fuzz, свойства, `t.Helper`, «got …, want …». - - **Находки:** ошибка исправляется вместе с тестом, который без исправления падает; отступление от стандарта — в `docs/decisions.md`. - - **Разбивка** на части по группам пакетов (правило 12): домен; адаптеры; транспорт и каркас. -- **Не входит:** безопасность (T55). Нагрузку проверяют стражи задач (правило 14) и T46a — до этого ревью. -- **Стоимость:** лимиты подписки Claude на прогоны ревью. -- **Разделы:** CLAUDE.md (Architecture and code standards). - -> **Точка проверки фазы 8.** Удаление, очистка, лимиты и наблюдаемость на месте; числа развёртывания сведены нагрузкой; код сервера прочитан. - ---- - -### Фаза 9. SPA - -#### T48. SPA: холст - -- **Цель:** ребёнок пишет на планшете как в тетради: перо в приоритете, ладонь не рисует, запись одинаково ложится на любом экране. -- **Сделать:** - - **Три слоя Konva** — `TaskLayer`, `DrawingLayer`, `HintLayer` (product 5.1); подсказки никогда не закрывают запись. - - **Логическая страница и масштаб (spec 4.1)** — одинаково по обеим осям, поля по краям. - - **Pointer Events (3.1):** - - перо и палец, нажим; - - «режим пера» после первого пера; - - касания больше 30 px — ладонь; - - `touch-action: none`. - - **Штрихи:** - - штрих длиннее 2000 точек закрывается и начинается новый; - - координаты прижимаются к странице; - - ластик удаляет штрих целиком. - - **Зависимости:** типы из `web/src/gen/` (T18); konva и react-konva точных версий, их лицензии — в списке. + - fuzz тела запроса; + - HTTP-тесты на подделке: каждый вердикт, потолок, негодная картинка, запрос без ключа; + - аудит лога: ни в одной строке нет прочитанного ответа, текстов и картинок; + - подделка при `K_SERVICE` не стартует. +- **Как проверить:** `just run` с подделкой и `curl` с PNG; после мержа — одна живая проверка в prod своим ключом, после оценки стоимости (правило 5). +- **Стоимость:** доли цента за живую проверку. +- **Разделы:** spec 4.3, 8.2–8.4, 13, 16; CLAUDE.md (Logging, Errors); решения 3, 6 и 9 E2. + +#### T71. SPA: Send Answer, результат, Next + +- **Цель:** ребёнок отправляет лист, видит, что прочитано, а при ошибке — рамку на своей записи и вопрос; затем — следующая задача (решение 4 E2). +- **Сделать:** + - **Send Answer:** PNG листа (T48) и `POST /v1/checks` с новым `client_check_id`. Кнопка недоступна, пока лист пуст или проверка в полёте. + - **Карточка результата** — фразы product 5.5: + - «Correct! I read “7”.» и решение; + - «Not yet. I read “6”.» и вопрос — ответ задачи не показывается; + - неразборчиво — просьба переписать или «Circle your answer»; + - сбой и потолок — нейтральные фразы. + - **Рамка** — по штрихам ребёнка (spec 4.4, R43): из штрихов, что были на листе при отправке, берутся те, у которых не меньше половины точек в рамке с отступом; их общая рамка — на слое подсказок. + - **Дальше:** Next task — чистый лист и следующая задача, после шестой — первая; Send again — при неразборчивом ответе и сбое; выбор задачи 1–6 — для автора. - **Тесты:** - - пересчёт координат туда и обратно (spec 17); - - отсечение ладони и режим пера на синтетических событиях; - - разрезание длинного штриха. -- **Как проверить:** `just ci-web`; iPad с Pencil и Android-планшет через туннель `cloudflared`. -- **Разделы:** spec 3.1, 4.1, 4.2; product 5.1, 8; R13, R15. - -#### T49. SPA: синхронизация и подсказки + - привязка рамки к штрихам (spec 17); + - машина состояний: отправка, ответ, сбой сети, Next; + - фразы карточки по вердиктам. +- **Как проверить:** `just ci-web`; против подделки разбора (T70) — каждый вердикт; в prod — одна живая проверка своим ключом, после оценки стоимости и моего подтверждения. +- **Стоимость:** доли цента за живую проверку. +- **Разделы:** spec 4.4, 5.4, 8.4; product 5.5, 5.6, 8; R43; решение 4 E2. + +#### T72. SPA: журнал и выгрузка + +- **Цель:** каждая проверка остаётся на устройстве автора со всем, что нужно eval и исследованию векторов, и выгружается файлом, который Go читает без сюрпризов (решения 7 и 8 E2). +- **Сделать:** + - **Запись журнала** — IndexedDB, каждое обращение в `try/catch`: + - PNG листа; + - штрихи как есть: точки со временем, тип указателя, нажим и наклон (модели они не уходят); журнал правок и штрихи на момент отправки; + - геометрия листа и масштаб экрана; + - ответ сервера целиком и времена клиента; + - все версии: приложение, сервер, промпт, задача и её отпечаток. + - **«Сохранить без проверки»** — лист попадает в журнал без вызова модели: лишние образцы для eval без трат. + - **Выгрузка** — архив: манифест, записи по контракту из `proto/`, PNG. Контракт версионирован; разбор на Go — пакет, который читает eval (T74), с fuzz. + - **Образцы — вне репозитория** (privacy 4): `/samples/` — в `.gitignore` и `.dockerignore`. +- **Тесты:** + - запись и чтение журнала: хранилище — за интерфейсом, в тестах — в памяти; + - выгрузка и разбор на Go дают ту же запись; + - fuzz разбора; + - запись без проверки не зовёт API. +- **Как проверить:** `just ci-web`, `just ci-test`; в prod выгрузка нескольких проверок читается разбором на Go. +- **Разделы:** spec 4.2, 17; privacy 4, 5.3; R42; решения 7–9 E2. + +#### T73. SPA: панель автора и разметка + +- **Цель:** после каждой проверки автор видит, что и как поняла модель, сколько это длилось и стоило, и размечает лист так, чтобы разметка не подстраивалась под ответ модели (решения 8 и 9 E2). +- **Сделать:** + - **Панель автора:** + - время клиента (нажатие → PNG → ответ), сервера по этапам и Gemini; холодный старт; + - токены — вход и картинка, выход, размышления; цена; модель и `prompt_version`; + - разбор модели: шаги, итог, кандидаты, первая ошибка, вопрос, своя оценка. Ответ задачи в разборе — исключение E2 (решение 9, Д17). + - **Разметка до отправки:** вариант сценария, задуманный итоговый ответ, шаг и значение ошибки, ожидаемый вердикт. + - **Пометка штрихов при письме:** переключатели «ответ» и «ошибка» — без пометок задним числом, глядя на рамку модели. + - **После ответа:** понят ли ход — да, частично или нет; заметка. + - **Скрыть панель:** так собирается тестовая часть образцов (T75), без подсказки от модели. + - Всё размеченное — в запись журнала (T72). +- **Тесты:** + - разметка сохраняется в запись; + - помеченные штрихи попадают в запись со своими `id`; + - скрытая панель не показывает разбор. +- **Как проверить:** `just ci-web`; одна проверка в prod с полной разметкой и выгрузкой — после оценки стоимости и моего подтверждения. +- **Стоимость:** доли цента за живую проверку. +- **Разделы:** product 12; spec 7.4, 17; решения 8 и 9 E2. -- **Цель:** запись ребёнка не теряется ни при обрыве сети, ни при второй вкладке, а маркер и подсказка появляются там, где нужно. -- **Сделать:** - - **Клиент протокола (spec 8.4):** - - пауза 1,5 с и кнопка Check; не больше одного запроса в полёте на холст; - - `seq` и `client_event_id`; неподтверждённые дельты — в памяти и IndexedDB; повтор с тем же `client_event_id`; - - перенумерация после `SEQ_CONFLICT`, снимок после `RESYNC_REQUIRED`; - - восстановление после перезагрузки из `GET /v1/canvases/{id}`; - - виноватый штрих выбрасывается на 400 и 413. - - **centrifuge-js:** - - токены через `getToken`, восстановление истории; - - событие применяется, только если его `revision` не меньше показанной; - - сверка с HTTP-ответом по `hint_id`. - - **Слой подсказок:** - - подсветка и текст вопроса; стиль — по типу действия (R24); снятие по `clear`; - - `offer_help`; - - поздравление и выбор «следующая задача» или «решить заново» (О-16). - - **Соединение** закрывается через 5 минут бездействия (14.4). -- **Тесты:** машина состояний протокола на подделанном сервере — повтор, конфликт, снимок, обрыв; применение событий по `revision`. -- **Разделы:** spec 3.1, 8.4, 8.5, 9.3–9.7, 14.4; product 5.3–5.7; R16, R23. - -#### T50. Тексты для родителей - -- **Цель:** у согласия есть уведомление, а у сервиса — политика конфиденциальности и условия. Тексты честные, простые и совпадают с тем, что сервис делает на самом деле. -- **Сделать:** - - **Страницы SPA на Hosting, на английском** — их адреса дальше не меняются: - - уведомление о данных для согласия — с номером версии, который пишется в `consent_version` (T23). В нём: что собираем, кому передаём, как удалить, сроки (privacy 8, П-6), просьба не писать на холсте ничего личного (privacy 5.3), вход только для взрослых 18+ (П-5); - - политика конфиденциальности — на основе privacy 11, с политикой хранения (privacy 8); - - условия использования; - - форма согласия для подписи (privacy 6). - - **Письменная программа информационной безопасности** (privacy 9) — в `docs/`. - - Никаких обещаний «полной анонимности» (privacy 1). -- **Я:** вычитываю построчно. Это не юридическая консультация: перед публичным запуском тексты смотрит юрист (шапка privacy). -- **Разделы:** privacy 1, 5.3, 6–9, 11, 12 (П-5, П-6, П-8); product 11. - -#### T51. SPA: раздел родителя - -- **Цель:** родитель проходит сценарии С1, С2, С6 и С7 целиком — от входа до удаления. -- **Сделать:** - - **Вход** через Google и Apple (`firebase/auth`); App Check с reCAPTCHA Enterprise и автообновлением токена (`firebase/app-check`). - - **Бета и согласие:** инвайт, уведомление и отметка согласия, ожидание подтверждения оператором (R26). - - **Профили детей:** псевдоним с подсказкой не вводить настоящее имя, аватар из набора, класс; PIN. - - **Детский режим** — только задачи и холст; выход — по PIN (spec 12.2). - - **Прогресс** (T37). - - **Удаление** профиля и аккаунта — с повторным входом (T43). IndexedDB очищается при выходе и удалении. - - **Неразборчиво и сбой** — нейтральные фразы из 5.4 и 9.9. Фраза исчерпанного лимита по паузе — один раз за сеанс (5.4, R35). - - **Интерфейс:** крупные элементы, без таймеров, рекламы и внешних ссылок (product 8). -- **Тесты:** экраны на подделанном API — каждый статус согласия и беты, лимит детей, `REAUTH_REQUIRED`. -- **Разделы:** product 6, 8, 11; spec 8.2, 12.1, 12.2; privacy 6, 7; R07, R26, R29. - -#### T52. SPA: e2e - -- **Цель:** задачи А и Б автоматически проходятся от начала до конца на записанных траекториях пера. -- **Сделать:** - - **Playwright** — из закреплённого образа, а не в devcontainer: как всё, что не Go, в standalone. - - **Синтетические штрихи:** - - запись и воспроизведение траекторий; - - отсечение ладони; - - обрыв сети посреди дельты (product 10); - - вторая вкладка. - - **Сервер** — локальный, с записанными ответами Mathpix и Gemini. - - **Рецепт `ci-e2e`** и задание в CI. -- **Как проверить:** `just ci-e2e` локально и в CI. -- **Разделы:** spec 17; product 10, 12. - -> **Точка проверки фазы 9.** Обе задачи проходятся в браузере на планшете, родитель проходит все сценарии; e2e работает в CI. +> **Точка проверки части 3.** `POST /v1/checks` в prod за один вызов Gemini отвечает вердиктом программы, рамкой, вопросом, разбором модели и метриками. Без ключа, на пустом листе и сверх потолка модель не зовётся, а локально проверка идёт на подделке. Автор решает задачи в prod: Send Answer, результат, Next. Панель показывает время, токены, цену и разбор модели, разметка не подстраивается под модель, журнал выгружается файлом. --- -### Фаза 10. Замеры и приёмка +### Часть 4. Замеры и выводы -#### T54. Спайк S4: задержка и холодный старт +Каждый прогон, который стоит денег, начинается с оценки и моего подтверждения (правило 5). Смета эксперимента — ≈ $10–30, в худшем случае до $45: это может выйти за бюджет $20, и тогда матрица eval ужимается или я поднимаю бюджет. -- **Цель:** подтвердить бюджет spec 14.1 и холодный старт не дольше 3 с — на настоящем prod. -- **Сделать:** - - сценарии 9.3 и 9.4 на тёплом и холодном пути (Cloud Run, Neon после сна); - - замеры: время до маркера и до подсказки по этапам 14.1, холодный старт двух контейнеров; - - числа сверяются с `docs/load.md` (T46a), где то же снято на ограниченном контейнере; - - даёт ли Cloud Run процессор запросу, клиент которого ушёл, — решение по Т-12 на настоящем сервисе; - - отчёт `docs/live/08-spike-latency.md`; если не укладываемся — варианты из 14.2 с ценой. -- **Как проверить:** критерий S4 — маркер ≤ 1 с и подсказка ≤ 3 с (p95), холодный старт ≤ 3 с. -- **Стоимость:** распознавания и подсказки прогона — центы. -- **Разделы:** spec 14.1, 14.2, 20; product 10. - -#### T55. Ревью безопасности - -- **Цель:** до беты код входа, прав, данных и внешних вызовов прочитан глазами безопасности. -- **Сделать:** - - `/security-review` по коду входа, прав, протокола, удаления и адаптеров; - - сверка с мерами spec 13 и privacy 9; - - находки исправить или записать с обоснованием; отчёт. -- **Разделы:** spec 12, 13; privacy 9, 10. - -#### T56. Приёмка MVP - -- **Цель:** все критерии приёмки product 12 выполнены на настоящих устройствах. -- **Сделать:** прогон по критериям 1–7: - - обе задачи — на iPad (Safari, Apple Pencil), Android-планшете (Chrome) и десктопе; - - eval-наборы; - - поведение без Mathpix и Gemini; - - удаление во всех хранилищах; - - лимиты и бюджетные оповещения; - - логи и метрики без ПДн. - - Отчёт — `docs/live/09-acceptance.md`. -- **Я:** прохожу сценарии на устройствах. -- **Разделы:** product 10, 12. +#### T74. Eval: журнал на других моделях -#### T57. Публичные документы - -- **Цель:** репозиторий понятен со стороны: что это, как устроено и как поднять свою копию. +- **Цель:** образцы журнала прогоняются тем же ходом проверки на других моделях и настройках, и выходит таблица, по которой выбирают модель и промпт (решение 8 E2). - **Сделать:** - - `README.md` — что это и навигация; плашки живые, добавлена плашка релиза; - - `docs/self-hosting.md` — полностью; - - `SECURITY.md`, `CONTRIBUTING.md`, `docs/openssf-best-practices.md` и `THIRD_PARTY_LICENSES` — актуальны. -- **Как проверить:** я прохожу `docs/self-hosting.md` по шагам или вычитываю его. -- **Разделы:** R25. - -#### T58. Запуск беты - -- **Цель:** первые семьи входят в бету после того, как выполнены юридические условия. -- **Сделать:** - - **Я:** - - договоры с обработчиками: Google Cloud DPA, Mathpix, Neon, Upstash; исключение из журналирования злоупотреблений у Gemini (П-3); - - юрист смотрит тексты T50 (П-7, П-8); - - DPIA — до семей из ЕС и Великобритании (П-2). - - **Claude** — `docs/operations.md`: - - как выдавать инвайты, принимать подписанные формы и подтверждать согласие через `cmd/admin`; - - как отвечать на запрос о данных и на удаление; - - что делать при инциденте (privacy 9); - - чек-лист запуска. - - Первые инвайты. -- **Разделы:** product 3, 6, 15; privacy 2, 6, 7, 9, 12. - -> **Точка проверки фазы 10.** Критерии приёмки выполнены, юридические условия беты закрыты, первые семьи получили инвайты. + - **`cmd/eval`** и `just eval`: выгрузки из `samples/` → разбор T72 → `internal/check` с адаптером T35, настроенным флагами: модель, регион, картинка, размышления, вариант промпта, повторы. Учётные данные — ADC автора; в образ сервера `cmd/eval` не входит. + - **Режим сводки** — по записанным ответам, без вызовов: так считается живой прогон (T75). + - **До прогона** — оценка стоимости и отказ выше `-max-cost`. 2–4 вызова одновременно, конфигурации вперемешку; повтор после сбоя не засчитывается. + - **Потолок eval — свой:** дневной потолок сервиса (T69) задаётся флагом и по умолчанию снят — иначе отбор T76 на ~1 400 вызовов упрётся в него и получит `UNCHECKED` · `limit` вместо ответов модели. Деньги в eval держит `-max-cost`. + - **Метрики:** + - прочитано точно; ответ найден — рамка итога против помеченных штрихов; + - описка не «исправлена» к ключу; шаги без штрихов под ними; + - рамка на ошибке — точно, частично, мимо, нет; тип ошибки против сценария; + - свой ответ модели в варианте без ключа; сдвиг от ключа — парно с этим вариантом; + - ложные `INCORRECT` и `CORRECT`, лишние `UNREADABLE`; разброс между повторами; + - задержка p50 и p95, токены, цена за вызов и за 1000 проверок по ценам 2026 и 2027 годов. + - **Доли — с доверительными интервалами по листам:** сотня листов — разведка (≤ 5 %), а не цели product 12 (≤ 1 %). + - **Отчёт** — Markdown и CSV в `samples/`, там же галерея сбоев: картинки из `samples/` в репозиторий не попадают. +- **Тесты** — на рукописной подделке порта и синтетической выгрузке в `testdata/` (картинки рисует код, не рука): + - метрики на известных случаях и интервалы; + - отказ по `-max-cost`; + - режим сводки не делает вызовов. +- **Как проверить:** `just eval` в режиме сводки на выгрузке T72; один короткий прогон на Flash — после оценки стоимости и моего подтверждения. +- **Стоимость:** короткий прогон — центы. +- **Разделы:** spec 7.4, 17, 20 (Т-5); product 9, 12; privacy 4; R22; решения 3 и 8 E2. + +#### T75. Живой прогон: образцы и замеры в prod + +- **Цель:** я, изображая ребёнка 1–6 класса, решаю задачи в prod по сценариям. Журнал даёт образцы для eval и первые цифры — понимание, задержку и цену на настоящем Cloud Run. +- **Сделать:** + - **Claude — сценарии листов** в случайном порядке, по 20 на задачу: + - 10 верных: полный ход с разными пометками ответа (×3), голый ответ, похожие цифры (×2), ответ не последним, ответ среди кандидатов, с украшениями, повторная проверка после правки; + - 6 неверных: ловушки задачи (×2), описка или надпись-инструкция (×2), ключ на листе как промежуточное число, ответ, похожий на ключ; + - 3 неразборчивых: каракули, ответа нет, два кандидата без пометки; + - 1 — на форму ответа задачи. + - **Разделение — до первой правки промпта:** треть листов — разработочные, две трети — тестовые, собранные со скрытой панелью (T73). + - **Мой шаг:** решаю ≈ 120 листов на компьютере, ставлю разметку, выгружаю журнал в `samples/`. По желанию — до 50 листов на задачу «без проверки» ($0). + - **Claude — сводка** `just eval` без вызовов и отчёт `docs/live/02-llm-live.md` в формате `docs/live/` (CLAUDE.md, Documents): задержка по этапам с холодным стартом, цена, качество по разметке, характерные сбои — словами, без картинок. +- **Как проверить:** отчёт следует формату; его цифры воспроизводит сводка. +- **Стоимость:** ≈ 120 проверок на Flash — ≈ $0.3–0.9; Cloud Run — в бесплатных лимитах (`just usage`). Оценка — до начала, прогон — после моего подтверждения. +- **Разделы:** product 9, 10, 12; spec 14.1, 20; privacy 4 (П-10); правило 5. + +#### T76. Выводы: модели, скорость, цена + +- **Цель:** сравнить на образцах живого прогона модели и настройки и записать, какой будет проверка в бете: модель, размышления, картинка, один вызов или два, сроки и цена. +- **Сделать:** + - **Матрица** — в плане, с оценкой стоимости: + - отбор: 120 листов на ~12 конфигурациях — Flash-Lite и Flash × картинка × размышления, Pro, варианты без ключа и «только чтение»; ≈ 1 400 вызовов; + - подтверждение: 2 финалиста ×3 на всех листах — на 120 живых это ≈ 720 вызовов, а с листами «без проверки» (до 420 листов) — ≈ 2 500. + - **Pro** — по документации: есть ли она в `us-east4`. Если только на глобальном адресе — отдельной строкой: её задержка не сравнима (R54). + - **Отчёт** `docs/live/03-llm-eval.md`. + - **Решения для E15** — в `docs/decisions.md`: модель и настройки (Т-5), один вызов или два (R38, R39), тайм-аут, цели задержки и цены. Цифры — в product 9 и 10, spec 7.1 и 14.1; описание E15 в RUN.md. +- **Мой шаг:** подтверждаю смету каждого прогона и принимаю решения по итогам. +- **Как проверить:** отчёт следует формату `docs/live/`, его цифры воспроизводит `just eval` на тех же выгрузках; решения записаны в журнале, а product, spec и описание E15 им не противоречат. +- **Стоимость:** отбор ≈ $3–10; подтверждение ≈ $2–5 на 120 листах и ≈ $6–18 на 420; вместе с остальными прогонами E2 — в бюджет $20 или, с моего согласия, выше. +- **Разделы:** product 9, 10, 12; spec 7, 14.1, 20 (Т-5); R22, R38, R39, R54. + +#### T77. Исследование: PNG или векторы + +- **Цель:** решить, что SPA отправляет на проверку в бете — PNG листа, как сейчас (R42), или штрихи, — на тех же образцах и метриках (решение 7 E2). +- **Почему:** решение автора 2026-10-15. Слать штрихи и рисовать на сервере R42 отверг, когда рендер ушёл вместе с путём Б; автор открыл вопрос заново как исследование. +- **Сделать:** варианты eval на журнале T72: + - PNG, нарисованный на сервере из штрихов, — один рендер для проверки и eval; + - штрихи текстом с порядком написания, без картинки; + - картинка и порядок написания вместе. + + Метрики T74, плюс объём запроса, токены и задержка. Рендер на сервере — только в eval: в сервис он не входит. Вариант без картинки не проходит проверку картинки хода проверки (T69), поэтому eval зовёт модель для него своим путём — расширением порта или отдельным входом адаптера; как — решает план задачи. Отчёт `docs/live/04-vectors.md`; решение по R42 для E15 — в `docs/decisions.md`. +- **Как проверить:** отчёт следует формату `docs/live/`, его цифры воспроизводит `just eval` на тех же выгрузках; решение по R42 записано в журнале. +- **Стоимость:** ≈ $2–5 — с оценкой до прогона. +- **Разделы:** spec 4, 7; privacy 1, 5; R15, R42; решение 7 E2. + +> **Точка проверки эпика E2.** Отчёты `docs/live/02–04` отвечают, насколько хорошо модель понимает задачу и свободное решение, верно ли проверяет ответ, сколько длится и стоит проверка. Решения для беты записаны, и E15 готов к разбиению. diff --git a/docs/decisions.md b/docs/decisions.md index 9ad2ea7..ce57bf2 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -898,4 +898,19 @@ Centrifugo сохраняет роль из R23. Событие `verdict` ухо - удалить прежнюю разбивку и ссылаться на историю git; - вынести прежнюю разбивку в отдельный файл. -— RUN.md. +— RUN.md. — *Поправка 2026-10-15:* сделанный эпик уходит из RUN.md в архив `docs/plan/`, и туда же после разбиения E2 уходит остаток прежней разбивки (R59). До разбиения она остаётся в RUN.md: это сырьё для ближайшего планирования. + +**R59. Сделанный эпик уходит из RUN.md в архив `docs/plan/<эпик>.md`.** +- В архив дословно переезжает всё, что RUN.md держал об эпике: описание («Результат», «Нужно», «Почему такой порядок»), таблица задач и тела задач с «Сделано» и «Открыто». Первый архив — `docs/plan/E1.md`. +- В RUN.md остаётся только живое: правила, текущий эпик, эпики на потом, вопросы автору и технический долг, а от сделанного эпика — строка в списке «Сделанные эпики» со ссылкой на архив. +- На задачу ссылаются по номеру. Номера сквозные, поэтому прежние ссылки «RUN.md, T11» не правятся: задачу, которой нет в RUN.md, находят в архиве по заголовку — `grep -n '^#### T11\.' RUN.md docs/plan/*.md`. +- Остаток черновика E2 — задачи, которые не пережили разбиение, — уходит в `docs/plan/E2-draft.md`, когда E2 разбит на задачи. + +— *Почему:* решение автора 2026-10-15. RUN.md читают в начале каждой задачи, и сделанное в нём занимает почти всё место. После E1 в файле было 2601 строка: около 260 приходилось на правила и таблицы, около 1260 — на тела задач E1, около 1070 — на черновик E2. Файл уже не помещался в одно чтение, а с каждым эпиком рос бы дальше, вместе с продуктом. Тела сделанных задач нужны редко: когда решение или отчёт ссылается на задачу. Тогда читают одну задачу, а не весь план. R44 отверг отдельный файл для черновика E2, пока тот — сырьё для разбиения; после разбиения остаток черновика — только история, и в RUN.md ему не место. + +— *Отвергнуто:* +- оставить тела сделанных задач в RUN.md: план растёт с продуктом, и каждая задача начинается с чтения всего сделанного; +- один архивный файл на все эпики: он растёт так же, а файл на эпик закрыт и больше не меняется; +- переписать ссылки «RUN.md, Txx» в журнале и документах: номер задачи и так однозначен, а записи журнала правятся только поправками. + +Поправляет R44. — RUN.md («Как устроен план: эпики»); `docs/plan/`. diff --git a/docs/plan/E1.md b/docs/plan/E1.md new file mode 100644 index 0000000..6678306 --- /dev/null +++ b/docs/plan/E1.md @@ -0,0 +1,1303 @@ +# Эпик E1. Настройка репозитория: CI/CD и первый деплой hello world + +> **Сделан 2026-10-14, перенесён в архив 2026-10-15.** Описание эпика, его таблица и тела задач перенесены из RUN.md дословно, с «Сделано» и «Открыто». Добавлены заголовки «Итог» и «Задачи» и список «Что было нужно для E1» — в RUN.md он стоял в «Как пользоваться» как «Нужно для E1». +> +> - «Этот файл», «правило N» и «таблица N» в задачах означают RUN.md в редакции коммита 9adad66 — последнего, где E1 был в нём; «таблица 1» — таблица задач в «Итоге» этого архива. Нынешние правила, вопросы и долг (таблицы 3 и 4) — в [RUN.md](../../RUN.md). +> - Последний пункт «Открыто» T12 — перенести E1 в «Сделанные эпики» — закрыт этим архивом. +> - Файл не читается целиком. Задача находится по номеру: `grep -n '^#### T11\.' RUN.md docs/plan/*.md`. + +## Итог + +**Что было нужно для E1:** +- Docker и VS Code с расширением Dev Containers — с T01; +- настройки репозитория на GitHub, аккаунты SonarCloud (организация `mathtrail`), Codecov и OpenSSF Best Practices — с T07; +- биллинг-аккаунт Google Cloud и свой домен — с T09; +- Firebase в проекте Google Cloud с биллингом — с T10. + +**Результат:** +- каждый PR проходит проверки Go и web, уязвимостей, лицензий, секретов и образа, CodeQL и SonarCloud; +- зелёный CI на `main` публикует подписанный релиз, и выкладка разворачивает сервис и SPA из его тега (R45); +- `/health` в prod называет релиз и коммит; +- правила работы и версии инструментов сверены с образцом от 2026-10-11. + +Продуктового кода в эпике нет: выкатываются простейший сервер (T02) и заглушка SPA (T05). + +| Статус | ID | Задача | Кто | План | Зависит от | +|---|---|---|---|---|---| +| [x] | T00 | Документы и план | Claude | — | — | +| [x] | T01 | CLAUDE.md | Claude, проверяю я | — | T00 | +| [x] | T02 | Простейший сервер | Claude | — | T01 | +| [x] | T03 | Devcontainer | Claude | да | T02 | +| [x] | T59 | Правила: сверка с образцом 2026-10-11 | Claude, проверяю я | — | T03 | +| [x] | T60 | Devcontainer: версии и наши отличия от образца | Claude | — | T03 | +| [x] | T04 | Проверки Go и образ сервиса | Claude | — | T59, T60 | +| [x] | T05 | Web-тулчейн | Claude | да | T04 | +| [x] | T06 | CI | Claude | — | T04, T05 | +| [x] | T07 | Анализ кода и витрина качества | я + Claude | — | T06 | +| [x] | T08 | Релиз | Claude | — | T06 | +| [x] | T09 | Решения для поставки | я + Claude | — | T01 | +| [x] | T10 | Terraform | Claude | да | T04, T09 | +| [x] | T11 | CD | Claude | да | T06, T07, T08, T10 | +| [x] | T12 | Первый деплой | я + Claude | — | T11 | + +**Почему такой порядок.** Начало задал я. Сначала правила (T01), простейший сервер (T02) и сразу вся DevX-часть (T03–T12). К первой строчке продукта уже работают проверки, релиз и выкладка, и каждая следующая задача проходит через тот же конвейер. + +Перед T04 — сверка с образцом от 2026-10-11 (T59, T60). Правила работы и версии инструментов должны быть свежими до того, как по ним пойдёт первый код: T03 и задачи T04–T12 писались по снимку образца от 2026-09-28, а за две недели образец поменял релиз и выкладку (R45), версии и практики. + +## Задачи + +### Часть 1. Правила и первый код + +Выполняется на хосте: контейнера ещё нет. + +#### T00. Документы и план + +- **Сделано при создании плана:** + - `docs/product.md`, `docs/spec.md`, `docs/decisions.md` и `docs/privacy.md` собраны 2026-09-27 из черновика автора и исправлены по итогам ревью документов; + - написан этот файл; + - мои решения: + - о средах: в MVP только prod, вторая среда — после успешного MVP (R32); + - о выкладке: только после зелёного CI и только из `main` (R33); + - о секретах провайдеров входа: Secret Manager под управлением Terraform (R34). + + Поправлены spec 8.1, 12.1, 13, 14.4, 15.1, 15.2, 15.5 и product 9. +- **Открыто:** вопросы О-9…О-17, Т-1…Т-9 и П-1…П-9 разложены по задачам ниже. + +#### T01. CLAUDE.md + +- **Цель:** правила работы в репозитории — те же, что в mathtrail-standalone, приложенные к canvas-api. С этой задачи каждая сессия Claude начинается с них. +- **Сделать:** + - **Копия образца** — `reference/mathtrail-standalone/`: снимок рабочего дерева `../mathtrail-standalone`. + - Без `.git`, `node_modules`, `prototype/`, `reference/`, `research/`, `draft/`, `bin/`, `dist/`, `coverage.out` и `.terraform/`. + - `/reference/` — в `.gitignore`. + - Копия нужна потому, что из devcontainer соседний каталог хоста не виден. + - **`CLAUDE.md`** на английском, по разделам `reference/mathtrail-standalone/CLAUDE.md`: + - *Your role* — без изменений. + - *The project* — что строим (product 1) и технический облик (spec 1–2): + - Go-сервис canvas-api на Cloud Run с сайдкаром OpenFGA; + - Centrifugo, Neon, Upstash; + - Firebase Auth и App Check; + - Mathpix, Gemini на Agent Platform; + - SPA на React и Konva на Firebase Hosting; + - контракты `canvasapi.v1` на protobuf; + - сначала детерминированный движок, модель — только для подсказки и неоднозначного рисунка. + - *Documents* — четыре файла `docs/`, этот план и `docs/live/` (отчёты спайков и живых прогонов). Контекст живёт в этих файлах, а не в истории чата. + - *How we work* — пункты образца: + - одна задача за раз; прочитать задачу и разделы; + - дыры — О-, Т- и П-вопросами; + - `/code-review high` с тем же порядком: два раунда, `git add -N` для новых файлов, разбор находок; + - отметка в таблице, отчёт по-русски, не коммитить; + - стоимость — до начала; + - не предлагать отвергнутое в `docs/decisions.md`; + - API — по документации закреплённой версии. + - *Hard rules*: + - **devcontainer only.** T00–T03 выполняются на хосте; маркер — `MATHTRAIL_DEVCONTAINER`; + - **точные версии** — как в образце; `github.com/tdewolff/canvas` без тегов закрепляется псевдоверсией (spec 3.2); + - **язык** — правило 11 этого файла; + - **данные детей** (privacy 1, 4, 5): + - о ребёнке — только псевдоним, аватар из набора и класс; + - в логах, метриках и трассах — ни прямых идентификаторов, ни LaTeX, ни текста подсказок (spec 13, 16); + - во внешний ИИ — только математика, без идентификаторов и псевдонима; + - детские образцы почерка никогда не попадают в репозиторий и `testdata/`; там только синтетика и образцы взрослых (spec 17); + - до согласия `verified` данных ребёнка нет (R26); + - **ответ задачи** не уходит клиенту до решения (spec 8.6, 13), а подсказка проходит проверку на утечку (spec 7.3); + - **секреты** — только Secret Manager, переменные окружения и локальный `.env`; + - **лицензии** — MIT; только совместимые зависимости; список третьих сторон актуален. + - *Architecture and code standards* — текст образца с поправками: + - раскладка — spec 18; + - порты домена — `Recognizer`, `Tutor`, `Authorizer`, `Notifier`, `Store` (spec 1); Firebase, Mathpix, Gemini, OpenFGA, Centrifugo и Postgres — адаптеры в `internal/infra/`; + - правило зависимостей — с примером из canvas-api: движок не ходит в Mathpix; + - config — viper, только переменные окружения, префикс `CANVAS_` (spec 15.4), порт из `PORT`; dev-переключатели не стартуют при `K_SERVICE`; + - logging — zap с ключами Cloud Run; в строке — UUID, коды, тайминги, `prompt_version` и ID модели (spec 16); + - errors: + - формат `apierror` `{code, message, details}` и заголовок `X-Request-Id` (R11, spec 8.3); + - сбой Mathpix, Gemini или движка — не HTTP-ошибка, а вердикт `UNCHECKED` или шаблонная подсказка (spec 9.9); + - context — тайм-ауты внешних вызовов из spec: Mathpix 3 с, Gemini 4 и 8 с; + - tests — правила образца, плюс: + - fuzz у всего, что разбирает пришедшее извне: LaTeX от Mathpix, JSON модели, дельты штрихов, JSON задач; + - свойства на gopter у инвариантов движка (spec 17); + - записанные ответы Mathpix и Gemini в `testdata/` — только синтетические; + - Postgres и OpenFGA в интеграционных тестах — в контейнерах; + - tooling and images — как в образце, плюс buf для `proto/` и Playwright из закреплённого образа. + - *Reference copies* — `reference/mathtrail-standalone/`: + - только для чтения, никогда не правится; + - по R11 из неё переносятся каркас (`app`, `config`, `logger`, `telemetry`, `apierror`, `version`), devcontainer, justfile, CI/CD, Terraform и настройки качества — с поправками под canvas-api; + - ripgrep копию не видит — читать явными путями; + - команда, которой копия обновляется на хосте. + - *Environment* — заготовка в одну строку; раздел заполнит T03. + - **`README.md`** — вход и навигация (R25): что это (product 1), ссылки на документы и этот план. +- **Как проверить:** + - каждый раздел образца либо перенесён, либо отличие объяснено ссылкой на документ canvas-api (R-номер, раздел spec или privacy); + - CLAUDE.md не противоречит документам; + - `git status` показывает только `CLAUDE.md`, `README.md`, `.gitignore` и отметку в этом файле; копия лежит на месте, но git её не видит. +- **Не делать:** код и конфиги инструментов (T02 и дальше). +- **Разделы:** spec 1–3, 13, 16–18; privacy 1, 4, 5; R02, R11, R25, R26. + +#### T02. Простейший сервер + +- **Цель:** первый Go-код, на котором дальше строятся devcontainer, проверки, CI, релиз и CD. +- **Сделать:** + - `go.mod`: `module github.com/MathTrail/canvas-api`, `go 1.27.1` (spec 3.2), без зависимостей; + - `cmd/server/main.go` — только стандартная библиотека: + - порт из `PORT`, по умолчанию 8080; + - `GET /health` → 200 (R30: не `/healthz`); + - `http.Server` с тайм-аутами чтения заголовков, чтения, записи и простоя; + - остановка по SIGINT и SIGTERM: `signal.NotifyContext` и `Shutdown` с тайм-аутом; + - `cmd/server/main_test.go`: `/health` отвечает 200 через `httptest`, неизвестный путь — 404. +- **Как проверить.** На хосте Go нет, поэтому проверка идёт в образе Go с тем же тегом и digest, что в `reference/mathtrail-standalone/Dockerfile`: + - `docker run --rm -v "$PWD":/src -w /src golang:1.27.1-trixie@sha256:… sh -c 'test -z "$(gofmt -s -l .)" && go vet ./... && go test ./... && go build ./cmd/server'`; + - тот же образ с `-p 8080:8080` и `go run ./cmd/server` → `curl localhost:8080/health` отвечает 200. +- **Не делать:** config, logger, gin — это каркас T16; Dockerfile сервиса — T04. +- **Разделы:** spec 3.2, 8.2, 15.3; R30. + +> **Точка проверки части 1.** Правила записаны, первый Go-код собирается и отвечает на `/health`. + +--- + +### Часть 2. DevX: окружение, качество, поставка + +Всё, что в standalone складывалось по ходу работы, здесь ставится сразу, до кода продукта. Каждая задача называет файлы образца, которые переносит. + +#### T03. Devcontainer + +- **Цель:** воспроизводимое окружение, как в standalone. Сама задача выполняется на хосте; все следующие сессии Claude идут внутри контейнера. +- **Сделать:** перенести `.devcontainer/` из `reference/mathtrail-standalone/` и приспособить. + - **`Dockerfile`** — те же две стадии и те же правила: + - `toolchain`: Go, Node.js, just, golangci-lint, mockery; + - `devcontainer`: gopls, dlv, cloudflared, gh, Docker Compose, Claude Code, Terraform, gcloud. cloudflared нужен, чтобы открывать SPA на планшете через туннель; gcloud ставится только на x86_64 (R28 standalone); + - версии — `ARG` с теми же значениями, что в образце на момент переноса; Go — 1.27.1, как в `go.mod`; + - сборка под любой builder, загрузки через `fetch()` с запретом понижения протокола (standalone: R35, R36). + - **`devcontainer.json`:** + - имя и сеть `canvas-api` (`initializeCommand` и `runArgs`); + - тома `canvas-api-claude`, `canvas-api-gcloud`, `canvas-api-gh` — входы в Claude, gcloud и gh переживают пересборку; + - `containerEnv`: `MATHTRAIL_DEVCONTAINER=1`, `CLAUDE_CONFIG_DIR`, `DISABLE_AUTOUPDATER=1`; + - feature docker-in-docker той же версии, с записью в `devcontainer-lock.json`; + - `forwardPorts`: 8080 (API) и 5173 (Vite); + - расширения VS Code образца точных версий, кроме пакета Python — в canvas-api Python нет; + - SonarQube for IDE с выключенной телеметрией (R81 standalone). + - **Скрипты:** + - `host-network.sh` — сеть с MTU хоста; + - `post-start.sh` — `core.hooksPath .githooks`, если папка есть, и тот же MTU для docker внутри контейнера. + - **Остальные файлы:** + - `.gitattributes` — как в образце; + - `.dockerignore` — контекст сборки образа — корень репозитория; из него исключаются `.git`, `.env`, `reference/`, `docs/` и всё, что образу не нужно; + - минимальный `justfile`: `default` (`just --list`) и `claude-update` — CLI и расширение Claude Code это одна версия в двух файлах. + - **Документация:** раздел Environment в `CLAUDE.md` по образцу; раздел для разработчиков в `README.md` — как открыть проект в контейнере. +- **Как проверить:** + - «Reopen in Container»; + - внутри работают `go version`, `node --version`, `just --version`, `golangci-lint version`, `mockery version`, `claude --version`, `gh --version`, `terraform version`, `gcloud --version` и `docker run --rm hello-world`; версии совпадают с `ARG`; + - `go test ./...` из T02 зелёный; + - `echo $MATHTRAIL_DEVCONTAINER` → `1`; + - пересборка без кеша даёт те же версии. +- **Не делать:** + - конфиги линтеров и рецепты сборки — T04; + - `web/` — T05; + - CI — T06; + - buf, firebase-tools и Playwright — их приносят задачи, которым они нужны. +- **Риски:** + - features devcontainer и установщик Claude Code плавают по версиям — закреплять версию и digest явно; + - туннель на хосте режет большие загрузки — поэтому сеть с MTU хоста (R36 standalone). +- **Разделы:** spec 3.4; R11. + +#### T59. Правила: сверка с образцом 2026-10-11 + +- **Цель:** правила работы и отчёта — те же, что образец выработал к 2026-10-11, и они действуют до первого кода. + - CLAUDE.md и правила этого файла переносились со снимка от 2026-09-28. + - С тех пор образец добавил практики, которые делают проверку правки строже. +- **Сделать** — только документы, по `reference/mathtrail-standalone/`: `CLAUDE.standalone.md`, `RUN.md`, `docs/decisions.md`. + - **`CLAUDE.md`, How we work:** + - проверки и ревью идут в чистой копии дерева, только с этой правкой: чужая незакоммиченная работа в них не попадает; + - флейк — тест, который падает под нагрузкой и проходит отдельно, — называется в отчёте и в «Открыто». Задание не перезапускают, пока не позеленеет (R207 standalone); + - отчёт называет, сколько проверок выключалось, чтобы доказать тесты, и все ли пойманы, а также сколько шёл fuzz; + - находку анализатора (CodeQL, SonarCloud, Scorecard) закрывают правкой кода. Пометка «ложное» — крайняя мера: со ссылкой на решение и с тестом, который это доказывает (standalone: R35, R111, R50); + - то, что журнал образца отверг для перенесённых практик, без новых фактов не предлагается. Например, мутационные инструменты (R39 standalone) и гейт по SonarCloud (R31 standalone); + - на время живого прогона Claude получает узкое разрешение в `.claude/settings.local.json`. После прогона файл удаляется; + - номера R и T берутся свободными в момент записи: параллельная сессия может занять следующий. + - **`CLAUDE.md`, Tests:** под `-race` тест с часами даёт время ×10 при тех же шагах, а не повтор и не тайм-аут (R207 standalone). + - **`CLAUDE.md`, Logging:** лог проверяется по таблице «событие → разрешённые поля», а не по списку запретов (standalone: R141, R169). Саму проверку приносит эпик MVP. + - **`CLAUDE.md`, Documents** — формат отчёта живого прогона в `docs/live/`: + - статус — первой строкой; + - без идентификаторов: репозиторий публичный; + - версия и коммит; + - роли; + - стоимость: оценка и факт; + - ход по UTC; + - «Открыто»; + - «Как повторить». + - **Правила этого файла:** + - правило 3: + - через `gh` проверить, что выкладка, которая несла прошлую правку, прошла (с T11); + - находку анализатора сначала чинить кодом; + - правило 6 — документацию читать с датой обращения. Если она расходится с закреплённой версией, эталон — сама библиотека (R06 standalone); + - правила 9 и 10 и формат задачи: + - исполнитель пишет в тело задачи «Сделано (дата)» и «Открыто»; + - при планировании в задачу пишутся «Почему», «Решено автором (дата)», «Выяснилось при планировании», «Мой шаг», «Когда»; + - статусы `[-]` — не делаем, причина в задаче; `[?]` — отложена, в задаче причина и когда вернуться; + - столбец «План» говорит, как начинать задачу, а не о её объёме. +- **Не переносить:** + - автономный режим исследовательских задач: он идёт вразрез с порядком «задача — проверка автором»; + - «English everywhere»: документы `docs/` и этот файл пишутся по-русски (R25); + - ручные значения секретов из R26 standalone: у нас секреты кладёт Terraform (R34). +- **Как проверить:** + - каждый пункт выше перенесён, или его отличие объяснено ссылкой на решение canvas-api; + - CLAUDE.md и правила этого файла не противоречат друг другу. +- **Сделано (2026-10-12):** + - `CLAUDE.md`: + - How we work — все семь пунктов задачи. Сюда же повторены правила 6 и 10 этого файла, а в перечень правил RUN.md добавлены Scorecard и проверка выкладки через `gh`; + - Tests — часы ×10 под `-race`; Logging — таблица «событие → разрешённые поля» со ссылкой на spec 16, где она уже названа; Documents — формат отчёта в `docs/live/`; + - правила этого файла: 3, 6, 9, 10, статусы `[-]` и `[?]`, столбец «План», абзац «Тело задачи». Формат в «Следующем эпике» теперь ссылается на этот абзац. Фраза «новые задачи получают номера с T59» заменена правилом свободного номера; + - «Не переносить» не перенесено: CLAUDE.md об автономном режиме молчит, язык и секреты уже держат R25 и R34; + - сверх текста задачи, на проверку: + - правило анализатора дополнено находкой, которую правка закрыть не может: она остаётся открытой с записанной причиной (R51 standalone). Например, находка Scorecard, которой нужен второй человек. Так T07 и T08 и записывают находки Scorecard и `release.yml`; + - копия дерева, если в нём есть чужая работа, — `git worktree` от `HEAD` вне репозитория. Туда идут только правки этой задачи, новые файлы тоже, ссылка на `reference/` и `web/node_modules` из lockfile; проверки и ревью запускаются изнутри копии. Исправления после ревью делаются в дереве и переносятся в копию, а копию убирает `git worktree remove --force`: без `--force` git отказывается удалять копию с правками. Механизма образец не описывает; + - проверка выкладки через `gh` — «когда T11 подключит CD», а не «с T11»: до T11 выкладки нет, и правило 3 так же говорит «с T08, когда T07 подключит»; + - разрешение на живой прогон даёт автор: так в образце (`docs/live/09-load.md`), и Claude не выдаёт его себе сам; + - флейк лечится в его тесте, как R207 standalone вылечил часы; + - из R207 standalone взяты ещё две вещи: множитель живёт в одном помощнике с тегом `race`, а не в константе каждого теста, а тест о самих часах ставит свои; + - ссылка на документацию с датой обращения пишется в отчёт и в решение или документ, которые на неё опираются; + - в «Теле задачи» есть «Нагрузка (правило 14)»: этот раздел уже стоит в 17 задачах черновика E2; + - статусы `[-]` и `[?]` — «решение автора», как в образце. Причина — в задаче, а не «в начале фазы»: у нас не фазы, а эпики (R44); + - проверки: кода в правке нет, рецепты правила 7 появятся с T04. `gofmt`, `go vet` и `go test ./...` зелёные, код не тронут. Выключенных проверок и fuzz нет. Дерево держит только эту правку, поэтому ревью шло в нём самом; + - ревью `/code-review high`, два круга по 10 замечаний. + - Первый круг. Исправлено: неполный механизм копии дерева (два замечания); потерянное исключение R207 для теста о самих часах; фраза «номера с T59», которая спорила с правилом свободного номера; «принять» с обязательным решением — ужесточение сверх задачи, которое расходилось с T07 и T08; не сказано, куда пишется дата обращения. Отброшено: T59 ещё не отмечена `[x]` (отметка ставится после ревью); строка в `.gitignore` в этой правке (правка только документов, а `.gitignore` принадлежит T04); дублирование правил в CLAUDE.md и RUN.md (зеркальные пункты были и раньше, правка держит их в согласии). Вопрос к автору — ниже. + - Второй круг, все десять исправлены: `git worktree remove` без `--force`; новые файлы в копии; проверки и ревью изнутри копии; возвращена оговорка о чужой работе в пункте о дефектах — дерево может перестать быть чистым уже после начала задачи; «с T11» на одну задачу раньше, чем есть выкладка; кто даёт разрешение на прогон; лечение флейка; «Нагрузка» в «Теле задачи»; формулировка про `.gitignore`; фраза о номерах всё ещё называла T59. +- **Открыто:** + - вопрос к автору: после прогона удаляется весь `.claude/settings.local.json`, как велит задача. Если там лежат и другие настройки автора, они пропадут. Убирать ли тогда только правило прогона? + - `.claude/settings.local.json` git не игнорирует: файл разрешения живого прогона виден в `git status`, и коммит может его забрать. В `.gitignore` образца такой строки нет. Предлагаю добавить её в T04, которая пишет `.gitignore`. +- **Разделы:** CLAUDE.md (How we work, Tests, Logging, Documents); правила 3, 6, 9 и 10 этого файла; R25, R34; standalone: R06, R31, R35, R39, R50, R111, R141, R169, R207. + +#### T60. Devcontainer: версии и наши отличия от образца + +- **Цель:** контейнер — на тех же версиях, что образец от 2026-10-11, а наши намеренные улучшения записаны и не откатятся при следующей сверке. +- **Сделать:** + - **Версии:** + - Go 1.27.1 → 1.27.2: `ARG GO_VERSION` в `.devcontainer/Dockerfile`, директива `go` в `go.mod`, spec 3.2 и `docs/stack.html`; + - golangci-lint 2.13.2 → 2.14.0: `ARG` и `docs/stack.html`. + + Остальные `ARG`, features, расширения VS Code и digest совпадают с образцом — сверено 2026-10-11. + - **Решение в `docs/decisions.md`** (номер — свободный на момент записи): чем devcontainer canvas-api намеренно отличается от образца. Без записи следующая сверка может это откатить: + - `host-network.sh` находит интерфейс через `ip route get` и MTU только понижает; + - `post-start.sh` пишет настройку docker, только если она изменилась, не трогает `docker0` и просит перезапустить контейнер; + - Dockerfile чистит кеши Go и npm в конце стадии; + - в `.gitattributes` правило `* text=auto` стоит первым; + - `.dockerignore` исключает `**/.env`; + - `just claude-update` принимает версию и проверяет оба пина; + - Python и uv образца не ставятся: в canvas-api Python нет (T03). +- **Как проверить:** + - Rebuild Container; внутри `go version` и `golangci-lint version` совпадают с `ARG`; + - `go test ./...` зелёный. +- **Сделано (2026-10-12):** + - версии: `ARG GO_VERSION=1.27.2` и `ARG GOLANGCI_LINT_VERSION=2.14.0` в `.devcontainer/Dockerfile`, `go 1.27.2` в `go.mod`, spec 3.2 и пять строк `docs/stack.html`. Сверено заново: все `ARG` Dockerfile, кроме uv и Python, и digest базового образа совпадают с образцом, а features и расширения VS Code — кроме пакета Python; + - решение R46 — в разделе «Архитектура и инфраструктура»; к R11 — пометка со ссылкой на R46, во вступление журнала — строка о R46; + - в подвале `docs/stack.html` среди сделанных теперь T59 и T60, в источниках — R01–R46; + - сверх текста задачи, на проверку: + - в R46 есть третье отличие `host-network.sh`, которого нет в списке задачи: сеть, созданную без опции MTU, скрипт читает как сеть с MTU 1500. Образец в этом случае пытается создать сеть заново и падает. Проверено на docker в контейнере: `docker network inspect` даёт пустую строку, повторный `docker network create` выходит с кодом 1; + - `claude-update` образца тоже принимает версию. Отличие в том, что наш рецепт берёт её из окружения, а не вставкой в текст скрипта; так R46 и пишет; + - остальные отличия R46 называет коротко: постоянные — имена, порт 5173, комментарии, а временные — то, что принесут следующие задачи E1; + - строку источников spec 20 «проверено … на Go 1.27.1» и историю T02 и T03 не трогал: это записи о том, что было сделано на той версии; + - проверки: образ собран из нового Dockerfile так же, как его собирает Rebuild Container (`docker build -f .devcontainer/Dockerfile --target devcontainer .`). Внутри `go version` — go1.27.2, `golangci-lint version` — 2.14.0, gopls 0.23.0 и dlv 1.27.2 собрались. В этом образе `gofmt -s`, `go vet ./...`, `go test ./...` и `go test -race ./...` зелёные. Выключенных проверок и fuzz нет: кода в правке нет. Дерево держит только эту правку, поэтому ревью шло в нём самом; + - ревью `/code-review high`, первый круг: 8 замечаний. Исправлено: R46 не называла временное отличие — фразу о `.sonarlint/connectedMode.json`, которая вернётся с T07; вывод о `docker0` подан как факт без ссылки на T03, откуда он взят; у R46 не было «Отвергнуто»; здесь не было итога ревью; в «Разделах» не было R46. Отброшено: `go 1.27.2` ломает Go в нынешнем контейнере — так велит задача, а Rebuild Container — её шаг проверки (ниже, в «Открыто»); команды T02 и T03 на 1.27.1 — это тела сделанных задач, запись того, что было (правило 1); подвал `docs/stack.html` называет T60 сделанной раньше отметки `[x]` — отметка ставится после ревью; + - второй круг: 5 замечаний. Исправлено: R46 обещала, что фразу о `.sonarlint/connectedMode.json` вернёт T07, а тело T07 о `devcontainer.json` молчит — теперь R46 говорит только, что фраза возвращается вместе с файлом, а предложение для T07 — ниже; R46 не называла временными короткий `.dockerignore` (его дополнит T04) и рецепты `justfile`; R46 пересказывала комментарии скриптов — теперь в ней только отличия и их причины; у находки S8431 не было названо, на что ссылаться. Отброшено: устаревший MTU в `daemon.json` после ухода туннеля — `post-start.sh` берёт MTU интерфейса самого контейнера, а он меняется, только если пересоздать сеть `canvas-api` на хосте. Старый контейнер после этого не стартует, а у нового нет старого `daemon.json`. +- **Открыто:** + - Rebuild Container — шаг автора. До него в нынешнем контейнере (Go 1.27.1, `GOTOOLCHAIN=local`) `go test ./...` отказывает: `go.mod requires go >= 1.27.2`; + - `.sonarlint/connectedMode.json` приносит T07, но её тело не называет `devcontainer.json`, а правило 1 не даёт трогать файлы, которых задача не называет. Предлагаю дописать в T07: вернуть в комментарий SonarQube for IDE фразу о файле, как в образце (R46); + - SonarQube for IDE отмечает `docker:S8431` («тег или digest, не оба») на `FROM` базового образа. Когда T07 подключит SonarCloud, эта находка останется открытой по правилу 3: убрать тег или digest — значит нарушить правило точных версий (CLAUDE.md, spec 1), на него и ссылаться. +- **Разделы:** spec 3.2; R11, R46; standalone: R05. + +#### T04. Проверки Go и образ сервиса + +- **Цель:** те же локальные проверки качества и тот же образ сервиса, что в standalone, — до того, как появится CI. +- **Сделать:** перенести из `reference/mathtrail-standalone/` и приспособить. + - **`justfile`, Go-часть:** + - рецепты `fmt`, `fmt-check`, `lint`, `test`, `build`, `run`, `mocks`, `ci-lint`, `ci-test`, `ci-mocks-check`, `release-artifacts`, `docker-build`, `docker-run`; + - `ci-test` — с `-race`, покрытие в `coverage.out`; + - переменные сборки и ldflags — как в образце, модуль `github.com/MathTrail/canvas-api`. + - **`.golangci.yml`** — файл образца: формат v2, тот же набор линтеров и настроек, включая `forbidigo`, `gocognit`, `interfacebloat` и `hugeParam`. Поменять: + - depguard `domain-stays-pure` для `internal/domain/**` и пакетов сценариев (`internal/check/**` и соседних, spec 18): без `net/http`, gin, Firebase, OpenFGA, genai, pgx, `database/sql` и Centrifugo; + - вместо запрета pgx везде — pgx только в `internal/infra/postgres` и `cmd/migrate`; + - `goimports.local-prefixes` — `github.com/MathTrail/canvas-api`; + - убрать `run.build-tags: [analytics]` — это продукт образца; + - `hugeParam.sizeThreshold`: порог образца 88 держится на его продуктовой причине (R47 standalone). Взять умолчание 80 или записать свою причину. + - **Прочие конфиги:** + - `.mockery.yaml` — как в образце, с пустым `packages: {}`: первый мок появится с первым интерфейсом, которому он нужен (R43 standalone); + - `.githooks/pre-commit` — как в образце: форматирование, сборка, тесты; без тулчейна — предупреждение и пропуск; + - `.vscode/launch.json` — две конфигурации образца: запуск сервера и отладка теста под курсором. Третья, «as Cloud Run sees it» с `K_SERVICE`, придёт вместе с каркасом в эпике MVP. + - **`internal/version`:** `Version`, `Commit` и `Date` через `-ldflags -X`. Тесты — как в образце: один собирает бинарник с ldflags и читает значения, другой проверяет, что Dockerfile и justfile ставят те же символы. + - **`cmd/server`:** + - `/health` отвечает `{"status":"ok","version":…,"commit":…}` с `Cache-Control: no-store` — по коммиту CD узнает, что выехала нужная ревизия (T11); + - при старте — строка лога с версией. + - **`Dockerfile` сервиса:** + - сборка на `golang:1.27.2-trixie` по digest образца — та же версия, что в `go.mod` (T60): `CGO_ENABLED=0`, `-trimpath`, ldflags с версией; + - образ запуска — `gcr.io/distroless/static-debian13` по digest, `USER nonroot:nonroot`. + - **`.gitignore`** — по образцу: `bin/`, `dist/`, покрытие, `node_modules/`, Terraform, `.scannerwork/`; `reference/` остаётся. + - В правилах Terraform сразу исключения `!infra/terraform/prod.auto.tfvars` и `!infra/terraform/backend.hcl`: эти файлы коммитятся (T10). + - **`.dockerignore`** — явный список, как в образце. К нынешним `.git`, `**/.env`, `.devcontainer`, `reference`, `docs`, `draft`, `*.md` добавить `.github`, `.vscode`, `.sonarlint`, `infra`, `testdata`, `tools`, `bin`, `dist`, `coverage.*`, `**/node_modules`. + - **`CLAUDE.md`, Environment** — как в образце: + - пункты «Git hooks» и «The runtime image»; + - список рецептов, которые обязательно зелёные: `ci-lint`, `ci-test`, `ci-mocks-check`. Его дополняют T05, T06, T08 и T10; + - строка «Build context» — по новому `.dockerignore`. +- **Тесты:** `/health` (код, тело, заголовок); `internal/version`. +- **Как проверить:** + - `just ci-lint`, `just ci-test` и `just ci-mocks-check` зелёные; + - `just docker-build && just docker-run` → `curl localhost:8080/health` показывает коммит; + - pre-commit останавливает `git commit` с неотформатированным файлом. +- **Не делать:** config, logger и gin — каркас эпика MVP; web — T05; CI — T06. +- **Сделано (2026-10-12):** + - `justfile`: тринадцать рецептов Go-части, переменные сборки и ldflags — как в образце, с модулем `github.com/MathTrail/canvas-api`. `build` и `release-artifacts` не собирают виджет: SPA в бинарник не входит (R10). `docker-run` — без ключа печати образца; + - `.golangci.yml` — файл образца с правками задачи и ревью: + - `domain-stays-pure` держит весь `internal/`, кроме `app`, `infra`, `telemetry` и `transport`. Так под правило попадают и `check`, и соседние сценарии, которых ещё нет (spec 18); + - запрещены `net/http`, gin и chi, Firebase, OpenFGA, genai, pgx, `database/sql` и Centrifugo; + - `pgx-stays-in-postgres` пускает pgx только в `internal/infra/postgres` и `cmd/migrate`; + - `goimports.local-prefixes` — `github.com/MathTrail/canvas-api`, `run.build-tags` убран, у `hugeParam` умолчание 80 с объяснением; + - `.mockery.yaml` с `packages: {}`, `.githooks/pre-commit` и `.vscode/launch.json` с двумя конфигурациями — как в образце, с поправками ревью (ниже); + - `internal/version` и два теста образца; `cmd/server`: + - `/health` отвечает `{"status","version","commit"}` с `Cache-Control: no-store`; + - при старте пишет одну JSON-строку с ключами, по которым Cloud Logging читает запись: `severity`, `message: startup`, версия, коммит, дата и адрес. Логгер — каркас эпика MVP, поэтому строку пишет `encoding/json`; + - `Dockerfile` сервиса: `golang:1.27.2-trixie` по digest образца, `CGO_ENABLED=0`, `-trimpath`, ldflags с версией; запуск — `gcr.io/distroless/static-debian13` по digest, `USER nonroot:nonroot`. `COPY go.* ./` вместо `go.mod go.sum`: зависимостей нет, и `go.sum` тоже; + - `.gitignore` по образцу, с исключениями `prod.auto.tfvars` и `backend.hcl`; `.dockerignore` — явный список задачи; + - `CLAUDE.md`, Environment: рецепты на каждый день и обязательные `ci-lint`, `ci-test`, `ci-mocks-check`; «Git hooks», «The runtime image», «Build context»; + - сверх текста задачи, на проверку: + - `fmt`, `fmt-check` и хук форматируют только файлы самого репозитория. `gofmt -s -w .` образца зашёл бы в `reference/`, а копию править нельзя. Сегодня все 611 Go-файлов копии отформатированы, но новый gofmt или обновлённый снимок могут это изменить; + - правило `domain-stays-pure` задано от обратного, чтобы не перечислять сценарии, которых ещё нет. Под ним оказываются и `config`, `logger`, `version`, `apierror`: таких импортов им тоже не нужно. Новый пакет ввода-вывода вписывается в исключения по имени; + - chi запрещён рядом с gin: роутер ещё не выбран (Т-1); + - `.claude/settings.local.json` в `.gitignore` — по предложению из «Открыто» T59; + - `ci-mocks-check` добавлен в обязательные и в правило 7, и в CLAUDE.md (How we work), чтобы три места называли один список; + - в `docs/stack.html` статус «в репозитории» получили depguard, `internal/version`, образ сервиса и gofmt; в подвале — T00–T04; + - Dockerfile и CLAUDE.md объясняют, почему образ запуска закреплён только digest: у distroless нет тегов с версией. Исключение вписано и в Hard rules; + - проверки — в образе стадии `toolchain` того же Dockerfile (Go 1.27.2, golangci-lint 2.14.0): контейнер ещё на Go 1.27.1. + - `golangci-lint config verify`, `just ci-lint` — 0 замечаний, `just ci-test` — покрытие `cmd/server` 81,1 %, `just ci-mocks-check`. + - depguard в копии дерева: отклонил `net/http` в домене, в `check`, в соседнем сценарии и в `version`, `database/sql` в `check`, pgx в домене и в `infra/gemini`; пропустил `net/http` в `infra`, `transport`, `app`, `telemetry` и pgx в `infra/postgres` и `cmd/migrate`. + - `just docker-build && just docker-run`: `curl localhost:8080/health` → `{"status":"ok","version":"dea01ca-dirty","commit":"dea01ca"}` с `no-store`, строка старта называет версию и коммит, образ — 16,2 МБ. + - Хук в одноразовом репозитории: + - останавливает `git commit` с неотформатированным файлом; + - пропускает коммит, когда неопрятный файл лежит только в `reference/`; + - останавливает коммит, где поменялся только justfile, если тот ломает путь ldflags (хук образца такой коммит пропускает); + - без Go предупреждает и пропускает проверки; + - `just fmt` файл в `reference/` не тронул. + - 14 поломок новых проверок в копиях дерева — четыре по `/health`, шесть по строке старта, четыре по `internal/version`: все пойманы. Fuzz нет: внешний ввод здесь никто не разбирает. + - ревью `/code-review high`, первый круг: 9 замечаний. + - Исправлено, и всё это — тоже отличия от образца: + - пресет `comments` глушил `exported` и `package-comments` у revive, то есть правило doc-комментариев из CLAUDE.md не проверялось. В копии с пресетом — 0 замечаний, без него — 3; наш код чист и без пресета. Пресет убран; + - `template: mockery` и `with-expecter` mockery v3.8.0 отвергает: шаблона с таким именем нет, а у testify нет такой опции. Теперь `template: testify`, и mock с `EXPECT()` получается — проверено на Go 1.27.1; + - условие хука `**/*.go` без `globstar` видит только один уровень, поэтому у нас хук пропускал все коммиты без `.go`. Условие переписано под нашу раскладку; + - в `domain-stays-pure` добавлены `google.golang.org/api` и `cloud.google.com/go`: в образце первый тоже запрещён; + - локальный `go.work` попадал в сборку образа — `.dockerignore` его исключает. Ещё он теперь исключает каждый `testdata/`, `web/`, `.claude/`, `.githooks/`, а комментарий говорит правду. + - Отброшено: + - хук смотрит рабочее дерево, а не индекс — так устроен хук образца: дешёвая первая проверка, а то, что закоммичено, проверяет CI; + - `just test` и хук без `-race` — так в образце: детектор гонок — дело `ci-test` (CLAUDE.md, Environment), а R207 standalone опирается на прогон без него; + - нет кеша сборки в образе, и `DATE` сбрасывает слой — кеш через `--mount` отвергнут (R36 standalone), а сборка с датой пересобирается по замыслу. + - второй круг: 10 замечаний. + - Исправлено: + - хук прятал, какой тест упал: теперь при провале показывает вывод `go test`; + - в строке старта не было ключа `time`, который CLAUDE.md (Logging) называет рядом с `severity` и `message`. Добавлен, тест проверяет момент; + - правило «тег плюс digest» в Hard rules не знало исключения для distroless — исключение вписано; + - тест строки старта ждал только строку, и ранний выход `run()` выглядел как тайм-аут на 5 с. Теперь тест сразу называет ошибку; + - `.dockerignore` пропускал `*.out`, `*.test`, `*.prof` и `.scannerwork` — добавлены; + - `find` заходил в `node_modules` и `.terraform` — теперь не заходит, а хук и justfile ссылаются друг на друга, чтобы список не разошёлся; + - комментарий к `domain-stays-pure` обещал больше, чем правило проверяет. Теперь он называет ровно то, что запрещено; + - `.gitignore` по образцу потерял строки шаблона GitHub (`*.so`, `*.dylib`, `*.dll`, `*.exe~`, `*.coverprofile`, `profile.cov`) — возвращены. + - Отброшено: + - хук блокирует коммит, когда Go старше `go.mod`: причину называет сам `go build`. На хосте Go нет, а хук включится тем стартом контейнера, который принесёт Go 1.27.2; + - `**/internal/**` совпадёт со всем деревом, если клон лежит в каталоге `internal`: depguard сверяет абсолютные пути и не умеет привязываться к корню модуля. Такой клон маловероятен, а провал был бы громким. +- **Открыто:** + - Rebuild Container — шаг автора (T60). До него в контейнере не работают команды Go, а хук `post-start` включит только при следующем старте контейнера; + - mockery 3.8.0 не загружает пакеты на тулчейне Go 1.27.2: «export data version 5 is greater than maximum supported version 4». Так и с `go 1.27.1` в модуле, и с пустым кешем, а на Go 1.27.1 всё работает. Внутри mockery — golang.org/x/tools v0.49.0. Пока `packages: {}`, рецепты моков ничего не делают, но к первому моку (эпик MVP) mockery придётся поднять. В образце те же версии; + - отличия проверок от образца — пресет `comments`, шаблон testify, условие хука, отказ `gofmt` от `reference/`, правило depguard от обратного — объяснены в комментариях файлов, но не в журнале решений. Предлагаю записать их решением, как R46 для devcontainer: иначе следующая сверка может их откатить; + - SonarQube for IDE отмечает `docker:S8431` и на `FROM` сборщика в `Dockerfile` сервиса — отвечать вместе с находкой из T60, когда T07 подключит SonarCloud. +- **Разделы:** spec 3.2, 18; R30; standalone: R05, R20, R35, R43, R47. + +#### T05. Web-тулчейн + +- **Цель:** у SPA с первого дня те же проверки, что у виджета standalone: формат и линт, типы, тесты, сборка, лицензии и уязвимости npm. +- **Почему:** продуктового кода в E1 нет, но CI (T06), релиз (T08) и выкладка на Hosting (T11) должны собирать и проверять SPA с первого дня. Поэтому web приходит заглушкой, сразу с тулчейном и проверками виджета standalone (standalone: R84, R85). +- **Решено автором (2026-10-12):** + 1. **JSX собирает `@vitejs/plugin-react` 6.1.2: нужен Fast Refresh в dev-сервере.** + - Плагин под MIT, его единственная зависимость — `@rolldown/pluginutils` (MIT), peer — `vite ^8.0.0`. + - Babel он больше не тянет: Babel и React Compiler — только необязательные peer. + - Тот же плагин ставит официальный шаблон `create-vite` для React. + - Образец отверг `@preact/preset-vite` (R84 standalone): Babel ради горячей перезагрузки, которой нет у чат-хоста. Здесь не верен ни один из двух доводов. + - Отвергнуто: + - сборка по tsconfig без плагина, как в образце: работает, но правка компонента перезагружает страницу; + - `@vitejs/plugin-react-swc`: тянет нативный `@swc/core`, а рядом с Oxc в Vite 8 он лишний; + - `@vitejs/plugin-react-oxc`: peer только `vite ^6.3 || ^7`, и он влит в plugin-react. + - Записано решением R47. + 2. **`ci-licenses` и `ci-vuln` обязательны всегда**, как `ci-lint`, `ci-test` и `ci-mocks-check`. Это записано в правиле 7 и в CLAUDE.md (How we work, Environment). + 3. **В `go.mod` — `ignore node_modules`** (директива Go 1.25+). + - Сегодня ни в одном пакете дерева `web/` нет файлов `.go`, но npm-пакеты с Go бывают: например, `flatted` 3.4.4 несёт `golang/pkg/flatted/flatted.go`. + - Без директивы `./...` сделал бы такой файл пакетом модуля, и его разбирали бы линтер, тесты, govulncheck и go-licenses. + - `GO_FILES` и хук уже пропускают `node_modules` для gofmt. + - В образце директивы нет; отличие записано в R47. +- **Выяснилось при планировании (2026-10-12):** + - **Vite 8.3.2** (документация тега v8.3.2): + - сам читает `jsx` и `jsxImportSource` из tsconfig; + - цель сборки по умолчанию — `chrome111`, `edge111`, `firefox114`, `safari16.4`, `ios16.4`. Product 10 требует «последние версии», поэтому `build.target` не задаётся; + - CSS по умолчанию минифицирует `lightningcss`; + - `server.allowedHosts` пускает только `localhost`, `*.localhost` и IP-адреса. Туннелю cloudflared до планшета понадобится разрешение — это дело E2. + - **React и типы:** React 19.3.0 — `latest`. У `@types/react` и `@types/react-dom` 19.3.0 тег `ts7.0`. `IS_REACT_ACT_ENVIRONMENT` они не объявляют, поэтому тест ставит его через `vi.stubGlobal`. + - **JSX в тестах.** Как в образце, `vitest.config.ts` плагинов не грузит, а при нём Vitest не читает `vite.config.ts`. Значит: + - тесты компилируют JSX по tsconfig; + - dev и сборка — через плагин, а он сам ставит автоматический runtime из `react` (его `dist/index.js`). + + Это те же настройки, что `react-jsx` в tsconfig. Плагину JSX-опций не передают, чтобы оба места стояли на общем умолчании; правка JSX-настроек — правка обоих. + - **Biome 2.5** сам включает рекомендованные правила домена React, когда в зависимостях есть `react` (biomejs.dev/linter/domains). + - **Ignore-файлы.** `.dockerignore` уже исключает весь `web` и `**/node_modules` (T04), а `.gitignore` держит `node_modules/` и `dist/` на любой глубине. Новая строка одна — `/web/coverage/`. + - **`THIRD_PARTY_LICENSES`** появляется здесь, а не в T06: без него `ci-licenses` не зелёный. T06 только запускает рецепт в CI. + - **Vite 8.3.3 и 8.3.4** вышли 2026-10-06 и 2026-10-08, после пина образца. + - В 8.3.3 — правки `server.fs` dev-сервера, перенесённые в тот же день в 8.2.4 и 8.1.6. + - Опубликованного GHSA на 8.3.2 нет (GitHub Advisory API). + - Сначала пин остался за образцом, как велела задача. После отчёта его подняли до 8.3.4 (см. «Сделано»). + - Пока dev-сервер наружу не открыт: он слушает только `127.0.0.1` и отвечает только своим хостам (`allowedHosts`). Туннель до планшета (E2) это снимет: cloudflared подключается изнутри контейнера, а `npm audit --omit=dev` dev-зависимость vite не видит. Как это закрыто — R48 и E2. +- **Сделать:** + - **`web/`** — минимальное приложение: страница-заглушка с названием и версией сборки, один тест. + - **Стек** — spec 3.1 и R84 standalone: + - React 19, Vite 8; + - TypeScript 7 только для проверки типов — сам он ничего не компилирует; + - Biome — формат и линт; Vitest с happy-dom; + - версии точные, `package-lock.json`, `.npmrc` с `save-exact` и `ignore-scripts`; + - версии берутся из `web/package.json` образца, а не из `docs/stack.html`: Biome 2.5.15, Vitest и `@vitest/coverage-v8` 5.0.3, TypeScript 7.0.2, Vite 8.3.2, happy-dom 20.14.5, `@types/node` 24.19.1. `docs/stack.html` поправить. + - **React.** Готового шаблона в образце нет: там Preact, JSX собирается без плагина Vite (R84 standalone). При планировании решить, как собирать JSX: + - через tsconfig, без плагина; + - через `@vitejs/plugin-react` или `-swc` — проверить лицензию и совместимость с Vite 8 и решить, нужен ли быстрый перезапуск компонентов. + - **Конфиги:** + - два tsconfig — для браузера (DOM) и для Node — и скрипт `typecheck`; + - отчёт покрытия Vitest — `lcovonly` с `projectRoot: ".."`, чтобы Codecov и SonarCloud нашли файлы (T07); + - dev-сервер Vite слушает `host: "127.0.0.1"`, `port: 5173`, `strictPort: true`. Если Vite сам выберет `localhost`, он слушает только IPv6, и проброшенный порт до него не дойдёт. + - **`web/scripts/licenses.ts`** — как в образце (R85 standalone): + - проверка лицензий всех пакетов из lockfile, включая dev, и строки для `THIRD_PARTY_LICENSES`; + - `lightningcss=MPL-2.0` — единственное исключение из списка: его тянет Vite 8, без исключения `ci-licenses` упадёт. Исключение записать решением в `docs/decisions.md`, как R85 standalone: CLAUDE.md пускает только зависимости, совместимые с MIT. Довод — пакет только собирает CSS и в сборку не попадает, а MPL-2.0 — copyleft на уровне файла, наш код он не задевает. + - **Рецепты:** + - `web-install`, `web-build`, `web-fmt`, `web-lint`, `web-test` и `web-dev` — dev-сервер Vite; + - `ci-web` — Biome, типы, тесты с покрытием в `web/coverage/lcov.info`; + - `licenses` и `ci-licenses` — Go и npm, список разрешённых лицензий образца; + - `ci-vuln` — `govulncheck` и `npm audit --omit=dev --audit-level=high`. + - SPA в бинарник не встраивается: её раздаёт Firebase Hosting (R10). Поэтому `build` и Dockerfile сервиса web не трогают. Текст npm-раздела в `THIRD_PARTY_LICENSES` говорит то же. + - `.gitignore` и `.dockerignore`: `web/node_modules`, `web/dist`, `web/coverage`. + - **`CLAUDE.md`, Environment** — список обязательных рецептов дополняется `ci-web`, `ci-licenses` и `ci-vuln`. +- **Тесты:** один тест компонента в happy-dom. +- **Как проверить:** + - `just ci-web`, `just ci-licenses` и `just ci-vuln` зелёные; + - `just web-build` кладёт сборку в `web/dist`; + - `just web-dev` в контейнере открывается на проброшенном порту 5173. +- **Не делать:** Konva, protobuf, centrifuge-js и Firebase SDK — их приносят задачи контрактов и SPA. +- **Риски:** TypeScript 7 и Vite 8 молоды — версии сверить с их release notes. +- **Сделано (2026-10-12):** + - **`web/`:** + - стек: React 19.3.0, Vite 8.3.4 с `@vitejs/plugin-react` 6.1.2, TypeScript 7.0.2 (только типы, две программы), Biome 2.5.15, Vitest 5.0.3 с happy-dom 20.14.6 и `@vitest/coverage-v8`. Пины образца — Vite 8.3.2, happy-dom 20.14.5 и `@types/node` 24.19.1 — подняты после отчёта (ниже); + - `.npmrc` образца, lockfile на 118 пакетов, файлов `.go` в `node_modules` нет; + - страница-заглушка `App`: название и версия сборки из `VITE_VERSION` (`buildVersion`, иначе `dev`); + - тесты: компонент в happy-dom (`createRoot` и `act` из `react`, флаг act через `vi.stubGlobal`), версия, `scripts/licenses.ts`. Последний — файл образца, сменены только слова «the widget and the site» → «the SPA»; + - **`go.mod`:** `ignore node_modules` (решение 3); + - **`justfile`:** + - переменные: пины govulncheck v1.8.0 и go-licenses v2.0.1 образца, `ALLOWED_LICENSES`, `NPM_LICENSE_EXCEPTIONS`; + - рецепты: `licenses`, `_license-list`, `web-install`, `web-build`, `web-fmt`, `web-lint`, `web-test`, `web-dev`, `ci-web`, `ci-vuln`, `ci-licenses`; + - SPA в сборку сервиса не входит (R10): `build`, `release-artifacts` и `Dockerfile` web не собирают. Кавычки в `build`, `docker-build` и `release-artifacts` поправлены после отчёта — ниже; + - **`THIRD_PARTY_LICENSES`:** `std` go1.27.2 и `react`, `react-dom`, `scheduler`; + - **`.gitignore`:** `/web/coverage/`. `.dockerignore` не менялся; + - **документы:** + - решения R47 (тулчейн SPA) и R48 (лицензии npm); + - CLAUDE.md: How we work, Tooling and images, Environment; + - правило 7 (решение 2); + - `docs/stack.html`: легенда, версии и статусы клиента и проверок, подвал; + - **сверх текста задачи, на проверку** — каждое пришло из ревью или проверок, а в R47 записано всё, чем мы отличаемся от образца: + - `tsconfig.node.json` — `lib: ["ES2022"]` без DOM: в образце программа Node наследует DOM, и `document` в скрипте проходил проверку; + - `web-build` сначала удаляет `web/dist`. Vite чистит выход, только когда начинает писать, поэтому упавшая сборка оставляла прежнюю — проверено; + - `_license-list` показывает вывод go-licenses, если отчёт упал. Образец его глотает: там та же поломка печатает только «recipe failed» — проверено; + - `web-dev` принимает версию так же, как `web-build`, через окружение. Версия, вставленная в текст команды, выполнила бы `$(…)` из имени тега — показано на пробном justfile; + - к тестам `licenses.ts` добавлен перебор всех выражений до пяти токенов из семи — около 20 тысяч, 0,2 с под покрытием. Свойства: пустой список ничего не пропускает, длинный пропускает не меньше короткого, и принимается только предложение (третье свойство добавлено ревью доработок, ниже); + - `index.html` с `meta charset` и viewport (без него Safari на iPad раскладывает страницу на 980 px); имена компонентов — в PascalCase, как в образце и create-vite; + - **проверки:** + - зелёные: `just ci-lint`, `just ci-test` (`cmd/server` — 81,1 %), `just ci-mocks-check`, `just ci-web` (53 теста; покрытие web — 95,7 % строк, в `licenses.ts` не покрыт только запуск из командной строки), `just ci-licenses`, `just ci-vuln`, `just docker-build`; + - `just web-build v9.9.9` кладёт сборку в `web/dist`, и бандл называет `v9.9.9`; пути в `lcov.info` — от корня (`SF:web/...`); + - `just web-dev` слушает только `127.0.0.1:5173`, IPv6 не отвечает, страница называет версию дерева; + - Fast Refresh работает: в `index.html` есть преамбула, `/@react-refresh` отдаётся, а в `App.tsx` есть `$RefreshReg$`; + - **выключенные проверки** — 19, все покраснели или показали отказ: + - версия убрана из `App`; + - запасное `dev`: `??` вместо `||`, а потом без запаса вовсе; + - в `licenses.ts`: `AND` как `OR`; исключение для поставляемого пакета; `WITH` принят; непарсимое принято — последние два ловит перебор; + - лицензия GPL-3.0 в lockfile; устаревший `THIRD_PARTY_LICENSES`; + - формат; условный хук (домен React в Biome включён); + - `process` и `Buffer` в коде страницы; `document` в скрипте; ошибка типа в `vite.config.ts`; + - битый импорт (сборка); `plugins: []` — преамбула и `$RefreshReg$` пропадают; + - `minimist@1.2.5` в копии `web/`: в `dependencies` audit красный, в `devDependencies` зелёный; + - Go-файл в `web/node_modules`: без `ignore` его видит `go list`, и `go vet` красный; с `ignore` зелёные `go list`, `go vet`, `ci-lint` и `ci-test`; + - Biome без `useIgnoreFile` — 3084 ошибки на `dist/`. + - **fuzz:** Go-кода в правке нет; разбор SPDX держат таблица образца и перебор. Флейков не было; + - **документация, прочитана 2026-10-12:** + - npm registry: версии, лицензии, peer; + - Vite v8.3.2 (features, build и server options) и changelog 8.3.3–8.3.4. Для v8.3.4 те же страницы сверены заново: по сути они не изменились; + - README и CHANGELOG plugin-react 6.1.x; + - CHANGELOG React 19.3.0; + - release notes TypeScript 7.0: убраны `baseUrl`, `moduleResolution` node10 и classic, target ES5 — ни одной из этих опций у нас нет; + - biomejs.dev (домены линтера) и схема Biome 2.5.15; + - go.dev/ref/mod (`ignore`); + - SPDX 2.3 (операторы чувствительны к регистру); + - GitHub Advisory API; + - **ревью `/code-review high`:** + - первый круг, 8 замечаний: + - исправлено: «слушает 127.0.0.1» подавалось как защита dev-сервера, а туннель её обходит; «tsconfig — единственный источник JSX» неверно — плагин сам ставит автоматический runtime из `react`; `web-dev` вставлял `{{ VERSION }}` в текст команды; `_license-list` глотал ошибки go-licenses; + - отброшено: подвал `stack.html` называет T05 сделанной раньше `[x]` — отметка ставится после ревью, как в T04 и T60; строчные `or`/`and` в лицензии — по SPDX 2.3 операторы чувствительны к регистру, отказ безопасен; go-licenses идёт дважды — так в образце, это секунды, а один проход переписал бы проверку go-licenses; + - вопрос к автору — ниже; + - второй круг, 7 замечаний: + - исправлено: комментарий `web-build` обещал, что упавшая сборка не оставит прежнюю, — неверно, проверено; `web-dev` и `web-build` получали версию по-разному; у разбора SPDX не было проверки свойств; `tsconfig.node.json` наследовал DOM; + - отброшено: в T05 нет «Открыто» — оно ниже; подвал `stack.html` — как в первом круге; `!coverage` в `biome.json` дублирует `.gitignore` — так в образце, и Biome обходит покрытие, даже если файл игнора не читается (без него он проверял `dist/`); + - исправления второго круга прошли проверки, третьего ревью нет (CLAUDE.md). + - **после отчёта, по поручению автора (2026-10-12):** открытые вопросы автор оставил на усмотрение исполнителя. Образец — только отправная точка: после переноса практики можно улучшать под canvas-api. + - Уязвимости dev-зависимостей (R48): + - `ci-vuln`, как и прежде, проверяет только то, что раздаётся, — иначе совет по инструменту сборки останавливал бы выкладку; + - инструменты разработки видят алерты Dependabot, они помечают dev-зависимости меткой Development (документация GitHub); + - dev-сервер проверяется там, где его открывают наружу: рецепт туннеля в E2 запускается, только если `npm audit` вместе с dev-зависимостями чист. Требование записано в E2; + - Пины подняты до свежих патчей своих линий: Vite 8.3.4 (в 8.3.3 — правки `server.fs`), happy-dom 20.14.6, `@types/node` 24.19.2. + - Мажоры happy-dom 21 и `@types/node` 26 не взяты: `@types/node` идёт за Node 24 из контейнера, а смену мажора оставляем Dependabot. + - Дерево по-прежнему 118 пакетов, лицензии и `THIRD_PARTY_LICENSES` не изменились. + - Рецепты T04 `build`, `docker-build` и `release-artifacts` больше не вставляют версию в текст команды: `build` и `docker-build` передают её через `quote()`, `release-artifacts` — через окружение. + - Проверено: `just build` и `release-artifacts v0.0.1-test` ставят версию, суммы сходятся, `/health` образа называет версию и коммит. + - Враждебная версия `v1$(touch…)` ничего не выполнила. + - В `biome.json` убрано `files.includes`: исключения берутся из одного места, `.gitignore`. Проверено: неотформатированный `.js` в `web/coverage/` Biome пропускает, а тот же файл в `src/` ловит. + - Ревью доработок `/code-review high`, первый круг, 9 замечаний: + - исправлено: + - разбор SPDX пропускал выражение, где после `WITH` стоит не имя исключения: `MIT WITH ) OR ISC`, `MIT WITH OR OR ISC`, `MIT WITH ( OR ISC` проходили проверку. Ошибка пришла из образца. Добавлены случаи в таблицу и третье свойство перебора — принимается только предложение, по независимому от парсера суждению о том, что за чем идёт. До исправления краснели три случая и перебор, который сам нашёл ещё `WITH WITH`; + - тег образа в `docker-build` и `docker-run` теперь тоже через `quote()`; + - `web-install` переустанавливает пакеты и тогда, когда `package.json` новее установки. Иначе правка версии без `npm install` проходила локально и падала в CI; теперь `npm ci` падает и локально — проверено; + - `release-artifacts` отказывает версии с пробелом, `/` или `$(…)` и говорит почему: раньше такая версия ломала сборку невнятной ошибкой; + - вложенный `just` вызывается через `just_executable()`, а не первый `just` на `PATH`; + - R48 опирался на алерты Dependabot, которые никто не включал: они — настройка репозитория, и шаг внесён в «Я» T07; + - в «Сделано» строка о «не тронутых» `build` и `release-artifacts` переписана; + - отброшено: + - вернуть исключение `coverage` в `biome.json` на случай, если `.gitignore` не прочитан: Biome запускают только из репозитория — рецепты и CI, — где `.gitignore` есть всегда, а `dist/` на него опирался и раньше; + - `ci-web` повторяет вызовы `web-lint` и `web-test`: команды живут в скриптах `package.json`, и оба рецепта зовут одни и те же скрипты. + - Второй круг, 4 замечания: + - исправлено: + - перебор шёл 1,3 с под покрытием без явного тайм-аута — на нагруженном раннере это был бы флейк. Он сокращён до пяти токенов, 0,2 с, и по-прежнему ловит все три поломки разбора; + - `.gitignore` скрывал только `.env`, а Vite читает ещё `web/.env.local`, `.env.production` и прочие. Теперь игнорируются все `.env.*`, кроме `.env.example`. Варианты не попадают и в контекст образа: в `.dockerignore` добавлено `**/.env.*` — проверено пробной сборкой; + - устаревшие цифры в «Сделано» поправлены; + - отброшено: лишний `npm ci` после правки `package.json`, которая меняет только формат. Из кэша npm это около секунды (замерено 0,9 с), а сравнение содержимого добавило бы скрипт ради этой секунды; + - исправления второго круга прошли проверки, третьего ревью нет (CLAUDE.md). +- **Открыто:** + - `server.allowedHosts`, HMR через cloudflared и проверка `npm audit` перед туннелем — когда понадобится планшет (E2, записано в его блоках); + - пункт `THIRD_PARTY_LICENSES` в T06 уже сделан здесь; T06 только запускает `ci-licenses` в CI; + - **шаг автора:** открыть `http://localhost:5173` с хоста после `just web-dev`. Из контейнера проверено только `curl`. +- **Разделы:** spec 3.1, 18; R10, R13; standalone: R84, R85. + +#### T06. CI + +- **Цель:** каждая правка проверяется автоматически, теми же рецептами и в том же образе, что локально. +- **Сделать:** перенести из `reference/mathtrail-standalone/.github/` и приспособить. + - **`.github/workflows/ci.yml`** — на PR и `main`, `ubuntu-24.04`, actions по SHA, минимальные права, `timeout-minutes`. + - **Окружение, как в образце:** + - concurrency: на PR — группа по ветке, с отменой прежнего прогона; на `main` — группа по коммиту, чтобы свой прогон получил каждый коммит. GitHub держит в группе только один ожидающий прогон и вытесняет прежний даже без отмены. При общей группе на `main` промежуточные коммиты остались бы без CI. Здесь canvas-api отличается от образца; + - `safe.directory` через `GIT_CONFIG_*`; + - кеш Go в `/tmp` и `actions/cache` по хешам `go.sum`; + - `defaults.run.shell: bash` — для pipefail, когда вывод идёт через `tee`; + - `persist-credentials: false`, где задание ничего не пушит. + - **Задания:** + - `toolchain` — образ стадии `toolchain` публикуется в GHCR с тегом по хешу `.devcontainer/Dockerfile` и дальше используется по digest (`just ci-toolchain-image`, R20 standalone). Ссылка на образ сначала читается в переменную, потом идёт в выход; + - в контейнере этого образа: + - `lint` — `just ci-lint`; + - `test` — `just ci-test`, покрытие артефактом; + - `web` — `just ci-web`, покрытие артефактом; + - `mocks` — `just ci-mocks-check`; + - `dependencies` — `just ci-vuln` и `just ci-licenses`; + - `secrets` — `just ci-secrets`: gitleaks по всей истории, поэтому `fetch-depth: 0`; + - `image` — на раннере, ему нужен Docker. Сначала action `docker-mirror`, затем один вызов `just ci-image-smoke … ci-image-scan …`: образ собирается один раз и с одной датой сборки. + - `ci-image-smoke` пишется сами: в образце его нет. Основа — ожидание `/health` из `ci-image-widget` образца: образ стартует и отвечает на `/health`, при сбое печатает журнал. + - `ci-image-scan` — Trivy, `HIGH,CRITICAL`, `--ignore-unfixed`. Исключение в `.trivyignore` — только с причиной; находку в stdlib Go чинит новая версия Go (R38 standalone). + - **Обязательная проверка никогда не приходит из workflow с фильтром `paths:`.** Workflow, который не стартовал, оставляет обязательную проверку висеть (R231 standalone). Условную работу отсекает шаг внутри задания. + - **Composite actions**, версии которых берутся из `ARG` в Dockerfile devcontainer: + - `.github/actions/just`; + - `.github/actions/node`; + - `.github/actions/docker-mirror` — образы Docker Hub тянутся через `mirror.gcr.io`. Анонимный pull с общего IP раннера ловит 429 из-за чужого лимита. По R33 красный CI на `main` — это несостоявшаяся выкладка. + - **`.github/dependabot.yml`** — как в образце: + - экосистемы: gomod; npm `/web`; github-actions (`/` и `/.github/actions/*`); docker (`/` и `/.devcontainer`); + - раз в неделю; версии остаются точными; + - группа `codeql-action`: части CodeQL обновляются вместе; + - правило образца `ignore` для `node` не переносить: оно держится на Node-стадии в Dockerfile сервиса, которой у canvas-api нет. + - **`.gitleaks.toml`** пока не нужен: исключения образца продуктовые. Файл появится с первой ложной тревогой, с узким исключением: правило, путь и regex. + - **`THIRD_PARTY_LICENSES`** — пишет `just licenses`; CI сверяет его с графом зависимостей (R21 standalone). + - **`CLAUDE.md`, Environment:** + - проверки CI идут в стадии `toolchain` из GHCR по digest; + - список обязательных рецептов дополняется `ci-secrets`, `ci-image-smoke` и `ci-image-scan`. +- **Я:** пакет тулчейна в GHCR — публичный, как в образце. PR из форка тулчейн не публикует, а берёт готовый образ (R20 standalone). +- **Как проверить:** + - PR с намеренно сломанным форматированием падает в `lint`; + - чистый PR проходит все задания; + - лог gitleaks чистый; + - повторный прогон берёт образ тулчейна из GHCR, а не собирает его заново. +- **Не делать:** CodeQL, SonarCloud, Codecov и Scorecard — T07; релиз — T08; деплой — T11. +- **Сделано (2026-10-13):** + - **`.github/workflows/ci.yml`** — восемь заданий с точными именами для правил мержа T07: «Toolchain image», «Formatting and lint», «Tests», «SPA», «Generated mocks», «Vulnerabilities and licenses», «Secrets», «Runtime image». + - Окружение — как велит задача: concurrency на PR по его ссылке с отменой, на `main` по коммиту; `safe.directory` через `GIT_CONFIG_*`; кеш Go в `/tmp`; `shell: bash`; `persist-credentials: false` у каждого checkout; `timeout-minutes` у каждого задания. `packages: write` есть только у `toolchain`, фильтров `paths:` нет. + - Покрытие — артефакты `coverage-go` и `coverage-web`, для T07. + - **Composite actions** `just`, `node` и `docker-mirror` — из образца, версии из `ARG`. `node` пока никто не зовёт: оно нужно T08 и T11. + - **`.github/dependabot.yml`** — экосистемы и группа `codeql-action` образца; правила для `node` и группы MCP нет. + - **`justfile`:** `TOOLCHAIN_IMAGE`, `GITLEAKS` v8.30.1, `TRIVY_IMAGE` 0.75.0 по digest; рецепты `ci-toolchain-image`, `ci-secrets`, `ci-image-smoke`, `ci-image-scan`. + - **`.gitleaks.toml`** появился здесь же: первая ложная тревога нашлась в T06. Правило `generic-api-key` читает как ключ `idempotency_key` в примерах spec 10.7 — вид события и UUID. Исключение держит правило, путь и форму значения вместе. + - **Документы:** решение R49 — CI и его отличия от образца; вопрос Т-17 в spec 20; CLAUDE.md — How we work, Tooling and images, Environment; правило 7; блок E2 о первом моке; `docs/stack.html`. + - **Сверх текста задачи, на проверку** — всё записано в R49: + - тег тулчейна — хеш только стадии `toolchain`: новый Claude Code его не пересобирает. Собирается без контекста, и `COPY` в стадии падает громко; + - у каждого задания свой кеш Go, ключ — `go.mod`, `go.sum`, `justfile` и ссылка на образ тулчейна. Контейнер и шаг кеша записаны один раз YAML-якорями; у `mocks` кеша нет, пока нет моков; + - `ci-image-smoke` требует, чтобы `/health` назвал версию и коммит этой сборки, и не принимает сборку, которую git назвать не смог; + - Trivy читает копию образа (`docker save`, `--input`) без сокета Docker и видит `.trivyignore` — рецепт образца файла не видит; + - `ci-secrets` отказывает мелкому клону, называет находки (`--verbose`, значение скрыто) и сканирует ещё не закоммиченное — в своём индексе и хранилище объектов, не трогая репозиторий; + - Dependabot: правило для `golang` (новый Go — руками в трёх местах), явный `cooldown` три дня; + - пины свежее образца: `upload-artifact` v7.0.2, `setup-node` v7.1.0, Trivy 0.75.0. + - **Проверки:** + - в дереве зелёные `just ci-lint`, `just ci-test` (`cmd/server` — 81,1 %), `just ci-mocks-check`, `just ci-licenses`, `just ci-vuln`, `just ci-secrets`, `just ci-web` и `just ci-image-smoke canvas-api:dev ci-image-scan canvas-api:dev` — так их зовёт задание `image`; + - задания CI повторены на чистом клоне с этой правкой: в образе тулчейна, собранном рецептом и опубликованном в локальный registry, под root и с окружением workflow — все зелёные. `ci-toolchain-image`: первый прогон собирает и публикует, второй берёт готовое за 0,15 с; + - actionlint 1.7.12 вместе с shellcheck, схемы SchemaStore (`check-jsonschema` 0.34.1) для `ci.yml`, `dependabot.yml` и трёх actions, shellcheck рецептов, раскрытие якорей `yq` — без замечаний. Скрипты actions `just` и `node` прогнаны с поддельным окружением раннера; + - **выключенные проверки** — 16 поломок, все покраснели или отказали: + - `ci-secrets`, 8: токен в коммите; форма ключа идемпотентности в другом файле; другой ключ в `docs/spec.md`; без `.gitleaks.toml` — три находки; мелкий клон; репозиторий, который git не читает — gitleaks без стража говорит «0 commits, no leaks», с ключом в истории; незакоммиченная правка с токеном; новый файл с токеном. Для сравнения: в мелком клоне gitleaks без отказа не видит удалённого ключа, а `.env` и разрешённая форма в spec проходят, как и должны; + - `ci-image-smoke`, 4: сервер не стартует; стартует и сразу выходит — проверка печатает его журнал; версия теряется в Dockerfile, тест `internal/version` этого не видит; дерево без `.git` — до правки ревью проверка здесь проходила; + - `ci-image-scan`, 1: образ на Go 1.25.0 — 25 уязвимостей stdlib. Тот же список через сокет Docker; `.trivyignore` их снимает, а рецепт образца с тем же файлом остаётся красным; + - `ci-toolchain-image`, 3: публикация отказала; registry не назвал digest; `COPY` в стадии `toolchain`; + - fuzz нет: кода Go в правке нет. Флейков не было; + - **документация, прочитана 2026-10-13:** + - docs.github.com: синтаксис workflow и управление concurrency, в том числе `queue`; обязательные проверки и пропущенные задания; Dependabot в Actions — права токена; справочник `dependabot.yml` — `directories`, `groups`, `ignore`, `cooldown` и его умолчание; публикация пакетов из Actions; видимость и доступ пакетов; YAML-якоря — changelog 2025-09-18; + - `action.yml` каждого action на его SHA; release notes `upload-artifact` v7.0.2 и `setup-node` v7.1.0; + - Trivy 0.75.0: changelog, `trivy image --help` из образа, страница баз данных; README trivy-db — база собирается каждые 6 часов; + - gitleaks v8.30.1: README (конфиг и `[[allowlists]]`) и `sources/git.go` — команда `git log` по умолчанию; + - **ревью `/code-review high`:** + - первый круг, 9 замечаний: + - исправлено: smoke проходил без `.git` — у сборки и у бинарника без версии одинаковые запасные имена; сообщение о провале публикации всегда винило форк; комментарий к `--verbose` умалчивал об авторе находки; пять копий шага кеша и шесть копий контейнера — теперь якоря; пустой кеш у `mocks` предупреждал в каждом прогоне; + - отброшено: подвал `docs/stack.html` называет T06 сделанной раньше `[x]` — отметка ставится после ревью, как в T04, T05 и T60; кеш базы Trivy — база нужна свежей в каждом прогоне; + - пины `justfile` вне Dependabot — записаны во втором круге; + - вопрос к автору — Т-17, ниже; + - второй круг, 7 замечаний: + - исправлено: `ci-secrets` не видел незакоммиченного, то есть самой правки до коммита; ключ кеша не знал версии Go из образа — теперь в нём ссылка на образ; `cooldown` не был записан; сборка тулчейна с контекстом; ручные пины записаны в R49 и в `dependabot.yml`; + - кеш `mocks` с первым моком — в блоках E2; + - отброшено второй раз: кеш базы Trivy. База собирается каждые 6 часов, кеш потребовал бы своего правила свежести, а Trivy сам берёт базу из `mirror.gcr.io`, а при отказе — из `ghcr.io`. Записано в «Отвергнуто» R49; + - исправления второго круга прошли проверки, третьего ревью нет (CLAUDE.md). Последнее из них — своё: первый вариант скана незакоммиченного оставлял висячие объекты в `.git/objects`, теперь у скана своё хранилище. +- **Открыто:** + - **шаги автора:** + - после первого прогона CI сделать пакет `canvas-api/toolchain` в GHCR публичным: новый пакет закрыт, и PR из форка его не скачает; + - «Как проверить» на GitHub: PR с нарочно сломанным форматированием падает в «Formatting and lint»; чистый PR проходит все восемь заданий; лог gitleaks чистый; повторный прогон берёт тулчейн из GHCR — в логе «Toolchain image» нет строки «This environment is not published yet»; + - **вопрос к автору — Т-17** (spec 20): кто публикует образ тулчейна. Права GHCR — на пакет, а не на тег, поэтому PR этого репозитория, публикуя образ, может переписать и тег, по которому идёт CI `main`. Рекомендация — подпись cosign на `main` и её сверка перед проверками, вместе с cosign из T08; + - **для T07:** имя «Toolchain image» должно быть среди обязательных проверок. Если задание упало, задания после него пропускаются, а пропущенное задание может не держать мерж (документация GitHub); + - **для T08:** у concurrency есть `queue: max` — до 100 ожидающих прогонов в группе (документация GitHub, 2026-10-13). С ним релизы при быстрых мержах не вытесняли бы друг друга, а T08 исходит из одного ожидающего; + - в `.git/objects` этого клона остались висячие объекты от первых прогонов скана незакоммиченного; их уберёт `git gc`. +- **Разделы:** spec 15.5, 17, 20 (Т-17); privacy 9; R33, R49; standalone: R20, R21, R38, R231. + +#### T07. Анализ кода и витрина качества + +- **Цель:** те же внешние проверки и та же витрина, что у standalone: статический анализ, покрытие, цепочка поставок, порядок сообщения об уязвимостях. +- **Сделать:** + - **CodeQL** — `.github/workflows/codeql.yml`: Go и TypeScript, `security-extended`, на PR, `main` и раз в неделю. В `paths-ignore` — `draft` (бандл макета со скриптами) и `docs/stack.html`. + - **SonarCloud:** + - `sonar-project.properties` — ключ `MathTrail_canvas-api`, организация `mathtrail`, покрытие Go и web. В `exclusions` — `draft/**`, `docs/stack.html`, `**/mocks/**`, `tools/**`; + - задание `sonar` в `ci.yml` на раннере, с артефактами покрытия и `fetch-depth: 0`. Оно ничего не гейтит, а PR без токена пропускает (R31 standalone); + - `.sonarlint/connectedMode.json` для SonarQube for IDE, с `"region": "EU"` (R81 standalone). + - **Codecov** — загрузка обоих отчётов из `ci.yml`, `fail_ci_if_error: false`. `codecov.yml`: + - патч не ниже 90 %, проект — `auto` с порогом 1 %; + - `ignore` для пакетов-помощников тестов; + - `comment` с `require_changes`. + - **Scorecard** — `.github/workflows/scorecard.yml`: OpenSSF Scorecard с публикацией результатов. + - **Что блокирует мерж, а что только сообщает** — как R38 standalone: + - блокируют патч Codecov, CodeQL и Trivy; + - сообщают SonarCloud, Scorecard и общее покрытие. + + Записать решением в `docs/decisions.md`. + - **Красный SonarCloud на `main` останавливает релиз и выкладку** (R45): она начинается только после зелёного CI. Так бывает, если на `main` нет токена или анализ упал. Как не дать гейтом стать тому, что «ничего не гейтит», решить в задаче и записать решением. + - **`SECURITY.md`** на английском: сообщать только через private vulnerability reporting; что сервис хранит и чего не хранит (privacy 4); какие проверки стоят до мержа. + - **`CONTRIBUTING.md`** на английском: + - порядок работы и devcontainer; + - раздел «What a change has to satisfy», как в образце: зелёные рецепты из Environment; патч не ниже 90 %; тест, который краснеет без проверки; ничего личного в логах; секреты проверяются по всей истории. + - **`docs/openssf-best-practices.md`** — ответы на критерии уровня passing с доказательствами, по образцу, но по-русски (R25). + - **`README.md`** — плашки: CI, CodeQL, версия Go, Codecov, OpenSSF Best Practices, Scorecard, лицензия и пять плашек SonarCloud. Плашку с номером релиза добавит T08, плашку статуса «Release and deploy» — T11. +- **Я:** + - проект в SonarCloud с выключенным Automatic Analysis и `SONAR_TOKEN`; + - репозиторий в Codecov и `CODECOV_TOKEN`; + - оба токена — ещё и в секреты Dependabot: без токена Codecov у PR Dependabot нет обязательного патч-статуса; + - ruleset «main»: + - только через PR, список обхода пуст; + - одобрения не нужны; + - ветка перед мержем актуальна; + - обязательные проверки — точные `name:` заданий CI и `codecov/patch`. Переименование задания ломает правило; + - private vulnerability reporting; + - Dependabot alerts в настройках безопасности репозитория. По ним видны уязвимости и dev-зависимостей, которые `ci-vuln` не проверяет (R48, T05); + - проект на bestpractices.dev. +- **Как проверить:** + - PR показывает результаты CodeQL, SonarCloud и Codecov; + - PR, который нарочно роняет одну блокирующую проверку, нельзя смержить; + - Scorecard опубликован; + - плашки живые и ведут на этот репозиторий; + - находки Scorecard, которые остаются открытыми, записаны с причиной (как R51 standalone). +- **Риски:** у публичного репозитория запуск по расписанию выключается после 60 дней без активности, а запуски по расписанию приходят с опозданием на часы. +- **Сделано (2026-10-13)** — часть Claude; шаги автора — в «Открыто»: + - **CodeQL** — `.github/workflows/codeql.yml`: задания «Analyze Go» (сборка Go версии из `go.mod`) и «Analyze TypeScript», `security-extended`, на PR, `main` и по понедельникам. В `paths-ignore` — `draft` и `docs/stack.html`. + - **SonarCloud:** + - `sonar-project.properties`: тесты, исключения задачи плюс сгенерированный buf код (`gen/`, `web/src/gen/`), покрытие Go и SPA; + - задание «Static analysis» в `ci.yml` — на раннере, с артефактами покрытия и `fetch-depth: 0`. PR без токена пропускает, права — только `contents: read`; + - `.sonarlint/connectedMode.json` с `"region": "EU"`. + - **Codecov:** загрузка обоих отчётов из заданий «Tests» и «SPA» с `fail_ci_if_error: false`. CLI загрузчика закреплён `version: v11.3.1`, иначе action скачивает `latest`. `codecov.yml`: патч 90 %, проект `auto` с порогом 1 %, статус — только после обеих загрузок (`after_n_builds: 2`), `ignore` для моков и кода buf, `comment` с `require_changes`. + - **Scorecard** — `.github/workflows/scorecard.yml` с публикацией, scorecard-action v2.5.0. + - **Решения:** + - R50 — что держит мерж, а что только сообщает. Сообщающее не останавливает и релиз: у задания SonarCloud `continue-on-error: true` на уровне задания, поэтому упавший анализ красит задание, а прогон CI остаётся зелёным (R45); + - R51 — находки, которые правка закрыть не может: `docker:S8431`; Scorecard `Code-Review`, `Branch-Protection` и `Pinned-Dependencies` про npm-установку; находки, которые пройдут со временем. + - **Документы:** `SECURITY.md`, `CONTRIBUTING.md`, `docs/openssf-best-practices.md` — по-русски, со статусом каждого критерия на сегодня. `README.md` — плашки и ссылки на оба файла. `docs/stack.html`. + - **Сверх текста задачи, на проверку:** + - scorecard-action v2.5.0 вышел 2026-10-12, раньше трёх дней ожидания: версии до него тянут образ из выведенного gcr.io и падают (release notes Scorecard v5.6.0); + - триггера `branch_protection_rule` в `scorecard.yml` нет, хотя в образце он есть. По документации GitHub событие идёт только для классических правил защиты, а `main` защищает ruleset; + - concurrency CodeQL на `main` — по коммиту, как в CI (R49); + - `permissions: contents: read` у заданий «Static analysis» и «Runtime image»; + - пины свежее образца: codeql-action v4.38.3, download-artifact v8.0.2; + - в `devcontainer.json` вернулась фраза о `.sonarlint/connectedMode.json`. Её обещал R46, а тело задачи файл не называет (предложение из «Открыто» T60). R46 поправлен на месте: временных отличий не осталось. + - **Проверки:** + - зелёные `just ci-lint`, `just ci-test`, `just ci-mocks-check`, `just ci-licenses`, `just ci-vuln`, `just ci-secrets`, `just ci-web` и `just ci-image-smoke canvas-api:dev ci-image-scan canvas-api:dev`; + - actionlint 1.7.12 с shellcheck и схемы SchemaStore (`check-jsonschema` 0.34.1) для трёх workflow; валидатор Codecov для `codecov.yml`; + - `yq explode`: права и `continue-on-error` каждого задания после раскрытия якорей. + - **Выключенные проверки:** кода с проверками в правке нет. Что держит мерж, проверяется на GitHub (ниже). Fuzz нет, флейков не было. + - **Документация, прочитана 2026-10-13:** + - docs.github.com: `continue-on-error` задания; правила ruleset и «Require code scanning results» с порогами; языки CodeQL и `paths-ignore`; события `branch_protection_rule`; видимость пакетов; + - `action.yml` каждого action на его SHA и release notes новых версий; + - README и `action.yaml` scorecard-action v2.5.0 — ограничения workflow при публикации, адрес плашки; + - release notes Scorecard v5.6.0; + - README codecov-action v7.1.1 — загрузка из форков без токена; API `cli.codecov.io` — последняя версия CLI v11.3.1; + - README sonarqube-scan-action v8.3.0; + - criteria bestpractices.dev. + - **Ревью `/code-review high`:** + - первый круг, 10 замечаний: + - исправлено: CLI Codecov качался как `latest`; статус патча мог прийти по одной загрузке из двух; критерии криптографии отмечены Met для кода, которого ещё нет, — теперь N/A; триггер `branch_protection_rule` ruleset не видит; concurrency CodeQL теряла коммиты `main`; R50 неверно говорил о PR из форка; R46 спорил сам с собой; CONTRIBUTING говорил, что все проверки идут в образе тулчейна; + - записано: плашка OpenSSF Best Practices ждёт ID проекта (ниже); правка `devcontainer.json` вне текста задачи — по R46 (выше); + - второй круг, 8 замечаний: + - исправлено: код buf не был исключён из Codecov и SonarCloud; R50 называл обязательными все задания CI, хотя «Static analysis» только сообщает; три доказательства в `openssf-best-practices.md` не держали свой ответ; `SECURITY.md` выдавал будущую выкладку за действующую; лишние права у «Static analysis» — и у «Runtime image»; + - плашка OpenSSF Best Practices — снова, ниже; + - исправления второго круга прошли проверки, третьего ревью нет (CLAUDE.md). +- **Открыто** — шаги автора по «Я»: + - SonarCloud: импортировать репозиторий в организацию `mathtrail` с ключом `MathTrail_canvas-api` и выключить Automatic Analysis. `SONAR_TOKEN` — в секреты Actions и Dependabot; + - Codecov: `CODECOV_TOKEN` — в секреты Actions и Dependabot; + - ruleset «main» (R50): + - только через PR, список обхода пуст, одобрения не нужны, ветка перед мержем актуальна; + - обязательные проверки: «Toolchain image», «Formatting and lint», «Tests», «SPA», «Generated mocks», «Vulnerabilities and licenses», «Secrets», «Runtime image», «Analyze Go», «Analyze TypeScript», `codecov/patch`. «Static analysis» — не обязательная; + - правило «Require code scanning results»: CodeQL, Security alerts — High or higher, Alerts — Errors; + - включить private vulnerability reporting и Dependabot alerts; + - зарегистрировать проект на bestpractices.dev и заполнить анкету по `docs/openssf-best-practices.md`. Затем — плашка в `README.md` после Codecov: `[![OpenSSF Best Practices](https://www.bestpractices.dev/projects//badge)](https://www.bestpractices.dev/projects/)`. ID появится только после регистрации; + - «Как проверить» на GitHub: PR показывает CodeQL, SonarCloud и Codecov; PR, который роняет блокирующую проверку, не мержится; Scorecard опубликован; плашки живые; + - по правилу 3 перед T08: сверить R51 с первыми прогонами SonarCloud, CodeQL и Scorecard и дописать новое; + - предложение: CodeQL для workflow (язык `actions`) — после T08 и T11. Их `workflow_run` и выкладка — ровно то, что он проверяет, но его находки держали бы мерж уже в самих этих задачах. +- **Разделы:** privacy 9; R25, R45, R46, R50, R51; standalone: R31, R38, R51, R81. + +#### T08. Релиз + +- **Цель:** зелёный `main` публикует подписанный релиз — как в standalone, без ручных тегов. Из тега этого релиза потом идёт выкладка (R45, T11). +- **Сделать:** + - **`.github/workflows/release.yml`** — по образцу (standalone: R40, R149, R154): + - **запуск:** + - `workflow_run` после успешного CI на `main`, только от `push` из этого репозитория — защита от форка с веткой `main`; + - кнопка с необязательной версией — только на `main` и только если CI этого коммита зелёный. Сначала кнопка убеждается, что прогон CI этого коммита завершился успешно, иначе отказывает; способ — `gh` или API запусков, сверить с документацией GitHub. Здесь canvas-api строже образца, где кнопка не ждёт CI (R33, R45); + - **concurrency** `release`, без отмены: релизы идут по одному, чтобы один номер линии не достался двум. GitHub держит в группе только один ожидающий прогон, поэтому при быстрых мержах подряд промежуточный коммит может остаться без своего релиза. Выкатывается всё равно только вершина `main`; + - **номер** — по линии: `RELEASE_LINE: "0.1"` в workflow и рецепт `just release-version` (R149 standalone). Рецепт: + - берёт следующий patch линии; + - отказывает, если линия старше новейшего тега, тег уже есть или номер не `vX.Y.Z`; + - если у коммита уже есть тег `v*`, релиз не публикуется: повтор кнопкой или перезапуск CI не вешает на тот же коммит второй тег; + - аргументы передаются аргументами, а не вставляются в текст скрипта; + - **checkout** с `fetch-depth: 0` коммита `workflow_run.head_sha`, а при запуске кнопкой — `github.sha`, как в образце. Тот же коммит идёт в `--target`. Go и Node — без кеша, чтобы подписанная сборка не бралась из кеша; + - **`just release-artifacts <версия>`:** + - `canvas-api-server__linux_amd64` и `…_arm64` — `CGO_ENABLED=0`, `-trimpath`, версия в ldflags; + - архив сборки SPA `canvas-web_.tar.gz`; + - `SHA256SUMS`; + - **подпись** каждого файла до публикации — `cosign sign-blob --bundle`, без ключей, через OIDC; cosign — v3.1.3, как в образце; + - **публикация** — `gh release create --target <коммит> --generate-notes --notes-start-tag <прошлый тег>`. Прошлый тег — новейший из `git tag --list 'v[0-9]*' --sort=-v:refname`; если тегов ещё нет, флага нет. Так в образце; + - **выход `newest`** — `main` всё ещё стоит на этом коммите. Выкладка идёт, только если это так (T11, R45). + - В `docs/decisions.md` — что входит в релиз canvas-api и почему в нём есть сборка SPA: её раздаёт Hosting, а не бинарник. + - **Ожидаемые находки на `release.yml`** — записать с причиной, как в образце: Scorecard Dangerous-Workflow на `workflow_run` и SonarCloud `githubactions:S7631`. + - Команда `cosign verify-blob` для проверки подписи — в `SECURITY.md`. cosign в devcontainer нет: проверку запускать из образа cosign по digest. + - **`README.md`** — плашка с номером последнего релиза (shields.io), как в образце. Плашку статуса «Release and deploy» добавит T11. + - **`CLAUDE.md`, Environment** — список обязательных рецептов дополняется `release-version` и `release-artifacts`. +- **Как проверить:** + - после мержа появляется релиз `v0.1.0` с файлами, суммами и `.sigstore.json`; + - `cosign verify-blob` проходит; + - `/health` локальной сборки показывает версию из `git describe`; + - кнопка релиза на другой ветке ничего не публикует. +- **Сделано (2026-10-14):** + - **`.github/workflows/release.yml`** («Release», задание «Build, sign and publish»): `workflow_run` после успешного CI на `main` только от push этого репозитория; кнопка — только на `main` и только при успешном прогоне CI этого коммита (первый шаг, API запусков, `actions: read`). Concurrency `release`, без отмены, `queue: max`. Сборка на раннере без кешей, подпись `cosign sign-blob --bundle` (v3.1.3), `gh release create --target … --generate-notes [--notes-start-tag …]`, `--latest` и выход `newest` — по одному чтению вершины `main`. Коммит с тегом `v*` второй раз не выпускается. + - **`justfile`:** `release-version` (линия из `release.yml` по умолчанию, строгий SemVer, отказы — по задаче); `release-artifacts` — `canvas-api-server__linux_{amd64,arm64}`, `canvas-web_.tar.gz` (детерминированный архив), `SHA256SUMS`. + - **Т-17 по решению автора — R53:** `ci-toolchain-image signed` на `main` принимает только образ с подписью `ci.yml@refs/heads/main` и аннотацией хеша стадии, иначе собирает заново; digest — из вывода своего `docker push`; `ci-toolchain-sign` и задание «Toolchain image signature» (только `main`, `continue-on-error`). + - **`ci.yml`:** cosign в «Toolchain image» на `main`, задания «Toolchain image signature» и «Release build». + - **Документы:** R52, R53, дополнены R49 и R51; spec 15.5 и 20 (Т-17 решён); `SECURITY.md` — проверка релиза из образа cosign по digest; плашка релиза в `README.md`; `CLAUDE.md`, `CONTRIBUTING.md`, правило 7 — `release-version` и `release-artifacts`; `docs/stack.html`, `docs/openssf-best-practices.md`, `.github/dependabot.yml`. + - **Проверки:** зелёные `ci-lint`, `ci-test`, `ci-mocks-check`, `ci-licenses`, `ci-vuln`, `ci-secrets`, `ci-web`, `ci-image-smoke`, `ci-image-scan`, `release-version`, `release-artifacts`. actionlint 1.7.12 и схема SchemaStore — единственное замечание на `queue: max`, которого они ещё не знают (R52). `release-version` проверен в пробном репозитории на всех отказах и на `$(…)` в версии; рецепты тулчейна — на локальном реестре с поддельным cosign во всех ветках; архив SPA из двух сборок совпал; команда `verify-blob` из `SECURITY.md` проверена на релизе образца и отвергла подменённый файл. + - **Документация, прочитана 2026-10-14:** docs.github.com — `concurrency` (`queue`), события `workflow_run` и `workflow_dispatch`, REST «List workflow runs for a workflow»; `gh api --help`, `gh release create --help` (gh 2.101.0); `cosign … --help` из образа v3.1.3, его исходники и release notes v3.0.1 и v3.1.3; `action.yml` cosign-installer v4.1.2. + - **Ревью `/code-review high`:** первый круг — 10 замечаний; исправлено: подпись тулчейна не была привязана к его определению (старый подписанный образ можно было подставить под новый тег), старый коммит становился Latest, права файлов в архиве зависели от umask, устаревшие комментарии в `ci.yml` и R49, неточность о `cancel-in-progress` в R52, комментарий о кнопке; отброшено: `v*` и выражение прошлого тега — так в тексте задачи; разница окружений «Release build» и релиза — намеренная (Т-17), комментарий поправлен. Второй круг — 10; исправлено: два чтения вершины `main`, дата в openssf-документе, это тело задачи; остальное — ниже, третьего ревью нет. +- **Открыто:** + - шаги автора: внести «Release build» в обязательные проверки ruleset «main»; после мержа — релиз `v0.1.0` с файлами и `.sigstore.json`, `verify-blob` из `SECURITY.md`, кнопка на другой ветке ничего не публикует; Scorecard `Dangerous-Workflow` и SonarCloud `githubactions:S7631` на `release.yml` ожидаемы (R51); + - предложение: включить неизменяемые релизы и правило ruleset на теги `v*`; + - из второго круга ревью: версия с кнопки выше линии (например, `v1.0.0` при `0.1`) потом роняет «Release build», пока не поднят `RELEASE_LINE` — так и в образце (R149 standalone), но стоит решить, отказывать ли ей; `imagetools inspect`, упавший по сети, читается как «не опубликован» и ведёт к пересборке; `release-artifacts` сверяет версию после сборки SPA; вызов `cosign verify` повторён в двух рецептах; «Toolchain image signature» идёт на каждом push `main`, даже когда подписывать нечего. +- **Разделы:** R33, R45; standalone: R40, R149, R154. + +#### T09. Решения для поставки + +- **Цель:** до Terraform решено, где и под какими адресами живёт сервис. +- **Сделать:** разбираем по одному; я решаю, исполнитель переносит решение в документы. + - **Т-3 и П-4 — регион всего контура:** Cloud Run, Gemini, Neon, Upstash. Вводные: + - в бете только семьи из США (product 3); + - domain mapping Cloud Run есть не во всех регионах (R27 standalone); + - Gemini и выбранные модели должны быть доступны в регионе (spec 7.1). + - **О-9 — рабочее название и адреса:** + - адрес SPA на Firebase Hosting; + - адрес API: domain mapping Cloud Run или прямой адрес `run.app` (spec 2, «Периметр»); + - что уходит в CORS и в `client.allowed_origins` Centrifugo. + - **Записи:** решения — в `docs/decisions.md` со следующими свободными номерами; поправки — в product 15, spec 15 и 20, privacy 12. +- **Как проверить:** Т-3, П-4 и О-9 переехали в решённые; у T10 есть все вводные. +- **Сделано (2026-10-14)** — решения автора, перенесены в документы: + - **Т-3 и П-4 → R54:** весь контур в `us-east4`. Gemini — региональный адрес `us-east4`, не глобальный; Neon — `aws-us-east-1`; Upstash — Северная Вирджиния. Данные беты — в США; ЕС и Великобритания — отдельным решением (SCC/DPF, П-2, П-3). + - **О-9 → R55:** название «MathTrail Canvas»; canvas-api и Centrifugo — по своим `*.run.app`; SPA — `https://.web.app`, он один в CORS и `client.allowed_origins` (локально ещё `http://localhost:5173` при `CANVAS_ENV=local`). + - **Документы:** product 15 (О-9 в решённых, шапка), spec 7.1, 15.1 и 20 (Т-3 решён), privacy 5 и 12 (П-4 решён), `docs/stack.html`. + - **По правилу 3:** релиз T08 `v0.1.0` вышел (прогон `release.yml` успешен); новых алертов CodeQL нет, алерт Scorecard Dangerous-Workflow на `release.yml` ожидаем (R51), остальные — из списка R51. + - **Документация, прочитана 2026-10-14:** Cloud Run «Mapping custom domains» (регионы, Preview), «Generative AI locations» (Gemini 3.x по регионам, глобальный адрес), Neon «Regions». + - **Ревью `/code-review high`**, 9 замечаний: исправлено — примеры `CANVAS_ALLOWED_ORIGINS` и `CANVAS_GEMINI_LOCATION` в spec 15.4, CORS в spec 8.1, место хранения в уведомлении (privacy 6), «рабочее название» в CLAUDE.md, локальный origin Centrifugo и `authDomain` Firebase Auth в R55; отброшено — шапка журнала решений не перечисляет каждую запись (R52–R53 тоже не там), «docs/stack.html» в списке верно — из него ушёл Т-3; картинка README (`docs/design/app.png`) — отдельная просьба автора, не T09. +- **Открыто:** + - ID проекта GCP и Firebase — в T10; из него выводятся точные адреса. Решено в T10: `mathtrail-canvas-prod` (R55); + - `authDomain` и адрес обработчика входа у Google и Apple — задача эпика MVP, что приносит вход (R55); + - регион Upstash (AWS `us-east-1` или GCP `us-east4`) сверить в консоли Upstash, когда его создаёт задача эпика MVP; + - для T10: бакет журналов `_Default` и хранилище трасс — в США, а не `global` (R54, privacy 4) — сделано в T10 (R57); вход по redirect требует, чтобы rewrites Hosting не перехватывали `/__/` (R55) — это T11; + - `docs/stack.html`: строки Cloud Run, Neon, Upstash и Hosting не называют регион и адреса — дописано в T10; +- **Разделы:** product 3, 15; spec 2, 15.1, 20; privacy 12. + +#### T10. Terraform + +- **Цель:** вся инфраструктура prod описана кодом, как в standalone (его R26, R29, R32, R33). +- **Сделать:** перенести `infra/` из образца и приспособить. + - **`infra/bootstrap.sh`** — разовый шаг человека: + - проект и биллинг, бакет состояния; + - федеративный пул и провайдер GitHub; + - сервисный аккаунт Terraform: роли образца — только те, что нужны ресурсам этого эпика, и права на Firebase. Роли под аналитику образца (`logging.configWriter`, `bigquery.admin`) не переносить; + - печатает строки для `infra/ci.env`. + - **`infra/terraform/`:** + - `versions.tf` — Terraform и провайдеры точных версий: `google` 8.3.0, как в образце; `google-beta` — только если без него не обойтись ресурсам Firebase; `.terraform.lock.hcl`; + - `backend.hcl` и `prod.auto.tfvars` лежат в репозитории, без секретов и персональных данных (spec 15.2, R33 standalone); + - `apis.tf`. Spec 15.2 перечисляет API всего MVP; этот эпик включает из них только нужные ему — Cloud Run, Artifact Registry, IAM и WIF, Firebase, бюджеты и Monitoring для `alert.tf`. Остальные включат задачи эпика MVP, которым они нужны (правило 13): Secret Manager — с первым секретом, Agent Platform — вместе с выключением кеша входов Gemini на уровне проекта (privacy 5.2), reCAPTCHA Enterprise — с App Check; + - `registry.tf` — Artifact Registry с автоочисткой; + - `service_api.tf` — Cloud Run canvas-api: + - пока один контейнер; сайдкар OpenFGA добавит эпик MVP; + - `min 0`, `max 1`, параллельность 40, тайм-аут 30 с, startup CPU boost, probe `GET /health` (spec 15.3). Boost и startup probe в образце не настроены — писать по spec 15.3; + - CPU, память и параллельность — переменные в `variables.tf`, как в образце: их будут читать нагрузочные прогоны и править калибровка эпика MVP; + - образ — начальный; его изменения игнорируются (`lifecycle.ignore_changes`), образ выкатывает CD; + - `iam.tf` — по spec 15.2: сервисные аккаунты сервиса и деплоя, WIF для репозитория, своя узкая роль деплоя на сервис. В образце то же лежит в `deploy.tf`; + - `firebase.tf` — Firebase в проекте и сайт Hosting; вход и App Check добавит эпик MVP; + - `budget.tf` — бюджет с оповещениями на 50, 90 и 100 % (spec 14.4); + - `alert.tf` — один бесплатный алерт, как в образце (R266 standalone): + - условие — число запросов к сервису, которое считает сама платформа; порог — как в образце, 300 в минуту в среднем за 5 минут, пока нагрузка не даст свой; + - алерт пишет письмо оператору. Почта — прямой идентификатор, поэтому в публичном `prod.auto.tfvars` её нет. Она приходит секретом репозитория `TF_VAR_operator_email`; переменная и адрес в канале оповещений помечены `sensitive`. По умолчанию переменная пустая, и тогда алерт не создаётся — секрет заносит автор в T12; + - аккаунту Terraform нужны роли на Monitoring — их приносит bootstrap; + - `variables.tf` с валидацией, `outputs.tf`. Вывод плана уходит в публичный summary PR, поэтому переменные и атрибуты с секретами помечаются `sensitive` (R34). + - **Рецепты** — по образцу: `tf-fmt`, `tf-check`, `bootstrap`, `ci-tf-plan`, `ci-tf-apply`, `ci-tf-outputs`. + - `just tf-check` — ещё и в CI, отдельным заданием на раннере. В образе тулчейна Terraform нет, поэтому он ставится `hashicorp/setup-terraform` с версией из `ARG`, как в `deploy.yml`. Задание идёт на каждом PR, без фильтра путей (T06). В образце `tf-check` в CI не запускается вовсе. + - **`docs/self-hosting.md`** — первая версия, по-русски: + - что делается руками, порядок применения, переменные, стоимость; + - раздел «Когда меняется bootstrap»: план на PR зелёный, а apply получает 403, пока bootstrap не выдаст аккаунту Terraform новую роль. + - **`CLAUDE.md`, Environment** — список обязательных рецептов дополняется `tf-fmt` и `tf-check`. +- **Как проверить:** `just tf-check` зелёный; `terraform plan` на моём проекте проходит без ошибок. `apply` делает пайплайн (T11–T12). +- **Стоимость:** ожидается $0 в бесплатных лимитах; назвать всё, что может стоить денег. +- **Не делать:** Neon, Upstash, секреты, сайдкар, Centrifugo и jobs — их добавляют задачи, которым они нужны. +- **Сделано (2026-10-14)** — по утверждённому плану. Решения автора в планировании: ID проекта `mathtrail-canvas-prod`, бюджет $1, журналы и трассы в `us-east4` уже в T10 (R57), одна задача без разбиения. + - **`infra/bootstrap.sh`** — по образцу: + - проект «MathTrail Canvas», привязка к платёжному аккаунту, API — вместе с Observability, заранее; + - бакет состояния в `us-east4`; + - пул `canvas-api-github` и провайдер `github`. Условие: репозиторий, ID владельца 261630499 и единственный workflow — выкладка `deploy.yml` (T11), так что до T11 не войдёт никто. Повтор приводит условие к записанному (`update-oidc`); + - аккаунт `canvas-api-tf`: 12 ролей проекта, `storage.objectAdmin` на бакете, `billing.costsManager` на платёжном аккаунте; + - сверка валюты аккаунта с `budget_currency`; ID платёжного аккаунта скрипт не печатает; + - всё создаваемое ищется перед каждой попыткой (`ensure`), остальные вызовы повторяются: на свежем проекте права доходят не сразу. + - **`infra/terraform/`:** + - `versions.tf` — `google` и `google-beta` 8.3.0, `user_project_override`; lock для linux amd64 и arm64; + - `backend.hcl` и `prod.auto.tfvars`; + - `apis.tf`, `registry.tf`, `iam.tf`; + - `service_api.tf` — по spec 15.3: масштаб 0–1, параллельность 40, тайм-аут 30 с, boost, startup probe `/health` раз в секунду, `GOMEMLIMIT`; + - `firebase.tf`, `budget.tf`, `alert.tf` и `logging.tf` — sink `_Default` в бакет `default-us-east4`, место трасс `us-east4`; + - `variables.tf`, `outputs.tf`; + - 12 тестов `terraform test` на mock-провайдерах. + - **Рецепты:** `tf-fmt`, `tf-check`, `bootstrap`, `ci-tf-plan` (без блокировки состояния), `ci-tf-apply` (ждёт блокировку до 5 минут), `ci-tf-outputs`, `_tf-redact` (маскирует ID платёжного аккаунта и адрес оператора). + - **CI:** задание «Terraform» на раннере; `.github/actions/terraform` читает `ARG TERRAFORM_VERSION`. Экосистемы `terraform` в Dependabot нет — причина в шапке `.github/dependabot.yml` и в R56. + - **Документы:** + - `docs/self-hosting.md` — первая версия; + - R56, R57; R50 — «Release build» и «Terraform» среди обязательных проверок; R54; R55 — точные адреса; + - spec 15.1, 15.2, 15.4, 16, 18; privacy 4, 7, 8; + - `CLAUDE.md`, `CONTRIBUTING.md`, `SECURITY.md`, правило 7, `docs/stack.html`. + - **Сверх текста задачи, утверждено планом:** + - `tf-fmt` — повседневный рецепт, а в списке обязательных — только `tf-check`; + - журналы и трассы — сразу в `us-east4`; + - тесты конфигурации на mock-провайдерах; + - маскировка вывода плана; + - правка T12 ниже. + - **Живой прогон (2026-10-14).** Стоимость названа (≈ $0) и подтверждена автором. Автор вошёл в gcloud и положил ID платёжного аккаунта в `.env`, а bootstrap и план запускал Claude. + - Первый запуск: `INVALID_ARGUMENT` на чтении валюты — в `.env` был шаблон из инструкции. Вместо него подставлен единственный открытый аккаунт, ID не печатался. + - Второй: проект, биллинг, API и бакет созданы, затем PERMISSION_DENIED на создание пула. На свежем проекте права владельца доходят не сразу, и в скрипт добавлены повторы. + - Третий прошёл. Четвёртый, повторный, не создал ничего. Пятый — после ревью — положил на провайдер суженное условие пула; оно ACTIVE, ролей по-прежнему 12. + - У аккаунта Terraform ровно 12 перечисленных ролей проекта; провайдер ACTIVE. + - План: `27 to add, 0 to change, 0 to destroy`, ID платёжного аккаунта в выводе нет. С подставным адресом оператора — 29, и адрес показан как `(sensitive value)`. + - **Проверки:** + - зелёные `just ci-lint`, `ci-test`, `ci-mocks-check`, `ci-licenses`, `ci-vuln`, `ci-secrets`, `release-version`, `release-artifacts`, `ci-image-smoke canvas-api:dev ci-image-scan canvas-api:dev` и `tf-check`; + - actionlint 1.7.12 и `check-jsonschema` 0.34.1 для `ci.yml`, action и `dependabot.yml`; shellcheck 0.11.0 для bootstrap; + - bootstrap с поддельным `gcloud`: создание, повтор без создания, отказы, повтор привязки после ошибки, ID нигде не печатается; + - скрипт action — с поддельным окружением раннера. + - **Выключенные проверки** — 17, каждая сломана в копии и краснит: + - `count` алерта, порог в секунду; + - валидации образа, аккаунта, адреса и имени; + - доля `GOMEMLIMIT`, единицы `Mi`; + - период probe, порог бюджета 0.9, фильтр `_Default` (без строки и с опечаткой); + - имя сервиса короче шести символов у его аккаунтов и с дефисом на конце; + - формат файла, битая ссылка, провайдер без lock. + + Fuzz нет, флейков не было. + - **Ревью `/code-review high`:** + - первый круг, 10 замечаний. Исправлено: + - подсказка про `.env`, который никто не читает; + - ID платёжного аккаунта в ошибках gcloud — теперь маскируется; + - тест фильтра `_Default` считал вхождения, а не сверял текст; + - комментарий о сайте Hosting не учитывал Firebase до 2026-10-15; + - запятая в CLAUDE.md, фраза о цене алертов; + - две копии `init` в рецептах — теперь `_tf-init`; + - имя бакета в тексте алерта — теперь ссылкой. + + Отброшено: кеш провайдеров в CI и общий шаг чтения `ARG` (причины — в «Отвергнуто» R56); + - второй круг, 9 замечаний. Исправлено: + - условие пула пускало любой workflow репозитория, а задание релиза держит токен личности и собирает SPA сторонними пакетами; теперь только `deploy.yml`; + - комментарий о силе `serviceAccountAdmin`; + - повторы для привязки биллинга, API и бакета; создание через поиск перед каждой попыткой; + - валидация имени сервиса: длина его аккаунтов и дефис на конце. + + Отброшено: + - `unique_writer_identity = false` у `_Default` не ломает apply: у живого sink writer identity пустой (проверено), и `false` ничего не меняет; + - кеш провайдеров и общий шаг `ARG` — повтор первого круга; + - вложенный `just` для маскировки — миллисекунды против секунд Terraform, а его backtick-переменные с запасными значениями упасть не могут. + + Исправления второго круга прошли проверки, третьего ревью нет (CLAUDE.md). + - **Документация, прочитана 2026-10-14:** + - исходники и документация провайдеров `google` и `google-beta` 8.3.0 (Cloud Run v2, `logging_project_sink`, `logging_project_bucket_config`, Observability, `billing_budget`, Firebase); + - Cloud Run: health checks, адреса, масштаб; + - Cloud Logging: маршрутизация и фильтр `_Default`; Observability: место по умолчанию, роли, регионы; + - Cloud Billing API (`currencyCode`); Firebase Hosting: сайт по умолчанию; + - `gcloud` 585.0.0 `--help`; + - `action.yml` setup-terraform v4.0.1; исходник `cloud-run-hello`; + - цены Cloud Run и Cloud Monitoring. +- **Открыто:** + - **шаги автора:** + - «Terraform» — в обязательные проверки ruleset «main», и «Release build», если его там ещё нет (R50); + - секрет `TF_VAR_billing_account` — до мержа T11: её мерж — первая выкладка. `TF_VAR_operator_email` — в T12; + - в `.env` лежит ID платёжного аккаунта; файл в `.gitignore`; + - **для T11:** + - `roles/resourcemanager.projectIamAdmin` аккаунту Terraform: роль выкладки Hosting выдаётся только на проект. Это правка bootstrap и его повтор; + - `.github/actions/terraform` — переиспользовать; + - сайт Hosting — в `firebase.json`, rewrites не перехватывают `/__/` (R55); + - `TF_VAR_operator_email` — и в план, и в apply; + - WIF по окружению — решение автора, тоже повтор bootstrap. Сейчас условие пула пускает `deploy.yml` с любой ветки, в том числе из PR, который его поменял; + - имя `deploy.yml` — часть условия пула: другое имя требует правки bootstrap; + - **для T12:** после первой выкладки `just ci-tf-plan` пуст — это доказательство для `ignore_changes` и `traffic`. Первый apply может потребовать одного повтора. Firebase может потребовать принять условия, если addFirebase откажет; + - T35 (черновик E2) пишет, что API Agent Platform включила T10. Это не так: его включит задача, которая выключает кеш входов (privacy 5.2). Поправить при разбиении E2; + - SonarCloud начнёт смотреть `infra/**` — по правилу 3 перед T11; + - провайдеры 8.3.0 отстают от 8.7.0 (2026-10-13) — поднять руками, вместе с lock. +- **Разделы:** spec 3.4, 14.4, 15.1–15.3; R32, R34; standalone: R26, R29, R32, R33, R266. + +#### T11. CD + +- **Цель:** зелёный CI на `main` сам доводит изменение до prod — релиз, инфраструктуру, образ и SPA — без ручных шагов (R32). Выкатывается ровно подписанный релиз T08, из его тега (R45). +- **Сделать:** перенести из образца `.github/workflows/deploy.yml` и actions `deployment-target` и `docker-mirror`, и приспособить. + - **Цепочка** (R45, R154 standalone): CI на `main` → `release.yml` (T08) → его задание `deploy` вызывает `deploy.yml` через `workflow_call` с номером релиза и `secrets: inherit`. + - Вызов идёт, только если выход `newest` релиза истинный: `main` всё ещё на этом коммите. Иначе перезапуск CI старого коммита откатил бы prod. + - Сам `deploy.yml` в начале каждого запуска ещё раз проверяет, что коммит тега — вершина `main`; если нет, ничего не выкатывает. При «Re-run failed jobs» выход `newest` берётся из прежнего прогона и может устареть. + - **Триггеры `deploy.yml`:** + - `workflow_call` с обязательной версией; + - кнопка — только на `main`. Она выкатывает релиз, чей тег стоит на вершине `main`, а если такого нет — ничего; непроверенный коммит она не собирает. Если тегов на вершине несколько, берётся старший по версии. Брать «новейший по номеру» нельзя: перезапуск CI старого коммита выпускает его под бо́льшим номером. В образце кнопка работает с любой ветки (R33); + - `pull_request` по `infra/**`, `deploy.yml` и `justfile` — только для задания `plan`. + - **`plan`** — `just ci-tf-plan` в summary PR. Задание читает состояние Terraform, а в нём секреты входа (R34), поэтому оно запускается: + - только для веток этого репозитория, как в образце, — не для форков; + - не для PR Dependabot: он меняет версии actions, которые иначе выполнились бы под аккаунтом Terraform до ревью; + - в своей группе concurrency: план не должен вытеснить ожидающую выкладку. + + Писать в репозиторий могу только я. + - **`infra`** — checkout тега релиза, `just ci-tf-apply`, выходы — в `GITHUB_OUTPUT`. + - **`deploy`** — от имени аккаунта деплоя: + - checkout тега релиза с `fetch-depth: 0`, затем action `docker-mirror`; + - `just ci-image-push`: + - собирает образ с `VERSION` = номер релиза; + - до push проверяет его через `ci-image-smoke`; + - пушит и печатает ссылку по digest — сначала в переменную; + - `just ci-deploy` — отказывает ссылке без digest. После обновления возвращает трафик на latest (`update-traffic --to-latest`): после отката трафик закреплён за старой ревизией, новая иначе его не получит, и smoke покраснеет. Флаги — по документации Cloud Run; + - `just ci-smoke` — с повторами и `--proto-redir =https`: `/health` называет релиз и коммит этого прогона. + - **`hosting`:** + - сборка SPA из тега и `firebase deploy --only hosting`; `firebase.json` — в репозитории; + - версия firebase-tools закреплена `ARG` в Dockerfile devcontainer, и `deployment-target` читает её так же, как версии Terraform и gcloud. Новый `ARG` — это пересборка контейнера. + - **Доступ** — только WIF, без ключей сервисных аккаунтов. Выкладка идёт в группе concurrency `cd`, без отмены. + - **Порядок шагов** — R31 с поправкой R45: релиз → инфраструктура → образ → сервис → Hosting → smoke. Миграции и Centrifugo встанут в него задачами эпика MVP. + - **Повтор** упавшей выкладки — только «Re-run failed jobs» в прогоне релиза, как в образце. + - **Откат** — трафик на прошлую ревизию (`gcloud run services update-traffic --to-revisions=<ревизия>=100`) и прошлый выпуск Hosting, а не `terraform apply` (R29 standalone; spec 15.5). Способ отката Hosting — по документации Firebase с датой обращения. + - После такого закрепления новые ревизии не получают трафик, пока его не вернут на latest. Следующая выкладка делает это сама (`ci-deploy`), а поведение сверить с документацией Cloud Run с датой обращения. + - Команды отката и возврата — в `docs/self-hosting.md`: на них опирается R32. + - **Решает автор:** окружение `prod`, разрешённое только для `main`, и WIF по subject окружения. Тогда «только `main`» обеспечивает сама платформа. Задание `plan` на PR входит с другим subject, поэтому ему нужна своя привязка WIF к аккаунту Terraform — иначе план на PR перестанет входить. + - **Плашка CD** — в `README.md`: статус `release.yml` («Release and deploy»), потому что прогоны вызванного workflow принадлежат вызывающему. Плашка с номером релиза из T08 остаётся рядом — она показывает версию, а не статус. +- **Как проверить:** + - PR с правкой `infra/` показывает план, а PR Dependabot его не запускает; + - после мержа CI, релиз и выкладка зелёные; `/health` называет релиз и коммит; + - страница SPA открывается на адресе Hosting; + - коммит с красным CI не выпускается и не выкатывается; кнопка на другой ветке ничего не применяет и не выкатывает; + - перезапуск CI старого коммита ничего не выкатывает; + - откат по записанным командам возвращает прошлую ревизию и прошлую SPA, и `/health` называет её релиз; возврат на latest работает. +- **Разделы:** spec 15.5; R31, R32, R33, R34, R45; standalone: R29, R30, R32, R33, R154. +- **Сделано (2026-10-14)** — по утверждённому плану. Решения автора в планировании (R58): + - окружения `prod` и `prod-hosting`, только для `main`, и WIF по их subject; + - выкатываются подписанные файлы релиза, а не сборка из тега; + - у выкладки SPA свой аккаунт; + - firebase-tools — автономный бинарник. + + Что сделано: + - **`deploy.yml`:** + - `target` выбирает релиз без учётных данных: переданный номер или, по кнопке, старший релиз на вершине `main`. Устаревший релиз — зелёный прогон с notice, кнопка вне `main` падает; + - `infra` и `deploy` — в окружении `prod`, `hosting` — в `prod-hosting`. На повторной попытке каждое из них сначала проверяет, что `main` стоит на коммите релиза; + - `plan` — на PR веток репозитория, кроме PR Dependabot; план — в summary; + - concurrency: `cd` без отмены, у PR — своя группа с отменой. `id-token: write` есть только у четырёх заданий. + - **`release.yml`** — «Release and deploy». Задание `deploy` вызывает `deploy.yml`, если `newest`; секреты передаются по имени. + - **Actions:** `deployment-target` грузит только `infra/ci.env`; `gcloud` и `firebase` читают версии из `ARG`. + - **Рецепты:** + - `ci-release-target` и `ci-release-current` — какой релиз и свежесть; + - `ci-release-fetch` — подпись `SHA256SUMS` с коммитом тега, затем строка файла; + - `ci-image-push` — стадия `release`, health до push, digest из вывода push; + - `ci-deploy` — только digest, затем `update-traffic --to-latest`; + - `ci-smoke`, `ci-hosting-deploy`, `ci-hosting-check`; + - общий `_image-health`: его зовёт и `ci-image-smoke`; + - `ci-tf-outputs` — ещё `project_id` и `hosting_service_account`. + - **Dockerfile** — стадии `runtime` и `release`. Образ CI остаётся последней стадией, сборки CI и `docker-build` не меняются. В `.dockerignore` и `.gitignore` — `firebase.json`, следы Firebase CLI и файл учётных данных action `auth`. + - **`firebase.json`** — `web/dist`; `no-cache` на странице, год и `immutable` на `/assets/**`; rewrites нет. + - **Terraform и bootstrap** (правило 13): + - переменная `github_oidc_subject_prefix` с валидацией; + - аккаунт деплоя — по subject `prod`; + - новый аккаунт `canvas-api-hosting`: две роли проекта и subject `prod-hosting`; выход `hosting_service_account`; + - четыре новых теста, всего их 16: последний сверяет роли проекта со списком условия в bootstrap; + - bootstrap: аккаунт Terraform — по subject `prod` и `pull_request`, привязка «весь репозиторий» снимается, `projectIamAdmin` выдаётся с условием на две роли. Под другим условием или без условия роль сначала снимается, а политику скрипт читает с повторами. + - **Devcontainer:** `ARG FIREBASE_TOOLS_VERSION=15.33.0`, бинарник на x86_64. Хеш toolchain не изменился, образ CI не пересобирается. Нужна пересборка контейнера автором. + - **Документы:** + - R58; поправки R31, R45, R46, R56; spec 15.2, 15.5, 18; + - `docs/self-hosting.md` — выкладка, окружения, откат и возврат, «Когда меняется bootstrap»; + - `SECURITY.md`; README — плашка «Release and deploy» и ссылка на `self-hosting.md`; `docs/stack.html`; `CLAUDE.md`; + - комментарий задания «Terraform» в `ci.yml`; + - T12 — проверка отката на второй выкладке. + - **Сверх плана:** + - подпись `SHA256SUMS` привязана к коммиту тега (`--certificate-github-workflow-sha`): подписанные файлы другого релиза не пройдут; + - `firebase.json` без `ignore`: сайт — весь архив; + - `deployment-target` требует обе строки `ci.env` вместо проверки на `REPLACE`: заглушек в этом файле не бывает. + - **Проверки без облака:** + - `ci-release-target` — на настоящем репозитории и на подставном remote: лёгкие и аннотированные теги, несколько тегов на вершине, `v1.0.0-rc1`, устаревший релиз, нет релиза; + - `ci-release-fetch` — на настоящем v0.1.2; подставной `gh` отдаёт испорченный бинарник, переписанные суммы, бандл другого файла, суммы без бандла и подписанный релиз образца — всё отвергнуто, и в папку ничего не попадает; + - `ci-image-push` собрал образ из подписанного бинарника v0.1.2 и отправил его в локальный registry (`gcloud` подставной). Образ называет v0.1.2 и 701cb47, в registry лежит один манифест, а не индекс; + - `ci-deploy` с подставным `gcloud` и `ci-smoke` против контейнера из этого образа; + - `firebase.json` и `ci-hosting-check` — на эмуляторе Hosting из того же бинарника: заголовки как задуманы, `/__/` свободен, схема v15.33.0 принимает файл и отвергает испорченный (check-jsonschema 0.34.1); + - bootstrap — с подставным `gcloud`, который хранит политики в файлах. Сценарии: свежий проект; повтор, который ничего не трогает; роль под старым условием и без условия; два неудачных чтения; чтения, которые не проходят вовсе; + - actionlint 1.7.12 со shellcheck: новых находок нет; shellcheck 0.11.0 — bootstrap, скрипты actions и рецептов. + - **Выключенные проверки** — 18, каждая выключена в копии или сломана и возвращена, и её плохой случай проходит: + - подпись сумм, личность подписавшего, коммит в подписи, сумма файла; + - свежесть переданной версии и на повторной попытке; у кнопки — старшая версия, только релизы и коммит аннотированного тега; + - версия и коммит в health образа, коммит в smoke; + - выкладка только по digest; + - страница сайта — та, что в `web/dist`, и без кеша; + - в bootstrap: чтение политики с повторами — без него снятие старой привязки молча пропускается, а скрипт пишет «Done»; роль под одним условием — без этого рядом ложится вторая привязка; + - в Terraform: роли SPA совпадают со списком условия bootstrap — тест краснеет, если убрать роль из bootstrap или добавить её в `iam.tf`. + + Fuzz нет, флейков не было. + - **Ревью `/code-review high`:** + - первый круг, 8 находок. Исправлено: + - снятие привязки «весь репозиторий» зависело от одного чтения без повтора, и неудачное чтение молча его пропускало. Теперь чтение повторяется (`retry_read`), а сбой останавливает скрипт; + - новый список ролей в условии `projectIamAdmin` ложился второй привязкой рядом со старой. Теперь bootstrap держит роль под одним условием; + - грамматика тега релиза была скопирована в четыре рецепта. Теперь она одна — `RELEASE_TAG`; + - R58 утверждал, что «только `main`» держит платформа и для аккаунта Terraform. Уточнено: у него есть ещё план на PR. + + Отброшено: + - общий action для чтения версии из `ARG` — отвергнут в R56, а пропущенная копия падает с ошибкой, а не молча; + - общий шаг проверки свежести для трёх заданий: три шага по четыре строки рядом с комментарием проще абстракции (CLAUDE.md, «Simplicity beats purity»); + - `rm -rf web/dist` и `gcloud auth configure-docker` в рецептах: `web/dist` — игнорируемая сборка, которую называет `firebase.json`, а запись помощника docker идемпотентна и та же, что в образце. + + Два вопроса — автору, в «Открыто». + - второй круг, 6 находок. Исправлено: + - кнопка на другой ветке вставала в общую группу `cd` и вытесняла бы ждущую выкладку `main`. Теперь она ждёт в группе своей ветки; + - роли SPA записаны и в `iam.tf`, и в условии bootstrap, и расхождение проявилось бы 403 после мержа. Теперь их сверяет тест `terraform test`; он краснеет, если убрать роль из bootstrap или добавить её в `iam.tf`; + - `--proto-redir` без `-L` ничего не делал, а комментарий обещал защиту от понижения. Флаг убран, комментарии говорят как есть: редиректы не выполняются; + - переменная `condition` в bootstrap держала и условие пула, и путь к файлу условия роли. Теперь файл — `condition_file`. + + Отброшено: «`ci-hosting-check` примет старую страницу, если `index.html` не изменился». Страница называет скрипт по хешу содержимого, а скрипт несёт номер релиза, поэтому одинаковых страниц у двух релизов не бывает: у v0.1.2 — `index-D48AILHJ.js`, у сборки дерева — `index-CTruYdVY.js`. Это записано в комментарий рецепта. + + Вопрос — автору: контракт `release.yml` ↔ `deploy.yml` до мержа не проверяется. Исправления второго круга прошли проверки, третьего ревью нет. + - **После отчёта (2026-10-14), по решению автора:** + - три вопроса ревью: отдельный аккаунт плана только для чтения и выкладка ревизии без трафика — «да, но не сейчас», они в таблице 4 (Д1, Д2); actionlint в CI — сейчас; + - **actionlint в CI:** рецепт `just ci-workflows` — actionlint 1.7.12 из образа по digest, с shellcheck 0.11.0 из того же образа — и задание «Workflows» на раннере, с `docker-mirror`. Находка `queue: max` снята в `.github/actionlint.yaml` — только эта и только для `release.yml`. Рецепт вписан в CLAUDE.md, CONTRIBUTING.md и правило 7; поправлены R50, R52 и R58, в `docs/stack.html` — строка actionlint; + - **комментарии без ссылок на план.** Журнал решений будет удалён, и ссылки на него в коде стали бы мусором. Поэтому из комментариев кода и конфигов, описаний Terraform, текста `THIRD_PARTY_LICENSES` и сообщения теста убраны номера решений и задач, разделы spec, product и privacy, вопрос Т-17 и ссылки на разделы CLAUDE.md. Где ссылка несла смысл, фраза переписана. Тест `the_service_is_sized_as_the_spec_says` переименован в `the_service_size_and_start_stay_as_chosen`. Правило записано в CLAUDE.md (Hard rules, Language) и в правило 11. Пометка находки анализатора теперь называет причину словами, а не ссылается на решение (CLAUDE.md, правило 3); + - **таблицы 3 и 4** — вопросы автору без ответа и технический долг, из «Открыто» задач T00–T11. Как их вести, говорят правило 10, «Как устроен план» и раздел Documents в CLAUDE.md; + - проверки: зелёные `ci-lint`, `ci-test`, `ci-mocks-check`, `ci-licenses`, `ci-vuln`, `ci-secrets`, `ci-web`, `release-version`, `release-artifacts`, `tf-check` (16 тестов), `ci-workflows`, `ci-image-smoke` и `ci-image-scan`, `just --fmt --check`; shellcheck bootstrap; + - выключенные проверки `ci-workflows` — 6, и каждая краснит рецепт: нет файла исключения; `queue: max` в другом workflow; `release.yml` не передаёт обязательный секрет; `deploy.yml` переименовал вход; передан секрет, которого `deploy.yml` не объявляет; замечание shellcheck в скрипте шага. Fuzz нет, флейков не было; + - ревью `/code-review high`: + - первый круг, 9 находок. Исправлено: + - `ci-workflows` отдавал чужому образу всё дерево, `.env` тоже, и с сетью. Теперь он получает копию `.github` и пустой `.git`, без сети; + - правило «без ссылок» нигде не было записано, а CLAUDE.md требовал от пометки находки ссылаться на решение; + - исключение actionlint не говорило, чем доказано и когда уходит; + - CLAUDE.md обещал shellcheck и для скриптов composite actions, а actionlint их не читает: формулировка поправлена, проверка в CI — долг Д12; + - в «Открыто» пропала сверка окружений и привязок Claude после шагов автора; + - `docs/stack.html` называл actionlint держащим мерж, пока «Workflows» не в ruleset. + + Отброшено: + - ссылки на spec, privacy и RUN.md убраны зря — автор велел убрать все ссылки на требования и задачи; + - описание условия роли не дойдёт до проекта, где роль уже есть: у аккаунта Terraform в prod роли `projectIamAdmin` нет (`get-iam-policy`, 2026-10-14), первый повтор bootstrap запишет новое описание. Комментарий скрипта теперь говорит, что сверяется только выражение; + - ссылка на R25 в CONTRIBUTING.md — документы в просьбу автора не входили, это вопрос В5; + - второй круг, 7 находок. Исправлено: + - ссылки на разделы CLAUDE.md в комментариях `main.go`, `.golangci.yml`, `.mockery.yaml`, `devcontainer.json` и `.gitignore`; + - новое правило в CLAUDE.md выдавало удаление журнала за решённое, а другие правила велят его вести: оговорка убрана, вопрос остаётся В5; + - исключение actionlint уйдёт, только если кто-то поднимет версию руками: долг Д13; + - рваный перенос строк там, где ссылка уходила из середины абзаца: такие абзацы перенесены заново по 80 знаков. + + Отброшено: ссылки на R в SECURITY.md и CONTRIBUTING.md — документы, это В5; описание условия роли — повтор первого круга; предложение из `scorecard.yml` о том, где отвечены находки, удалено, а не переписано, — оно указывало на журнал решений, другого места нет (В5). Исправления второго круга прошли проверки, третьего ревью нет. + - **Живые шаги до мержа (2026-10-14), ≈ $0, с подтверждения автора.** + - Автор создал окружения `prod` и `prod-hosting` (только `main`) и секрет `TF_VAR_billing_account` и повторил bootstrap. Claude сделать это не смог: auto-режим Claude Code не пускает выдачу прав без разрешения автора. + - Claude сверил только чтением: + - у обоих окружений одно правило — ветка `main`; + - аккаунт Terraform одалживают только subject `:environment:prod` и `:pull_request`, привязка «весь репозиторий» снята; + - `projectIamAdmin` — одна привязка, под условием на две роли и с новым описанием; + - ролей проекта у аккаунта 13. + - Первый прогон `plan` на PR #15 упал только на пустом секрете: вход прошёл по старой привязке «весь репозиторий». + - На коммите 0538208 `plan` зелёный. Это первое живое доказательство subject PR и условия пула: старой привязки уже нет. План — `31 to add`; лишний против первого прогона — бюджет, который без ID платёжного аккаунта не планировался. ID в логе нет: `***` и `(sensitive value)`. + - Задание «Workflows» зелёное. «Release build» и «Terraform» автор внёс в обязательные проверки ruleset «main». +- **Открыто:** + - «Workflows» — в обязательные проверки ruleset «main»: до первого прогона GitHub его не предлагал (R50) — внесено автором 2026-10-14 (T12); + - мерж — первая выкладка. «Как проверить» после мержа сверяется в T12, откат и возврат — на второй выкладке (T12); + - пересборка devcontainer автором: в нём появится `firebase`; + - `tee /dev/stderr` в `ci-toolchain-image` — в таблице 4 (Д6); неизменяемые релизы и правило на теги — в таблице 3 (В1); + - concurrency вызванного workflow держится на опыте сообщества, а не на документации — сверить на первых выкладках (T12) — сверено в T12: кнопка ждала, пока выкладка релиза не кончится. + +#### T12. Первый деплой + +- **Цель:** сервис и SPA живут на своих адресах, и разворачивает их пайплайн, а не руки. +- **Сделать:** + - **Я** — дела без API, каждое один раз: + - bootstrap сделан в T10, его строки уже в `infra/ci.env`. Повторить `just bootstrap` — только если скрипт менялся (`docs/self-hosting.md`); + - секрет репозитория `TF_VAR_billing_account` и окружения `prod` и `prod-hosting` — уже в T11, до её мержа: её мерж — первая выкладка; + - секрет репозитория `TF_VAR_operator_email` — почта для алерта (T10). Без неё алерт не создаётся; + - Firebase: принять условия, если этого не может Terraform; + - своих доменов нет (R55): адреса — `run.app` и `web.app`, DNS не нужен. + - Первый прогон — мерж в `main`: CI, затем релиз и выкладка (R45). + - **Claude:** + - чек-лист первой поставки в `docs/self-hosting.md`, по образцу: + - строка старта в журнале называет версию и коммит; + - автоочистка Artifact Registry действует, а не в пробном режиме (`cleanupPolicyDryRun = false`); + - оповещения бюджета и алерт называют проект; + - рецепт `just usage`, как в образце (R169 standalone): vCPU-секунды, ГиБ-секунды и запросы сервиса против бесплатного лимита Cloud Run. Так «$0» становится фактом, а не оценкой; + - отчёт `docs/live/01-first-deploy.md` в формате T59; + - на время живого прогона — узкое разрешение в `.claude/settings.local.json`, после прогона файл удаляется (T59). +- **Как проверить:** + - все задания CI, релиза и выкладки зелёные; + - `https://<адрес API>/health` называет выкаченный релиз и коммит; + - SPA открывается по своему адресу; + - бюджетные оповещения и алерт настроены; + - в Artifact Registry действует автоочистка; + - откат и возврат по командам `docs/self-hosting.md` возвращают прошлую ревизию и прошлую SPA, `/health` называет её релиз, а возврат — снова новый. Проверяется на второй выкладке: после первой прошлая ревизия — образ `hello`, а прошлого выпуска SPA нет (T11); + - `just usage` показывает расход в бесплатных лимитах. +- **Стоимость:** $0 в бесплатных лимитах. +- **Риски:** проект и бакет состояния не пересоздавать. +- **Разделы:** spec 15; R32, R45; standalone: R169. +- **Решено автором (2026-10-14, в планировании):** + - первая выкладка уже прошла — мерж T11. Вторая выкладка — мерж этой задачи, поэтому задача в две части: до мержа — код, документы и отчёт о первой выкладке; после — очередь `cd`, откат и возврат, затем второй PR с итогом; + - задержку IAM, на которой упала первая выкладка, чинить здесь: задания ждут, пока их аккаунт сможет войти; + - очередь `cd` проверить кнопкой Deploy во время второй выкладки; + - откат и возврат выполняет Claude по узкому разрешению. Перед этим автор пересобирает контейнер ради `firebase`. +- **Сделано (2026-10-14) — часть 1, до мержа:** + - **по правилу 3:** + - SonarCloud — только три находки из R51, хотспотов нет; + - code scanning — только алерты Scorecard из R51; + - Dependabot alerts — 0; + - выкладка T11 прошла со второй попытки (ниже); + - **первая выкладка** — отчёт `docs/live/01-first-deploy.md`, ход по UTC: + - релиз v0.1.3, apply на 31 ресурс; + - 403 у аккаунта деплоя через 5 с после его привязки; + - «Re-run failed jobs» по разрешению автора, и выкладка зелёная; + - **шаги автора:** условий Firebase принимать не понадобилось; своего домена нет. `TF_VAR_operator_email` — до мержа, тогда алерт создаст вторая выкладка; + - **ожидание входа** — рецепт `ci-identity-ready`: + - повторяет `gcloud auth print-access-token` до 8 минут по часам: и отказ, пока привязка не вошла в силу, и минутный сбой. Сразу показывает только то, что отвергает сам обмен токена GitHub: нет пула или провайдера, токен не проходит условие пула. Аккаунт, которого нет, получает тот же отказ, что ещё не одолженный, и ждётся вместе с ним; + - в `deploy.yml` стоит после входа в заданиях `deploy` и `hosting`. Задание SPA ради него ставит gcloud: в задании деплоя это занимает 17 с; + - ждёт только привязку, которая одалживает аккаунт, а роли, которые тот же apply выдаёт новому аккаунту, — нет. Это долг Д14; + - сроки — по документации IAM: обычно 2 минуты, иногда 7 и дольше (прочитана 2026-10-14); + - R58, «Следствие», поправлен; + - **адрес сервиса** — выход `service_url` берёт из `urls` адрес по номеру проекта, как его называет R55, а не адрес со случайной частью из `uri`. Тест `the_service_url_is_the_one_by_project_number` подставляет оба адреса через `override_resource` с `override_during = plan` (документация Terraform о моках, прочитана 2026-10-14). Если Cloud Run такого адреса не сообщит, предусловие выхода остановит apply с объяснением, а не отдаст выкладке пустой адрес; тест `a_service_without_its_regional_address_is_refused`; + - **sink `_Default`** — `unique_writer_identity = true`: + - пустой план после выкладки, проверка из «Открыто» T10, нашёл вечную правку `true → false`; + - провайдер 8.3.0 читает sink без идентичности как уникальный (исходник), а у sink с бакетом в своём проекте идентичности не бывает (справочник Cloud Logging API, прочитан 2026-10-14); + - тест держит значение. С исправлением план не меняет ни одного ресурса, только выход `service_url`; + - вывод T10 «`false` ничего не меняет» был верен для apply, но не для плана; + - **`just usage [since] [service]`** — перенос рецепта образца; проект он читает из `prod.auto.tfvars` сам, без помощника образца `_project`, у которого был бы один потребитель: + - метрики Cloud Run из Monitoring API v3 через `curl` и токен `gcloud`; токен идёт заголовком из дескриптора; + - лимит месяца при оплате за запросы — 180 000 vCPU-с, 360 000 ГиБ-с, 2 млн запросов (Google Cloud Free Tier, прочитана 2026-10-14); + - вид, единицы и задержка метрик (120 с) — по их дескрипторам из самого API, прочитаны 2026-10-14; + - сверх образца: + - окно не больше месяца: за месяц по минутам выходит 44 640 точек, а страница ответа держит 100 000 (справочник `timeSeries.list`); + - имя сервиса проверяется до того, как встанет в фильтр; + - точка без значения считается нулём, а не роняет рецепт; + - заготовке `REPLACE…` вместо проекта рецепт отказывает, как bootstrap; + - числа пишутся с точкой при любой локали (`LC_ALL=C`): `printf` в локали с запятой не принял бы ответ jq. Доказать выключением здесь нельзя — таких локалей в контейнере нет; + - вложенный `just` — через `just_executable()`; + - **чек-лист** «Первая выкладка: чек-лист» в `docs/self-hosting.md` — восемь пунктов с командами. Каждая команда прогнана на v0.1.3 ровно как записана. ID платёжного аккаунта и адрес канала алерта в вывод не попадают; + - **README** — раздел Design перед The MathTrail core, отдельная просьба автора. Прошёл ревью вместе с T12. Его первая фраза повторяла подпись к картинке выше, поэтому осталось только правило: «Where the design differs from the documents, the documents win.»; + - `CLAUDE.md` (`just usage`), `docs/stack.html`; + - **живые чтения на v0.1.3** — ≈ $0, только чтение. Все пункты чек-листа, кроме алерта (адреса ещё нет), `ci-tf-plan` вручную и `just usage 1d`: 12,8 vCPU-с — 0,007 % лимита месяца; + - **выключенные проверки** — 14. Каждая выключена в копии, и её плохой случай проходит: + - адрес сервиса — 3 поломки: `uri`, первый адрес из `urls`, без предусловия; + - sink — `false`; + - ожидание входа — 2 поломки: без повтора, без быстрого отказа на чужую ошибку; + - `usage` — 8 поломок: окно, месяц, имя сервиса, `int64Value` строкой, точка без значения, сумма вместо максимума экземпляров, токен в аргументах `curl`, заготовка `REPLACE…`. + + Проверки Terraform — выбор адреса, предусловие и sink — держат тесты `terraform test`. Проверки рецептов доказаны руками, на поддельных `gcloud` и `curl`: тестов рецептов в репозитории нет, как и в T08–T11. Fuzz нет, флейков не было. + - **ревью `/code-review high`:** + - первый круг, 9 находок. Исправлено: + - ожидание входа считало попытки, а не время — теперь 8 минут по часам; + - оно же пережидало любой отказ — теперь сразу показывает отказ обмена токена; + - выход `service_url` мог стать пустым — теперь предусловие; + - `_project` не отказывал заготовке `REPLACE…`; + - точка без значения роняла `usage`; + - запись о проверках выдавала ручные доказательства за тесты; + - запись о README говорила, что ревью не было, а фраза раздела Design повторяла подпись к картинке; + - документы обещали, что выкладка ждёт все новые привязки, — теперь только токен, а роли записаны долгом Д14. + + Отброшено: ставка gcloud в задании SPA (17 с замера) — тот же закреплённый SDK Google, что в задании деплоя. + - второй круг, 9 находок. Исправлено: + - быстрый отказ касался и минутных сбоев 5xx — теперь сразу показывается только отказ самого обмена токена (`invalid_grant`, `invalid_target`, `unauthorized_client` и подобные), остальное пережидается до срока; + - комментарий обещал быстрый отказ для аккаунта, которого нет, — его отказ неотличим от ещё не одолженного; + - `_project` с одним потребителем встроен в `usage`; + - ошибка `gcloud billing budgets` в чек-листе назвала бы платёжный аккаунт — теперь вывод маскируется; + - `usage` в локали с десятичной запятой писал бы неверные числа — `LC_ALL=C`; + - Firebase CLI в задании SPA получает токен заново — дописано в Д14. + + Отброшено: + - номер проекта будто бы не назван в `infra/` — он в `infra/ci.env`; + - ставка gcloud в задании SPA — повтор первого круга; + - пять запросов `usage` по очереди — рецепт запускают руками, а параллельные запросы усложнили бы разбор ошибок. + + Исправления второго круга прошли проверки, третьего ревью нет. +- **Сделано (2026-10-14) — часть 2, вторая выкладка:** + - **шаги автора:** + - секрет `TF_VAR_operator_email`; + - Rebuild Container — в нём `firebase` 15.33.0; + - «Workflows» — в обязательные проверки ruleset «main»; + - узкое разрешение в `.claude/settings.local.json` на кнопку, `update-traffic` и `hosting:clone`; + - мерж PR #16. После прогона Claude файл разрешения удалил; + - **вторая выкладка v0.1.4 (09938cd)** — зелёная с первой попытки: + - apply — 2 ресурса, алерт и канал; + - `ci-identity-ready` в обоих заданиях получил токен с первой попытки; + - smoke идёт по адресу по номеру проекта; + - **очередь `cd` проверена.** + - Кнопка Deploy нажата в 20:29:17 UTC, во время выкладки релиза. Её прогон стоял в `pending` и начал первое задание в 20:31:23 — через секунду после последнего задания выкладки релиза. + - Затем кнопка выкатила тот же v0.1.4. Её apply — `0 added, 0 changed, 0 destroyed`: это проверка `ignore_changes`, `traffic` и исправления sink; + - **чек-лист на v0.1.4** — «да» по всем пунктам. Алерт `canvas-api load` и канал `canvas-api operator` включены; + - **откат и возврат** — командами `docs/self-hosting.md`: + - ревизия `canvas-api-00002-p84` и выпуск Hosting v0.1.3: `/health` и скрипт страницы назвали v0.1.3; + - затем `--to-latest` и выпуск v0.1.4 — снова v0.1.4; + - около полуминуты в каждую сторону; + - **находка:** `hosting:clone` пишет выпуск `ROLLBACK` без сообщения — дописано в `docs/self-hosting.md`; + - **расход:** `just usage 1d` — 31,1 vCPU-с за сутки, 0,017 % лимита месяца; + - **отчёт** `docs/live/01-first-deploy.md` — «закрыто»; в «Открыто» T11 закрыты «Workflows» и очередь `cd`; + - **ревью `/code-review high` итоговой правки** — первый круг, 8 находок, все исправлены: + - повторная выкладка того же релиза меняет digest образа и тратит место автоочистки — находка 5 отчёта и долг Д15; + - список выпусков Hosting не показывал вид выпуска и брал 5 строк — теперь вид и 20 строк; + - в RUN.md стояла заглушка вместо итога ревью, а «эпик E1 закрыт» спорил с правилом о сделанных эпиках; + - в отчёте — команда `hosting:clone` без проекта квоты, «выпуск» вместо версии, строка старта без версии и времени, два разных времени начала заданий кнопки; + - **второй круг, 8 находок, все исправлены:** у Д15 названа и вторая причина нового digest — время файла в слое — и повод вернуться: возврат кнопкой и «Re-run» тоже повторные выкладки; отчёт больше не говорит, что это «не вредит»; токен в списке выпусков Hosting — заголовком из дескриптора, а не аргументом `curl`; выпуск без версии не роняет `jq`; полное имя раздела чек-листа; шаг автора с «Workflows» вписан; лишняя пустая строка в списке. Исправления второго круга прошли проверки, третьего ревью нет. +- **Открыто:** + - из «Как проверить» T11 вживую не проверено: план на PR Dependabot, коммит с красным CI, кнопка на другой ветке, перезапуск CI старого коммита. Их держат условия workflow и проверки без облака; + - письмо алерта не проверено: такой нагрузки не было; + - что откат не отменяет следующий apply, вживую не проверялось: трафика в конфигурации нет; + - долги Д14 — роли нового аккаунта выкладка не ждёт, и Д15 — повторная выкладка того же релиза даёт новый образ; + - все задачи таблицы 1 отмечены. Перенести E1 в «Сделанные эпики» и выбрать следующий эпик — шаг автора («Как устроен план»). + +> **Точка проверки эпика E1.** +> - Правила работы и версии сверены с образцом от 2026-10-11. +> - Каждый PR проходит проверки Go и web, уязвимостей, лицензий, секретов и образа, CodeQL и SonarCloud. +> - Зелёный CI на `main` публикует подписанный релиз, и выкладка разворачивает сервис и SPA из его тега. +> - `/health` в prod называет релиз и коммит. diff --git a/docs/plan/E2-draft.md b/docs/plan/E2-draft.md new file mode 100644 index 0000000..afc9692 --- /dev/null +++ b/docs/plan/E2-draft.md @@ -0,0 +1,1082 @@ +# Черновик разбиения E2 до поворота + +> **Перенесён в архив 2026-10-15, когда E2 разбит на задачи (R59).** Ниже — приложение RUN.md «Черновик разбиения E2 (MVP) до поворота» дословно; изменены только заголовки шести задач, которые перешли в E2. +> +> - Задачи T13–T58 написаны до поворота к LLM-first (R37–R43, 2026-10-11) и не исполнялись. Их ссылки на разделы документов ведут к редакции до поворота — коммит 2796f48 в `main`. «Этот файл», «таблица 1» и «описание E2 под таблицей 2» в тексте ниже — RUN.md в редакции коммита 9adad66, последнего в `main`, где черновик был в нём. +> - 2026-10-15 автор сделал E2 минимальным срезом с замером LLM, а закрытую бету вынес в эпик E15. Черновик — сырьё для разбиения E15: что в нём устарело, сказано в описании E15 в [RUN.md](../../RUN.md). +> - T16, T18, T19, T34, T35 и T48 перешли в E2 и получили в RUN.md новые тела. Здесь их текст до разбиения стоит под заголовком вида «#### T16 (до разбиения E2).», чтобы поиск по номеру находил одну задачу. +> - Файл не читается целиком. Задача находится по номеру: `grep -n '^#### T20\.' RUN.md docs/plan/*.md`. + +## Приложение. Черновик разбиения E2 (MVP) до поворота + +> **Черновик, не исполняется.** Задачи T13–T58 написаны до поворота к LLM-first (R37–R43, 2026-10-11) и в таблицу 1 не входят. Их ссылки на разделы документов ведут к редакции до поворота — коммит 2796f48 в `main`. Что в черновике устарело, сказано в описании E2 под таблицей 2. Раздел «Доработки», на который ссылается черновик, разошёлся по эпикам E3, E13 и E14 таблицы 2. Доводы о порядке MVP, на которые черновик ссылается как на «Почему такой порядок», теперь в описании E2 — «Порядок, который уже решён». Когда E2 разбивается на задачи, выжившие задачи переезжают в таблицу 1 со своими номерами и поправками, а их тела — из приложения в тело файла (R44). Остаток приложения уходит в архив, `docs/plan/E2-draft.md` (R59). + +### Фаза 2. Решения и спайки + +Спайки пишут одноразовый код в `spike/<имя>/`. Его удаляет задача, которая пишет настоящий адаптер; полезное переезжает в отчёт в `docs/live/`. + +#### T13. Решения по открытым вопросам + +- **Цель:** до кода решены вопросы, которые блокируют каркас, движок и задачи. +- **Сделать:** разбираем по одному. Я решаю, исполнитель переносит решение в документ, где жил вопрос, и в `docs/decisions.md`. + - **Продукт:** + - О-10 — какие две задачи (формат spec 8.6, eval-наборы); + - О-12 — лестница подсказок; О-13 — лимиты; + - О-14 — что видит родитель; О-15 — отмечать ли верный шаг; + - О-16 — что после решения задачи; О-17 — как собирать сигналы о ложных тревогах. + - **Техника:** + - Т-1 — роутер (рекомендация: gin, как каркас standalone); + - Т-2 — разбор LaTeX (рекомендация: свой Pratt-парсер); + - Т-4 — оплата Mathpix; Т-8 — сегментация строк в MVP; Т-9 — срок хранения штрихов; + - Т-11…Т-15 — поведение под нагрузкой: бюджет запроса дельты, уход клиента после приёма, строки «в проверке» после сбоя, слоты к поставщикам и рендеру, пулы соединений. Решается устройство; числа пока предварительные, их сводит T46a. + - Для Т-12 до решения — живая проверка: даёт ли Cloud Run при оплате за запросы процессор запросу, клиент которого ушёл. Одноразовый сервис из `spike/cancel/` в проекте из T12, развёрнутый и удалённый моими учётными данными gcloud, как спайки фазы 2; код удаляется в этой же задаче, итог — в записи решения. Стоимость — $0 в бесплатных лимитах; запуск — после моего подтверждения (правило 5). + - **Приватность:** П-5 — как подтвердить, что пользователь взрослый; П-6 — сроки хранения. + - Если решения меняют задачи — поправить этот файл. +- **Как проверить.** В product 15.2, spec 20 и privacy 12 остались только вопросы, которые явно ничего не блокируют до своей задачи: + - Т-5 — решают T14 и T38; Т-6 — T15; Т-7 — «Доработки»; + - П-1 — публичный запуск; П-2 и П-3 — T58; П-7 и П-8 — T50 и T58; П-9 — T46. +- **Разделы:** product 15; spec 20; privacy 12. + +#### T14. Спайк S1: распознавание почерка + +- **Цель:** до адаптера распознавания измерить, держится ли выбор Mathpix (R19). Против Gemini Flash-Lite меряем точность на почерке строк задачи А, долю ложных тревог при порогах spec 5.7, задержку и цену. +- **Сделать:** + - **`spike/recognition/`:** + - страница записи штрихов на тулчейне T05: линовка страницы (4.1), запись строк в формате 4.2, выгрузка в JSON с разметкой «что написано»; + - Go-прогонщик: + - Mathpix v3/strokes по R18 и spec 10.3: `confidence_threshold: 0`, `improve_mathpix: false`; + - Gemini Flash-Lite — по картинке строки, в проекте из T12, с моими учётными данными gcloud; + - сравнение с разметкой через нормализацию 5.2. + - **Набор** — 200+ строк задачи А: + - образцы взрослых; + - детские — только по отдельному письменному согласию родителей, вне репозитория и только в Mathpix (privacy 4, 5.1). + - **Метрики:** + - точность распознавания; + - доля верных строк, которые при порогах 5.7 стали бы ошибкой; + - p95 задержки, цена за строку. + + Отсюда же — подбор порогов 5.7. + - **Отчёт** `docs/live/02-spike-recognition.md`: таблица по поставщикам, выбранные пороги, вывод по R19 и по модели для текста (Т-5). +- **Я:** аккаунт Mathpix, активация, отказ от хранения данных; пишу строки сам и собираю образцы взрослых. +- **Как проверить:** критерий S1 (spec 20) — Mathpix даёт ≥ 95 % точности, ложных тревог ≤ 2 % при порогах 5.7 и p95 ≤ 500 мс. Иначе — вопрос о пересмотре R19. +- **Стоимость:** активация Mathpix $19.99 (стартовый кредит $29 покрывает запросы); Gemini — центы. +- **Не делать:** адаптер в `internal/` — это T30; хранить образцы в репозитории. +- **Разделы:** spec 4.1–4.3, 5.2, 5.7, 6.4, 10.3, 20; product 9, 13; privacy 4, 5; R18, R19, R22. + +#### T15. Спайк S2: vision в пути Б + +- **Цель:** узнать, насколько верно Gemini vision читает неоднозначные стрелки и сходится ли номер штриха по `box_2d` — с сеткой и без (Т-6). +- **Сделать:** + - **`spike/vision/`:** + - 50+ неоднозначных рисунков на задаче Б (spec 8.6): концы между объектами, отдельные наконечники, штрихи внутри колонки. Рисую я, на странице записи из T14; + - рендер PNG по 6.3 на `github.com/tdewolff/canvas` (псевдоверсия, spec 3.2): одна единица — один пиксель, номера штрихов, сетка A–J и 1–N; + - вызов `gemini-3.8-flash` со схемой ответа из 10.9, `thinkingLevel: LOW`, `maxOutputTokens: 4096` — в проекте из T12, с моими учётными данными gcloud; + - сверка номера штриха по `box_2d` (4.4). + - **Отчёт** `docs/live/03-spike-vision.md`: + - точность интерпретаций с сеткой и без; + - доля несошедшихся номеров, задержка, цена; + - решение по Т-6 и по модели (Т-5). +- **Как проверить:** критерий S2 — верная интерпретация ≥ 90 %, номер сходится по `box_2d`. +- **Стоимость:** центы (≈ $0.004 за вызов до 31.12.2026). +- **Разделы:** spec 4.4, 6.2, 6.3, 10.9, 20; R21. + +> **Точка проверки фазы 2.** Вопросы, блокирующие код, решены. Выбор Mathpix подтверждён или пересмотрен, пороги 5.7 подобраны, решение о сетке для пути Б принято. + +--- + +### Фаза 3. Каркас и контракты + +#### T16 (до разбиения E2). Каркас сервиса + +- **Цель:** простейший сервер уступает место каркасу standalone (R11): тот же контейнер зависимостей, конфиг, логи, ошибки и HTTP-слой. +- **Сделать:** перенести из `reference/mathtrail-standalone/` и приспособить. + - **`cmd/server/main.go`** — как в образце: + - `signal.NotifyContext`, `errgroup`; + - закрытие с `context.WithoutCancel` и тайм-аутом, код выхода; + - остановка во время старта — выход 0. + - **`internal/app`:** + - ручной контейнер `NewContainer(ctx, cfg, logger)`; ресурсы закрываются в обратном порядке, в том числе при сбое посередине; + - `server.go` с тайм-аутами и бюджетом остановки. + - **`internal/config`:** + - viper, только переменные окружения, только объявленные ключи; + - переменные: `PORT`, `CANVAS_ENV`, `CANVAS_LOG_LEVEL`, `CANVAS_LOG_FORMAT`, тайм-ауты HTTP и остановки; + - `CANVAS_ENV` — `local` или `prod`; `dev` оставлено для второй среды после MVP (spec 8.1, R32); + - `Validate()` называет переменную; + - при `K_SERVICE` консольный формат логов и dev-переключатели не стартуют. + - **`internal/logger`** — zap, JSON с ключами Cloud Run `severity`, `message`, `time`; `request_id` в контексте. + - **`internal/apierror`** — плоский `{code, message}` с необязательным `details` (R11, spec 8.3) и кодами из таблицы 8.3. + - **`internal/transport/http`** на gin (Т-1): + - роутер; `/health` на GET и HEAD; + - middleware в порядке образца: `LastResort`, `SecurityHeaders`, `RequestID` с заголовком `X-Request-Id`, логирование, восстановление после паники; + - место для CORS, App Check, ID token и лимитов (spec 8.1) пока пустое — его займут T22 и T45. + - **`.vscode/launch.json`** — с переменными `CANVAS_*`. +- **Тесты:** + - конфиг: умолчания, ошибка называет переменную, защита dev-режима; + - логгер — ключи Cloud Run; `apierror` — формат и `details`; + - `/health`; `X-Request-Id` в каждом ответе; + - остановка по сигналу; + - сбой на середине сборки контейнера закрывает то, что уже построено. +- **Как проверить:** + - `just run` и `curl -i localhost:8080/health`; + - строки лога — JSON с `severity`; + - после мержа CD выкатывает каркас, `/health` отвечает коммитом. +- **Не делать:** телеметрию — T17; protobuf — T18; вход — T22. +- **Разделы:** spec 8.1–8.3, 15.4, 16, 18; R11, R30; standalone: R18, R19, R74, R77. + +#### T16a. Нагрузка: инструмент, первые сценарии и проверки на PR + +- **Цель:** с первой ручки есть чем нагрузить сервис и поймать регрессию конкурентности — в той задаче, которая пишет код, а не перед бетой. Почему здесь — в «Почему такой порядок» и правиле 14. +- **Сделать:** перенести `tools/load/` из `reference/mathtrail-standalone/` (T52a.2 и T52a.3 standalone) и приспособить. Если этих частей в копии ещё нет — остановиться и попросить обновить копию. + - **Отдельный модуль** `tools/load/go.mod`, `module github.com/MathTrail/canvas-api/tools/load`: + - продукт — через `replace … => ../..`: тесты инструмента поднимают настоящий сервис, а типы у инструмента и сервиса одни; + - `github.com/tsenart/vegeta/v12` той же версии, что в образце, — только темп (`vegeta.Pacer`) и перцентили (`vegeta.Metrics`); + - зависимости инструмента не попадают ни в сервис, ни в `THIRD_PARTY_LICENSES`. Корневые `go build ./...` и `golangci-lint run ./...` модуль не видят, поэтому у него свои рецепты. + - **Клиент canvas-api** — на месте сессии MCP образца: + - свой срок на каждый вызов; `X-Request-Id` каждого ответа — в отчёт; + - вид каждого ответа: `ok`, `api:<код>` из `apierror` (spec 8.3), `http:<статус>`, `no_answer:timeout|refused|reset`, `mismatch` — ответ чужому холсту или чужому `seq`; + - в этой задаче клиенту хватает `/health`. Остальное приносят задачи, которые вводят ручки (правило 14): тело protobuf на типах из `gen/` — с первой ручки `/v1` после T18, как у SPA; протокол дельт (8.4) — так же, как его ведёт SPA (3.1): пауза, один запрос в полёте на холст, повтор с тем же `client_event_id`, перенумерация после `SEQ_CONFLICT`, снимок после `RESYNC_REQUIRED`. + - **`scenario`** — бегун с постоянным темпом и `Launch`, единственной абстракцией: + - сервис в том же процессе — в тестах инструмента: `config` + `app.NewContainer` + настоящий сервер каркаса `app.NewServer(…).Run` на свободном порту. Не голый `httptest`, как в образце: под нагрузкой важны тайм-ауты сервера и бюджет остановки T16; + - по адресу — `-url`, например против `just run`. Из командной строки в этой задаче — только так, как в образце: без `-url` прогон не идёт (код 2); + - образом — T16b. + - **`report`** — перцентили и доли по видам ответа, жёсткие факты, Markdown. Коды выхода: 0 — чисто, 1 — есть жёсткий факт, 2 — прогнать не удалось. + - **Жёсткие факты** — то, что красит прогон. Задачи дополняют список: + - ответ 500 и паника; + - `UNAVAILABLE` и `LIMIT_EXCEEDED`, которых сценарий не ждёт. В сценариях насыщения и лимитов они ожидаемы и только считаются; + - запрос без ответа в срок, ответ чужому запросу; + - потерянный штрих: после прогона на сервере нет штриха, приём которого клиент получил (product 10); + - `ERROR` на строке, которую сценарий написал верно, — ложная тревога (product 5.6); + - ответ задачи в любом ответе (spec 13); + - вызовов поставщика больше, чем ждёт сценарий (product 9); + - `UNCHECKED` с причиной `engine_error`; + - против образа (T16b): OOM, самовольный выход контейнера, пик памяти выше `-memory-ceiling`; + - провал проверки восстановления: после прогона `/health` не отвечает 200, а с T33 — новый ребёнок не решает задачу А. + + Задержки и пропускная способность печатаются, но гейтом не служат: раннер общий, и абсолютная задержка на нём — флейк. + - **Сценарии каркаса:** + - `health` — `/health` в постоянном темпе; ожидание — все ответы `ok`; + - `shutdown` — остановка посреди нагрузки: начатые запросы доходят, сервер закрывается в бюджет остановки T16. В процессе её даёт отмена контекста `Run` — тот же путь, что SIGTERM; против образа (T16b) — `docker stop`. Пока у каркаса нет медленных ручек, медленный запрос даёт маршрут, которым тест оборачивает `Router` контейнера; с T33 сценарий идёт посреди `lesson`. + - **Рецепты:** + - `just load <сценарий> [флаги]`; + - `just load-test` — тесты модуля под `-race`; + - `just load-lint` — `go mod tidy -diff` с подсказкой `just load-tidy`, `gofmt`, `golangci-lint` с корневым конфигом, лицензии по корневому списку, сверка версий модулей, общих с продуктом; + - `just load-tidy`. + - **CI:** `just load-lint` — в задании lint, `just load-test` — в test; ключ кэша Go — по обоим `go.sum`; SonarCloud и Codecov исключают `tools/**`; dependabot — gomod для `/tools/load`, только vegeta; `.dockerignore` — `tools`. + - **Документы:** + - запись в `docs/decisions.md` — устройство инструмента. Отвергнуто: + - облачный сервис нагрузки: он достаёт только prod (R32), где настоящие Mathpix и Gemini стоят денег, а входу нужны настоящие аккаунты и debug-токены App Check; + - k6, Locust и Gatling: протокол дельт и protobuf пришлось бы вести на втором языке; + - сырой `Attacker` vegeta: он шлёт открытым потоком и не видит ответа, а дельте нужен замкнутый цикл по холсту; + - модуль без продукта: слабые тесты; + - spec 17 — раздел о нагрузке: что гоняется на PR, что раз в неделю, что красит сборку; + - spec 18 и Layout в `CLAUDE.md` — строка `tools/load/`: раскладка CLAUDE.md повторяет spec 18; + - `CLAUDE.md` — ещё рецепты, `just load-tidy` после правки `go.mod` продукта, правило 14 в How we work и стражи в Tests; `CONTRIBUTING.md` — две строки. +- **Тесты:** + - клиент — против подделки через `httptest`: вид каждого ответа, срок вызова, заголовки; + - бегун держит темп при любых ответах; + - `health` и `shutdown` — против настоящего сервиса в процессе; + - `report` — «что жёстко, что нет»; `main` — умолчания, неизвестный сценарий даёт код 2. +- **Как проверить:** + - `just load-lint`, `just load-test`; + - `just run` в одном терминале и `just load health -url http://localhost:8080` в другом — отчёт без жёстких фактов; + - убрать ожидание начатых запросов при остановке — `shutdown` краснеет. +- **Разделы:** spec 3.1, 8.3, 8.4, 13, 14, 15.3, 17, 18; product 5.6, 9, 10; R11, R23, R32; standalone: T52a. + +#### T16b. Нагрузка: образ, холодный старт и прогоны в CI + +- **Цель:** сценарии идут против настоящего образа с ограничениями экземпляра Cloud Run, и отчёт знает, что израсходовал контейнер. Каждый PR проходит короткую нагрузку, раз в неделю — полную. +- **Сделать:** перенести из `reference/mathtrail-standalone/` (T52a.4 и T52b standalone) и приспособить. + - **Пакет `instance`:** + - `docker run` образа с CPU и памятью контейнера из переменных Terraform (`variables.tf` и `prod.auto.tfvars`, T10) — там же, где их правит T46a. Без свопа, как в Cloud Run; с T21 — вместе с сайдкаром OpenFGA, каждый в своих пределах. Числа читаются из Terraform, а не повторяются в инструменте, — расхождение ловит тест; + - адрес — `127.0.0.1` и порт из `docker port`; + - cgroup v2 раз в 250 мс: `cpu.stat`, `memory.current`, `memory.peak`, `oom_kill`; + - остановка строго по порядку: последнее чтение cgroup, `inspect`, `docker stop`, журнал (паники, `engine_error`), `docker rm -f` — всегда; + - OOM, самовольный выход и пик памяти выше `-memory-ceiling` — жёсткие факты (T16a); + - сеть: контейнер входит в сеть compose, где с T20 стоят Postgres и OpenFGA, и видит подделки инструмента на хосте — например, через `--add-host host.docker.internal:host-gateway`. Подделки слушают адрес, видимый из контейнера, а адреса зависимостей в окружении контейнера указывают туда. + - `just load <сценарий>` без `-url` сначала собирает образ (`just docker-build`) и гонит сценарий против него; с `-url` — как в T16a, против запущенного сервиса. + - **Сценарий `cold`:** 5 запусков; время от `docker run` до первого `/health` и пик памяти после первого вызова. + - **Стоимость в отчёте:** + - оплачиваемая — время, пока идёт хотя бы один запрос, × vCPU × ГиБ экземпляра — против бесплатного лимита Cloud Run (product 9); + - израсходованная — из cgroup; + - с T30 — ещё вызовы поставщиков на задачу по ценам product 9. + - **`just ci-load-smoke`** — в задании `image` на каждом PR, после `ci-image-smoke`: + - образ под ограничениями экземпляра, короткие варианты сценариев; + - гейт — только жёсткие факты (T16a); отчёт — в summary задания; + - бюджет — около 3 минут: сценарий, который в него не помещается, идёт только в `load.yml`; + - задание `image` идёт на раннере ради docker, и Go там нет. Инструмент собирается Go из образа тулчейна (T06), как в остальных заданиях, а не Go раннера: бинарником из задания test или в контейнере тулчейна с docker раннера — способ выбирает задача. + + CD ждёт CI этого коммита (R33), так что выкладка в prod ждёт и нагрузку. + - **`.github/workflows/load.yml`:** + - раз в неделю на `main` и по кнопке с любой ветки: облака он не касается; + - все сценарии с полной длительностью; отчёт — в summary и артефактом; + - красит сборку только жёсткое; + - actions по SHA, минимальные права, `timeout-minutes`. + - Плашка нагрузки в `README.md`; дополнение записи T16a в `docs/decisions.md`. +- **Тесты:** путь cgroup для docker и systemd, отказ для cgroup v1; разбор файлов cgroup; паники в журнале; аргументы `docker run`; образ и `-url` вместе не принимаются; числа экземпляра совпадают с Terraform. +- **Как проверить:** + - `just load health` и `just load cold` — код 0; + - `just ci-load-smoke` зелёный; + - подложенная регрессия — `/health`, который держит память, — даёт OOM и код 1; + - ручной запуск `load.yml` на ветке кладёт отчёт в summary. +- **Стоимость:** $0 — раннеры GitHub для публичного репозитория; облака прогон не касается. +- **Разделы:** spec 14.2–14.4, 15.3, 15.5, 17; product 9; R33; standalone: T52a.4, T52b. + +#### T17. Телеметрия + +- **Цель:** трассы и метрики, пока кода мало и инструментировать его дёшево (spec 16). +- **Сделать:** перенести `internal/telemetry` из образца (T28a и R49 standalone). + - **Провайдеры и экспорт:** + - провайдеры трасс и метрик, ресурс с версией и определением Cloud Run; + - экспорт OTLP на `telemetry.googleapis.com`; + - сэмплирование `ParentBased(TraceIDRatioBased)`; + - сброс до ответа: после ответа Cloud Run почти не даёт CPU. + - **Вне развёртывания экспорт выключен:** `just run` и тесты не требуют учётных данных Google. + - **Инструментирование:** + - `otelgin` без собственных метрик; `/health` не трассируется; + - адрес клиента и user agent затираются; + - `logging.googleapis.com/trace` — в строке лога. + - **Что можно в атрибутах спанов и метках метрик** — только UUID, коды, маршруты и закрытые словари (spec 16). + - **Terraform:** API телеметрии, роли `telemetry.writer` и `serviceUsageConsumer` аккаунту сервиса; права аккаунта Terraform в `infra/bootstrap.sh` — если нужны (как T28a.3 standalone). +- **Тесты:** + - экспортёр против `httptest`, говорящего OTLP; + - сбой экспорта не меняет ответ и не роняет процесс; + - выключенная телеметрия не делает исходящих запросов; + - страж на утечку: в атрибутах и метках нет псевдонима, LaTeX, текста подсказок и токенов. +- **Нагрузка (правило 14):** `health` идёт и с экспортом в подделку OTLP с задержкой: сброс до ответа укладывается в свой срок и не копит запросы. +- **Как проверить:** после деплоя в Cloud Trace спан сервиса висит под спаном Cloud Run, а строка лога раскрывается в ту же трассу. +- **Стоимость:** в бесплатных лимитах Cloud Trace и Monitoring; назвать, что может стоить. +- **Разделы:** spec 16; privacy 4; R49 standalone. + +#### T18 (до разбиения E2). Контракты API + +- **Цель:** контракт клиента и сервера — один источник правды в `proto/`, из которого генерируются Go и TypeScript (R12). +- **Сделать:** + - **`proto/canvasapi/v1/*.proto`** — сообщения spec 8.4 и ресурсы 8.2: запрос и ответ каждого метода; `buf.yaml`, `buf.gen.yaml`. + - **Генерация** — тот же генератор, что в `contracts` (spec 3.1); сгенерированное коммитится: + - Go — в `gen/` (`protoc-gen-go`); + - TS — в `web/src/gen/` (`@bufbuild/protobuf`, `protoc-gen-es`). + - **Инструменты:** buf и `protoc-gen-go` — `ARG` в стадии `toolchain`; `protoc-gen-es` — в `web/package.json`; расширение VS Code для buf точной версии. + - **Рецепты:** + - `proto` — генерация; + - `ci-proto` — `buf lint`, `buf breaking` против `main` и генерация без расхождений с закоммиченным; задание в `ci.yml`. + - **`internal/transport/http`:** + - разбор и ответ в `application/x-protobuf`; + - JSON по правилам protojson — только при `CANVAS_ENV=local` (spec 8.1) и никогда при `K_SERVICE`; + - ошибки — `apierror`. +- **Тесты:** protobuf и JSON при `CANVAS_ENV=local` дают одно и то же; JSON при `K_SERVICE` отклоняется; fuzz на разборе тела. +- **Как проверить:** `just ci-proto`; PR, ломающий совместимость поля, падает на `buf breaking`. +- **Разделы:** spec 3.1, 3.2, 8.1–8.4, 19; R12. + +#### T19 (до разбиения E2). Статичные задачи + +- **Цель:** две задачи MVP (О-10) живут в коде, проверяются при старте и никогда не отдают ответ. +- **Сделать:** + - **`tasks/*.json`** — две задачи по формату spec 8.6, выбранные в T13. Картинки объектов пути Б — в `web/public/tasks/{task_id}/`: свои SVG под MIT; правая колонка перемешана. + - **Загрузка через `go:embed` и проверка при старте:** + - схема и согласованность страницы с 4.1; + - путь Б — объекты не пересекаются, пары ссылаются на существующие объекты разных колонок; + - путь А — условие разбирается и ответ ему удовлетворяет; эту проверку добавит T28, когда появится движок; + - битая задача — сервис не стартует. + - **Представление задачи для клиента** — без `answer` (spec 13). Отдают его ручки T31, уже за входом; своей ручки у этой задачи нет, чтобы в prod не появился открытый маршрут (правило 13). +- **Тесты:** + - каждая ошибка формата даёт отказ старта с понятной причиной; + - fuzz на разборе JSON; + - страж: ни в одной форме ответа нет поля с ответом задачи. +- **Как проверить:** я вычитываю задачи и картинки. +- **Разделы:** spec 8.2, 8.6, 13; product 5, 7; R04. + +> **Точка проверки фазы 3.** Каркас standalone работает в prod с трассами. Контракт `proto/` и задачи на месте. Инструмент нагрузки гоняет каркас на каждом PR и раз в неделю. + +--- + +### Фаза 4. Данные и доступ + +#### T20. Postgres и миграции + +- **Цель:** данные приложения лежат в Postgres со схемой spec 11, миграциями и интеграционными тестами. Хранилище — как в mentor-api, проверки — как в standalone. +- **Сделать:** + - **`migrations/`** — SQL-миграции goose по модели данных spec 11: + - таблицы, индексы, `ON DELETE CASCADE` — кроме `parent_usage_daily`; + - миграции совместимы со старым кодом: «сначала добавить, потом убрать» (15.5). + - **`cmd/migrate`** — отдельная команда (spec 11); тот же образ собирает и её бинарник. + - **`internal/infra/postgres`:** + - пул pgx, транзакции, закрытие в контейнере; + - хранилище за портами домена — узкие интерфейсы по потребителям, а не один `Store` на всё. + - **Локальный Postgres** — `docker compose` с образом по тегу и digest; рецепты `db-up`, `db-down`, `migrate`. + - **Интеграционные тесты** — отдельная метка сборки, рецепт `ci-integration`, задание в `ci.yml` с Postgres в контейнере. + - **Dependabot** — для файла compose. + - **Облако** — мерж выкатывается в prod, и без базы сервис не стартует (правило 13): + - Neon — проект в регионе T09 и база `canvas`. Через провайдер Terraform, если он есть и его лицензия подходит; иначе руками, по записанной инструкции; + - секрет с URL базы в Secret Manager и его передача сервису в `service_api.tf`; + - job `canvas-migrate` (наш образ, `/migrate`) и шаг CD по R31: миграции на новом образе до выкладки сервиса; + - разделы о Neon и секретах в `docs/self-hosting.md`. +- **Я:** аккаунт Neon; URL базы — в секрет репозитория до мержа (правило 13). +- **Тесты:** + - миграции вверх и вниз на чистой базе; + - каскадное удаление ребёнка стирает холсты, дельты, штрихи, шаги, подсказки и счётчики, но не `parent_usage_daily`; + - транзакция откатывается при ошибке. +- **Нагрузка (правило 14):** + - пул pgx и сроки на стороне базы — по решению Т-15; ожидание соединения ограничено, исчерпанный пул — `UNAVAILABLE` (spec 8.3); + - страж: ожиданий больше, чем соединений, — каждое кончается в срок; отменённый контекст откатывает транзакцию; после прогона соединения вернулись; + - сервису в процессе теперь нужна база. Тесты инструмента против настоящего сервиса получают метку сборки интеграционных тестов и идут в `just ci-integration`, а `just load-test` оставляет тесты самого инструмента — клиента, бегуна, отчёта. `ci-integration` гоняет их и в модуле `tools/load`: корневой `go test ./...` его не видит. `ci-load-smoke` поднимает Postgres из того же compose и применяет миграции. +- **Как проверить:** `just ci-integration`; `just migrate` на пустой базе; после мержа CD зелёный, а миграции прошли на новом образе до выкладки сервиса. +- **Стоимость:** Neon — бесплатный тариф. +- **Риски:** договоры с обработчиками нужны до данных первого ребёнка (П-3). До T58 в prod — только мои тестовые данные. +- **Разделы:** spec 3.2, 11, 13, 15.2, 15.5, 17; privacy 3, 12; R01, R08, R31. + +#### T21. OpenFGA + +- **Цель:** права по модели spec 12.3 проверяет сайдкар OpenFGA, а сервис сам держит модель актуальной (R28). +- **Сделать:** + - **Модель** — `deploy/openfga/model.fga` из 12.3: + - тесты модели (`.fga.yaml`) гоняет CLI OpenFGA из закреплённого образа; рецепт и задание в CI; + - при сборке модель переводится в JSON и встраивается в бинарник. + - **Порт `Authorizer`** в домене и адаптер `internal/infra/openfga` на `github.com/openfga/go-sdk`: + - `Check`; + - запись и удаление кортежей пачками до 100, отсутствующие пропускаются. + - **При старте:** + - найти или создать store `canvas`; + - сравнить последнюю модель со встроенной и при расхождении записать новую (R28, 12.3); + - ждать готовности сайдкара: повтор каждые 200 мс, до 5 с (14.2). + - **Сайдкар в compose** — с настройками 12.3: `127.0.0.1`, без playground, кеш проверок, JSON-логи, отдельная база `openfga`; `openfga migrate` перед стартом. + - **Интеграционные тесты** — против сайдкара в контейнере. + - **Облако** — без сайдкара сервис не стартует (правило 13): + - база `openfga` в том же проекте Neon и секрет с её URL; + - сайдкар OpenFGA по digest в `service_api.tf` с настройками 12.3: параллельный старт контейнеров, startup probe каждую секунду с `failureThreshold: 30` (14.2); + - job `openfga-migrate` в CD — до выкладки сервиса (R31). +- **Я:** URL базы `openfga` — в секрет репозитория до мержа (правило 13). +- **Тесты:** + - родитель управляет своим ребёнком и пишет в его холст, чужой — нет; + - учитель без кортежей ничего не видит; + - повторный старт не пишет модель заново, а два одновременных старта безвредны; + - удаление пачками идемпотентно. +- **Нагрузка (правило 14):** клиент OpenFGA держит соединения под параллельность экземпляра (Т-15); CPU и память сайдкара — тоже переменные Terraform (T10), и в прогоне образа сайдкар стоит рядом с сервисом в этих пределах; страж: одновременных `Check` больше, чем соединений, — каждый кончается в срок. +- **Как проверить:** `just ci-integration`; тесты модели зелёные; после мержа CD зелёный, холодный старт сервиса с сайдкаром — на глаз около 3 с (замер — T54). +- **Разделы:** spec 12.3, 14.2, 15.3, 15.5; R06, R09, R28, R31. + +#### T22. Вход: Firebase Auth и App Check + +- **Цель:** каждый запрос проходит цепочку spec 8.1 в правильном порядке, а необратимые действия требуют свежего входа. +- **Сделать:** + - **Адаптер `internal/infra/firebase`** на Admin SDK (R14): `VerifyIDToken`, `VerifyIDTokenAndCheckRevoked`, `appcheck.VerifyToken`; позже — `RevokeRefreshTokens` и `DeleteUser`. + - **Middleware по порядку 8.1:** + 1. CORS — первым: только домен SPA, свой ответ на `OPTIONS`, `Access-Control-Max-Age: 7200`; + 2. App Check; + 3. ID token — с проверкой отзыва везде, кроме горячего пути: штрихов и `/v1/realtime/token`; + 4. место для лимитов (T45). + - **Свежий вход** — `auth_time` не старше 5 минут, иначе `REAUTH_REQUIRED`. + - **Dev-заглушка** входа и App Check для локальной работы — не стартует при `K_SERVICE`. + - **Субъект доступа** — всегда родитель (R07); в логах — только uid. + - **Облако** — `CANVAS_FIREBASE_PROJECT_ID` и `CANVAS_ALLOWED_ORIGINS` в `service_api.tf`, чтобы после мержа сервис стартовал в prod (правило 13). +- **Тесты:** + - порядок middleware: preflight без токенов проходит; без App Check — `APP_CHECK_FAILED`; без токена — `UNAUTHENTICATED`; + - отозванный токен отклоняется везде, кроме горячего пути; + - свежий вход; + - заглушка при `K_SERVICE` не стартует; + - Firebase подделан через `httptest`, без сети. +- **Нагрузка (правило 14):** заглушка различает родителей, как R123 standalone: `Bearer <имя>` входит как uid `dev-<имя>`, а токен, который не имя, — как один `dev`. Имя — от 1 до 64 букв, цифр, точек, дефисов и подчёркиваний: uid попадает в логи и в кортежи OpenFGA. Нагрузке нужны десятки родителей на одном экземпляре, а настоящий ID token выдаёт только вход через Google или Apple. Клиент инструмента подписывает запросы заглушкой. +- **Как проверить:** `just run` с заглушкой и запрос с её токеном. +- **Разделы:** spec 8.1, 8.3, 12.1, 13; R07, R14. + +#### T23. Родитель: бета, согласие, PIN + +- **Цель:** родитель попадает в бету по инвайту и отмечает уведомление, а согласие подтверждает оператор (R26, R29). +- **Сделать:** + - **`GET /v1/me`** — родитель, дети, статус согласия и беты; всегда 200. + - **`POST /v1/me/invite:redeem`** — сверка SHA-256 кода, привязка к uid, одноразовость, срок. До погашения — `NOT_IN_BETA`. + - **`POST /v1/me/consent`** — статус `pending` и версия уведомления; метод согласия клиент не передаёт. + - **`PUT /v1/me/pin` и `POST /v1/me/pin:verify`:** + - argon2id; + - 5 попыток за 15 минут, счётчик и блокировка — в `parents`; + - смена забытого PIN — после свежего входа (spec 12.2). + - **`cmd/admin`** — запускается как Cloud Run job (15.5): + - `invite create` — печатает код один раз и хранит только хеш; + - `consent verify --uid … --method signed_form` — метод, время и кто подтвердил. + - Email сервис не читает и не хранит (12.1). +- **Тесты:** + - повторное погашение и чужой код; + - согласие без инвайта; + - `verified` ставится только командой; + - PIN: блокировка и её снятие по времени; параметры argon2id — из документации пакета. +- **Нагрузка (правило 14):** + - сценарий `family`: инвайт, согласие, подтверждение командой `cmd/admin`, `GET /v1/me`. Команду инструмент запускает сам: в процессе — её кодом, против образа — её бинарником из того же образа в сети compose; с `-url` сценарий без адреса базы не идёт (код 2); + - стражи: одновременное погашение одного кода — ровно один успех; параллельные `pin:verify` не дают больше 5 попыток за 15 минут. +- **Как проверить:** `just ci-integration`; сценарий 9.1 через HTTP с заглушкой входа — до шага «оператор подтверждает». +- **Разделы:** spec 8.2, 9.1, 11, 12.1, 12.2; privacy 6; R26, R29. + +#### T24. Профили детей + +- **Цель:** родитель с подтверждённым согласием создаёт и меняет профили детей, и OpenFGA сразу знает о правах на них. +- **Сделать:** + - **`POST /v1/children`:** + - только при `verified`, иначе `CONSENT_REQUIRED`; + - псевдоним, аватар из набора, класс 1–6; + - лимиты О-13: не больше 3 детей и 3 новых профилей в день (`parent_usage_daily.children_created`, `LIMIT_EXCEEDED`). + - **Кортеж** `user:{uid} parent child:{id}` пишется внутри транзакции Neon до `COMMIT` (9.1). «Висящий» кортеж безвреден; его убирает очистка (T44). + - **`PATCH /v1/children/{child_id}`** — с проверкой `can_manage`. + - Псевдоним не попадает в логи, трассы и во внешний ИИ. +- **Тесты:** без согласия — отказ; лимиты; сбой записи кортежа откатывает транзакцию; чужой ребёнок — `PERMISSION_DENIED`. +- **Нагрузка (правило 14):** `family` создаёт детей; запись кортежа внутри транзакции — со своим сроком (Т-15); страж: параллельные `POST /v1/children` не выходят за 3 детей и 3 новых профиля в день. +- **Как проверить:** `just ci-integration`. +- **Разделы:** spec 8.2, 9.1, 11, 12.3, 14.5; product 6; privacy 1. + +#### T25. Облако: вход и администрирование + +- **Цель:** в prod работает настоящий вход родителя через Google и Apple с App Check, а оператор выполняет административные команды — всё описано в Terraform (R34). Данные уже в облаке: базы и сайдкар принесли T20 и T21. +- **Сделать:** + - **`firebase.tf`:** провайдеры входа Google и Apple, App Check с reCAPTCHA Enterprise (ключ с оценкой риска), разрешённые домены. + - **Секреты провайдеров входа (R34):** + - `secrets.tf` — секреты для закрытого ключа Apple и секрета клиента Google; + - значения приходят из секретов репозитория: `ci-tf-apply` кладёт версию, если её ещё нет, — как секрет клиента Google в образце. В публичный `prod.auto.tfvars` они не попадают; + - провайдеры Google и Apple в `firebase.tf` берут значения из Secret Manager; в консоли Firebase руками ничего не настраивается; + - ротация — отдельная команда в `docs/self-hosting.md`, как ротация ключа в образце: + 1. новое значение — сначала в секрет репозитория; + 2. затем новая версия в Secret Manager; + 3. прежняя версия уничтожается, только когда Terraform уже читает новую, — чтобы число активных версий не росло (spec 13); + - если ресурс Terraform не умеет нужную настройку провайдера — остановиться и спросить: R34 требует полного IaC. + - **Job `admin`** для `cmd/admin` (spec 15.5): оператор запускает его с аргументами, а не через публичный API. + - **Документация:** разделы о провайдерах входа, их секретах и ротации в `docs/self-hosting.md`; отчёт `docs/live/04-sign-in.md`. +- **Я** — дела без API, каждое один раз: + - OAuth-клиент Google (Web) в консоли Google Cloud: client id — в `prod.auto.tfvars`, секрет — в секрет репозитория; + - Apple Developer Program: Services ID и ключ для входа через Apple; идентификаторы — в `prod.auto.tfvars`, закрытый ключ — в секрет репозитория. +- **Как проверить:** + - CD зелёный; + - вход через Google и Apple в prod работает с провайдерами из Terraform; `terraform plan` после `apply` пуст — ручных правок нет; + - инвайт, выпущенный job `admin`, гасится в prod. +- **Стоимость:** Apple Developer — $99 в год; reCAPTCHA Enterprise — в бесплатном лимите; секреты — по расчёту spec 13. +- **Разделы:** spec 12.1, 13, 15.2, 15.5; R14, R29, R34. + +> **Точка проверки фазы 4.** В prod, с сайдкаром OpenFGA, родитель входит, гасит инвайт и отмечает уведомление. Оператор подтверждает согласие, после чего родитель создаёт профиль ребёнка. + +--- + +### Фаза 5. Холст и путь А + +Домен — чистый Go, тесты, $0. Правила тестов — в CLAUDE.md. + +#### T26. Холст: страница, штрихи, строки + +- **Цель:** геометрия холста — чистая функция, одинаковая на всех устройствах (R15). +- **Сделать:** `internal/domain/canvas`. + - **Логическая страница (spec 4.1):** 1000 × 1600, область условия до y = 200, 14 строк по 100. + - **Штрих и точка (4.2).** Проверки пределов (13): + - до 2000 точек в штрихе; + - координаты в пределах страницы; + - строки фиксированной длины; + - до 300 штрихов на холсте. + - **Сегментация (4.3):** + - строка — по медиане y через `math.Floor`; + - штрихи в области условия не распознаются; + - медиана ≥ 1600 — в 14-ю строку; + - какие строки изменились. + - **Рамки (4.4):** рамка штрихов строки с отступом 10, рамка одного штриха, перевод `box_2d` из 0..1000 в логические координаты. +- **Тесты:** + - таблица границ: 199, 200, 1599, 1600, отрицательные; + - свойства на gopter: перевод рамки туда и обратно; строка штриха не зависит от порядка точек; + - fuzz на проверке пределов. +- **Разделы:** spec 4, 13; R15, R16, R20. + +#### T27. Движок: разбор записи + +- **Цель:** LaTeX, который Mathpix выдаёт на школьную запись, разбирается в дерево, а незаконченная строка узнаётся как незаконченная. +- **Сделать:** `internal/domain/mathstate`. + - **Нормализация (spec 5.2):** пробелы, `\left`, `\right`, `\,`, юникодный минус, `\times`, `\cdot`, `\div`. + - **Парсер** — по решению Т-2 (рекомендация — свой Pratt). Поддерживает: + - числа и одну переменную; + - `+ - × · ÷ / :` и неявное умножение; + - скобки, `\frac`, `=`. + - **Недописанное и неподдерживаемое:** + - незавершённый ввод (`2x=11-`, `2x=`) — признак `INCOMPLETE`, а не ошибка разбора; + - нелинейное и неподдерживаемое — `UNRECOGNIZED`. +- **Тесты:** + - таблица записей из 5.2 и 5.6; + - fuzz на парсере: LaTeX от Mathpix — недоверенный ввод, и паника здесь роняет запрос; + - свойство: печать дерева и повторный разбор дают то же дерево. +- **Разделы:** spec 5.1–5.3, 20 (Т-2). + +#### T28. Движок: арифметика и вердикты + +- **Цель:** шаг проверяется точно, без плавающей точки, и движок никогда не роняет запрос. +- **Сделать:** + - **Точная арифметика (spec 5.3):** вычисления в `big.Rat`; стороны приводятся к `a·x + b`; шаги эквивалентны, если совпадают множества решений; выражения — по значению. + - **Деление на ноль** — `ERROR` с кодом `DIVISION_BY_ZERO`, в том числе когда знаменатель стал нулём после вычисления. + - **Вердикты (5.4):** `CORRECT`, `INCOMPLETE`, `UNRECOGNIZED`, `ERROR`, `FOLLOWS_ERROR`, `UNCHECKED`; признак «задача решена» — `x = v` или `v = x`. + - **Паника** перехватывается на границе пакета → `UNCHECKED` с причиной `engine_error`. + - **Проверка задач пути А при старте (T19):** условие разбирается, ответ ему удовлетворяет. +- **Тесты:** + - вся таблица 5.6; + - свойства на gopter из spec 17: + - случайные линейные уравнения и верные преобразования дают `CORRECT`; + - ни одно верное преобразование не даёт `ERROR`; + - выражения с делением, в том числе на ноль, не паникуют. +- **Разделы:** spec 5.3–5.6, 17. + +#### T29. Движок: типовые ошибки и пороги + +- **Цель:** неверный шаг получает код и место ошибки, подсказка — запрещённые фрагменты и пример. Сомнение в распознавании даёт тишину, а не ложную тревогу. +- **Сделать:** + - **Библиотека ошибок (spec 5.5):** `SIGN_ON_TRANSFER`, `ARITHMETIC`, `WRONG_INVERSE_OPERATION`, `COPY_ERROR`, `DIVISION_BY_ZERO`, `UNCLASSIFIED`. Каждое «ошибочное правило» применяется к последней верной строке и говорит, что передать в подсказку. + - **Ключ лестницы** `ladder_key` — из кода ошибки и её места (7.2). + - **Запрещённые фрагменты** для проверки подсказки (7.3): ответ, исправленная строка, исправленный член. + - **Пример третьей ступени** — на других числах, не совпадающих с запрещёнными (7.2, R24). + - **Пороги 5.7** — параметры конфигурации, значения по итогам T14: + - ниже порога оценки — `UNRECOGNIZED`; + - `ERROR` показывается только выше порога ошибки; + - между порогами — `UNRECOGNIZED`. +- **Тесты:** + - свойство: внедрённая ошибка каждого типа классифицируется своим кодом (spec 17); + - пример третьей ступени никогда не содержит запрещённых чисел; + - таблица порогов на границах. +- **Разделы:** spec 5.5–5.7, 7.2, 7.3, 17; R20, R24. + +#### T30. Распознавание: Mathpix + +- **Цель:** строка рукописи превращается в LaTeX и уверенность, а любой сбой поставщика — в `UNCHECKED`, а не в ошибку. +- **Сделать:** + - **Порт `Recognizer`** в домене; адаптер `internal/infra/mathpix` на `net/http`. + - **Запрос — строго по R18 и spec 10.3:** + - `strokes.strokes.x/y` — массивы массивов целых, без времени и идентификаторов; + - `metadata.improve_mathpix: false`; + - `confidence_threshold: 0`, `confidence_rate_threshold: 0`. + - **Ответы и сбои:** + - тайм-аут 3 с; + - `error_info` о низкой уверенности — `UNRECOGNIZED`; + - тайм-аут, 5xx и прочие ошибки — `UNCHECKED` с причиной `recognizer_unavailable` (9.9). + - **Конфиг:** `CANVAS_MATHPIX_APP_ID`, `CANVAS_MATHPIX_APP_KEY`; ключ не попадает в логи. + - **Облако:** секрет ключа Mathpix в Secret Manager и его передача сервису в `service_api.tf` — тем же путём, что секрет базы в T20. + - **Спайк:** удалить `spike/recognition/` — полезное уже в отчёте T14. +- **Тесты:** + - запрос сверяется с записанным синтетическим эталоном в `testdata/`; + - записанные ответы через `httptest`: успех, низкая уверенность, ошибка, мусор; + - fuzz на разборе ответа; + - страж: в запросе нет ничего, кроме координат. +- **Нагрузка (правило 14):** + - подделка Mathpix в инструменте: задержки по spec 14.1, 5xx, 429, тайм-ауты, мусор; + - адрес Mathpix — из конфигурации; с переопределённым адресом сервис при `K_SERVICE` не стартует, как прочие dev-переключатели; + - слоты вызовов — по решению Т-14; страж: распознаваний больше, чем слотов, — лишние получают `UNCHECKED` в срок, а не висят. +- **Я:** отказ от хранения данных на уровне аккаунта Mathpix (privacy 5.1); ключ — в секрет репозитория до мержа (правило 13). +- **Разделы:** spec 3.2, 5.7, 6.4, 9.9, 10.3; privacy 5.1; R18, R19, R20. + +#### T31. Задачи и холсты + +- **Цель:** ребёнок выбирает задачу и начинает её, а после перезагрузки SPA получает холст обратно — без ответа задачи. +- **Сделать:** + - **`GET /v1/tasks`** — список задач без `answer` (spec 8.2, 13), по ID token. + - **`POST /v1/canvases`** — `can_manage` на ребёнка и согласие `verified`: + - прежний активный холст этой задачи становится `abandoned`; + - новый холст и кортеж `child:{id} owner canvas:{cid}` — в одной транзакции (spec 9.2); + - ответ — холст и задача без `answer`; токены Centrifugo добавит T36. + - **`GET /v1/canvases/{canvas_id}`** — `can_read`: штрихи, вердикты и `last_seq` для восстановления (8.4). + - `canvas_id` берётся только из пути (13). +- **Тесты:** + - второй холст той же задачи бросает первый; + - чужой холст — `PERMISSION_DENIED`; + - страж: ответ задачи не уходит ни в одной ручке. +- **Нагрузка (правило 14):** `family` открывает холст и восстанавливает его через `GET /v1/canvases/{canvas_id}`; страж: два одновременных `POST /v1/canvases` одной задачи оставляют один активный холст. +- **Разделы:** spec 8.2, 8.4, 9.2, 9.7, 11, 12.3, 13. + +#### T32. Приём дельт + +- **Цель:** протокол дельт не теряет штрихи ребёнка — ни при второй вкладке, ни при обрыве сети, ни при сбое посреди запроса (R16). +- **Сделать:** `POST /v1/canvases/{canvas_id}/strokes`, часть приёма. + - **Пределы (spec 13):** тело до 512 КБ, до 300 штрихов в запросе, до 2000 точек в штрихе, до 300 штрихов на холсте (`PAYLOAD_TOO_LARGE`, `CANVAS_FULL`). + - **Короткая транзакция** под `SELECT … FOR UPDATE` (8.4): сверка `seq`, сохранение штрихов, запись в `canvas_deltas` со статусом `accepted`, сдвиг `last_seq`. + - **Таблица «порядок и повторы» 8.4 — целиком:** + - повтор получает сохранённый ответ; + - прерванная проверка в статусе `accepted` перезапускается; + - `SEQ_CONFLICT` с `details.last_seq`; + - `RESYNC_REQUIRED` с `details.expected_seq`. + - **Снимок** `full_snapshot` заменяет штрихи холста. Ластик ставит `removed_at`, а не удаляет строку. + - **Одна проверка на холст за раз:** строки «в проверке» (`steps` со статусом `pending` и `revision`) параллельный запрос не берёт. + - **Распознавание и проверка** идут вне блокировки (T33). Ответ дельты сохраняется в `canvas_deltas` со статусом `done`. +- **Тесты:** + - все сценарии «Протокол» из spec 17: повтор, `SEQ_CONFLICT`, снимок после `RESYNC_REQUIRED`, прерванный запрос в статусе `accepted`, параллельные запросы к одному холсту, отклонённый штрих; + - fuzz на разборе дельты. +- **Нагрузка (правило 14):** + - сценарий `second_tab` — две вкладки пишут в один холст: `SEQ_CONFLICT`, перенумерация, ни одного потерянного штриха; + - сценарий `flaky_network` — клиент бросает запрос по своему сроку и повторяет тот же `client_event_id`: ни дублей, ни потерь; + - страж: параллельные дельты одного холста ждут блокировку не дольше срока (Т-15). Пока идёт проверка — здесь её заменяет медленная подделка, — ни соединение, ни блокировка не заняты (Т-12, Т-13). +- **Как проверить:** `just ci-integration`. +- **Разделы:** spec 3.1, 8.3, 8.4, 11, 13, 17; R16. + +#### T33. Проверка пути А + +- **Цель:** принятая дельта проверяется по правилам spec 4.3, 5.4 и 6.1. Верный шаг проходит молча; ошибка показывается только в законченной строке и только при уверенном распознавании. +- **Сделать:** + - **Цепочка:** распознаются только изменившиеся строки (Mathpix) → движок сравнивает строку с последней верной выше; первую строку — с условием. + - **Что проверяется (8.4):** + - `TRIGGER_PAUSE` — изменившиеся строки и строки ниже изменённой; нижние пересчитываются по сохранённому LaTeX, без распознавания; + - `TRIGGER_CHECK_BUTTON` — все строки без окончательного вердикта. + - **«Строка закончена» (4.3):** ребёнок начал другую строку, нажал Check или 6 секунд не трогал холст. До этого `ERROR` сохраняется, но не публикуется. + - **Маркеры и вердикты:** + - маркер снимается (`clear: true`), когда строку исправили или стёрли; + - `FOLLOWS_ERROR` для строк, верно выведенных из ошибочной; + - неизменившаяся строка получает сохранённый вердикт — повторный Check не гоняет её по кругу (5.3). + - **Завершение (9.7):** `completed`, `completed_at`, `task_completed`. + - **Реплики по Check** — по таблице 5.4: шаблонные фразы, без модели. + - **Ответ** — `SubmitStrokesResponse`; подсказок пока нет, их добавит T35. +- **Тесты:** + - сценарии 9.3, 9.5 и строки таблицы 5.6 через HTTP, с записанными ответами Mathpix; + - ошибка в незаконченной строке не показывается, а после дописывания становится `CORRECT`; + - исправленная строка снимает маркер и пересчитывает нижние; + - сбой Mathpix — `UNCHECKED` и повтор при следующем триггере. +- **Нагрузка (правило 14):** + - `lesson` — путь А на синтетических штрихах от первой строки до `x = 3`. Подделка Mathpix узнаёт строку только по координатам, как их получает Mathpix (privacy 5.1): сценарий пишет каждую строку по шаблону штрихов, однозначно связанному с её LaTeX, и подделка сверяет шаблон — без побочного канала с id ребёнка. Ожидание — задача решена, ложных тревог нет, распознаваний на задачу не больше оценки product 9; + - `saturation` — детей больше, чем помещается в экземпляр: `UNAVAILABLE` и `UNCHECKED` считаются, жёстких фактов нет; + - `shutdown` — посреди `lesson`: начатые проверки доходят до `done` или остаются `accepted` и доводятся повтором (Т-12, Т-13); + - бюджет запроса — по решению Т-11. +- **Как проверить:** `just ci-integration`; задача А решается на записанных ответах от первой строки до `x = 3`. +- **Разделы:** spec 4.3, 5.4, 6.1, 8.4, 9.3, 9.5, 9.7, 9.9; product 5.2–5.7; R16, R17, R20. + +> **Точка проверки фазы 5.** Задача А решается через HTTP от первой строки до ответа. Ошибки из таблицы 5.6 находятся, ложных тревог на записанных ответах нет. + +--- + +### Фаза 6. Наставник и realtime + +#### T34 (до разбиения E2). Наставник: лестница, проверки, шаблоны + +- **Цель:** подсказка, откуда бы она ни пришла, проходит одни и те же проверки, а при любом сбое ребёнок получает заготовленный вопрос (R24). +- **Сделать:** `internal/domain/tutor`. + - **Маршрутизация (spec 6.1):** когда нужна подсказка, а когда хватит шаблонной фразы. + - **Лестница (7.2):** ступень считается по `ladder_key` и числу подсказок на холсте; после третьей — `offer_help`. + - **Проверки (7.3):** + - схема ответа; `target_element` — из переданных; + - не больше 2 предложений и 200 символов, без ссылок и разметки; + - нормализация — числа словами в цифры, «take away 5» → `-5` — и сравнение числовых токенов с запрещёнными фрагментами. + - **Шаблоны (7.3):** по коду ошибки и ступени; шаблон третьей ступени — с примером движка; источник `template`. +- **Тесты:** + - утечки словами и цифрами ловятся (`minus five`, `three`), а `3` не совпадает с `13` и `3x`; + - пример третьей ступени проходит проверку; + - свойства на gopter для нормализации; + - fuzz на проверке текста модели. +- **Разделы:** spec 6.1, 7.2, 7.3; product 5.4; privacy 10; R24. + +#### T35 (до разбиения E2). Подсказки: Gemini + +- **Цель:** при ошибке ребёнок получает короткий наводящий вопрос от модели, а если модель не справилась — шаблон. Сам запрос не уносит ничего, кроме математики. +- **Сделать:** + - **Порт `Tutor`**; адаптер `internal/infra/gemini` на `google.golang.org/genai` с бэкендом `BackendVertexAI`, регион — из T09. + - **Модель и параметры (spec 7.1):** + - ID модели — из конфигурации; выбор (Т-5) — по итогам T14 и T38; + - `thinkingLevel: MINIMAL`, `maxOutputTokens: 1024`, тайм-аут 4 с; + - `responseMimeType` и `responseJsonSchema` (10.5); + - `MAX_TOKENS` — брак. + - **Промпт:** + - системная инструкция — файл `internal/domain/tutor/prompts/hint.system.md` с номером `prompt_version`; + - пользовательская часть собирается из результата движка — без идентификаторов и псевдонима (7.2, privacy 5.2). + - **Порядок внутри запроса (R23):** + 1. вердикт; + 2. вызов модели; + 3. проверки T34; + 4. сверка `revision` цели — подсказка к изменённой строке отбрасывается; + 5. запись в `hints` (модель, `prompt_version`, `latency_ms`); + 6. ответ. + - **Terraform:** роль вызова моделей аккаунту сервиса; API Agent Platform включила T10. Кеширование входов модели в проекте выключено (privacy 5.2) — способ сверить с документацией. + - **В лог** — `prompt_version` и ID модели, но не текст (spec 16). +- **Тесты:** + - записанные синтетические ответы Gemini через `httptest`: хороший, с утечкой, с двумя вопросами, обрезанный JSON, `MAX_TOKENS`, тайм-аут; + - страж: в запросе к модели нет UUID и псевдонима; + - fuzz на разборе ответа. +- **Нагрузка (правило 14):** + - подделка Gemini в инструменте: задержки, `MAX_TOKENS`, утечки, тайм-ауты. Адрес модели переопределяется только вне Cloud Run; способ — по документации `genai` закреплённой версии; + - `lesson` со строкой-ошибкой: подсказка или шаблон приходят в бюджете запроса (Т-11), утечек нет; слоты вызовов — по Т-14. +- **Как проверить:** один живой вызов в prod-проекте — после предупреждения о стоимости. +- **Стоимость:** доли цента за вызов. +- **Разделы:** spec 7, 8.4, 9.4, 9.9, 10.5, 10.6; privacy 5.2, 10; R22, R23, R24. + +#### T36. Realtime: Centrifugo + +- **Цель:** маркер появляется быстрее секунды, подсказка догоняет его по WebSocket, и при разрыве ничего не теряется. +- **Сделать:** + - **`deploy/centrifugo/config.json`** — формат v6: + - пространство `canvas` из spec 8.5: история 20 событий, 600 с, `history_meta_ttl` 3600 с, `force_recovery`; + - без `allow_*`; `client.allowed_origins` — домен SPA. + - **Порт `Notifier`**, адаптер `internal/infra/centrifugo`: HTTP API `/api/publish` с `idempotency_key` по версии цели. Сбой публикации пишется в лог и не роняет ответ (9.9). + - **Токены (12.4):** + - HS256 — подключение и подписка на `canvas:{canvas_id}`, подписка только после `can_read`; + - выдаются в `POST /v1/canvases` и `POST /v1/realtime/token`. + - **Публикация по R23:** `verdict` — сразу после движка, `hint` — после модели, затем `task_completed`. HTTP-ответ повторяет всё. + - **Локально** — Centrifugo и Redis в compose по digest; интеграционный тест с настоящим сервером. + - **Облако:** + - Upstash в регионе T09, `rediss://`; + - сервис `service_centrifugo.tf` (15.3): 1 vCPU, 512 МиБ, параллельность 1000, тайм-аут 3600 с, session affinity; + - секреты: API-ключ, HMAC и URL Upstash; + - выкладка образа по digest в CD. +- **Тесты:** + - токены: срок, `sub`, канал; на чужой холст подписаться нельзя; + - порядок событий; + - Centrifugo недоступен — ответ всё равно 200 с вердиктами. +- **Нагрузка (правило 14):** подделка HTTP API Centrifugo в инструменте — с задержкой и сбоями: ответ не ждёт публикацию дольше её срока (Т-11). `load.yml` гоняет ещё и настоящий Centrifugo из compose. +- **Я:** аккаунт Upstash; его URL — в секрет репозитория до мержа. API-ключ и HMAC-секрет Centrifugo генерирует `ci-tf-apply` (правило 13). +- **Как проверить:** `just ci-integration`; в prod `centrifuge-js` из консоли браузера получает `verdict` раньше HTTP-ответа. +- **Стоимость:** Upstash — бесплатный тариф. Cloud Run считает инстанс с открытым WebSocket активным (14.4); оценку даст T39. +- **Разделы:** spec 2, 8.5, 9.4, 9.9, 12.4, 14.3, 14.4, 15.2–15.5; R08, R10, R23. + +#### T37. Прогресс ребёнка + +- **Цель:** родитель видит минимум из О-14: какие задачи начаты и решены, сколько было подсказок, какие типы ошибок встречались, когда было последнее занятие. +- **Сделать:** `GET /v1/children/{child_id}/progress` с проверкой `can_manage`: + - агрегаты из `canvases`, `steps` и `hints`; + - пометку «дневной лимит исчерпан» (spec 14.5) добавит T45; + - без текстов подсказок и LaTeX. +- **Тесты:** агрегаты на холстах в разных статусах; чужой ребёнок — отказ. +- **Нагрузка (правило 14):** `family` читает прогресс; запрос агрегатов укладывается в свой срок и не держит соединение дольше него (Т-15). +- **Разделы:** product 6 (С6), 15 (О-14); spec 8.2, 11. + +#### T38. Eval-набор подсказок + +- **Цель:** модель и промпт подтверждены на наборе ситуаций, а не на глаз: подсказка не раскрывает ответ, в ней один вопрос, она по возрасту и по делу (spec 7.4). +- **Сделать:** + - **Набор** — синтетические ситуации «код ошибки × ступень × задача» с ожиданиями, в `testdata/eval/hints/`. + - **Прогон `just eval-hints`** — настоящая модель, автоматические проверки 7.3 и лист для ручной рубрики. В `ci-test` не входит. + - **Выбор** модели (Т-5) и `prompt_version`. + - **Отчёт** `docs/live/05-eval-hints.md` — доли по рубрике и утечкам. +- **Я:** ручная рубрика по выборке. +- **Как проверить:** критерии product 12 — не меньше 95 % подсказок проходят рубрику, ни одна не раскрывает ответ. +- **Стоимость:** ≈ $0.001 за вызов; сотни вызовов — меньше доллара. +- **Разделы:** spec 7.4, 17, 20 (Т-5); product 12; R22, R24. + +#### T39. Спайк S3: Centrifugo на Cloud Run + +- **Цель:** до беты узнать, во что обходятся открытые WebSocket и служебные команды Upstash. +- **Сделать:** + - **Генератор трафика** в `spike/realtime/` — подключения, подписки, переподключения и публикации по сценарию занятия. + - **Среда одна — prod (R32), поэтому генератор изолирован от данных:** + - ходит прямо в Centrifugo: токены подписывает сам секретом HMAC, публикует через HTTP API; + - работает как Cloud Run job в том же проекте и берёт секреты из Secret Manager, как сервисы: на машину разработчика они не попадают; + - canvas-api, Neon и данные родителей не трогает; + - каналы `canvas:spike-…` исчезают сами по сроку истории — 10 минут, мета-ключи — через час (spec 8.5); + - у генератора — потолок подключений и публикаций. Расход команд Upstash считается до запуска: бесплатные лимиты и бюджетное оповещение общие для всего платёжного аккаунта. + - **Неделя работы в prod.** + - **Отчёт** `docs/live/06-spike-realtime.md`: стоимость инстанса, расход команд Upstash, восстановление истории. +- **Как проверить:** критерий S3 (spec 20) — стоимость и команды в пределах бюджета беты. Иначе — меры из 14.4 и вопрос. +- **Стоимость:** назвать до запуска; ожидается в пределах бесплатных лимитов и бюджета. +- **Разделы:** spec 14.3, 14.4, 20. + +> **Точка проверки фазы 6.** При ошибке маркер приходит по WebSocket, следом — подсказка. Eval подсказок пройден, стоимость realtime измерена. + +--- + +### Фаза 7. Путь Б + +#### T40. Путь Б: разбор связей + +- **Цель:** стрелки между объектами разбирает программа — точнее, дешевле и быстрее модели (R21). +- **Сделать:** `internal/domain/diagram` по spec 6.2. + - **Отбор штрихов:** отбрасываются короче 40, лежащие внутри зоны одного объекта и отдельные наконечники до 120. + - **Концы и объекты:** рамка объекта, зона +30; при разнице расстояний меньше 20 конец неоднозначен. + - **Связи:** однозначные связи и неоднозначные штрихи. + - **Сравнение с `answer.pairs`:** `WRONG_CONNECTION`, `DUPLICATE_CONNECTION`. + - **Для подсказки:** маркер — рамка штриха; `ladder_key` — по объекту (7.2); запрещённый фрагмент — правильная пара объекта (7.3). +- **Тесты:** + - таблица случаев из 6.2 на задаче spec 8.6; + - свойства на gopter: сдвиг конца внутри объекта не меняет связь, направление стрелки не важно; + - fuzz на штрихах. +- **Разделы:** spec 6.1, 6.2, 7.2, 7.3; R21. + +#### T41. Путь Б: рендер и vision + +- **Цель:** неоднозначный рисунок распознаёт vision-модель, вердикт выносит программа, а сбой модели даёт тишину, а не ложную ошибку (R21). +- **Сделать:** + - **Рендер** — `internal/infra/render` на `github.com/tdewolff/canvas` (псевдоверсия): PNG области задачи по spec 6.3, сетка — по решению Т-6 из T15. + - **Адаптер vision** в `internal/infra/gemini`: + - `gemini-3.8-flash`, `thinkingLevel: LOW`, `maxOutputTokens: 4096`, тайм-аут 8 с; + - схема ответа с перечислениями (10.9); эталон в запрос не попадает. + - **Сверка и память:** номер штриха сверяется по `box_2d` (4.4); интерпретация сохраняется в `strokes.interpretation`, и повторно модель не зовут. + - **Сбой** — `UNCHECKED` с причиной `vision_failed`, повтор при следующей проверке; задача не засчитывается (6.1, 9.9). + - **Оркестрация** — строки пути Б таблицы 6.1; подсказка идёт тем же путём, что в T35. + - **Спайк:** удалить `spike/vision/`. +- **Тесты:** + - записанные синтетические ответы vision: верный, номер не сошёлся, обрезанный JSON, тайм-аут; + - рендер — снимком, которым владеет сам сервис: golden с `-update`, новый diff — часть ревью; + - страж: в запросе нет ответа задачи. +- **Нагрузка (правило 14):** сценарий `diagram` — путь Б с неоднозначными штрихами и подделкой vision: пик памяти рендера под пределом контейнера; слоты рендера и vision — по Т-14. +- **Разделы:** spec 4.4, 6.1, 6.3, 9.6, 9.9, 10.9; R21. + +#### T42. Eval-набор пути Б + +- **Цель:** точность интерпретаций на неоднозначных рисунках подтверждена набором (spec 17). +- **Сделать:** набор из T15, дополненный рисунками, на которых vision ошибался; прогон `just eval-diagram`; отчёт `docs/live/07-eval-diagram.md`. +- **Как проверить:** не меньше 90 % верных интерпретаций (S2); ни одной ложной `WRONG_CONNECTION` из-за модели. +- **Стоимость:** центы. +- **Разделы:** spec 17, 20. + +> **Точка проверки фазы 7.** Задача Б решается. Неоднозначные рисунки разбирает vision, а сбой модели не даёт ложной ошибки. + +--- + +### Фаза 8. Приватность и эксплуатация + +#### T43. Удаление профиля и аккаунта + +- **Цель:** родитель удаляет профиль ребёнка или весь аккаунт в один шаг, и повтор после любого сбоя доводит удаление до конца (R27). +- **Сделать:** + - **`DELETE /v1/children/{child_id}`** — `can_manage` и свежий вход. Порядок spec 9.8: + 1. Neon — каскадом; + 2. кортежи холстов и ребёнка — пачками до 100, отсутствующие пропускаются. + + Повтор отвечает 204, а не 404. + - **`DELETE /v1/me`:** + 1. всё то же для каждого ребёнка; + 2. `parents`; + 3. `RevokeRefreshTokens`; + 4. `DeleteUser`. + - `parent_usage_daily` не удаляется вместе с ребёнком (14.5). +- **Тесты:** + - сбой на каждом шаге 9.8 и повтор, который доводит удаление до конца (spec 17); + - старый токен после удаления аккаунта не создаёт `parents` заново — работает проверка отзыва. +- **Нагрузка (правило 14):** `family` в конце удаляет ребёнка, а затем аккаунт; страж: удаление ребёнка во время приёма дельты его холста — ни 500, ни осиротевших строк и кортежей. +- **Как проверить:** `just ci-integration`; сценарий 9.8 через HTTP. +- **Разделы:** spec 9.8, 12.1, 17; privacy 7; R14, R27. + +#### T44. Ежедневная очистка + +- **Цель:** данные живут не дольше сроков privacy 8 — без ручной работы. +- **Сделать:** + - **`cmd/cleanup`** (spec 11, privacy 8): + - помечает брошенные холсты — 24 часа без изменений; + - удаляет штрихи и дельты старше срока (Т-9, по умолчанию 30 дней после `completed_at` или `abandoned_at`); + - удаляет счётчики старше 30 дней и истёкшие инвайты; + - удаляет «висящие» кортежи OpenFGA без строк в Neon; + - удаляет записи `changelog` базы `openfga` старше суток; + - удаляет профили, неактивные 12 месяцев. + - **Cloud Run job `cleanup` и Cloud Scheduler** — в Terraform; образ приносит CD. + - **Идемпотентность:** повторный прогон ничего не ломает. +- **Тесты:** каждое правило на подготовленных данных; граница сроков; повторный прогон. +- **Нагрузка (правило 14):** страж: очистка идёт во время приёма дельт — ни одного потерянного подтверждённого штриха, а блокировку холста очистка ждёт не дольше срока (Т-15). +- **Как проверить:** `just ci-integration`; ручной запуск job в prod — в логе число удалённого по видам, без идентификаторов. +- **Разделы:** spec 9.7, 11; privacy 7, 8, 12 (П-6); R27. + +#### T45. Лимиты + +- **Цель:** бета не выходит за бюджет: дневные лимиты на ребёнка и родителя и лимит частоты проверок (spec 14.5, О-13). +- **Сделать:** + - **Счётчики** `usage_daily` и `parent_usage_daily` (сутки — UTC) — для распознаваний, подсказок и vision. Потолок родителя считается по всем детям и не сбрасывается удалением ребёнка. + - **Исчерпанный лимит не отклоняет дельту и не прерывает занятие (R35):** + - штрихи сохраняются; + - строки получают `UNCHECKED` с причиной `limit`; + - ребёнок решает дальше сам, без проверки, и видит фразу из таблицы spec 5.4. + - **Частота** — не больше 2 проверок в секунду на холст: счётчик в памяти инстанса, `LIMIT_EXCEEDED`; middleware — на своём месте в цепочке 8.1. + - **Значения** — `CANVAS_LIMIT_*` (15.4). + - **Прогресс** — пометка «дневной лимит исчерпан» в `GET /v1/children/{child_id}/progress` (T37, spec 14.5). +- **Тесты:** + - каждый лимит на границе; + - удаление и пересоздание ребёнка не снимает потолок родителя; + - дельта при исчерпанном лимите сохраняется. Любой исчерпанный лимит — распознаваний, подсказок или vision, ребёнка или родителя — останавливает проверку целиком: ни Mathpix, ни Gemini, ни маркера, ни подсказки, ни `task_completed`; + - после обновления лимита следующая проверка, в том числе по паузе, начинает со строк `UNCHECKED` с причиной `limit`, сверху вниз; + - прогресс показывает пометку об исчерпанном лимите. +- **Нагрузка (правило 14):** сценарий `limits` — жадный ребёнок и остальные: у остальных всё проходит, у жадного — `LIMIT_EXCEEDED` и `UNCHECKED` с причиной `limit`; страж: счётчики `usage_daily` и `parent_usage_daily` не теряют прибавлений при параллельных проверках. +- **Разделы:** spec 5.4, 8.4, 13, 14.5, 15.4; product 5.6, 9; R16, R35. + +#### T46. Наблюдаемость и аудит логов + +- **Цель:** метрики MVP видны, алерты настроены, а в логах и трассах нет персональных данных (product 12, критерий 7). +- **Сделать:** + - **Метрики** spec 16 — с закрытыми словарями меток; спаны внешних вызовов: OpenFGA, Neon, Mathpix, Gemini, Centrifugo. + - **Алерты** spec 16 в Terraform — 5xx, p95 до маркера, доля шаблонных подсказок, отказы Mathpix, бюджет. С оценкой стоимости политик на метриках. + - **Тест аудита:** полный сценарий 9.1–9.8 с перехватом логов → нет email, псевдонима, LaTeX, текстов подсказок и токенов. + - **Журналы запросов Cloud Run с IP-адресами** — решение по П-9 и его настройка в Terraform. + - **`just report`** — агрегаты из Cloud Logging для метрик беты (product 12). +- **Как проверить:** + - после прогона сценария метрики видны в Cloud Monitoring; + - алерт срабатывает на тестовом условии; + - аудит зелёный. +- **Стоимость:** назвать цену алертов на метриках. +- **Разделы:** spec 13, 16; product 12; privacy 4, 12 (П-9). + +#### T46a. Нагрузка: калибровка чисел развёртывания + +- **Цель:** числа экземпляра, пулов, слотов и сроков сведены друг с другом на готовом сервере — до SPA и беты (как T52b standalone). +- **Сделать:** + - **Прогон** — все сценарии против образа под ограничениями экземпляра. Между сервисом и Postgres — прокси с задержкой, как у Neon (spec 14.1): без неё блокировка холста и пул выглядят лучше, чем будут в prod. + - **Свести числа, которые до сих пор друг друга не видели:** + - параллельность 40 и `max-instances` (spec 15.3) — с пулом pgx, пулом OpenFGA и лимитом соединений Neon, прямых и через pooler (Т-15); + - слоты Mathpix, Gemini, vision и рендера и их ожидания (Т-14) — с квотами поставщиков и памятью экземпляра; + - CPU и память api и openfga внутри 1 vCPU и 1 ГиБ: худший законный расход рендера на слот, умноженный на число слотов; + - бюджет запроса дельты — с тайм-аутом Cloud Run и сроком клиента (Т-11). + - **Результат** — в `infra/terraform/variables.tf`, в умолчаниях `CANVAS_*` (spec 15.4), в spec 14 и 15.3 и записью в `docs/decisions.md`. Отчёт с числами — `docs/load.md`, на него ссылается T54. Не в `docs/live/`: прогон идёт на раннере, а не в облаке. +- **Как проверить:** подложенная регрессия ловится — транзакция приёма дельты, растянутая на распознавание, даёт `UNAVAILABLE` от исчерпанного пула в `saturation` и запросы без ответа в `second_tab`. +- **Стоимость:** $0. +- **Разделы:** spec 8.4, 14, 15.3, 15.4; product 9; R23; standalone: T52b. + +#### T47. Ревью кода сервера + +- **Цель:** код сервера прочитан на ошибки и по стандартам CLAUDE.md до того, как на нём начнёт строиться SPA (как T39a standalone). +- **Сделать:** по каждому пакету: + - **`/code-review high <папка пакета>`** — пакет целиком, с тестами; не больше трёх прогонов на пакет. + - **Таблица «правило CLAUDE.md → соблюдено или где нарушено»:** + - правило зависимостей, интерфейсы, простота; + - имена и размер, ошибки, контекст, документация; + - тесты — fuzz, свойства, `t.Helper`, «got …, want …». + - **Находки:** ошибка исправляется вместе с тестом, который без исправления падает; отступление от стандарта — в `docs/decisions.md`. + - **Разбивка** на части по группам пакетов (правило 12): домен; адаптеры; транспорт и каркас. +- **Не входит:** безопасность (T55). Нагрузку проверяют стражи задач (правило 14) и T46a — до этого ревью. +- **Стоимость:** лимиты подписки Claude на прогоны ревью. +- **Разделы:** CLAUDE.md (Architecture and code standards). + +> **Точка проверки фазы 8.** Удаление, очистка, лимиты и наблюдаемость на месте; числа развёртывания сведены нагрузкой; код сервера прочитан. + +--- + +### Фаза 9. SPA + +#### T48 (до разбиения E2). SPA: холст + +- **Цель:** ребёнок пишет на планшете как в тетради: перо в приоритете, ладонь не рисует, запись одинаково ложится на любом экране. +- **Сделать:** + - **Три слоя Konva** — `TaskLayer`, `DrawingLayer`, `HintLayer` (product 5.1); подсказки никогда не закрывают запись. + - **Логическая страница и масштаб (spec 4.1)** — одинаково по обеим осям, поля по краям. + - **Pointer Events (3.1):** + - перо и палец, нажим; + - «режим пера» после первого пера; + - касания больше 30 px — ладонь; + - `touch-action: none`. + - **Штрихи:** + - штрих длиннее 2000 точек закрывается и начинается новый; + - координаты прижимаются к странице; + - ластик удаляет штрих целиком. + - **Зависимости:** типы из `web/src/gen/` (T18); konva и react-konva точных версий, их лицензии — в списке. +- **Тесты:** + - пересчёт координат туда и обратно (spec 17); + - отсечение ладони и режим пера на синтетических событиях; + - разрезание длинного штриха. +- **Как проверить:** `just ci-web`; iPad с Pencil и Android-планшет через туннель `cloudflared`. +- **Разделы:** spec 3.1, 4.1, 4.2; product 5.1, 8; R13, R15. + +#### T49. SPA: синхронизация и подсказки + +- **Цель:** запись ребёнка не теряется ни при обрыве сети, ни при второй вкладке, а маркер и подсказка появляются там, где нужно. +- **Сделать:** + - **Клиент протокола (spec 8.4):** + - пауза 1,5 с и кнопка Check; не больше одного запроса в полёте на холст; + - `seq` и `client_event_id`; неподтверждённые дельты — в памяти и IndexedDB; повтор с тем же `client_event_id`; + - перенумерация после `SEQ_CONFLICT`, снимок после `RESYNC_REQUIRED`; + - восстановление после перезагрузки из `GET /v1/canvases/{id}`; + - виноватый штрих выбрасывается на 400 и 413. + - **centrifuge-js:** + - токены через `getToken`, восстановление истории; + - событие применяется, только если его `revision` не меньше показанной; + - сверка с HTTP-ответом по `hint_id`. + - **Слой подсказок:** + - подсветка и текст вопроса; стиль — по типу действия (R24); снятие по `clear`; + - `offer_help`; + - поздравление и выбор «следующая задача» или «решить заново» (О-16). + - **Соединение** закрывается через 5 минут бездействия (14.4). +- **Тесты:** машина состояний протокола на подделанном сервере — повтор, конфликт, снимок, обрыв; применение событий по `revision`. +- **Разделы:** spec 3.1, 8.4, 8.5, 9.3–9.7, 14.4; product 5.3–5.7; R16, R23. + +#### T50. Тексты для родителей + +- **Цель:** у согласия есть уведомление, а у сервиса — политика конфиденциальности и условия. Тексты честные, простые и совпадают с тем, что сервис делает на самом деле. +- **Сделать:** + - **Страницы SPA на Hosting, на английском** — их адреса дальше не меняются: + - уведомление о данных для согласия — с номером версии, который пишется в `consent_version` (T23). В нём: что собираем, кому передаём, как удалить, сроки (privacy 8, П-6), просьба не писать на холсте ничего личного (privacy 5.3), вход только для взрослых 18+ (П-5); + - политика конфиденциальности — на основе privacy 11, с политикой хранения (privacy 8); + - условия использования; + - форма согласия для подписи (privacy 6). + - **Письменная программа информационной безопасности** (privacy 9) — в `docs/`. + - Никаких обещаний «полной анонимности» (privacy 1). +- **Я:** вычитываю построчно. Это не юридическая консультация: перед публичным запуском тексты смотрит юрист (шапка privacy). +- **Разделы:** privacy 1, 5.3, 6–9, 11, 12 (П-5, П-6, П-8); product 11. + +#### T51. SPA: раздел родителя + +- **Цель:** родитель проходит сценарии С1, С2, С6 и С7 целиком — от входа до удаления. +- **Сделать:** + - **Вход** через Google и Apple (`firebase/auth`); App Check с reCAPTCHA Enterprise и автообновлением токена (`firebase/app-check`). + - **Бета и согласие:** инвайт, уведомление и отметка согласия, ожидание подтверждения оператором (R26). + - **Профили детей:** псевдоним с подсказкой не вводить настоящее имя, аватар из набора, класс; PIN. + - **Детский режим** — только задачи и холст; выход — по PIN (spec 12.2). + - **Прогресс** (T37). + - **Удаление** профиля и аккаунта — с повторным входом (T43). IndexedDB очищается при выходе и удалении. + - **Неразборчиво и сбой** — нейтральные фразы из 5.4 и 9.9. Фраза исчерпанного лимита по паузе — один раз за сеанс (5.4, R35). + - **Интерфейс:** крупные элементы, без таймеров, рекламы и внешних ссылок (product 8). +- **Тесты:** экраны на подделанном API — каждый статус согласия и беты, лимит детей, `REAUTH_REQUIRED`. +- **Разделы:** product 6, 8, 11; spec 8.2, 12.1, 12.2; privacy 6, 7; R07, R26, R29. + +#### T52. SPA: e2e + +- **Цель:** задачи А и Б автоматически проходятся от начала до конца на записанных траекториях пера. +- **Сделать:** + - **Playwright** — из закреплённого образа, а не в devcontainer: как всё, что не Go, в standalone. + - **Синтетические штрихи:** + - запись и воспроизведение траекторий; + - отсечение ладони; + - обрыв сети посреди дельты (product 10); + - вторая вкладка. + - **Сервер** — локальный, с записанными ответами Mathpix и Gemini. + - **Рецепт `ci-e2e`** и задание в CI. +- **Как проверить:** `just ci-e2e` локально и в CI. +- **Разделы:** spec 17; product 10, 12. + +> **Точка проверки фазы 9.** Обе задачи проходятся в браузере на планшете, родитель проходит все сценарии; e2e работает в CI. + +--- + +### Фаза 10. Замеры и приёмка + +#### T54. Спайк S4: задержка и холодный старт + +- **Цель:** подтвердить бюджет spec 14.1 и холодный старт не дольше 3 с — на настоящем prod. +- **Сделать:** + - сценарии 9.3 и 9.4 на тёплом и холодном пути (Cloud Run, Neon после сна); + - замеры: время до маркера и до подсказки по этапам 14.1, холодный старт двух контейнеров; + - числа сверяются с `docs/load.md` (T46a), где то же снято на ограниченном контейнере; + - даёт ли Cloud Run процессор запросу, клиент которого ушёл, — решение по Т-12 на настоящем сервисе; + - отчёт `docs/live/08-spike-latency.md`; если не укладываемся — варианты из 14.2 с ценой. +- **Как проверить:** критерий S4 — маркер ≤ 1 с и подсказка ≤ 3 с (p95), холодный старт ≤ 3 с. +- **Стоимость:** распознавания и подсказки прогона — центы. +- **Разделы:** spec 14.1, 14.2, 20; product 10. + +#### T55. Ревью безопасности + +- **Цель:** до беты код входа, прав, данных и внешних вызовов прочитан глазами безопасности. +- **Сделать:** + - `/security-review` по коду входа, прав, протокола, удаления и адаптеров; + - сверка с мерами spec 13 и privacy 9; + - находки исправить или записать с обоснованием; отчёт. +- **Разделы:** spec 12, 13; privacy 9, 10. + +#### T56. Приёмка MVP + +- **Цель:** все критерии приёмки product 12 выполнены на настоящих устройствах. +- **Сделать:** прогон по критериям 1–7: + - обе задачи — на iPad (Safari, Apple Pencil), Android-планшете (Chrome) и десктопе; + - eval-наборы; + - поведение без Mathpix и Gemini; + - удаление во всех хранилищах; + - лимиты и бюджетные оповещения; + - логи и метрики без ПДн. + + Отчёт — `docs/live/09-acceptance.md`. +- **Я:** прохожу сценарии на устройствах. +- **Разделы:** product 10, 12. + +#### T57. Публичные документы + +- **Цель:** репозиторий понятен со стороны: что это, как устроено и как поднять свою копию. +- **Сделать:** + - `README.md` — что это и навигация; плашки живые, добавлена плашка релиза; + - `docs/self-hosting.md` — полностью; + - `SECURITY.md`, `CONTRIBUTING.md`, `docs/openssf-best-practices.md` и `THIRD_PARTY_LICENSES` — актуальны. +- **Как проверить:** я прохожу `docs/self-hosting.md` по шагам или вычитываю его. +- **Разделы:** R25. + +#### T58. Запуск беты + +- **Цель:** первые семьи входят в бету после того, как выполнены юридические условия. +- **Сделать:** + - **Я:** + - договоры с обработчиками: Google Cloud DPA, Mathpix, Neon, Upstash; исключение из журналирования злоупотреблений у Gemini (П-3); + - юрист смотрит тексты T50 (П-7, П-8); + - DPIA — до семей из ЕС и Великобритании (П-2). + - **Claude** — `docs/operations.md`: + - как выдавать инвайты, принимать подписанные формы и подтверждать согласие через `cmd/admin`; + - как отвечать на запрос о данных и на удаление; + - что делать при инциденте (privacy 9); + - чек-лист запуска. + - Первые инвайты. +- **Разделы:** product 3, 6, 15; privacy 2, 6, 7, 9, 12. + +> **Точка проверки фазы 10.** Критерии приёмки выполнены, юридические условия беты закрыты, первые семьи получили инвайты. diff --git a/docs/stack.html b/docs/stack.html index 41e1164..322c69c 100644 --- a/docs/stack.html +++ b/docs/stack.html @@ -412,7 +412,7 @@

После MVP: платформа MathTrai