205 lines
22 KiB
Markdown
205 lines
22 KiB
Markdown
# Журнал зоны «Платформа»
|
||
|
||
> Весь прогресс платформы — ЗДЕСЬ (решение владельца 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.
|
||
|
||
## Открытые вопросы к владельцу/оркестратору
|
||
|
||
1. **⚠ ВЛАДЕЛЬЦУ (П-5).** При сбросе окна лимитов приостановленный (`paused`) перевод
|
||
продолжается САМ или ждёт явного «Продолжить»? От ответа зависит, есть ли кнопка на экране и
|
||
нужно ли уведомление «продолжили без вас». Технически дёшевы оба варианта.
|
||
2. **Оркестратору (контракт).** Поток событий привязан к ПРОГОНУ (`/runs/{runId}/events`), а
|
||
статусы `uploading`/`parsing` существуют ДО прогона, и библиотека охватывает книги без прогонов.
|
||
Живого канала у них нет вовсе. Нужна либо строка в спеке «до старта прогона состояние
|
||
опрашивается», либо пользовательский поток (он же закрыл бы К-12 пушем). Решение — не наше.
|
||
3. **Оркестратору (спека).** Третий слой CSRF из STACK §5 требует от браузерного клиента
|
||
заголовок `X-TM-Client` на небезопасных запросах cookie-пути. Это требование к ФРОНТУ, и его
|
||
место — в описании `sessionCookie` в спеке. Реализовано и проверено тестами.
|
||
|
||
## Дизайн-ответы на ратификацию
|
||
|
||
### К-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` по доке ошибку
|
||
не возвращает вовсе (падает), поэтому ветки её обработки убраны, а не оставлены изображать проверку.
|
||
|
||
Дерево не коммичено — лендит оркестратор.
|