textmachine/platform/docs/platform-PROGRESS.md

2024 lines
272 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.

# Журнал зоны «Платформа»
> **Что это.** Состояние зоны и её живые остатки. Обратно-хронологический: свежее выше.
> Отработавшие эры вынесены срезами в [`archive/`](archive/) — читать только по конкретной ссылке.
## ⚠ ПИНГ ОРКЕСТРАТОРА №23 (10.09) — ЧЕТЫРЕ АДРЕСА В ВАШЕЙ ЗОНЕ ПОСЛЕ ДВИЖКОВОГО ПАКА И РАТИФИКАЦИИ ДВУХ ОСТАНОВОК
Зона ваша, рукой не трогаю. Всё ниже пере-снято моим прибором сегодня; каждый адрес — от корня репозитория.
**(1) Два комментария утверждают о движке неверное — ПРОЗА протухла, КОД у вас верен.**
`platform/internal/runs/reconcile.go:1534` и `platform/internal/pgstore/runs.go:320` пишут «the engine
catches SIGTERM and exits 1». Движок на пойманном сигнале отдаёт **5** (`backend/cmd/tmctl/main.go:100`
= «5 graceful stop»), и ваш собственный код это знает: `platform/internal/ingest/exit.go:34` = `ExitStopped = 5`,
`reconcile.go:1134` сверяется именно с ним. То есть врут только комментарии — но их цитируют доки, и ложь
уезжает дальше. Тот же текст, вероятно, в `platform/internal/runner/control_test.go` (у меня адрес из
чужого замера, сам не открывал).
**(2) `platform/internal/runner/runner.go:56-58` и `:163-164`НЕ ошибка, а ЦЕЛЬ; удалять НЕЛЬЗЯ.**
Там сказано, что движок «finishes the in-flight chunk before exiting» и что SIGTERM даётся ему, «so it can
finish the chunk it is paying for». Сегодня это ЛОЖНО: у движка одна кнопка и она жёсткая
(`backend/cmd/tmctl/main.go:207` отменяет контекст прогона, летящие вызовы рвутся). Но **владелец 10.09
ратифицировал ровно то, что ваш комментарий описывает** — акт `D39.234` п.1: первое нажатие мягкая
остановка (новых единиц не раздаём, начатое доигрываем), второе — сегодняшняя жёсткая. ⇒ это случай «док
впереди кода»: комментарий станет истинным, когда сядет пак. Пометьте его как цель, а не правьте под
сегодняшнее дерево.
**(3) ⛔ ЧИСЛОВОЙ КОНФЛИКТ, который включится в ту же секунду, как SIGTERM станет мягким.**
`platform/internal/runner/runner.go:59` = `stopGrace = 10 * time.Minute` (600 с), и он же уезжает в
`TimeoutStopSec`. Законное ожидание ОДНОГО вызова у движка — до `attempt_max_s` **1240 с**
(`backend/configs/models.yaml`, deepseek), и владелец это ожидание ратифицировал (`D39.230` п.3, потолок
подтверждён `D39.234` п.1б). ⇒ мягкий SIGTERM при нынешнем грейсе означает SIGKILL по ЗАКОННОМУ ожиданию,
а SIGKILL не оставляет ни пометки `cancelled`, ни сеттла оценки — деньги списаны, следа нет. Грейс обязан
вырасти выше потолка ожидания ПЛЮС время жёсткой фазы, либо второй сигнал шлёте вы сами по своему
продуктовому дедлайну.
**(4) То, что вы просили, движок публикует — но не туда, где вы это возьмёте.**
`platform/internal/pgstore/credits.go:235` просит «publish the count and sum of estimated-price rows beside
committed_usd, which is what would let this side say "at most Y"». Движок их считает и печатает —
`backend/internal/pipeline/status.go:321` (`estimated_rows` / `estimated_usd` в `status --json`) — но по ШВУ
они не едут: в кадрах `backend/internal/runevents/runevents.go` слова `estimated` **0 хитов** (контроль:
`committed` в том же файле — **6**; денежный кадр несёт один `committed_micro_usd`, `runevents.go:244`).
С вашей стороны читателя тоже нет: в `platform/internal` вне тестов `estimated` — **2 хита, оба
комментарии** (контроль: `committed_micro` — 1 хит; go-файлов вне тестов прибор прочёл **81**). Носитель —
строка бэклога **382**; чинится ПАРНО с движком.
**Что из этого следует для очереди.** Пак движка «мягкая остановка» и платформенная половина лендятся
ПАРОЙ и порознь не лендятся (`D39.234` п.2). Платформенная половина, как я её вижу: `mode` у `/stop`,
грейс выше потолка ожидания, путь второго сигнала (`systemctl kill --signal=SIGTERM`, юнит не трогая),
чтение оценочных строк из потока. Промт ещё не написан — если у вас есть довод против любой из четырёх
позиций, скажите ДО того, как я его напишу.
## ПРАВДА У ДВЕРИ — ОТЧЁТ (11.09, `textmachine-8e`)
> Предмет: платформа говорила человеку не то, что произойдёт. Три ряда — **`PD-455`** (форма обещает
> старт, в котором дверь откажет) · **`PD-162`** (прогон над пропавшим каталогом берёт холд и висит
> вечно) · **`PD-448`** (двойной клик по «продолжить»; строить было ЗАПРЕЩЕНО, заказан разбор).
> Вход: HEAD `05f690e`, дерево зоны чистое, батарея на входе зелёная и снятая ДО первой правки —
> `MAKE-EXIT=0 · 20 пакетов ok · 0 FAIL · линтер 0 issues · 5 скипов`. Сессия не коммитит: дерево
> передано оркестратору №23.
### Комплектность против заказа — по пунктам §4 промта
| Пункт | Исход | Чем предъявлено |
|---|---|---|
| §4.1 `PD-455` — форма обязана знать про живой прогон | **сделано** | предикат с двумя привязками + `blocked.code: run_in_flight`; пины; живая проба на стенде |
| §4.2 `PD-448` — исследуй и предложи, НЕ строй | **разбор, не строил** | замер воспроизводимости, три пути с ценой, рекомендация, расхождение канона отдельным пунктом |
| §4.3 `PD-162` — отказ ДО денег | **сделано у ОБЕИХ денежных дверей**, ряд остаётся открытым остатком | пины с контрольным прогоном; живая проба; ряд пере-написан |
| §4.4 интейк-гигиена | **не делал** (подписанный пропуск) | одна строка про следующий предмет `PD-175` — ниже |
| §4.5 якоря регистра | **сделано**, и их оказалось не четыре, а пятнадцать | `counts.py --lint` до и после |
| §5 самопроверка исполнением | **сделано** | батарея · `make vuln` · мутации на изолированной копии · живая проба · адверсариальный проход |
### `PD-455` — одно определение, две привязки
Правило допуска по фактам КНИГИ стояло инлайном в `Start` тремя проверками (статус · материализованное
дерево глав · живой прогон), и форма заказа о них не знала: вердикт считался из остатка книги и баланса,
а `ErrRunInFlight` жил только у двери. Человек видел `covers_all`, жал и получал 409.
Сделано: три проверки вынесены в **один предикат** `platform/internal/runs/runs.go:655`=`func startable`,
у него две привязки РАЗНОЙ авторитетности — `Start` зовёт его под замком книги (решает), `Order` зовёт
на опрашиваемом пути (`runs.go:304`, совещательно и заведомо устаревает). Мерило пака — «сколько
ОПРЕДЕЛЕНИЙ придётся тронуть, если правило изменится» — выполнено: одно.
Три решения, которые я принял внутри этой свободы, и довод каждого:
1. **`PricedBook` НЕ расширен.** Рядом с ним стоит письменное возражение (`platform/internal/pgstore/books.go:1476`:
«It is a second read beside ReadBookForRun rather than more columns on it, because the two answer
different questions of different shapes»). Форма делает ВТОРОЕ чтение — `ReadBookForRun`, ту самую
запись, которой судит дверь. Возражение этим исполнено, а не переступлено: две разной формы записи
остаются двумя, и определение по-прежнему одно. Цена, которую я принял: +1 запрос на КАЖДЫЙ опрос
формы (рядом уже три чтения, одно из них с пер-главным агрегатом по `units`). Это моё суждение, а не
замер: бенчмарка я не снимал.
2. **`verdict` не тронут.** Он отвечает про ДЕНЬГИ, и баланс книгу действительно покрывает; четвёртое
значение закрытого enum сломало бы генерённых клиентов. Рост пошёл через `Blocked.code`, который канон
предавторизует.
3. **Порядок двух причин в `blocked`.** Член один, кандидатов два, и они не равны: свой живой прогон —
это «старта нет вовсе», чужой холд — «шкала короче, чем мог бы позволить баланс». Свой прогон
выигрывает: сказать второе, когда верно первое, значит отправить человека останавливать ЧУЖОЙ прогон,
после чего клик всё равно откажет. Пин: `httpapi.TestARunOfThisBooksOwnOutranksAnotherBooksHold`.
Контракт: минор **0.14.0** ратифицирован оркестратором (`D39.244`) по моему пингу — `Blocked.code`
получил значение `run_in_flight`, смысл схемы расширен с «что держит шкалу короче» на «почему старт не
предлагается так, как обещал бы один баланс», `book_id` при этой причине — ЭТА ЖЕ книга. Константа
`httpapi.ContractVersion` переведена на `0.14.0` тем же деревом; гейт `gates.TestTheAnnouncedContractVersionIsTheOneTheCanonRatified`
зелёный.
### `PD-162` — отказ до денег, и вторая дверь, которой ряд не называл
Проверка каталога книги стоит на допуске ДО холда (`runs.go:707`=`func (s *Service) sourceThere`, зовётся
из `Start` после предиката книги и перед чтением счёта). ⚠ **Трудности, которую промт объявлял главной,
действительно нет** (§4.3 промта): каталог создаётся на интейке ДО строки в БД, а исключение первого
прогона принадлежит ЖУРНАЛУ внутри каталога (`journalSize` мапит ENOENT в нулевой офсет) — так что
`os.Stat(workdir)` на допуске не отвергает ничего законного, и никакой ветви-исключения я не строил.
**Что я нашёл сверх заказа: резюм — вторая денежная дверь, и клин там тот же.** Измерено исполнением ДО
правки: тест `runs.TestAResumeOverAMissingDirectoryIsRefusedBeforeTheHold` на дереве без гарда дал
`a resume over a missing directory answered <nil>, want ErrSourceGone` — то есть резюм над пропавшим
каталогом ВОЗВРАЩАЛ прогон и брал холд остатка бюджета. Оркестратор подтвердил, что это в границах пака
(«предмет `PD-162` — прогон, который никогда не поедет, а не конкретная ручка»), и гард стоит теперь и там
(`platform/internal/runs/reconcile.go:1721`, перед `reopen`).
**Вина книги и вина хоста разведены и отвечают РАЗНЫМИ словами** — это урок `PD-192`, который зона
уже оплатила один раз. Итоговое правило (третья и четвёртая строки приехали дофиксом по ревью):
| что с каталогом | ответ | почему так |
|---|---|---|
| его нет, книга ПОД корнем интейка, сентинел на месте | `ErrSourceGone` → 409 `book_not_ready` + `cause: source_gone` (на резюме — 409 `run_not_resumable` + та же причина) | это и вправду конец этой книги, и ожидание не поможет — так причина и говорит |
| его нет вместе с СЕНТИНЕЛОМ корня (форма размонтирования) | `ErrStorageUnavailable` → 503 | вина ХОСТА; сказать здесь «ваша книга непригодна» — ровно тот дефект, который в интейке отклонил все книги тома |
| его нет, а книга лежит ВНЕ корня интейка (`tmplatformctl book add --workdir`, инстанс без `BooksDir`) | `ErrStorageUnavailable` → 503 | сентинела на том томе нет вовсе: чужой здоровый маркер уликой про этот том не является, и платформа не объявляет конец книги на основании каталога, которого не создавала |
| путь ЕСТЬ, но это не каталог (файл, симлинк на файл) | `ErrStorageUnavailable` → 503 | `os.Stat` на файле успешен, движок открывает каталог; писала этот файл не платформа |
| путь есть и НЕ ЧИТАЕТСЯ (права, I/O) | `ErrStorageUnavailable` → 503, в сообщении операция и errno БЕЗ пути | ошибка уходит в ERROR-строку, а норма зоны §2 запрещает id книги в логах |
Предикат корня не скопирован, а **вызван**: `books.storageIsThere` стал экспортируемым
`platform/internal/books/books.go:574`=`func StorageIsThere(booksDir string) bool`, а «книга под нашим
корнем?» — `books.go:594`=`func Owns(booksDir, dir string) bool`; методы остались обёртками в одну
строку. Копия была бы вторым экземпляром правила, у которого первая редакция стоила зоны всех книг
хоста — и мутация, ломающая `StorageIsThere`, красит не только мой тест, но и ДВА чужих пина `PD-192`
в `internal/books`, что и есть доказательство, что предикат один.
**Ряд остаётся ОТКРЫТЫМ, и остаток пере-написан честно:** класс шире удаления каталога — прогон, УЖЕ
живущий, который не поедет никогда (запинен к старой сборке движка, без `reserved_usd`, беспризорный
деплой), по-прежнему висит с холдом до прихода человека. Это половина эскроу (`П-18`), автоматического
терминального вердикта у реконсилятора нет и он не строился.
### `PD-448` — разбор, а не стройка
⚠ Ниже — ТОЛЬКО разбор; кода по этому ряду я не написал ни строки.
**Механика, снятая чтением (адреса пере-сняты сегодня).** `Resume` читает состояние прогона ДО замка
книги: `platform/internal/runs/reconcile.go:1652`=`s.Store.ReadRunForResume`, затем
`reconcile.go:1663`=`unlock, err := s.lockBook(ctx, l.BookID)`, и только потом судит по `l.Status`
(`reconcile.go:1696`) — по значению, прочитанному ДО замка. Из этого следуют два разных проигравших:
* **проигравший ВНУТРИ окна** (его чтение случилось до коммита победителя) держит устаревший
`stopped`, доходит до `reopen`, его вставка `run_attempts` падает на уникальном индексе
`run_attempts_run_id_attempt_no_key``ErrNoRun``Resume` отвечает **прогоном**
(`reconcile.go:1736`=`return s.Store.ReadRun(ctx, userID, runID)`), и холд не берётся: транзакция
откатывается целиком;
* **проигравший СНАРУЖИ окна** (его чтение случилось уже после коммита победителя) видит
`translating`, падает в `default:` и получает `ErrNotResumable` — «the run cannot be continued: it
is translating». **Вот это и есть дефект ряда.**
**Окно дефекта — НЕ гонка за индекс, а интервал между чтением состояния и взятием замка.** И отсюда
же объяснение, почему изолированно тест зелёный, а в полном пакете под нагрузкой красный: замок книги
(`platform/internal/runs/bank.go:400`=`func (s *Service) lockBook`) — ВНУТРИПРОЦЕССНЫЙ мьютекс, так что
два резюма одного демона идут друг за другом, и под голоданием по процессору горутины стартуют дальше
друг от друга — проигравший чаще успевает прочитать уже переведённое состояние. Формулировка в шапке
теста («both calls pass the state check together and race for attempt N+1») описывает состояние ДВУХ
экземпляров демона; в одном процессе это последовательность, а не гонка.
**Воспроизводимость — замер сегодня, субагентом, инструментом и с числом прогонов** (вывод в
`scratchpad/pd448/`, 67 файлов):
| что запускал (всё — `go test ./internal/runs/ -race`) | раз | красных | контроль |
|---|---|---|---|
| изолированно `-count=10 -run '^TestTwoResumes…$'`, load 0.17 | 10 | **0** | в выводе 10 строк `=== RUN` и 10 `--- PASS` с этим именем, других `---` нет |
| полный пакет на простаивающей машине, `-count=1` | 3 | **0** | в каждом прогоне 183 верхнеуровневых теста, целевой `--- PASS` = 1, `--- FAIL` = 0 |
| `GOMAXPROCS=1` (`-cpu 1`) | 5 | **0** | в каждом `=== RUN` = 1, `--- PASS` = 1 |
| **под нагрузкой** (32 CPU-жруна на 8 ядрах, load 2950), подмножество `TestTwoResumes\|TestResumeIsRefused` | 20 | **4** | `--- PASS` 16, `--- FAIL` 4 |
| полный пакет под нагрузкой | 4 | **не измерено** | все четыре уперлись в `-timeout 9m` на ДРУГИХ тестах; целевой в каждом успел пройти зелёным до обрыва |
Текст всех четырёх падений ОДИН и тот же, и он читается, а не считается цветом:
`control_test.go:898: resume 1: runs: the run cannot be continued: it is translating, want the run back`
— то есть падает ровно утверждение «проигравший обязан ответить прогоном», и источник ошибки — ветка
`default` из разбора выше. ⚠ **Запись ряда «полный пакет упал 2 из 2» сегодня НЕ пере-проверена:**
полный пакет под нагрузкой ни разу не дошёл до конца, и это названо «не измерено», а не «не
воспроизвелось».
⚠ Замер шёл на МОЁМ дереве (гард каталога в `Resume` уже стоял). Путь гонки он не трогает, а если и
влияет, то в сторону РЕЖЕ: лишний `os.Stat` внутри замка отодвигает коммит победителя, то есть чаще
оставляет проигравшего в «хорошей» ветке. Числа выше поэтому — нижняя граница, а не верхняя.
**Чего сегодня нет, чтобы различить два случая.** Ни `LiveRun`, ни `run_attempts` не несут ни
заявителя, ни ключа идемпотентности: колонки таблицы — `id · run_id · attempt_no · engine_run_id ·
started_at · ended_at · exit_code · last_seq` (`platform/internal/pgstore/migrations/00002_readmodel.sql:72-87`,
и ни один из четырёх поздних альтеров этого не добавил). То есть у проигравшего состояние побайтово то
же, что у законного резюма часового прогона.
**Три пути промта, с ценой каждого:**
| путь | что делает | цена | закрывает ли предмет |
|---|---|---|---|
| **(а)** `Idempotency-Key` на `resumeRun` | второй клик под ТЕМ ЖЕ ключом реплеит ответ первого | контрактный минор (параметр операции) + клиент обязан прислать один ключ на оба клика | **не полностью:** при ОДНОВРЕМЕННЫХ кликах второй получает `409 key_in_flight` + `Retry-After` (`platform/internal/httpapi/idempotency.go:110`), то есть снова ошибку; прогоном он ответит лишь на ПОВТОРЕ |
| **(б)** claim-токен или колонка в транзакции рестарта | записать, КТО и чем открыл попытку, и дать проигравшему это увидеть | **миграция** + суждение по временному окну («открыто секунду назад резюмом») | да, но окно — это лотерея под другим именем |
| **(в)** перенести чтение состояния ПОД замок | убрать интервал | одна строка | ⛔ **делает хуже ДЕТЕРМИНИРОВАННО:** проигравший тогда видит `translating` ВСЕГДА |
**Рекомендация — четвёртый путь, которого в промте нет: сделать резюм идемпотентным ПО СУЩЕСТВУ.**
Ветка `case "translating"` в том же switch (`reconcile.go:1696`) отвечает **прогоном**, а не отказом —
с единственным исключением «стоп уже запрошен» (`l.StopRequestedAt != nil`; поле есть,
`platform/internal/pgstore/runs.go:322`), потому что такой прогон сворачивается, а не продолжается, и
202 там был бы ложью. Тогда оба проигравших — и внутри окна, и снаружи — отвечают прогоном, и мерить
больше нечего: гонки не остаётся, потому что исходы совпали.
Цена: **одна строка кода и одна строка канонной таблицы.** Ни миграции, ни ключа, ни нового состояния.
Семантика честная: `POST /resume` означает «пусть этот прогон идёт»; если он уже идёт — требование
выполнено, это ординарная идемпотентность записи.
**Довод ПРОТИВ, который я вижу сам:** клиент теряет различие «я продолжил» и «оно и так шло», а канон
этим свойством дорожит строкой выше — `paused` отвечает 409 именно потому, что 202 был бы «тихим
no-op». Разница, на которой я стою: у `paused` НИЧЕГО не переоткрывается и лекарство другое (новый
прогон), а у `translating` состояние ровно то, которого просил пользователь.
**Чего я НЕ знаю:** как это увидит фронт — у него сегодняшний `409 run_not_resumable` описан таблицей
канона, а его кода я не читал (чужая зона). И не знаю, есть ли деплой с ДВУМЯ экземплярами демона: при
одном экземпляре внутрипроцессный замок делает «оба прошли проверку вместе» невозможным, при двух —
возможным, и тогда путь (г) закрывает и эту форму тоже, а (а) и (б) — нет.
**ДИСПОЗИЦИЯ ОРКЕСТРАТОРА №23, пришла в тот же день.** Четвёртый путь ПРИНЯТ как направление, строит
его СЛЕДУЮЩИЙ платформенный пак — не этот. Довод принятия оказался не тем, с которого я начинал:
сомнение было в том, что идемпотентный `202` ломает канонную гарантию «202 = работа реально
пере-открыта», а проверка показала, что зона её уже НЕ держит — дерево отдаёт `202` проигравшему, у
которого пере-открыл не он, и это закреплено шапкой самого теста. Значит выбор был не между починкой
и обходом, а между двумя смыслами глагола. Критерий, по которому это лечение: обход оставляет ответ
зависимым от интерливинга и молчит об этом, лечение делает его ИНВАРИАНТНЫМ К ПОРЯДКУ.
**Три вещи поехали в ряд `PD-448`, чтобы следующий пак их не потерял** — и каждую я пере-проверил
своим прибором, а не принял на слово: вырез «стоп уже запрошен» обязателен (`l.StopRequestedAt`,
`pgstore/runs.go:322`; слово `stop_requested` уже на проводе, `httpapi/v0.go:242`); проверка re-pass
стоит ДО свитча (`reconcile.go:1687` против `:1696`) и после новой ветки должна пропускаться, иначе
двойной клик по re-pass снова получит отказ; и остаток окна, который правка НЕ закрывает —
`stop_requested` читается в том же незамкнутом снимке, а `Stop` замка книги не берёт вовсе (первый же
его оператор — `RequestStop`), так что стоп между снимком и ответом даст один `202` на сворачивающийся
прогон: один кадр, денег не трогает. Канон при этом НЕ двинут осознанно: строка таблицы — минор
`0.15.0`, и бамп сегодня покрасил бы гейт версии на сданном дереве, где константа несёт `0.14.0`.
### ⚠ Расхождение канона и дерева — отдельным пунктом (правит оркестратор)
**1. `translating` у `resumeRun`.** Канон обещает `409 run_not_resumable` («nothing; it is already
running» — таблица §`resumeRun` в `docs/architecture/14-api-contract/openapi.yaml`), а отгруженный код
проигравшему гонки за уникальный индекс возвращает ПРОГОН, 202 (`reconcile.go:1736`). То есть одна
ручка отвечает на одно состояние двумя разными способами в зависимости от того, где её застали. Это
расхождение СТАРШЕ моего пака и им не создано; рекомендация (г) как раз и сводит обе стороны к одному
ответу.
**2. Отказ резюма над пропавшим каталогом — ввёл Я, называю прямо.** `Resume` теперь может ответить
`409 book_not_ready` + `cause: source_gone` (`reconcile.go:1721`), а таблица §`resumeRun` перечисляет
ответы по СТАТУСУ прогона и такого отказа не знает. Генерённого клиента это не ломает: операция
объявляет общий `409 Conflict`, а `book_not_ready` — законное значение `ErrorCode`, которое эта же
поверхность уже отдаёт на соседней ручке. Но текст канона неполон, и строка таблицы нужна.
### Одна строка про `PD-175` (§4.4)
Следующим предметом ряда я считаю **ВЫХОД ИЗ `rejected`**, а не свип сирот и не потолок аплоадов.
Довод: у отклонённой книги сегодня выхода нет ВООБЩЕ — единственный переход в `not_started` стоит в
`platform/internal/pgstore/books.go:360` и требует `status = 'parsing'`, то есть перепарса не существует,
а ручки удаления нет в контракте вовсе (`PD-122`). Такая строка живёт в библиотеке человека навсегда, и
цена этого — доверие на КАЖДОЙ неудачной загрузке, тогда как сироты и потолок диска стоят операторского
диска, который в закрытой бете ограничен её же размером. Свип и ретеншен при этом остаются за гейтом
открытой регистрации (`D39.176` п.1), а счётный потолок аплоадов — это квота под другим именем, и он
запрещён.
### Якоря регистра (§4.5): их было не четыре, а пятнадцать
Промт называл три уехавших якоря в зоне плюс четвёртый, линтером не ловимый (без токена, внутри
`PD-162`). Все четыре починены. **Остальные одиннадцать убили МОИ СОБСТВЕННЫЕ переезды строк**
восемь на первом круге правок и ещё три после дофикса по ревью, — и это ровно то правило зоны, по
которому «якорь, убитый твоим переездом, чинит тот, чей переезд его убил». Прибор до и после:
`python3 docs/scripts/counts.py --lint` из корня репозитория — **23 проблемных якоря → 12**, и группа
«по КОРНЮ ЦЕЛИ … internal 8» из выдачи исчезла целиком.
⚠ Двенадцатый живой ✗ — **не мой к починке**: он лежит в `docs/architecture/05-decisions-log.md:2661`
(зона оркестратора) и указывает В мою: `platform/internal/runs/runs.go:451``:479`. Убил его мой
переезд, поэтому адрес назван ему пингом, а файл я не трогаю.
⚠ И один урок про сам прибор: якорь БЕЗ токена (`path:line` без `=`) линтер не проверяет вовсе, так
что мои собственные такие ссылки (`v0.go:610` в ряду `PD-455`) уехали МОЛЧА и были пере-сняты рукой.
Ставя новый якорь, ставь токен.
### Находка → что сделано → ЧЕМ ПРЕДЪЯВЛЕНО
| находка | что сделано | чем предъявлено |
|---|---|---|
| форма заказа не спрашивала правило допуска (`PD-455`) | один предикат `startable`, две привязки; на проводе `blocked.code: run_in_flight` | пины (ниже) + живая проба на стенде: `blocked":{"code":"run_in_flight","book_id":"bk_7JDPZD3S7J22XS3I"}` и 409 на втором клике |
| **резюм — ВТОРАЯ денежная дверь с тем же клином** (ряд её не называл) | гард `sourceThere` и там, перед `reopen` | до правки тест дал `a resume over a missing directory answered <nil>, want ErrSourceGone`; после — зелёный 8/8 |
| **мой собственный `fmt.Errorf` унёс бы путь книги в ERROR-лог** (класс `PD-139`) | путь выброшен, оставлены операция и errno | пин `runs.TestADirectoryThatCannotBeReadIsTheDeploymentsAndCarriesNoPath`; в логе стенда `tmstand-truth` встречается 2 раза, обе — эхо конфига на буте |
| мои переезды строк убили 8 чужих якорей (3 были сломаны до меня) | пере-снял все 11 в своей зоне + 12-й без токена внутри `PD-162` | `counts.py --lint`: 23 → 12 проблемных, группа «internal 8» исчезла |
| 12-й якорь моего переезда живёт в ЧУЖОЙ зоне | НЕ правлю, сообщаю адрес оркестратору | `docs/architecture/05-decisions-log.md:2661`: `platform/internal/runs/runs.go:451``:479` |
| `PD-455` покинул класс тревог гейта регистра | id убран из `alarmBaseline` с причиной, ТЕМ ЖЕ деревом (инструкция самого гейта) | `ALARM PD-count: 14 … (baseline 14)`, строка `ALARM PD-455 LEFT` исчезла, тест зелёный |
| форма молчит про ПРОПАВШИЙ каталог | НЕ чинил, завёл ряд `PD-466` с доводом, почему опрос — не место для `os.Stat` | ряд в регистре |
| носитель числа скипов батареи (`STACK_DECISIONS` §«Гейты батареи») называет не все условия | дописал два измеренных условия | два прогона одной смены назвали РАЗНЫЕ условия для одного и того же теста — оба вывода в отчёте ниже |
| **рецепт стенда называл сторону НАОБОРОТ**: «относительные `pipeline:`/`models:` — отличие ШАБЛОНА», хотя относительные они в ОРИГИНАЛЕ, а в шаблоне обязаны быть абсолютными | сторона названа явно, добавлена проверка на месте и цена ошибки | замерено: рабочий шаблон стенда несёт АБСОЛЮТНЫЕ пути (строки 2021), и с ним `books.TestTheRenderedConfigurationIsOneTheEngineActuallyLoads` зелёный против свежесобранного `tmctl`; по этой строке приёмка получила восемь красных живых проб, которые были дефектом шаблона, а не дерева |
### Какой тест что пинит (норма зоны §3 п.4)
| свойство несущего пути | пин |
|---|---|
| форма несёт отказ двери про живой прогон, и дверь отвечает то же | `runs.TestTheFormSaysWhatTheDoorWillRefuseOverABookThatIsAlreadyRunning` |
| на проводе это `blocked.code: run_in_flight` с `book_id` ЭТОЙ книги | `httpapi.TestTheFormCarriesTheRunInFlightThatWillRefuseTheClick` |
| свой живой прогон важнее чужого холда в одном члене `blocked` | `httpapi.TestARunOfThisBooksOwnOutranksAnotherBooksHold` |
| старт над пропавшим каталогом отказывает ДО холда | `runs.TestABookWhoseDirectoryIsGoneIsRefusedBeforeTheHold` |
| резюм над пропавшим каталогом отказывает ДО холда | `runs.TestAResumeOverAMissingDirectoryIsRefusedBeforeTheHold` |
| размонтированный том — вина деплоя, и книгу она не винит | `runs.TestAVanishedBooksVolumeIsTheDeploymentsFaultAndNotTheBooks` |
| нечитаемый каталог — тоже деплой, и путь книги в ошибку не попадает | `runs.TestADirectoryThatCannotBeReadIsTheDeploymentsAndCarriesNoPath` |
| два отказа выходят на провод по-разному (409+`source_gone` против 503) | `httpapi.TestAVanishedSourceAndAVanishedVolumeAnswerDifferently` |
| объявленная версия контракта = ратифицированный канон | `gates.TestTheAnnouncedContractVersionIsTheOneTheCanonRatified` (существующий) |
### Числа, снятые ПОСЛЕ последней правки
* **Батарея с ЧЕТЫРЬМЯ гейтами** (`TM_PLATFORM_TEST_DSN` на стендовый Postgres · `ENGINE_BIN` собран
из ТЕКУЩЕГО `backend/` по `PD-432` · `BOOK_TEMPLATE` стенда · достижимый пользовательский systemd):
`MAKE-EXIT=0` · **20 пакетов `ok` · 0 `FAIL`** · линтер `0 issues` · `sqlc diff` чисто · 5 скипов.
(Перечислены все двадцать: `cmd/tmplatformctl` `cmd/tmplatformd` `auth` `backup` `books` `config`
`exports` `gates` `httpapi` `ingest` `jobs` `login` `metrics` `money` `pgstore` `pricing`
`readmodel` `reqid` `runner` `runs` — «зелёная батарея» без полного списка ничего не значит.)
* **`make vuln`** (отдельная цель, в `check` не входит, блокер лендинга по §2 стандарта):
`No vulnerabilities found.`, `VULN-EXIT=0`.
* ⚠ **Оба числа ПЕРЕ-СНЯТЫ ПОСЛЕ ДОФИКСА по верификатору** (дофикс тронул `internal/gates` и
`cmd/tmplatformd`, то есть код, а не только доки): батарея снова `MAKE-EXIT=0 · 20 ok · 0 FAIL ·
линтер 0 issues · 5 скипов`, `make vuln` снова `No vulnerabilities found.`. Числа совпали с
до-дофиксными — но это ИЗМЕРЕНО, а не унаследовано: правка кода после снятых чисел обнуляет их
независимо от того, насколько она мала.
* **Скипы: 5, и условий у них ДВА, а не одно** — это отдельная находка сегодняшнего дня.
Четыре пробы `internal/runner` скипаются словами «this deployment's pipeline enables the bank
contour and names `<репо>/backend/configs/mining-contrast.zh.txt`, which is not on this host»
(артефакт чужой зоны, его отсутствие — не поломка платформы). Пятая,
`runner.TestTheSnapshotGuardIsLoudWithoutTheFlagsAndPassesWithThem`, умеет скипнуться ВТОРЫМ
условием — `listen tcp 127.0.0.1:11434: bind: address already in use`, — и два честных прогона одной
смены назвали для неё РАЗНЫЕ условия. Носитель числа (`STACK_DECISIONS` §«Гейты батареи») знал ноль
из двух; оба дописаны туда же вместе с предупреждением, что по одному прогону условия не считают.
* **Денежные пины не флейковые:** четыре теста × 8 прогонов под `-race` = **32 строки `--- PASS`,
0 `--- FAIL`** (`go test ./internal/runs/ -race -count=8 -run '<четыре имени>'`).
* **Живая проба на стенде** — свой демон (порт 18080, свой `BOOKS_DIR` под `$HOME`, своя база
`tmstand_truth`), книга заведена ЖИВЫМ интейком через `tmplatformctl seed`, и порт сверен с PID
своего демона перед сидом (шаг 3а рецепта). ⛔ `TM_PLATFORM_BOOKS_DIR` ни секунды не смотрел на
`<репо>/books`; проверка стоит в самом скрипте стенда, а не в намерении.
- форма над книгой в покое: `"blocked":null`, `verdict":"covers_all"`;
- первый клик: `202`, баланс 25.000000 → 22.862733 (холд взят);
- форма над той же книгой С ЖИВЫМ ПРОГОНОМ:
`"blocked":{"code":"run_in_flight","book_id":"bk_7JDPZD3S7J22XS3I"}` — то есть **ровно то, что
дверь и ответила** вторым кликом: `409 run_in_flight`;
- каталог книги удалён (руками, внутри своего стенда) → `409 book_not_ready` + `cause: source_gone`,
**баланс 25000000 до и 25000000 после** — холд не взят;
- сентинел `.tmplatform-books` убран (форма размонтирования) → `503 service_unavailable`, баланс тот
же, и в логе ERROR-строка `run refused: this deployment cannot start runs`;
- ⚠ **Живая движковая половина на этом хосте ДОСТИЖИМА, и «5 скипов» читать как «сюда не
добраться» нельзя:** приёмка №23 прогнала пробы `internal/runner` с шаблоном, чьи
`pipeline:`/`models:` абсолютные, и получила **42 PASS · 0 FAIL** при тех же пяти скипах;
условие одно — артефакт контраста, строка бэклога **251**, чужая зона. Числа приёмки, не мои.
- ⚠ $0: движок на стенде провайдерских ключей не имеет (`.env` рядом с `book.yaml` нет, ключей в
окружении 0), прогон честно умер `exit-code`, и холд вернулся сам.
### Мутационные посадки — 18 штук, выживших НЕТ
Изоляция по зонному стандарту §3 п.3: копия взята ВМЕСТЕ с каноном
(`cp -a --parents platform docs/architecture/14-api-contract <куда>/`), перед каждой правкой
скрипт утверждает `test -f go.mod` и что путь — копия, а не дерево. **База копии зелёная**
(`internal/runs` ok 57 с · `httpapi` ok 2.8 с · `gates` ok 0.46 с · `books` ok 25 с, exit 0), и
вердикты судятся по ДЕЛЬТЕ против неё.
⚠ Первый прогон базы упёрся в `-timeout 20m` на `internal/books` — под ЧУЖОЙ нагрузкой (load 46,
соседний замер), — и скрипт ОТКАЗАЛСЯ судить мутации против красной базы. Числа ниже со второго
прогона. Та же ловушка поймала и адверсариальный проход: его первая батарея была красной по
таймауту трёх пакетов из-за параллельной батареи, а не из-за дерева.
| # | что сломано | падает | ТЕКСТ падения (по нему и засчитано) |
|---|---|---|---|
| 1 | дверь перестаёт спрашивать про живой прогон | форма | `the form says <nil> over a book that is being translated, want the door's run_in_flight` |
| 2 | форма перестаёт НЕСТИ ответ двери | форма | то же сообщение с другой стороны |
| 3 | провод предпочитает чужой холд своему прогону | `httpapi` ×1 | `blocked = map[book_id:bk_other code:credit_held], want this book's own run` |
| 4 | `blocked` называет чужую книгу | `httpapi` ×2 | `blocked.book_id = , want the book being asked about` |
| 5 | допуск перестаёт смотреть на каталог | `runs` ×5 | `a start over a missing directory answered <nil>, want ErrSourceGone` |
| **6** | **каталог смотрится ПОСЛЕ холда, а не до** | `runs` ×5 | **`a refused start moved money: {Balance:10000000 Reserved:0} -> {Balance:6180172 Reserved:3819828}`** — денежный пин не вакуумен: на кону были настоящие $3.819828 |
| 7 | резюм перестаёт смотреть на каталог | `runs` ×1 | `a resume over a missing directory answered <nil>, want ErrSourceGone` |
| 8 | пропавший ТОМ свалить на книгу | `runs` ×2 | `answered … its source directory is not on this deployment, want ErrStorageUnavailable` |
| 9 | пропавший КАТАЛОГ свалить на деплой | `runs` ×2 | `answered … the book storage is not mounted on this deployment, want ErrSourceGone` |
| **10** | **маркер СВОЕГО корня читается как улика про ЛЮБОЙ том** | `runs` ×1 | `a start over a book outside the root answered … not on this deployment, want the deployment's` — пин находки ревью |
| 11 | сентинел хранилища перестаёт спрашиваться | `runs` ×1 **плюс два ЧУЖИХ пина `PD-192`** в `internal/books` | `the host is at fault, not the book` |
| 12b | путь, который не каталог, проходит дверь | `runs` ×1 | `a start over a FILE in the book's place answered runs: stat journal: …` |
| 13 | пропавший источник перестаёт быть видом `book_not_ready` | `runs` ×1 | `the refusal is not a book_not_ready: runs: its source directory is not on this deployment` |
| 14 | причина `source_gone` замолкает у СТАРТА | `httpapi` ×1 | `cause = <nil>, want "source_gone" — without it the client offers waiting` |
| 15 | РЕЗЮМ отвечает словами старта | `httpapi` ×1 | `code = book_not_ready, want "run_not_resumable"` |
| **16** | **оба новых значения провода переименованы** | `httpapi` ×4 **и гейт** | `this build serves blocked.code: runInFlight and the canon's enum does not carry it: [credit_held run_in_flight]` — до дофикса эта посадка ВЫЖИВАЛА |
| 17 | объявленная версия контракта уезжает от канона | `gates` ×1 | `this build announces contract 0.13.1 and the ratified canon is 0.14.0` |
**Итог: 18 посадок (17 + одна пере-посаженная), 17 пойманы, 0 выживших**, и каждая засчитана по
ТЕКСТУ падения, а не по цвету.
**Четыре посадки в ПЕРВОЙ редакции не собрались** — снятие блока оставляло неиспользованными `facts`,
импорт `books`, переменную `st`, — и «ни один тест не упал» там означало «пакет не собрался», то есть
НЕ ИЗМЕРЕНО, а вовсе не «дыра». Пере-посажены так, чтобы менялось РЕШЕНИЕ, а идентификаторы
оставались в деле, и только тогда засчитаны. Это тот самый случай, когда вердикт по неправой причине
отличается от поимки лишь тем, прочёл ли кто-нибудь текст.
**Посадка 11 — улика «предикат ВЫЗВАН, а не скопирован»:** ломая `books.StorageIsThere`, она красит
и мой тест, и ДВА чужих пина `PD-192`. Копия так бы не покраснела.
**Посадка 1 показала границу:** снятие `HasLiveRun` из предиката красит только ФОРМУ — дверь
по-прежнему отвергает вторую покупку, потому что за ней стоит частичный уникальный индекс
`runs_one_live_per_book` (`pgstore.StartRun`, ветка `isUnique`). У двери есть второй рубеж, у формы
его не было — это и есть предмет `PD-455` одной фразой.
### Греп ОТКРЫТЫХ рядов регистра по СВОИМ ПОЛНЫМ путям (норма зоны §3 п.8)
Прибор: греп по полным путям семи тронутых файлов с разбором таблицы регистра — **34 открытых ряда**
цитируют мои файлы. Диспозиции:
* **тронуты механизмом — четыре:** `PD-455` закрыт · `PD-162` сужен, остаток пере-написан · `PD-448`
разобран и остаётся открытым · `PD-466` заведён этим паком.
* **назван, но не закрыт — один:** `PD-139` (путь каталога книги утекает в ERROR-логи внутри обёрнутых
ошибок). Моя новая ветка МОГЛА стать его третьим носителем и не стала — путь выброшен из ошибки, пин
`runs.TestADirectoryThatCannotBeReadIsTheDeploymentsAndCarriesNoPath`. Сам ряд я не лечил: два его
существующих носителя — тейлер и обёртки спавна — мой дифф не трогает.
* **`PD-175`** — §4.4, одна строка выше.
* **остальные 28 — совпадение по ИМЕНИ ФАЙЛА, а не по предмету** (реконсилятор, свип, телеметрия,
интейк, читающая модель). Проверяемо: в `reconcile.go` мой дифф — ОДИН хунк на 8 строк
(`git diff -- platform/internal/runs/reconcile.go | grep -E '^@@'` даёт ровно одну строку,
`@@ -1713,6 +1713,14 @@`), в `books.go` — экспорт одной функции, в `runner.go` — одно поле
конфигурации. Оставлены открытыми с этой причиной.
### Что сказал адверсариальный проход (author≠reviewer, исполнением)
Направленный второй читатель по пяти осям §5 промта, со своей копией дерева и своими посадками.
**Девять находок, четыре существенные, и три из них я сам не видел.** Что сделано с каждой:
| находка прохода | вердикт | что сделано |
|---|---|---|
| **F3.** `sourceThere` спрашивал сентинел КОРНЯ ИНТЕЙКА про любую книгу — а книга от `tmplatformctl book add --workdir` живёт на томе, которого платформа не писала. Проход показал пробой: том такой книги пропал, корень интейка здоров ⇒ ответ был «конец КНИГИ». **Это `PD-192` заново, моими руками** | принята целиком | сентинел спрашивается только под своим корнем (`books.Owns`); где спрашивать нечего — ответ деплоя. Пин `runs.TestABookOutsideTheIntakesRootIsNotDeclaredDeadByAnotherVolumesMarker` |
| **F2.** Оба новых значения провода (`run_in_flight`, `source_gone`) не были запинены НИЧЕМ: все мои ассерты сравнивали провод с той же Go-константой. Проход переименовал обе**вся батарея осталась зелёной** (класс `PD-1`) | принята целиком | ассерты переписаны на ЛИТЕРАЛЫ + гейт со ВТОРЫМ независимым источником: `gates.TestTheBlockedVocabularyServedIsTheOneTheCanonEnumerates` читает enum `Blocked.code` из канона и сверяет в обе стороны |
| **F1.** Отказ резюма выходил словом СТАРТА (`book_not_ready` на `runId`), которого таблица §`resumeRun` не знает, и не был покрыт ни одним тестом | принята | резюм отвечает своим словарём — `409 run_not_resumable` + `cause: source_gone`; это форма, какой канон уже пользуется для двух осей, перекрывающих таблицу. Пин `httpapi.TestAResumeOverAGoneSourceIsRefusedInTheResumeHandlesOwnWords`. Строка канонной таблицы всё равно нужна — пункт оркестратору |
| **F5.** «Одно определение» верно для предиката, но отображение «ответ предиката → член провода» — вторая рукописная вещь с МОЛЧАЛИВЫМ хвостом: новый отказ завтра оставит форму с `covers_all` | принята | хвост стал громким: незнакомый отказ пишет ERROR-строку оператору. Компайл-тайм это не ловит (предикат отвечает `error`), и я это говорю, а не прячу |
| **F8.** `os.Stat` успешен на ФАЙЛЕ: путь, который есть, но не каталог, проходил дверь | принята | `IsDir()`, ответ деплоя; пин `runs.TestAFileWhereTheBooksDirectoryShouldBeIsRefusedToo` |
| **F9.** Фраза «the wire this build serves is the 0.13.1 shape» осталась в настоящем времени над константой `0.14.0` | принята | время исправлено |
| **F4.** Свойство «путь книги не течёт в ERROR» запинено у `sourceThere` и сломано строкой позже на том же вызове: `journalSize` (`internal/runs/spawn.go:301`) отдаёт `*fs.PathError` целиком, и он уходит в `default:` → 500 | **не лечил, назвал** | это носитель ряда `PD-139`, у которого СВОЯ диспозиция («решение, а не побочный эффект»); я записал в ряд и своё решение по своей ветке, и второй носитель адресом. Класс закрыт на ОДИН носитель из двух, и в отчёте это сказано так |
| **F6.** `bank.go` несёт посимвольную копию первой ветки предиката | **не лечил, назвал** | копируется не ПРАВИЛО, а предложение: сам предикат `readyToTranslate` там и вызывается, а живой-прогон половина у двери банка законно ДРУГАЯ (исключение `awaiting_bank`, ратифицированное находкой P9). Сводить их значило бы либо сломать исключение, либо втащить внутрь флаг «я дверь банка» |
| **F7.** Реконсилятор при своём респавне каталог не спрашивает, и окно между проверкой и спавном остаётся | **граница, а не дефект** | ровно то, что абзац «ОТКРЫТЫМ остаётся» ряда `PD-162` и говорит; половина живого прогона — эскроу (`П-18`) |
**Проход также подтвердил три моих утверждения исполнением, а не чтением:** автоматического
терминального вердикта живому прогону в дельте нет (грепы по `+367` добавленным строкам
`reconcile.go`); `PricedBook` не тронут (`pgstore/books.go` вообще отсутствует в `git status`);
контракт-первичность соблюдена — канон уехал на 0.14.0 РАНЬШЕ кода, коммитом `ba8542a`.
⚠ И назвал ловушку прибора, которую я знал по чужому опыту, а он встретил сам: его ПЕРВАЯ батарея была
красной по таймауту трёх пакетов — из-за параллельной батареи на той же машине, а не из-за дерева;
на чистом перепрогоне те же пакеты дали 48 с / 148 с / 123 с.
### Чего НЕ удалось / не измерено (§10)
* **Полный пакет `internal/runs` под нагрузкой ни разу не дошёл до конца** — четыре попытки уперлись в
`-timeout 9m` на ДРУГИХ тестах (`TestALiftedQuarantineMaterializesTheJournalAgainFromTheCursor`,
`TestARestartHoldsWhatTheRunWasSoldForWhenTheRateHasMovedSince`, `TestADeadlockDoesNotStopTheProjection`).
Значит запись ряда `PD-448` «упал 2 из 2 полным пакетом» сегодня **не пере-проверена**; гонка
воспроизведена подмножеством. Это «не измерено», а не «не воспроизвелось».
* **Цена второго чтения на пути формы не замерена.** Я утверждаю, что +1 индексный запрос на опрос
приемлем рядом с уже стоящим там пер-главным агрегатом, но бенчмарка не снимал — это суждение.
* **Ветка «стат каталога упал не-ENOENT» проверена только правами** (`chmod 000` на корне): EIO,
залипшее сетевое монтирование и симлинк-в-никуда я не воспроизводил.
* **Гонка «каталог удалён МЕЖДУ проверкой и спавном» остаётся** — проверка сужает окно, а не закрывает
его; это уже половина живого прогона, то есть эскроу (`П-18`), и её пак не брал.
* **Фронт я не читал** (чужая зона), поэтому не знаю, как он сегодня рисует `blocked` и что сделает с
новым значением кода; канон обязывает клиента терпеть незнакомое значение, но проверить это я не мог.
* **Число скипов батареи снято дважды и дало РАЗНЫЕ условия** для одного и того же теста (см. ниже) —
считать условия по одному прогону нельзя, и я не знаю, сколько их всего.
### Что я сам считаю слабым местом сделанного
1. **Форма по-прежнему может обещать старт над пропавшим каталогом** (`PD-466`). Я разрезал правило на
«факты БД спрашивают обе площадки» и «диск спрашивает только дверь», и довод у разреза есть
(опрос — не место для `os.Stat` зависшего монтирования), но это именно РАЗРЕЗ: половина обещания
формы осталась неправдой, и я назвал её, а не закрыл.
2. **`Options.Refusal` — поле типа `error` в структуре-значении.** Оно даёт слою провода решать
словарь (что правильно: коды живут в `httpapi`), но это необычная форма, и следующий читатель может
принять её за «ошибку чтения формы», а не за «ответ двери». Комментарий это говорит; форма всё равно
на любителя.
3. **Обе главные находки прохода — мои СЛЕПЫЕ ПЯТНА, и они одного рода.** Я построил фикстуру
`onTheVolume` под свою же картину мира (книга лежит под корнем интейка) и посадил двенадцать
мутаций — но ни фикстура, ни каталог мутаций не могли найти случай, которого в моей картине не
было: книгу ВНЕ этого корня. И мутировал я ЛОГИКУ, ни разу не тронув СЛОВАРЬ, поэтому «переименуй
оба новых значения провода» в мой каталог не попало — а именно эта посадка и выживала. Вывод, к
которому я пришёл не сам: свои мутации проверяют то, что автор считает важным, и ровно поэтому
второй читатель не роскошь.
4. **Мой предикат не покрывает третью площадку, которая судит те же факты** — дверь правок банка
(`internal/runs/bank.go:124-142`) читает `readyToTranslate` и `HasLiveRun` своим кодом, потому что у
неё СВОЁ правило (исключение для `awaiting_bank`, ратифицированное находкой P9). Я сознательно её не
тронул: свести их в один предикат значило бы либо сломать исключение, либо втащить в предикат флаг
«я — дверь банка», после которого «одно определение» становится лозунгом. Но факт остаётся: слово
«одно определение» верно для ДВУХ площадок из трёх, и я предпочитаю сказать это прямо.
### Дофикс по верификатору приёмки (11.09, тот же день) — один ВЫЖИВШИЙ мутант и одно тихо-зелёное
Проход верификатора по замороженной копии: девять посадок, семь чистых поимок, **один выживший и одно
тихо-зелёное**. Оба — вне моей карты находок, оба однострочные, и оба ломали ровно тот механизм, что
пак объявил главным. Обе находки я пере-проверил исполнением, прежде чем чинить.
1. **ВЫЖИВШИЙ: единственная проводка `BooksDir` в дверь не была утверждена ничем.**
`cmd/tmplatformd/runner.go:39` — единственное производственное присваивание, а соседний тест-свидетель
несёт комментарий «Mutation caught: dropping any assignment in runsConfig» и **`BooksDir` не
утверждал**. Посадка «снять строку» пережила полную батарею. Цена: с непроведённым корнем
`books.Owns("", …)` ложен для КАЖДОЙ книги, и книга, чей собственный каталог пропал, отвечает `503`
и операторским «this deployment cannot start runs» вместо `409 book_not_ready`+`source_gone` — то
есть путаница `PD-192` возвращается одной выпавшей строкой, молча, при целых деньгах. Запинено
своим случаем в `TestTheOperatorsRunnerKnobsReachTheReconciler`; посадка теперь краснеет текстом
«BooksDir is "", want the intake's …: the run door cannot tell whose fault a missing directory is».
⚠ И класс тут тот же, что мы записали нормой сегодня: **комментарий теста утверждал ШИРЕ, чем тест
делал.** Теперь утверждение и комментарий сошлись — покрыты все одиннадцать полей.
2. **ТИХО-ЗЕЛЁНОЕ В МОЁМ ЖЕ НОВОМ ГЕЙТЕ.** Регулярка `(?s)\n Blocked:\n.*?\n +enum: \[…\]`
телом схемы не ограничена: с удалённым из канона enum она лениво дотягивалась до СЛЕДУЮЩЕГО
`enum:` и зачитывала `Usage.state` семьюдесятью строками ниже — красное по неправой причине; а если
те же два слова положить под соседнюю схему, гейт отвечал **`--- PASS` над каноном, который их
больше не ратифицирует**. Чтение теперь ограничено телом схемы (`enumOfSchema`), и у самого
читателя есть пин на трёх синтетических документах — `TestTheSchemaEnumReaderStopsAtTheSchemasOwnBody`.
Проверено посадкой верификатора на копии: удаление enum из канона даёт теперь МОЙ текст
«schema "Blocked" carries no enum», а не чужие значения.
⚠ Горькая деталь: этот гейт я построил ровно как противоядие от своего слепого пятна по сигналу
прохода — и построил его с собственным слепым пятном того же рода. Прибор, проверяющий словарь,
сам обязан иметь пин; теперь имеет.
3. **Носитель числа скипов нёс ДВА разных числа.** Мой новый блок говорил «5 скипов», а нетронутая
строка того же раздела — «ожидание при всех четырёх: … **скипов 0**» (замер 29.08). Читатель,
попавший на вторую, объявил бы приёмку при пяти скипах или счёл бы пять поломкой. Разведено по
УСЛОВИЯМ: ноль относится к дереву 29.08, где не было ни артефакта контраста (строка бэклога 251),
ни занятого локального адреса провайдера; счёт скипов сверяется с блоком, который эти условия
называет.
4. **Якорь `PD-448` на сам тест уехал** (`control_test.go:846``:881`) — уехал ДО пака и промтом не
назывался, починен заодно. Плюс мой собственный `contract_test.go:16``:17`, который сдвинул
импорт, добавленный этим же дофиксом.
**Чего в этом списке НЕТ и почему.** Верификатор назвал пятым пунктом отсутствие фразы «работа
завершена, править не планирую» в зонном журнале — **фраза там была и есть**, в конце моей
секции (на момент проверки — `platform-PROGRESS.md:481`, после этой вставки она уехала ниже; греп по
файлу даёт ТРИ хита: сама фраза, её упоминание в этом абзаце и пак 08.09). Проверил прибором, прежде
чем «чинить»: приёмку своей работы тоже надо проверять, иначе в журнал уедет вторая копия той же
фразы и следующая смена будет гадать, какая из них настоящая.
**Работа завершена, править не планирую.** Дерево — 14 путей, все в `platform/` (13 изменённых плюс
новый `internal/runs/admission_test.go`), вне зоны не тронуто ничего; `docs/PROGRESS.md`,
`docs/experiments/`, `backend/docs/` в дереве — работа соседних смен. Сессия не коммитит: передано
оркестратору №23 вместе с тремя пунктами, которые правит он (якорь в журнале решений · строка
канонной таблицы §`resumeRun` · расхождение по `translating`, старше этого пака).
## ДОРАБОТКА ПО ИНВАРИАНТУ `D39.240` — ОТЧЁТ (11.09, `textmachine-5c`)
> Три предмета: строки **401** (вторая половина), **394**, **392**. Вход HEAD `d61469f`, дерево зоны
> на входе чистое. Записка-план ниже по странице; порядок исполнен как объявлен.
### 401 — отметка о расчёте больше не остаётся пустой навсегда
Расчёт — ДВА действия: `Settle` закрывает резервацию своей транзакцией, `MarkSettled` ставит
`settled_at` вторым оператором. Смерть процесса между ними оставляла леджер верным, а столбец пустым
**навсегда**: рабочий список ключуется на ОТКРЫТОЙ резервации, а её уже нет — прогон выпадает из
всех списков, которые могли бы к нему вернуться.
**Сделано:** отметка не запоминается, а ВЫВОДИТСЯ. `Store.StampSettledRuns` одним оператором
доштамповывает прогоны, чьи жизнь и деньги кончились («прогон завершён И у него нет открытых
резерваций»), и фаза расчёта зовёт его последним действием прохода. Ни новой колонки, ни нового
состояния; «навсегда» стало «до следующего прохода». Число починенного идёт WARN-ом — это факт о
деплое (процесс умер посреди расчёта), а не о прогонах.
**Названная цена:** отметка ставится временем ПОЧИНКИ, а не моментом закрытия денег; точное время
живёт в `closed_at` самой резервации. Прогон, починенный здесь, опаздывает штампом не больше чем на
один проход.
**Своя находка адверсариального прохода — своя же правка чуть не стала дороже дефекта.** Запрос
фильтрует `finished_at is not null and settled_at is null`, и ни один существующий индекс `runs` ему
не помогает: один частичный по `finished_at is null` (противоположная половина), второй по книге. ⇒
починка была бы **последовательным сканом `runs` на КАЖДОМ тике**, а `runs` только растёт. Заведён
частичный индекс миграцией `00035`, предикат в предикат: в здоровом деплое множество ПУСТО, потому
что обычный путь ставит отметку в том же проходе, — индекс держит ровно то, что оставил сбой.
### 394 — «убили своим грейсом» больше не читается как поломка деплоя
**Замерено исполнением, с контрольной посадкой.** Юнит нашей формы (`TimeoutStopSec=3`, процесс ловит
SIGTERM и не выходит) даёт маркер `{"result":"timeout","code":"killed","status":"KILL"}`; тот же юнит
с процессом, который по SIGTERM выходит, даёт `{"result":"exit-code","code":"exited","status":"5"}`.
Тройка `timeout/killed/KILL` и есть «сработал НАШ `TimeoutStopSec`», и прибор эти случаи различает.
**Сделано:** `timeout` выведен из группы «поломка деплоя» в `interrupted`. Довод — не вкус, а
невыполнение довода самой группы: там написано «the next spawn dies the same way», что верно для
OOM (следующий процесс упрётся в тот же лимит) и ложно для убийства по грейсу (следующий процесс
никто не останавливает, работа предшественника в чекпоинтах). Контракт при этом НЕ трогается:
`interrupted` — существующее значение, и его определение дословно описывает этот случай («retrying IS
the remedy, and finished work is not bought again»).
⛔ **Проверено прибором по требованию оркестратора: в эту развилку `timeout` приходит ДВУМЯ ветвями, а
не одной.** Замер трёх состояний намерения: намерение СТАРШЕ маркера → `outcome` отдаёт `stopped` и
до причины отказа не доходит вовсе; намерения НЕТ`failed`; намерение МЛАДШЕ маркера → `failed`.
Последнее — не угол: часы разные (маркер несёт хостовые, намерение платформенные), и «нажали стоп
после убийства» — обычное состояние. Обе достигающие ветви хотят одного ответа, поэтому правка одна,
но запинены обе.
**Чего НЕ сделано и почему** — не переведён в `stopped`. Грейс срабатывает только после того, как
остановку у systemd попросили, но при перезагрузке хоста менеджер останавливает ВСЕ юниты, и прогон,
не успевший свернуться, получил бы `stopped` и остался мёртвым после штатного ребута. Это ровно
асимметрия, ради которой написан `interruptedBySomeoneElse`.
### 392 — гонка имени юнита: ВОСПРОИЗВЕДЕНА, потом закрыта
Строка описывала механизм грубее, чем он есть. Бóльшая часть окна уже была закрыта: `reopen`
отказывает рестарту при записанном намерении, а подзапрос читает «последнюю не кончившуюся попытку»,
то есть при любом порядке КОММИТОВ ответ верен. **Настоящая дыра — в изоляции READ COMMITTED:**
`RequestStop` был одним UPDATE; когда он ждёт замок строки `runs` (его держит рестарт, пока кончает
одну попытку и открывает следующую), Postgres после разблокировки пере-проверяет `WHERE` против
НОВОЙ версии строки, **а подзапрос в `RETURNING` считается по снимку начала оператора**.
**Предъявлено исполнением на живой БД, в обе стороны:**
```
до правки: рестарт закоммитил "unit-NEW"; RequestStop ответил "unit-OLD"; живой юнит "unit-NEW"
после правки: рестарт закоммитил "unit-NEW"; RequestStop ответил "unit-NEW"
```
**Сделано:** `RequestStop` стал транзакцией — `select … for update of r` по строке прогона, затем
чтение имени и запись намерения. В READ COMMITTED блокирующее чтение после взятия замка пере-читает
последнюю зафиксированную версию, поэтому каждый следующий оператор транзакции видит рестарт, который
был в полёте. Имя юнита и намерение принадлежат одному моменту.
⚠ Ожидание замка правкой НЕ добавлено: одиночный UPDATE ждал того же замка. Изменилось не то, ждёт ли
вызов, а что он видит, проснувшись.
### Мутация — пять посадок на копии, засчитано по ТЕКСТУ
Копия жила в `~/.cache/tm-5c/mut2/platform` (не в общем скретчпаде), перед каждой правкой утверждались
`test -f go.mod` и точный `pwd`, после — `diff -rq` с деревом «идентично» и копия удалена.
| посадка | что сломано | вердикт и **текст** |
|---|---|---|
| A (392) | снято блокирующее чтение, назад к одному оператору | КРАСНО: «the stop answered "unit-OLD" while the live unit is "unit-NEW": the caller would signal a unit that is already dead…» |
| B (394) | `timeout` возвращён в группу поломок деплоя | КРАСНО ×2: «a "timeout" kill is reported to the client as "service_error", want "interrupted"» и то же во второй ветви |
| C (401) | из фазы расчёта убрана починка | КРАСНО: «a run whose money had closed is still carrying no settled mark after a full sweep…» |
| D (401) | починка штампует и ЖИВЫЕ прогоны | ⛔ **ВЫЖИЛА** на первом заходе |
| D (401) | то же, против пере-строенной фикстуры | КРАСНО: «a run that is still LIVE was stamped settled … because it happened to hold nothing» |
**Выжившая посадка — дефект В МОЁМ СОБСТВЕННОМ ПИНЕ, и он важнее трёх пойманных.** Починка
исключает живой прогон ДВАЖДЫ — потому что у него открыт холд, и потому что его жизнь не кончилась.
Первое исключение ПРЯЧЕТ второе: моя зеркальная фикстура держала прогон с открытым холдом, поэтому
снятие условия «прогон завершён» не меняло ничего. Фикстура пере-строена на состояние, где условия
расходятся, и это состояние ОБЫЧНОЕ, а не угловое: **между попытками** — свип рассчитал кончившуюся
попытку (резервация закрыта) и ещё не открыл следующую; прогон жив и не держит ничего, и только
«кончился ли прогон» удерживает починку от него. На пере-строенной фикстуре посадка краснеет, и
краснеет ИМЕННО во второй подветви — то есть первая действительно её прятала.
### Числа, сняты ПОСЛЕ последней правки
```
$ TM_PLATFORM_TEST_DSN=…55433 TM_PLATFORM_TEST_ENGINE_BIN=… TM_PLATFORM_TEST_BOOK_TEMPLATE=… \
TM_PLATFORM_TEST_PGDUMP=… TM_PLATFORM_TEST_PGRESTORE=… make check
MAKE-EXIT=0 · пакетов `ok` 20 · строк FAIL 0 · линтер «0 issues» · скипов 5
gofmt / go vet / sqlc diff чисты · ALARM PD-count: 15 (baseline 15) — не сдвинут
скипы поимённо, условие у всех ОДНО и названное — нет `configs/mining-contrast.zh.txt`:
TestTheRealEngineNamesItsRestorePointInTheLineThisPlatformParses ·
TestALivePreviewWritesNothingAndALiveApplyWrites ·
TestALiveBuildOfAHollowBookWritesTheMarkedCopyInsteadOfRefusing ·
TestWithoutPartialTheSameBookIsRefusedWithTheBuildsOwnNumber ·
TestTheSnapshotGuardIsLoudWithoutTheFlagsAndPassesWithThem
```
### ⚠ Правка ЧУЖОГО теста, вызванная заказанной сменой поведения — объявляю по `D39.183`
`TestWhyARunFailedDecidesWhetherARetryIsWorthOffering` (`internal/runs/reconcile_test.go`) держал ряд
«killed by the stop timeout → `service_error`». После правки 394 он честно покраснел в батарее — не
подогнан, а **описывал поведение, которое заказано сменить** (строка 394, ратифицировано письмом
оркестратора «делай, обе половины»). Ряд приведён к новому поведению, и вместе с ним исправлен
КОММЕНТАРИЙ теста, который нёс тот самый довод, что правка и опровергает.
**Куда уехала гарантия:** в `TestAKillByOurOwnGraceIsNotReportedAsABrokenDeployment`, где живёт и
довод, и замер маркера, и контроль `oom-kill`, который обязан ОСТАТЬСЯ `service_error`. Остальные
шесть рядов старого теста не тронуты — довод группы для них выполняется.
### Что НЕ удалось
**1. Цепочка «свип → `Stop` → настоящий systemd» по-прежнему не пройдена ни одним тестом.** Объявлено
заранее в записке-плане и остаётся верным: такого стенда в зоне нет, и я его не строила. Предмет 392
живёт в изоляции транзакции, и ЕГО стенд есть — доказательство полное для гонки и неполное для цепочки.
**2. Ущерб по 392 предъявлен на уровне СТОРА, а не продукта.** Тест показывает, что `RequestStop`
возвращал мёртвое имя; что обработчик после этого шлёт сигнал в пустоту и пользователь держит `202`
следствие, выведенное чтением, а не поставленный сценарий.
**3. Гонка воспроизведена НА ФИКСТУРЕ, а не на настоящем рестарте.** Транзакция-соперник пишет те же
три оператора, что и `reopen` (замок строки прогона, конец попытки, вставка следующей), но это моя
запись, а не вызов `ReopenRun`. Если `reopen` однажды перестанет брать замок строки `runs` первым,
фикстура продолжит воспроизводить старую форму гонки, а не новую.
**4. По 394 не проверено исполнением, что `timeout` не приходит от НАЧАЛЬНОГО тайм-аута.** У наших
юнитов `Type=simple` (дефолт `systemd-run`), для которого стартовая фаза завершается сразу, поэтому
`timeout` может прийти только от остановочной, — но это РАССУЖДЕНИЕ о systemd, а не мой замер.
Сконструировать стартовый тайм-аут на `Type=simple` я не пробовала.
**5. Взяты три предмета из десяти.** 398, 399, 400 и три носителя мусора на диске не тронуты по
границам захода; 400 и мусор — ещё и потому, что я их не читала.
## ДОРАБОТКА ПО ИНВАРИАНТУ `D39.240` — ЗАПИСКА-ПЛАН (11.09, `textmachine-5c`)
> Три предмета из улова отменённого пака, открытые владельцем словом «если остались баги или
> рефакторинг про останов — доделать просто»: строки **392**, **394** и **401** (вторая половина).
> Инвариант: остановка ЖЁСТКАЯ, деньги терять допустимо, но **без гонок, без половинчатых состояний,
> с верным возобновлением**. Вход HEAD `d61469f`, дерево зоны на входе чистое.
> ⛔ Границы заданы оркестратором и приняты: **398** и **399** (логика служебного обхода), **400** и
> носители мусора на диске (**401**а/б/в) НЕ берутся — последние два потому, что я их не читала, и
> давать себе предмет, которого не смотрела, под видом «доделать» нельзя.
### 392 — гонка имени юнита: механизм назван точно, и он тоньше, чем строка
Строка говорит «между коммитом намерения и вызовом systemd свип успевает рестартовать». Разбор кода
показывает, что бóльшая часть этого окна УЖЕ закрыта, и закрыта хорошо: `reopen` отказывается
рестартовать прогон с записанным намерением (`ErrStopRequested`), а `RequestStop` читает имя юнита
подзапросом «последняя не кончившаяся попытка» — то есть при любом порядке КОММИТОВ ответ верен.
**Настоящая дыра — не в порядке коммитов, а в изоляции READ COMMITTED.** `RequestStop` сегодня один
UPDATE. Когда он блокируется на замке строки `runs`, который держит идущий `reopen`, Postgres после
разблокировки пере-проверяет `WHERE` против НОВОЙ версии строки (EvalPlanQual) — но **подзапрос в
`RETURNING` считается по снимку, взятому в начале оператора**. Значит намерение ложится на новую
попытку, а имя юнита возвращается от СТАРОЙ. Обработчик шлёт сигнал мёртвому юниту, живой работает,
пользователь держит `202`.
**Чинить так:** `RequestStop` становится транзакцией — `select … for update` по строке прогона (в
READ COMMITTED он после взятия замка пере-читает последнюю зафиксированную версию), затем чтение
имени живой попытки, затем UPDATE. Тогда имя юнита и запись намерения принадлежат одному моменту.
**Предъявлять исполнением**, а не рассуждением: тест на настоящем Postgres, где рестарт и стоп идут
параллельно, и утверждение — какое имя юнита вернулось.
**Про стенд, честно и заранее.** Цепочку «свип → `Stop` → настоящий systemd» по-прежнему не гоняет
ни один тест, и я такого стенда не строю: он не влезает в этот заход. Но предмет 392 живёт НЕ там —
он живёт в изоляции транзакции, и её стенд в зоне есть (живой Postgres). ⇒ доказательство будет
полным для гонки и неполным для цепочки; второе пойдёт в «что не удалось», как и предупредил
оркестратор.
### 394 — «убили своим грейсом» читается как поломка деплоя
**Замерено исполнением** (юнит нашей формы, `TimeoutStopSec=3`, процесс ловит SIGTERM и не выходит):
маркер, который пишет systemd, — `{"result":"timeout","code":"killed","status":"KILL"}`.
**Контрольная посадка на том же стенде** (тот же юнит, процесс выходит по SIGTERM) —
`{"result":"exit-code","code":"exited","status":"5"}`. То есть тройка `timeout/killed/KILL` и есть
«сработал НАШ `TimeoutStopSec`», и прибор эти два случая различает.
Сегодня она попадает в общий список с `oom-kill` и даёт `failed` + `service_error`
(`reconcile.go`, `failureReason`). ⛔ **И довод, записанный над этим списком, ДЛЯ `timeout` не
работает**: там сказано «an out-of-memory kill and a stop-timeout SIGKILL both told the client that
retrying would help, which for those two is exactly false: the next spawn dies the same way». Для OOM
это верно — следующий процесс упрётся в тот же лимит. Для убийства по грейсу неверно: следующий
процесс НИКТО не останавливает, и работа его предшественника лежит в чекпоинтах, то есть повтор не
покупается заново.
**Делаю: `timeout` уходит из группы `service_error` в `interrupted`** — «ретрай и есть лекарство» —
и довод пишется на месте, вместе с тем, почему общий довод не покрывает этот случай.
**ОБЪЯВЛЯЮ ГРОМКО: это правка ПРОДУКТОВО ВИДИМОГО поведения поверх решения с записанным доводом.**
Она в моей зоне и по предмету строки, но если оркестратор считает, что вопрос «предлагать ли
пользователю повтор» — не мой, пусть скажет, и я откачу этот пункт, оставив вторую половину.
**Чего я НЕ делаю и почему:** не перевожу такой прогон в `stopped`. Соблазн есть — грейс срабатывает
только после того, как остановку у systemd попросили, — но при перезагрузке хоста менеджер
останавливает все юниты, и прогон, не успевший свернуться, получил бы `stopped` и остался бы мёртвым
после штатного ребута. Это ровно та асимметрия, ради которой написан `interruptedBySomeoneElse`.
### 401 (вторая половина) — отметка о расчёте может остаться пустой навсегда
Расчёт и отметка — ДВА действия: `Settle` закрывает резервацию своей транзакцией, `MarkSettled`
ставит `settled_at` отдельным оператором (`reconcile.go`, четыре места). Смерть процесса между ними
оставляет леджер верным, а столбец пустым — **навсегда**, потому что рабочий список `UnsettledRuns`
ключуется на ОТКРЫТОЙ резервации, а она уже закрыта. Читателей у столбца вне тестов ноль (пере-снято:
три писателя, одна очистка, ноль чтений; контроль — 15 упоминаний в тестах).
**Чинить самолечением, а не новым состоянием.** Отметка ВЫВОДИМА: «прогон кончился И у него нет
открытых резерваций». Значит фаза расчёта одним оператором доштамповывает такие прогоны, и «навсегда»
превращается в «до следующего прохода». Ни новой колонки, ни нового смысла.
⚠ Альтернатива — удалить столбец, у которого ноль читателей, — отвергается: он несёт СМЫСЛ, который
охраняет ветка `--release-hold` («a field that lies is a field the next reader believes»), и удаление
было бы решением про будущее, которого я не знаю.
### Порядок и почему такой
**401 → 394 → 392.** От дешёвого и однозначного к тому, что меняет продуктовое поведение, и дальше к
тому, что требует конкурентного стенда: если заход придётся сдавать неполным, неотданным останется
предмет с самым узким радиусом, а не самый дешёвый.
### Что предъявляю исполнением
1. **392** — конкурентный тест на живом Postgres: рестарт и стоп в параллель, утверждение про
ВОЗВРАЩЁННОЕ имя юнита; мутация — вернуть одиночный UPDATE, ждать красноты по тексту.
2. **394** — маркер грейс-убийства уже замерен (выше, с контрольной посадкой); в батарее — пин на
разбор этого маркера, с контролем на `oom-kill`, который обязан ОСТАТЬСЯ `service_error`.
3. **401** — тест: расчёт прошёл, отметка не поставлена (симулируем смерть между двумя действиями),
следующий проход её ставит; контроль — живой прогон отметку НЕ получает.
### Мандат самопроверки
Мутация обязательна и засчитывается по ТЕКСТУ падения, а не по цвету. Отдельным заходом —
адверсариальный проход по своей готовой работе. Секция «что не удалось» непустая по построению:
цепочка «свип → systemd» в ней уже есть.
## ДОФИКС ПО ОТМЕНЁННОМУ ПАКУ: `--no-block` С ПИНОМ (11.09, `textmachine-5c`)
> Взят владельцем ОТДЕЛЬНО от отменённого пака и взят ровно в той форме, которую зона просила: правка
> вместе с пином, а не правка с довеском. Довод, который дошёл дословно: без пина в коде остаётся
> утверждение «asking again is free and idempotent», которое держится на недокументированном
> поведении systemd и которое никто не проверяет.
> ⛔ **Границы захода:** только это. Ни режима остановки, ни второго сигнала, ни кадра событий — пак
> отменён и остаётся отменённым.
### Что сделано, тремя правками
**1. `Runner.Stop` больше не ждёт конца прогона** (`platform/internal/runner/runner.go`). Замер, ради
которого правка и берётся, уже в журнале решений: блокирующая форма вернулась через **15,058 с**
(по SIGKILL, юнит с `TimeoutStopSec=15`), `stop --no-block` — через **0,014 с**. Комментарий над
функцией несёт оба следствия и НЕ несёт третьего, которого нет (см. правку 3).
**2. Пин на то, что раньше держалось на удаче**
`runner.TestARepeatedStopNeitherSignalsNorExtendsTheGrace`, живой, на настоящем systemd. Он
спрашивает у systemd ровно те два свойства, которые УТВЕРЖДАЕТ комментарий свипа: повторный `stop` на
юните в `deactivating` (а) не доставляет второго сигнала и (б) не перезапускает `TimeoutStopSec`.
Числа прогона: три стопа в t+0 / t+2 / t+4 против грейса 6 с → процесс получил **1** сигнал, юнит
умер на **t+6,06 с**.
**Контроль встроен в сами числа, отдельного прогона не требуется, и это сказано в теле теста:**
«один сигнал» не читается как отсутствие, потому что тот же лог доказывает, что фикстура сигналы
СЛЫШИТ (первый стоп доставил); а юнит не «просто не убит» — он УБИТ, и утверждение о том, по чьим
часам: по первому стопу (t+6), тогда как перезапущенный таймер дал бы t+10.
**3. Комментарий свипа перестал утверждать без основания** (`platform/internal/runs/reconcile.go`,
ветка пере-выдачи стопа): «free and idempotent» теперь названо ЗАМЕРЕННЫМ свойством systemd, с обеими
половинами и с ценой потери каждой, и со ссылкой на пин.
### Мутационная проверка — выполнена, на КОПИИ, три посадки
⚠ Это то, чего смена не сделала в отменённом паке и записала в «что не удалось». Здесь сделано.
Копия дерева зоны жила в `~/.cache/tm-5c/mut/platform` (не в общем скретчпаде — его чистит не только
свой процесс), перед каждой правкой утверждались `test -f go.mod` и точный `pwd`; после прогона копия
сверена с деревом (`diff -rq` по `internal/runner` — идентично) и удалена.
| посадка | что сломано | вердикт и **текст** падения |
|---|---|---|
| M1 | снят `--no-block` у `Stop` | КРАСНО, `TestTheSoftStopDoesNotWaitForTheRunToEnd`: «the stop waits for the unit to be gone: [systemctl --user stop tm-run-X-1.service]» |
| M3 | пере-выдача ДОСТАВЛЯЕТ (`systemctl kill --signal=SIGTERM` вместо `Stop`) | КРАСНО: «the process received **3** signals from three stops, want exactly 1» |
| M4 | часы грейса идут от ПОЗДНЕГО стопа (первый стоп сдвинут на t+4) | КРАСНО: «the unit died at **t+10,09s** against a 6s grace: a repeated stop restarted the stop timeout» |
**Каждая посадка засчитана по ТЕКСТУ, а не по цвету:** в каждом случае сообщение называет ровно то,
что сломано. И две половины утверждения оказались независимо различающими — M3 краснит только счёт
сигналов, M4 только часы.
**M1 живой пин НЕ ловит, и это названо, а не обойдено:** без `--no-block` первый стоп блокируется на
весь грейс, остальные два приходят уже на мёртвый юнит, и числа сходятся прежние. Флаг ловит
argv-пин, который для того и написан отдельно — живой тест скипается на хосте без пользовательского
менеджера systemd, и флаг на таком хосте остался бы непроверенным вовсе.
**Флейковость проверена, а не предположена:** живой пин прогнан **5 раз подряд** — 5 зелёных,
разброс смерти 6,046,12 с при пороге 8 с, счёт сигналов 1 во всех пяти. Пин не маргинален ни по
одной из двух осей.
### Числа
```
$ TM_PLATFORM_TEST_DSN=…55433 TM_PLATFORM_TEST_ENGINE_BIN=… TM_PLATFORM_TEST_BOOK_TEMPLATE=… \
TM_PLATFORM_TEST_PGDUMP=… TM_PLATFORM_TEST_PGRESTORE=… make check
MAKE-EXIT=0 · пакетов `ok` 20 · строк FAIL 0 · линтер «0 issues» · скипов 5
gofmt / go vet / sqlc diff чисты · ALARM PD-count: 15 (baseline 15) — не сдвинут
скипы поимённо, условие у всех ОДНО и названное — нет деплой-артефакта
`configs/mining-contrast.zh.txt`: TestTheRealEngineNamesItsRestorePointInTheLineThisPlatformParses ·
TestALivePreviewWritesNothingAndALiveApplyWrites · TestALiveBuildOfAHollowBookWritesTheMarkedCopyInsteadOfRefusing ·
TestWithoutPartialTheSameBookIsRefusedWithTheBuildsOwnNumber · TestTheSnapshotGuardIsLoudWithoutTheFlagsAndPassesWithThem
$ go test ./internal/runner/ -run TestARepeatedStopNeitherSignalsNorExtendsTheGrace -count=1 -v (×5)
5 зелёных · сигналов 1 во всех пяти · смерть юнита 6,04 / 6,09 / 6,09 / 6,10 / 6,12 с при пороге 8 с
```
**Новый пин ВНУТРИ батареи отработал, а не проскочил:** цель `check` гоняет `-v` и печатает КАЖДЫЙ
`--- SKIP`; скипов ровно пять, и пина среди них нет, при нуле падений. Числа выше — из отдельных
прогонов той же командой, потому что `t.Logf` в сводку `make check` не попадает.
⚠ Гейты стенда закрыты все четыре намеренно: без `TM_PLATFORM_TEST_DSN` та же батарея печатает те же
`ok` и прячет ~370 тестов (эррата `08.09-д`).
### Что НЕ удалось
**1. Живой пин не покрывает саму правку, и это названо выше, а не обойдено** — снятие `--no-block`
ловит только argv-пин. Разделение осознанное (живой тест скипается без пользовательского менеджера
systemd), но означает: на хосте без systemd свойство «стоп не ждёт» проверено, а «systemd инертен к
повтору» — нет, и это не чинится в зоне.
**2. Пин говорит о ЮНИТЕ, а утверждение живёт в СВИПЕ.** Тест проверяет `Runner.Stop` напрямую;
никакой тест не гоняет пере-выдачу ЧЕРЕЗ реконсайлер против настоящего systemd — такого стенда в зоне
нет. То есть цепочка «свип → `Stop` → systemd» на живом юните не пройдена ни разу, и если однажды
свип начнёт звать не `Stop`, а что-то другое, пин этого не заметит.
**3. Ущерб от блокировки не предъявлен исполнением — ни одна половина.** Что запрос повиснет, а свип
запишет ложный клин, взято ЧТЕНИЕМ кода (`reconcileOne`, комментарий про израсходованный бюджет) плюс
замером самой блокировки. Сценария «блокирующий стоп внутри прохода довёл прогон до отсрочки» я не
ставила; на предмет правки это не влияет, но утверждение о механизме ущерба остаётся выведенным.
**4. Прочие девять адресов улова не тронуты** — они вне границ захода и живут строками бэклога
**392****397**.
## ПАК «ДВЕ ОСТАНОВКИ» — ОТМЕНЁН ВЛАДЕЛЬЦЕМ, РАБОТА ОТКАЧЕНА (10.09, `textmachine-5c`)
> Промт `docs/PLATFORM_SOFT_STOP_SESSION_PROMPT.md` (редакция `ac9a24d`), вход HEAD `ac9a24d`.
> **Отменён владельцем по размаху**, а не по качеству: две остановки того не стоят. Новый инвариант
> объявлен тем же словом: **стоп остаётся ЖЁСТКИМ, терять деньги ДОПУСТИМО, но остановка обязана быть
> КОРРЕКТНОЙ** — без гонок и половинчатых состояний, с правильным возобновлением. Предмет переехал с
> «не сжечь деньги» на «не оставить мусор».
>
> ⚠ **Эта секция — не отчёт о построенном, а НАЙДЕННОЕ.** Код зоны возвращён к HEAD целиком (кроме
> константы версии контракта, см. ниже). Здесь остаётся то, что зона УЗНАЛА о собственном дереве:
> оно пережило пак и стоит дороже, чем стоил бы код.
### Что откачено и что оставлено
Возвращены к HEAD **26** путей в `platform/`. Удалены девять моих новых файлов:
`internal/gates/stopgrace_test.go` · `internal/httpapi/stopmode_test.go` ·
`internal/pgstore/migrations/00035_stop_mode.sql` · `internal/pgstore/migrations/00036_estimated_and_stop_ledger.sql` ·
`internal/pgstore/stopmode_test.go` · `internal/runner/stop_systemd_test.go` · `internal/runner/stop_test.go` ·
`internal/runs/estimated_test.go` · `internal/runs/stopmode_test.go`.
**Оставлено ОДНО и по прямому указанию оркестратора:** `internal/httpapi/capabilities.go`, константа
`ContractVersion`. Это чужой долг, взятый попутно: канон уехал на `0.13.1` актом `D39.235` п.6, а
константа осталась на `0.13.0`, и гейт `internal/gates.TestTheAnnouncedContractVersionIsTheOneTheCanonRatified`
краснел с момента ратификации — базовая линия зоны была КРАСНОЙ на входе, и не по вине пака.
В ходе пака константа стояла на `0.14.0` (свой минор: `mode` у `/stop` плюс `Run.stop_mode`, по
ратифицированному порядку «код первым»); с отменой пака этого минора нет, поэтому при откате она
приведена к **`0.13.1`** — к тому, чему билд действительно служит.
### ⛔ Что зона узнала о СЕБЕ — это и есть результат смены
Ниже разделено на «замерено исполнением», «прочитано в коде» и «латентно» намеренно: смешивать их
дороже обычного, потому что по ним заводятся строки. Адреса — по HEAD `ac9a24d`.
**1. `Runner.Stop` блокируется на весь грейс — ЗАМЕРЕНО.** `internal/runner/runner.go:195` зовёт
`systemctl --user stop` без `--no-block`, `runCommand` (`:106-112`) своего тайм-аута не имеет.
Пробник на транзиентном юните с `TimeoutStopSec=15`, процесс ловит SIGTERM и не выходит (systemd 259):
блокирующая форма вернулась через **15,058 с** (по SIGKILL), `stop --no-block` — через **0,014 с**,
юнит в `deactivating`. Сегодня движок на жёстком сигнале умирает за секунды, поэтому это невидимо.
**Испр. 11.09 по эррате `10.09-з`:** носитель последствия НЕ `WriteTimeout` — тридцать секунд стоят у
слушателя МЕТРИК (`cmd/tmplatformd/main.go:204-208`, комментарий там прямо говорит «for once a WriteTimeout
too: nothing here streams»), а у API-слушателя его НЕТ намеренно (`internal/httpapi/serve.go:36`, «No
WriteTimeout» — срезал бы SSE). Пере-снято моей рукой: во всей зоне вне тестов `WriteTimeout`
УСТАНАВЛИВАЕТСЯ **в одном месте**`main.go:208`. Контроль, что прибор спрашивал существующее:
`grep -rn WriteTimeout platform/ --include=*.go | grep -v _test.go` на `1c5bd2a` даёт четыре строки, из
которых три — комментарии, и ДВЕ из них объясняют, почему у API-слушателя его нет.
⭐ И механизм, на который поправка меняет ущерб, в зоне УЖЕ ЗАРЕГИСТРИРОВАН: `PD-103` — «зависший Postgres
паркует хендлеры и ждущих в пуле, пока клиент сам не уйдёт», ровно потому что `WriteTimeout` у сервера
отсутствует по проекту (SSE) и `TimeoutHandler` в цепочке нет. Блокирующий `Stop` — тот же класс, другой
источник блокировки; ⇒ поправка садится на существующий носитель, а не заводит новое утверждение. ⇒ ущерб не «оборванный `202`», а **зависший запрос с удержанной горутиной обработчика**
(контракт описывает этот вызов АСИНХРОННЫМ) плюс ложный клин свипа (п.2).
**2. Израсходованный бюджет прогона свип считает ПРОВАЛОМ реконсиляции — прочитано в коде.**
`reconcileOne` (`internal/runs/reconcile.go:89-90`) заворачивает каждый прогон в `s.runBudget()`
(дефолт 60 с), и комментарий на `:92-115` говорит буквально «SPENDING the budget counts as a failure
even when nothing reported one». ⇒ блокирующий `Stop` внутри свипа (`:580`) даёт `ReconcileFailures++`,
отсрочку и через `StalledAfter` ЛОЖНУЮ тревогу оператору, плюс съеденную долю прохода для всех
остальных аккаунтов. ⚠ Прежняя формулировка оркестратора («свип встанет до двадцати минут») неверна
по механизму и исправлена актом `D39.236` п.5 по возражению этой смены.
**3. Повторный `stop` на юните в `deactivating` ИНЕРТЕН — ЗАМЕРЕНО, и это несущее свойство.**
Три стопа в t+0 / t+2 / t+4 против грейса 6 с: процесс получил **один** сигнал, юнит умер на
**t+6,04 с** (не t+10). То есть пере-выдача не шлёт второго сигнала и не перезапускает
`TimeoutStopSec`. ⭐ Ценность не в хорошей половине, а в том, что комментарий свипа
(`reconcile.go:576-579`) УЖЕ утверждает «asking again is free and idempotent», и это утверждение
держится на недокументированном поведении systemd, которое до этой смены никто не проверял.
⚠ И вторая половина того же комментария — «it is the only thing that closes the first case» —
**не выполняется**, если юнит уже начал останавливаться: пропущенный первый SIGTERM свип не чинит,
юнит стоит в `deactivating` до SIGKILL по грейсу (сегодня — до десяти минут прогона, который ничего
не делает, с зарезервированным холдом).
**4. Два SIGTERM подряд Go-процесс видит как ОДИН — ЗАМЕРЕНО, ратифицировано `D39.238`.**
gap 0 мс → оба сигнала увидены в **0 из 20** прогонов (в длинной серии слиплись 40 из 40);
gap 1 мс → **20 из 20**; буфер принимающего канала **8** — коалесценция живёт НИЖЕ канала, в
рантаймовом БИТЕ на сигнал. ⚠ Первый мой прибор давал «2 из 2» и подтверждал удобное: `kill` шёл из
шелла, и запуск двух процессов сам создавал зазор. Прибор чинен, число перевернулось.
Норма, выведенная из этого оркестратором: **замер, чей результат подтверждает удобное, проверяется на
ПРИБОР прежде, чем на предмет.**
**5. ⛔ `finish` рассчитывает деньги из ДО-ДРЕНАЖНОЙ выписки — ЛАТЕНТНО, но класс настоящий.**
`reconcile.go:955-958`: расчёт внутри `finish` объявлен оппортунистическим и получает тот самый `l`,
который проход прочитал ДО дренажа журнала. `finish` вручную освежает `l.Status`/`l.PausedReason`
перед вызовом (`:~918`) — то есть автор знал про класс и закрыл ровно два поля. **Любое число, которое
дренаж ЭТОГО ЖЕ прохода записал в строку попытки, для этого расчёта невидимо.**
⚠ Граница честности: на HEAD ничего не сломано — `settle` не читает из выписки ни одного поля, которое
пишет дренаж. Класс предъявлен исполнением только потому, что пак добавил такое поле: строка попытки
читала 9 оценочных строк, выписка — 0, и расчёт взял число, которое случайно знал запасной канал.
Поле ушло с паком, класс остался. Родственник в этом же файле уже стоил зоне дефекта: `freshRunState`
(`reconcile.go:~660`) написан ровно потому, что выписка не может знать, что записал её собственный дренаж.
**6. Обработчик `/stop` шлёт сигнал по имени юнита из СВОЕЙ ЖЕ выписки — прочитано в коде.**
`Service.Stop` (`reconcile.go:1541`) берёт `unit` из `RequestStop` и зовёт `Runner.Stop(ctx, unit)`.
Успел ли свип между этими двумя рестартовать попытку — сигнал уйдёт СТАРОМУ юниту. Самолечение есть
(следующий проход увидит `alive && StopRequestedAt != nil` и пере-выдаст стоп текущему, `:575-583`),
но приходит оно от СВИПА, а не от обработчика: окно равно одному тику, и всё это время пользователь
держит `202`, а прогон работает.
**7. Двойной клик по `/stop` — гонка по построению, сегодня безвредная.** `RequestStop`
(`internal/pgstore/runs.go:1298-1309`) — один UPDATE; под READ COMMITTED два одновременных нажатия
не различимы самим оператором, потому что `RETURNING` отвечает НОВОЙ строкой и не может отличить
собственную запись от чужой. Схлопывание в первый таймстамп делает оба нажатия одинаковыми, поэтому
вреда нет — ровно до той секунды, когда ответ или действие начнут зависеть от того, кто нажал первым.
Форма лечения, проверенная в отменённом паке: `select … for update` в одной транзакции с UPDATE.
**8. `stoppedOnRequest` сравнивает ДВОЕ РАЗНЫХ ЧАСОВ, и код это признаёт.** `reconcile.go:1137-1153`,
довод на `:~1000`: «the two clocks being compared are not even the same one — the marker carries the
host's, the intent the platform's». Сегодня цена ошибки — ЯРЛЫК. В любой конструкции, где от этого
ярлыка зависит ДЕЙСТВИЕ (перезапуск, возврат холда, возобновление), это гонка по построению.
**9. Каталог движка: потолка ожидания нет у пяти провайдеров из восьми — ЗАМЕРЕНО прибором.**
Программа на `config.LoadModels` + `Timeouts.Profile().DeadlineFor(B)` над снапшотом `git archive HEAD
backend` (ни байта записи в чужую зону): `attempt_max_s` задан у **3** (`deepseek` 1240 · `kimi` 1200 ·
`gemini` 1200), инертен у **5** (`zai` · `xai` · `openai` · `mistral` · `local`), и у них `DeadlineFor`
растёт с бюджетом линейно и сверху не ограничена. Максимум по каталогу при наибольшем бюджете
сегодняшней конфигурации (**35 200** ток. = редакторские `max(8000×2.2, 16000) = 17 600` с одним
удвоением по `regenerate_before_escalate: 1`) — **1240 с**, deepseek. Беcключевой `zai` перерастает его
при **43 400** ток., то есть при `edit_ceiling_out` ≈ 9 900 против сегодняшних 8 000 — одна строка
yaml, которая читается как ручка качества. Носитель — строка бэклога **383**.
**10. Dev и прод расходятся по грейсу в 20 раз, и это не объявлено нигде.**
`internal/ingest/supervisor.go:18``stopGrace = 30 * time.Second` (уезжает в `cmd.WaitDelay`, `:75`)
против `600 с` в проде (`internal/runner/runner.go:59`). Один и тот же движок против тех же платных
провайдеров получает на свёртку полминуты на dev-пути и десять минут на боевом.
**11. Пять открытых рядов реестра — про холды и остановку, и это один предмет, а не пять.**
Их печатает сам гейт зоны (`make check`, `internal/gates`, строка `ALARM PD-count: 15 open rows …
(baseline 15)`): **PD-418** (у settling-строки живого прогона нет ручки, а рантбук обещает обратное) ·
**PD-244** (единственный выход `settle`, оставляющий холд открытым МОЛЧА) · **PD-162** (книга с
пропавшим каталогом заклинивает прогон навсегда с открытым холдом) · **PD-465** (заявка на спавн
коммитится ДО подъёма юнита) · **PD-217** (незакрытый холд блокирует апгрейд движка бессрочно).
Под новым инвариантом это готовый предметный список, уже приоритизированный реестром.
**12. Что в дереве оказалось ЛУЧШЕ ожидаемого — и это тоже находка.** Гонка «кто пишет исход мёртвого
прогона» ЗАКРЫТА, и закрыта правильной формой: `FinishRun` возвращает `closed bool`, и `finish`
(`reconcile.go:903`, ветка `!closed` ~`:937`) разбирает случай «прогон уехал под этим проходом» явно;
второй расчёт получает `ErrNoReservation`, потому что резервация закрывается под `state = 'open'`.
Столкновение реконсайлера с `/stop` названо в трёх местах и в каждом закрыто: `finishStopped`
(`:678-685`), отказ рестарта по `ErrStopRequested` (`:1324`), пере-проверка заявки под замком в
`FinishUnspawnedStop` (`:~710`). ⇒ **модель «записать условно и прочитать вердикт» уже живёт в зоне**,
и новому инварианту стоит брать её образцом, а не изобретать другую.
**13. И одна развилка, которую новый инвариант обязан пересмотреть ЯВНО.**
`interruptedBySomeoneElse` (`reconcile.go:539`, функция `:1129`) ПЕРЕЗАПУСКАЕТ прогон по коду 5 без
записанного намерения. Это осознанное решение с записанным доводом (ребут против ручного
`systemctl stop`, асимметрия цен), а не дефект — но это главная развилка «правильного возобновления»,
и наследовать её молча нельзя.
### Числа смены
```
$ make check (все четыре гейта стенда закрыты: Postgres 55433 · движковый бинарь и шаблон из
`git archive HEAD backend` · достижимый пользовательский systemd · MemoryMax)
вход (до первой правки): MAKE-EXIT=2 · 19 `ok` · 1 FAIL · линтер «0 issues» · скипов 5
единственный красный — `internal/gates.TestTheAnnouncedContractVersionIsTheOneTheCanonRatified`
(«this build announces contract 0.13.0 and the ratified canon is 0.13.1»), НЕ по вине смены
скипы 5, условие у всех одно и названное: нет деплой-артефакта `configs/mining-contrast.zh.txt`
ПОСЛЕ ОТКАТА (последний прогон, снят после последней правки):
MAKE-EXIT=0 · 20 `ok` · 0 FAIL · линтер «0 issues» · скипов 5
`gofmt`/`go vet`/`sqlc diff` чисты · `ALARM PD-count: 15 open rows … (baseline 15)` — не сдвинут
красный входа ПОГАШЕН правкой константы: канон `0.13.1`, билд `0.13.1`
```
⚠ Гейты стенда закрыты все четыре намеренно: без `TM_PLATFORM_TEST_DSN` та же батарея печатает те же
`ok` и прячет ~370 тестов (эррата `08.09-д`). Счёт скипов печатается рядом со счётом `ok`.
### Что НЕ удалось
**Ничего из заказанного не сдано — пак отменён на середине, и это не оценка работы, а факт.**
Построенное и зелёное на момент отмены (грейс суммой с названными слагаемыми, `--no-block`, путь
второго сигнала через наблюдение `SubState`, режим в записи намерения с атомарной заявкой на второй
сигнал, чтение минора `1.4` из обоих каналов, пометка оценки на строке расчёта, живой systemd-стенд)
откачено целиком. Из адверсариального прохода §5.5 успели отработать три направления из шести —
(а) гонка двух нажатий, (б) остановка мёртвого юнита, (д) пере-выдача не должна эскалировать; (в)
чтение мягко остановленного прогона реконсиляцией, (г) идемпотентность `/stop` и (е) удлинённое окно
`deactivating` до отмены проверены не были. Мутационная проверка СВОИХ новых пинов — та, которую
норма зоны требует отдельным заходом, — не выполнена ни для одного из них: до неё смена не дошла.
Поэтому ни один пин выше в дереве не остаётся, и ни на один нельзя ссылаться как на проверенный.
## Состояние зоны на 08.09.2026
| Вопрос | Ответ |
|---|---|
| последний заленджённый пак | «разрез приёма до готовности и правда о себе» (08.09, `ddcbf0c`, акт **D39.229**), канон контракта `0.13.0`. ⚠ Обе величины берутся ПРИБОРОМ, а не отсюда: `git log --oneline -1 -- platform/` и `grep '^ version:' ../../docs/architecture/14-api-contract/openapi.yaml` — эта строка стареет, они нет. ⭐ И стареет она БЫСТРЕЕ, чем кажется: её уже правил этот пак по §4.5, и лендинг того же пака сделал её неверной снова — читай прибором, а не глазами |
| пак в дереве, не закоммиченный | дофикс по паку «разрез приёма» (две позиции, 08.09) — отчёт ниже |
| открытые дефекты | `DEFECT_REGISTER.md` (счёт — `python3 docs/scripts/counts.py` от корня) |
| нормы и приёмка | `ENGINEERING_STANDARDS.md` · направление — `PLATFORM_DIRECTION.md` · стек и стенд — `STACK_DECISIONS.md` |
| незакрытые куски работы | `../BACKLOG.md` (`П-N`) |
| как разворачивается | `../deploy/README.md` |
## ДОФИКС ПО ПАКУ «РАЗРЕЗ ПРИЁМА» — ДВЕ ПОЗИЦИИ (08.09, `textmachine-fa`, после лендинга `ddcbf0c`)
> Заказ оркестратора №23: он закрывал пробел в СВОЕЙ приёмке (норма требует двух верификаторов по
> сданной работе, он их не поставил и поставил задним числом), и слепой верификатор нашёл две вещи в
> моей зоне. Обе я пере-проверила прежде, чем чинить. Правило остановки объявлено заказчиком заранее:
> дофикса ровно два, дальнейшее — строка бэклога.
**1. Рукописная тройка в резерве — класс `D39.216` ВНУТРИ пака, нанятого его убрать.**
`cutTailReserve()` возвращал `3 * s.write()`, где тройка — ручной счёт записей, следующих за разрезом,
и жила она только в докстринге. Замер подтверждён мной: **0 хитов в тестах** при 3 хитах в не-тестах.
Четвёртая пост-разрезная запись — и резерв тихо недокрывает, а последствие называет ⛔-абзац моего же
`stepLeaving`: оборванная терминальная запись, книга в `parsing` без задания до свипа.
⇒ Выбрана вторая из двух названных форм — **тройка ПИНИТСЯ**, а не выводится. Довод против первой,
чтобы он не пропал: вывести резерв структурно значит дать прогулке список её оставшихся шагов, то есть
вернуть тот самый рукописный перечень одним уровнем ниже. Вместо этого — сверка с ДРУГИМ выражением
того же факта: `UploadSettle` уже собирает прогулку из разреза и четырёх записей, ровно одна из
которых (`StartParsing`) идёт ДО разреза, значит резерв обязан равняться остатку прогулки после выноса
разреза и этой одной записи. **Два выражения делят константы, но не маршрут** — потому это сверка, а
не тавтология.
Предъявлено посадкой: пятая запись, добавленная в `UploadSettle` и НЕ добавленная в счёт, красит
`TestTheCutsReserveIsTheWalkMinusTheCutAndTheWriteBeforeIt` — и он **ЕДИНСТВЕННЫЙ красный** на трёх
пакетах (`books`, `config`, `httpapi`), текстом: «the cut reserves 1m30s … but the walk (4m0s) has
2m0s left once the cut and the write before it are taken out».
**2. `PD-464` нёс ровно ту протухшую фразу, которую пак снял с трёх других рядов.**
Диспозиция говорила «(08.09, в дереве, статус флипает лендинг)» при колонке статуса `fixed` и
состоявшемся лендинге. Замер: рядов с этой фразой в файле был **ровно один — мой собственный**.
Приведена к лендингу (`ЗАЛАНДЁН ddcbf0c, акт D39.229`); теперь фразы в файле **0** при 465 рядах,
колонок ≠ 7 — **0**, `counts.py --check`**EXIT=0**.
⭐ **Класс, из-за которого это уцелело, стоит назвать: норма §4.5 сработала на ТРЁХ чужих рядах и не
сработала на ОДНОМ моём.** Ряд я писала сама и потому не подпала под собственную правку. Тот же класс
оркестратор поймал у себя часом раньше на строке 253. Общее правило: **проход по норме обязан включать
строки, которые этот же проход и создал** — иначе он чистит только унаследованное.
**Числа дофикса, сняты после последней правки кода:** `make check` со всеми четырьмя гейтами —
**MAKE-EXIT=0** · пакетов `ok` **20** · строк FAIL **0** · линтер «**0 issues**» · скипов **5** (условие
прежнее и названное). Моих файлов в дереве — **4, все в `platform/`**. ⚠ `git status` показывает **7**: остальные три
(`docs/BACKLOG.md`, `docs/ORCHESTRATOR_SESSION_PROMPT.md`, `docs/architecture/05-decisions-log.md`) —
живая работа оркестратора в ЕГО зоне, идущая параллельно. Не мои и не тронуты; называю их, чтобы «все в
моей зоне» не читалось как «в дереве больше ничего нет». Якоря дофикс НЕ сдвинул: 7 проблемных на
`HEAD` и 7 у меня, новых ноль (замер дифференциальный, как в основном отчёте).
⚠ Код выхода взят на этот раз ВЕРНО и подтверждён вторым источником: в прошлый раз я написала
`echo "MAKE-EXIT=$?"` после подстановки `$(git rev-parse …)`, и `$?` ловил код `git`, а не `make`
напечатанный ноль не значил ничего. Теперь `st=$?` стоит сразу за `make`, и рядом вердикт самой цели:
`make check` СОХРАНЯЕТ `.check.log.<pid>` при провале и удаляет при успехе, лога нет.
**Попутно — прибрала свой мусор на общем стенде.** Убитые прогоны (в том числе мой, когда я гасила
зависший пакет) оставили в общем Postgres **34** скретч-базы ≈9,5 МБ каждая. Удалены все 34, отказов
**0**, осталось **0**. ⚠ Инструмент выбран самоохраняющийся: обычный `drop database` БЕЗ `with (force)`
— он ОТКАЖЕТ, если между листингом и дропом кто-то подключился, вместо того чтобы выдернуть базу из-под
чужого прогона. Верификатор не стал их трогать именно из-за этого риска, и это была верная осторожность.
## ПАК «РАЗРЕЗ ПРИЁМА ДО ГОТОВНОСТИ И ПРАВДА О СЕБЕ» — ОТЧЁТ (08.09, `textmachine-fa`)
> Промт `docs/PLATFORM_INTAKE_TRUTH_SESSION_PROMPT.md`, вход HEAD `3f4680c`, дерево на входе чисто.
> Зона НЕ коммитит — дерево передано оркестратору №23 (`textmachine-a8`). Пак $0, платных вызовов **0**.
> **Работа завершена, править не планирую.** Сказано ПОСЛЕ адверсариального круга, а не до него: первая
> редакция этого отчёта несла ту же фразу при шести неисправленных дефектах, введённых этим паком.
> Дерево — 26 файлов, все в `platform/` (`git status --porcelain -- platform/`), 23 правленых и 3 новых.
### Исход по каждому пункту §4
| Пункт | Исход |
|---|---|
| §4.1 ограничитель параллелизма | **сделано**; форма — `x/sync/semaphore` на общей точке порождения, ожидание с деградацией в очередь. Предъявлено НАГРУЗКОЙ |
| §4.2 бюджет хвоста из кода | **сделано, форма Б (структурная)**; попутно вскрыт и закрыт ЧЕТВЁРТЫЙ промах суммы — квитанция не входила в неё |
| §4.3 рантбук | **сделано**; правок рантбука ДВЕ, вторая объявлена ниже с доводом |
| §4.4 три места неразличимого сбоя | **сделано все три**, каждое своим лечением; свип вылечен БЕЗ миграции |
| §4.5 правда о себе | **сделано**: шапка, два ряда флипнуты, маркер третьего починен, черты заэкранированы |
| §4.6 честная причина человеку | **закрыто по построению на моей стороне** — и ПРЕМИСА пака при этом опровергнута замером (ниже) |
| §4.7 живой гейт | **рецепт исполнен и РАБОТАЕТ**; довод зоны опровергнут, разрез впервые встретился с настоящим движком |
| §4.8 п.2 (комментарий `cutNow`) | **взят вместе с §4.4**, как и предписано |
| §4.8 п.4 (мёртвый `enqueue`) | **взят вместе с §4.1**: предикат сведён в одно названное место |
| §4.8 п.6 («ВСЕГДА» контракта) | **не беру — пинг оркестратору** с моим выбором из двух (ниже) |
| §4.8 остальное | **не делаю**, как объявлено паком |
| ⚠ сверх пака | константа контракта `0.12.0``0.13.0` — красное на входе, взято по явному указанию оркестратора (ниже) |
### Что стало с деревом — находка → что сделано → чем предъявлено
| Находка | Что стало с деревом | Чем предъявлено |
|---|---|---|
| §4.1 у синхронного входа нет ограничителя параллелизма | `internal/books/limit.go`: потолок на `semaphore.Weighted`, взводится в `s.manifest` — ОДНОЙ строке, через которую к движку идут все три входа. Дефолт — `DefaultMaxCuts = jobs.DefaultWorkers` (4), то есть число, подо что хост уже рассчитан, и носитель у него ОДИН. Конфиг `TM_PLATFORM_MAX_CUTS`; ноль отвергает читатель чисел (`loader.number`), а не отдельная проверка интейка. Наблюдаемость: 3 гейджа + 2 счётчика, публикует существующий телеметрический проход | `TestTheHostRunsNoMoreCutsAtOnceThanItsCapAllows` — 6 загрузок при потолке 2, пик **2**, и это НЕ вакуум: тест сперва дожидается контрольной величины «4 из 6 стоят в очереди» из счётчиков самого потолка · парный `TestWithRoomForEveryCutTheHostRunsThemAllAtOnce` — та же нагрузка при потолке 6 даёт пик **6** (иначе первый тест проходил бы и на фикстуре, где ничего не совпало по времени) · посадка M4 |
| упор в потолок не должен стоить пользователю загрузки | ожидание, а не отказ: не дождался ⇒ `errNotConclusive``201 parsing`, дорезает очередь. На очередном пути — claim обратно, НОЛЬ потраченных попыток и `river.JobSnooze`, то есть задание возвращается, не тратя единственную попытку (`giveBack` + `jobs.ErrTryAgainLater`) | `TestAnUploadThatRunsOutOfBudgetWaitingForASlotIsAcceptedRatherThanRefused` · `TestAnUploadTheHostCouldNotCutStillLeavesSomebodyToFinishTheBook` (claim ОТДАН и задание ПОСТАВЛЕНО — то, чего первая редакция не утверждала) · `TestAQueuedParseThatCannotCutSpendsNothingAndGivesTheBookBack` (оба исхода: упор в потолок и нехватка бюджета) · `TestAPassThatEstablishedNothingGetsItsJobBackInsteadOfSpendingIt` · посадки M9, N7, N8 |
| §4.2 граница хвоста выводилась руками и трижды была неверна | ОДИН отсоединённый дедлайн на весь хвост (`walk`), каждый шаг берёт `min(свой бюджет, остаток)` (`step`). Шаг, добавленный завтра, границу не двигает ПО ПОСТРОЕНИЮ | `TestNoStepOfAnUploadsTailOutlivesTheWalk` — в т.ч. **50** вложенных шагов, каждый просит час · `TestTheCutOfAnUploadIsBoundedByTheWalkAndNotByItsOwnBudget` (по дедлайну, который движок РЕАЛЬНО получил, а не по секундомеру) · `TestAStepOutsideAWalkKeepsItsOwnBudgetAndSurvivesItsCaller` · посадки M1, M2 |
| ⚠ ЧЕТВЁРТЫЙ промах той же суммы, найден моей же посадкой | квитанция идемпотентности (10 с) писалась ПОСЛЕ `Accept` и в сумму не входила ⇒ хвост был длиннее объявленного ровно на неё. Квитанция стала ТЕРМИНОМ: `UploadSettle = CutBudget + 4*writeBudget + ReceiptBudget` = **220 с** (ровно замер 07.09), носитель величины ОДИН — `books.ReceiptBudget`, тратит её `httpapi.settleCtx` | `TestTheWalkLeavesTheReceiptItsShareOfTheSettleBudget` · `TestTheReceiptSpendsTheShareTheIntakeSetAsideForIt` (httpapi) · посадки M3, M7'' |
| §4.3 рантбук молчал про таймаут ОТВЕТА прокси | `deploy/README.md`: молчание названо числом (220 с), требование к прокси — `TM_PLATFORM_UPLOAD_DEADLINE + 220 с` = 13 мин 40 с на дефолтах, цена ошибки названа (человек получает ошибку на ПРИНЯТОЙ книге и повтором делает вторую) | директивы сверены с вендор-доками ЭТОЙ сессией: nginx `proxy_read_timeout`, дефолт **60 с** (nginx.org, ngx_http_proxy_module) · HAProxy `timeout server` (docs.haproxy.org 3.0, индекс ключевых слов) |
| §4.4 `ClaimParse` в `cutNow` молчал о сбое БД | гонка и «хранилище не спросили» разведены: первая — INFO, вторая — ERROR, и текст называет следствие (книга `parsing` без задания до свипа) | `TestTheIntakeTellsALostRaceApartFromAStoreItCouldNotAsk`обе фикстуры, и каждое сообщение проверено ОТСУТСТВУЮЩИМ в чужой |
| §4.4 ветвь `RowsAffected()==0 ⇒ задание НЕ ставить` не запинена | пин на ОБЕ стороны: чужой claim ⇒ задания нет и чужой claim цел; свой ⇒ задание есть и claim снят | `TestOnlyAClaimThatWasReallyGivenBackQueuesTheJobThatFinishesTheBook` · посадка M6' красная ТЕКСТОМ про задание |
| §4.4 свип `StuckIntake` считает от `added_at` (штамп ДО тела) | ⭐ вылечено БЕЗ миграции и без колонки: бутовый гейт сверял дедлайн с `min(UploadGrace, ClaimStale)`; добавлено ТРЕТЬЕ окно `books.ClaimGrace` (20 мин — самое узкое). Условие «свип забирает claim у идущей загрузки» стало недостижимо настройкой | посадка M5 красная текстом «does not fit the parse claim's grace» + `TestEveryWindowAnUploadMustFitInsideIsActuallyConsulted`. ⚠ **Исправление к первой редакции этой строки:** я написала, что у каждого из трёх окон свой случай, недостижимый двум другим — это БЫЛО НЕВЕРНО и найдено адверсариальным проходом. `ClaimGrace` сегодня самое узкое, поэтому все пять случаев ловит один терм, и два других можно было удалить из гейта при зелёной батарее. Вылечено выносом выбора окна в `intakeWindow(...)`, который тест кормит значениями, делающими каждое окно самым узким по очереди |
| §4.8 п.2 комментарий `cutNow` лгал о том, кто ставит задание | снят тем же движением, что и правка ветви | `grep -rn "the queue job is already enqueued" platform/ --include=*.go`**0** при 200 осмотренных `.go` (единственный хит по дереву — цитата находки в этом журнале, и она историческая) |
| §4.8 п.4 предикат «кто ставит задание» размазан по двум местам | `cutsItsOwnUploads()` — одно названное место, читают оба; doc параметра `StartParsing` больше не выдаёт его за штатный путь | `grep "s.Engine != nil" internal/books/*.go` (без тестов, 4 файла) → **1 хит, и он внутри самого предиката**. ⚠ Рядом остаётся `s.Engine == nil` в `s.manifest` — это НЕ тот предикат, а nil-гард самого вызова, и он не решает, кто ставит задание |
| §4.5 шапка журнала лгала о том, где зона | пере-снята прибором: последний пак «деньги и правда» `fda0679`, коммитов в `platform/` после него 0, канон `0.13.0`; в шапку вписаны КОМАНДЫ, которыми числа берутся | `git log --oneline fda0679..HEAD -- platform/ \| wc -l`**0** · `grep '^ version:' openapi.yaml``0.13.0` |
| §4.5 три ряда `open` при легшем лечении | `PD-424` и `PD-438``fixed`; у `PD-441` починен МАРКЕР, статус оставлен `open` и в ячейке названо, что держит его движковая половина (строка **331**). Фраза «в дереве» снята во всех трёх | `grep -c 'статус флипает лендинг'`**0** при 465 рядах |
| §4.5 незаэкранированные `\|` | заэкранированы в трёх рядах (`PD-375`, `PD-422`, `PD-197`) | эскейп-аware счёт колонок: рядов с числом колонок ≠ 7 — **0** из 465 |
| `PD-464` (строка регистра, моя зона) | закрыт ОБЕИМИ половинами и переведён в `fixed` с диспозицией | см. ячейку ряда |
### §6 ось 4: существующее ПРЕЖДЕ велосипеда — что рассмотрено и чем отвергнуто
| Кандидат | Исход | Довод |
|---|---|---|
| **`golang.org/x/sync/semaphore`** | **ВЗЯТ** | `Acquire(ctx, 1)` — ровно нужная семантика: ждёт до слота или до конца контекста, очередь **FIFO** (в отличие от буферизованного канала, где поздний может обогнать раннего и часть загрузок ждала бы весь бюджет). `TryAcquire` у него не «барджит»: `success := s.size-s.cur >= n && s.waiters.Len() == 0` — быстрый путь отказывает, пока список ожидающих непуст, и слот у стоящих в очереди не ворует. ⚠ Прочитано в ИСХОДНИКЕ пинованной версии (`$(go env GOMODCACHE)/golang.org/x/sync@v0.22.0/semaphore/semaphore.go`, `TryAcquire`), а не по памяти: на этом свойстве держится довод про FIFO. Уже был в `go.sum` **косвенной** зависимостью той же версии `v0.22.0`; правка `go.mod` — перевод в прямые, БЕЗ смены версии (`git diff platform/go.mod`: одна строка вверх, одна вниз; `go.sum` 1 строка) |
| `errgroup.SetLimit` | отвергнут | ограничивает горутины, которые запускает САМА группа. Здесь группы нет и быть не может: вызывающие независимы и приходят из разных мест (HTTP-обработчик, воркер River, свип). Форма не подходит по существу, а не по вкусу |
| `netutil.LimitListener` | отвергнут | ограничивает СОЕДИНЕНИЯ на слушателе — то есть весь API разом, включая чтения, листинги и логин, из-за нагрузки на приём. И не накрывает ни воркера, ни свип: это ровно «потолок в маршруте», который пак запрещает |
| `MaxWorkers` очереди (уже стоит) | отвергнут как ЕДИНСТВЕННОЕ средство, но учтён как число | ограничить синхронный вход им нельзя: этот путь намеренно НЕ ставит задание, чтобы воркер не гонялся с разрезом за claim. Зато он назвал дефолт: 4 — то, подо что хост уже рассчитан, и потолок сказан один раз для всех способов запустить движок, а не только для того, что идёт через очередь |
| самописный счётчик / буферизованный канал | отвергнут | норма зоны «stdlib или устоявшаяся библиотека прежде своего», и здесь у своего есть конкретная цена — отсутствие FIFO |
### Посадки мутаций — вердикт по ТЕКСТУ падения, а не по цвету
Копия дерева с каноном (`cp -a --parents platform docs/architecture/14-api-contract`), базовая линия копии зелёная на всех четырёх пакетах.
| Посадка | Вердикт | Текст, по которому он вынесен |
|---|---|---|
| M1 разрез снова отсоединён от хвоста | **RED** | `the cut was granted 1m29.99s inside a 700ms walk` |
| M2 `step` перестаёт капать по хвосту | **RED** | `the walk is not capping it` + `adding a step moves the boundary` |
| M3 хвост съедает долю квитанции | **RED** | `want UploadSettle (3m30s) less the receipt's share (10s)` |
| M4 потолок не применяется вовсе | **RED** | контрольная величина легла в ноль: `{Limit:2 InFlight:0 Waiting:0 Waited:0 GaveUp:0}` |
| M5 гейт забывает окно claim'а | **RED** | `an upload deadline of 21m0s was accepted, though ... does not fit the parse claim's grace` |
| M6 release ставит задание, ничего не вернув | **RED** | `a release that gave back nothing still queued a job (2 in total)` |
| M7 значение `ReceiptBudget` изменено | ⚠ **ВЫЖИЛА, и это ВЕРНЫЙ исход** | пере-сайзинг остаётся согласованным по обе стороны шва; ловить надо не значение, а ДРЕЙФ — см. M7'' |
| M7'' квитанция возвращается к своему литералу | **RED** (после того, как по находке M7 заведён пин) | `the receipt was given 30.0s, want the share the intake declared for it (10s)` |
| M9 занятый хост тратит попытку книги | **RED** | `a queued parse that found no slot answered <nil>, want the cap` |
| M10 два способа не получить claim свёрнуты обратно в один молчаливый `return` | **RED** | `losing the race said nothing` + `a claim that could not be asked for said nothing, so the book sits `parsing` with no job and nobody knows` + `was not logged at ERROR` |
**ТРЕТИЙ круг посадок — по коду, ПЕРЕПИСАННОМУ после адверсариального прохода.** Первые две редакции
двух пинов оказались тавтологичны, и посадка это показала, а не рассуждение.
| Посадка | Вердикт | Текст |
|---|---|---|
| N1 у ожидания удалена строка INFO | **RED** | `a cut that queued for a slot and got one said nothing` |
| N2 переименована причина `host_at_cut_capacity` | ⚠ ВЫЖИЛА → **N2 RED** | первая редакция пина сверяла КОНСТАНТУ с самой собой и проходила при любом значении; переписана на литерал: `the reason is "MUTATED", want the stable "host_at_cut_capacity"` |
| N4 два гейджа потолка поменяны местами | **RED** | `the exposition is missing "tm_platform_cuts_in_flight 4"` |
| N5 из проводки выброшен `MaxCuts` оператора | **RED** | `MaxCuts is 0, want the operator's 7: TM_PLATFORM_MAX_CUTS does nothing` |
| N6 `cutsItsOwnUploads` всегда истинен | ⚠ ВЫЖИЛА → **N6 RED** | счёт заданий РАЗЛИЧИТЬ НЕ МОЖЕТ (сломанный предикат ставит то же одно задание через релиз); переписано на лог с положительным контролем: `a deployment with no engine attempted a cut anyway` |
| N7 разрез перестаёт оставлять хвосту резерв | **RED** | `the intake's parse claim was NOT given back` + `the queue was handed 0 jobs` + `has spent 1 attempts` |
| N8 `giveBack` перестаёт возвращать задание очереди | **RED** | `the error does not tell the queue to bring the job back, so the single attempt is spent` |
| N9 разрез запускается даже когда места на него нет | **RED** | `a cut was started with no room for it (1 calls)` + `the upload is "not_started", want ` + `the pass does not say WHY it did not cut ("no_time_to_cut")` |
**Единственная выжившая, которую я НЕ чиню и объявляю:** смена значения `DefaultMaxCuts`. Это
сайзинг, а не свойство: изменённый потолок остаётся согласованным по всей системе, и «поймать» его
можно было бы только пином на литерал, то есть запретом менять число. Что запинено — ПРОВОДКА
(оператор получает своё число) и ЕДИНСТВЕННОСТЬ носителя (`DefaultMaxCuts = jobs.DefaultWorkers`).
**Первая редакция M6 и M7 НЕ КОМПИЛИРОВАЛАСЬ** (`declared and not used: tag`, `imported and not used`). Это не вердикт, а его отсутствие: посадка, которая не собралась, красит батарею по причине, не имеющей отношения к предмету. Обе пере-посажены компилирующимися.
### Классы и знаменатели — «закрыт в N из M», M посчитан командой
| Класс | Знаменатель | Как посчитан |
|---|---|---|
| входы, порождающие процесс движка на разрезе | **3 из 3** (интейк · `parseWorker` · свип `Sweep`) | `s.manifest` имеет РОВНО ОДНОГО вызывающего (`grep -rn 's\.manifest(' --include=*.go internal/ \| grep -v _test` → 1: `parse.go:178`), у `parseClaimed` их два (`Parse`, `cutNow`), у `Parse` — воркер `jobs.go:117` и `Sweep`. Все три сходятся в одну строку |
| порождения движка ВНЕ потолка | **1**`internal/readmodel/readmodel.go:156` | `grep -rn '\.Manifest(' --include=*.go \| grep -v _test` → 2 вызывающих, один из них мой. Оставлен снаружи сознательно, довод — ниже |
| шаги хвоста под общим дедлайном | **все**`Accept` их 5 + квитанция) | построением, а не перечнем: `writeCtx` идёт через `step`, `step` капает по хвосту. Проверено на 50 шагах, которых в коде нет |
| ряды регистра с диспозицией «статус флипает лендинг» | **3 из 3** | `grep -c 'статус флипает лендинг'` → было 3, стало **0** |
| ряды с числом колонок ≠ 7 | **3 из 3** | эскейп-аware счёт: было 3, стало **0** при 465 осмотренных |
| открытые ряды регистра по МОИМ файлам | 10 путей осмотрено, совпадений — 20 рядов, из них МОЙ предмет **1** (`PD-464`, закрыт); остальные 19 — соседние классы, не тронутые этим паком | `grep` по ПОЛНЫМ путям (норма §3 п.8), контроль: открытых рядов всего **109** |
### Числа и команды — сняты ПОСЛЕ последней правки
```
$ python3 docs/scripts/counts.py --check → EXIT=1, и это ОЖИДАЕМО: ровно два расхождения,
✗ docs/PROGRESS.md: «открытых рядов регистра платформы — 112» против пере-счёта 110
✗ docs/PROGRESS.md: «... (major 3)» против пере-счёта 1
оба литерала — в ЧУЖОЙ зоне (`docs/PROGRESS.md`), туда не лезу. После лендинга и закрытия
PD-464 верные числа: **открытых 109, major 1, minor 36, info 72** (`counts.py` по дереву).
$ git log --oneline fda0679..HEAD -- platform/ | wc -l → 0
$ grep '^ version:' docs/architecture/14-api-contract/openapi.yaml → 0.13.0
$ go list ./... | wc -l → 20 (носитель скипов чинен первым движением)
$ TM_PLATFORM_TEST_DSN=… TM_PLATFORM_TEST_ENGINE_BIN=… TM_PLATFORM_TEST_BOOK_TEMPLATE=… \
TM_PLATFORM_TEST_PGDUMP=… TM_PLATFORM_TEST_PGRESTORE=… make check
MAKE-EXIT=0 · пакетов `ok` **20** · строк FAIL **0** · линтер «0 issues» · скипов **5**
⚠ Прогон ПОСЛЕДНИЙ — после адверсариального круга и после последнего добавленного пина. Прежние
редакции этих чисел (до круга) в отчёте не оставлены: они были верны и уже не про этот код.
```
**Скипы 5, и условие у всех одно, названное** (§10 ниже): нет деплой-артефакта
`backend/configs/mining-contrast.zh.txt`. Из четырёх гейтов батареи на этом хосте закрыты все:
Postgres · движковый бинарь + шаблон (собран из `git archive HEAD backend`, §4.7) · достижимый
пользовательский `systemd` · `MemoryMax` — судится самим `TestARunIsBoundedByItsOwnCgroup` (`STACK_DECISIONS` §«Гейты батареи»: прямой пробы у этого условия нет), и он ОТРАБОТАЛ: в полном перечне скипов, который печатает `check`, его нет, а `FAIL` в прогоне нет вовсе.
⚠ Числа Go-батареи сняты ПОСЛЕ последней правки кода; доковые правки после них Go-батарею не касаются.
### Живой гейт (§4.7) — довод зоны опровергнут ИСПОЛНЕНИЕМ
Зона писала, что второй гейт батареи требует `$0`-пайплайна рядом с `backend/prompts/`, то есть записи в
чужую зону или полной копии дерева. **Проверено исполнением — неверно.** Рецепт:
```
$ git archive HEAD backend | tar -x -C $W # снапшот чужой зоны, ни байта записи в неё
$ cd $W/backend && go build -o $W/tmctl ./cmd/tmctl
$ sed -e 's|pipeline: ../configs/...|pipeline: $W/backend/configs/pipeline-c1.yaml|' \
-e 's|models: ../configs/...|models: $W/backend/configs/models.yaml|' \
$W/backend/example/book.yaml > $W/template.yaml # пути абсолютные
$ TM_PLATFORM_TEST_ENGINE_BIN=$W/tmctl TM_PLATFORM_TEST_BOOK_TEMPLATE=$W/template.yaml go test ./internal/books/
```
`TestTheRenderedConfigurationIsOneTheEngineActuallyLoads`**PASS**.
**И где именно рассуждение зоны свернуло не туда:** «нужен `$0`-пайплайн» верно для теста
`internal/runner`, который гоняет `translate` и падает на `missing API keys`. Оно было ОБОБЩЕНО на гейт
целиком — а `manifest` есть `$0`-глагол и ключей не требует по `D20.4`, поэтому боевой `pipeline-c1.yaml`
(«платный») загружается и режет без единого ключа. То есть довод был верен про один тест и ложен про гейт,
и разница видна только исполнением.
### ⚠ Правки, вызванные заказанной сменой поведения (объявляю по `D39.183`)
1. **`TestAnUploadDeadlineIsRefusedUnlessTheWholeUploadFitsTheTighterWindow``...TightestWindow`.** Гейт
получил третье окно (§4.4, п.7 десятки) — прежний тест утверждал, что дедлайн `26m29s` ПРИНИМАЕТСЯ, и
после лечения это неверно. Тест не «починен под зелень»: он пере-написан строже — у каждого из трёх окон
свой случай, недостижимый двум другим, и посадка M5 краснит его именем нового окна.
2. **`ClaimGrace` экспортирована** (была `claimGrace`) — переименование затронуло 3 тестовых файла зоны
механически; утверждений не тронуто. Основание — то же, по которому экспортирована `UploadGrace`: бут
обязан отказывать конфигурации, которая её нарушает.
3. **Вторая правка рантбука сверх §4.3** — абзац про `TM_PLATFORM_MAX_CUTS`. Довод: §4.1 требует, чтобы
потолок был «конфигурируемым и наблюдаемым», а ручка, о которой рантбук молчит, оператору не доступна;
документировать ручку, которую этот же пак и завёл, — часть §4.1, а не «остальное про выкат».
### ⚠ Константа контракта `0.12.0` → `0.13.0` — что было сломано и кем
Батарея была КРАСНОЙ на входе, до единой моей правки: `internal/gates`
`TestTheAnnouncedContractVersionIsTheOneTheCanonRatified` — «this build announces contract 0.12.0 and the
ratified canon is 0.13.0». Улика: файл-носитель (`internal/httpapi/capabilities.go:36`) в моём диффе
отсутствовал (`git diff --name-only HEAD | grep -i contract` → пусто). Канон увёл на `0.13.0` коммит
оркестратора `3d90943`, константу за собой не потянув; последняя правка константы — `6ceb133`, до него.
То есть гейт красен с момента ратификации, **сутки**, и заметила это входная сверка следующей сессии зоны.
Взято мной по ЯВНОМУ указанию оркестратора с названным основанием: ратифицированный порядок `D39.208` п.1
— код первым с честно красным гейтом, канон вторым; здесь порядок был обратный, и правка возвращает мир к
гейту, а не гейт к миру (`D39.183`, обслуживание). **Авторство ошибки — оркестратор, не прошлый пак:** на
`fda0679` канон и константа обе были `0.12.0`, гейт был зелёным, и число батареи в акте `D39.221` честное.
### §4.6 — ответ (а): закрыто по построению НА МОЕЙ СТОРОНЕ, но премиса пака опровергнута
Пункт 10 десятки предполагал, что причина отказа не доезжает. **Опровергнуто:** перечислены ВСЕ семь
пользовательских отказов приёма — `payload_too_large` и `request_timeout` (корневые коды), `malformed` ×2,
`unsupported_pair` ×2, `no_book`, `no_chapter_structure`, `too_long`, `missing_or_late`. Причины, не
выразимой перечислимым кодом, я не нашла; расширять `errors[]`/`cause.code` нечем, и минор не нужен.
**Собственную первую находку снимаю:** я решила, что слишком длинное поле формы уходит с ПУСТЫМ
`errors[]` — неверно, оно названо на месте чтения (`v0.go:843`, `ItemTooLong`), а ветвь `Invalid(w, r)` без
элемента до него не доходит.
**А вот премиса пака про клиента ЗАМЕРОМ НЕ ПОДТВЕРЖДАЕТСЯ, и следующая смена не должна её унаследовать.**
Пак пишет: «Таблица „код → русская фраза“ у клиента уже есть (`14-api-contract/README.md`, и фронт её
рисует)». Замер: `no_chapter_structure` в живых доках — **11 хитов при 1104 осмотренных `.md`**, и НИ ОДИН
не таблица фраз; в `14-api-contract/README.md` нет ни `no_book`, ни `no_chapter_structure`. В зоне фронта
`no_book`/`unsupported_pair`**0 хитов при 8114 осмотренных `.ts`/`.tsx`**. Что там есть на самом деле —
правило «клиент диспетчеризует по СТАТУСУ и показывает одну нейтральную фразу» (README §вход) и решение
владельца 16.08 о машинном коде. ⇒ вывод пака (моя работа тут закончена, клиентская половина — фронт, а он
заморожен) остаётся ВЕРНЫМ, но не потому, что таблица есть, а потому, что платформа дала клиенту всё, по
чему её можно нарисовать. Разница существенна: с премисой пака работа выглядит сделанной у обеих сторон.
### Пинги оркестратору
1. ⛔ **Литералы в `docs/PROGRESS.md` под гардом `counts.py --check` протухли моим флипом — двигать их
тебе.** После лендинга верно: **открытых 109, major 1** (было «112 (major 3)»). Разница в три ряда:
`PD-424` и `PD-438` переведены в `fixed` по твоему же маркеру, `PD-464` закрыт этим паком. Гейт красен
ОЖИДАЕМО и ровно на этих двух строках — других расхождений он не даёт.
2. **§4.8 п.6, «ВСЕГДА» в дельте контракта — моё мнение, как просил пак: нужна ОГОВОРКА В КАНОНЕ, а не
структурная гарантия.** Довод из кода, а не из вкуса. Между `FinishParse` и `ReadBook` свип
материализатора может взять долг (он записан ИМЕННО `FinishParse`) и дописать строке `source_chars` и
`structure` (`readmodel.refresh``SaveStructure`). Но он НЕ МОЖЕТ ни снять вердикт разреза, ни
изменить `status`/`chapter_count`: единственный писатель `reject_reason``RejectBook`
(`pgstore/books.go:394`, один хит по всему коду), а статус двигают только `FinishParse`/`reject`.
⇒ расхождение возможно только В СТОРОНУ БОЛЬШЕГО: ответ либо уже несёт поверхностные поля, либо ещё
нет. Структурная гарантия потребовала бы держать что-то поперёк `FinishParse``ReadBook` на горячем
пути ради полей, которые клиент всё равно перечитывает карточкой. ⚠ И отдельно: **саму фразу «ВСЕГДА» я
в каноне не нашла** — `grep 'ВСЕГДА' 14-api-contract/README.md` даёт 0, `grep -i always` в
`openapi.yaml` — 12 хитов, все про другое (SSE-кадры, `about:blank`). Назови предложение адресом, и
если оно живёт не там, где я искала, мой довод надо перепроверить против него.
3.**Премиса пака в §4.6 неверна — см. секцию выше.** «Таблица код → русская фраза у клиента уже есть, и
фронт её рисует» замером не подтверждается (0 хитов `no_book`/`unsupported_pair` при 8114 осмотренных
`.ts`/`.tsx`; в `14-api-contract/README.md` ни `no_book`, ни `no_chapter_structure`). Вывод пака устоял,
основание — нет. Стоит поправить, иначе следующая смена унаследует «у клиента всё готово».
4. **Мелкая неточность адреса в §4.7:** «а 35 строками ниже в том же журнале лежит рецепт снапшота» —
реально 261 строкой ниже. Адреса в дереве, которое я сдаю: довод — `platform-PROGRESS.md:452`, рецепт —
`:713` (на входном `HEAD` это были `:173` и `:434`; расстояние то же). На существо не влияет: рецепт там
и есть, и он работает.
5. **Твой вопрос «есть ли дешёвый способ закрыть сутки красноты между ратификацией и следующей сессией
зоны» — есть, и он в ТВОЕЙ зоне.** Пара «канон ↔ объявленная константа» сегодня судится только Go-тестом,
который гоняет зона. А `docs/scripts/counts.py --check` уже читает оба дерева, уже висит на зонном
pre-commit и уже срабатывает **именно на коммитах с D-логом или PROGRESS** — то есть ровно на
ратификационных. Добавить туда одну проверку — `grep '^ version:' openapi.yaml` против
`const ContractVersion` в `platform/internal/httpapi/capabilities.go` — стоит десятка строк и ловит
ровно тот класс, который стоил суток: он предупреждает того, КТО ДВИГАЕТ КАНОН, в момент движения.
Заказом не делаю (файл в `docs/`), рекомендацию записываю.
### Адверсариальный проход по СВОЕЙ готовой работе — восемь находок, и они были настоящие
Проход заказан §5.4 и выполнен субагентом (author ≠ reviewer) по готовому диффу, с направлением на
классы, которые уже стоили зоне денег. **Круги НЕ сошлись с первого раза: он нашёл восемь, и шесть из
них — дефекты, которые ввёл ЭТОТ пак.** Каждую я пере-проверила по коду прежде, чем чинить.
| # | Находка | Чем оказалась | Что сделано |
|---|---|---|---|
| **F1** | комментарий `giveBack` обещал повтор задания с бэкоффом | ⛔ ЛОЖЬ: `ParseArgs.InsertOpts``MaxAttempts: 1`, повтора нет вовсе; книга на занятом хосте ждала свип **20 минут** | заведён `jobs.ErrTryAgainLater`; воркер переводит его в `river.JobSnooze(RetryDelay)`, который НЕ тратит единственную попытку. Комментарий приведён к правде. Пин — `TestAPassThatEstablishedNothingGetsItsJobBackInsteadOfSpendingIt` |
| **F2** | у выигравшего слот не проверялось, осталось ли время на разбор | ⛔ настоящий: слот, выигранный в конце бюджета, отдавал движку миллисекунды; убитый процесс читается как `parser_unavailable`, а он ТРАТИТ попытку — пять таких удаляют файл пользователя | `takeCutSlot(ctx, reserve)`: `worthStarting` до и ПОСЛЕ ожидания, `waitCtx` обрывает ожидание на резерв раньше. Резерв — `CutBudget` на очередном пути, `0` на интейке (там попытка не тратится) |
| **F3** | отказ бута при `MaxCuts < 1` | ⛔ МЁРТВЫЙ КОД: `l.number` уже отвергает всё непозитивное, и мой тест пинил чужой охранник, а не мой | ветвь удалена; в тесте названо, ГДЕ живёт отказ |
| **F4** | тест трёх окон | ⛔ ВАКУУМЕН для двух окон из трёх, и его комментарий утверждал обратное — ровно тот класс, который он якобы чинил | выбор окна вынесен в `intakeWindow(...)`; новый тест делает каждое окно самым узким по очереди. Ревьюер пере-мутировал независимо: теперь красный |
| **F5** | терминальная запись могла родиться истёкшей | ⛔ настоящий и злой: при спетом хвосте claim НЕ отдавался и задание НЕ ставилось — книга «принята», а доделать её некому 20 минут. Мой тест этого не утверждал | `stepLeaving` + `cutTailReserve`: слабину забирает РАЗРЕЗ, а не записи. Пин — `TestAnUploadTheHostCouldNotCutStillLeavesSomebodyToFinishTheBook` |
| **F6** | комментарий потолка обещал больше, чем потолок делает | верно: `readmodel` порождает те же процессы мимо него; плюс `DefaultMaxCuts` был вторым литералом числа воркеров | комментарий сужен до правды и называет, что осталось снаружи; `DefaultMaxCuts = jobs.DefaultWorkers` — один носитель, и `config.Runner.Workers` берёт его же |
| **F7** | шесть поверхностей пережили мутацию | верно все шесть | закрыты пинами (ниже), кроме значения `DefaultMaxCuts` — это САЙЗИНГ, и его смена не дефект; названо в §10 |
| **F8** | баннер `capabilities.go` противоречил себе | верно, и сломала его Я этой же сменой | баннер разводит два порядка: код первым (канон отстаёт) — ратифицированный, канон первым (код отстаёт) — тот, что стоил суток |
### ⛔ НАХОДКА №9 — класс, которого не ловит НИ батарея, НИ мутация
Поймана мной при починке F5: **моя починка была дефектной, и её дефект не имел цвета.**
`cutTailReserve` был КОНСТАНТОЙ `3 * writeBudget`, а бюджет записи в тестах — полем сервиса
(`s.writeBudget`, который фикстуры укорачивают, чтобы достать случаи, недостижимые за 30 секунд). В
фикстуре с хвостом 300 мс резерв оставался 90 с — больше всего хвоста ⇒ шаг разреза рождался истёкшим,
движок не звался НИКОГДА, и пакет `books` **зависал навсегда** на `<-first`.
**Почему это отдельный класс.** Батарея его не ловит, потому что зелёного вердикта просто не
наступает — но и красного тоже: прогон висит до таймаута `go test`, и в CI это читается как «долго», а
не как «сломано». Мутация его не ловит по той же причине: у посадки нет вердикта, есть тайм-аут.
Единственное, что его назвало — прогон с УКОРОЧЕННЫМ `-timeout` и чтение стека упавшего по нему
процесса (`limit_test.go:162`, `<-first`); по цвету он неотличим от медленной машины. Две вещи из этого:
- резерв сделан производным от бюджета В СИЛЕ (`s.cutTailReserve()` = `3 * s.write()`), иначе фикстура
молча моделирует не то;
- «нет места для разреза» больше не притворяется отказом хранилища: claim берётся на СВОЁМ бюджете
записи, разрез — на своём, и пустой разрез отвечает «вердикта нет» (`ReasonNoTimeToCut`), а не падает
внутри `ClaimParse`. Это тот же класс, что F1: диагноз, который называет не то, что случилось.
Запинено `TestAnUploadWithNoRoomLeftForACutSaysThatAndHandsTheBookOver` + посадка N9.
**И правило, которое стоит пережить пак:** число, которое фикстура умеет укорачивать, и число,
выведенное из него, обязаны быть выведены ОДИНАКОВО. Константа рядом с полем — это две величины,
которые совпадают в бою и расходятся в тесте, то есть ровно то, что фикстура сделать не может увидеть.
**Урок, который стоит пережить этот пак:** шесть из восьми находок — в коде, который я СДАВАЛА как
готовый, с зелёной батареей, десятью посадками и отчётом, где написано «круги сошлись». Батарея была
зелёной на всех восьми. Ловит их не цвет, а второй читатель, которому названо, ГДЕ у этого пака мягко.
### Якоря, убитые моим переездом — норма §3 п.8
Мои правки сдвинули строки в `internal/config/config.go`, `internal/pgstore/books.go`,
`internal/metrics/metrics.go` и `cmd/tmplatformd/runner.go` (везде вставки, сдвиг +6 в первых двух).
**Замер дифференциальный, а не «посмотрела»:** линтер на входном `HEAD` даёт **8** проблемных якорей,
моё дерево давало **26**. Чтобы отделить своё от унаследованного и от WIP чужой сессии, собрала
`git archive HEAD` в /tmp, подменила в копии ТОЛЬКО `platform/` своим и сравнила списки `comm`-ом.
⚠ Кап вывода линтера — 25 строк; на 26 проблемах обрезка читается как отсутствие, поэтому в копии
скрипта кап поднят до 500. Появившихся из-за меня — **18**.
| Где | Сколько | Что сделано |
|---|---|---|
| `platform/docs/DEFECT_REGISTER.md` | **14 из 14** | пере-наведены механически: токен найден в цели, адрес заменён; ни одного «руками» |
| `docs/PROGRESS.md`, `docs/architecture/05-decisions-log.md` | **4** | ЧУЖАЯ зона — ушли пингом с готовыми адресами и токенами |
**Находка из этого же хода:** экранирование `\|` в ячейке регистра (§4.5) **ломает якорь**, если черта
попала в его токен. `PD-422` держал `internal/runs/runs.go:436`=`resnapshot := book.BankMoved || book.HasPriorRun`;
после экранирования токен перестал совпадать с кодом. Вылечено укорочением токена до
`resnapshot := book.BankMoved` (единственный хит в файле). Счёт колонок и сверка токена тянут ячейку в
разные стороны — следующий, кто пойдёт экранировать черты, наступит на то же. Ушло пингом.
Итог: мой лес **12** проблемных якорей против **8** на `HEAD`; остаток — ровно те 4 чужой зоны.
### §10 — что НЕ удалось и что НЕ проверено (это разные исходы)
- **Скипов 5 (было 6), и условие у всех ОДНО и названное:** `backend/configs/mining-contrast.zh.txt` нет
на этом хосте, и это деплой-артефакт, которого нет в репозитории (снапшот `git archive HEAD backend`
его не несёт — `ls backend/configs` даёт `langpacks pairs models.yaml pipeline-*.yaml`, и всё).
Скипающиеся: `TestTheRealEngineNamesItsRestorePointInTheLineThisPlatformParses`,
`TestALivePreviewWritesNothingAndALiveApplyWrites`,
`TestALiveBuildOfAHollowBookWritesTheMarkedCopyInsteadOfRefusing`,
`TestWithoutPartialTheSameBookIsRefusedWithTheBuildsOwnNumber`,
`TestTheSnapshotGuardIsLoudWithoutTheFlagsAndPassesWithThem`. Шестой (`TestARestorePointCanActuallyBeRestored`)
закрыт: `pg_dump`/`pg_restore` есть в `~/.local/pgsql/bin`, переменные выставлены.
⚠ Это НЕ «не проверено» про мой предмет: ни один из пяти не касается разреза приёма — они про банк,
выдачу и снапшот-гард. Живой гейт МОЕГО предмета закрыт и зелёный.
-**ЭТО МЕСТО БЫЛО «НЕ ПРОВЕРЕНО» И ОКАЗАЛОСЬ ДЕФЕКТОМ — оставляю как след.** Первая редакция отчёта
писала: «полагаюсь на то, что River повторит задание с бэкоффом; сколько попыток он даёт, я не
измеряла». Замер (адверсариальный проход, подтверждён мной по коду): `ParseArgs.InsertOpts`
`MaxAttempts: 1`, повтора НЕТ ВООБЩЕ, задание просто списывается, и книга ждала свип 20 минут. То
есть моё «рассуждение, а не замер» было не осторожностью, а неверным утверждением в комментарии кода.
Вылечено `river.JobSnooze`, который возвращает задание не тратя единственную попытку. ⭐ Урок ровно
тот, что записан в каноне зоны: строка «не проверено» — это не смягчение, это место, где ещё не
посмотрели, и смотреть надо ДО сдачи.
- **НЕ ЗАПИНЕНО ИМЕНЕМ, но покрыто исполнением** (проверено посадками, не грепом по именам):
`cutTailReserve` — посадкой N7, `worthStarting` и `waitCtx` — обоими исходами
`TestAQueuedParseThatCannotCutSpendsNothingAndGivesTheBookBack` (ожидание обрывается на резерв раньше,
поэтому случай «упор в потолок» кончается за ~1,5 с, а не за весь бюджет), `engineNotAsked` — обоими
ветвями там же, `jobs.RetryDelay` — новым тестом очереди. Собственного теста по имени у них нет.
- **НЕ ПРОВЕРЕНО экспериментально: сколько памяти реально держит один `tmctl manifest`.** Дефолт 4 выбран
как число, под которое хост уже был рассчитан (`MaxWorkers` очереди), а не измерен на большой книге.
Ручка конфигурируема именно поэтому.
- **Опровержение премисы §4.6 сделано ГРЕПОМ ПО КОДАМ**, а не чтением рендера фронта: я искала строки
`no_book`/`unsupported_pair` в 8114 `.ts`/`.tsx`. Таблица, ключуемая иначе (например, по корневому
`code`), таким грепом не нашлась бы. Утверждаю ровно замеренное.
- **Одно новое условное сообщение НЕ запинено:** `«the parse claim could not be given back; the backstop
sweep takes the book»` в `giveBack` — ветвь, где отказ `ReleaseParseClaim` накладывается на упор в
потолок. Четыре остальных новых сообщения запинены ОБЕИМИ фикстурами (где обязано прозвучать и где
обязано молчать), это пятое — нет: чтобы его достать, нужен отказ хранилища ВНУТРИ уже насыщенного
потолка, и фикстуру такой конъюнкции я не построила. Называю прямо, а не выдаю шесть из семи за семь.
Остальные шесть — включая обе новые («движок не спрошен» и «не осталось места на разрез») — запинены
фикстурой, где сообщение обязано прозвучать, И фикстурой, где обязано молчать.
- **Ограничитель НЕ накрывает `readmodel`** (`internal/readmodel/readmodel.go:156` — второй и последний
вызывающий `Engine.Manifest`) и глаголы `export`/`status`. Это осознанная граница, а не пропуск:
материализатор и `status` идут внутри очереди, которая уже ограничена одним `MaxWorkers` на все три
типа заданий (`internal/jobs/jobs.go:170`), свипы последовательны, а `readEngine` накрыл бы ещё
`Status` на пути СТАРТА платного прогона (`internal/runs/spawn.go:242`) и связал бы запуск прогонов с
нагрузкой приёма. Единственным неограниченным источником процессов был синхронный интейк — он и закрыт.
⚠ Если приёмка считает, что хосту нужен потолок на ВСЕ порождения, это отдельная работа со своим
дизайном (развязка денежного пути), а не райдер к этому паку.
### Попутно: совместимость с новой секцией `bank.json` (пришло пингом от движковой зоны, проверено моим кодом)
Движковый пак добавил в `bank.json` секцию `consolidation` (полнота банка) и поле `never_asked`, версия
`tm-bank-v1` НЕ бампнута. **Пере-проверено на моей стороне, не принято на слово:**
- `bank.json` разбирает `internal/ingest/bank.go:78` `DecodeBank` — простой `json.Unmarshal`, неизвестный
член игнорируется. ⚠ Строгий декодер в зоне ЕСТЬ ровно один (`internal/httpapi/bank.go:137`,
`grep -rn DisallowUnknownFields --include=*.go` → **1 хит при 200 `.go`**), но он на ДРУГОМ пути — тело
запроса на правки ОТ КЛИЕНТА, где строгость требует сам канон. Пути не пересекаются ⇒ лендинг движка
приём банка не ломает.
- Читателей секции у зоны нет. ⚠ По подстроке их **2 при 186 `.go` в `internal/`**
(`internal/ingest/manifest.go:97`, `internal/pricing/pricing.go:112`) — и оба английская ПРОЗА про
«terminology consolidation» в денежных комментариях, а не чтение поля. Счёт по подстроке и счёт по
владению здесь расходятся на два: следующему, кто будет снимать этот ноль, читать хиты, а не число.
- ⛔ **Закон на будущее:** бит `complete` брать ГОТОВЫМ из артефакта, не выводить у себя (п.6 закона
входной двери шва). У движка он считается от среза рендер-паса, а срез классификатора полноты банка не
означает — самостоятельный вывод разошёлся бы с движковым молча.
Строку под читателя НЕ завожу: это следующий пак зоны, и решение оркестратора — не торопить.
### Вопросы оркестратору
- **Нужен ли ряд регистра на остаток §4.1** (порождения вне потолка: `readmodel` + `export`/`status`)?
Я его НЕ завела: это не дефект сегодняшнего поведения, а названная граница механизма, и заводить ряд
«мы решили иначе» — засорять регистр. Скажи, если хочешь ряд.
- **`ReasonHostAtCapacity` — константа, которая НИКОГДА не пишется в БД** (`reject` — единственный писатель
причины, а класс потолка возвращает claim до него). Я оставила её строкой рядом с пятью
`ReasonX`-константами, потому что читатель приходит за ними туда же, и написала это в комментарии.
Если считаешь, что не-хранимой причине там не место — скажу, куда унести.
## ПАК «РАЗРЕЗ ПРИЁМА ДО ГОТОВНОСТИ И ПРАВДА О СЕБЕ» — ЗАПИСКА-ПЛАН (08.09, `textmachine-fa`)
> Промт `docs/PLATFORM_INTAKE_TRUTH_SESSION_PROMPT.md`, вход HEAD `3f4680c`, дерево на входе чисто
> (`git status --porcelain` — пусто). Зона НЕ коммитит. Пак $0.
> Baseline снят сам: `python3 docs/scripts/counts.py --check` → «Литералы сходятся с пере-счётом
> (8 проверок)», регистр 465 рядов / open 112 / major 3.
**Что беру и в каком порядке.** Сначала то, что стоит $0 и является предусловием остального (шапка
этого журнала, носитель числа пакетов, ряды регистра), потом код в порядке связности: бюджет хвоста —
ограничитель — три неразличимых сбоя, потому что первые два связаны структурно и чинить их по
отдельности значит ломать один другим. Живой гейт и рантбук — последними, они судят уже построенное.
**Разметка решений, принятых ДО кода — чтобы их можно было опровергнуть по этой записке.**
1. **Бюджет (§4.2) — форма Б, структурная.** Перечень шагов подвёл трижды, и четвёртый перечень был бы
той же заплатой (`D39.216`). Беру ОДИН отсоединённый контекст хвоста с дедлайном `UploadSettle`,
от которого наследуются все шаги: `context.WithTimeout` на потомке с более ранним дедлайном сам даёт
`min(шаг, остаток)`, поэтому добавленный шаг границу не двигает ПО ПОСТРОЕНИЮ, а не по внимательности
следующего автора. Пин утверждает САМО свойство (§5.3), а не сумму слагаемых.
2. **Ограничитель (§4.1) — на `books.Service.manifest`.** Замер входов, а не память: `Engine.Manifest`
зовут ДВА места (`internal/books/parse.go:440`, `internal/readmodel/readmodel.go:156`), а `s.manifest` —
ровно те три, что названы заказом (интейк · `parseWorker` · свип `Sweep`). Шире (`runner.readEngine`,
общий на `manifest`/`export`/`status`) НЕ ставлю, и довод замером: очередь у платформы ОДНА и уже
ограничена (`internal/jobs/jobs.go:170`, `MaxWorkers` дефолт 4) на все три типа заданий сразу, свипы
последовательны — то есть единственный неограниченный источник процессов на хосте это и есть
синхронный интейк; а `readEngine` накрыл бы ещё `Status`, который стоит на пути СТАРТА платного
прогона (`internal/runs/spawn.go:242`, `bookMeter`), и связал бы запуск прогонов с нагрузкой приёма.
Что осталось снаружи — называю в отчёте числом, а не умолчанием.
3. **Форма — ожидание, а не немедленный отказ.** Ожидание внутри уже стоящего `CutBudget` к хвосту
ничего не добавляет (приор оркестратора, проверяю кодом), а упор даёт штатную деградацию: не уложился
⇒ `errNotConclusive` ⇒ `201 parsing` ⇒ книгу доделывает очередь. Это строго лучше `503`: пользователь
получает книгу. Существующее прежде своего (§6): `x/sync/semaphore`, `errgroup.SetLimit`,
`netutil.LimitListener`, `MaxWorkers` — рассматриваю и отвергнутое называю с доводом.
4. **Свип `StuckIntake` (§4.4) — гипотеза лечения БЕЗ миграции.** `StartParsing` не штампует
`parse_started_at` (`internal/pgstore/books.go:213`), поэтому `coalesce(parse_started_at, added_at)`
в предикате свипа — это `added_at`, поставленный ДО прихода тела; бутовый гейт
(`internal/config/config.go:729`) сверяет дедлайн только с `min(UploadGrace, ClaimStale)` = 30 мин и
пропускает дедлайн до 26m29s, а `claimGrace` = 20 мин ⇒ условие достижимо настройкой. Кандидат —
добавить `claimGrace` третьим окном в тот же `min()`: колонки не нужно, форма гейта уже ровно эта.
Не выйдет — пинг, а не полумера молча (§4.8).
**Что считаю рискованным.** (а) Форма Б трогает контексты на ВСЁМ пути приёма — класс ошибок здесь
«тихо-зелёный»: путь продолжает работать, а гарантия исчезает, поэтому пин обязан быть структурным
(дедлайны шагов), а не «уложились по часам». (б) Ограничитель на общей точке касается и очередного
входа — нагрузочное предъявление обязано считать ОДНОВРЕМЕННЫЕ процессы, а не суммарные. (в) Фикстуры
зоны уже делали два разных числа одним (`D39.208` п.5): везде, где в фикстуре встречаются `writeBudget`,
`CutBudget` и `UploadSettle`, беру ТРИ РАЗНЫХ значения.
**Чего не делаю:** п.6 десятки (текст контракта) — пинг оркестратору; всё из §4.8.
## ПАК «ДЕНЬГИ И ПРАВДА НА ЭКРАНЕ» — ОТЧЁТ (0607.09, `textmachine-bf`)
> Промт `docs/PLATFORM_MONEY_TRUTH_SESSION_PROMPT.md`, вход HEAD `e4097cb`, дерево на входе чисто.
> Зона НЕ коммитит — дерево передано оркестратору №23 (`textmachine-a8`).
### Что построено
| Заказ | Что стало с деревом | Чем предъявлено |
|---|---|---|
| §4.0 триаж журнала | журнал 5029 → **491** строки, из которых 385 — этот отчёт; две эры вынесены срезами в `archive/` | `carriers.py` по живому файлу — **0** находок при 491 осмотренных строках; вынос разобран построчно (ниже) |
| `PD-441` объявленная маржа | каждая строка `settlement` несёт БАЗИС; `runs` печатает `` перед суммой и легенду | два независимых пути по деньгам (ниже) · пины `TestEverySettlementSaysThatItsFigureIsAFloor`, `TestOnlyAnEndingTheEngineChoseIsSettledAsComplete`, `TestTheOperatorsTableSaysSpentIsAFloor…` |
| `PD-424` окно гонки | арбитр В ХРАНИЛИЩЕ: `AbandonOrder.ProofAttemptID` + `ProofSpawns`, сверка под книжной блокировкой | пины `TestAProofAboutTheAttemptBeforeThisOneIsRefused`, `TestAnAttemptClaimedWhileSystemdWasBeingAskedIsRefused` |
| `PD-438` видимость парковки | колонка `run_attempts.parked_at`, гейдж `tm_platform_parked_attempts`, ячейка PARKED в `runs` | пины `TestAParkedAttemptIsVisibleInTheRowTheGaugeAndTheListing`, `TestTheOperatorsTableSaysSpentIsAFloor…` |
| строки 285 + 325 | ⛔ **ПОСТРОЕНО, НО НЕ ГОТОВО — сработало правило остановки, см. секцию ниже.** Синхронный $0 `tmctl manifest` на приёме: fail fast и вход сужен по ЧИСЛУ ГЛАВ | 4 пина `failfast_test.go` + `TestOnlyTheHolderOfTheParseClaimCanDeleteARefusedIntake` + пин медленного разреза (круг 3) и три пина круга 4 (деплойные ветви · материализация · очередь) |
| строка 305 | `make check` при таймауте называет пакет, тест и причину | воспроизведено и предъявлено сквозным `make check` на копии |
| §4.4б | комментарий `THE FIX IS NOT IN THIS PACKAGE` приведён к поведению | греп по отозванной формулировке — 0 при 197 осмотренных `.go` |
### Заказ по пунктам — исход каждого
| Пункт промта | Исход |
|---|---|
| §4.0 триаж журнала | **сделано**; независимо пере-проверено оркестратором №23 мультимножеством и `carriers.py` по самим срезам |
| §4.1 `PD-441` | **сделано в достижимой половине**; вторая половина — движковая, названа поимённо и ушла строкой бэклога |
| §4.1 `PD-424` | **сделано**, и предмет оказался шире ряда: окон два |
| §4.1 `PD-438` | **сделано** |
| §4.2 строка 282 | **сознательно не делаю** — снято до выдачи, платформенная половина уже построена |
| §4.3 строки 285 и 325 | ⛔ **построено и ОСТАНОВЛЕНО**: пять кругов самопроверки, девять major из одиннадцати — в этом одном механизме, третий подряд промах бюджета хвоста. Предмет уезжает отдельным паком по правилу остановки оркестратора №23; семь незакрытых пунктов перечислены поимённо |
| §4.4 два малых дока | **не трогаю** — зона оркестратора; расхождения ушли пингами (ниже) |
| §4.4а строка 305 | **сделано**, предъявлено сквозным `make check` на копии |
| §4.4б комментарий | **сделано** |
| §4.5 запреты | **соблюдены**, проверено исполнением: контракт, `ENGINEERING_STANDARDS`, `PLATFORM_DIRECTION`, `backend/` — 0 изменённых файлов; коммитов 0; индекс пуст |
| пинги оркестратора: `PD-458`, `П-10` | **пере-сняты по дереву, оба подтвердились**; `PD-458` → `fixed`, `П-10` закрыта актом зоны |
### Два круга самопроверки — что нашёл каждый
⚠ **Круги сошлись на ТРЕТЬЕМ, а не на втором.** Второй я вела сама и объявила сходимость
преждевременно: адверсариальный субагент нашёл четыре major, три из них — дефекты, которые ввёл ЭТОТ
пак и которых не видел ни один мой пин.
| круг | находка | что сделано | чем предъявлено |
|---|---|---|---|
| 1 (мой, по готовой работе) | посадка «снят сравнитель попытки» ВЫЖИЛА — случай ловился чужим охранником | новой попытке дан тот же счёт заявок + сверка ТЕКСТА отказа | пере-посадка красная, текст называет именно сравнитель попытки |
| 1 | посадка «снят охранник `is null`» ВЫЖИЛА — два охранника прикрывали друг друга | добавлено обращение к записи напрямую | пере-посадка красная: `a second mark moved the stamp` |
| 1 | синхронный разрез добавил хвост, о котором бутовый гейт не знал | `UploadSettle = CutBudget + writeBudget + 30s`, `CutBudget` стал константой | пин + мутация, текст называет оба числа |
| 2 (по заказу и по первому кругу) | «≥» и PARKED были предъявлены ЧТЕНИЕМ КОДА, а не исполнением | заведён пин, читающий настоящий вывод команды | он же поймал мою ошибку в ожидании: `money.USD()` не печатает `$` |
| 2 | охранники `DeleteRefusedIntake` (стадия, claim, отсутствие прогона) не запинены | заведён пин | мутация «охранник claim'а всегда истинен» → `gave <nil>, want ErrNoBook` |
| 2 | дельта контракта врала про `character_count_exact` и `structure` | ⚠ **починка второго круга оказалась НЕВЕРНОЙ и пере-сделана четвёртым:** материализация с интейка снята, поэтому в `201` оба поля отсутствуют ВСЕГДА, а не «когда материализация удалась» | дельта переписана; пин `TestTheIntakeNeitherMaterializesNorEnqueuesWhenItsCutSucceeds` |
| 2 | числа отчёта устаревали четырежды | сняты заново после последней правки | команды и выводы — в разделах ниже |
| **3 (опровергатель, `fable`)** | ⛔ **пере-чтение строки для `201` шло на контексте, открытом ДО тела** (бюджет 30 с), а разрез длится до 90 с ⇒ после медленного разреза `201` нёс `parsing`/0 глав над строкой `not_started`/500 | свежий `writeCtx` для пере-чтения | пин `TestASlowCutStillAnswersWithTheRowAsItStandsAfterIt`; бюджет укорочен сеемым полем, а не ожиданием |
| 3 | ⛔ **три ветви деплойного класса НЕ отдавали claim и тратили попытку**, а одна писала `rejected`, который `201` выносил пользователю | все три при `atIntake` возвращают `errNotConclusive`, не трогая `defer_`/`reject` | пин `TestADeploymentFaultLeavesNoVerdictAboutTheFile`: `parse_attempts = 0`, `parse_started_at is null` |
| 3 | ⛔ **синхронная материализация читательской поверхности** (до 5 мин) держала запрос и не входила в `UploadSettle` | на интейке не материализуем — долг записан, забирает свип; `UploadSettle = CutBudget + 3×writeBudget` с перечнем шагов | гейт `internal/config` читает саму константу |
| 3 | ⛔ **маппинг обоих отказов на HTTP не был запинен** — обмен кодов местами оставлял пакет зелёным | пин по КОДУ, а не по факту 400 | `TestTheIntakesOwnRefusalsReachTheWireWithTheirOwnCodes` |
| 3 | ⚠ **сужение входа действует только на синхронной ветви** | закрыть сегодня нечем: словарь `RejectReason` закрыт, ближайшее значение УДАЛЯЕТ исходник | заведён `PD-463`, пинг ниже |
| 3 | `PD-458` помечен `fixed(в дереве пака…)` — ложная атрибуция | `fixed(0632a30)` | `git merge-base --is-ancestor 0632a30 e4097cb` → да |
| 3 | ряды `PD-441`/`PD-424`/`PD-438` не несли диспозиции пака | дописаны, форма ряда сохранена | `awk -F'|' '{print NF-2}'` по трём рядам → 7, 7, 7 при шапке 7 |
| 3 | `TestTheUploadSettleBudgetCoversTheSynchronousCut` — ТАВТОЛОГИЯ | тест удалён, вместо него перечень шагов в комментарии константы | `CutBudget = 10h` оставлял его зелёным, а `internal/config` давал 12 красных |
| 3 | замер денег не воспроизводился: скратч-БД удалена, команда не названа | стал ПИНОМ, печатающим обе стороны | `TestTheRawLedgerAndTheReadModelAgreeOnWhatWasSpent` |
| 3 | живой `STACK_DECISIONS` отсылал в архив, чей баннер запрещает исполнять инструкции | сказано, что правило целиком стоит на месте, а архив — археология | — |
| 3 | док-комментарий `cutNow` склеен с `cutResult` | разделены | `gofmt` и `go vet` чисты |
| 3 | числа «потеряна 1 строка» и «195 осмотренных `.go`» | пере-сняты: **10** строк (все названы) и **197** | раздел «Команды и их вывод» |
| **4 (опровергатель, `fable`)** | ⛔ **задание очереди ставилось в транзакции `StartParsing` и гонялось с синхронным разрезом за один claim** — выигрывает очередь ⇒ fail-fast молча нет; выигрывает разрез ⇒ job съеден впустую, и после возврата claim'а книгу подбирает только свип через 20 мин | job ставится ТОЛЬКО там, где разрез не идёт; на пути «вердикта нет» — вместе с возвратом claim'а, одной транзакцией (`ReleaseParseClaim`) | пины `TestTheIntakeNeitherMaterializes…` и `TestACutWithNoVerdictGivesTheClaimBackAndEnqueuesTheJob`, обе мутации красные |
| 4 | ⛔ **`UploadSettle` снова не покрывал хвост:** `StartParsing` (30 с) выпал из перечня, а квитанция идемпотентности 10 с, не 30 ⇒ худший путь 190 с при константе 180 | `CutBudget + 4*writeBudget`, перечень шагов пере-написан ПО КОДУ и по бюджету каждого | гейт `internal/config` читает саму константу |
| 4 | ⛔ **из «трёх ветвей деплойного класса» запинена была одна**; мутации на `ErrStorageGone` и `ErrDirectoryGone` выживали | обе ветви решаются ДО вызова движка, поэтому запинены на своём уровне | `TestTheBranchesDecidedBeforeTheEngineAlsoLeaveNoVerdict` |
| 4 | ⛔ **починка «не материализуем на интейке» не имела пина** | заведён | мутация «снят `!atIntake`» → `the intake materialized the reading surface (1 claims, 1 refreshes)` |
| 4 | ⛔ **дельта контракта противоречила починке третьего круга** — обещала `character_count_exact: true` там, где его теперь не бывает | дельта переписана: оба поля отсутствуют в `201` ВСЕГДА | пин выше |
| 4 | `Settle` потерял док-комментарий: мой тип встал между ним и функцией | комментарий возвращён | `go doc ./internal/pgstore Store.Settle` печатает его |
| 4 | `PD-461` сам нёс дефект, который описывает (9 полей), и занижал счёт | ряд переписан: полей 7, строк ЧЕТЫРЕ, а не две | `awk` по разделителю: `PD-461` → 7 |
| 4 | в таблице «Что построено» стоял носитель-призрак — удалённый тест | заменён на настоящие | `grep` по имени → 0 |
| 4 | ⚠ **опровергатель ошибся** в одном числе: `character_count_exact` «в 3 файлах» | пере-снято: **4** (`v0.go` 2 · `project.go` 1 · `v0_test.go` 2 · `books.go` 2); он грепал только строковый вид имени | команда в разделе ниже |
| 4 | ⚠ мутация «снят охранник стадии у `DeleteRefusedIntake`» выживает | **по построению, а не из-за дыры:** каждый терминальный переход (`FinishParse`, `reject`) обнуляет `parse_started_at`, поэтому непустой claim влечёт `status = 'parsing'` — охранник избыточен | `grep -n parse_started_at internal/pgstore/books.go` — все четыре писателя |
### `PD-424`: окон оказалось ДВА, а не одно
Ряд называл одно — заявку на спавн между пробой systemd и коммитом. По коду их два, и второе опаснее:
`AbandonRun` под книжной блокировкой читает **любую живую попытку** (`a.ended_at is null`), поэтому
разблокировавшаяся расплата + `restart` подставляют под доказательство о попытке N **попытку N+1** с
живым процессом. Лечение — доказательство приходит парой (`ProofAttemptID`, `ProofSpawns`) и
сверяется в той же транзакции; расхождение — `ErrProofOvertaken`, отказ, а не применение.
`unit_name` в свидетели не годится: `ReleaseSpawnClaim` возвращает его в NULL. Отсюда монотонный
счётчик `spawns`, инкремент — внутри самого CAS `RecordSpawn`.
### Миграция `00034` (санкционирована оркестратором №23)
`run_attempts`: `spawns integer not null default 0` · `parked_at timestamptz`. Down-путь ГОНЯЕТСЯ
(`pgstore.TestMigrationsRollBackAndReapply`, зелёный на живом Postgres). Существующие строки поведения
не меняют: сверка идёт с ПРОЧИТАННЫМ числом, не с абсолютным. План гейджа снят исполнением: обе
подвыборки (`parked_at`/`quarantine_reason`) идут `Index Scan using run_attempts_live_idx` — новый
индекс не нужен. Запись парковки — только на ПЕРЕХОДЕ, в установившемся состоянии ноль операторов.
### `PD-441`: чем ограничена платформенная половина
Маржу платформа **измерить не может** — биллинга провайдера у зоны нет, а движковый леджер её не
несёт. Что сделано: цифра перестала читаться как цена. Что НЕ сделано и почему: показывать маржу
конечному пользователю нечего — недо-счёт бьёт по ДЕПЛОЮ, баланс пользователя завышен в его же
пользу (решение оркестратора №23, 06.09; формулировка «показать пользователю» из промта снята).
**Движковая половина — заказ следующему паку, поимённо:**
1. `backend/internal/pipeline/stagerun.go`, ветвь `No 2xx ever arrived: nothing was billed` — сеттлить
ОЦЕНКУ резервации, а не ноль, когда отменённый вызов уже ушёл; различитель — факт ухода, порог
обязан быть ЗАМЕРЕН, а не назначен (ряд называет латентность кандидатом: 20 мс против 156 000 мс).
2. `tmctl status --json` — публиковать рядом с `committed_usd` число и сумму строк, чья цена
ОЦЕНОЧНАЯ или неизвестна (движок уже печатает `estimated-cost rows: N`, строка бэклога **78**).
Без этого платформа умеет говорить «≥ X», но никогда «≥ X, до Y».
### Строки 285 и 325: одна правка, предикат — по данным
`tmctl manifest` зовётся синхронно после последнего байта. Четыре исхода:
`глав ≥2` → `FinishParse` инлайн, `201` несёт `not_started` и число глав · `глав 0` → `400`
`invalid_request`, `errors[{file, no_book}]`, не принято ничего · `глав 1` → `400`,
`errors[{file, no_chapter_structure}]` · **деплойный класс или таймаут** → `201 parsing`, очередь
доделывает, claim ВОЗВРАЩАЕТСЯ (иначе задание очереди нашло бы книгу занятой и ничего не сделало).
⭐ Отказ **по числу глав, а не по расширению**: выдача кладёт по одному XHTML на главу ДВИЖКА, значит
«книга одним полотном» ⟺ движок нарезал <2 глав. `.epub` движок режет по nav/NCX и он проходит —
блокировка по расширению отказала бы тому, что мы умеем доставлять. Go по паре и формату не ветвится.
#### Дельта контракта, которую ратифицирует оркестратор (минор `0.12.0` → `0.13.0`)
⚠ **Канон я НЕ трогаю** — это зона оркестратора. Здесь лежит ТЕКСТ дельты, чтобы её не выводили заново.
**Что становится ложным:** `docs/architecture/14-api-contract/openapi.yaml`, описание `201` у
`createBook` — фраза «**The `201` carries `parsing`, not `uploading`**». После синхронного разбора
`201` несёт `parsing` только на одном из четырёх исходов.
⛔ **ПРЕЖНЯЯ РЕДАКЦИЯ ЭТОЙ ДЕЛЬТЫ ОТОЗВАНА (строка 285), и вот что она говорила неверно.** Она обещала, что
`character_count_exact` и `structure` зависят от того, «удалась ли материализация читательской
поверхности», и что при удаче приезжают в том же `201`. Это было верно ровно до починки четвёртого
круга: интейк БОЛЬШЕ НЕ МАТЕРИАЛИЗУЕТ читательскую поверхность (она стоит два движковых прогона и
держала бы запрос загрузившего), поэтому оба поля в `201` отсутствуют **ВСЕГДА** и приезжают
следующей ревизией библиотеки. ⚠ Сказано вслух, а не заменено молча: при конфликте редакций
действует эта.
**Что несёт ответ `POST /v0/books` в каждом исходе:**
| исход | ответ | тело |
|---|---|---|
| движок нарезал **≥ 2 глав** | `201`, заголовок `Location` | `Book.status = "not_started"` · `chapter_count` = число глав движка · `character_count` = счёт интейка с **`character_count_exact: false`** и **`structure: null`** — ВСЕГДА. Оба поля пишет `SaveStructure` вместе с читательской поверхностью, а интейк её не материализует (уплотняет запрос на два движковых прогона): они приезжают позже, проходом материализатора, и клиент видит их следующей ревизией библиотеки. Правило `null` не меняется |
| движок прочёл и нарезал **0 глав** | `400` `invalid_request` | `errors: [{ pointer "/file", code "no_book" }]`; книга НЕ принята — ни строки в библиотеке, ни каталога на диске |
| движок прочёл и нарезал **1 главу** | `400` `invalid_request` | `errors: [{ pointer "/file", code "no_chapter_structure" }]`; тоже не принято ничего |
| **деплойный класс или таймаут** | `201`, заголовок `Location` | `Book.status = "parsing"` — прежнее поведение маршрута целиком; очередь и страховочный свип доделывают |
**Деплойные классы перечнем** (ни один не отказывает пользователю): `not_configured` — у книги нет
конфигурации движка, или шаблон деплоя не читается · `storage_unavailable` — корень хранилища книг не
смонтирован · `schema_mismatch` — проектная БД книги не той схемы, что бинарь · `parser_unavailable` —
движок не удалось ЗАПУСТИТЬ, либо он ответил классом, которого эта сборка не знает, либо обычным
выходом 1 · плюс превышение бюджета синхронного разбора (`books.CutBudget`).
**Новых значений `ErrorCode` НЕ заводится.** Оба отказа — существующий `invalid_request`; `no_book` и
`no_chapter_structure` живут в `errors[].code`, который сама спека объявляет НЕ закрытым («Not closed,
like `cause.code`»). ⚠ `no_chapter_structure` — ВРЕМЕННЫЙ: он снимается, когда построена структура глав
для выдачи (строка бэклога **283**), и в коде ветки стоит этот номер, чтобы её нашли и убрали.
### Батарея, мутации и деньги
- **`make check` MAKE-EXIT=0**, 20 пакетов `ok`, красных 0, скипов **8**. Невыполненное условие хоста
названо: `TM_PLATFORM_TEST_ENGINE_BIN` + `TM_PLATFORM_TEST_BOOK_TEMPLATE` (рецепт требует $0-пайплайн
РЯДОМ с `backend/prompts/`, то есть записи в чужую зону или полной копии дерева).
- **Тесты: 853 → 875 (+22).** Тестов, существовавших ДО пака, не удалено ни одного
(`git diff -- '*_test.go' | grep -c '^-func Test'` → 0). ⚠ Один тест, добавленный ЭТОЙ ЖЕ сменой,
удалён третьим кругом как тавтологичный (`TestTheUploadSettleBudgetCoversTheSynchronousCut`:
`UploadSettle` определён через `CutBudget`, поэтому утверждение выполнялось всегда) — в диффе против
`943617a` это не видно, и потому названо здесь. ⚠ Число снималось ПЯТЬ раз и первые четыре были
неверны: «866 → 863» (глоб захватил не-тестовые файлы), «853 → 863», «853 → 864», «853 → 866» и
«853 → 869» — каждое снято до пинов, добавленных следующим кругом самопроверки. В отчёте последнее,
после последней правки. ⚠ Само по себе это и есть измеренная цена преждевременного объявления
сходимости: шесть замеров одного числа, потому что кругов оказалось не два, а пять.
- **Мутации: посажено 18, поймано 14 сразу, выжило 3, одна поимка отозвана** (её тест удалён третьим кругом, см. таблицу). Две выживших — дыры в МОИХ пинах,
обе починены и пере-посажены (после починки красные); третья выживает СОЗНАТЕЛЬНО и названа.
⚠ Шестнадцатая посадка ОТБРОШЕНА мной как негодная: первая версия мутации охранника `claim`'а
краснила тест ошибкой ТИПИЗАЦИИ Postgres (`could not determine data type of parameter $2`), то есть
давала правый вердикт по неправой причине; пере-посажена корректно типизированной, и в таблице стоит
вторая.
| посадка | текст падения (или почему выжила) | хеш восстановлен |
|---|---|---|
| `report-failures` без строки сводки пакетов | `does not carry "textmachine/platform/internal/money"` | да |
| `report-failures` возвращён к грепу `--- FAIL` (исходный дефект 305) | то же, на всех трёх фикстурах | да |
| `report-failures` без счёта улик | `does not carry "Evidence, 1 lines"` | да |
| `AbandonRun` без сравнителя ПОПЫТКИ | ⚠ **ВЫЖИЛА**: случай ловился сравнителем ЗАЯВОК — правый вердикт по неправой причине. Пин починен (новой попытке даётся тот же счёт заявок + сверка ТЕКСТА отказа), пере-посажена → `answered <nil>, want ErrProofOvertaken` | да |
| `AbandonRun` без сравнителя ЗАЯВОК | `answered <nil>, want ErrProofOvertaken: the unit name reads exactly as the proof saw it` | да |
| `RecordSpawn` без инкремента свидетеля | `the claim counter went 0 → 0: a witness that does not move cannot catch the race` | да |
| свип не пишет парковку | `the parked attempt carries no ParkedAt` | да |
| свип пишет парковку каждый проход (снят порог) | ⚠ **ВЫЖИВАЕТ ПО ПОСТРОЕНИЮ**: охранник записи всё равно не двигает метку. Порог покупает СТОИМОСТЬ, а не свойство; названо в комментарии теста | да |
| `MarkParked` без охранника `is null` | ⚠ **ВЫЖИЛА**: два охранника прикрывали друг друга. Добавлено обращение к записи НАПРЯМУЮ, пере-посажена → `a second mark moved the stamp from … to …` | да |
| снятие метки сделано неисполнимым | `the attempt is materializing again and the row still says parked since …` | да |
| парковка посчитана карантинным гейджем | `parked=0 quarantined=1, want the park counted once and in its own series` | да |
| разрез выпал из `UploadSettle` (состояние ДО находки) | ⚠ **поимка НЕВОСПРОИЗВОДИМА:** ловивший её тест удалён третьим кругом как тавтологичный, и в «поймано» она больше не считается | да |
| знак `` снят с колонки SPENT | `SPENT prints an exact amount; the engine's meter is a lower bound` | да |
| ячейка PARKED слита с QUARANTINE | `a parked attempt shows no elapsed time in PARKED, so «how long has it been quiet» has no answer` | да |
| охранник claim'а у `DeleteRefusedIntake` всегда истинен | `a delete under somebody else's claim gave <nil>, want ErrNoBook` | да |
| пере-чтение снова на контексте, открытом ДО тела | `after a cut that outlived the write budget the response says "parsing" with 0 chapters, want the parsed row` | да |
| ветвь «манифест не читается» снова тратит попытку | `the intake's pass spent 1 attempts of a budget that bounds how often a broken HOST is asked` + `the claim is still held (…)` | да |
| коды двух отказов на HTTP поменяны местами | `item code no_chapter_structure, want "no_book": the two refusals are different answers to the user` | да |
- **Деньги двумя независимыми путями** на настоящих строках: сырой SQL по `credit_ledger` (7 строк,
сумма 4919187 micro) и чтение платформы `ReadAccount` (Balance=LedgerSum=$4.919187) — сходятся.
Расхождение до/после починки на тех же строках: было `note=""`, стало `at least: …` и, для
оборванной попытки, `…cut off mid-work… (PD-441)`.
### ⛔ СРАБОТАЛО ПРАВИЛО ОСТАНОВКИ — синхронный разрез НЕ ГОТОВ и должен уехать отдельным паком
Пятый круг дал major **внутри бюджета хвоста загрузки**, а оркестратор №23 отнёс этот бюджет к пути
синхронного разреза именно на этот случай. Правило исполнено: путь больше не чинится, находки ниже
записаны для следующего пака и НЕ закрыты.
**Чем правило сработало (замер, а не оценка).** `UploadSettle` не покрывает хвост ТРЕТИЙ раз подряд, и
каждый раз по новой причине. Сегодняшняя: перечень шагов в комментарии константы называет `FinishParse`
и `ReleaseParseClaim` АЛЬТЕРНАТИВАМИ («whichever end the cut reaches»), а код их СКЛЕИВАЕТ — ошибка
`FinishParse` уходит наверх (`internal/books/parse.go`, греп `owed, err := s.Store.FinishParse`), и
`cutNow` на любой не-`ErrBadIntake` ошибке зовёт `ReleaseParseClaim` на СВЕЖЕМ `writeCtx`. Худший путь:
`StartParsing 30 + разрез 90 + FinishParse 30 + Release 30 + ReadBook 30 + квитанция 10 = 220 с` при
`UploadSettle = 210 с`.
**Измерено, а не оценено (двоичный поиск по бутовому гейту через `Load()`):** гейт принимает дедлайн
до **26m29s**, ложь начинается с **26m21s** — окно шириной восемь секунд, достижимое только ручной
настройкой почти вплотную к потолку самого гейта; на дефолте **10m0s** запас **16m20s**. Наблюдаемое
следствие — **дубль книги, а не потеря**: претензия на ключ идемпотентности, пережившая `ClaimStale`,
перехватывается повтором с новым токеном, и человек, повторивший «висящую» загрузку, получает вторую
книгу вместо реплея первой. ⚠ Достижимость худшего пути без искусственного замедления **не измерена**:
она требует конъюнкции «большая книга» и «три полных `writeBudget` подряд», а гейт живого движка на
этом хосте не закрыт — подставное замедление на вопрос «достижимо ли БЕЗ него» не отвечает. Ряд —
`PD-464`. ⚠ Батарея этого не видит по построению: пин на сумму был
тавтологичным и снят третьим кругом, а мутация `4*writeBudget → 3*` оставляет и `internal/config`, и
`internal/books` зелёными.
**Почему это остановка, а не ещё одна починка.** Из одиннадцати major, найденных кругами 35, девять
лежат в одном месте — синхронном разрезе на приёме. Это новый механизм в конкурентном платном пути
(claim · очередь · транзакция · бюджет), и он ведёт себя как такой механизм: каждая починка открывает
новую площадь. Три круга подряд одна и та же константа оказывалась короче хвоста, и каждый раз по
другой причине — это свойство предмета, а не невнимательности.
**Что остаётся НЕ ЗАКРЫТЫМ в этом пути** (для пака, который его заберёт):
1. `UploadSettle` короче хвоста на 10 с (`PD-464`). ⛔ Лечение — **не поднять константу**: она
выводилась руками трижды и трижды была неверной, каждый раз по новой причине, поэтому четвёртый
вывод руками — подпорка (`D39.216`). Границу надо выводить **ИЗ кода пути**.
2. Комментарий в `cutNow` («the queue job is already enqueued») противоречит починке, ради которой
функцию правили: на этом пути задание ставит сам `ReleaseParseClaim`.
3. Отказ `ClaimParse` в `cutNow` не различает рутинную гонку (`ErrParseClaimed`) и сбой БД, и во
втором случае молчит: книга остаётся `parsing` без задания и без строки лога до свипа.
4. Параметр `enqueue` у `StartParsing` в бою мёртв (движок настроен всегда), а его doc описывает его
как штатный путь; решение «кто ставит задание» размазано по двум местам на одном предикате.
5. Охранник `RowsAffected() == 0 → задание не ставить` в `ReleaseParseClaim` не запинен ничем.
6. «ВСЕГДА» в дельте контракта — гарантия по ТАЙМИНГУ, не по построению: свип материализатора
теоретически успевает между `FinishParse` и `ReadBook`. Практически вероятность близка к нулю, но
тексту контракта полагается либо структурная гарантия, либо оговорка.
7. Свип `StuckIntake` — третий претендент на claim, в разборе гонки не назван: при
`TM_PLATFORM_UPLOAD_DEADLINE > claimGrace` он может взять claim раньше разреза.
8. Разрез потерял единственный ограничитель параллелизма: задание на приёме больше не ставится, а
`MaxWorkers: 4` был у очереди — одновременных разрезов теперь не ограничивает ничто.
9. После последнего байта тела маршрут МОЛЧИТ до 210220 с; промежуточный прокси об этом не спрашивали.
10. Отказ уходит наружу только машинным кодом (`Detail` не заполняется) ⇒ «честная причина человеку»
из §4.3 сегодня не доезжает. Фронт заморожен, значит либо причина едет в `Detail`, либо пункт
признаётся неисполненным.
### Замер, который должен пережить пак: очередь выигрывала гонку у разреза 5 раз из 6
Синхронный разрез берёт `ClaimParse` ПОСЛЕ коммита `StartParsing`, а `StartParsing` ставил задание
очереди ВНУТРИ той же транзакции — то есть воркер становился видимым тем же коммитом и гонялся с
разрезом за один и тот же claim. Проба (энкьюер зовёт `Parse` сразу после коммита, как это делает
River на видимости строки), шесть прогонов: **очередь выиграла 5 раз, разрез 1 раз**.
Оба исхода стоят книге, и это делает гонку дефектом, а не шероховатостью:
- **выигрывает очередь** — синхронного вердикта нет вовсе, пустой файл принимается `parsing`,
попытка бюджета потрачена, отказ приезжает асинхронно и оставляет книгу в библиотеке;
- **выигрывает разрез** — задание съедено впустую (`ErrParseClaimed` → `nil`), и после возврата
claim'а книгу подбирает только страховочный свип, то есть через `claimGrace` = 20 минут.
⇒ задание ставится ТОЛЬКО там, где синхронного разреза не будет, а на пути «вердикта нет» — вместе с
возвратом claim'а одной транзакцией. Цена названа: процесс, умерший между строкой и разрезом,
оставляет книгу без задания, и её берёт свип через `claimGrace`, а не сразу.
### Команды и их вывод — числа этого отчёта
Сняты ПОСЛЕ последней правки кода; доковые правки после них Go-батарею не касаются.
```
$ TM_PLATFORM_TEST_DSN=… TM_PLATFORM_TEST_PGDUMP=… TM_PLATFORM_TEST_PGRESTORE=… make check
20 строк `ok`, ни одной `FAIL`, MAKE-EXIT=0
--- did NOT run: 8 skipped … UNSET: TM_PLATFORM_TEST_BOOK_TEMPLATE, TM_PLATFORM_TEST_ENGINE_BIN
$ git grep -h '^func Test' 943617a -- 'platform/**/*_test.go' | wc -l → 853
$ find platform -name '*_test.go' -exec grep -h '^func Test' {} + | wc -l → 875
$ git diff -- '*_test.go' | grep -c '^-func Test' → 0
$ git diff -- '*_test.go' | grep -c '^[-+]func Fuzz' → 0
$ python3 docs/scripts/carriers.py platform/docs/platform-PROGRESS.md
carriers: 0 помеченных утверждений без носителя · осмотрено строк: 491
$ grep -c '^| PD-' platform/docs/DEFECT_REGISTER.md → 465
$ python3 docs/scripts/counts.py --check
✗ docs/PROGRESS.md: «открытых 106» против пере-счёта 112 · «всего 458» против 465 (зона docs/)
$ grep -rc 'THE FIX IS NOT IN THIS PACKAGE' --include=*.go platform/ | grep -v ':0' | wc -l → 0
контроль: .go файлов осмотрено 197 · 'character_count_exact' найдено в 4 (v0.go, project.go, v0_test.go, books.go)
$ git status --short -- docs/architecture/14-api-contract/ platform/docs/ENGINEERING_STANDARDS.md \
platform/docs/PLATFORM_DIRECTION.md → пусто
$ git status --short -- backend/ | wc -l → 17
⚠ и это НЕ мой след: 17 файлов правит параллельная бэкенд-сессия. Что этот пак в `backend/` не
писал, из дерева НЕ измеримо — измеримо лишь то, что один файл я туда положила по инерции и
убрала (`backend/configs/` чист). Прежняя редакция подавала это как замер; это не замер.
$ git diff --cached --name-only | wc -l → 0
```
### Вынос журнала — что именно потеряно, пере-снято после всех правок
`comm` по непустым строкам: было **4156**, стало **4420** (три файла плюс баннеры срезов), не сошлось
**10** строк — и все десять названы, потому что «одна» в прежней редакции этого абзаца была верна лишь
на момент замера и устарела от моей же последующей правки:
- заголовок «Пинги оркестратора (живые ссылки для следующих сессий)» → «…ИСПОЛНЕНЫ» (все пять пингов
под ним отработаны);
- девять строк трёх ХВОСТОВЫХ секций-указателей («Эра пака P7 — в архиве», «Закрытые эры P0P3 — в
архиве» и дублирующий их буллет) — они дублировали блок «Архив эр» и слиты в него; обе ссылки
(`-P7.md`, `-P0-P3.md`) в живом файле сохранены и резолвятся.
То есть потеряны ФОРМУЛИРОВКИ дублей, не содержание, и это утверждение проверяемо: все шесть ссылок
на срезы в живом файле разрешаются в существующие файлы. Ссылки ИЗ других файлов в вынесенные
диапазоны пере-нацелены: `sqlc.yaml`, `STACK_DECISIONS.md` (условия стенда — там же сказано, что архив
читается как археология, а не как инструкция), `README.md` (шапка и таблица).
### Находка собственного круга: хвост загрузки, о котором бутовый гейт не знал
Синхронный разрез добавил до полутора минут к тому, что загрузка делает ПОСЛЕ тела, а бутовый гейт
(`internal/config`: `UploadDeadline + books.UploadSettle < min(UploadGrace, ClaimStale)`) про этот
участок не знал — то есть оператор мог настроить дедлайн, при котором загрузка переживает окно
идемпотентного ключа. Вылечено переносом бюджета в саму константу: `UploadSettle = CutBudget +
writeBudget + 30s`. Существующий бутовый тест читает `UploadSettle`, а не литерал, поэтому подъём
`CutBudget` теперь автоматически ужимает допустимый дедлайн.
**Цена того, что `CutBudget` стал КОНСТАНТОЙ, а не настройкой — явно.** Деградация мягкая: бюджет
работает ПОРОГОМ, а не потолком — на хосте, где разрез книги законно дольше полутора минут, загрузка
не падает, она возвращается на прежний асинхронный путь (`201 parsing`, очередь доделывает), и
единственная потеря — менее информативный `201`. Ручка убрана не ради чистоты: за ней стоял способ
выстрелить себе в ногу — настройка, которой можно вытолкнуть хвост загрузки за окно, в котором её
ключ ещё можно переиграть, а окно это ни один экран не показывает.
### Дофикс по приёмке оркестратора №23 — шесть пунктов, все закрыты
| пункт | что было | что сделано | чем предъявлено |
|---|---|---|---|
| **Д1** | ⛔ **базис расчёта ВЫВЕРНУТ на самом частом окончании:** `finish()` считает исход в локальную переменную, а `settle()` получает ТОТ ЖЕ до-финишный снапшот ⇒ у прогона, кончившегося чисто, в леджер уезжал `halted` | снапшот несёт исход, который этот же вызов только что решил (`l.Status, l.PausedReason = status, pausedReason`) | **ИСПОЛНЕНИЕМ, не по коду:** прогон доведён до `ready`, строка леджера прочитана SQL-ом. До починки: `status="ready"`, а `note` — «cut off mid-work». Пин `TestACleanEndingIsSettledOnTheBasisOfTheEndingItReached`, мутация возвращает дефект и красит его текстом |
| **Д2** | гейдж `PARKED` не запинен на уровне ЭКСПОЗИЦИИ, и проводка `Observe metrics.Runner` в демоне не запинена вовсе | ассерт на серию в экспозиции + гейт, читающий композитный литерал демона и требующий, чтобы каждое поле бралось из поля СВОЕГО имени | две мутации: подмена серии → `the exposition is missing "tm_platform_parked_attempts 7"`; обмен полей в демоне → `one state's number is published under another's name` |
| **Д3** | гейт 305 пинил ТАРГЕТ, а не его использование: откат ветви отказа к голому грепу оставлял батарею зелёной | гейт держит ветвь отказа рецепта `check`: она обязана звать `report-failures` и НЕ грепать `--- FAIL` сама | мутация — буквальный откат строки — красит обе проверки |
| **Д4** | рантбук утверждал, что у парковки нет ни колонки, ни гейджа, ни строки; после пака это ложь | рантбук переписан: у парковки СВОИ три сигнала, карантинных нет и не будет, потому что снимать её не надо. Плюс `` у SPENT и пятый отказ `run abandon` | `deploy/README.md`, греп `tm_platform_parked_attempts` и `ПЯТЫЙ ОТКАЗ` |
| **Д5** | комментарий `PD-424` объявлял исчерпывающие «two ways», а способов ТРИ | комментарий перестал объявлять полноту и называет третий способ с его радиусом; сам способ — ряд `PD-465` | заявка коммитится в `RecordSpawn`, юнит поднимается строкой ниже (`spawn.go`, `Runner.Start`) |
| **Д6** | 18 протухших якорей в регистре, сдвинутых этим паком | пере-нацелены ПО ТОКЕНУ, а не по памяти; девятнадцатый — мой собственный, я записала токен с многоточием, которого в коде нет | `counts.py --lint`: было 25 проблемных якорей в 120 доках, стало **5**, в регистре **0** |
| **Д7** | не заказан приёмкой, найден её же батареей: новый ряд `PD-465` несёт два маркера тревоги и не был объявлен в `alarmBaseline` | объявлен, с доводом почему он НИЖЕ major (окно в миллисекунды, расход ограничен потолком того же прогона, аккаунтом не эксплуатируем) | гейт `TestOpenRowsBelowMajorThatCarryAlarmMarkers` красил батарею именно на нём; ⭐ и назвал его прибор **305** — «`check` упал» отличилось от «тест упал» на настоящем падении |
⚠ **Один якорь остался и он НЕ мой по зоне:** `docs/architecture/05-decisions-log.md:2678` целит в
`platform/internal/pgstore/books.go:314` по токену `FinishParse records a parsed book`; мой пак сдвинул
цель на **343**. Правка в зоне оркестратора — число передано пингом.
⚠ **Что приёмка нашла и что чинить НЕ надо** (правило остановки в силе, дописано к семи пунктам
остановленного разреза): разрез потерял единственный ограничитель параллелизма — задание на приёме
больше не ставится, а `MaxWorkers: 4` был у очереди · маршрут молчит до 210220 с после последнего
байта, промежуточный прокси об этом не спрашивали · отказ уходит наружу только МАШИННЫМ кодом, `Detail`
не заполняется, то есть «честная причина человеку» из §4.3 сегодня не доезжает.
### Пять кругов самопроверки — что дал каждый
| круг | кто | major | итог |
|---|---|---|---|
| 1 | я, по готовой работе | — | две дыры в МОИХ пинах (посадки выживали) + хвост загрузки, о котором бутовый гейт не знал |
| 2 | я, против заказа | — | «≥» и парковка были предъявлены чтением кода, а не исполнением; охранники `DeleteRefusedIntake` не запинены; дельта контракта неверна. ⚠ **Сходимость объявлена здесь ПРЕЖДЕВРЕМЕННО** |
| 3 | опровергатель `fable` | **4** | три — дефекты, введённые этим паком: мёртвый контекст пере-чтения · три ветви без возврата claim'а · синхронная материализация; плюс незапиненный маппинг на проводе |
| 4 | опровергатель `fable` | **5** | гонка задания очереди с разрезом за один claim (**очередь выигрывала 5 из 6**) · бюджет снова короче хвоста · две ветви из трёх без пина · `!atIntake` без пина · ложная дельта контракта |
| 5 | опровергатель `fable` | **1** (+6 minor) | бюджет короче хвоста ТРЕТИЙ раз, по третьей причине ⇒ **правило остановки** |
⚠ **Цена преждевременной сходимости, измеренная:** одно число (счёт тестов) снималось ШЕСТЬ раз,
потому что кругов оказалось не два, а пять; и дельта контракта, которую оркестратор собирался
ратифицировать, дважды была ложной — первый раз по существу, второй раз после моей же починки.
⭐ **Что из этого стоит унести дальше:** восемь major из одиннадцати нашёл ЧУЖОЙ прибор, а не я, и
все восемь — в коде, который я только что написала и считала проверенным. Оба круга, которые я вела
сама, дали настоящие находки, но НИ ОДНОГО major в новом механизме: свой код я проверяла по тому,
что он должен делать, а опровергатель — по тому, что он делает.
### Что НЕ удалось и что считаю слабым местом
- **Не измерено:** маржа `PD-441` в деньгах — приборa нет на этой стороне (см. движковую половину).
- **Не предъявлено живым прогоном:** синхронный разбор гонялся на фикстурах и на живом Postgres, но
не на настоящем `tmctl` — гейт `TM_PLATFORM_TEST_ENGINE_BIN` требует записи в `backend/`.
- **Слабое место, которое называю сам:** `settlementBasis` судит по СТАТУСУ попытки, а не по факту
наличия вызовов в полёте. Классы огрублены сознательно (пере-пометка стоит менее точного сигнала,
недо-пометка прячет деньги), но `awaiting_bank` отнесён к «чистым» по чтению кода движка, а не по
замеру: если окажется, что банк-стоп тоже рвёт волну, класс придётся сузить.
- **Третье, названное опровергателем и оставленное сознательно:** если удаление отказанной книги
само упрётся в БД (`discard` → `removeIntake`), пользователь получит `400`, а строка останется
`parsing` под claim'ом; свип через `claimGrace` перечитает манифест, потратит бюджет попыток и
оставит книгу `rejected` в библиотеке — то есть ровно то состояние, которого отказ и избегает.
Не лечу: путь требует отказа БД РОВНО между двумя её же операциями в одном запросе, а лечение —
компенсирующая транзакция, то есть механизм заметно крупнее устраняемого класса. Названо, чтобы
следующий пак не открывал его заново.
- **Второе слабое место:** отказ книге без глав — продуктовое сужение. Оно временное и помечено
строкой 283 в коде ветки, но пока владелец его не подтвердил, это решение сессии.
**Работа завершена, править больше не планирую.** Дерево передано оркестратору №23 незакоммиченным:
37 файлов, все внутри `platform/`, индекс пуст. Синхронный разрез построен и ОСТАНОВЛЕН правилом
остановки — его семь незакрытых пунктов перечислены выше поимённо и уезжают отдельным паком; всё
остальное закрыто и предъявлено.
## Живые нормы зоны, у которых нет другого носителя
Каждая прошла триаж 06.09: она не выводится из кода и не стоит ни в `ENGINEERING_STANDARDS.md`, ни в
`STACK_DECISIONS.md`. ⚠ Оркестратору предложено абсорбировать их в нормы зоны — до тех пор живут здесь.
- **Ничего не отдавать на лендинг без прогона ПАКЕТА, которого правка касалась.** «Код написан и пин
заведён» — не то же, что «прогнано»; числа снимаются после ПОСЛЕДНЕЙ правки, включая комментарные
(генезис — `D39.211` п.6, где это зафиксировано как ошибка оркестратора, а не как норма зоны).
- **`go test` без `-v` строк `--- SKIP` не печатает вовсе**, поэтому греп по ним в не-verbose логе даёт
ЛОЖНЫЙ НОЛЬ скипов (`D39.213` п.3).
- **Зелёная батарея — это полный список ПАКЕТОВ плюс отсутствие `FAIL`**, а не отсутствие красных строк
в хвосте вывода (`D39.169`).
- **Гейты доков перегоняются ПОСЛЕ последней правки доков, а не кода**, и число из растущего файла —
не число (`D39.188`).
- **Шаблон книги второго гейта батареи обязан указывать на СНАПШОТ движковых конфигов того же коммита,
что и бинарь** (`git archive <commit> backend/configs backend/prompts`), а не на рабочее дерево:
иначе живой тест читает файлы параллельной бэкенд-сессии и краснеет без дефекта (`PD-432` покрывает
БИНАРЬ, не шаблон).
- **Механизм, который агрегирует чужой каталог и отдаёт результат наружу, обязан иметь СПИСОК ТОГО,
ЧТО НЕ БЕРЁТ, и список начинается с секретов.** Исключать по расширению — ловушка в обе стороны
(`D39.201`).
- **Красный тест, мерящий ВРЕМЯ, на загруженной машине — не результат:** прежде чем звать его
дефектом, гонять изолированно и смотреть `uptime` (`D39.194`).
## Вопросы и пинги оркестратору — ОТКРЫТЫ
- **Счёт рядов регистра сдвинут этим паком** и живёт в чужой зоне: `docs/PROGRESS.md` объявляет
«открытых 106 / всего 458», пере-счёт даёт **112 / 465** (`python3 docs/scripts/counts.py --check`).
Заведены `PD-459`…`PD-465`, `PD-458` переведён в `fixed`.
- ⛔ **Нужен пятый `RejectReason` в контракте (`PD-463`), либо решение, что асинхронная ветвь приёма
остаётся проницаемой.** Сужение входа до того, что умеет выдача, действует только на синхронной
ветви; на ветви, куда книга уходит при деплойном классе, «книга одним полотном» попадает в
библиотеку молча. Закрыть нечем: словарь закрыт четырьмя значениями, и ближайшее по словам
`source_unreadable` ТЕРМИНАЛЬНО и УДАЛЯЕТ исходник — то есть уничтожает файл пользователя из-за
НАШЕГО ограничения.
- **Предложение движка провести `--ceiling-usd` в `status`** (пинг №15, 09.08) не принято и не
отклонено с тех пор; `backend/internal/pipeline/status.go` отвечает «a read path never carries a
run-scoped override». Нужна диспозиция или строка бэклога.
- **`counts.py` держит регистр ОДНИМ путём и слайсы не глобит.** Это предусловие плана нарезки
`DEFECT_REGISTER.md` (`docs/DOC_CLEANUP_PLAN.md`, Б14в): вынос без него молча уронит счёт.
- **§2.12 компаньона контракта** (`docs/architecture/14-api-contract/README.md`) утверждает, что пять
денежных полей не могут доехать, тогда как аллоулист шва несёт `committed_usd`/`reserved_usd`
(`platform/internal/ingest/resync.go`); якоря на `pipeline/status.go` там же мертвы. Зона `docs/`.
- **Зеркало контракта у фронта — `0.2.3` против канона `0.12.0`** и несёт отозванное правило «денег в
интерфейсе MVP нет вообще» (`D39.196` п.2 его снял). Зона фронта заморожена; пинг на разморозку.
- **Восстановление из бэкапа не предъявлено на книге в сотни мегабайт, при заполненном диске и при
чужих соединениях** (`PD-462` покрывает соседний класс, этот — нет). Нужна строка или ряд.
- **Правило записи адресов в доках, живущее только в архиве и относящееся к прибору из `docs/`:**
адрес пишется БЕЗ якорной нотации там, где текст РАССКАЗЫВАЕТ о форме (линтер `counts.py` разбирает
форму где угодно, включая объяснение этой формы), а мёртвый адрес цитируется ПОРОЗНЬ — имя файла в
кавычках, номер словами, — иначе цитата сама становится якорем. В `counts.py` этого нет; носитель
нужен в зоне `docs/`.
- **Рантбук деплоя ни разу не прогонялся end-to-end с настоящим `migrate`** — названо предусловием
первого выката ещё пингом №17 и носителя не получило.
## Архив эр
- **Паки 0406.09** («закрыть цикл» · «пустить внутрь можно» · «форма заказа перевода» · наблюдение за
живым платным потоком) — [archive/platform-PROGRESS-2026-09-04-06.md](archive/platform-PROGRESS-2026-09-04-06.md).
Ратификации **D39.194**, **D39.201**, **D39.208**, **D39.211****D39.214**.
- **Эры P9P13 (27.0803.09)** плюс приёмка P8-REVIEW, раздел «Состояние эры P8» и исполненные пинги
оркестраторов №15№22 — [archive/platform-PROGRESS-P9-P13.md](archive/platform-PROGRESS-P9-P13.md).
Ратификации **D39.159**, **D39.162**, **D39.166**, **D39.169**, **D39.172**, **D39.180**, **D39.188**.
- **Эра пака P8-FIX (2122.08) — В АРХИВЕ.** Отчёт пака, обе волны ревью, спил пер-термной подписи, инвентарь каналов и obstacle — [archive/platform-PROGRESS-P8.md](archive/platform-PROGRESS-P8.md). Ратификация — D39.154, лендинг `31f1f82`. Живое из этой эры: четыре открытые строки регистра (`PD-370` мажор — контрактная половина · `PD-371` · `PD-372` · `PD-373`/`PD-374`), инвентарь каналов шва в `STACK_DECISIONS.md`, граница sqlc в `BACKLOG.md` П-19.
- **Записи акта 5 пака P7, остававшиеся в живом журнале, — ДОСЛАНЫ в [archive/platform-PROGRESS-P7.md](archive/platform-PROGRESS-P7.md)** 22.08: заголовок «эра P7 в архиве» стоял, а тела лежали здесь.
- **Паки P4, P5, P6 и их дофиксы (0815.08) приняты и залендены.** Ратификации: **D39.123** (P4 «раннер», `d29e30c`; формула аргумента потолка = `committed + прирост`, PD-158) · **D39.130** (P5, `69d485a`; Go-floor 1.26.6 — `toolchain`-директива в `go.mod` плюс сравнивающий гейт `make version-check`; интейк пишет `book.yaml` формой Б) · **D39.131** (эмиттер шва движка; словарь кадров — `internal/ingest/events.go`) · **D39.132** (P6 + дофикс; `PD-113` закрыт, у батареи появился второй гейт окружения). Записи паков с дофиксами, живыми пробами и посадками — [archive/platform-PROGRESS-P4-P6.md](archive/platform-PROGRESS-P4-P6.md); промт P5 — `archive/PLATFORM_P5_SESSION_PROMPT_2026-08-10.md`. ⚠ Счёт регистра и тестов тех дней устарел на порядок: живые числа берутся `python3 docs/scripts/counts.py --check` и `DEFECT_REGISTER.md`, а не отсюда.
- **Эра пака P7 (1620.08)** — пять актов, приёмка и фикс-раунды: [archive/platform-PROGRESS-P7.md](archive/platform-PROGRESS-P7.md).
- **Эры P0P3** (вход OIDC · кредиты · админ-CLI · деплой · фикс-паки) — исполнены и залендены
(D39.107/109/112/114): [archive/platform-PROGRESS-P0-P3.md](archive/platform-PROGRESS-P0-P3.md) (D39.124).
Решения оттуда живут в D-логе и `DEFECT_REGISTER.md`.