22 KiB
Журнал зоны «Платформа»
Весь прогресс платформы — ЗДЕСЬ (решение владельца 04.08): пинги, итоги сессий, открытые вопросы, предложения на ратификацию. В
docs/PROGRESS.mdплатформа не пишет; оркестратор читает этот журнал при каждом лендинге зоны (свип «решений владельца» — норма D39.99 п.4).
Текущее состояние
- P0 собран (сессия 04.08): модуль компилируется, батарея зоны
make checkзелёная,/healthzи/readyzпроверены живым запуском против живого Postgres 18.4. - Стек запинен и live-сверен —
STACK_DECISIONS.md(зонный). - Дизайн-ответы К-4 · К-7 · К-12 · форма П-5 — ниже, ПРЕДЛОЖЕНИЯМИ на ратификацию.
- Контрактных ручек нет намеренно: они ждут ратификации К-4/К-7 (форма ответов) — это П-1.
Открытые вопросы к владельцу/оркестратору
- ⚠ ВЛАДЕЛЬЦУ (П-5). При сбросе окна лимитов приостановленный (
paused) перевод продолжается САМ или ждёт явного «Продолжить»? От ответа зависит, есть ли кнопка на экране и нужно ли уведомление «продолжили без вас». Технически дёшевы оба варианта. - Оркестратору (контракт). Поток событий привязан к ПРОГОНУ (
/runs/{runId}/events), а статусыuploading/parsingсуществуют ДО прогона, и библиотека охватывает книги без прогонов. Живого канала у них нет вовсе. Нужна либо строка в спеке «до старта прогона состояние опрашивается», либо пользовательский поток (он же закрыл бы К-12 пушем). Решение — не наше. - Оркестратору (спека). Третий слой CSRF из STACK §5 требует от браузерного клиента
заголовок
X-TM-Clientна небезопасных запросах cookie-пути. Это требование к ФРОНТУ, и его место — в описанииsessionCookieв спеке. Реализовано и проверено тестами.
Дизайн-ответы на ратификацию
К-4 — ревизия: пер-ресурсная со скоупом КНИГА; чтения её несут
Обе половины вопроса:
- Чтения ревизию несут — да. Без неё правило «отбрось чтение старше уже применённого
события» нечем реализовать, а рефетч по возврату фокуса окна включён у
react-queryпо умолчанию — то есть гонка на каждое переключение вкладки, а не редкий случай. - Счётчик — один на КНИГУ. Все книго-скоупные чтения (карточка, главы, юниты, замечания,
банк, прогон) возвращают ОДНО и то же число —
books.revision, +1 за материализующую транзакцию; строки, которых она коснулась, штампуются новым значением.idSSE-кадра прогона — ТО ЖЕ число, поэтому события и чтения книги полностью упорядочены между собой.
Почему книга, а не сквозной счётчик:
- сравнивать ревизии осмысленно только внутри скоупа, а книга — минимальный скоуп, в котором лежит всё, что поток может протухнуть;
- единственный писатель на книгу уже гарантирован (сериализация очереди по
book_id— П-3, плюс EXCLUSIVE-лок движка на файл проекта), поэтому счётчику не нужны ни блокировка, ни глобальная последовательность; - глобальный счётчик отвергнут по двум причинам: одна горячая последовательность на всех пользователей и утечка — по разрывам номеров любой клиент оценивает активность всей платформы;
- у библиотеки (
GET /books) свой скоуп — счётчик на пользователя (users.library_revision), потому что она охватывает книги.
Просим внести в спеку прозой: (i) revision монотонна В ПРЕДЕЛАХ скоупа ресурса и между скоупами
не сравнивается; (ii) кадр потока и книго-скоупные чтения несут ОДИН счётчик; (iii) отбрасывание
устаревшего чтения — обязанность клиента.
Побочная выгода: тот же штамп даёт докачку потока «строки книги с revision > X» БЕЗ журнала
событий — реплей истории остаётся запрещённым (D39.85, контракт §2.11).
К-7 — курсор на каждом списке, дефолт «одна страница»
Замер (сериализация фикстур контрактной формы, случайные значения — не повторяющиеся, иначе gzip льстит): 2284 главы = 289 КБ JSON / 46 КБ gzip; 1200 терминов = 229 КБ / 40 КБ. Одним ответом влезает — но китайские вебновеллы на 5000+ глав норма, а банк растёт вместе с книгой, поэтому «всегда одним ответом» — это отложенное молчаливое обрезание.
Предложение: keyset-курсор на КАЖДОМ списочном ответе, параметры ?limit=&cursor=, поле
next_cursor: string|null присутствует ВСЕГДА. Дефолты: главы 5000 (обычная книга = одна
страница), банк 1000, замечания 500; юниты и библиотека курсор тоже несут, хотя практически не
пагинируются.
- Keyset, не offset: материализатор пишет параллельно чтению, а offset на пишущейся таблице
пропускает и дублирует строки; keyset по
(book_id, number)устойчив к дозаписи. - Поле с первого дня у всех списков — намеренно. Добавить его позже — минорное изменение, которое у клиента, его не читающего, молча отрезает хвост.
- Курсор непрозрачный, кодирует последний ключ сортировки и
revision; сменаrevisionмежду страницами обязывает клиента начать цикл заново, иначе он склеит два состояния.
К-12 — опрос; причина структурная, а не вкусовая
Единственный поток контракта привязан к ПРОГОНУ, а экспорт делают с законченной книги — живого прогона обычно нет. Пуш завершения потребовал бы второго потока ради одного булева.
Предложение — индустриальный async request-reply: POST /books/{id}/exports → 202 +
Location; GET /books/{id}/exports/{id} → 200 c ready:false и заголовком Retry-After, пока
строится, и ready:true + url, когда готов. Интервал называет СЕРВЕР, клиент не угадывает.
Просим добавить Retry-After в спеку. Появится пользовательский поток (вопрос 2 выше) — пуш
поедет им, опрос останется фолбэком.
П-5 — форма API лимитов/использования
GET /v0/usage (страница лимитов в настройках):
{"revision": 42, "state": "ok|approaching|exhausted", "used_percent": 37,
"resets_at": "2026-08-11T00:00:00Z",
"windows": [{"period": "day", "used_percent": 12, "resets_at": "…"},
{"period": "week", "used_percent": 37, "resets_at": "…"}]}
- Сумм нет ни в каком виде. Процент и время сброса — статус использования, а не деньги (D39.84 в силе, механика «как Claude Code» — D39.100/ПТ-35).
- Стоп по потолку:
BookStatus: paused+ машинная причина. ПредлагаемRun.paused_reason: "limits_exhausted" | null: фразу («перевод остановлен: лимиты исчерпаны») рисует клиент словами владельца (В-3), API несёт состояние. Без поля причины второй повод для паузы станет ломающим изменением. - Источник цифр. Поток событий денег не несёт и не должен (кадр
ceiling— только факт), поэтому платформа метрит изtmctl status --json(committed_usd) на границах попыток и на ре-синке; хранит целыми микро-долларами вusage_windows. - Поднятие потолка — политика платформы, не кнопка на экране. Платформа сама владеет
book.yaml, поднимаетceilings.book_usdи перезапускает прогон. Проверено кодом, что это безопасно:Ceilingsобъявлен вbackend/internal/config/book.go:106, а в канонBriefHash(:280-297) НЕ входит — значит поднятие потолка не двигаетbrief_hash→ снапшот и не вызывает ни дрифт, ни ре-билл. Риск «подняли лимит — переплатили книгу заново» снят фактом, не надеждой.
Что построено (P0)
| Кусок | Где | Проверено |
|---|---|---|
| Модуль, layout, батарея | go.mod (sibling движка, гард D39.85 соблюдён), Makefile, .golangci.yml |
make check зелёный: build · vet · gofmt · lint 0 issues · go test -race |
| HTTP-скелет | internal/httpapi/ |
Живой запуск: /healthz 200, /readyz 200 против живого PG, /v0/* 401 problem+json, graceful shutdown по SIGTERM |
| Заголовки ПТ-34 | internal/httpapi/middleware.go |
Живой ответ несёт X-Robots-Tag: noindex, nofollow, Cache-Control: no-store, nosniff, no-referrer |
| Сессии П-1 | internal/auth/ + internal/pgstore/sessions.go |
Тесты: обе презентации → principal, Bearer > cookie, истечение/отзыв/свип, токен в БД не попадает (только SHA-256), скольжение окна только во второй половине |
| CSRF | internal/auth/csrf.go |
stdlib http.CrossOriginProtection + обязательный X-TM-Client на cookie-пути; 6 кейсов тестом + живой пробой (cookie-POST без заголовка → 403, cross-site → 403) |
| Схема read-model | internal/pgstore/migrations/ |
Миграции применены на ЖИВОМ PostgreSQL 18.4 дважды (идемпотентность), все констрейнты сработали поимённо |
| Интерфейс NDJSON-ингеста | internal/ingest/ |
Тесты: хендшейк обязателен, мажор отвергается, минор и незнакомый тип толерируются, разрыв/повтор seq ловятся; супервизор проверен НАСТОЯЩИМ процессом (exit 3 → bank_stop, stderr движка в файл, поток материализован) |
| Ре-синк | internal/ingest/resync.go |
Тест на фикстуре в форме pipeline.StatusReport (имена полей сверены по backend/internal/pipeline/status.go:37-130, живого прогона не было): аллоулист берёт своё, деньги/снапшоты игнорируются |
Не построено намеренно: материализатор Sink → Postgres (нужен ратифицированный словарь событий,
иначе перепишется), контрактные ручки и SSE (П-1 после ратификации К-4/К-7), очередь River (П-3),
брокер лимитов (П-2).
Находки (грунтованные)
- Ключ идемпотентности
(run_id, seq)работает только еслиrun_id— ДВИЖКОВЫЙ. Resume поднимает новый процесс, егоseqстартует с 1; если ключом взять платформенный run, high-water mark отбросит весь поток второй попытки. Заведено в схеме:run_attempts.engine_run_id(unique)last_seq. Просьба к строке 103: кадрhelloобязан нести этот id; идеально — вместе со строкой 102 (внешний trace-контекст), тогда id назначает платформа и пространство ключей наше.
- Дыра контракта — статусы до прогона и библиотека без канала. См. вопрос 2 выше.
- Банк: канала нет — но не полностью. Подтверждаем находку оркестратора и уточняем состав:
сегодня добываемы (а) ПРЕДЛОЖЕННЫЕ термины стопа — сайдкар/строка 101 и (б) термины, которые
промотировала сама платформа — она же ПИШЕТ mined-delta и сид. Недобываемы
auto/ruby-строки, материализованные внутри движка. То естьGET /bankчастично реализуем уже сейчас; полностью — после артефакта экспорта банка. - Ре-синк не восстанавливает пофазный прогресс: в
status --jsonразбивки нет (строка 99). После обрыва и до следующего события прогресса клиент увидит агрегат. Записано в коде. status --json— ремонтный путь, не поллинг: каждый вызов заново ингестит и режет исходник (1.4–1.5 с CPU на книге 23 МБ — замер фронт-сессии 02.08, не наш; строка 100).- Деньги движка живут в его stderr на уровне INFO. Поэтому супервизор пишет stderr движка в ФАЙЛ попытки и не тейлит его в структурный лог платформы — иначе суммы попадут в наш INFO (запрет D39.84 + норма P0-промта).
- Мелочи в чужой зоне (не трогали, лендить оркестратору): в ратифицированной копии
docs/architecture/14-api-contract/openapi.yamlinfo.descriptionвсё ещё называет файл черновиком S3 и ссылается на../API_CONTRACT_DRAFT.md; в README той же папки ссылка «нормативная поверхность →api-contract/openapi.yaml» бьёт мимо (файл лежит рядом:./openapi.yaml). Решения D39.100 (paused,eta_seconds) в YAML ещё не внесены — это работа S3; схема платформы их уже держит. - Стенд: Postgres как системного пакета нет и sudo нет, поэтому схема проверена на живом
PostgreSQL 18.4, поднятом БЕЗ root из бинарников zonky в скрэтчпаде (вне репозитория и вне
зависимостей модуля). Тесты с БД гейтятся
TM_PLATFORM_TEST_DSNи создают свою базу на прогон.
Диспозиции бэклога зоны
| ID | Диспозиция |
|---|---|
| П-1 | НАЧАТА. Готово: каркас сессий (схема + мидлварь + CSRF), HTTP-скелет, схема read-model, интерфейс ингеста и ре-синка. Осталось: контрактные ручки, SSE-эндпоинт, материализатор Sink → Postgres, воркер. Блокеры: ратификация К-4/К-7 (форма ответов), словарь событий (строка 103) |
| П-2 | Не трогали — гейт «до второго параллельного пользователя» в силе |
| П-3 | Не строили. В схеме заведён гард: частичный уникальный индекс «один живой прогон на книгу» (runs_one_live_per_book) — то, что очередь обязана соблюдать, теперь отказывает база. River запинен, но в go.mod НЕ добавлен |
| П-4 | Схема usage_windows заведена драфтом; источник метрик назван (дельты committed_usd из status --json). Гейт бюджета ДО старта — вместе с очередью |
| П-5 | Форма предложена выше. Ждёт ответа владельца по авто-продолжению (вопрос 1) |
Хроника
(записи сессий — сверху новые)
04.08.2026 — сессия P0 (платформа №1)
Прочитано: CLAUDE.md, research/23, контракт 14-api-contract (README + openapi.yaml целиком),
platform/BACKLOG.md, frontend/docs/STACK_DECISIONS.md §5, D39.81/84/85/99/100 по grep.
Сделано: стек live-сверен (три библиотечных пина §5 — pgx · goose · River — на 04.08 всё ещё последние; по Go последний патч 1.26.5 от 07.07, floor модуля оставлен общим с движком) → модуль наполнен → скелет HTTP + сессии + CSRF → схема read-model тремя миграциями → интерфейс ингеста/супервизии/ ре-синка → батарея зоны → дизайн-ответы (выше).
Ревью исполнением: make check зелёный; сервер поднят живьём против живого PostgreSQL 18.4 и
опрошен curl'ом (healthz/readyz/401/CSRF-403); миграции применены дважды; констрейнты проверены
поимённо через pgconn.PgError.ConstraintName; супервизор проверен настоящим процессом с
контрактными кодами возврата.
Адверсариальная самопроверка (author≠reviewer) дала четыре правки, каждая внесена:
(а) вложенный mux под StripPrefix терял Request.Pattern, из-за чего лог писался бы по сырому
пути с id книг — проверено экспериментом, переделано на один mux; (б) отклонённые запросы (401/403)
вообще не логировались, потому что лог висел на маршрутах, а гард стоял снаружи — лог поднят
наружу, добавлен тест «денайл тоже виден»; (в) дефект, найденный запуском бинарника без БД:
предъявленный Bearer уходил в nil-хранилище сессий и падал паникой в 500 — теперь отсутствие
хранилища это отказ 401, как и любой другой промах (регрессионный тест на месте); (г) пин тулчейна
поднят до 1.26.5 — в нём security-фиксы crypto/tls и os, а этот модуль сетевой (в go.mod
floor остался 1.26.4, общий с движком). Плюс снят мёртвый код: crypto/rand.Read по доке ошибку
не возвращает вовсе (падает), поэтому ветки её обработки убраны, а не оставлены изображать проверку.
Дерево не коммичено — лендит оркестратор.