textmachine/docs/research/25-seam-cold-review.md

15 KiB
Raw Permalink Blame History

25 · Шов движок↔платформа: ратифицированная форма и почему не иначе

ЭРРАТА D39.156 (23.08): «поднятие потолка ФАЙЛОМ через atomic rename» (пункт ниже, дословно потолка — файлом через atomic rename на границах юнитов) НИКОГДА НЕ РАТИФИЦИРОВАЛОСЬ. Отчёт относит этот механизм к носителям решения D39.106 — в теле D39.106 его НЕТ вовсе (проверено грепом по слайсу живого тела ноты D39.106 («ШОВ: прогон переживает деплой платформы») в ../architecture/05-decisions-log.md — адресуем НОМЕРОМ, а не строкой: line-якорь в журнал решений не переживает вставку эрраты, и он уже съезжал дважды (гейт якорей, 28.08); первый хит темы — только в D39.110. ⚠ Испр. 23.08: прежняя редакция ссылалась на слайс archive/architecture/05-decisions-D39-106-123.md, где тела D39.106 НЕТ — греп по нему подтвердил бы эррату вакуумно). Строка была КАНДИДАТОМ панели, а живой канон решил ось иначе: потолок едет со стартом прогона и после не меняется, лекарство при упоре — НОВЫЙ прогон (platform/internal/runs/reconcile.go (греп 202 в обработчике возобновления; номер снят 30.08 — файл правит живая сессия зоны)). ⚠ Испр. 23.08: прежняя редакция звала «строку 69», но вставка самого баннера сдвинула нумерацию — 69-я строка теперь несёт ЖИВОЙ денежный механизм («холд не освобождается по выходу юнита»), и исполнение прежней инструкции пометило бы отвергнутым НЕ ТО. Номера строк здесь больше не называем, только подстроку. Читать НАЗВАННЫЙ ВЫШЕ пункт как отвергнутого кандидата, а не как обязательный механизм; закон входной двери шва (../architecture/17-seam-inbound-law.md п.2) это фиксирует: посреди прогона внутрь идёт ТОЛЬКО сигнал.

Что это. Независимая верификация шва и топологии: задача в переодетом домене отдана четырём чистым панелям (~18 агентов, Opus и Fable, без доступа к репозиторию; изоляция проверена поагентно по журналам). Финальная тройка: атака на готовое решение + два абстрактных дизайна, которым явно разрешили REST/gRPC/брокер. Пятое плечо — кросс-чек третьим семейством (GPT, через владельца): слепой дизайн сошёлся по форме и списку отвергнутого, атака — build with named changes без альтернативы; его уточнения помечены (GPT) ниже. Воркфлоу: wf_6adb39d7-870 · wf_2b45042d-115 · wf_4ac43daf-ec5 · wf_b7eae996-b9c (первый прогон wf_b2b954de-e83 загрязнён чтением репозитория и отброшен; правило: файловые инструменты панелям запрещать явно, проверять постфактум).

Статус: решение владельца 05.08 «прогон переживает деплой платформы» ратифицировано D39.106. Оркестратор №14.

Форма

  • Два Go-модуля (движок · платформа), граница — модульная, D39.85-гард в силе.
  • Движок — транзиентный/шаблонный systemd-юнит на прогон (Restart=no, стоп SIGTERM, щедрый TimeoutStopSec), платформа его не родитель: её деплой прогоны не трогает.
  • События — events.jsonl в каталоге книги, append-only, как проекция уже закоммиченных строк SQLite движка (та же транзакция, что чекпойнт chunk×stage; outbox, не вторая запись).
  • Платформа тейлит журнал; запись курсора — (engine_run_id, seq), байтовое смещение — хинт; курсор коммитится в той же Postgres-транзакции, что эффект события.
  • Деньги: холд + потолок движку = защита (не зависит от живости платформы), события — свежесть; hold→capture→release. tmctl status --json — ремонт, только по остановленному прогону (лок).
  • Реконсилятор — обязательный компонент: на каждый выход юнита (D-Bus-сигнал + ExecStopPost=-маркер) и на буте, где он ПЕРЕЗАПУСКАЕТ прерванные прогоны (транзиентные юниты не переживают ребут VM).
  • Формат потока (NDJSON · hello · версия · seq), D39.84, запрет читать живой SQLite движка — без изменений; журнал им не является.

Отвергнутые альтернативы (кем и почему — кратко)

Вариант Счёт Причина отказа
Монолит (движок внутри платформы) 0 из ~18 Радиус поражения (леджер+вход в одном адресном пространстве с часто меняющимся движком); «одна транзакция решает деньги» ложна — БД прогона + леджер + провайдер = три хранилища при любом числе процессов. Пороги пересмотра: без cgo · деплой реже месяца ИЛИ p99 ≤ 2 ч · кросс-версионный резюм в CI
Платформа — родитель + пайп (stdout/fd) 0 из 15 Время жизни движка — подмножество платформы: деплой/рестарт убивает или осиротляет прогон; дешёвый резюм это НЕ лечит (проверено отдельным плечом: факт «прерывание стоит минуты» не сдвинул ни один голос)
gRPC/HTTP-сервис поверх движка 0, отвергли явно Изобретает жизненный цикл, которого у движка нет (одна задача, эксклюзивный лок); поток всё равно умирает на рестарте платформы — долговечный буфер нужен, а он и есть журнал
Push в платформу (unix-сокет/localhost HTTP) 2 голоса в одном плече, затем 0 Платформа лежит несколько раз в неделю ПО ЗАМЫСЛУ ⇒ push требует буфер+ретраи+дедуп = журнал; плюс неаутентифицированный локальный писатель в денежные таблицы
Брокер (NATS/Redis/Kafka) 0 Третий stateful-демон на одной VM для 1 продюсера и 1 консюмера; redelivery сам создаёт риск двойного списания
Чтение чужой БД (в обе стороны) 1 голос из ~18 Сварка с приватной схемой, меняющейся еженедельно; лок-конфликт с живым писателем
Выделенный fd 3 вместо stdout 0 Никому не понадобился при свободном выборе; отдельно замерено: fd 1/2 дают SIGPIPE-смерть сироты, fd 3 — нет
journald/stdout как источник событий 0 journald молча роняет под всплеском и ротирует сам; людям — да, контракту — нет

Подтверждено вхолодную (наши решения воспроизведены независимо)

Потолок у движка вместо синхронной авторизации · hold→capture→release · идемпотентность (run_id, seq) · запрет читать приватную БД движка · standalone тем же путём кода (не режимом) · flock вместо pid-файлов · запись намерения до платного вызова (Р7) · целочисленные деньги · SSE, не WebSocket · status как ремонт, не поллинг.

Атака на готовое решение: вердикт build-with-changes, доминирующая альтернатива — none.

Обязательные механизмы при постройке (носители в D39.106)

Деньги:

  • write-ahead intent + состояние uncertain для окна «платный вызов прошёл, коммит не успел»: деньги остаются НЕРЕШЁННЫМИ в эскроу — intent доказывает намерение, не факт; spend не постится без свидетельства провайдера; флаг человеку (GPT); молчаливой бесплатной работы не бывает;
  • холд НЕ освобождается по выходу юнита — сигнал обгоняет финальные денежные события: состояние closing, эскроу держится до терминального seq журнала (GPT); расход сверх холда записывать полной суммой, превышение — явной строкой убытка платформы, не примечанием (GPT, уточняет PD-27);
  • ночная сверка с данными провайдера — приоритетнее споров об округлении: в движок суммы входят float64 по ручной таблице цен с ненулевым якорем незнакомого слага (backend/internal/ledger/pricing.go:27-52), систематика процентная;
  • гейт потолка на каждый платный вызов, не на юнит работы;
  • state-machine строки прогона в Postgres (холд может утечь до создания каталога); поднятие потолка — файлом через atomic rename на границах юнитов; кандидат: холд траншами (~часы ожидаемого расхода), не на весь прогон.

Эмиттер журнала (строка 103):

  • events (fsync, seq, деньги) ≠ status (atomic rename, косметика, не для биллинга);
  • формат — развилка строки 103 (GPT): одиночный JSONL с ftruncate-ремонтом имеет гонку живого читателя и слеп к битой записи с валидным \n; альтернативы — пофайловые события (events/<seq>.json, temp+rename) либо CRC-фрейминг; побайтовая идентичность не требуется;
  • материализатор принимает только seq == last+1 под блокировкой строки прогона; тот же seq с другим payload = карантин прогона, не тихий no-op (перекрытие инстансов при деплое) (GPT);
  • status --json берёт тот же flock — проверка «остановлен» не есть взаимоисключение (GPT);
  • пиннинг прогона к версионированному пути бинаря; резюм другой версией — только по явному флагу (движок выкатывается чаще, чем идёт прогон);
  • сброс буфера на всех путях выхода (os.Exit пропускает defer — теряются финальные события);
  • живость = растущий seq, не удерживаемый лок; политика обратного давления выбирается явно.

Опс: privilege-модель создания юнитов (linger-пользователь либо узкое polkit-правило) решить до постройки · состояние systemd — не истина (источник на буте = каталоги + Postgres) · диск ограничить (ENOSPC на ФС леджера ломает денежные инварианты; квота на прогон, проверка места при допуске) · ключи идемпотентности провайдеров протухают — replay это оптимизация, не гарантия · отдача SPA из бинаря платформы (один origin) — вопрос к П-1, не решено.

Открытое

  • Строка 103 единого бэклога и денежные строки — завести при освобождении docs/PROGRESS.md от чужих незакоммиченных правок; задачи зонам — отдельными промтами.
  • Вторая машина (решение владельца: одна пока что): при переезде умирают файловая шина и systemctl start как глагол — заменяются хост-агентом, шлющим те же строки; outbox, деньги, идемпотентность, SSE переживают без правки. Пиннинг книги к хосту уже в П-3.