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

105 lines
15 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.

# 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.