textmachine/platform/docs/platform-PROGRESS.md

3054 lines
413 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/) — читать только по конкретной ссылке.
## ОТЧЁТ 17.09 — ЧЕЛОВЕКУ ПЕРЕСТАЛИ ПОКАЗЫВАТЬ ПУСТОЙ ЭКРАН (ряды 224 · 253, контрактный минор 0.16.0)
Пак `docs/PLATFORM_BANK_READOUT_SESSION_PROMPT.md` (180c41e), сессия `textmachine-main-94`. НЕ коммичу,
дерево передаю оркестратору. Записка-план — ниже этим же блоком, она писалась ДО первой правки кода и
намеренно не переписана задним числом: два её решения я потом ПЕРЕМЕНИЛА, и это видно.
### 1. Числа ДО и ПОСЛЕ — командой, на обоих купленных файлах
Собственный контроль ряда **224** (он в теле ряда: «`json:"proposed` в `platform/` — 0 хитов»):
```
git grep -l 'json:"proposed' HEAD -- 'platform/**/*.go' → 0 файлов
grep -rn 'json:"proposed' platform/ --include=*.go → 1 (internal/ingest/bank.go:184)
```
Собственный контроль ряда **253**`grep -rn 'consolidat\|unanswered\|batches_dropped' platform/internal/
--include=*.go` → 0»). ⚠ **Пере-снят и на HEAD — там НЕ 0, а 3**, и это первая мелкая находка отчёта:
```
git grep -l '…' HEAD -- 'platform/internal/**/*.go' → 3 файла (manifest.go · pricing.go · sweep_test.go)
grep -rln '…' platform/internal/ --include=*.go → 10 файлов, из них 7 мои
контроль, что прибор читал населённое дерево: 194 файла при HEAD, 198 сейчас
```
Три хита на HEAD — прозаические упоминания в комментариях, читателей среди них нет, так что СУТЬ ряда
верна. Неверно ЧИСЛО, напечатанное рядом с нулём: команда ряда даёт 3, а не 0. Класс знакомый — контроль
у нуля обязан быть воспроизводимым той командой, которая рядом написана.
Исполнением, на самих купленных проекциях (оракул `TestARealReadOutIsReadTheWayThisBuildClaims`, копии
на чтение, `sha256` совпал, `mtime` оригиналов не тронут):
```
прогон A: boundary=signature_requested offered=69 terms=0 consolidation=true
прогон B: boundary=signature_requested offered=66 terms=0 consolidation=true
```
**Было 0, стало 69 и 66.** `terms=0` осталось нулём — и это правда движка на стопе, а не дефект.
### 2. Что построено
| Слой | Что | Файл |
|---|---|---|
| Ридер | две секции + якорь свежести (`as_of`, `run_id`), которого ридер не читал вовсе | `internal/ingest/bank.go` |
| Хранилище | миграция `00036`: `bank_read_out` (граница · идентичность прогона · ДВА флага наличия) + `bank_offered_terms` | `internal/pgstore/migrations/00036_bank_read_out.sql` |
| Шов | `SaveBank` берёт ВЕСЬ развёрток, а не его строки; одна транзакция | `internal/pgstore/readmodel.go`, `internal/readmodel/readmodel.go` |
| Провод | ресурс `GET /books/{bookId}/bank/signing-stop` + агрегат `BankPage.consolidation` | `internal/httpapi/{v0,reading,project,capabilities}.go` |
| Контракт | минор **0.16.0** + компаньон: §2.27 (провенанс минора) и §2.26-бис (две идиомы «может быть null», по наблюдению сплошного аудита оркестратора, ряд 480). ⚠ испр. 17.09: здесь стояла ОДНА секция вместо двух | `docs/architecture/14-api-contract/{openapi.yaml,README.md}` |
### 3. Исход КАЖДОГО пункта заказа
⚠ Сверено механически, а не глазами: заголовки пака (`^## N.` и `^### N.N.`) против строк этой таблицы.
Заказов с §4 и дальше — **14**, строк здесь **15** (у §4.1 и §4.3 по две: у каждого две разные обязанности).
⚠ Первую редакцию таблицы поймала приёмка: в ней не было строки под §9, и тем же прибором нашлись ещё
две — §11 и §12. Довод «пункт исполнен, не хватает лишь исхода в таблице» верен, но таблица, объявляющая
себя полной по КАЖДОМУ пункту, и есть носитель этой полноты: три пропуска в ней — это три места, где
читатель не смог бы отличить «сделано» от «не рассматривалось».
| Пункт пака | Исход |
|---|---|
| §4.1 прочитать `proposed` и `consolidation`, поля снять ЗАМЕРОМ | **сделано**, и замер дал находку — см. §4 |
| §4.1 не потерять `invented` | **сделано**; пин `TestTheClassAPersonReadsFirstSurvivesTheReadOut`, и поле доезжает до провода |
| §4.2 перенести КЛАСС гарантий ридера | **сделано**: «секции нет» ≠ «секция пуста» (указатели на всех трёх ярусах), «поля нет» ≠ «ноль» (четыре числа — указатели), граница читается ВМЕСТЕ с секцией и является УСЛОВИЕМ выдачи |
| §4.3 контрактный минор, форму решаю сама | **сделано**, форма ПЕРЕМЕНЕНА относительно записки — довод в §5 |
| §4.3 развилка идентичности предложения | **решено без пинга**: контракт уже отвечает — см. §5 |
| §4.4 хранилище назвать явно | **в БД**, миграция часть пака; довод и доказательство, что дельта и ревизия не ломаются — §6 |
| §4.5 не строить UI, не трогать движок, не подпирать заплаткой | **соблюдено**; рефакторинг — §8 |
| §5 самопроверка исполнением + адверсариальный проход | **сделано**: гейт зоны целиком, мутационная кампания, один старший советчик, чьи посылки я проверила и одну опровергла |
| §6 три оси ревью | **§9** |
| §7 купленное сырьё только на чтение | **соблюдено**: работала с копиями, база рядом с B не открывалась вовсе |
| §8 записка-план до кода | **сделано**, лежит ниже |
| §9 эхо-протокол старта: десять строк своими словами ДО работы | **сделано первым действием** — блок в `/tmp/textmachine-channel` и письмо оркестратору до первой правки: скоуп, инварианты, чего не делаю |
| §10 что не удалось и где прибор слеп | **§10** |
| §11 канал вопросов и право отказа | **использован**: восемь писем оркестратору по ходу пака, два из них — улики, менявшие работу (состав полей, смена формы). Правом отказа не пользовалась: предмета для «этого делать не надо» не нашлось |
| §12 критерий завершённости | **§12**, по пунктам |
### 4. ⚠ ГЛАВНАЯ НАХОДКА ЗАМЕРА: состав полей в промте был неверен, и это не мелочь
Промт давал контроль «восемь обязательных (`src dst kind channel freq spread conf variants`) и четыре
опциональных (`conventions invented contradicts bank_holds`)». Снято с писателя
(`backend/internal/pipeline/bankexport.go:125-160`) и с обоих файлов: **счёт 8+4 верен, состав — нет.**
Обязательность определяется наличием `omitempty` у писателя, и по нему обязательны
`src dst kind channel freq spread` **`conventions`** `conf`, а опциональны
`invented contradicts bank_holds` **`variants`**.
**И объяснение «в A поля нет ни у одной строки» через опциональность — ЛОЖНОЕ.** У `conventions` нет
`omitempty`, значит сериализатор опустить его не мог ⇒ **прогон A написан сборкой движка до `0997e41`
(11.09), добавившего поле.** Доказательство построением, а не совпадением двух снимков.
Следствие несущее и оно в коде: **у обязательного числового поля «поля нет» ≠ «ноль»**, потому что ноль
у каждого из четырёх — собственный ответ движка. `freq: 0` — «поверхность видела только черновая
сторона» (`terminology.Candidate.Freq`); `conf: 0` — «роль сказала 0 %», по её же комментарию самая
важная строка листа; отрицательный `conf` — «роль не сказала ничего». Сборка, читающая отсутствие нулём,
показала бы человеку 69 терминов, чьи варианты «единогласны», на листе, весь смысл которого —
разногласие.
### 5. Две развилки, где я ПЕРЕДУМАЛА после записки — с доводами
**(а) Форма. Записка обещала обе секции на `BankPage`. Довод записки оказался ложным по коду.**
Я писала «обе описывают ОДИН момент стопа»; это опроверг мой советчик, и я проверила деревом:
`r.lastTerminology` ставится в `backend/internal/pipeline/mining.go:171` — ДО развилки «стоп/не стоп» —
и живёт в Runner до конца процесса ⇒ секция полноты едет на ТРЁХ границах из пяти, а секция вопросов —
ровно на одной («Empty at every boundary that is not a stop»). Оси свежести разные уже у движка.
**полнота — агрегат `BankPage`** (буква ряда 253: «поле на `BankPage`», и по сути это свойство банка),
**вопросы стопа — свой ресурс**, потому что они появляются и исчезают вместе с ПРОГОНОМ, пока ревизия
банка не двигается.
⚠ Проверка советчика поймала его перегиб: он утверждал, что третий член «только первой страницы» требует
правки шапки спеки про «Two exceptions». Шапка называет КЛАСС («the aggregates of `BankPage`»), а не
перечень ⇒ новый агрегат входит в существующее исключение, шапка не тронута.
**(б) Имя. Записка обещала `suggestions`. Отвергла по существу.** «Suggestion» — то, что можно
проигнорировать, а здесь **игнор есть ПРИНЯТИЕ**. ⚠ Адрес довода испр. 17.09 приёмкой, и дважды: я
писала `mining.go:281`, настоящая строка — `:280` (внутри многострочного `WarnContext`), а **сильнейший
носитель фразы вообще не там** — `backend/cmd/tmctl/render.go:225-227`: «whatever you leave undecided
still goes to the model, as part of the same law block as the terms you signed (D39.104: the bank on the
wire is law for every row)». Это операторский ВЫВОД — текст, который человек читает в момент подписи, —
и он ссылается на ратифицированное `D39.104`, то есть довод от поправки стал крепче, а не слабее.
⚠ И поправка к поправке: приёмка заявила «`leave undecided` в `mining.go` — 0 хитов»; пере-снято мной —
**1 хит** (`:280`; контроль: `bank` в том же файле — 114). Носителей фразы в движке три. Взято слово, которым канон УЖЕ называет этот предмет: `BankSignatureCount`
описан как «the count against the surfaces the LAST signing stop **offered**» ⇒ `offered[]` / `OfferedTerm`
/ `readBankSigningStop`. Слово `proposed` наружу не идёт ВОВСЕ, третьего смысла нет, существующий
`TermStatus.proposed` не тронут.
**⭐ И там же нашлась ратифицированная ФОРМА моей гарантии — я её не изобретала.** У
`BankSignatureCount` есть `unreadable` с ровно моим обоснованием: «`true` — the two numbers mean NOTHING…
Without this flag, „could not count“ would be byte-identical to „nothing left undecided“ — the one thing
this schema must never say by accident». Взяла буквой. `unreadable: true` истинен по трём причинам сразу
(материализации ещё не было · документ не несёт секции · документ снят на границе, которая её нести не
может) — они не разделяются, потому что лекарство одно.
**Вот где граница у меня НЕСУЩАЯ, а не прочитанная и выброшенная:** секция, стоящая на границе, которая
её не несёт, — документ, который эта сборка не понимает, и отдать его строки значило бы нарисовать стоп,
которого не держит ни один прогон. Это условие выдачи, и его стережёт посадка.
### 6. Идентичность предложения — закрыто контрактом, пинга не потребовалось
`id` нет ни в одном из **135** объектов; выдумывать запрещено. Развилка разбивается надвое, и обе
половины закрываются без выдумки:
- **чтение:** дельта над списком не выразима ПО ПОСТРОЕНИЮ (нечего называть), ресурс отдаётся целиком,
без курсора. Написано прозой операции, а не подразумевается;
- **действие:** контракт УЖЕ говорит, чем адресуется поверхность, которой в банке нет — кортежная форма
`BankCorrection`: «for a surface the bank does not list, send `sense: ""` and `null` windows».
**Дельта и ревизия не ломаются, и вот чем это предъявлено.** Строки стопа лежат ОТДЕЛЬНОЙ таблицей,
заменяемой целиком, и в машинерию `bank_reset_revision` не входят. Втянуть их туда было бы дефектом: без
идентичности КАЖДАЯ материализация читалась бы как «строки исчезли» и держала бы книгу в вечном сбросе
ревизии. Пин `TestTheStopsRankingIsTheOrderThatComesBackAndTheListIsReplacedWhole`; вся прежняя батарея
`internal/pgstore` (включая тесты ревизии и дельта-чтения) зелёная.
### 7. Гейт зоны — и КРАСНЫЙ ПАКЕТ, который оказался не моим
`make check` = `build vet fmt lint sqlc-check` + батарея. Прогонов было пять, и стоит назвать все,
потому что три из них учат разному.
**Прогон 1 — `MAKE_EXIT=2`, а уведомление харнесса сказало «exit code 0».** Гейт умер на `tools-check`:
`sqlc` лежал в `~/go/bin` вне `PATH`. Первая цель, упавшая первой, отменила все последующие; ни одного
теста прогнано не было. Код обёртки — не код работы, и вердикт я читаю строкой `MAKE_EXIT` из лога.
**Прогон 2 — ЗЕЛЁНЫЙ ЦЕЛИКОМ, и полнота сверена СПИСКОМ, а не отсутствием слова FAIL:**
```
MAKE_EXIT=0 · линтер 0 issues · sqlc diff чист · FAIL 0
go list ./... → 20 · вердиктов → 20 · comm -23 список вердикты → 0 строк
контроль, что сравнение умеет говорить: убрать один вердикт → 1 строка
```
**Прогоны 3 и 4 — красные, и оба единственным красным дали `internal/books`, пакет, которого мой диф не
касается.** Прогон 3: `pgstore: commit: timeout: context deadline exceeded` за 145 с. Прогон 4: паника
таймаута 600 с, тест висел 7 м 26 с. Разные тесты, один пакет.
**Флейком по цвету я это не назвала — проверила цепь ЗВЕНЬЯМИ.** (1) Текст падения сам называет
звено: коммит перерос свой срез. (2) Фикстуры `internal/books` НАМЕРЕННО сжимают 220-секундный
продуктовый бюджет до 700 мс и 300 мс и делают внутри настоящий коммит в Postgres — иначе разрез не
удержал бы ни одного среза. (3) Звено, через которое могла бы войти МОЯ правка, — длительность установки
схемы; пере-снято: лишняя миграция стоит ≈0.12 с на прогон, и она лежит ВНЕ измеряемого окна.
(4) В том же логе все пакеты шли примерно в 2.4 раза дольше обычного — на машине работала чужая
мутационная кампания.
**РЕШАЮЩИЙ ЗАМЕР снят правильным прибором — ДВУМЯ полными батареями ОДНОВРЕМЕННО, а не по очереди.**
Развёрнут `git archive HEAD` (35 миграций, ни строки пака) и запущен в одну секунду с батареей пака,
под одной и той же чужой нагрузкой:
| дерево | вердикт | что упало |
|---|---|---|
| чистый `HEAD` | FAIL `internal/books` 201 с | `TestAnIntakeStoppedByTheCap…` (4.48 с) · `TestTheCutOfAnUploadIsBoundedByTheWalk…` (2.81 с) |
| дерево пака | FAIL `internal/books` 600 с | таймаут на `TestAnUploadThatRunsOutOfBudgetWaitingForASlot…` |
**Красны ОБА, в одном пакете, на РАЗНЫХ тестах** ⇒ краснота принадлежит ПАКЕТУ, а не правке, и разные
тесты при каждом падении — подпись голодания планировщика, а не логического дефекта. ⚠ Последовательным
прогоном этот вывод не снимался бы: условия между двумя прогонами не совпадают, и сравнение вышло бы о
разной нагрузке. Изолированно пакет целиком зелен в ОБОИХ деревьях даже при load average 11 — губит его
не нагрузка сама по себе, а нагрузка ПЛЮС собственная батарея зоны под `-race`.
Заведено **PD-469**, родня — **PD-420** того же класса. Не чиню: не мой предмет и не мой заказ.
**Прогон 5 — ФИНАЛЬНЫЙ, на дереве, которое я передаю, с поднятой переменной оракула: ЗЕЛЁНЫЙ ЦЕЛИКОМ.**
Гейт, снятый ДО двух последних правок (пина на владение и строк регистра), я за финальный не выдавала —
именно за это канон и бьёт, — и дождалась окна, когда чужая кампания отпустила процессор (load 3.18).
```
MAKE_EXIT=0 · линтер 0 issues · sqlc diff чист · FAIL 0 · скипов 9
go list ./... → 20 · вердиктов → 20 · comm -23 список вердикты → 0 строк
контроль перевёрнутого сравнения: убрать один вердикт → 1 строка
```
**Что оракул РЕАЛЬНО бежал внутри батареи, доказано разностью, а не словом:** скипов было 10 в
прогоне 2 и 9 в прогоне 5, и единственная разница — `TestARealReadOutIsReadTheWayThisBuildClaims`;
ничего нового скипаться не начало (`comm` в обратную сторону — 0 строк).
**Условия гейта на этом хосте, названные вместе с числом скипов (стандарт зоны §3.1).** Скипов **10** в
зелёном прогоне 2. `TM_PLATFORM_TEST_DSN` был UNSET — **подняла** по рецепту `STACK_DECISIONS.md`
(Postgres без root, порт 55433); контроль, что гейт РЕАЛЬНО открыт, а не объявлен открытым: тот же
селектор `-run Bank` в `internal/pgstore` даёт **4 SKIP** без переменной и **4 PASS** с ней. НЕ подняты
`TM_PLATFORM_TEST_ENGINE_BIN` + `TM_PLATFORM_TEST_BOOK_TEMPLATE` (пара): шаблон на машине есть, но его
`pipeline:`/`models:` указывают на пути ДРУГОГО хоста (`/home/ubuntu-26/…`). Они открывают 9 из 10
скипов — живые движковые пробы, ни одна не про читатель проекции; `artifacts_test.go`, где живёт мой
предмет, этой парой НЕ гейтится. Десятый скип — МОЙ оракул, он гейтится новой переменной
`TM_PLATFORM_TEST_BANK_READOUT`, которая выводится в `make conditions` автоматически (перечень условий
там ПАРСИТСЯ из исходников, руками его никто не ведёт).
### 8. Что отрефакторено и почему
1. **`SaveBank` принимает `ingest.Bank`, а не `[]ingest.BankTerm`.** Не косметика: развёрток — одна
проекция одного момента, и два сохранения дали бы экран, где вопросы этого стопа стоят рядом с банком
другого момента. Одна транзакция, одна книжная блокировка.
**Правка тестов ОБЪЯВЛЯЕТСЯ** (D39.183): **8** вызовов в `internal/pgstore/{readmodel,events}_test.go`
(⚠ испр. 17.09 приёмкой: стояло «9» — я посчитала изменённые СТРОКИ диффа, а один вызов занимает две) и
фейк в `internal/readmodel/readmodel_test.go` переписаны под новую сигнатуру. Поведение не ослаблено —
каждый передаёт тот же список термов внутри `ingest.Bank{Terms: …}`; гарантия никуда не уехала.
2. **`ingest.BoundaryOrUnknown` / `ingest.ChannelOrNone` — гарды закрытых словарей у ШВА записи.**
Заведены не для красоты: приёмочный прогон показал, что нулевое значение `ingest.Bank`, которое любой
вызывающий вправе построить, нарушает CHECK новой таблицы. Лечение по существу — значение, «заявляющее
о себе меньше всего», а не отказ: падать всем сохранением банка из-за ярлыка границы было бы худшей
сделкой.
3. **Больше ничего.** Существующий ридер, `ReadBank`, ListBank-курсор и ревизионная машинерия не тронуты:
заказ их не менял, а трогать работающее ради стройности — то, что канон запрещает.
### 9. Три оси ревью
**Контрактная.** Минор законен (мажор `0`, только добавление: новый путь, четыре схемы, один
необязательный агрегат — ни одно существующее поле не сужено и не переименовано). Полнота предъявлена
ГЕЙТОМ, а не глазами: `TestEveryMemberTheCanonRequiresIsOnTheWire` читает `required` ИЗ канона и сверяет
с реальными байтами ответа в ОБЕ стороны — объявленное и не отданное красит так же, как отданное и не
объявленное. Константу версии подняла до `0.16.0`, и её стережёт чужой гейт против самого канона.
Компаньон: §2.27 плюс ОБА носителя номера в шапке (список ратификаций и строка про отставание зеркала) —
именно на втором предыдущие три бампа спотыкались три раза подряд.
**Дисциплина ридера.** Гарантии перенесены, а не процитированы, и каждую стережёт посадка (§11). Одну
вещь я перенесла ШИРЕ образца и называю это явно: образец различает «нет» и «пусто» на уровне ДОКУМЕНТА,
а у меня то же различение стоит и на уровне ПОЛЯ — потому что замер показал, что именно там оно и
ломается.
**Человеческий узел.** Чего человеку для решения НЕ хватает, хотя я этого не строю:
1. **`variants` — склеенные ярлыки** (`"третий оборот ×1"`, с алиасом `"… (proposed for X)"`). Части не
опубликованы, разбирать нельзя (парсер живёт рядом с писателем не случайно) ⇒ клиент не может
отрисовать это на языке читателя. Движковый долг, оркестратор завёл рядом **479**.
2. **Пустой `dst` неотличим по причине.** На печатном листе движка тот же символ несёт четыре факта, и
один из них — `SettledByBank`, «решать нечего», — движок намеренно в проекцию не кладёт (ряд **353**).
Контроль, который у меня это ловит: пустых `dst`**0 и 0**, `never_asked`**0 и 0**, сходится;
класс реален, но на купленном сырье не проявился. ⚠ **Гейт ряда 353 сработал: читатель у секции
появился**, и там же висит долг «у `SettledByBank` нет свидетеля в списке полей, которых сайдкар
намеренно НЕ несёт». Это движковая зона — пинг, не правка у себя.
3. **Ни одной подсказки, СКОЛЬКО осталось.** Счёт живёт только на квитанции правок, и канон сам называет
это «named narrowness». Мой ресурс его не дублирует намеренно: популяции совпали на обоих прогонах
(69/69 и 66/66, разность множеств 0 в обе стороны), но механизм расхождения реален — реверс-секция
капается (`mining.go:143-147`), а при выключенной роли её нет вовсе ⇒ равенство в контракте не обещано
и «N из M» между ними запрещено прозой.
### 10. Что не удалось — и где прибор слеп
-**`contradicts` и `bank_holds` — 0 из 135 объектов.** Поля объявлены и доезжают, но проверены только
синтетикой ЭТОЙ ЖЕ сборки, то есть пин спрашивает «декодер согласен сам с собой», а не «согласен с
движком». Не чинила: лечение — не код, а УЛИКА (прогон, где конфликт случился). Строка **PD-468**.
-**`dst: ""` и отрицательный `conf` не встретились ни разу** (0 из 135). Ветки построены и запинены
синтетикой; на живых данных не наблюдались. Закрывается той же уликой.
-**Якорь свежести хранится целиком и не сверяется.** Ресурс обещает ПОСЛЕДНИЙ стоп, а не текущий, и
прозой это сказано — но клиент, не прочитавший `Run.status`, покажет вопросы ушедшего прогона.
Сравнение построить можно и дёшево, но материализация идёт по долгу КНИГИ и прогона в руках не держит.
Не строила намеренно: расширять обещание без механизма — взять форму якоря без гарантии. Строка **PD-467**.
-**Два гейта зоны на этом хосте не подняты** (движковый бинарь + шаблон книги): 9 скипов из 10.
Ни один не про мой предмет, но сказать «батарея без скипов» нельзя, и я этого не говорю.
-**`unreadable` схлопывает три причины.** Оператор по нему не отличит «старая сборка движка» от «ещё
не материализовали». Сделано сознательно (лекарство одно, и контракт служит клиенту), но диагностика
беднее, чем могла бы быть.
-**Пятая граница, `redrive/re-seeded`, живого носителя в моих уликах не имеет**обе проекции сняты
на стопе. Перевод всех пяти проверен синтетикой против ГРЕПА по вызовам движка, а не против пяти живых
документов.
### 11. Мутационная кампания — посадки и их вердикты
Копия дерева ВМЕСТЕ с каноном контракта (`cp -a --parents platform docs/architecture/14-api-contract`,
норма `D39.113`/`PD-395`), один мутатор на копию, копия защищена ПОСТРОЕНИЕМ: харнесс отказывается
работать, если по корню нет `platform/go.mod` и канона, или если корень внутри рабочего дерева.
Базовые прогоны всех четырёх пакетов ЗЕЛЁНЫЕ до первой посадки — без этого вердикт был бы о них.
Засчитывается по ТЕКСТУ падения: харнесс требует, чтобы упал ИМЕННО тот пин, который обязан назвать
посадку, и выдаёт отдельный исход «не измерена» на посадке, которая не собралась.
| Посадка | Что ломает | Вердикт |
|---|---|---|
| A | отсутствующая секция читается как пустая | RED · `…SectionTheDocumentDoesNotCarryIsNotASectionThatIsEmpty` |
| B | отсутствующее `conventions` читается нулём | RED · `…MandatoryNumberTheReadOutDoesNotCarryIsNotZero` |
| C | неназываемая граница читается как СТОП | RED · `…BoundaryIsTranslatedAndAnUnnameableOneIsNeverTheStop` |
| D | неназываемый детектор читается как «подтверждено обоими» | RED · `…DetectorVocabularyDoesNotReachAClient` |
| E | отсутствующая полнота читается как обнулённая | RED · `…SectionTheDocumentDoesNotCarryIsNotASectionThatIsEmpty` |
| F | `invented` теряется | RED · `…ClassAPersonReadsFirstSurvivesTheReadOut` |
| G | граница перестаёт быть УСЛОВИЕМ выдачи | RED · `…TermsStandingAtABoundaryThatCannotCarryThemAreNotServed` |
| H | «развёртка нет» читается как читаемый стоп | RED · `…ThreeWaysAStopCanHaveNoQuestionsAreNotOneAnswer` |
| I | наличие секции выводится из числа строк | RED · `…ThreeWaysAStopCanHaveNoQuestionsAreNotOneAnswer` |
| J | неизмеренная полнота отдаётся | RED · `…CompletenessIsAbsentUntilSomethingMeasuresIt…` |
| K | ранжирование заменено сортировкой | RED · `…StopsRankingIsTheOrderThatComesBackAndTheListIsReplacedWhole` |
| L | прежний список не очищается | RED · тот же пин (+1) |
| M | `null`-число исчезает с провода (`omitempty`) | RED · `…EveryMemberTheCanonRequiresIsOnTheWire` |
| N | неизмеренная полнота проецируется обнулённой | RED · `…CompletenessRidesTheFirstPageAndItsAbsenceIsNotWholeness` |
| O | через шов едут только строки | RED · `…WholeReadOutCrossesTheSeamAndNotOnlyItsRows` |
| P | пустой список уходит как `null` | RED · `…MeasurementNobodyTookReachesTheClientAsNullAndNotAsZero` |
| Q | проверка владения снята | RED · `TestAnotherUsersStopIsNotReadable` |
**17 посадок — 17 RED, выживших 0, неизмеренных 0.**
⭐ **И ОТДЕЛЬНО — про сам ОРАКУЛ, потому что «скип превратился в PASS» доказывает, что тест ИДЁТ, а не
что он ДЕРЖИТ.** В кампании оракул скипался (переменной у харнесса не было), то есть все посадки поймала
СИНТЕТИКА. Спросила его посадкой отдельно: посадка **B** (отсутствующее `conventions` читается нулём) при
поднятой переменной даёт `--- FAIL: TestARealReadOutIsReadTheWayThisBuildClaims` с топичным текстом
«row 0: the document does not state `conventions` and the reader reported it stated», а при снятой —
`--- SKIP`. ⇒ **эта посадка поймана ДВУМЯ независимыми приборами** — синтетическим пином и оракулом
холодного прогона на настоящем купленном документе, — значит сломать чтение отсутствующего поля и
получить зелень нельзя даже мимо синтетики. ⚠ Посадка Q прогнана ОТДЕЛЬНО и на пере-снятой
копии: пин владения написан после первой кампании, и копия его не несла. Контроль чистоты после
кампании — не `diff -rq` (он говорит о дереве, а не о числах), а вопрос «на месте ли ЦЕЛЬ каждой
правки»: **16 целей проверено, пропавших 0.**
### 10-бис. КРУГ ДОФИКСА ПО ПРИЁМКЕ 17.09 — находка → что сделано → чем предъявлено
| Находка приёмки | Что сделано | Чем предъявлено |
|---|---|---|
| **F1 (мажор, денежный экран).** Канон объявляет `confidence` как `0..100`, движок шлёт `-1` для «роль не назвала», и вся цепь несла минус на провод — при том что компаньон обещал `null`. Класс: вместо ложного НУЛЯ — ложное ЧИСЛО | **⛔ И это оказался КЛАСС, а не поле — нашёл мой же новый гейт.** Свернула у ШВА (`ingest`), где файл велит переводить все пересечения, и потом гейт показал, что канон бьёт диапазоном ЧЕТЫРЕ числа, а проходили насквозь все четыре | `statedCount`/`statedConfidence`/`inRange` в `ingest/bank.go`; `TestTheEnginesNoConfidenceSentinelNeverReachesAClient` |
| **Почему гейт этого не поймал**`TestEveryMemberTheCanonRequiresIsOnTheWire` читает из канона только `required` и по построению не видит `minimum`/`maximum`: «взять форму образца и не взять гарантию», но у ГЕЙТА | Заведён гейт, читающий из канона ДИАПАЗОНЫ и падающий на любом ранжированном члене, которого ридер не ограничивает — то есть закрыт класс, а не случай | `TestEveryRangeTheCanonDeclaresIsOneThisReaderEnforces`: «ranges read from the canon and exercised: 4»; на момент заведения краснел по ЧЕТЫРЁМ полям |
| **F2.** `omitempty` на nil-указателе ОПУСКАЕТ ключ, поэтому обещанный каноном `null` («никто не мерил») недостижим, а комментарий строкой выше утверждал обратное | У члена ДВЕ разных пустоты, и тег умеет одну: на поздней странице — отсутствие, на первой — `null`. Перешла на `json.RawMessage` | `TestCompletenessRidesTheFirstPageAndItsAbsenceIsNotWholeness` |
| **F3.** Носителей номера версии в шапке компаньона **четыре**, подняты два — и предупреждение рядом само утверждает, что их два | Поднят четвёртый (счёт отставания зеркала: тринадцать → четырнадцать). `README.md:6` не тронут намеренно: он про РАТИФИКАЦИЮ, её делает акт оркестратора | греп `ЧЕТЫРНАДЦАТЬ`; в тексте правки названо, что носителей четыре, а не два |
| **F4.** «Роутер монтирует 19 операций из 21» — минор двинул оба числа | Пере-снято: операций в каноне **22**, маршрутов **20**, без маршрута те же две, что названы в строке | `yaml`-разбор канона против таблицы `contractSurface` |
| **F5.** Нового ресурса нет в таблице зависимостей компаньона, которая объявляет себя единственным местом, где это ведётся | Заведены ДВЕ строки — ресурс стопа и агрегат полноты — с носителями 224 и 253 | греп `bank/signing-stop` в README |
| **A.** Удалён доккомментарий `DecodeBank`, несший довод гарда («пустой банк и документ, который эта сборка не умеет читать, декодируются одинаково») | Восстановлен дословно. §8 говорил «больше ничего» — удаление под «ничего» не подходило | греп `replace a book's whole bank with nothing` |
| **B/C.** Гард закрытого словаря стоит на канале и не стоит на `kind`; и сам гард якобы дублирует `nullif` | ⛔ **Замер опроверг вторую половину и усилил первую:** `nullif` делает ДРУГУЮ работу, и без гарда движковое слово не «ложится как неназванное», а **роняет CHECK и всю запись банка**. Гард добавлен на `kind`, оба запинены | `TestAnEngineWordThatLeakedThisFarIsStoredAsNotNamedRatherThanTakingTheBankDown` — до правки красный именно на `kind` |
| **D.** §8 говорит «9 вызовов», их 8 | Испр.: я посчитала изменённые СТРОКИ диффа, а один вызов занимает две | `grep -c 'SaveBank('` → 7 + 1 |
| **Семь чисел консолидации** не получили второй половины вывода — условия, при котором он перестанет держаться | Условие названо: семь полей вошли в писателя ОДНИМ коммитом, поэтому «present» сегодня всегда значит «все семь»; в день восьмого числа `unanswered: 0` станет ложным нулём, и они станут указателями | комментарий у `decodeConsolidation` |
| **Адрес довода §5(б)** неверен | Испр. трижды: `:281``:280` → настоящий носитель `cmd/tmctl/render.go:225-227`. ⚠ И поправка к поправке приёмки: «0 хитов в `mining.go`» — неверно, там **1** | §5(б), контроль напечатан рядом |
| **§2 отчёта** называл одну новую секцию компаньона вместо двух | Испр., §2.26-бис назван | таблица §2 |
### 11-бис. Опись дерева — снята ПОСЛЕ последней правки
**Двадцать один путь, и все внутри разрешённых паком зон** (`platform/` + `docs/architecture/14-api-contract/`):
```
docs/architecture/14-api-contract/README.md platform/internal/ingest/bank.go
docs/architecture/14-api-contract/openapi.yaml platform/internal/pgstore/readmodel.go
platform/docs/DEFECT_REGISTER.md platform/internal/pgstore/readmodel_test.go
platform/docs/platform-PROGRESS.md platform/internal/pgstore/events_test.go
platform/internal/httpapi/capabilities.go platform/internal/pgstore/migrations.sha256
platform/internal/httpapi/project.go platform/internal/readmodel/readmodel.go
platform/internal/httpapi/reading.go platform/internal/readmodel/readmodel_test.go
platform/internal/httpapi/v0.go
platform/internal/httpapi/v0_test.go
новые: platform/internal/pgstore/migrations/00036_bank_read_out.sql
platform/internal/ingest/bankreadout_test.go
platform/internal/pgstore/bankreadout_test.go
platform/internal/httpapi/signingstop_test.go
```
**В дереве лежит ЧУЖОЕ, и оно не моё — коммитить его нельзя:** 16 путей под `backend/` (пак лестницы
попытки, сессия `textmachine-main-12`) плюс `docs/BACKLOG.md` и `docs/experiments/00-provider-quirks.md`.
Я их не трогала ни разу. Коммит — только pathspec-формой по списку выше.
**Двадцать первый путь появился ПОСЛЕ зелёного гейта и назван отдельно**`platform/docs/STACK_DECISIONS.md`,
одна грабля в раздел «Грабли стенда, каждая стоила времени»: TCP-проба стенда отвечает
`Connection refused` на ЖИВОМ сервере, потому что рецепт поднимает его с `listen_addresses=''` и слушает
он только Unix-сокет. Правка инертна к гейту, и это ЗАМЕРЕНО, а не объявлено: файл цитируется в прозе
комментариев 12 раз и не открывается кодом НИ РАЗУ (`grep` по `platform/**/*.go` вне комментариев — 0);
исполняемых путей (`.go`/`.sql`) новее лога зелёного прогона — **0**, при контроле «новее предыдущего
лога — 1», то есть сравнение умеет говорить. Записано потому, что цена этой грабли уже уплачена
приёмкой, а знание, оставшееся в переписке, следующей смене не достаётся.
**Один побочный эффект, который надо знать при лендинге:** две мои строки в `DEFECT_REGISTER.md`
(а теперь три — `PD-467`, `PD-468`, `PD-469`) двигают числа, которые стережёт `docs/scripts/counts.py`,
а живут они в `docs/PROGRESS.md` — зона оркестратора, куда платформа не пишет. Оркестратор знает и
правит тем же коммитом.
**Стенды, оставленные для приёмки** (вне репозитория, git не видит): `/home/ubuntu/tm-mut-94-1709`
копия под мутации вместе с каноном; `/home/ubuntu/tm-head-94-1709` — развёрнутый чистый `HEAD` для
сравнения двух батарей. Postgres стенда поднят мной на порту 55433 и оставлен работать.
### 12. Критерий завершённости — по пунктам пака
- у каждого пункта заказа исход — **§3**;
- круги сошлись, находки закрыты таблицей — **§13**;
- числа ДО и ПОСЛЕ на обоих купленных файлах предъявлены командой — **§1**;
- ряды 224 и 253 — **§14**;
- список отрефакторенного — **§8**;
- гейт зоны зелёный целиком — **§7**;
- **работа завершена, править не планирую.**
### 13. Находки круга самопроверки: находка → что сделано → чем предъявлено
| Находка | Кто нашёл | Что сделано | Чем предъявлено |
|---|---|---|---|
| Состав «8 обязательных / 4 опциональных» в промте неверен; `conventions` обязательно, `variants` опционально | я, замером писателя | замер принят, промт не подгонялся | §4; оркестратор подтвердил и правит промт |
| «В A нет `conventions`» — не опциональность, а ДРУГАЯ СБОРКА движка | я | четыре числа стали указателями | посадка **B**, пин `…MandatoryNumber…` |
| «Обе секции — один момент» ЛОЖНО: полнота едет на трёх границах, вопросы на одной | советчик, проверено мной по `mining.go:171` | форма переиграна: агрегат + отдельный ресурс | §5(а), эррата над запиской |
| `suggestions` врёт читателю: игнор здесь есть принятие | советчик; адрес трижды уточнён (`:281``:280``render.go:225`) | имя `offered` из слов самого канона | §5(б) |
| Гарантию «не читается» изобретать не надо — она ратифицирована у `BankSignatureCount` | я, при проверке имени | `unreadable` взят буквой вместе с обоснованием | посадка **H**, контракт §`BankSigningStop` |
| Советчик перегнул: третий член первой страницы якобы правит шапку спеки | я, чтением шапки | шапка НЕ тронута — там КЛАСС, а не перечень | §5(а) |
| Советчик: популяции списка и счётчика подписи расходятся | я, замером карты подписи | **опровергнуто на сырье** (69/69, 66/66, разность 0), но механизм реален ⇒ равенство не обещано | §9, проза операции |
| Нулевое значение `ingest.Bank` нарушает CHECK новой таблицы | приёмочный прогон | гарды `BoundaryOrUnknown`/`ChannelOrNone` у шва записи | §8 п.2; вся батарея `pgstore` зелёная |
| Уведомление харнесса сказало «exit 0» на упавшем гейте | я, чтением лога | вердикт читается строкой `MAKE_EXIT` из лога, не уведомлением | §7 |
| Контроль ряда 253 даёт 3, а не заявленный 0 | я, пере-снятием на HEAD | суть ряда верна, число — нет; названо оркестратору | §1 |
| `contradicts`/`bank_holds` — 0 из 135, проекция исполнением не проверена | я | не закрыто: нужна улика, а не код | **PD-468** |
| Якорь свежести хранится и не сверяется | советчик, принято | не строила; обещание ресурса сужено прозой до «ПОСЛЕДНИЙ стоп» | **PD-467** |
| `variants` — склеенные ярлыки с английским фрагментом | я, по пункту 4 аддендума | пропущено как непрозрачный текст, разбирать нельзя | ряд **479** (завёл оркестратор) |
| Гейт ряда 353 сработал: у секции появился читатель | я | пинг движковой зоне через оркестратора | §9 п.2 |
### 14. Ряды 224 и 253 — что закрыто и что осталось
**224** — платформенная половина **ЗАКРЫТА**: у секции есть читатель, хранилище и поверхность; её
собственный контроль ушёл с 0 на 1. Осталось за пределами ряда — ряд **479** (части ярлыка `variants`).
**253** — платформенная половина **ЗАКРЫТА**: поле на `BankPage` есть, бит `complete` берётся у движка
ГОТОВЫМ и не выводится у меня (ратифицированное требование ряда соблюдено буквой). ⚠ Осталась
ЧУЖАЯ работа, которую этот пак только разбудил: ряд **353** обещал вернуться к исчерпывающему пину
контракта секции «когда у неё появится ЧИТАТЕЛЬ», и читатель появился — вместе с висящим там долгом
про отсутствие свидетеля у `SettledByBank`. Это движковая зона.
### 15. Аддендум владельца 17.09 — отдельным пунктом, как просил оркестратор
1. **«Тщательно проектируй решение».** Три развилки предъявлены запиской ДО кода; две из трёх я потом
переиграла, и обе — не по вкусу, а по опровержению довода кодом (§5). Эррата над запиской оставляет
видимым и прежнее решение, и причину отмены.
2. **«Комментарии — только нужные, без странных гарантий».** Аддендум пришёл, когда ридер уже был
написан, и **изменил написанное**: я прошла по своим комментариям и сократила их, оставив «почему» и
выбросив пересказ строки. Где комментарий несёт ВЫВОД, названо условие его конца — прямо у
`Invented`: «плоский bool, потому что движок опускает поле при `false`; в день, когда `omitempty`
уйдёт, отсутствие перестанет значить `false` и поле обязано стать указателем — и НИЧТО здесь в этот
день не покраснеет, гарда — эта фраза». Гарантий, которых не стережёт пин, я не писала: каждое
утверждение §4.2 имеет посадку в §11.
3. **«Прозу в код не писать»** — и плоскости не спутаны: в Go минимум, в спецификации описание, потому
что там проза НОРМАТИВНА и компилируется в исходник генерируемого клиента.
4. **«Решение общее для книг и языков».** Ревью-вопрос «заработает ли пара, которой в репо нет, без
правки Go?» — **да**: ни одно поле секций и ни одно значение их словарей не несёт пара-специфики,
ветвлений по паре/книге в добавленном Go нет, а закрытые словари (`kind`, `channel`, граница) — про
устройство пайплайна, не про язык. ⚠ И ровно этот вопрос дал находку: **`variants` несут английский
фрагмент фразы внутри данных** («proposed for X»), то есть клиент не отрисует их на языке читателя.
Не зашила и не пере-вывела — назвала; ряд **479**.
5. **«Советчиков до двух»** — использован ОДИН, с постоянным контекстом, на проектировании (развилка
формы и имени). Его посылки проверены деревом: одна опровергнута (шапка спеки), одна опровергнута
замером (популяции), остальные приняты и изменили работу.
6. **«Право грепать доки и полигон»** — воспользовалась при проверке доводов советчика.
## ЗАПИСКА-ПЛАН (17.09) — ЧИТАТЕЛЬ СЕКЦИЙ ПРЕДЛОЖЕНИЙ И КОНСОЛИДАЦИИ (ряды 224 · 253)
Пак `docs/PLATFORM_BANK_READOUT_SESSION_PROMPT.md` (180c41e), сессия `textmachine-main-94`.
Записка написана ДО первой правки кода, как требует §8 пака: по ней приёмка судит работу против
замысла, а не против домысла. Решения ниже названы явно; передумаю с доводом — напишу это здесь же.
> ⚠⚠ **ЭРРАТА 17.09 — ДВА РЕШЕНИЯ ЭТОЙ ЗАПИСКИ ОТМЕНЕНЫ, ЧИТАТЬ ВМЕСТЕ С §5 ОТЧЁТА ВЫШЕ.**
> Тело записки НЕ переписано задним числом намеренно: смена замысла и её довод — сама по себе улика,
> и затирать её значило бы предъявить приёмке замысел, которого у меня в тот момент не было.
> **(1) Имя.** Записка обещала `suggestions` — **отменено**, действует **`offered`** (ресурс
> `.../bank/signing-stop`, схема `OfferedTerm`). Довод записки был «ноль хитов в спеке» — он
> доказывает, что имя СВОБОДНО, а не что оно ВЕРНО. Отменяющий довод: игнор здесь есть ПРИНЯТИЕ
> (сильнейший носитель — `backend/cmd/tmctl/render.go:225-227`, операторский вывод, который человек
> читает в момент подписи; ⚠ адрес испр. 17.09 — в первой редакции стоял `mining.go:281`),
> а «suggestion» обещает необязательность; и `offered` контракт уже употребляет
> об этом же предмете («the surfaces the LAST signing stop offered»).
> **(2) Форма.** Записка обещала обе секции на `BankPage` — **отменено**: полнота осталась агрегатом
> `BankPage`, а вопросы стопа уехали в свой ресурс. Довод записки («обе описывают ОДИН момент»)
> опровергнут кодом движка: `r.lastTerminology` ставится ДО развилки «стоп/не стоп», поэтому полнота
> едет на трёх границах из пяти, а вопросы — на одной.
> Остальное записки — состав полей, три состояния уверенности, перевод словаря границ, отказ выдумывать
> идентичность, хранилище в БД — **действует без изменений**.
### Что меряю и чем (замер, не пересказ)
Обе купленные проекции прочитаны копиями в скретчпаде (`sha256` копий совпал с оригиналами, `mtime`
оригиналов не тронут). Числа сошлись с промтом по КОЛИЧЕСТВУ и **разошлись по СОСТАВУ** — это находка,
и она ушла пингом оркестратору, а не выровнялась молча:
| | A | B |
|---|---|---|
| `terms` | 0 | 0 |
| `proposed` | 69 | 66 |
| `consolidation` | объект из 7 полей | объект из 7 полей |
| `as_of` | `bank-mining/signature-stop` | `bank-mining/signature-stop` |
| различных `src` | 69 из 69 | 66 из 66 |
Восемь полей, которые движок пишет ВСЕГДА (у них нет `omitempty` в `backend/internal/pipeline/bankexport.go`):
`src dst kind channel freq spread` **`conventions`** `conf`. Четыре с `omitempty`: `invented contradicts
bank_holds` **`variants`**. ⚠ Промт называл `variants` обязательным, а `conventions` опциональным —
наоборот; счёт 8+4 верен, состав нет.
### Находка, ради которой состав и переснимался
`conventions` отсутствует у **всех 69** строк прогона A и стоит у **всех 66** строк прогона B. У поля нет
`omitempty`, значит `json.Marshal` его не мог опустить ⇒ **A написан сборкой движка ДО `0997e41` (11.09),
добавившего поле**. Это доказательство построением, а не догадка. Следствие несущее: у ОБЯЗАТЕЛЬНОГО поля
«поля нет» значит «другая сборка», и прочесть его нулём — напечатать измерение, которого никто не делал.
Ровно тот ложный ноль, ради которого пак заказан, только на ярус ниже — не у секции, а у поля.
`contradicts` и `bank_holds` не встретились НИ РАЗУ (0 из 135 объектов). Читатель их несёт, но живым
сырьём они не проверены — назову это в секции «где прибор слеп».
### Что строю
**1. Ридер (`platform/internal/ingest/bank.go`).** Две новые секции плюс якорь свежести (`as_of`, `run_id`),
которого ридер сегодня не читает вовсе. Наследую КЛАСС гарантий существующего ридера, а не цитирую его:
- «секции нет» ≠ «секция есть и пуста» — обе секции указателями; `nil` ⇔ член документа отсутствовал;
- у ОБЯЗАТЕЛЬНЫХ числовых полей «поля нет» ≠ «ноль»: `conventions` и `conf` — указатели. У `conf` три
состояния, а не два: нет поля · отрицательное («роль не назвала уверенности», закон движка) · `≥0`
(названная уверенность, включая осмысленный ноль);
- у полей с `omitempty` отсутствие есть нулевое значение ПО ЗАКОНУ ПИСАТЕЛЯ, и комментарий назовёт
условие, при котором вывод перестанет держаться (движок снимет `omitempty` — и тогда `invented`
обязан стать указателем);
- **граница читается вместе с секцией.** «Граница не та» обязано отличаться от «предложений нет»:
документ с `as_of: run-finished` и без секции — это не «нечего подписывать», это «read-out снят не в
том месте».
**2. Словарь границ ПЕРЕВОДИТСЯ, а не проходит насквозь.** Шапка `bank.go` ратифицирует: каждое
пересечение словаря переводится, движковые слова наружу не идут. `bank-mining/signature-stop` — имя
СТАДИИ конвейера, тот самый класс. Пять значений сняты грепом `exportBank(` по `backend/` (пять живых
вызовов, не из комментария), неизвестное шестое читается как то, что заявляет о себе меньше всего, и
НИКОГДА как стоп.
**3. Наружу — контрактный минор `0.16.0`** (канон `0.15.0`, полоса мажора `0`, добавление = минор),
вместе с ратифицированным компаньоном `README.md`. ⚠ Номер живёт в ДВУХ местах шапки компаньона, и
предыдущие три бампа правили только `openapi.yaml` — правлю оба.
**Коллизия имени — решение.** `proposed` в контракте уже значит СТАТУС строки (`TermStatus`), в проекции —
ИМЯ СЕКЦИИ. Третьего смысла не завожу, существующий не переименовываю, наружу слово `proposed` не беру
вовсе. Внешнее имя — **`suggestions`** (контроль: `suggestion`/`Suggestion` в `openapi.yaml` — 0 хитов,
в компаньоне — 0). Не `candidate`: это движковый тип (`terminology.Candidate`), то есть словарь, который
шапка `bank.go` велит переводить, а не заимствовать.
**Идентичность предложения — решение, а не пинг.** `id` у предложения нет ни в одном из 135 объектов, и
выдумывать его у себя запрещено. Развилка разбивается надвое, и обе половины закрываются БЕЗ выдумки:
- **чтение:** предложения в дельта-чтении не участвуют ВОВСЕ и едут всегда целиком. Дельта над ними не
выразима по построению (нет идентичности), а порядок — ранжирование стопа, которое между прогонами
меняется. Это пишется в контракт прозой, а не подразумевается;
- **действие:** контракт УЖЕ говорит, чем адресуется поверхность, которой в банке нет —
`BankCorrection`, кортежная форма: «for a surface the bank does not list, send `sense: ""` and `null`
windows». То есть дверь для решения по предложению построена и ратифицирована; своего ключа ей не надо.
**4. Хранилище — в БД, миграцией `00036`, не на лету.** Довод — исполнением, а не вкусом: реплика API
может не иметь движка вовсе (`platform/internal/readmodel/readmodel.go`: «A replica with no engine serves
what was materialized before»), а путь артефакта берётся из манифеста, чтение которого стоит вызова движка
с ре-чанком исходника (`PD-248`). Чтение на лету в HTTP-пути перенесло бы пустой экран с одной причины на
другую — и именно на том развёртывании, где его труднее всего увидеть.
Дельта-чтение и ревизию это не ломает, и вот почему: предложения ложатся ОТДЕЛЬНОЙ таблицей, заменяемой
целиком, и в машинерию `bank_reset_revision` не входят. Втянуть их туда было бы дефектом: у них нет
идентичности, значит КАЖДАЯ материализация читалась бы как «строки исчезли» и держала бы книгу в вечном
сбросе ревизии.
**5. Чего НЕ отдаю наружу.** `run_id` проекции хранится, но на провод не идёт: это движковая идентичность
(`tm-stream-<runID>-<attempt>`, `pgstore.EngineStreamID`), а шов движковых идентичностей не экспортирует.
Хранится потому, что у движка якорь свежести из ДВУХ полей, и взять форму без её гарантии — известный
класс ошибки.
### Гейт и его условия на этом хосте (называю ВМЕСТЕ с числом, как требует §3.1 стандарта зоны)
`TM_PLATFORM_TEST_DSN` был UNSET — **поднят мной** по рецепту `STACK_DECISIONS.md` («Postgres на стенде без
root», порт 55433). Контроль, что гейт действительно открыт, а не объявлен открытым: тот же селектор
`-run Bank` в `internal/pgstore` даёт **4 SKIP** без переменной и **4 PASS** с ней.
Не подняты: `TM_PLATFORM_TEST_ENGINE_BIN` + `TM_PLATFORM_TEST_BOOK_TEMPLATE` (пара). Они открывают живые
движковые пробы `internal/runner` (`backup_live` · `bankapply_live` · `build_live` · `priceprojection_live` ·
`translate_resnapshot_live`) и `internal/books` — ни одна из них не про читатель проекции; `artifacts_test.go`,
где живёт мой предмет, этой парой НЕ гейтится (контроль: греп `TM_PLATFORM_TEST_ENGINE_BIN` по
`platform/internal/runner/*_test.go` даёт 5 файлов, и `artifacts_test.go` среди них нет).
## ⚠ ПИНГ ОРКЕСТРАТОРА №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-37`)
> Пак `docs/PLATFORM_ANSWER_THAT_DOES_NOT_DEPEND_ON_LUCK_SESSION_PROMPT.md`. Дерево передано
> оркестратору №23, **сессия не коммитит**. Платных вызовов ноль. Зона записи — `platform/` плюс канон
> `docs/architecture/14-api-contract/` ровно в объёме минора **0.15.0** и только вместе с кодом.
### 0-бис. ВТОРОЙ ДОФИКС ПО ВТОРОМУ КРУГУ ПРИЁМКИ (11.09) — семь находок, и ОДНО ОБЩЕЕ ПРАВИЛО
⛔⛔ **ПРАВИЛО, КОТОРОЕ СВЯЗЫВАЕТ ОБЕ ДЕНЕЖНЫЕ НАХОДКИ И ДОРОЖЕ ИХ ОБЕИХ: оба моих денежных
предохранителя проверены НЕ ТЕМ ПРИБОРОМ, и одним и тем же движением — предмет судили ПО ДОКУМЕНТУ
там, где исполнение было доступно и бесплатно.** Гард сверял ШАБЛОН, хотя движок грузит РЕНДЕР;
рецепт $0-шаблона сверялся текстовым сканером, хотя рядом на диске лежал загрузчик. Оба раза прибор
отвечал на соседний вопрос, и оба раза я записала ответ как доказательство.
⭐ **И ответ лежал В ДЕРЕВЕ — его написал комментарий того самого хелпера, строкой выше того места,
где остановилось моё чтение.** `writeProbeBook` не отдаёт движку пайплайн шаблона: он БЕЗУСЛОВНО
выводит из него $0-пайплайн (`zeroCostPipeline`), и его собственный комментарий говорит это прямо,
включая «a paid model, on every stand built by the recipe in STACK_DECISIONS». Механизм строился под
задачу, которую дерево уже решило и задокументировало. Норма, которую это подтверждает третий раз за
смену: **комментарий рядом с предметом — носитель, и его читают ПЕРЕД тем, как строить механизм.**
| # | что было неверно | что сделано | чем предъявлено |
|---|---|---|---|
| **1** | ⛔ **Гард судил ШАБЛОН, а движок грузит РЕНДЕР.** ⇒ он отказывал стендам, чья настоящая конфигурация бесплатна, и **не мог увидеть единственный случай, в котором деньги тратятся** — вендора, ПЕРЕЖИВШЕГО дериват. А дериват — рерайтер ПО КЛЮЧАМ, ровно класс F1 | вопрос перенесён на рендер — файл, который проба кладёт рядом с книгой. Плюс: платный рендер стал **дефектом** (`Fatalf`), а не условием хоста, потому что рендер — НАШ артефакт | `runner.TestTheGuardSeesAVendorThatSurvivedTheZeroCostDerivation` (вендор под ключом, которого дериват не знает, → гард называет его) и `runner.TestTheRenderOfAnOrdinaryPaidTemplateComesOutFree` (обычный платный шаблон → рендер свободен) |
| **2** | **Мой новый рецепт $0-шаблона производил пайплайн, который движок отказывается грузить** — и это F1 второй раз, слоем ниже: рецепт снова проверен гардом-сканером вместо загрузчика | ⭐ **рецепт снесён целиком, а не починен** (санкция оркестратора): $0-шаблон никогда не берёг денег, проба выводит $0-пайплайн сама. Развилка П-22 схлопнута, размен П-23 снят — мина стережётся по умолчанию | мой прогон настоящего `tmctl`: `stage "draft": escalate_to must differ from the primary model "local-qwen3-8b"`, `EXIT=10`; контроль — нетронутый `pipeline-c1` там же, `EXIT=0` и выданный манифест |
| **3** | ⛔ **M26 — НЕ эквивалентный мутант. Моё «окно недостижимо» — худший методологический промах за оба круга:** зонд считал, доходят ли МОИ фикстуры, а я записала ответ как утверждение о достижимости | фикстура приёмки вставлена: окно берётся не гонкой, а аранжировкой под замком книги — держать строку книги, дождаться по `pg_stat_activity`, что резюм ДЕЙСТВИТЕЛЬНО встал, вписать в зазор попытку победителя и перевести прогон в `paused`, отпустить | `runs.TestTheLoserOfTheIndexRaceIsAnsweredFromTheRowAndNotFromItsSnapshot`: на дереве PASS с `ceiling_reached`, на мутанте **RED**`the loser was handed a "paused" run with no error` |
| **5** | `continuing` на ветви `translating` — голый `ReadRun` выживал; приёмка фикстуру построить не смогла и сказала это прямо | **фикстура построена:** ветвь достижима через ВНУТРИПРОЦЕССНЫЙ замок книги, который стоит между снимком и веткой. Стальность снимка АСЕРТИТСЯ по формулировке: «ended while this call was deciding» против «it is failed» | `runs.TestTheGoingRunsAnswerIsMadeOfTheRowAndNotOfTheSnapshot`: 3 из 3 PASS, на мутанте **RED**`a caller whose snapshot said «translating» was handed a "failed" run with no error` |
| **4** | **Вычерк на самой громкой площадке (`resync failed`) не запинен:** страж `sourceThere` перехватывал мою фикстуру «каталог удалён», и до строки с вычерком пин не доходил | фикстура того же вида, что для settlement: каталог НА МЕСТЕ, юнит ЖИВ, движок падает на файле внутри | `runs.TestTheResyncOfAPresentDirectoryCarriesTheEnginesTextWithoutTheBook`; мутант «вычерк снят» → **RED** с текстом про WARN над присутствующим каталогом |
| **6** | **Пятый носитель класса, которого не было в моей переписи:** `exports.go` кладёт `"book", row.BookID` на INFO | снят; ручкой остаётся id экспорта — собственный опаковый идентификатор сервиса | `exports.TestTheBuiltExportIsLoggedByItsOwnIdAndNotByItsBook`; мутант «book_id вернулся» → **RED** |
| **7** | Две ветви гарда без пинов | закрыты фикстурами: шаблон/каталог, которых нет или которые не разбираются; каталог без моделей; и отдельный пин на адреса — петлевой по имени, IPv6, приватный-но-чужой, **пустой** (у движка `base_url` не обязателен для `kind: anthropic`) и неразбираемый | `runner.TestAnAddressIsThisHostsOnlyWhenItSaysSo` (8 подтестов) и шесть подтестов `TestTheLiveGuardRefusesWhatItCannotResolve` |
⛔ **И ЕЩЁ ОДИН ДЕФЕКТ ПРИБОРА, найденный мной при исполнении этого же дофикса: мой мутационный
харнесс читал ненулевой выход `go test` как «поймано».** Два пакета склеивались в ОДИН аргумент,
команда не разрешалась, выход был ненулевым — и три записи получили ложное RED. Нашла пере-проверкой
одной из них руками. **Харнесс положен в дерево** (`platform/tools/mutate.py`) с обоими правилами
внутри кода, а не в дисциплине: пакеты идут списком, а ненулевой выход без единой строки `--- FAIL`
считается НЕ ИЗМЕРЕННЫМ, а не пойманным. Числа этого пака сняты уже исправленным.
**ЧТО ОСТАЛОСЬ НЕ ЗАПИНЕННЫМ И ПОЧЕМУ — третий случай одного класса за смену, называю как класс.**
Посадка «гард спрашивает шаблон вместо рендера» **ВЫЖИЛА**, и причина не в гарде: на ЭТОМ хосте живая
проба скипается ВНУТРИ `writeProbeBook` (нет артефакта контраста банкового контура) ещё до того, как
гард спросят. То же было с посадкой «согласие снято у живого теста» в первом дофиксе. **Класс: АДРЕС
вызова — то, О ЧЁМ спрашивают и спрашивают ли вообще — не пинится изнутри того же набора, когда сам
вызывающий тест не бежит.** Поймает это хост, у которого артефакт контраста есть: там проба доходит до
гарда, и мис-аим краснеет `Fatalf`-ом, который для этого и поставлен. Артефакт подделывать не стала:
он операторский вход чужой зоны, и фальшивый список частотности — это отладка не своего предмета.
### 0. ДОФИКС ПО ПРИЁМКЕ (11.09) — шесть находок, все взяты, две были ДЕНЕЖНЫМИ
Приёмка судила готовую работу независимым проходом: ядро §4.1 выдержало все посадки (ветка снята → RED
×3, перенесена ниже стража → RED, оператор инвертирован → RED ×3, вырез снят → RED, «ровно один холд» —
8 RED из 8 одним текстом), а вокруг него нашлось шесть дефектов. Все мои.
| # | что было неверно | что сделано | чем предъявлено |
|---|---|---|---|
| **F1** | ⛔ **ГАРД СОГЛАСИЯ ОЧИЩАЛ КАК БЕСПЛАТНЫЙ ТОТ САМЫЙ $0-ШАБЛОН, РАДИ КОТОРОГО СТРОИЛСЯ.** Он собирал модели под ключом `model`, а эскалация живёт под `escalate_to:`, `escalation.chains.default: [...]`, `adult: [...]` — и рецепт зоны переписывал только `model:`-строки | **предикат перестал зависеть от ключей вообще**: каталог говорит, какие модели не на этом хосте, и гард ищет их ИМЕНА в байтах пайплайна. Новый ключ не может его ослепить по построению; имя вендора в комментарии тоже останавливает прогон — сторона ошибки безопасная | замер на файле, собранном ровно прежним рецептом: строк `model:` с вендором **0**, вхождений вендорских имён **17** (`deepseek-v4-pro` 9, `gemini-3.1-pro-preview` 3, `deepseek-v4-flash` 2, `glm-5.1` 2, `grok-4.3` 1). Новый гард на нём отвечает `names "deepseek-v4-flash"` и не пускает |
| **F1-рецепт** | рецепт $0-шаблона производил файл, который гард (верно) отвергает ⇒ выход «возьми $0-шаблон» был мнимым | рецепт пере-писан: замена по ИМЕНАМ моделей, выведенным из `models.yaml`, **без списка ключей** — список ключей и был тем, что промахнулось. Плюс рецепт теперь ПРОВЕРЯЕТСЯ ГАРДОМ, а не глазом | на правильно собранном файле гард пропускает и тест доходит до следующего условия; на платном — отказывает, назвав модель |
| **F2** | **слепота гарда гасилась СКИПОМ**: хелпер был и ассертом, и скип-условием, поэтому мутация «перестань видеть модели» выходила зелёной | хелпер стал ЧИСТОЙ функцией `(paid, decided, why)`; скипает вызывающий живой тест, а пин утверждает `decided=false` на фикстуре слепоты | мутант «слепота = бесплатно» → RED: `the guard answered (paid "", decided true), want ("", false)` |
| **F5** | **`continuing` — функция, на которой стоят ОБЕ гарантии, — не была запинена ничем**; и её свежая проверка отдавала 409 без причины там, где канон велит `ceiling_reached` | запинена ПРЯМО, четырьмя состояниями, плюс причина приведена к канону | мутанты: «свежая проверка снята» → RED ×3, «причина снята» → RED ×2, оба с текстом про предмет |
| **F3/F4** | **носителей утечки пути было ЧЕТЫРЕ, а не два**: классификация спрашивала `os.Stat` КОРНЯ, и движок, упавший на файле ВНУТРИ существующего каталога, нёс путь мимо неё; четвёртый носитель — **мой собственный WARN в `backup.go`**. ⚠ И моя же «вторая половина» пина брала единственную движковую ошибку БЕЗ пути — выбери она любую другую, соседний пин упал бы | **правило перестало быть перечнем случаев**: `books.WithoutItsPath` ВЫЧЁРКИВАЕТ каталог книги и её id из любого текста перед логированием, на всех четырёх площадках. Слепая половина пина заменена зрячей — теперь фикстура берёт ошибку, которая путь НЕСЁТ | мутанты: «вычерк ничего не вычёркивает» → RED, «вычерк съедает всё» → RED (диагноз пропал), «id сам по себе не вычёркивается» → RED, «вычерк снят у `backup.go`» → RED |
| **F6** | вакуумный блок в проводном пине: после `Fatalf` сравнение с соседними причинами недостижимо | снят; соседние причины пинятся там, где ОТВЕЧАЮТСЯ (`runs.TestResumeOverAnEmptyAccountSaysTheCreditIsUnavailableAndNotTheCeiling`) | посадкой приёмки |
| **F7** | размен П-23 был хуже объявленного — обе ветки развилки непроверенное состояние | закрыт вместе с F1-рецептом; ряд пере-написан с замером | см. F1-рецепт |
**ДВЕ ВЕЩИ, КОТОРЫЕ НАШЛА НЕ ПРИЁМКА, А МОЙ СОБСТВЕННЫЙ ПЕРЕ-ЗАМЕР, и обе — дефекты приборов.**
1. **Мой мутационный харнесс склеивал два пакета в ОДИН аргумент `go test`**, тот не мог его разрешить,
выходил ненулевым — и харнесс читал это как «поймано». Так три записи получили ложное «RED». Поймано
на M30: пере-проверка руками показала, что мутант ЖИВ. Харнесс исправлен (пакеты списком), все
затронутые записи пере-сняты; M30 после этого действительно выжила, и под неё написан пин.
2. **Мой же новый пин гарда флейковал**: ответ зависел от порядка обхода карты, и фикстура с ДВУМЯ
вендорами называла то одного, то другого. Ответ отсортирован, и на это заведён отдельный пин из
двадцати повторов.
⚠ **И одно, о чём я говорю прямо: M26 («проигравшему гонку возвращён голый `ReadRun`») ВЫЖИЛА, и я
считаю её эквивалентным мутантом — с замером, а не с доводом.** Ветка проигравшего достижима только
после того, как победитель вставил попытку N+1, то есть прогон в этот момент `translating`, а там
`continuing` и `ReadRun` неразличимы. Зонд на ветке: **достигнута 11 раз, статус, отличный от
`translating`, — 0 раз**. Развести их могла бы только отмена прогона между двумя соседними операторами,
а `Service.Store` — конкретный `*pgstore.Store` без шва, куда можно вклиниться. ⇒ окно реально (в бою
реконсилятор может завершить прогон в нём), фикстурой недостижимо, и `continuing` — безопасная его
сторона. Называю слепым пятном, а не поимкой.
### 1. Что закрыто: резюм стал идемпотентным по существу (§4.1, `PD-448`)
**Ответ двери больше не зависит от того, кто успел первым.** Ветка `case "translating"` отвечает
ПРОГОНОМ — тем же оператором `continuing`, которым отвечает проигравший уникальному индексу, так что
два пути не могут разойтись правкой одного. Вырез: прогон с УЖЕ запрошенным стопом остаётся `409` с
причиной `stop_requested` (`D39.240` — остановка жёсткая, и «продолжается» там ложь). Ветка стоит ВЫШЕ
проверки re-pass; сама проверка уехала ПОД свитч, к прочим стражам ПЕРЕ-ОТКРЫТИЯ, и это превращает
свитч в единственное место, где решает статус.
**ПРЕДМЕТ РЯДА ОКАЗАЛСЯ ШИРЕ, ЧЕМ РЯД, и это находка замера, а не уточнение прозы.** Ряд описывал
гонку в миллисекунды под `load average 4248`. Замерено счётчиками путей в копии дерева:
| вопрос | команда | ответ |
|---|---|---|
| держится ли гарантия сегодня | `go test ./internal/runs/ -race -count=20 -run '^TestTwoResumesOfOneRunTakeOneHoldAndBothAnswer$'` | **20 PASS из 20** — пин зелен |
| ЧЕМ он удовлетворён | счётчики путей в `Resume`, `-count=5` | **5 из 5: `winner=1 staleLoser=1 freshRefusal=0`** — держит только ветка устаревшего снимка, дефектная НЕДОСТИЖИМА |
| второй порядок | два `Resume` подряд, та же фикстура, `-count=5` | **5 из 5 отказ**: `first: status="translating" err=<nil>` · `second: err=runs: the run cannot be continued: it is translating` |
⇒ отказ получал **любой** резюм живого прогона; гонка — узкий частный случай. Человеку довольно
секунды между кликами. Старый пин был зелёным именно потому, что его фикстура измеряла половину
сценария. Тело ряда `PD-448` пере-написано под это, ряд закрыт, вес оставлен `minor` с доводом:
рубрика регистра взвешивает ПОТЕРЮ, а денег на отказном пути не двигалось —
`attempts=2 reserved=3.319828 balance=6.180172 ledger=6.180172`.
**Остаток окна, названный, а не закрытый.** `stop_requested` читается в снимке, взятом ДО замка книги,
а `Stop` замка книги не берёт вовсе (его первый оператор — запись намерения). Стоп, нажатый между
снимком и ответом, даёт один `202` на сворачивающийся прогон: один кадр, денег не двигает, и тело
ответа несёт `stop_requested` из СВЕЖЕГО чтения строки — клиент узнаёт про стоп тем же ответом.
Закрыть окно значило бы сериализовать `Stop` за замком книги, то есть поставить ожидание на путь, чей
смысл в том, что он не ждёт. Цена бездействия названа: один кадр, ноль денег.
**Канон — тем же деревом, минор `0.15.0`.** Правлены строка `translating` таблицы §`resumeRun` (теперь
две строки: идущий прогон и прогон под стопом) и фраза «a `202` therefore means work was actually
reopened» — она была неверна и ДО этого пака, потому что дерево отдавало `202` проигравшему гонку и это
было запинено шапкой собственного теста зоны. Второй уровень причин расширен `stop_requested`; словарь
`ErrorCause` объявлен каноном ОТКРЫТЫМ, поэтому номер версии двигает строка таблицы, а не причина.
Провенанс — §2.26 компаньона. Константа `httpapi.ContractVersion` поднята тем же деревом.
### 2. Что НЕ строится, с доводом и ценой
**§4.2 `PD-162` — автоматического терминального состояния НЕ будет.** Довод не в цене работы, и он
опирается на замер, а не на чтение.
| носитель клина | прогон доходит до конца? | холд возвращается? | чем |
|---|---|---|---|
| попытка НЕ дошла до движка (нет юнита, нет базовой линии) | да — собственным стопом пользователя | **да, целиком и автоматически** | уже построенный `ReleaseUnspawned` |
| попытка ДОШЛА, счётчик движка нечитаем | да — тем же стопом | **нет** — до `run abandon` оператора | эскроу `П-18` |
Замер (8 проходов свипа над прогоном с удалённым каталогом): `status=translating failures=1…8
openHolds=1 held=0.444828`, строка в списке оператора с пятого прохода; затем стоп пользователя —
`status=stopped finished=true`, холд остаётся. Для второго носителя (отказ спавна без всякого удаления
каталога): `AFTER 6 PASSES: status=translating openHolds=1``AFTER STOP: status=stopped openHolds=0
reserved=0.000000 balance=20.000000`.
**Утверждение ряда «ни один путь не приходит к терминальному состоянию» неверно**: путь есть и он в
руках у того, чьи это деньги. Не возвращается только ХОЛД, и только в одном из двух носителей. А чтобы
его вернуть, надо ответить «сколько списать, когда мерить нечем» — это и есть эскроу: отдать целиком
значит не взять денег за работу, уже оплаченную провайдеру; взять целиком — наказать пользователя за
аварию оператора. Обе половины цены названы в самом CLI (`run abandon`: «would return the WHOLE hold and
charge nothing for what the run actually spent»). Плюс два довода, которые стоят сами по себе: счётчик
неудач такого вопроса не решает («I could not ask» никогда не «the run is gone» — записано в дереве,
комментарий `backoff`), и снятие холда обязано ехать с КОРРЕКТНОЙ остановкой процесса, иначе вечный
холд меняется на потерянные деньги плюс живой движок, тратящий против прогона, который никто не
считает. Продуктовый пробел назван и НЕ закрыт: человек видит «идёт» и не знает, что стоит нажать стоп.
**§4.3 `PD-466` — факт в БД НЕ строится сейчас.** Названное дешёвым лечение дешёвым не является:
писателя у этого факта в дереве НЕТ (пропажу видит только проход бэкапа и разбор интейка — для книги,
уже прошедшей приём, никто её в базу не пишет). Завести факт значит завести проход, который статит
каталог КАЖДОЙ книги, а такт реконсилятора ПОСЛЕДОВАТЕЛЕН и его собственный файл уже замерил цену
блокирующего прохода: он задерживает расчёт денег, ретраи интейка, GC экспортов и все гейджи. То есть
цена зависшего монтирования, которую ряд увёл с опрашиваемого пути, вернулась бы на денежный. И второе:
кэшированное «каталога нет» — это класс `PD-192` с более длинным фитилём; предикат двери верен именно
тем, что спрашивает в момент решения. Ряд остаётся открытым честным остатком: обещание формы ложно,
денег это не двигает, дверь остаётся авторитетом.
**§4.4 `PD-139` — одна диспозиция, и она выведена замером.** ⚠ Формулировка заказа («в дереве две
диспозиции на один класс») предполагала то, что надо было сначала померить, и оркестратор принял
поправку релеем. Мерил первым: путь каталога и идентификатор книги — ОДНО И ТО ЖЕ. Интейк кладёт книгу
в `<books dir>/<book id>`, и на живом стенде холодного прогона это так у **5 книг из 5**
(`psql … -c "select id, workdir from books"` по базе `tm_coldrun_door`: `bk_EQWUDWUXXALA7BFP`
`…/books/bk_EQWUDWUXXALA7BFP`). ⇒ класс ОДИН, и двух диспозиций быть не может.
**Правило:** платформа никогда не КЛАДЁТ идентичность книги в лог сама. Где предложение сочиняет она —
называются операция и errno без пути. Где текст чужой программы — он несётся как написан, потому что
переписывать чужой диагноз хуже, чем нести его; работа платформы тогда — сделать так, чтобы обычный
случай до чужого текста не доходил.
**Что из этого построено.** Замер показал утечку НИЖЕ уровня ERROR, там, где интейк свою половину уже
запинил: на клине пропавшего каталога **10 строк из 20 несли путь книги, все на WARN**, одним
сообщением — расчёт цитировал текст движка. Теперь заблокированный расчёт классифицируется своим же
предикатом (`s.sourceThere`) и говорит своими словами, различая пропавший каталог книги и не
смонтированный том; `journalSize` приведён к той же форме, что `sourceThere`. Текст движка остаётся
только там, где каталог НА МЕСТЕ и назвать причину платформе нечем. Остаток назван: на ERROR путь
по-прежнему законен, и закрыть исключение можно, дав `pgstore.StalledRun` колонку каталога — сегодня её
там нет.
### 3. Правка теста по ЗАКАЗАННОЙ смене поведения — объявляется (`D39.121`)
- **Что утверждала фикстура раньше:** два одновременных резюма отвечают прогоном и берут один холд.
Утверждение верное, фикстура — половинная: два горутины, обе читают состояние сразу, и на тихой
машине ветка дефекта недостижима (замер выше, 5 из 5).
- **Что она утверждает теперь:** то же самое, но КАЖДЫЙ из двух порядков имеет свою фикстуру, в которой
он неизбежен. Контендированный порядок держится замком строки, который берёт сам тест, и ВРЕМЯ
ОЖИДАНИЯ асертится — вызов, который не ждал, контенции не встретил и ничего не доказал. Плюс
утверждение о границе: пока оба вызова внутри, закоммиченный статус обязан быть `stopped`.
- **Куда уехала гарантия:** имя теста и его смысл сохранены, файл — `internal/runs/resumeorder_test.go`;
прежнее место в `control_test.go` несёт указатель. Фикстура гоняется на ДВУХ сервисах, потому что
замок книги внутрипроцессный и шапка теста всегда описывала деплой из двух экземпляров.
-**Вторая смена поведения БЫЛА и СНЯТА по находке адверсариального прохода.** Первая форма правки
уводила проверку re-pass под свитч, и это меняло ответ re-pass-прогону в статусах `paused`
(`ceiling_reached`), `failed`/`ready` («it is <status>») — а лекарство `ceiling_reached` канон пишет
«a NEW run with a larger `chapters`» над покупкой, у которой глав нет вовсе. Форма исправлена: ветка
`translating` — отдельный `if` ВЫШЕ стража, страж вернулся на своё место. **В сданном дереве ответ
статусам, которые re-pass никогда не продолжает, не изменён ничем.**
-**ТРЕТЬЯ правка тестов, не заказанная паком и объявляемая отдельно: два пина re-pass укреплены.**
Что утверждали раньше: `errors.Is(err, ErrNotResumable)` — и это удовлетворялось ДВУМЯ чужими
отказами, так что со снятым стражем оба пина проходили 8 из 8. Что утверждают теперь: фикстуры
закрывают оба чужих отказа И проверяются СЛОВА стража. Куда уехала гарантия: никуда — она впервые
появилась. Это не правка ради зелени, а обратное: пин, который ничего не измерял, начал измерять
(`ENGINEERING_STANDARDS` §3 п.4 — свойство без ловящего мутацию теста считается НЕ закрытым).
### 4. Мутации ПЕРВОГО круга: 16 посадок · поймано 14 · ВЫЖИЛО 2 · одна запись недействительна
⚠ **Заголовок исправлен по второму кругу приёмки: здесь стояло «выживших 0», и это противоречило телу
собственной таблицы** (строка M17-до помечена «ВЫЖИЛ 8 из 8») **и секции 0** (M26 выжила, и я объявила
её эквивалентным мутантом — ошибочно, см. 0-бис). Правда: на первом круге выжили ДВЕ — M17-до (страж
re-pass не был запинен) и M26 (ветвь проигравшего). Обе закрыты, первая в первом дофиксе, вторая во
втором.
⛔ **И ТРИ ЗАПИСИ ЭТОЙ ТАБЛИЦЫ БЫЛИ СНЯТЫ СЛОМАННЫМ ПРИБОРОМ — называю поимённо, как и требует
приёмка.** Харнесс склеивал два пакета в один аргумент `go test`, и ненулевой выход неразрешимой
команды читался как «поймано». Пострадали записи, у которых в каталоге стояло ДВА пакета: **M27**
(«вычерк ничего не вычёркивает»), **M28** («вычерк съедает всё») и **M30** («вычерк снят у
`backup.go`»). Все три ПЕРЕ-СНЯТЫ исправленным харнессом: M27 и M28 действительно RED, **M30
действительно ВЫЖИЛА** — и под неё написан пин (`backup.TestTheSweepsOwnWarningDoesNotNameTheBook`),
после которого она краснеет. Прибор положен в дерево (`platform/tools/mutate.py`), так что пере-снять
любую запись теперь есть чем.
Копия с каноном (`cp -a --parents platform docs/architecture/14-api-contract`), базовая линия копии
зелёная, вердикт судился по ТЕКСТУ падения.
| # | что сломано | итог | чем поймано (текст) |
|---|---|---|---|
| M1 | ветка `translating` снята | RED | `resume 1: runs: the run cannot be continued: it is translating — the run is continuing, and a refusal makes the answer depend on which call reached the database first` |
| M2 | вырез `stop_requested` снят | RED | `resuming a run under a stop answered <nil>, want ErrStopRequested` |
| M3 | guard re-pass поднят над свитчем | RED | `resuming a re-pass that is RUNNING answered … a re-pass is bought again rather than resumed` |
| M4 | `continuing` отдаёт пустой прогон | RED | `resume 1 answered run "" in "", want "run_…" translating` |
| M5 | классификация в `settle` снята | RED | `a WARN line carries the book's own directory, which is its identifier: …` |
| M6 | путь возвращён в `stat journal` | RED | `the error carries the book's own directory: runs: stat journal: stat …/bk_THISISTHEBOOKSOWNID/events.jsonl: permission denied` |
| M7 | `ContractVersion` откачена | RED | `this build announces contract 0.14.0 and the ratified canon is 0.15.0` |
| M8 | причина снята с провода | RED | `cause = <nil>, want {code: stop_requested}` |
| M9 | замок снят из МОЕЙ фикстуры гонки | RED | `the run is committed as "translating" while both callers are still inside: … this fixture is measuring the other order` |
| M10 | ветка `translating` ПЕРЕ-ОТКРЫВАЕТ | RED | пойман утверждением о ПОРЯДКЕ, а не о холде — см. ниже |
| M13 | класс назван верно, путь приписан полем | RED | та же строка про WARN |
| M14 | вырез читает не то поле (`StartedAt` вместо `StopRequestedAt`) | RED | `resuming a run under a stop answered <nil>, want ErrStopRequested` |
| M15 | ветка снята **И** снят страж открытой резервации | RED | `2 open holds of 6.639656 after two resumes, want exactly one of 3.319828` |
| M16 | классификация `resync` снята (второй носитель утечки) | RED | `a WARN line of a LIVE run carries the book's own directory: … "msg":"resync failed" …` 3 из 3 |
| M17 | **страж re-pass снят ЦЕЛИКОМ** — посажен ПОСЛЕ укрепления пинов, по находке прохода | RED | **8 FAIL из 8 каждому** из двух пинов: `resuming a re-pass answered <nil> (run {… Stage:re_pass …}), want ErrNotResumable (bought again)` |
| M17-до | тот же страж, снятый ДО укрепления | ⛔ **ВЫЖИЛ 8 из 8** — это и есть находка №1 прохода: оба пина были удовлетворены чужими отказами | — |
**M10 — НЕДЕЙСТВИТЕЛЬНАЯ ЗАПИСЬ, и это мой методологический промах, а не дыра.** Я ослабила
собственное утверждение (`holds != 1``holds > 2`) и записала «выжила». Ослабленное утверждение по
построению не может покраснеть на верном коде; это не мутация. Заменено на M10 и M15.
**И из M10/M15 вышла настоящая находка о МОЁМ пине: «ровно один холд» держит не моя ветка, а база.**
Снятие ответной ветки ко второму холду НЕ приводит — его держит страж открытой резервации в `reopen`, и
счёт холдов краснеет только при ДВУХСАЙТОВОЙ посадке. То есть пин счёта — регрессионный сторож над
гарантией, которую держит СУБД, и говорить, что его удовлетворяет только моя правка, было бы неверно.
### 5. Числа после последней правки
**`make check` со всеми четырьмя гейтами, пере-снят ПОСЛЕ ВТОРОГО дофикса: `MAKE-EXIT=0` · 20 пакетов ·
**20 `ok` · 0 `FAIL`** · линтер «0 issues» · скипов 5 при ОДНОМ условии — артефакт контраста банкового
контура (строка бэклога 251).** ⚠ Второе условие («гард отказал живому переводу») исчезло вместе с
развилкой шаблонов: гард спрашивает о рендере, рендер свободен, и живая проба больше не скипается по
согласию. Шаблон второго гейта — настоящий
`pipeline-c1.yaml` (дефолт, выбранный оркестратором №23 по развилке П-22).
- **Скипы, 5, по условиям — пере-снято командой, а не по памяти** (`go test ./internal/runner/ -v`,
группировка причин): **4** — артефакт контраста банкового контура (`mining-contrast.zh.txt` не на
этом хосте, строка бэклога 251); **1** — гард согласия отказал живому переводу под платным шаблоном
(`this deployment's book template names "deepseek-v4-flash"`). Второе условие — принятый размен,
носитель П-23.
-**Это НЕ то число, с которым пак шёл в середине смены.** На $0-шаблоне батарея давала
`MAKE-EXIT=2 · 19 ok · 1 FAIL` (два теста проекции цены в `internal/runner`), и оба падения были
пре-существующими — доказано контролем: дерево, возвращённое к `HEAD` пофайлово (15 файлов через
`git show HEAD:<file>`, два новых удалены), давало те же два. Зелень пришла не от правки тестов, а от
смены дефолтного шаблона, и её довод — в `STACK_DECISIONS` и П-22.
- **Пакеты, которых касается пак:** `internal/runs`, `internal/httpapi`, `internal/gates`,
`internal/pgstore`, `internal/runner` — все `ok`.
- Оба порядка §4.1 гонялись по десять раз: `go test ./internal/runs/ -race -count=10 -run
'TestTwoResumesOfOneRunTakeOneHoldAndBothAnswer|TestAResumeOfARunAlreadyGoingAnswersWithThatRun'` →
**10 PASS из 10 каждому**. Флейка нет; адверсариальный проход пере-ранил и получил те же 10 из 10.
- Линтер якорей `python3 docs/scripts/counts.py --lint`: в зоне `platform/` **0 проблемных**; всего по
дереву 15, из них **4 сломаны моим переездом и лежат в `docs/` — не моя зона, отдаю пингом** (§7),
остальные 11 — чужих зон и были до меня. ⚠ Свои якоря пере-снимала ТРИЖДЫ: каждая следующая правка
кода двигала строки под уже починенными ссылками.
- **Дифф `^func Test`, снятый командой ПОСЛЕ ВТОРОГО дофикса:** снят один
(`TestTwoResumesOfOneRunTakeOneHoldAndBothAnswer` из `control_test.go` — пере-построен под тем же
именем в `resumeorder_test.go`), добавлено **двадцать четыре**: 7 в `runs/resumeorder_test.go`, 7 в
`runner/liveconsent_test.go`, 5 в `runs/logprivacy_test.go`, 2 в `books/logprivacy_test.go`, по
одному в `httpapi/control_test.go`, `backup/backup_test.go` и `exports/exports_test.go`.
### 5-бис. Адверсариальный проход (субагент `fable`) — восемь находок, СЕМЬ приняты, две ломали мои утверждения
Мандат был «найди отсутствующее и противоречащее». Нашёл, и две находки — дефекты в том, что я объявила
сделанным. Дерево проход не трогал (мутации в своей копии), базовая линия рабочего дерева у него сошлась
с моей.
| # | находка | что сделано | чем предъявлено |
|---|---|---|---|
| 1 | ⛔ **Страж «re-pass покупается заново» НЕ ЗАПИНЕН: мутант со снятым стражем выживал 8 из 8.** Моя контрольная половина и старый K4-пин проверяли только `errors.Is(err, ErrNotResumable)`, а его удовлетворяют ДВА чужих отказа по бокам стража — «a newer run of this book exists» (часы фикстуры стоят, у обоих прогонов книги один `started_at`, и `newestRun` решает по `id`) и «its previous attempt is still being settled» (SQL-фикстура ставила `settled_at`, но резервацию не закрывала) | **ОБА пина укреплены**: фикстуры закрывают оба чужих отказа (`started_at` сдвинут, резервация закрыта) И утверждают СЛОВА стража | после правки мутант «страж снят целиком» даёт **8 FAIL из 8 каждому** из двух пинов, с текстом про предмет |
| 2 | ⛔ **Второй носитель утечки: `resync failed` на WARN несёт путь книги, пока юнит ЖИВ,** и повторяется каждый `ResyncEvery`. Мой пин держал юнит МЁРТВЫМ и до этой ветки не доходил — то есть моё «текст движка остаётся только там, где каталог на месте» было ЛОЖНЫМ | **классификация добавлена и на этот путь**, плюс НОВЫЙ пин с живым юнитом | пин печатает `4 строки, 4 несут id прогона, 3 называют каталог`; мутант «классификация снята» → **3 FAIL из 3** с текстом `a WARN line of a LIVE run carries the book's own directory` |
| 3 | **Paused re-pass стал отвечать `ceiling_reached`**, чьё лекарство канон пишет «a NEW run with a larger `chapters`» — над покупкой, у которой глав нет вовсе | **форма исправлена: ветка `translating` стала отдельным `if` ВЫШЕ стража re-pass, а сам страж вернулся на своё место над свитчем.** Ответ статусам, которые re-pass никогда не продолжает, больше не меняется — незаказанная смена поведения снята | сборка + прежние пины зелёные; заказ §4.1 («ветка ВЫШЕ проверки re-pass») исполнен буквально |
| 4 | **Окно «прогон кончился между снимком и ответом» названо только для стопа**: над прогоном, успевшим уйти в `paused`, ответ был `202`, а канон велит читать `202` как «идёт» | **закрыто построением**: `continuing` теперь судит по той же строке, из которой делает ответ, — не `translating` в свежем чтении ⇒ отказ, а не `202`. Окно сжалось до «состояние изменилось ПОСЛЕ ответа», то есть до течения времени | сборка + пины порядка зелёные |
| 5 | **Довод под `os.Stat` в `settle` неверен в той форме, в какой я его написала**: «вызов движка уже ходил в тот же каталог» ложно для самой частой формы (`PD-384` — снесённый бинарь: exec падает, тома не касаясь) | **комментарий переписан честно**: стат назван ПЕРВЫМ касанием монтирования, сказано, что контекст такта он не чтит и такт последователен, экспозиция помечена «названа, не замерена» | — (правка прозы; замерить нечем — зависшее монтирование на этой машине не воспроизводится) |
| 6 | **Сентинел `ErrStopRequested` на пути `reopen` не запинен** — гарантия «оба стража отвечают одним словом» живёт в комментарии | **не чиню, называю**: путь гоночный (стоп между снимком и замком строки внутри `RestartRun`), фикстуры под него у меня нет, а выдуманная фикстура пинила бы не его | — |
| 7 | **Граница пина утечки**: исключение «на ERROR путь можно» пропускает и ПОВТОРЯЮЩИЙСЯ идентификатор | принято дословно в §6 «где прибор слеп» | — |
| 8 | **Проза и якоря**: докстринг `Resume` устарел · комментарий `bank_test.go` ложен для живого re-pass · голый номер `reconcile.go:1721` в `PD-162` уехал (гейт якорей голых номеров не видит) · компаньон канона говорил «канон 0.13.0, ОДИННАДЦАТЬ миноров» при 0.15.0 · **моё число «10 строк из 20» из дерева не воспроизводится** | всё исправлено; число заменено на то, что печатают сами пины (14/6 на мёртвом юните, 4/3 на живом), с пометкой, откуда взялось прежнее | якорь `:1808` пере-снят командой; счёт миноров исправлен с пометкой, что промах третий подряд и число живёт в ДВУХ местах шапки |
⭐ **Чего проход НЕ нашёл, и это тоже результат:** деньги — ни ветка, ни вырез, ни `continuing` не двигают
резерв и баланс; таблица канона против исполнения сходится по всем шести строкам; порядок сентинелов в
`v0.go` верен; и он подтвердил независимо мою же находку о том, что «ровно один холд» держит база, а не
моя ветка. Плюс пере-считал мои числа из сырья: `reserved=3.319828` и `balance=6.180172` сошлись
арифметически, «14 строк / 6 называют» сошлось, `-race -count=10` по обоим порядкам дал те же 10 из 10.
⚠ **И честная оговорка о самом проходе:** он гонял мутанты в СВОЕЙ копии и по СВОЕМУ каталогу, а не по
моему; его «M1 … M15» — его нумерация, не моя, и совпадение имён случайно. Пере-проверять его выводы я
могла только тем, что воспроизводила у себя, — что и сделано по находкам 1 и 2.
### 6. Что НЕ удалось · ГДЕ ПРИБОР СЛЕП И Я ЭТО ЗНАЮ
**Не измерено.**
- **Многоэкземплярный деплой не проверялся вживую.** Контендированная фикстура моделирует два
экземпляра ДВУМЯ `Service` над одной базой; настоящих двух демонов я не поднимала. Утверждение
«в двух экземплярах путь устаревшего снимка — обычный» выведено из устройства замка книги, а не
замерено.
- **Цена `os.Stat` в `settle` на зависшем монтировании не замерена.** Я назвала её ограниченной
бюджетом фазы и тем, что вызов движка к тому же каталогу повис бы первым; это рассуждение, не замер.
Зависшее монтирование на этой машине воспроизвести нечем.
- **Продакшн-форма пути** (`<books dir>/<book id>`) подтверждена на 5 книгах живого стенда и чтением
кода интейка. Книги, заведённые оператором через `book add --workdir`, кладутся куда он скажет — для
них равенство «путь = идентификатор» не доказано, и мой пин их не покрывает.
**Где прибор слеп, и я это знаю.**
- **Пин утечки смотрит на ОДНУ строку-подстроку — сам идентификатор книги.** Утечка через производную
форму (хеш, срез, кодировка) им не ловится. Проверять надо бы предикатом «в строке нет ничего, что
резолвится в книгу», а такого прибора нет.
- **Уровень ERROR пин не смотрит вовсе** — это названное исключение, а не пробел прибора; но следующий
читатель обязан знать, что зелёный пин про ERROR не говорит ничего.
- **Гейт версии сверяет НОМЕР, а не ФОРМЫ.** Минор `0.15.0` он подтверждает; что таблица §`resumeRun`
описывает именно то, что делает `Resume`, не проверяет ничто, кроме человека. Это известный класс
(строка 309 общего бэклога), и мой минор его не уменьшает.
- **Причина `stop_requested` не гейчена каноном** — словарь `ErrorCause` объявлен открытым, гейта
формы для него нет и быть не может; пин держит её ЛИТЕРАЛОМ на проводе.
- **Сентинел `ErrStopRequested` на пути `reopen` НЕ ЗАПИНЕН** (находка прохода): гарантия «оба стража
отвечают одним словом» живёт в комментарии. Путь гоночный — стоп между снимком и замком строки
внутри `RestartRun`, — фикстуры под него у меня нет, а выдуманная пинила бы не его.
- **Исключение «на ERROR путь можно» пропускает ПОВТОРЯЮЩИЙСЯ идентификатор** (находка прохода):
строка ERROR, повторяющаяся каждый проход, законна по принятой диспозиции и несёт книгу столько же
раз, сколько запрещённая WARN. Правило про род текста это не ловит; ловило бы правило про частоту,
которого нет.
- ⛔ **Двух носителей утечки я нашла ОДИН, и второй нашёл не я.** Пин держал юнит мёртвым, и живая
половина класса — `resync`, повторяющийся каждый `ResyncEvery`, — была объявлена мною закрытой, пока
была открыта. Урок дороже находки: **пин, у которого фиксированное значение фонового условия
(`alive = false`), измеряет ОДНУ ветвь класса, а отчитывается за класс.** Оба носителя теперь имеют
свой пин, но искать третий я не умею — прибора «перечисли все места, где текст движка попадает в
лог» у меня нет.
- **`os.Stat` в `settle` и в `resync` — первое касание монтирования на последовательном такте**, и
контекст такта он не чтит. Экспозиция названа в комментарии, НЕ замерена: зависшее монтирование на
этой машине воспроизвести нечем.
### 6-бис. Внеплановая находка о СТЕНДЕ — вне моего диффа, зафиксирована тремя носителями
Рецепт «Гейты батареи» внутренне противоречив: $0-шаблон нужен `TestTheSnapshotGuard…` и он же обнуляет
проекцию цены, которую читают два соседних теста, — зелёного `internal/runner` рецепт не даёт ни при
какой конфигурации. Плюс денежная половина, помеченная ВЫВОДОМ: на хосте, где выполнены все три условия
скипа, ПЛАТНЫЙ шаблон запустит настоящие вызовы при исполнении гейта. Платную ветку я не гоняла —
санкции нет, и оркестратор запрет подтвердил. Носители: `docs/STACK_DECISIONS.md` «Гейты батареи»
(развилка, команды, снятие числа «0 FAIL» с атрибуцией приёмке смены №23 — по её же просьбе) и строка
**П-22** зонного бэклога. ⚠ И поправка к собственному первому сообщению: я написала оркестратору, что
гейт скипается по свободному порту; замер батареи показал, что на этом хосте он скипнулся ТРЕТЬИМ
условием — артефактом контраста, — то есть условий не два, а три, и моё утверждение было у́же истины.
### 7. Пинги оркестратору
⚠ **Четыре якоря, сломанные моим переездом, лежат в `docs/` — чужая зона, не трогаю. Числа посчитаны,
правка в одно движение** (все — по дереву, которое я передаю; после лендинга пере-снять):
- `docs/BACKLOG.md`: `docs/architecture/14-api-contract/openapi.yaml:2191` → `:2200` («is reserved and is the server»)
- `docs/BACKLOG.md`: `…/openapi.yaml:2367` → `:2376` («declining a surface removes EVERY window»)
- `docs/BACKLOG.md`: `…/openapi.yaml:3159` → `:3168` («unless its own status»)
- `docs/architecture/05-decisions-log.md:2666`: `platform/internal/runs/runs.go:479` → `:490` («if quote.Chapters == 0»)
⚠ **`PD-448` помечен `fixed(пак «ответ двери», дерево сессии)` и остался в своей секции регистра** — по
прецеденту `PD-455`; переезд между секциями делает лендинг, если зона так решит. Его id снят из
`alarmBaseline` тем же деревом, как гейт и требует.
⚠ **`PD-139` и `PD-466` ВОШЛИ в класс alarm моей ПРОЗОЙ, а не свойством,** и внесены в `alarmBaseline` с
поимённым разбором слов (`заблокированный`/`замолчал` у первого, `блокируется` у второго). Я не стала
переписывать текст рядов ради цвета гейта: слова в рядах верны, и подгонка под регексп была бы ровно тем,
что `D39.121` запрещает. Цена внесения названа там же: класс подрос двумя рядами, чья беда не деньги и
не тишина.
### 8. Комплектность против §4 — сверка таблицей, а не перечтением
| пункт | что заказано | исход |
|---|---|---|
| §4.1 | резюм идемпотентен, критерий — инвариантность к порядку | **сделано**, оба порядка предъявлены исполнением, остаток окна назван абзацем, канон `0.15.0` тем же деревом |
| §4.2 | `PD-162`: граница с эскроу, решить и аргументировать | **не строю, с доводом и замером**; вопрос «а что с процессом» отвечен: снятие холда без остановки юнита меняет дефект на худший |
| §4.3 | `PD-466`: надо ли строить факт в БД | **не строю, с доводом**: у факта нет писателя, а завести его значит вернуть цену зависшего монтирования на денежный такт |
| §4.4 | `PD-139`: одна диспозиция на класс | **принята одна**, выведена замером (5 книг из 5), и подкреплена двумя починками плюс двумя пинами |
| §4.5 | чего не брать | эскроу не строила, квот не заводила, `backend/` и `docs/` вне канона не трогала, новых ручек нет |
| §5 | самопроверка исполнением | 16 посадок, таблица выше; адверсариальный проход отдельным субагентом (`fable`, один) — восемь находок, семь приняты, две ломали мои утверждения и обе починены с новыми пинами |
| §6 | деньги · порядок · провод и канон | каждая новая ветвь отвечает числом; оба порядка исполнением; версия и гейт согласованы |
⛔ **РАБОТА ЗАВЕРШЕНА, править не планирую.** Дерево передаю оркестратору №23: **27 путей — 25 в
`platform/`** (пять новых: четыре тест-файла и `tools/mutate.py`), **2 в каноне** `docs/architecture/14-api-contract/` (минор 0.15.0, едет ТЕМ ЖЕ лендингом), и ни
одного файла вне этих двух мест. `docs/PROGRESS.md` не трогала ни разу — платформа туда не пишет, и в
дереве он лежит с чужой незакоммиченной правкой. Платных вызовов ноль. Не коммичу.
## ОТВЕТ ДВЕРИ НЕ ЗАВИСИТ НИ ОТ ПОРЯДКА, НИ ОТ ВЕЗЕНИЯ — ЗАПИСКА-ПЛАН (11.09, `textmachine-37`)
> Пак `docs/PLATFORM_ANSWER_THAT_DOES_NOT_DEPEND_ON_LUCK_SESSION_PROMPT.md`. Зона записи `platform/`
> плюс канон `docs/architecture/14-api-contract/` ровно в объёме минора **0.15.0** и только вместе с
> кодом. Сессия не коммитит. Платных вызовов ноль. HEAD на входе `218ee4e`, дерево `platform/` чистое.
### Замер, который решал, остаётся ли §4.1 в прежнем виде — снят ПЕРВЫМ, до единой правки
Опасность назвал я сам в эхо-протоколе: пин `TestTwoResumesOfOneRunTakeOneHoldAndBothAnswer`
(`internal/runs/control_test.go:881`) носит имя ровно той гарантии, которую пак собирается строить.
Развилка была полная: либо гарантия уже есть и `PD-448` описывает мимо, либо фикстура не доводит до
гонки. **Ответ: фикстура меряет ОДИН из двух порядков, второй в ней недостижим.**
| Замер | Команда | Результат |
|---|---|---|
| Пин зелёный на тихой машине | `go test ./internal/runs/ -race -count=20 -run '^TestTwoResumesOfOneRunTakeOneHoldAndBothAnswer$'` | **20 PASS из 20**, `ok` 16.760s |
| ЧЕМ он удовлетворён | счётчики путей в копии дерева (`/home/ubuntu-26/tmwork-37`), `-count=5` | **5 из 5: `winner=1 staleLoser=1 freshRefusal=0`** — утверждение держит ТОЛЬКО путь «проигравший прочитал устаревший `stopped`, дошёл до `reopen`, упал на уникальном индексе попытки и получил ПРОГОН» |
| Второй порядок | два `Resume` подряд, та же фикстура, `-count=5` | **5 из 5 отказ**: `first: status="translating" err=<nil>` · `second: err=runs: the run cannot be continued: it is translating`, счётчики `winner=1 staleLoser=0 freshRefusal=1` |
**Три следствия, каждое меняет чтение ряда.**
1. Гарантия НЕ цела: пак остаётся в прежнем виде, пере-целивать нечего.
2. ⛔ **Предмет `PD-448` ШИРЕ, чем гонка.** Ряд описывает окно в миллисекунды под `load average 4248`;
на простаивающей машине тот же отказ **детерминирован**: любой резюм ЖИВОГО прогона отвечает 409, и
двум последовательным кликам с паузой в секунду гонка не нужна вовсе. Гонка — частный случай, а не
предмет.
3. Деньги на отказном пути целы и это пере-снято, а не процитировано: `attempts=2 reserved=3.319828
balance=6.180172 ledger=6.180172` — второй холд не берётся, кэш и леджер сходятся.
### Что беру и в каком порядке
1. **§4.1, ядро.** Ветка `translating` в `Resume` отвечает ПРОГОНОМ, вырез — `l.StopRequestedAt != nil`
(409 `run_not_resumable` + `cause.code: stop_requested`), ветка стоит ВЫШЕ проверки re-pass. Форма:
свитч по статусу становится единственным местом, где решает СТАТУС, а проверки, стерегущие
ПЕРЕ-ОТКРЫТИЕ (re-pass, `sourceThere`), уезжают под него. Канон §`resumeRun` правится тем же
деревом — минор **0.15.0**: меняется строка `translating` таблицы И фраза «a `202` therefore means
work was actually reopened», которая уже сегодня неверна (дерево отдаёт 202 проигравшему, `D39.246`).
2. **§4.2 `PD-162`.** Сначала отвечаю на вопрос «а что с процессом» (находка оркестратора из моего
эхо-протокола), и только потом решаю, строится ли терминальное состояние. Снятие холда без
корректной остановки живого юнита меняет вечный холд на потерянные деньги плюс процесс-сироту.
3. **§4.3 `PD-466` и §4.4 `PD-139`** — решения с доводом; по `PD-139` сначала ЗАМЕР (выводится ли
`book_id` из пути), потом диспозиция. Релей оркестратора 11.09: если классов окажется два, это
полноценный исход пункта.
4. Адверсариальный проход по своей готовой работе, мутации в копии с каноном.
### Где жду сопротивления
- **Пин гонки нельзя оставить в нынешней фикстуре.** Она недостижима для второго порядка, то есть
измеряет половину сценария. Нужна фикстура, где ОБА порядка неизбежны, а не вероятны, и оба гоняются
много раз. Это правка теста по ЗАКАЗАННОЙ смене поведения (`D39.121`) — объявляю её отдельно.
- **Остаток окна §4.1.** `stop_requested` читается в снимке, взятом ДО замка книги, а `Stop` замка книги
не берёт вовсе. Назову интервал абзацем; строить под него не буду, пока не покажу, что цена не нулевая.
- **Форма (а) меняет ответ re-pass-прогону в статусах `paused`/`failed`/`ready`** — с «a re-pass is
bought again» на ответ его собственного статуса. Это КАНОН-конформнее, но смена поведения, и она
объявляется, а не проскакивает.
## ПРАВДА У ДВЕРИ — ОТЧЁТ (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:666`=`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:606`=`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:1702`=`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:447`=`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`.