textmachine/platform/docs/platform-PROGRESS.md

294 lines
34 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 ПРИНЯТ и ЗАЛЕНДЕН** (`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 757 до закрытия КЛИЕНТОМ);
скептик независимо пинил 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.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` по доке ошибку
не возвращает вовсе (падает), поэтому ветки её обработки убраны, а не оставлены изображать проверку.
Дерево не коммичено — лендит оркестратор.