textmachine/platform/docs/platform-PROGRESS.md

205 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Журнал зоны «Платформа»
> Весь прогресс платформы — ЗДЕСЬ (решение владельца 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.41.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` по доке ошибку
не возвращает вовсе (падает), поэтому ветки её обработки убраны, а не оставлены изображать проверку.
Дерево не коммичено — лендит оркестратор.