# Журнал зоны «Платформа» > Весь прогресс платформы — ЗДЕСЬ (решение владельца 04.08): пинги, итоги сессий, открытые > вопросы, предложения на ратификацию. В `docs/PROGRESS.md` платформа не пишет; оркестратор > читает этот журнал при каждом лендинге зоны (свип «решений владельца» — норма D39.99 п.4). ## Текущее состояние - **P0 ПРИНЯТ и ЗАЛЕНДЕН** (`eeeef89`, приёмка оркестратора №14 04.08 — раздел «Ратификация приёмкой» ниже). Дизайн-ответы К-4/К-7/К-12/П-5 ратифицированы С ПОПРАВКАМИ. Найдено 19 дефектов, все строками в `DEFECT_REGISTER.md`; один — ЖИВАЯ уязвимость (PD-2, пиннинг соединений), гейт P1. - **Стандарты зоны заведены** (решение владельца 04.08): `ENGINEERING_STANDARDS.md` (критерии приёмки, индустриальные базовые линии) + `DEFECT_REGISTER.md` (отдельная колонка багов и уязвимостей). - **P0 собран** (сессия 04.08): модуль компилируется, батарея зоны `make check` зелёная, `/healthz` и `/readyz` проверены живым запуском против живого Postgres 18.4. - Стек запинен и live-сверен — `STACK_DECISIONS.md` (зонный). - Дизайн-ответы К-4 · К-7 · К-12 · форма П-5 — ниже, ПРЕДЛОЖЕНИЯМИ на ратификацию. - Контрактных ручек нет намеренно: они ждут ратификации К-4/К-7 (форма ответов) — это П-1. ## Открытые вопросы к владельцу/оркестратору **Решения владельца 05.08 (закрыли всё, что висело по деньгам):** оплаты нет и в бете не будет — пробные аккаунты на фри-тире, ключи предоплачены владельцем · модель лимита = БАЛАНС кредитов, а не окна с обнулением («не подписки, а покупка токенов как у OpenRouter») ⇒ `resets_at`/`usage_windows` в подписочной форме отменены, вопрос авто-резюме после сброса окон отпал вместе с окнами · фри-тир = грант в леджер из админки, дефолт $5, настраиваемый. ⚠ Мой вывод «покупателям из России платить нечем» СНЯТ как необоснованный: он был выведен из целевого языка перевода, а не установлен. Резервация решена оркестратором (вариант B — холд + пер-книжный потолок движку, жёсткий стоп исполняет движок). Разбор — `PLATFORM_DIRECTION.md` §2. 2. **Оркестратору (контракт).** Поток событий привязан к ПРОГОНУ (`/runs/{runId}/events`), а статусы `uploading`/`parsing` существуют ДО прогона, и библиотека охватывает книги без прогонов. Живого канала у них нет вовсе. Нужна либо строка в спеке «до старта прогона состояние опрашивается», либо пользовательский поток (он же закрыл бы К-12 пушем). Решение — не наше. 3. **Оркестратору (спека).** Третий слой CSRF из STACK §5 требует от браузерного клиента заголовок `X-TM-Client` на небезопасных запросах cookie-пути. Это требование к ФРОНТУ, и его место — в описании `sessionCookie` в спеке. Реализовано и проверено тестами. ## Ратификация приёмкой (оркестратор №14, 04.08) **Вердикт: P0 ПРИНЯТ, заленден `eeeef89`.** Метод: батарея пере-прогнана мной (офлайн зелёная; с живым PostgreSQL 18.4, поднятым без root, все три БД-гейченных теста зелёные — 6/6 констрейнтов сработали поимённо) · живые пробы бинаря (healthz 200 без БД · readyz 503 честно · Bearer без стора → 401, не паника · неизвестный путь под `/v0` → 401 раньше 404 · CSRF: cookie-POST без `X-TM-Client` 403, cross-site 403, Bearer-POST не требует заголовка · SIGTERM → «shutting down» и чистый выход) · **три СВОИ мутации** в несущие свойства (см. ниже) · адверсариальный воркфлоу пяти линз со скептик-пассом (линзы анти-следовые: мнение до чтения отчёта, поиск вне его карты). **Что подтверждено исполнением:** зонная дисциплина (в коммите только `platform/*`, чужого нет) · модуль-sibling, `backend/internal` не импортируется, `go.work` не заведён · живой SQLite движка нигде не открывается · event-sourcing не построен (high-water mark) · деньги отсутствуют на проводе и в INFO-логах (stderr движка уходит в ФАЙЛ попытки — верно) · ПТ-34-заголовки мидлварью на всём, не дисциплиной хендлера · имена полей `status --json` сверены 1:1 с `pipeline/status.go:37-130` · экзит-коды сверены с `cmd/tmctl/main.go:30-52` · языко-агностичность (пар-литералов нет) · пин линтера идентичен движковому. **Мои мутации (author≠reviewer):** (1) `Digest` возвращает плейнтекст вместо SHA-256 — **тесты ВЫЖИЛИ** ⇒ свойство «в БД только хеш» истинно, но НЕ запинено (тест сверяет через ту же функцию); строка PD-1. (2) Снятие требования `X-TM-Client` — тест упал поимённо ✓. (3) Ослабление констрейнта `units_translated_has_text` до `check (true)` — тест упал поимённо ✓. Дерево после мутаций восстановлено байт-в-байт (sha256-сверка). **Главная находка приёмки — PD-2, ЖИВАЯ уязвимость, а не латентная.** Отсутствие `ReadTimeout` позволяет пиннить соединения СЕГОДНЯ, без единой body-принимающей ручки: `net/http` дренирует непрочитанное тело <256 КБ внутри `chunkWriter.writeHeader` ДО отправки заголовка ответа, и это чтение наследует отсутствующий дедлайн. Репродуцировано мной на собранном бинаре (50 полу-кормленных POST: сервер залогировал 50×401 `ms:0`, клиенты получили ноль байт, fd 7→57 до закрытия КЛИЕНТОМ); скептик независимо пинил 500. Фикс — одна строка; **гейт: закрыть первым шагом P1**. **Ратификация дизайн-ответов — все четыре ПРИНЯТЫ, каждый с обязательной поправкой:** - **К-4 (ревизия пер-книжная, кадр SSE = `books.revision`) — ПРИНЯТ + три поправки.** (а) Посылка «единственный писатель» неверна: HTTP-хендлеры тоже пишут книго-скоупное состояние (подпись банка обязана вернуть бампнутую ревизию). Нормативный механизм — не «единственность писателя», а **блокировка строки книги, удерживаемая до коммита** (`update books set revision = revision + 1` сериализует и бамп, и порядок коммитов); материализатор и хендлеры обязаны ходить через неё. (б) Одна транзакция = одна ревизия, но НЕСКОЛЬКО кадров: докачка по `Last-Event-ID` обязана читать `revision >= R` (дельта-чтения идемпотентны), иначе теряются кадры-братья транзакции R. (в) Дельта- чтение не выражает УДАЛЕНИЯ (банк переписывается целиком, пере-чанковка заменяет главы/юниты) ⇒ после любой транзакции-замены сервер обязан выдать `resync_required` (событие в контракте есть). - **К-7 (keyset-курсор на всех списках, `next_cursor` всегда) — ПРИНЯТ + две поправки.** (а) Курсор НЕ привязывать к `books.revision`: тот бампается на каждой материализации, и на книге 5000+ глав правило «сменилась ревизия — начни цикл заново» даёт вечный рестарт пагинации во время прогона. Привязка — к СТРУКТУРНОЙ эпохе (поколение манифеста / `books.chunker_version`), которая меняется только при (пере-)разборе. (б) Отклонение протухшего курсора — обязанность СЕРВЕРА (MUST), не клиента: курсор непрозрачен, клиент не может её исполнить. Ключи сортировки для банка и замечаний назвать при правке спеки (для глав — `(book_id, number)`). - **К-12 (опрос с `Retry-After`, 202+`Location`) — ПРИНЯТ.** Аргумент структурный и верен: поток привязан к прогону, экспорт делают с законченной книги. Поправка формы: `Retry-After` на 200 стандартом не определён (RFC 9110 — 503 и 3xx), поэтому в спеке объявить его ЯВНЫМ заголовком этого ответа, а не полагаться на общую семантику. - **П-5 (`GET /v0/usage` статусом, `Run.paused_reason`, потолок поднимает платформа) — ПРИНЯТ + три поправки.** Несущий клейм ПЕРЕПРОВЕРЕН мной по коду и подтверждён: `Ceilings` объявлены (`backend/internal/config/book.go:106`), в канон `BriefHash` НЕ входят (`:280-297`, доккоммент `:264` «wiring fields … deliberately excluded»), и ни один другой хеш их не сворачивает (снапшот волны `pipeline/snapshot.go`, кортеж чекпойнта `stagerun.go:406-412`) ⇒ поднятие потолка не двигает снапшот и не вызывает ре-билл. Поправки: (а) `Run.paused_reason` не имеет колонки — завести в схеме (собственный мандат сессии: у каждого поля ответа есть источник); (б) `revision` в ответе usage не имеет скоупа — назвать счётчик пользователя либо убрать поле; (в) формулировка «поток денег не несёт и НЕ ДОЛЖЕН» подана как следствие D39.84 — это не так: D39.84 запрещает суммы на ПОЛЬЗОВАТЕЛЬСКОМ проводе, экране и в INFO-логах, а внутренний поток движок→платформа в приватную таблицу — другая поверхность. Вопрос «нести ли версионированное денежное событие в словаре строки 103» ОТКРЫТ (см. ниже), а не закрыт. - **Три находки сессии подтверждены и маршрутизированы:** движковый `run_id` в кадре `hello` (иначе resume отбрасывается high-water mark'ом) — в строку 103 единого бэклога; статусы до прогона и библиотека без живого канала — правка спеки (S3); `X-TM-Client` в спеку — туда же, с уточнением «значение любое, несущей является ПРИСУТСТВИЕ заголовка». **Открытый канон-вопрос, поднятый приёмкой (нужно слово владельца/решение оркестратора): у платформы нет санкционированного источника денег ВО ВРЕМЯ попытки.** Движок держит эксклюзивный лок (`status --json` физически недоступен), его stderr-INFO с ценами парсить запрещено (анти-паттерн research/23 §2 + запрет INFO-денег), а словарь строки 103 денег не несёт. Следствие: пер-пользовательские окна отстают на целую попытку (часы), и единственный он-лайн-гард — пер-книжные потолки, которые платформа обязана ставить консервативно. Варианты — версионированное денежное событие в словаре 103 · консервативная политика потолков · нарезка попыток — в ресёрч-пакете направления «биллинг». ## Дизайн-ответы на ратификацию ### К-4 — ревизия: пер-ресурсная со скоупом КНИГА; чтения её несут Обе половины вопроса: 1. **Чтения ревизию несут — да.** Без неё правило «отбрось чтение старше уже применённого события» нечем реализовать, а рефетч по возврату фокуса окна включён у `react-query` по умолчанию — то есть гонка на каждое переключение вкладки, а не редкий случай. 2. **Счётчик — один на КНИГУ.** Все книго-скоупные чтения (карточка, главы, юниты, замечания, банк, прогон) возвращают ОДНО и то же число — `books.revision`, +1 за материализующую транзакцию; строки, которых она коснулась, штампуются новым значением. `id` SSE-кадра прогона — ТО ЖЕ число, поэтому события и чтения книги полностью упорядочены между собой. Почему книга, а не сквозной счётчик: - сравнивать ревизии осмысленно только внутри скоупа, а книга — минимальный скоуп, в котором лежит всё, что поток может протухнуть; - единственный писатель на книгу уже гарантирован (сериализация очереди по `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` (страница лимитов в настройках): ```json {"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). ## Находки (грунтованные) 1. **Ключ идемпотентности `(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. **Дыра контракта — статусы до прогона и библиотека без канала.** См. вопрос 2 выше. 3. **Банк: канала нет — но не полностью.** Подтверждаем находку оркестратора и уточняем состав: сегодня добываемы (а) ПРЕДЛОЖЕННЫЕ термины стопа — сайдкар/строка 101 и (б) термины, которые промотировала сама платформа — она же ПИШЕТ mined-delta и сид. Недобываемы `auto`/`ruby`-строки, материализованные внутри движка. То есть `GET /bank` частично реализуем уже сейчас; полностью — после артефакта экспорта банка. 4. **Ре-синк не восстанавливает пофазный прогресс:** в `status --json` разбивки нет (строка 99). После обрыва и до следующего события прогресса клиент увидит агрегат. Записано в коде. 5. **`status --json` — ремонтный путь, не поллинг:** каждый вызов заново ингестит и режет исходник (1.4–1.5 с CPU на книге 23 МБ — замер фронт-сессии 02.08, не наш; строка 100). 6. **Деньги движка живут в его stderr на уровне INFO.** Поэтому супервизор пишет stderr движка в ФАЙЛ попытки и не тейлит его в структурный лог платформы — иначе суммы попадут в наш INFO (запрет D39.84 + норма P0-промта). 7. **Мелочи в чужой зоне (не трогали, лендить оркестратору):** в ратифицированной копии `docs/architecture/14-api-contract/openapi.yaml` `info.description` всё ещё называет файл черновиком S3 и ссылается на `../API_CONTRACT_DRAFT.md`; в README той же папки ссылка «нормативная поверхность → `api-contract/openapi.yaml`» бьёт мимо (файл лежит рядом: `./openapi.yaml`). Решения D39.100 (`paused`, `eta_seconds`) в YAML ещё не внесены — это работа S3; схема платформы их уже держит. 8. **Стенд:** 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` по доке ошибку не возвращает вовсе (падает), поэтому ветки её обработки убраны, а не оставлены изображать проверку. Дерево не коммичено — лендит оркестратор.