textmachine/platform/docs/platform-PROGRESS.md

34 KiB
Raw Blame History

Журнал зоны «Платформа»

Весь прогресс платформы — ЗДЕСЬ (решение владельца 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 (разбор — PLATFORM_DIRECTION.md): 0а. Платить из России нечем. Paddle своей политикой блокирует покупателей из РФ и Беларуси, compliant-пути у западных PSP нет. Для продукта с русским целевым языком это вопрос рынка и способа оплаты, а не выбора вендора — решать ДО стройки П-7. 0б. Источник денег во время попытки (движок держит лок, stderr-INFO парсить запрещено, поток цифр не несёт): (A) версионированное денежное событие в словаре строки 103 — трогает движок и отменяет действующую доктрину · (B) консервативные потолки + расчёт на границе попытки — ноль правок движка · (C) нарезка попыток. Рекомендация: (B) сейчас; строить П-7 можно, не дожидаясь. 0в. Размер и форма фри-тира (грант в тот же леджер — механика выбрана; число — за владельцем).

  1. ⚠ ВЛАДЕЛЬЦУ (П-5). При сбросе окна лимитов приостановленный (paused) перевод продолжается САМ или ждёт явного «Продолжить»? От ответа зависит, есть ли кнопка на экране и нужно ли уведомление «продолжили без вас». Технически дёшевы оба варианта.
  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}/exports202 + 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).

Находки (грунтованные)

  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 по доке ошибку не возвращает вовсе (падает), поэтому ветки её обработки убраны, а не оставлены изображать проверку.

Дерево не коммичено — лендит оркестратор.