diff --git a/platform/docs/DEFECT_REGISTER.md b/platform/docs/DEFECT_REGISTER.md index 384fa4a0..7a036198 100644 --- a/platform/docs/DEFECT_REGISTER.md +++ b/platform/docs/DEFECT_REGISTER.md @@ -41,7 +41,7 @@ | PD-448 | bug | minor | `internal/runs/runs.go:170`=`ErrNotResumable` (отказ, который получает проигравший) и `internal/runs/control_test.go:846` `TestTwoResumesOfOneRunTakeOneHoldAndBothAnswer` (пин гарантии) | **ДВОЙНОЙ КЛИК ПО «ПРОДОЛЖИТЬ»: проигравший гонку получает ОТКАЗ вместо прогона, когда два вызова не успевают пройти проверку состояния в одном окне.** Гарантия зоны сформулирована в шапке самого теста: «Both calls pass the state check together and race for attempt N+1 on the unique index; the loser must answer with the run the winner re-opened, not with a not-found». Замер 05.09: под нагрузкой машины (`load average 42–48` на 8 ядрах, тринадцать чужих `tmmutate`) вызовы СЕРИАЛИЗУЮТСЯ — победитель успевает перевести прогон в `translating` до того, как проигравший дойдёт до своей проверки, — и проигравший получает `runs: the run cannot be continued: it is translating` (`control_test.go:863`). ⚠ **Пользовательский смысл: человек, дважды нажавший «продолжить», видит ошибку, хотя прогон в этот момент СТАРТОВАЛ.** ⚠ **Деньги не затронуты:** отказ приходит ДО взятия холда, так что второй холд не берётся — та половина гарантии («ровно один холд») держится и на отказном пути. ⚠ **Это НЕ `PD-420`:** тот ряд про другой тест (`internal/pgstore/runs_test.go` `TestAClaimThatLostARaceToAReleaseIsRetriedAndNotAnError`); проверено грепом, имени этого теста в реестре не было ни в одном ряду. **Воспроизведение:** полный пакет `go test ./internal/runs/` при загруженной машине — упал 2 раза из 2; ИЗОЛИРОВАННО (`-run TestTwoResumesOfOneRunTakeOneHoldAndBothAnswer -count=5`) — 5/5 `ok` за 10 с. То есть окно существует и открывается голоданием по процессору, а не правкой кода. **Направление лечения (не решение):** проигравшему различать «прогон уже идёт, потому что его только что открыл параллельный резюм» от «прогон идёт сам по себе» — первое обязано вернуть прогон, второе законно отказывает. | open | замер зоны 05.09 при ревизии документации: батарея краснела дважды, разобрано до конкретного теста | | PD-443 | bug | minor | `internal/exports/exports.go` `Build`/`Sweep`, `internal/pgstore/queries/exports.sql`, миграция `00032_exports.sql` (`on delete cascade`) | **Артефакт экспорта может пережить СТРОКУ, которая одна умеет его найти, и тогда его не удалит никто.** Путь артефакта детерминирован (`//.`), но каталог со строками не сверяет ничто: GC удаляет только те пути, которые НЕСУТ строки. Три живых пути, найдены адверсариальным проходом по готовой работе: **(а)** демон убит между `rename` движка и `FinishExport` — путь в строку так и не попал, свип пере-водит её в `failed`, файл остаётся навсегда (не экзотика: `KillMode=mixed` в юните и дренаж очереди на 10 с делают это штатным исходом рестарта под нагрузкой); **(б)** книга удалена — `on delete cascade` сносит строки, файлы и каталог книги под `Dir` не трогает никто (сегодня у `DeleteBook` вызывающих нет, но внешний ключ уже стоит и ловушка взведена); **(в)** артефакт, чей unlink не прошёл, — ⚠ **ЭТОТ ПУТЬ ЗАКРЫТ ТЕМ ЖЕ ПАКОМ**: `path` теперь переживает смену состояния и снимается только после реального удаления (`UnlinkedExports`/`ForgetExportPath`), то есть unlink стал ПОВТОРЯЕМЫМ; пин `TestAFileTheSweepCouldNotRemoveKeepsItsRowPointingAtIt`. Четвёртый путь — временные файлы движка (`.<имя>.tmp-*`) после SIGKILL — тоже закрыт (`exports.Service.discard`), потому что `tmctl build` контекста не читает и его всегда добивает SIGKILL. ⚠ **Лечение (а) и (б) — одно и то же и это НЕ райдер:** сверка каталога со строками, то есть реконсилятор файловой системы со своим дизайном (что считать сиротой, как отличить чужой файл от своего, что делать с каталогом книги, которой нет). Делать его заодно с дверью значило бы решить мимоходом. **Радиус сегодня ограничен:** TTL двери — сутки по умолчанию, файлы лежат под одним каталогом деплоя, и оператор видит рост диска раньше, чем что-либо ломается. ⚠ **ДВА СИБЛИНГА, НАЗВАННЫЕ ПРИЁМКОЙ 04.09 — один закрыт, один остаётся здесь.** (I) STAGING-файл движка (`.<имя>.tmp-*`) переживает смерть демона ПОСРЕДИ сборки: `discard` живёт в том же процессе, поэтому при `kill -9` убирать его некому, а следующая сборка того же экспорта не случится — строка уже не `pending`. Это тот же класс, что (а), и лечится тем же реконсилятором каталога. (II) ⚠ **ВТОРОЙ ВОРКЕР НА ОДНОЙ СБОРКЕ — ЗАКРЫТО ТЕМ ЖЕ ДОФИКСОМ, а не только названо:** оба писали бы по ОДНОМУ пути (он выводится из id экспорта), и уборка проигравшего снесла бы файл, только что опубликованный выигравшим. Клейм сделан ИСКЛЮЧАЮЩИМ — `where … and state = 'pending' and started_at is null`, — поэтому второй воркер получает `ErrExportSettled` и до сборки не доходит. Сегодня недостижимо (River ведёт одно задание рода за раз), но именно это превращает вторую реплику из потерянного артефакта в дубль сборки. Пин — в `TestAQueuedBuildAndAClaimedOneAreJudgedOnDifferentClocks`. Воспроизведение (а): `kill -9` демона между строкой лога `export built` и следующей записью в БД. | open | адверсариальный проход пака «закрыть цикл» по своей же работе, 04.09 | | PD-444 | bug | minor | `internal/httpapi/bank.go:142`=`Invalid(w, r)` (ветка строгого разбора) против `internal/httpapi/bank.go:146`=`Invalid(w, r, items...)` (ветка валидации); механизм — `internal/httpapi/problem.go:173`=`Errors []Item` | **Дверь правок банка отвечает `400 invalid_request` БЕЗ единого указателя на то, ЧТО не так.** Замерено живым прогоном 04.09: две попытки с чужими именами членов (`decisions` вместо `corrections`) вернули голое тело, оба ответа `Content-Length: 119` — ни `errors[]`, ни имени члена, ни позиции. ⚠ **Сам отказ ВЕРЕН и оспаривать его нечего:** схема канона объявляет `additionalProperties: false` на обоих уровнях, и незнакомый член — это клиент, уверенный, что он что-то задал; молчаливая версия этого — правка, применённая наполовину. Дефект в другом: канон нигде не требует МОЛЧАТЬ о том, какой член виноват, а механизм у зоны уже построен и на соседней ветке применяется — `validateCorrections` возвращает `[]Item` и отдаёт его в `Invalid`. Ветка строгого разбора теряет даже то, что у неё в руках: `encoding/json` называет поле в тексте ошибки (`json: unknown field "decisions"`), а обработчик его не только не отдаёт, но и не логирует — соседняя ветка «тело не доехало» логирует. Цена: клиент двери — редактор пользователя, и `400` без адреса отлаживается перебором. ⚠ Лечение НЕ трогает канон: `errors[]` в нём уже объявлен, довести до ответа нужно ветку разбора. | open | живой платный прогон пака «закрыть цикл», наблюдение H12, 04.09 | -| PD-446 | doc | minor | канон `docs/architecture/14-api-contract/` §`PausedReason`; носители в зоне — `internal/pgstore/books.go:1183`=`PausedCreditExhausted = ingest.PausedCreditExhausted` и `internal/httpapi/v0.go:994`=`ContractHaltReason` | **`paused_reason: credit_exhausted` при 34% НЕТРОНУТОГО баланса — слово называет кредит там, где исчерпан потолок ПРОГОНА.** Замерено 04.09: в один и тот же момент прогон стоял с `paused_reason: credit_exhausted`, а `GET /v0/usage` отвечал `state: ok, halt_reason: null` при остатке `0.102494` из `0.30`. ⚠ **Оба ответа ВЕРНЫ, и счётная половина этого дефекта уже вылечена — пере-открывать её нельзя:** halt читается с АККАУНТА, а не с паузы последнего прогона (`ReadUsage`, разбор в `books.go` над телом), и именно поэтому `halt_reason` здесь честно пуст. Остаётся ОДНО: у `PausedReason` и `AccountHaltReason` разные словари с одним и тем же единственным значением, и это слово — `credit_exhausted`. Пользователь, читающий «кредит исчерпан» рядом с «остаток 34%», получает противоречие, которого в фактах нет. ⚠ Зона правку канона своей рукой не делает: кандидат в состав минора — значение `PausedReason` обязано называть ПРОГОН (`run_ceiling_reached`), словарь аккаунта не трогается. Пока минор не принят, ряд держит вопрос открытым. ⚠ **ЗАКРЫТО ДЕРЕВОМ ПАКА «ФОРМА ЗАКАЗА» (05.09), статус флипает ЛЕНДИНГ.** У паузы прогона появилось СВОЁ слово: `run_limit_reached` (`ingest.PausedRunLimitReached`), и `CeilingPause(ScopeBook)` отдаёт теперь его, а не `credit_exhausted`. Аккаунтное слово осталось за аккаунтом (`ReadUsage`, `balance <= 0`). ⚠ Разделены и ДВА вердикта реконсилятора, которые делили одно слово: `ceilingSpent` → `run_limit_reached`, `creditUnavailable` → `credit_exhausted` (`internal/runs/reconcile.go`, греп `if v == creditUnavailable`) — лечения противоположны (купить снова против пополнить), и пользователь, которому сказали не то, идёт делать не то. Миграция 00033 пере-называет и старые строки. Пины: `pgstore.TestTheCeilingScopeDecidesWhichPauseTheRunGets`, `runs.TestAnInterruptedRunWithNothingLeftIsPausedAndStaysPausedThroughAResume`, `runs.TestAnInterruptedRunThatTheBalanceCannotCarryIsPaused`. | fixed(628cc56) | живой платный прогон пака «закрыть цикл», наблюдение H14, 04.09 | +| PD-446 | doc | minor | канон `docs/architecture/14-api-contract/` §`PausedReason`; носители в зоне — `internal/pgstore/books.go:1196`=`PausedCreditExhausted = ingest.PausedCreditExhausted` и `internal/httpapi/v0.go:1008`=`ContractHaltReason` | **`paused_reason: credit_exhausted` при 34% НЕТРОНУТОГО баланса — слово называет кредит там, где исчерпан потолок ПРОГОНА.** Замерено 04.09: в один и тот же момент прогон стоял с `paused_reason: credit_exhausted`, а `GET /v0/usage` отвечал `state: ok, halt_reason: null` при остатке `0.102494` из `0.30`. ⚠ **Оба ответа ВЕРНЫ, и счётная половина этого дефекта уже вылечена — пере-открывать её нельзя:** halt читается с АККАУНТА, а не с паузы последнего прогона (`ReadUsage`, разбор в `books.go` над телом), и именно поэтому `halt_reason` здесь честно пуст. Остаётся ОДНО: у `PausedReason` и `AccountHaltReason` разные словари с одним и тем же единственным значением, и это слово — `credit_exhausted`. Пользователь, читающий «кредит исчерпан» рядом с «остаток 34%», получает противоречие, которого в фактах нет. ⚠ Зона правку канона своей рукой не делает: кандидат в состав минора — значение `PausedReason` обязано называть ПРОГОН (`run_ceiling_reached`), словарь аккаунта не трогается. Пока минор не принят, ряд держит вопрос открытым. ⚠ **ЗАКРЫТО ДЕРЕВОМ ПАКА «ФОРМА ЗАКАЗА» (05.09), статус флипает ЛЕНДИНГ.** У паузы прогона появилось СВОЁ слово: `run_limit_reached` (`ingest.PausedRunLimitReached`), и `CeilingPause(ScopeBook)` отдаёт теперь его, а не `credit_exhausted`. Аккаунтное слово осталось за аккаунтом (`ReadUsage`, `balance <= 0`). ⚠ Разделены и ДВА вердикта реконсилятора, которые делили одно слово: `ceilingSpent` → `run_limit_reached`, `creditUnavailable` → `credit_exhausted` (`internal/runs/reconcile.go`, греп `if v == creditUnavailable`) — лечения противоположны (купить снова против пополнить), и пользователь, которому сказали не то, идёт делать не то. Миграция 00033 пере-называет и старые строки. Пины: `pgstore.TestTheCeilingScopeDecidesWhichPauseTheRunGets`, `runs.TestAnInterruptedRunWithNothingLeftIsPausedAndStaysPausedThroughAResume`, `runs.TestAnInterruptedRunThatTheBalanceCannotCarryIsPaused`. | fixed(628cc56) | живой платный прогон пака «закрыть цикл», наблюдение H14, 04.09 | | PD-435 | bug | minor | `internal/pgstore/readmodel.go` `runDone`/`runTotal`/`runStage` (читают монотонный `editWave`), `internal/pgstore/runs.go` `StartRun` (снятие базлайнов) | **Полоса прогона на деплое, где редактора УБРАЛИ, не доходит до единицы: знаменатель тарифицирует edit-волну, которой не будет.** Вторая половина `PD-403`; первая (счёт книги) закрыта паком P12 эпохой формы конвейера, эта — нет, и попытка закрыть её тем же носителем была ОТКАЧЕНА тем же паком: полоса, переведённая на ПРИСВАИВАЕМУЮ эпоху, перестаёт быть монотонной — замерено, один прогон читал 4/4, затем 2/2 на своих же двух попытках при неизменной `structure_version`, что канон запрещает прямо (строка 200, «одна монотонная дробь на всю работу прогона»). ⚠ **Почему это не однострочник:** правильный носитель — форма, под которой работает ЭТОТ прогон, записанная на самом прогоне; а в `StartRun` она ещё НЕ ИЗВЕСТНА — движок объявляет её первым progress-событием прогона, и попытка снять её раньше это ровно `PD-401`, закрытый пином. Значит запись должна происходить на ПЕРВОМ объявлении прогона, и тогда нужен разбор, что делать со второй попыткой того же прогона, объявившей другую форму (сегодняшний ответ — ничего, потому что флаг монотонен). Лечение: колонка формы на `runs`, заполняемая первым объявлением, плюс решение о смене формы между попытками одного прогона. Пин обратной стороны уже стоит и покраснеет, если кто-то снова переведёт полосу на эпоху: `pgstore.TestTheRunsBarIsMonotoneAcrossAShapeBoundaryItSpans`. ⚠ **Почему `minor`, когда родительская `PD-403` была `major` — вопрос приёмки, отвечаю доводом, а не весом.** У `PD-403` мажорной её делали ДЕНЬГИ: шкала покупки продавала уже переведённые главы повторно. Эта половина ЗАКРЫТА — `ChaptersLeft` считается через эпоху, дважды не продаётся ничего, и `PD-410` (тоже про деньги, ручка «купить N глав» ограничивает не главы, а доллары) остаётся `major` именно поэтому. Здесь не двигается ни один микро-доллар: прогон делает всю купленную работу, закрывается `ready`, леджер сходится, следующая покупка предлагает правильный остаток. Врёт ДРОБЬ и подпись (`editing` на деплое без редактора) — контрактно видимо и потому не `info`, но это отчёт о работе, а не её оплата. Плюс достижимость: нужна смена ФОРМЫ деплоя под книгой, которая уже прошла редактирующий пайплайн, — не обычный путь, в отличие от `PD-410`, который кусает на КАЖДОЙ покупке. Если приёмка сочтёт довод слабым — поднять до `major` дешевле, чем спорить: работа от веса не меняется | open | адверсариальный проход пака P12 (30–31.08), замерено на живом Postgres через продовые пути записи | | PD-433 | hardening | minor | `internal/pgstore/identity.go:106`=`if !errors.Is(err, errIdentityRace)` (ретрай), `:118`=`var errIdentityRace`, ветвь `on conflict … do nothing` в `upsertIdentityOnce` | **Ветвь, обслуживающая ЛЕГИТИМНУЮ гонку двух первых логинов одной новой личности, не исполняется НИ ОДНИМ тестом.** Форма та же, что у `PD-380` и `PD-86`: единственная точка принуждения объявленного свойства, мутация переживает полную батарею, транзитивной страховки нет. Механика: под READ COMMITTED `for update` в `LockIdentity` не блокирует строку, которой ещё нет, поэтому оба гонщика проходят; проигравший видит `created == 0`, получает `errIdentityRace` и ретраит — и именно ретрай спасает его от 500 на пути логина. ⛔ **Снос ветви — только с доказательством недостижимости:** она обслуживает легитимную гонку, и её снос = 500 на легитимном пути (класс `PD-369`, который этот же пак и закрывал). Диспозиция: запинить конкурентным тестом ЛИБО доказать недостижимость. Форма теста, если пинить: в пакете `pgstore`, ~40 раундов, в каждом СВЕЖАЯ личность и 4 горутины на `UpsertIdentity`; утверждать, что все четыре вернули `nil`, что userID у всех ОДИН, что `identities` держит одну строку, что грант посева записан ОДИН раз и что число строк в `users` равно числу раундов — последнее ловит настоящую утечку, осиротевшего пользователя от проигравшего, который успел `CreateUser` до проигрыша. Проверять посадкой (снять арм `errIdentityRace` — тест обязан покраснеть), потому что вероятностный тест без посадки не доказывает, что он вообще кусает | open | пак P12 (30.08), греп непокрытых ветвей по своим путям | | PD-434 | bug | minor | `internal/pgstore/sink.go` `ApplyStatus`, `internal/runs/reconcile.go` `maybeResync` | **Канал ПОЧИНКИ прогресса не чинит прогресс: ресинк не материализует ничего, что читает экран.** `maybeResync` существует ровно для прогона, чей поток в карантине или чей курсор не двигался — «это теперь единственный источник», говорит его собственный комментарий. Но `ApplyStatus` писал пер-волновые цифры отчёта ТОЛЬКО в `runs.draft_done/draft_total/edit_done/edit_total`, у которых не было ни одного читателя (`PD-411`), и не трогает ни `unit_resolutions`, ни `chapters` — а вся полоса выведена из `chapters`. Значит полоса такого прогона стоит всю его жизнь, как бы исправно ни отвечал `tmctl status`. ⚠ **Строка заведена паком P12 при сносе `PD-411`, и это главное в ней:** мёртвые колонки ПРЯТАЛИ гап — код выглядел так, будто канал починки чинит, и комментарий `maybeResync` прямо утверждал «ApplyStatus now materializes the same four counters as the stream». Обе лгущие фразы исправлены, сам гап НЕ лечится сносом и вынесен сюда, чтобы снос не выдал себя за лечение. Лечение: либо ресинк складывает отчёт в `chapters` (и тогда нужен разбор, как он не спорит с потоком, который те же строки пишет из `unit_resolutions`), либо зона признаёт, что карантинный прогон полосы не показывает, и говорит об этом на поверхности. Пин формы «ресинк НЕ двигает счётчики глав» уже стоит — `pgstore.TestTheResyncRecordsFreshnessAndShapeAndNoProgress` — и он покраснеет, если канал научится материализовать: это указатель на строку, а не её лечение | open | пак P12 (30.08), вскрыто сносом `PD-411` | @@ -57,7 +57,7 @@ | PD-103 | hardening | minor | `internal/auth/middleware.go:43,66` | У обращений к БД на аутентифицированном пути (`Lookup`/`Touch`) нет собственного дедлайна — только голый `r.Context()`, а `WriteTimeout` у сервера отсутствует по проекту (SSE) и `TimeoutHandler` в цепочке нет. Зависший Postgres паркует хендлеры и ждущих в пуле, пока клиент сам не уйдёт. `readyz` свой таймаут получил (PD-14) — горячий путь нет | open | приёмка P2 (панель) | | PD-115 | standards | minor | `docs/ENGINEERING_STANDARDS.md` §2 | **Внешняя версионированная базовая линия объявлена ровно для ОДНОЙ оси — безопасности** (ASVS 5.0 L2 + OWASP API Top-10 2023, с указанием глав). Отказоустойчивость, наблюдаемость и контракт-первичность описаны собственной прозой зоны без внешнего эталона, а конфигурация, релиз/откат, ёмкость и восстановление не описаны вовсе. Разница не теоретическая: PD-57 и PD-58 нашлись ИМЕННО сверкой кода с RFC 9700/9207 и NIST SP 800-63B — механизм работает там, где эталон есть, и не может сработать там, где его нет. Грепнуто на 08.08: метрик и трейсинга ноль (ни prometheus, ни otel, ни expvar, ни pprof), процедуры бэкапа/восстановления в `deploy/README.md` нет, SLO не заданы. Предложение зоны: §2 получает по эталону на ось (наблюдаемость, ops, конфигурация) — **направление РАТИФИЦИРОВАНО 09.08 (оркестратор №15 по делегации владельца); носитель работы — эта строка, исполнение — своими паками** ⚠ Уточнено паком P5: ось НАБЛЮДАЕМОСТИ эталон получила — практики именования Prometheus (базовые единицы, `_total` у счётчиков, единица не в лейбле) плюс «четыре золотых сигнала» на вопрос «что мерить», записано в `STACK_DECISIONS` §24 и пинится `metrics.TestTheRunnersStateIsExposedWithItsUnits`. Оси ops/конфигурация/восстановление эталона по-прежнему не имеют — строка открыта ими | open (наблюдаемость закрыта P5; ops и конфигурация — нет) | абстрактный вопрос владельца 08.08 + сессия P4 | | PD-157 | bug | minor | `internal/runs/spawn.go`, `cmd/tmplatformctl/runs.go` `book add` | **ДНЕВНОЙ потолок книги `--ceiling-usd` не перекрывает, а платформа его не видит и не задаёт.** Движок требует хотя бы один из `book_usd`/`day_usd` (`backend/internal/config/book.go:332`=`book_usd/day_usd` (⚠ пере-нацелен ВТОРОЙ раз, 04.09: цель уехала с `:321`; первая попытка того же дня промахнулась на строку — условие на Go-поля стоит на `:331`, а процитированные литералы на `:332`), Р7 — ⚠ якорь пере-нацелен оркестратором №19 при лендинге 27.08 с `:250`: требование уехало на 321 из-за лендинга бэкенда `d1eb8a9`, не из-за правки платформы), флаг переопределяет только книжный (D39.122 прямо: «День-потолок не перекрывается»), а `book.yaml` пишет ОПЕРАТОР — платформа его не правит (D39.110 §2b) и в `book add` только проверяет наличие файла. Значит книга с низким `day_usd` останавливает прогон на лимите, которого платформа не выбирала: движок выходит кодом 1 (тот же путь, что у PD-113), прогон приезжает `failed`, а деньги пользователя целы и он не понимает, почему. В `status --json` дневной фигуры нет вовсе (есть `book_ceiling_usd`/`ceiling_pct`), поэтому даже диагностировать это платформа сегодня не может. Заведено, не построено: закрывать — либо проверкой `day_usd` при заведении книги, либо словом контракта о том, кто владеет потолками `book.yaml` у книг под платформой ⚠ **ПОЛОВИНА ЗАКРЫТА (P6): дневной потолок стал РАЗЛИЧИМ.** Поток несёт `ceiling.scope` со значениями book и day (D39.131), платформа хранит внутреннюю причину `daily_ceiling` (миграция 00015) и НЕ проецирует её как `credit_exhausted` — иначе экран сказал бы «кончились деньги» об аккаунте, на котором деньги есть, и зажёгся бы аккаунт-флаг `ReadUsage`. Резюм такого прогона отвечает 409 с диагностикой, а не гоняет попытки в цикл: `day_usd` живёт в `book.yaml` оператора, платформа его не ставит и поднять не может, а граница дня принадлежит ледджеру ДВИЖКА — таймер здесь был бы догадкой, которая тратит спавны. ОСТАЁТСЯ открытым то, ради чего строка заведена: платформа по-прежнему не выбирает и не видит `day_usd` (в `status --json` его нет), поэтому книга с низким дневным потолком остановится на лимите, которого никто на этой стороне не назначал. Пины: `pgstore.TestTheCeilingScopeDecidesWhichPauseTheRunGets` · `internal/runs/seam_test.go:146`=`func TestAResumeOfARunPausedAtALimitIsRefusedWhoseverLimitItWas` (переименование, коммит `9b23e8c`) · `httpapi.TestOnlyTheContractsOwnPausedReasonReachesTheWire` ⚠ Дополнено рефутером: контракт 0.3.0 РАСШИРИЛ свойство — резюм отбивается `ErrCeilingReached` на ЛЮБУЮ паузу, а различение day против credit пинится не этим тестом, а `pgstore.TestTheCeilingScopeDecidesWhichPauseTheRunGets` и `httpapi.TestOnlyTheContractsOwnPausedReasonReachesTheWire`. Посадка мутации (снят `ErrCeilingReached` в ветке `paused`) роняет пин под НОВЫМ именем — свойство держится | open | собственная сверка шва при F1 (чтение движка + D39.122) | -| PD-162 | bug | minor | `internal/runs/spawn.go` `journalSize`, `internal/runs/reconcile.go` | **Книга, чей каталог удалён или перемещён, принимает прогон и заклинивает его навсегда с открытым холдом.** `journalSize` мапит ENOENT в «ноль, ошибки нет» (законно для первого прогона), поэтому `Start` отдаёт 202 и берёт холд; дальше `bookMeter` вечно падает (спавн отказывает), либо расчёт вечно откладывается — ни один путь не приходит к терминальному состоянию: прогон вечно `translating`, деньги вечно в холде, пользователю видно только «идёт». Дизайн «холд лучше догадки» осознан (строка 136), но отсутствие И валидации каталога на старте, И эскалации после N неудач — дыра. Закрывать вместе с эскроу/`uncertain` либо проверкой каталога при допуске. ⚠ Ре-чек V2 расширил КЛАСС строки: обязательность `reserved_usd` даёт тот же клин без всякого удаления каталога — прогон, запиненный к СТАРОЙ сборке движка (строка 139), у которой поля ещё нет, вечно отказывает спавну с открытым холдом. Лечится тем же терминальным состоянием после N неудач; отказ сам по себе верен (без цифры потолок считать нечем) ⚠ Сужено паком P5 с одной стороны и НЕ закрыто с другой: у ИНТЕЙКА терминальное состояние после N неудач теперь есть (`books.parseAttempts` = 5 → `rejected` с причиной `parser_unavailable`), и прогон на книге, не прошедшей интейк, отвергается до денег (`runs.ErrBookNotReady`). Клин ЖИВОГО прогона на удалённом каталоге не тронут: там нужен тот же счётчик неудач на стороне реконсилятора либо эскроу-половина строки 136 ⚠ Дополнено кросс-семейным ревью: терминальность интейка теперь НЕ применяется к `not_configured` — книга без конфигурации ждёт в `parsing` вместо уничтожения, потому что это состояние ДЕПЛОЯ, а не книги. Цена названа: пока развилка `book.yaml` не ратифицирована, такие книги копятся, и видит их метрика `tm_platform_books_in_intake{status="parsing"}` ⚠ **ДИСПОЗИЦИЯ P7: не бралась.** Клин живого прогона на удалённом каталоге лечится терминальным состоянием после N неудач у реконсилятора, а это половина эскроу (строка 136) — строить её рядом с проектируемым целым значит ставить второй, более слабый ответ на тот же вопрос. P7 добавил к строке одно наблюдение: материализатор читающей поверхности зовёт движок в том же каталоге и на ту же ошибку отвечает логом, не трогая деньги ⚠ **ПАК P8-REVIEW 24.08, сужено рефутером:** для половины «спавн отказал» (попытка до движка не дошла) построены ДИАГНОЗ (`runs --stalled`, гейдж `tm_platform_runs_stalled`) и РУЧНОЙ вердикт оператора (`tmplatformctl run abandon --run --reason [--release-hold]`, коммит `31f1f82`, строка П-20 зонного бэклога). **Автоматического терминального состояния после N неудач НЕТ и оно не планируется вне эскроу** — это записанное решение (`internal/runs/reconcile.go:249-257` «what it must not buy is this platform deciding on its own»). Проба end-to-end на стенде, восемь проходов при отказывающем движке: `status=translating unit="" failures=8`, гейдж 1, `reserved=0.300000` — прогон висит и деньги заморожены, пока не придёт человек. Плюс возврат холда только с флагом: без `--release-hold` холд ждёт до 30 минут (`PD-391`). Значит сужать строку до «клина с юнитом или базовой линией» НЕЛЬЗЯ: беспризорный деплой на удалённом каталоге по-прежнему висит вечно ⚠ Границы улики: числа пробы (`failures=8`, гейдж 1, `reserved=0.300000`) сняты рефутером на его стенде, и ЛОГ прогона в артефакты не попал — пере-ранить их приёмка не сможет, воспроизводить придётся по описанию. Механизм при этом проверяем чтением: `internal/pgstore/runs.go:683`=`select finished_at from runs where id = $1` ⚠ якорь пере-нацелен паком P11: прежняя строка отвечала `ErrNoRun` законченному прогону — она и была дефектом; теперь это ветвление, читаемое ПОД блокировкой книги | open | самопроверка дофикса (ревью вне карты) | +| PD-162 | bug | minor | `internal/runs/spawn.go` `journalSize`, `internal/runs/reconcile.go` | **Книга, чей каталог удалён или перемещён, принимает прогон и заклинивает его навсегда с открытым холдом.** `journalSize` мапит ENOENT в «ноль, ошибки нет» (законно для первого прогона), поэтому `Start` отдаёт 202 и берёт холд; дальше `bookMeter` вечно падает (спавн отказывает), либо расчёт вечно откладывается — ни один путь не приходит к терминальному состоянию: прогон вечно `translating`, деньги вечно в холде, пользователю видно только «идёт». Дизайн «холд лучше догадки» осознан (строка 136), но отсутствие И валидации каталога на старте, И эскалации после N неудач — дыра. Закрывать вместе с эскроу/`uncertain` либо проверкой каталога при допуске. ⚠ Ре-чек V2 расширил КЛАСС строки: обязательность `reserved_usd` даёт тот же клин без всякого удаления каталога — прогон, запиненный к СТАРОЙ сборке движка (строка 139), у которой поля ещё нет, вечно отказывает спавну с открытым холдом. Лечится тем же терминальным состоянием после N неудач; отказ сам по себе верен (без цифры потолок считать нечем) ⚠ Сужено паком P5 с одной стороны и НЕ закрыто с другой: у ИНТЕЙКА терминальное состояние после N неудач теперь есть (`books.parseAttempts` = 5 → `rejected` с причиной `parser_unavailable`), и прогон на книге, не прошедшей интейк, отвергается до денег (`runs.ErrBookNotReady`). Клин ЖИВОГО прогона на удалённом каталоге не тронут: там нужен тот же счётчик неудач на стороне реконсилятора либо эскроу-половина строки 136 ⚠ Дополнено кросс-семейным ревью: терминальность интейка теперь НЕ применяется к `not_configured` — книга без конфигурации ждёт в `parsing` вместо уничтожения, потому что это состояние ДЕПЛОЯ, а не книги. Цена названа: пока развилка `book.yaml` не ратифицирована, такие книги копятся, и видит их метрика `tm_platform_books_in_intake{status="parsing"}` ⚠ **ДИСПОЗИЦИЯ P7: не бралась.** Клин живого прогона на удалённом каталоге лечится терминальным состоянием после N неудач у реконсилятора, а это половина эскроу (строка 136) — строить её рядом с проектируемым целым значит ставить второй, более слабый ответ на тот же вопрос. P7 добавил к строке одно наблюдение: материализатор читающей поверхности зовёт движок в том же каталоге и на ту же ошибку отвечает логом, не трогая деньги ⚠ **ПАК P8-REVIEW 24.08, сужено рефутером:** для половины «спавн отказал» (попытка до движка не дошла) построены ДИАГНОЗ (`runs --stalled`, гейдж `tm_platform_runs_stalled`) и РУЧНОЙ вердикт оператора (`tmplatformctl run abandon --run --reason [--release-hold]`, коммит `31f1f82`, строка П-20 зонного бэклога). **Автоматического терминального состояния после N неудач НЕТ и оно не планируется вне эскроу** — это записанное решение (`internal/runs/reconcile.go:249-257` «what it must not buy is this platform deciding on its own»). Проба end-to-end на стенде, восемь проходов при отказывающем движке: `status=translating unit="" failures=8`, гейдж 1, `reserved=0.300000` — прогон висит и деньги заморожены, пока не придёт человек. Плюс возврат холда только с флагом: без `--release-hold` холд ждёт до 30 минут (`PD-391`). Значит сужать строку до «клина с юнитом или базовой линией» НЕЛЬЗЯ: беспризорный деплой на удалённом каталоге по-прежнему висит вечно ⚠ Границы улики: числа пробы (`failures=8`, гейдж 1, `reserved=0.300000`) сняты рефутером на его стенде, и ЛОГ прогона в артефакты не попал — пере-ранить их приёмка не сможет, воспроизводить придётся по описанию. Механизм при этом проверяем чтением: `internal/pgstore/runs.go:689`=`select finished_at from runs where id = $1` ⚠ якорь пере-нацелен паком P11: прежняя строка отвечала `ErrNoRun` законченному прогону — она и была дефектом; теперь это ветвление, читаемое ПОД блокировкой книги | open | самопроверка дофикса (ревью вне карты) | | PD-175 | hardening | minor | `internal/books/`, `internal/httpapi/v0.go` `createBook` | **Квоты на интейк нет: аутентифицированный аккаунт может писать на диск оператора неограниченно.** Пер-маршрутный потолок (PD-72) ограничивает ОДИН аплоад (64 МиБ по умолчанию), число аплоадов — ничто: ни лимита книг на аккаунт, ни ретеншена. Отклонённая по вине ИСТОЧНИКА книга свой файл теряет (каталог удаляется), а отклонённая по вине ДЕПЛОЯ — сохраняет намеренно (удалять чужую загрузку из-за своей поломки нельзя), и такие каталоги не чистит никто. Лечится квотой на аккаунт плюс свипом ретеншена по `rejected`; и то и другое — продуктовая политика (сколько книг входит в фри-тир), поэтому заведено, а не выбрано зоной ⚠ Третья половина того же вопроса — УДАЛЕНИЕ книги: `pgstore.DeleteBook` убирает строку и закрытые резервации и НЕ трогает каталог книги на диске, а ручки удаления в контракте нет вовсе (гейт PD-122). То есть сегодня утечки нет, потому что удалять нечем; день, когда ручка появится, — это и день, когда каталог обязан уходить вместе со строкой, и гард «только под `BooksDir`» для этого уже есть (`books.owns`). Диспозиция: закрывать ВМЕСТЕ с ручкой удаления, не раньше и не позже ⚠ Дополнено адверсариальным ревью P5 двумя фактами, которые делают строку острее, чем она написана: (1) пока развилка `book.yaml` не ратифицирована, ЛЮБАЯ загрузка приходит к `rejected/not_configured` — то есть путь «интейк пишет на диск и никто не убирает» сегодня ординарный, а не краевой; (2) у `rejected` нет ВЫХОДА вовсе: ни перепарса, ни удаления в контракте нет, строка остаётся в библиотеке навсегда. ⚠ Дополнено кросс-семейным ревью (Fable, 11.08): крэш-окно «строка закоммичена — каталог ещё не снесён» оставляет каталог-сироту, которого не найдёт никто (`StuckIntake` берёт только `uploading` и `parsing`). У отказа порядок перевёрнут в самоизлечивающийся — сначала каталог, потом строка, — а у брошенной загрузки перевернуть нельзя (каталог можно сносить только убедившись, что строки нет), так что окно там остаётся и закрывается тем же свипом ретеншена, что и вся строка ⚠ **ПРОДУКТОВАЯ ПОЛОВИНА СНЯТА — D39.176 п.1 (слово владельца 30.08); диспозиция записана паком P12 31.08 по пингу аудита доков.** Квот НЕТ и не будет, фри-тир-лимиты не проектируются: живём на покупке API, бонусы зачисляются из админки. Вопрос «сколько книг во фри-тир» не ждёт владельца — его больше нет. Остаётся ИНЖЕНЕРНАЯ половина, и она НЕ требует ничьего слова: ретеншен `rejected`-книг, свип каталогов-сирот, потолок диска — зона решает сама. Гейт один и не продуктовый: открытая регистрация, которой в закрытой бете нет. | open | сессия P5 (самопроверка, ось «что этот маршрут создаёт») | | PD-371 | bug | minor | `internal/pgstore/runs.go` `RunsToReconcile`, `internal/runs/reconcile.go` `deferItem` | **Исключение «стоп перевешивает отсрочку» обходит отсрочку БЕЗ ГРАНИЦЫ, и для прогонов с запрошенным стопом голодание PD-169 внутри фазы возвращается.** Прогон, чей `stop_requested_at` не пуст, выбирается КАЖДЫМ проходом независимо от `reconcile_after`, сколько бы раз подряд он ни падал. **Измерено живьём при приёмке** (дев-демон на дереве пака, хост без пользовательской шины systemd, поэтому `Runner.Alive` падает по-настоящему): `reconcile_after` стоял на ~30 минут вперёд (`NEXT TRY 14:21:21Z`), а `FAILS` дорос до **6 за ~90 секунд**, то есть на каждом 15-секундном такте — отсрочка не действовала ни разу. На ДЕШЁВОЙ ошибке это безвредно и было именно так в пробе. На дорогой (шина или чтение журнала книги висит до конца бюджета) прогон снова держит голову списка весь бюджет фазы реконсиляции, а класс запускает ЛЮБОЙ пользователь кнопкой «остановить». Что смягчает и почему это не блокер: расчёт денег живёт во ВТОРОЙ фазе и не страдает (это и есть половина лечения PD-169), счётчик всё равно растёт, гейдж `tm_platform_runs_stalled` и `run abandon` работают — проверено той же пробой. Комментарий у `RunsToReconcile` называет цену НЕ-исключения («стоп ждал бы истечения бэкоффа») и не называет цену исключения. Направление, не решение: исключать до пересечения `StalledAfter`, а дальше подчинять стоп общему бэкоффу — переиздание стопа идемпотентно, и на пятой неудаче подряд «переиздать немедленно» уже ничего не покупает | open | приёмка P8-FIX (живая проба оркестратора №18, вне карты пака) | | PD-372 | bug | minor | `internal/pgstore/runs.go` `DeferRun`, `internal/pgstore/books.go` `truncateReason`, `internal/pgstore/isolation_test.go` | **Починку текста ошибки пинит только САМА функция, но ни один из четырёх её вызовов.** `TestAnEnginesOwnErrorTextSurvivesBeingRecorded` зовёт `truncateReason` напрямую и доказывает, что Postgres принимает её результат, — а того, что вызывающий её ЗОВЁТ, не проверяет ничто. **Посажена мутация оркестратором вне списка автора:** `truncateReason(reason)` → `reason` в `DeferRun` — батарея (`./internal/pgstore/` + `./internal/runs/`) осталась ЗЕЛЁНОЙ. Цена ровно та, которую комментарий этой же функции называет вслух: невалидный UTF-8 из stderr движка Postgres отвергает, запись отказа не проходит, счётчик не растёт и попытка держит голову списка вечно — то есть механизм PD-169 отключается тем самым текстом, ради которого заведён. Класс — PD-1 («свойство без пинящего теста не закрыто»), и он тут в форме «пин есть, но не на пути». Лечение дешёвое: провести один случай через `DeferRun`/`DeferReadModelDebt` и прочитать колонку назад | open | приёмка P8-FIX (посадка мутации оркестратором №18) | @@ -77,7 +77,7 @@ |---|---|---|---|---|---|---| | PD-445 | doc | info | `internal/httpapi/bank.go:386`=`Undecided int` (проекция квитанции); честный счёт — `internal/pgstore/readmodel.go:324` | **`signature.undecided` не сдвинулся после ПРИНЯТОЙ правки (82 → 82), и оператору нечем отличить «правка не зашла» от «зашла, но счёт про другое».** Замерено 04.09: `preview` и `apply` вернули `signature.surfaces 82, undecided 82` при `accepted[0].state = applied`. ⚠ **Арифметика ВЕРНА:** счёт неопределённости — движковый и считает МАЙНЕННЫЕ поверхности, а термин правки был не из них, поэтому двинуться числу было не от чего. Дефект — в обратной связи: единственное число, которое оператор видит после правки, на успешную правку не реагирует вовсе, и чтобы убедиться, что правка ЗАШЛА, потребовалась отдельная сверка по другому пути. ⚠ Вес info, потому что квитанция уже несёт точный ответ (`accepted[].state`) — это не ложь механизма, а то, что глаз читает первым. | open | живой платный прогон пака «закрыть цикл», наблюдение H13, 04.09 | | PD-396 | standards | info | `internal/pgstore/books.go:326`=`chunker_version = $4, parse_started_at = null,`, `internal/pgstore/sink.go:127`=`update books set chunker_version = $2 where id = $1`, `internal/ingest/resync.go:36`=`UnsignedBankTerms int `json:"unsigned_bank_terms"`` | **Мёртвые поля шва: у `books.chunker_version` ДВА писателя и НОЛЬ читателей, и это второй экземпляр класса, первый назвал оркестратор.** Колонку пишет интейк из манифеста движка (`FinishParse`) и пишет тейлер из хендшейка потока (`effect`); ни одного `select` по ней в зоне нет — грепом ноль. Сегодня это безвредно, но следствие названо ЗАРАНЕЕ, потому что оно семантическое, а не техническое: в день, когда читатель появится, РАСХОЖДЕНИЕ двух писателей (чанкер прогона против чанкера разбора) станет значением, и решать, какой из них правда, придётся задним числом — по колонке, у которой уже накоплена история из обоих источников. Родня — п.4 пинга оркестратора №19: `ingest.StatusReport.UnsignedBankTerms` разбирается из ответа движка и не используется НИ ОДНОЙ строкой продакшн-кода (грепом — только объявление и его доккомментарий, где поле описано как опора экрана подписи). Формулировка оркестратора применима дословно к обоим: мёртвое поле в структуре шва читается как контракт. ⚠ Заведено ОТДЕЛЬНОЙ строкой, а не дописком в `PD-166`: та про потерю значения между автокоммитами, и её свойство построено. Диспозиция — вопрос владельца, а не зоны: либо назначить владельца колонки (один писатель), либо записать расхождение как ожидаемое до появления читателя Воспроизведение — две команды без конвейера (символ вертикальной черты в ячейку регистра не влезает): `grep -rn chunker_version platform/internal --include=*.go` даёт два `update` и ни одного `select`, `grep -rn UnsignedBankTerms platform/internal platform/cmd --include=*.go` даёт только объявление и его доккомментарий | open | ревью-пак P8-REVIEW (побочная находка сверки реестра, оформлена по указанию закрывающего ревью) | -| PD-378 | bug | info | `internal/pgstore/books.go:1162`=`u.RemainingPercent = int(balance * 100 / granted)`, `internal/httpapi/v0.go` `usageState`, канон `14-api-contract` `remaining_percent` | **`/v0/usage` отдаёт `remaining_percent` вне контрактных 0..100 и зажигает предупреждение «low» на полном счёте: `balance * 100` переполняет int64.** Порог измерен точно: баланс 92 233 720 368 547 758 микро ещё даёт 99%, следующий микро-доллар даёт минус 99. Ответ нарушает схему (`minimum: 0`, `maximum: 100`), и хуже того `usageState` видит отрицательное значение ниже порога `lowCredit` и отдаёт `state: "low"` — «денег почти нет» счёту на сто миллиардов. Замерено на проводе: до гранта `{"state":"ok","remaining_percent":96}`, после `grant --usd 100000000000` → `{"state":"low","remaining_percent":-84}`; соседняя арифметика (`pricing.Scale`, `balance`) при том же балансе отвечает верно, то есть переполнение локально именно в этой строке. ⚠ Рефутер сузил minor → info: чтобы туда попасть, оператор должен добавить на счёт не меньше 92.23 млрд долларов, ни одна пользовательская ручка кредит не пишет; прецедент веса — `PD-39`. ⚠ Оговорка рефутера в другую сторону: более правдоподобный носитель — не разовая команда, а конфиг `TM_PLATFORM_SIGNUP_GRANT_USD`, у которого верхней границы нет и значение НАМЕРЕННО не печатается в стартовый лог, так что промах в нём сломал бы `/usage` каждому новому аккаунту невидимо. Воспроизведение: `docs/p8-review/axis1-money/a1-usage-overflow.sh` (сам откатывает грант) | open | ревью-пак P8-REVIEW, ось 1 (живой провод, сужено рефутером с измеренным порогом) | +| PD-378 | bug | info | `internal/pgstore/books.go:1175`=`u.RemainingPercent = int(balance * 100 / granted)`, `internal/httpapi/v0.go` `usageState`, канон `14-api-contract` `remaining_percent` | **`/v0/usage` отдаёт `remaining_percent` вне контрактных 0..100 и зажигает предупреждение «low» на полном счёте: `balance * 100` переполняет int64.** Порог измерен точно: баланс 92 233 720 368 547 758 микро ещё даёт 99%, следующий микро-доллар даёт минус 99. Ответ нарушает схему (`minimum: 0`, `maximum: 100`), и хуже того `usageState` видит отрицательное значение ниже порога `lowCredit` и отдаёт `state: "low"` — «денег почти нет» счёту на сто миллиардов. Замерено на проводе: до гранта `{"state":"ok","remaining_percent":96}`, после `grant --usd 100000000000` → `{"state":"low","remaining_percent":-84}`; соседняя арифметика (`pricing.Scale`, `balance`) при том же балансе отвечает верно, то есть переполнение локально именно в этой строке. ⚠ Рефутер сузил minor → info: чтобы туда попасть, оператор должен добавить на счёт не меньше 92.23 млрд долларов, ни одна пользовательская ручка кредит не пишет; прецедент веса — `PD-39`. ⚠ Оговорка рефутера в другую сторону: более правдоподобный носитель — не разовая команда, а конфиг `TM_PLATFORM_SIGNUP_GRANT_USD`, у которого верхней границы нет и значение НАМЕРЕННО не печатается в стартовый лог, так что промах в нём сломал бы `/usage` каждому новому аккаунту невидимо. Воспроизведение: `docs/p8-review/axis1-money/a1-usage-overflow.sh` (сам откатывает грант) | open | ревью-пак P8-REVIEW, ось 1 (живой провод, сужено рефутером с измеренным порогом) | | PD-381 | hardening | info | `internal/auth/middleware.go:38`=`a.Deny.ServeHTTP(w, r)` и та же строка на `:52`, `internal/auth/cookie.go` `ClearSession` | **401 по мёртвой сессии не стирает куку: браузер продолжает слать отозванный токен до конца её `Max-Age` (по умолчанию 14 суток).** Обе ветки отказа зовут `a.Deny.ServeHTTP` и к `a.Cookies` не обращаются, перекрытия выше по стеку нет — живой 401 не несёт ни одной строки `Set-Cookie`. Норму формулирует сам код: комментарий `ClearLogin` говорит, что кука, пережившая свой круг, это «a replay waiting for an accident», а `PD-88` заведена ровно на тот исход, при котором кука переживает сессию. Дешёвое лечение — чистить куку на пути отказа, где она была предъявлена. Отдельно от `PD-88` (та про `Max-Age` меньше секунды) и от `PD-5`/`PD-70`/`PD-74`/`PD-103` Воспроизведение: `docs/p8-review/axis2-auth/csrf-matrix.sh` и `session-clocks-probe.sh` (обе пробы поднимают демон и печатают ПОЛНЫЕ заголовки ответа, включая отсутствие `Set-Cookie` на 401); проверка чтением — `grep -n 'Cookies' internal/auth/middleware.go`, ни одного вхождения на путях отказа | open | ревью-пак P8-REVIEW, ось 2 (чтение + живая проба, подтверждено рефутером) | | PD-382 | hardening | info | `internal/auth/cookie.go:52`=`func (c Cookies) ClearSession(w http.ResponseWriter) { c.set(w, c.SessionName(), "", -time.Second) }`, `internal/auth/cookie.go` `ClearLogin` | **Путь ИСТЕЧЕНИЯ куки не запинен: две мутации, стирающие выход из браузера, прошли батарею целиком.** `ClearSession` и `ClearLogin` — единственные места, где кука получает отрицательный `Max-Age`, и порча этого выражения ничего не роняет. ⚠ Рефутер поправил ЦЕНУ, названную первой редакцией находки: `set` пишет значение вызывающего, а обе `Clear`-ручки передают ПУСТУЮ строку, поэтому мутант не перевыпускает куку с живым токеном — он оставляет пустую куку, и следующий запрос всё равно приходит без сессии. То есть вреда сегодня нет, а не запинено СВОЙСТВО «выход удаляет куку из браузера», и это класс `PD-1`, родня `PD-86`/`PD-87`. Воспроизведение: `docs/p8-review/axis2-auth/mutations-axis2.sh` ⚠ **Пере-проверено на ПОЛНОЙ батарее координатором пака (мутации M13 и M14), и по дороге поймана СВОЯ ошибка метода:** первый прогон M13 дал красный, но упавшим оказался `TestAClaimThatLostARaceToAReleaseIsRetriedAndNotAnError` — известный флейк `PD-369`, к `ClearSession` отношения не имеющий. То есть вердикт, вынесенный по ЦВЕТУ батареи, а не по ТОПИЧНОСТИ упавшего теста, даёт ложное «пойман» и тихо теряет находку. Пере-прогон обеих мутаций даёт пустую дельту против чистой копии. Правило записано здесь, потому что цена его забывания — потерянная находка о недостающем пине: `docs/p8-review/mutations-round2.log` | open | ревью-пак P8-REVIEW, ось 2 (посадка мутации, цена поправлена рефутером) | | PD-383 | hardening | info | `cmd/tmplatformd/main.go:257`=`const loginJournalRetention = 180 * 24 * time.Hour`, `cmd/tmplatformd/main.go` `sweepLogins`, `internal/pgstore/identity.go` `DeleteOldLoginEvents` | **Ретенция журнала входов работает и не запинена ничем: и срок хранения, и сам свип переживают батарею.** Механизм построен (константа 180 суток, тикер 15 минут, `delete from login_events where at < $1`, монтируется в обеих ветках входа) и проверен ЖИВЬЁМ: строка возрастом 200 суток исчезла на ближайшем тике, демон напечатал `"login sweep" events=1`. Но ни срок, ни вызов не пинятся: `DeleteOldLoginEvents` не зовёт ни один тест, а `sweepLogins` — неэкспортируемая функция пакета `main` без теста. Класс `PD-1` на механизме, который ЛЕЧИТ уже закрытую строку. Воспроизведение: `docs/p8-review/pd23-retention-probe.sh` и `pd23-result.txt` ⚠ **Пере-проверено на ПОЛНОЙ батарее координатором пака (мутации M15 и M16):** срок хранения поднят со 180 суток до 180 ЛЕТ и, отдельно, предикат свипа обезврежен (`delete from login_events where at < $1 and 1=0`) — обе дельты против чистой копии ПУСТЫ на всех 18 пакетах. Лог — `docs/p8-review/mutations-round2.log` ⚠ **ПОЛОВИНА ЗАКРЫТА паком `sqlc` (`63fcee5`), и обе половины пере-проверены посадками — строка остаётся `open` ровно на остатке.** ЗАКРЫТ сам свип-предикат: `DeleteOldLoginEvents` теперь зовёт `pgstore.TestTheLoginJournalRetentionDeletesOnlyWhatIsOlderThanTheCutoff`, и он утверждает ЧИСЛО удалённых строк плюс границу (в фикстуре есть строка РОВНО на отсечке, потому что предикат строгий и без неё `<` неотличимо от `<=`). Мутация M16 (`and 1=0`) теперь красная адресно — «deleted 0 rows, want exactly 1». **НЕ закрыто и остаётся живым: сам СРОК хранения и вызов свипа.** Мутация M15 — `loginJournalRetention` 180 суток → 180 ЛЕТ (`cmd/tmplatformd/main.go:257`) — пере-прогнана 29.08 на полной батарее конвертированного дерева: **EXIT=0, ноль красных**. Причина остатка структурная и не лечится в `pgstore`: константа и `sweepLogins` живут в пакете `main`, куда тест `pgstore` не достаёт. То есть класс `PD-1` здесь снят с ЗАПРОСА и стоит на КОНФИГУРАЦИИ | open | ревью-пак P8-REVIEW, ось 2 (находка рефутера, живая проба координатора) | @@ -137,7 +137,7 @@ | PD-408 | doc | info | `internal/runs/bank.go` (бюджет двери = `s.runBudget()`), `internal/runner/bankapply.go` (`errOut` без лимита; `Stderr: firstLine`) | **Две операционные оговорки двери правок, названные воркфлоу-ревью; обе — цена конфигурации, не дефект пути.** (1) Бюджет двери — та же ручка `TM_PLATFORM_RUN_BUDGET`, что у прохода свипа; движок выбирал потолок 5000 решений против ЖЁСТКИХ 60 с («пять раз внутри бюджета»), и оператор, понизивший ручку (к чему соседние комментарии подталкивают), делает легальный документ-максимум навсегда неприменимым — вечный `503` вместо «разбей документ»; связка ручки и капа нигде не названа. (2) stderr глагола читается в НЕограниченный `bytes.Buffer`, хотя потребляется только первая строка, — не-тот бинарь по сконфигурированному пути (полудеплой, обёртка) может раздуть демона до OOM за 60-секундный бюджет; stdout той же команды капнут 64 МиБ | open | воркфлоу-ревью P9 28.08 (линзы door:lock-lifecycle · door:crash-windows), диспозиция оркестратора 28.08: строкой | | PD-428 | doc | info, деньги | `internal/pricing` (`TM_PLATFORM_USD_PER_CHAPTER`, `Pricing.Ceiling`) | **Цена продажи не знает о накладных, которые масштабируются КНИГОЙ, а не грантом.** Замер движкового охотника (лендинг `6ec9f8a`): терминолог переигрывается на КАЖДОЙ покупке ЦЕЛИКОМ по книге — три покупки по одному юниту дали три полнокнижных консолидации по $0.005460 каждая, при том что сам юнит дешевле. То есть книга на 500 юнитов, проданная по одному, оплатит 500 полнокнижных проходов. ⚠ **Сегодня это НЕ дефект платформы и заведено только как калибровка:** продажа идёт ГЛАВАМИ (`Ceiling(chapters)`), а не юнитами, так что нарезки, при которой накладные обгоняют полезную работу, в продукте нет. Строка существует, чтобы факт не потерялся к моменту, когда мелкая нарезка появится: любая будущая единица продажи мельче главы обязана нести в цене эту книжную составляющую, иначе COGS растёт быстрее выручки на самых дешёвых покупках. Носитель — константа цены, а не код движка ⚠⚠ **НОСИТЕЛЬ УМЕР И ПОСЫЛКА ПЕРЕВЕРНУЛАСЬ — паком «форма заказа» 05.09, строка пере-написана ПО СУЩЕСТВУ.** Названные тут `TM_PLATFORM_USD_PER_CHAPTER` и `Pricing.Ceiling(глав)` удалены вместе со ставкой (строка бэклога 280). ⚠ И оговорка ряда «сегодня это НЕ дефект платформы: продажа идёт ГЛАВАМИ, так что нарезки, при которой накладные обгоняют полезную работу, в продукте нет» — **больше не верна**: этот же пак ввёл заказ В ЗНАКАХ, который разрешается в ПРЕФИКС ЮНИТОВ, то есть единицу МЕЛЬЧЕ главы, и ровно её ряд и ждал. **Что с этим сделано и чего НЕ сделано, раздельно.** Книжная составляющая ТЕПЕРЬ ВИДНА и названа: движок публикует её отдельным числом `book_once_usd` (плоские $2.00 на боевом `pipeline-c1`), платформа читает его и НЕ кладёт в основу холда — иначе короткая книга непокупаема, — а кладёт СВЕРХ, когда баланс несёт, и говорит покупателю `term_consistency_funded: false`, когда не несёт (`pricing.Model.Hold`, пин `TestAFlatBookLevelBoundDoesNotPutATwoDollarThresholdUnderEveryPurchase`). **НЕ сделано главное, ради чего ряд заведён:** цена мелкой покупки по-прежнему не несёт книжной составляющей ПРОПОРЦИОНАЛЬНО — заказ в один юнит и заказ во всю книгу видят один и тот же бонд, поэтому COGS на самых дешёвых покупках растёт быстрее выручки ровно так, как ряд и предупреждал. Ряд остаётся `open` и с этого дня ПРЕДМЕТЕН, а не гипотетичен. Носитель — `pricing.Model.Hold` и `ingest.BookPrice.BookOnceUSD`. | open | движковый пак «деньги» (охотник), передано оркестратором №19 сессии P11 | | PD-421 | hardening | info | `internal/pgstore/sessions.go` `StillLive` и `SweepSessions`, `docs/STACK_DECISIONS.md` §13 | **Открытый поток теряет свою сессию по ПОДМЕТАНИЮ строки, а не по клаузе бездействия, — и это остаток закрытия `PD-379`, названный прямо.** Проверка живости потока намеренно НЕ содержит клаузы `idle_expires_at`: окно бездействия скользит на ЗАПРОСЕ, а поток — один запрос на всю жизнь, поэтому гашение по idle рвало бы связь активному читателю. Но `SweepSessions` раз в час УДАЛЯЕТ строки и по бездействию тоже, а «строки нет» ОБЯЗАНО значить «мертва» — иначе отозванная сессия держала бы поток до свипа, то есть дыра ровно в час. Следствие: сессия, протухшая по бездействию и подметённая, теряет поток с опозданием до часа. Это не idle гасит поток, а отсутствие строки; к тому моменту любой другой запрос того же вызывающего — 401. Лечение, если сочтётся недопустимым, — скольжение окна бездействия ИЗ потока, но это правка ПОЛИТИКИ §13: открытая вкладка держала бы сессию до абсолютного потолка, а это слово владельца | open | пак P11 (назван при закрытии `PD-379`) | -| PD-422 | bug | info | `internal/runs/runs.go:408`=`resnapshot := book.BankMoved || book.HasPriorRun`, `internal/pgstore/books.go:1035`=`HasPriorRun bool` | **`--resnapshot` платформа передаёт УСЛОВНО, а условие ставит только ПРАВКА банка — рост авто-банка от майнинга его не ставит.** Флаг выводится под `if book.BankMoved`, а единственный писатель `bank_moved_at` — дверь правок банка. На книге, которая МАЙНИТ банк, вторая покупка без правок банка идёт без флага, и движковый джоб-гард останавливает прогон (`exit 1` ⇒ `failed` на стороне платформы): авто-банк растёт от покупки к покупке, edit-снапшот съезжает, а гард банк-онли-движение от смены конфига не отличает. ⚠ Сегодня БЕСПРЕДМЕТНО: проводка `tmctl translate --max-units` на платформе гейчена оркестратором до лечения, а без неё вторая покупка этой формы не возникает. Строка заведена, чтобы условность не всплыла сюрпризом при снятии гейта. Найдено бэкенд-сессией `textmachine-e4` (пак «деньги»), проверено чтением платформенной стороны сессией P11 ⚠ **БЕСПРЕДМЕТНОСТЬ КОНЧИЛАСЬ И ДЕФЕКТ ЗАКРЫТ ТЕМ ЖЕ ПАКОМ (05.09), статус флипает ЛЕНДИНГ.** Гейт на проводку `--max-units` снят строкой 280, флаг едет в argv — значит условность `--resnapshot` перестала быть теоретической ровно в тот момент. Условие расширено: `resnapshot := book.BankMoved || book.HasPriorRun` (`internal/runs/runs.go`, греп `book.HasPriorRun`). Довод, почему флаг на КАЖДОМ продолжении безопасен: гард срабатывает ПО ДЖОБУ, то есть только на главах, которых прогон касается, а объёмный потолок допускает НОВУЮ книгу прежде пере-делки (`backend/internal/pipeline/volume.go`, проход `unitFresh` затем `unitRework`) — продолжение тратит грант на недоставленные главы. Согласие при этом фондированное: собственный холд прогона, никогда бланкетная форма. Пин: `runs.TestASecondPurchaseCarriesResnapshotEvenWithoutABankCorrection` (первая покупка флага НЕ несёт, вторая несёт, правки банка не было). | fixed(628cc56) | бэкенд-пак «деньги» + пак P11 (сверка шва) | +| PD-422 | bug | info | `internal/runs/runs.go:408`=`resnapshot := book.BankMoved || book.HasPriorRun`, `internal/pgstore/books.go:1048`=`HasPriorRun bool` | **`--resnapshot` платформа передаёт УСЛОВНО, а условие ставит только ПРАВКА банка — рост авто-банка от майнинга его не ставит.** Флаг выводится под `if book.BankMoved`, а единственный писатель `bank_moved_at` — дверь правок банка. На книге, которая МАЙНИТ банк, вторая покупка без правок банка идёт без флага, и движковый джоб-гард останавливает прогон (`exit 1` ⇒ `failed` на стороне платформы): авто-банк растёт от покупки к покупке, edit-снапшот съезжает, а гард банк-онли-движение от смены конфига не отличает. ⚠ Сегодня БЕСПРЕДМЕТНО: проводка `tmctl translate --max-units` на платформе гейчена оркестратором до лечения, а без неё вторая покупка этой формы не возникает. Строка заведена, чтобы условность не всплыла сюрпризом при снятии гейта. Найдено бэкенд-сессией `textmachine-e4` (пак «деньги»), проверено чтением платформенной стороны сессией P11 ⚠ **БЕСПРЕДМЕТНОСТЬ КОНЧИЛАСЬ И ДЕФЕКТ ЗАКРЫТ ТЕМ ЖЕ ПАКОМ (05.09), статус флипает ЛЕНДИНГ.** Гейт на проводку `--max-units` снят строкой 280, флаг едет в argv — значит условность `--resnapshot` перестала быть теоретической ровно в тот момент. Условие расширено: `resnapshot := book.BankMoved || book.HasPriorRun` (`internal/runs/runs.go`, греп `book.HasPriorRun`). Довод, почему флаг на КАЖДОМ продолжении безопасен: гард срабатывает ПО ДЖОБУ, то есть только на главах, которых прогон касается, а объёмный потолок допускает НОВУЮ книгу прежде пере-делки (`backend/internal/pipeline/volume.go`, проход `unitFresh` затем `unitRework`) — продолжение тратит грант на недоставленные главы. Согласие при этом фондированное: собственный холд прогона, никогда бланкетная форма. Пин: `runs.TestASecondPurchaseCarriesResnapshotEvenWithoutABankCorrection` (первая покупка флага НЕ несёт, вторая несёт, правки банка не было). | fixed(628cc56) | бэкенд-пак «деньги» + пак P11 (сверка шва) | | PD-439 | standards | info | `docs/DEFECT_REGISTER.md` (строки `PD-59`, `PD-115`, `PD-122`, `PD-273`, `PD-380`, `PD-407`, `PD-44`), шапка регистра (словарь статусов), `internal/gates/register_test.go` (греп `registerStatus`) | **Семь строк несут в колонке статуса не статус, и каждая из них невидима для всякого счёта, который эту колонку читает.** Словарь шапки — `open` · `fixed()` · `accepted-risk(<кем, когда>)`; в дереве встречаются `open (наблюдаемость закрыта P5; ops и конфигурация — нет)`, `open (грейс-половина закрыта D39.162)`, `open (добавление закрыто P5; удаление — нет)`, `closed (решение владельца 17.08)`, `fixed` без коммита (дважды) и `**закрыт ратификацией**, работа уходит строкой 103`. Зонный `awk` сверяет ячейку с `open` ТОЧНО, поэтому три аннотированных открытых ряда не попадают ни в одно число, которое зона печатала (включая «open=96»), а `fixed` без коммита нарушает правило «закрытие — только с коммитом фикса». ⚠ Найдено новым гейтом класса: он читает статус по словарю с аннотацией, считает такие ряды открытыми, называет нечитаемые поимённо и краснеет, если нечитаемый ряд стоит под заголовком «Открытые» и несёт маркеры тревоги. Строки НЕ правлю: смена статуса — акт лендинга, а здесь под вопросом и форма, и содержание вердикта | open | самопроверка пака P13 (гейт класса, первый прогон) | ## Принятый риск @@ -542,8 +542,8 @@ ## Закрытые — эра P11 (отзыв сессии на открытом потоке · застрявший расчёт · денежные пины) | PD-379 | vuln | **major** | `internal/httpapi/stream.go:160`=`h.pump(r.Context(), s, who, bookID, state, last, resuming)` ⚠ якорь пере-нацелен паком P11: сигнатуру `pump` изменил он сам (принципал вместо голого id — в этом и лечение), `internal/httpapi/server.go` `guard`, `internal/auth/session.go` `SessionStore` | **Открытый поток событий переживает и отзыв сессии, и оба её потолка: «выйти везде» не выключает уже установленный канал.** Аутентификация происходит РОВНО ОДИН РАЗ, в `auth.Authenticator.Require` внутри `guard`; дальше `streamEvents` уходит в `pump`, и цикл до конца соединения читает только `ReadFrames`/`ReadStream`, строку сессии не смотрит ни разу. Значит `POST /auth/logout`, `POST /auth/logout-all`, `tmplatformctl revoke` и оба потолка (idle и абсолютный) уже открытый `GET /v0/books/{bookId}/events` не прекращают. Пере-проверено трижды независимо (финдер, рефутер в отдельной копии, координатор); на демоне с `SESSION_IDLE=5s`/`SESSION_MAX_AGE=10s` поток жил +40 с после отзыва. Бьёт по объявленной норме: ASVS 5.0 7.4.1 — требование УРОВНЯ 1 при объявленном зоной L2, и `STACK_DECISIONS` §13 отказывается от лимита одновременных сессий ИМЕННО в обмен на мгновенный отзыв. ⚠ Побочно, тем же прогоном: `/auth/logout-all` на ДЕВ-профиле не смонтирован вовсе (404) — из пары ручек, которой §13 обосновывает свою политику, на стенде доступна одна. Воспроизведение: `docs/p8-review/axis2-auth/sse-outlives-revocation.sh` и `sse-outlives-absolute-ceiling.sh`, снимок координатора `docs/p8-review/sse-outlives-revocation.txt` ⚠ **ВЕС ПОДНЯТ minor → major ПОСЛЕ РЕВЬЮ СТАРШЕЙ МОДЕЛЬЮ (fable-5), и поднят по трём доводам, которых сужение не учло.** **(1)** Граница «соединение само закрывается, когда книга приходит в покой» — не гарантия кода: у книги, чей долг материализации списан как неоплатный, поток НЕ КОНЧАЕТСЯ НИКОГДА, и это собственный комментарий зоны — `internal/pgstore/books.go:629`=`a book whose event stream can NEVER end`. То есть окно утечки не ограничено прогоном. **(2)** Вес отказавшего КОМПЕНСИРУЮЩЕГО контроля наследуется от рисков, которые он компенсирует, а не от схемы кадра: `STACK_DECISIONS` §13 отказывается и от лимита одновременных сессий, и от собственной границы федеративной сессии ИМЕННО в обмен на мгновенный отзыв и два срока — а открытый поток ускользает от всех трёх разом, и у §13 не остаётся содержания. **(3)** Провалено требование УРОВНЯ 1 при объявленном зоной L2 — это дыра ниже собственного пола, а не отклонение от лучших практик. ⚠ **ЗАКРЫТО ПАКОМ P11 (29.08).** `auth.Principal` несёт непубличную способность пере-спросить свою сессию, `pump` зовёт её ПЕРВЫМ ДЕЛОМ на каждом тике, отказ даёт терминальный кадр `session_ended` (канон 0.8.0) с watermark СОЕДИНЕНИЯ, а не головой истории. Запрос стора `StillLive` намеренно БЕЗ клаузы idle: окно бездействия скользит на запросе, а поток — один запрос на всю жизнь, поэтому гашение по idle рвало бы связь активному читателю. Доказано ДВУМЯ раздельными живыми сценариями: (а) длинные потолки + `tmplatformctl revoke` → поток кончился через 1 с (`~/tm-p11/probes/a-revocation-ends-the-stream.txt`); (б) idle 10 с / абсолютный 40 с, БЕЗ отзыва → поток пережил окно бездействия и кончился ровно на потолке (`b-the-ceiling-ends-the-stream.txt`). Посадки `r_nocheck`, `r_idle`, `r_head`, `r_open`, `r_wirename` — пойманы. Эррата `STACK_DECISIONS` §13 снята, галочка ASVS 7.4.1 в архиве восстановлена. ⚠ Остаток отдельной строкой: строку сессии удаляет часовой свип и по бездействию тоже, поэтому «строки нет» обязано значить «мертва» | fixed(пак P11 — лендинг оркестратора; статус проставлен зоной по норме §3.6 «закрытие дефекта — коммит + пинящий тест») | ревью-пак P8-REVIEW, ось 2 (живая проба, подтверждено рефутером и координатором) | -| PD-385 | bug | **major** | `internal/pgstore/runs.go:535`=`where a.ended_at is null and a.reconcile_failures >= $1` ⚠ якорь пере-нацелен паком P11: прежняя строка (`where r.finished_at is null and …`) была ОДНИМ предикатом на обе половины и её больше нет — выборка разложена на две ветви, и это ровно лечение, `internal/pgstore/observe.go` (гейдж), `internal/pgstore/runs.go` `AbandonRun` | **Прогон, чей РАСЧЁТ доведён до `StalledAfter`, не виден операторским поверхностям порога, а лог-строка на пересечении порога шлёт оператора именно туда.** Обе фазы делят один счётчик через общий `deferItem`, но операторская половина построена только для ЖИВЫХ прогонов: `StalledRuns` джойнит `a.ended_at is null` и фильтрует `r.finished_at is null`, гейдж `tm_platform_runs_stalled` считает по тому же предикату, а `AbandonRun` читает `where id = $1 and finished_at is null` и отвечает `ErrNoRun`. Живая проба на состоянии, выращенном штатными путями (интейк, HTTP-старт, отказ спавна, `run abandon`): `runs --stalled` отвечает «no run is failing to reconcile», `runs` — «no run is live», `run abandon` — «is not a live run», гейдж 0, при этом в базе `settled_at` NULL, `reconcile_failures` 5 и открытая резервация на 90000 микро. Тот же слепой угол закрывает прогон, который ЖИВ, но чья ПРЕДЫДУЩАЯ попытка не рассчиталась после рестарта. ⚠ Рефутер опроверг заголовочный абсолют «невидим ВСЕМ поверхностям»: `tmplatformctl balance --user` печатает этот холд строкой, `tm_platform_oldest_open_hold_seconds` растёт без потолка, а `tmplatformctl books` показывает «WHY NOT: unsettled hold»; ноль в улике финдера был артефактом его фикстуры. Остаётся то, ради чего строка заведена: три поверхности ПОРОГА слепы, терминальной ручки для такого прогона нет, а ERROR на пороге называет команду, которая на нём молчит. Воспроизведение: `docs/p8-review/axis3-queue/live-stalled-settlement.sh` ⚠ **ВЕС ПОДНЯТ minor → major ЗАКРЫВАЮЩИМ РЕВЬЮ СТАРШЕЙ МОДЕЛИ, и довод не про эту строку в одиночку, а про КРУГОВОЕ сужение четырёх строк пака.** `PD-384` сужен до minor тем, что холд «виден» гейджу `tm_platform_oldest_open_hold_seconds` и команде `balance --user`. Но `PD-392` доказывает ЖИВОЙ ПРОБОЙ, что у этого гейджа ручки НЕТ: идентификатора он не даёт, `balance --user` требует аккаунт, которого гейдж не называет, глобального списка открытых холдов в CLI нет, а документированный случай самого гейджа это ровно данная популяция — при `oldest_open_hold_seconds 10813` все три команды отвечают «no run is live», «no run is failing to reconcile», «no book has been given up on». `PD-390` доказывает, что тот же гейдж умеет ЗАМИРАТЬ и отдавать нули как здоровье. `PD-389` — что его сеттеры не запинены ничем. То есть каждое из четырёх сужений держится поверхностью, несостоятельность которой доказывает соседняя строка ТОГО ЖЕ пака, и по кругу. А терминальной ручки для этой популяции нет ПО ПОСТРОЕНИЮ: `internal/pgstore/runs.go:683`=`select finished_at from runs where id = $1` ⚠ якорь пере-нацелен паком P11: прежняя строка отвечала `ErrNoRun` законченному прогону — она и была дефектом; теперь это ветвление, читаемое ПОД блокировкой книги. Следствие, названное прямо: для всей популяции «закончен, но не рассчитан» деньги пользователя заморожены бессрочно, поверхности ПОРОГА слепы, ERROR на пороге называет команду, которая откажет, и единственный выход — сырой SQL в проде. Составной инвариант, на котором принят пак P8-FIX (`D39.154`: гейдж плюс `runs --stalled` плюс `run abandon` как ответ на `PD-169`), для этой популяции ЛОЖЕН ЦЕЛИКОМ — а «решается до следующего пака» есть определение major-секции самого регистра. Носителем major сделана ЭТА строка как самая полная по улике (живая проба на состоянии из штатных путей плюс отказ ручки); `PD-384` и `PD-392` несут ссылку сюда, чтобы не плодить второй major на тот же корень ⚠ **ЗАКРЫТО ПАКОМ P11 (29.08).** Популяция «кончился, а деньги нет» вошла в `StalledRuns` (колонка `PHASE`), в гейдж `tm_platform_runs_stalled` и получила терминальную ручку: `run abandon` закрывает КАЖДЫЙ осиротевший холд прогона, снимает отсрочку и штампует `settled_at`; допуск сужен до `reconcile_failures >= 1`, иначе команда отдавала бы целиком холд расчёта, который просто ещё не закрылся. Доказано до/после на состоянии из ШТАТНЫХ путей (интейк → HTTP-старт → спавн → выход движка → снят запиненный бинарь): было «no run is live» / «is not a live run» / гейдж 0 при открытой резервации 90000 микро, стало строка `settling` с холдом и возврат денег целиком (`~/tm-p11/probes/pd385-before.txt`, `pd385-after.txt`). ⚠ Вторая названная строкой популяция — ЖИВОЙ прогон с нерассчитанной ПРЕДЫДУЩЕЙ попыткой — получила обе поверхности видимости, но не ручку: отдельной строкой | fixed(пак P11 — лендинг оркестратора; статус проставлен зоной) | ревью-пак P8-REVIEW, ось 3 (живая проба, сужено рефутером) | -| PD-376 | bug | minor, деньги | `internal/pgstore/runs.go:1221`=`select min(a.spend_baseline_micro_usd)`, пин `internal/runs/sweep_test.go` `TestADeferredSettlementIsNotChargedForTheNextRunOfTheSameBook` | **`PD-159` стоит `fixed`, а её пин доказывает свойство СЛАБЕЕ, чем читается: слово `min` не исполняет никто, и мутация `min` → `max` проходит батарею.** Строка PD-159 закрывает двойную оплату формулой «SpendBound — НАИМЕНЬШАЯ базовая линия среди попыток этой книги, стартовавших ПОЗЖЕ», а названный ею пин кладёт в книгу РОВНО ОДНУ более позднюю попытку — на множестве из одного элемента `min` и `max` совпадают. Прогон `./internal/pgstore/` и `./internal/runs/` под мутацией зелёный; независимый пин на той же мутации падает (`the bound is 0.500000, want 0.200000`), то есть мутация поведенческая, а не эквивалентная. Достижимость: при монотонном росте книжного счётчика `min` и `max` расходятся уже при ДВУХ более поздних попытках, а две даёт один преемник, переживший рестарт или резюм; тогда границей становится базовая линия, УЖЕ содержащая трату предыдущего прогона — это ровно PD-159 на одну попытку дальше. Переплата ограничена холдом. Класс — «реестр умеет врать», тот же разбор, каким был найден PD-169. Готовый пин: `docs/p8-review/axis1-money/a1_spendbound_test.go.txt` и независимый `r1_spendbound_test.go.txt`. ⚠ **Пере-проверено на ПОЛНОЙ батарее координатором пака (мутация M11):** улика пере-снята на полной батарее — прогон `go test ./... -count=1` со всеми тремя гейтами под той же мутацией даёт ПУСТУЮ дельту против чистой базовой линии той же копии — то есть мутацию не ловит ни один из 18 пакетов. Лог — `docs/p8-review/mutations-round2.log` ⚠ **ЗАКРЫТО ПАКОМ P11 (29.08).** Взят готовый пин пака — вариант `r1_` как более сильный (ходит настоящими дверями `StartRun`/`RecordSpawn`, наименьшая базовая линия стоит В СЕРЕДИНЕ, так что «первая поздняя» и «последняя поздняя» тоже падают) — и усилен ВТОРОЙ книгой того же аккаунта, чтобы исполнялся и фильтр `r.book_id`. Посадка `min`→`max`: чистая копия EXIT=0, с мутацией EXIT=1, единственный красный — этот пин. ⚠ **ДИСПОЗИЦИЯ, которую строка оставляла приёмке: `PD-159` НЕ пере-открывается.** Основание прежнее (D39.159 §5): дефект из кода ушёл, недоставало ПИНА, — а теперь пин есть, то есть пробел закрыт там, где он был. Двусторонняя ссылка сохраняется: `PD-159` несёт `ОСПОРЕНО(PD-376)`, эта строка называет `PD-159` | fixed(пак P11 — лендинг оркестратора; статус проставлен зоной) | ревью-пак P8-REVIEW, ось 1 (посадка мутации, подтверждено рефутером собственным пином) | +| PD-385 | bug | **major** | `internal/pgstore/runs.go:541`=`where a.ended_at is null and a.reconcile_failures >= $1` ⚠ якорь пере-нацелен паком P11: прежняя строка (`where r.finished_at is null and …`) была ОДНИМ предикатом на обе половины и её больше нет — выборка разложена на две ветви, и это ровно лечение, `internal/pgstore/observe.go` (гейдж), `internal/pgstore/runs.go` `AbandonRun` | **Прогон, чей РАСЧЁТ доведён до `StalledAfter`, не виден операторским поверхностям порога, а лог-строка на пересечении порога шлёт оператора именно туда.** Обе фазы делят один счётчик через общий `deferItem`, но операторская половина построена только для ЖИВЫХ прогонов: `StalledRuns` джойнит `a.ended_at is null` и фильтрует `r.finished_at is null`, гейдж `tm_platform_runs_stalled` считает по тому же предикату, а `AbandonRun` читает `where id = $1 and finished_at is null` и отвечает `ErrNoRun`. Живая проба на состоянии, выращенном штатными путями (интейк, HTTP-старт, отказ спавна, `run abandon`): `runs --stalled` отвечает «no run is failing to reconcile», `runs` — «no run is live», `run abandon` — «is not a live run», гейдж 0, при этом в базе `settled_at` NULL, `reconcile_failures` 5 и открытая резервация на 90000 микро. Тот же слепой угол закрывает прогон, который ЖИВ, но чья ПРЕДЫДУЩАЯ попытка не рассчиталась после рестарта. ⚠ Рефутер опроверг заголовочный абсолют «невидим ВСЕМ поверхностям»: `tmplatformctl balance --user` печатает этот холд строкой, `tm_platform_oldest_open_hold_seconds` растёт без потолка, а `tmplatformctl books` показывает «WHY NOT: unsettled hold»; ноль в улике финдера был артефактом его фикстуры. Остаётся то, ради чего строка заведена: три поверхности ПОРОГА слепы, терминальной ручки для такого прогона нет, а ERROR на пороге называет команду, которая на нём молчит. Воспроизведение: `docs/p8-review/axis3-queue/live-stalled-settlement.sh` ⚠ **ВЕС ПОДНЯТ minor → major ЗАКРЫВАЮЩИМ РЕВЬЮ СТАРШЕЙ МОДЕЛИ, и довод не про эту строку в одиночку, а про КРУГОВОЕ сужение четырёх строк пака.** `PD-384` сужен до minor тем, что холд «виден» гейджу `tm_platform_oldest_open_hold_seconds` и команде `balance --user`. Но `PD-392` доказывает ЖИВОЙ ПРОБОЙ, что у этого гейджа ручки НЕТ: идентификатора он не даёт, `balance --user` требует аккаунт, которого гейдж не называет, глобального списка открытых холдов в CLI нет, а документированный случай самого гейджа это ровно данная популяция — при `oldest_open_hold_seconds 10813` все три команды отвечают «no run is live», «no run is failing to reconcile», «no book has been given up on». `PD-390` доказывает, что тот же гейдж умеет ЗАМИРАТЬ и отдавать нули как здоровье. `PD-389` — что его сеттеры не запинены ничем. То есть каждое из четырёх сужений держится поверхностью, несостоятельность которой доказывает соседняя строка ТОГО ЖЕ пака, и по кругу. А терминальной ручки для этой популяции нет ПО ПОСТРОЕНИЮ: `internal/pgstore/runs.go:689`=`select finished_at from runs where id = $1` ⚠ якорь пере-нацелен паком P11: прежняя строка отвечала `ErrNoRun` законченному прогону — она и была дефектом; теперь это ветвление, читаемое ПОД блокировкой книги. Следствие, названное прямо: для всей популяции «закончен, но не рассчитан» деньги пользователя заморожены бессрочно, поверхности ПОРОГА слепы, ERROR на пороге называет команду, которая откажет, и единственный выход — сырой SQL в проде. Составной инвариант, на котором принят пак P8-FIX (`D39.154`: гейдж плюс `runs --stalled` плюс `run abandon` как ответ на `PD-169`), для этой популяции ЛОЖЕН ЦЕЛИКОМ — а «решается до следующего пака» есть определение major-секции самого регистра. Носителем major сделана ЭТА строка как самая полная по улике (живая проба на состоянии из штатных путей плюс отказ ручки); `PD-384` и `PD-392` несут ссылку сюда, чтобы не плодить второй major на тот же корень ⚠ **ЗАКРЫТО ПАКОМ P11 (29.08).** Популяция «кончился, а деньги нет» вошла в `StalledRuns` (колонка `PHASE`), в гейдж `tm_platform_runs_stalled` и получила терминальную ручку: `run abandon` закрывает КАЖДЫЙ осиротевший холд прогона, снимает отсрочку и штампует `settled_at`; допуск сужен до `reconcile_failures >= 1`, иначе команда отдавала бы целиком холд расчёта, который просто ещё не закрылся. Доказано до/после на состоянии из ШТАТНЫХ путей (интейк → HTTP-старт → спавн → выход движка → снят запиненный бинарь): было «no run is live» / «is not a live run» / гейдж 0 при открытой резервации 90000 микро, стало строка `settling` с холдом и возврат денег целиком (`~/tm-p11/probes/pd385-before.txt`, `pd385-after.txt`). ⚠ Вторая названная строкой популяция — ЖИВОЙ прогон с нерассчитанной ПРЕДЫДУЩЕЙ попыткой — получила обе поверхности видимости, но не ручку: отдельной строкой | fixed(пак P11 — лендинг оркестратора; статус проставлен зоной) | ревью-пак P8-REVIEW, ось 3 (живая проба, сужено рефутером) | +| PD-376 | bug | minor, деньги | `internal/pgstore/runs.go:1227`=`select min(a.spend_baseline_micro_usd)`, пин `internal/runs/sweep_test.go` `TestADeferredSettlementIsNotChargedForTheNextRunOfTheSameBook` | **`PD-159` стоит `fixed`, а её пин доказывает свойство СЛАБЕЕ, чем читается: слово `min` не исполняет никто, и мутация `min` → `max` проходит батарею.** Строка PD-159 закрывает двойную оплату формулой «SpendBound — НАИМЕНЬШАЯ базовая линия среди попыток этой книги, стартовавших ПОЗЖЕ», а названный ею пин кладёт в книгу РОВНО ОДНУ более позднюю попытку — на множестве из одного элемента `min` и `max` совпадают. Прогон `./internal/pgstore/` и `./internal/runs/` под мутацией зелёный; независимый пин на той же мутации падает (`the bound is 0.500000, want 0.200000`), то есть мутация поведенческая, а не эквивалентная. Достижимость: при монотонном росте книжного счётчика `min` и `max` расходятся уже при ДВУХ более поздних попытках, а две даёт один преемник, переживший рестарт или резюм; тогда границей становится базовая линия, УЖЕ содержащая трату предыдущего прогона — это ровно PD-159 на одну попытку дальше. Переплата ограничена холдом. Класс — «реестр умеет врать», тот же разбор, каким был найден PD-169. Готовый пин: `docs/p8-review/axis1-money/a1_spendbound_test.go.txt` и независимый `r1_spendbound_test.go.txt`. ⚠ **Пере-проверено на ПОЛНОЙ батарее координатором пака (мутация M11):** улика пере-снята на полной батарее — прогон `go test ./... -count=1` со всеми тремя гейтами под той же мутацией даёт ПУСТУЮ дельту против чистой базовой линии той же копии — то есть мутацию не ловит ни один из 18 пакетов. Лог — `docs/p8-review/mutations-round2.log` ⚠ **ЗАКРЫТО ПАКОМ P11 (29.08).** Взят готовый пин пака — вариант `r1_` как более сильный (ходит настоящими дверями `StartRun`/`RecordSpawn`, наименьшая базовая линия стоит В СЕРЕДИНЕ, так что «первая поздняя» и «последняя поздняя» тоже падают) — и усилен ВТОРОЙ книгой того же аккаунта, чтобы исполнялся и фильтр `r.book_id`. Посадка `min`→`max`: чистая копия EXIT=0, с мутацией EXIT=1, единственный красный — этот пин. ⚠ **ДИСПОЗИЦИЯ, которую строка оставляла приёмке: `PD-159` НЕ пере-открывается.** Основание прежнее (D39.159 §5): дефект из кода ушёл, недоставало ПИНА, — а теперь пин есть, то есть пробел закрыт там, где он был. Двусторонняя ссылка сохраняется: `PD-159` несёт `ОСПОРЕНО(PD-376)`, эта строка называет `PD-159` | fixed(пак P11 — лендинг оркестратора; статус проставлен зоной) | ревью-пак P8-REVIEW, ось 1 (посадка мутации, подтверждено рефутером собственным пином) | | PD-384 | bug | minor | `internal/runs/reconcile.go:278`=`case overran:` (`settleOne`) ⚠ якорь пере-нацелен паком P11: прежнее `if !overran {` было САМИМ дефектом — судить по цене вместо вердикта — и заменено свитчем по вердикту, `internal/runs/reconcile.go` `settle` (три тихих `return nil`) | **Расчёт денег, упавший ДЁШЕВО, не считается никогда: порог `StalledAfter` для него недостижим.** Вторая фаза считает неудачу ТОЛЬКО по исчерпанию бюджета (`overran := errors.Is(item.Err(), context.DeadlineExceeded)`, дальше `if !overran { return }`), а `settle` возвращает nil БЫСТРО в трёх случаях: движок не ответил, в отчёте нет committed, попытка без базовой линии. Каждый может быть ПОСТОЯННЫМ — запиненный бинарь движка снесён при выкате, проект заменён под платформой, попытка старой схемы. Тогда цикл вечен: `reconcile_failures` остаётся 0, `reconcile_after` NULL, гейдж и `tmplatformctl runs --stalled` пусты, холд заморожен. Замерено пробой: пять проходов одного нерассчитываемого прогона дали 5 вызовов движка, `reconcile_failures=0`, `StalledRuns(5)=0`. Плюс цена: `settle` зовёт `tmctl status` НА КАЖДОМ проходе без рейт-лимита, тогда как соседний `maybeResync` имеет `dueForResync` ровно из-за этой цены. ⚠ Рефутер сузил вес major → minor: холд ВИДЕН двум поверхностям, которых финдер не спросил — гейдж `tm_platform_oldest_open_hold_seconds` и `tmplatformctl balance --user`, печатающий каждый открытый холд суммой, книгой и id прогона; плюс каждый проход пишет WARN с id прогона. Воспроизведение: `docs/p8-review/axis3-queue/probe_settlement_surface_test.go.txt` ⚠ **Общий корень с `PD-385`, и там же он взвешен:** сужение ЭТОЙ строки опирается на операторскую поверхность, несостоятельность которой доказывает соседняя строка того же пака — круговое сужение разобрано в `PD-385`, поднятой до major ⚠ **ЗАКРЫТО ПАКОМ P11 (29.08).** Неудачей считается ВЕРДИКТ расчёта, а не только исчерпание бюджета: `settle` вернул три состояния (закрыт · гонка · не вычислим), `settleOne` судит по ним, поэтому все три тихих `return nil` теперь доходят до порога. Цена, названная строкой, закрыта тем же ходом: отсрочка ограничивает `tmctl status` вместо вызова каждым проходом. ⚠ Первая неудача НЕ откладывается — открытая резервация это ворота РЕЗЮМА пользователя (`reopen` отказывает, пока холд предыдущей попытки открыт), и минута ожидания после секундной аварии была бы регрессом; бэкофф идёт со второй и капнут пятью минутами, а не тридцатью. Посадка `r_firstfast` | fixed(пак P11 — лендинг оркестратора; статус проставлен зоной) | ревью-пак P8-REVIEW, ось 3 (проба на реальном сторе, сужено рефутером) | | PD-391 | bug | minor | `internal/pgstore/sink.go:724`=`where a.reconcile_after is null or a.reconcile_after <= $1`, `internal/pgstore/runs.go` `AbandonRun`, `cmd/tmplatformctl/runs.go` (сообщение), `deploy/README.md` | **`run abandon` не возвращает холд «на ближайшем свипе»: отсрочка застрявшей попытки остаётся, и деньги ждут до 30 минут.** `AbandonRun` завершает прогон и попытку, но `run_attempts.reconcile_after` не трогает, а `UnsettledRuns` по нему фильтрует. Застрявший прогон по построению всегда отсрочен: `deferItem` ставит `now + backoff(failures+1)`, а `backoff` при пяти неудачах упирается в потолок 30 минут. Значит для ВСЕЙ популяции, ради которой команда построена, обещание CLI «its hold comes back whole on the next sweep» и та же фраза рантбука ложны: кредит остаётся вычтенным, `tm_platform_oldest_open_hold_seconds` продолжает расти ПОСЛЕ действия оператора, и оператор читает это как «я сделал, не помогло». Пин `PD-361` доказывает свойство слабее: он берёт прогон, созданный `Start` и брошенный СРАЗУ, у которого `reconcile_failures` 0 и `reconcile_after` NULL. Живая проба на состоянии, выращенном штатным механизмом: через 45 секунд и три свипа холд открыт, `balance` печатает «reserved 3.000000», гейдж 2439 с; ручной `update run_attempts set reconcile_after = now()` закрывает холд в тот же свип. Воспроизведение: `docs/p8-review/axis4-metrics/20-abandon-keeps-the-hold.sh` и `r3-abandon-hold.sh` ⚠ **ЗАКРЫТО ПАКОМ P11 (29.08).** `AbandonRun` снимает `reconcile_after` в ОБЕИХ ветках, живой и расчётной. Пин растит прогон до пяти неудач штатными путями и сверяет холд ПОСЛЕ свипа на НЕДВИНУТЫХ часах — то есть исполняет ровно то обещание CLI, которое было ложным. Названные строкой носители обещания исправлены: `deploy/README.md` и сообщение команды. Посадка `m391_defer` — поймана топично | fixed(пак P11 — лендинг оркестратора; статус проставлен зоной) | ревью-пак P8-REVIEW, ось 4 (живая проба, подтверждено рефутером на состоянии из штатного пути) | | PD-397 | hardening | info | `internal/pgstore/credits.go:52-53`=`A ledger row is never edited: the correction is another row`, `internal/pgstore/credits.go:398`=`The two are never written apart`, миграция `internal/pgstore/migrations/00007_credits.sql` | **Два самых сильных денежных инварианта объявлены ПРОЗОЙ и держатся ТОЛЬКО кодом — схема их не навязывает.** `Adjust` обещает «леджер не правится, коррекция это ещё одна строка, и именно это делает сумму воспроизводимой»; `appendLedger` обещает «кэш и леджер никогда не пишутся врозь, потому что отстающий кэш — это второй ответ про деньги». Проба прямым SQL по стенду показывает, что DDL допускает нарушение обоих: `UPDATE` и `DELETE` строки леджера ПРИНЯТЫ, кэш баланса выставляется ЛОЖЬЮ и ОТРИЦАТЕЛЬНЫМ тоже. Пере-проверено координатором пака независимо от агента — все четыре приняты, и откат пробы сам же оставил расхождение кэша с леджером в 1 микро-доллар, которое поймало только сведение двумя путями, а не база. ⚠ Что схема при этом ДЕРЖИТ и что находкой НЕ является (иначе строка читается как «денежных констрейнтов нет»): знак по каждому виду строки, обязательная нота у коррекции, закрытый словарь видов, непустые `source`/`source_id`, уникальность ключа идемпотентности в пределах аккаунта, положительность сумм резервации, согласованность состояния и времени закрытия, владение книгой через композитный внешний ключ, и переполнение bigint в кэше. То есть DDL закрывает ФОРМУ строки и не закрывает ИСТОРИЮ. Цена названа и она не про сегодняшний код: пути правки леджера в Go нет, поэтому эксплуатации нет — опасны миграция данных, операторский `psql` и будущий инструмент, каждый из которых по построению идёт мимо кода, а прозу в доккомментарии не читает. Лечится либо триггером на `update`/`delete` по `credit_ledger`, либо явной записью «append-only — дисциплина кода, не схемы» рядом с обещанием. Воспроизведение: `docs/p8-review/axis1-money/constraint-probe.sh` ⚠ **ЗАКРЫТО ПАКОМ P11 (29.08), и закрыто ВТОРЫМ из двух предложенных строкой способов.** Триггер на `update`/`delete` по `credit_ledger` ОТКЛОНЁН с двумя основаниями, проверенными в дереве: он сработает на КАСКАДНОМ удалении пользователя, которое миграция `00007` объявляет границей append-only, и сломает законную фикстуру `TestAReleaseWhoseKeyWasSpentIsRefusedRatherThanSilent`, которая правит леджер намеренно. Вместо него: проза `Adjust` и `appendLedger` сделана честной («держит КОД, а не схема», с перечнем того, что схема ДЕРЖИТ), плюс ГЕЙТ `TestTheLedgerIsAppendOnlyInTheCodeThatWritesIt` — ни один `update`/`delete` по `credit_ledger` (в том числе схемо-квалифицированный) не написан ни в одном из 172 SQL пакета. Посадка `r_ledgeredit` настоящей формой (`tx.Exec` внутри `appendLedger`). ⚠ Что осталось НЕзакрытым и названо: миграция данных, операторский `psql` и будущий инструмент идут мимо пакета по построению | fixed(пак P11 — лендинг оркестратора; статус проставлен зоной; ⚠ закрыт РАЗБОРОМ с отказом от триггера, см. тело) | ревью-пак P8-REVIEW, ось 1 (проба агента, пере-проверена координатором; строка заведена по аудиту полноты) | @@ -597,7 +597,7 @@ | PD-367 | bug | minor | `internal/books/parse.go:129`, `internal/ingest/manifest.go` `Whole` | **Пол на самосогласованность манифеста стоит только у материализатора, а интейк тот же документ ПРИНИМАЕТ.** Манифест `{ChaptersTotal: 120, UnitsTotal: 400}` с пустым списком глав `Whole()` отвергает, а `books.Parse` заводит книгу `not_started` с `chapter_count=120` и пустым деревом — по такой книге можно СТАРТОВАТЬ и ОПЛАТИТЬ прогон (потолок считается от `chapter_count`). Воспроизведено ревью на живом Postgres. ⚠ **ЗАКРЫТА пак P12 (30–31.08) по решению оркестратора: пол самосогласованности СИММЕТРИЧЕН, интейк отказывает.** `ingest.Manifest.Readable()` — ВТОРОЙ предикат рядом с `Whole()`, не расширение `DecodeManifest` (у того четыре пина намеренно декодируют частичные и чужие документы, и его собственная дока объявляет правило «значение не гейтим» решением). Стоит ПЕРВЫМ в `books.Parse`, до ветвей по `ChaptersTotal`: ниже этой черты документ, прочитанный неверно, неотличим от книги, в которой ничего нет, а ЭТО чтение удаляет аплоад. Класс — `parser_unavailable`: бюджет попыток тратится, файл остаётся. ⚠ **ПРАВКА ЗАПИНЕННОГО КОНТРАКТА, заказанная промтом P12 §3.8** — не подгонка под зелень: батарея интейка ездила на документах без списка глав, и фикстуры РАСШИРЕНЫ (`wholeManifest`), а не обойдены; поимённо пере-подписаны фикстуры `books_test.newFixture` и два манифеста `render_test`. Пин — `books.TestTheIntakeRefusesTheDocumentItsOwnMaterialiserWouldReject` (ровно документ строки: 120 глав, 400 пар, пустой список; проверяет и что `chapter_count` НЕ записан, и что файл цел); посадка M15 КРАСНАЯ адресно. | fixed(пак P12) | воркфлоу-ревью волны 2 (P8-FIX); рефутер подтвердил механику и опроверг предложенное лекарство | | PD-213 | hardening | info | `internal/ingest/manifest.go`, `internal/books/parse.go` | **Форма манифеста не версионируется на стороне платформы — латентная мина на УДАЛЕНИЕ файла.** `DecodeManifest` не сверяет `manifest_version` ни с чем; `json.Unmarshal` тихо игнорирует незнакомые поля и оставляет отсутствующие нулями, поэтому смена формы движком (`tm-manifest-v2` → v3, переименование `chapters_total`) даст валидный разбор с `ChaptersTotal = 0`. А ноль глав интейк трактует как «источник прочли, книги нет» — тот же терминал и тот же бюджет, что exit 11, то есть после пяти попыток файл пользователя УДАЛЯЕТСЯ, хотя движок книгу прекрасно разобрал. Сегодня формы совпадают поле-в-поле, так что не эксплуатируется; в отличие от потока (мажор отвергается) и от полосы кодов (незнакомый номер безопасен), у манифеста аналога нет. Лечить сверкой `manifest_version` с известной, где незнакомая версия даёт НЕ-деструктивный класс ⚠ **ЗАКРЫТА пак P12 (30–31.08) тем же классом «незнакомое → не-деструктивно»,** как и предписывала строка. `ingest.KnownManifestVersion` (`tm-manifest-v2`, зеркало `backend/internal/pipeline/manifest.go:46`) сверяется в `Readable()` перед всем остальным, и незнакомая версия даёт `parser_unavailable` — файл цел. Это ФОРМЕННЫЙ гейт, а не пин значения: сама строка и дока поля правы, что пиннинг значения везде сделал бы каждый релиз движка релизом платформы, поэтому версия сверяется РОВНО в одном месте — там, где неверное чтение разрушительно. Пин — `books.TestAManifestShapeThisBuildDoesNotKnowNeverDeletesTheUpload`: v3-манифест переживает весь бюджет попыток с целым файлом, а ЗНАКОМАЯ форма с честно нулевыми счётчиками по-прежнему удаляет каталог (иначе гейт съел бы настоящий вердикт о тексте пользователя). Посадка M14 (снять сверку версии) КРАСНАЯ адресно. | fixed(пак P12) | адверсариальное ревью P6 (линза шва) | | PD-427 | doc | minor | `internal/ingest/resync.go:37-43` (аллоулист `StatusReport`), опровергнуто `backend/internal/pipeline/status.go` `projectStoredMemory` (лендинг `6ec9f8a`, D39.170) | **Комментарий несёт ПОСЫЛКУ, которую сняли, и читается как действующий довод.** Он объясняет, почему платформа сознательно НЕ берёт `rebill_units`/`rebill_usd` через шов: «status проецирует СОХРАНЁННУЮ память, и сразу после `bank-apply` — в единственный момент, когда согласие хотело бы цифру, — он честно читает ноль». Это было верно и ратифицировано (эррата 28.08-к). Движковый пак «деньги» починил ровно это: `foldMemoryForRead` стал ПЕРВЫМ ответом читающего пути, а `projectStoredMemory` понижена до фолбэка, и комментарий движка объявляет это дословно — «IT IS NO LONGER THE READ PATH'S FIRST ANSWER». Слепое окно закрыто, `status` отвечает «сколько будет стоить» ДО покупки, оставаясь $0-глаголом без записи. ⚠ **Комментарий неверен ДВАЖДЫ:** не только посылка, но и предсказанное лечение — он обещает, что «пара вернётся с движковым ГЛАГОЛОМ, который умеет свернуть и оценить коррекцию ВНЕ прогона», а нового глагола не появилось: починили существующий `status`. ⚠ **ПРОВОДКУ ПОЛЕЙ ЭТА СТРОКА НЕ ОТКРЫВАЕТ** (слово оркестратора при передаче): она гейчена вместе с `tmctl translate --max-units`, и тот гейт в силе — движковый потолок объёма на майнящей банк книге пробивался, лечение легло, но проводка ждёт отдельного решения. То есть предмет строки — ровно устаревший ДОВОД, а не отсутствие полей. Класс — «указатель пережил то, на что указывал», тот же, что `PD-310`/`PD-326`/`PD-366`, только в прозе шва. Зеркалит строку 234 единого бэклога ⚠ **ЗАКРЫТА пак P12 (30–31.08) — акт закрытия, не работа:** комментарий `internal/ingest/resync.go` уже исправлен 29.08 аудитом доков, снятая посылка из него ушла, предсказание про «новый движковый глагол» тоже. Проверено чтением обеих сторон. Проводку полей строка не открывала и не открывает — гейт `--max-units` в силе. | fixed(пак P12, акт закрытия) | оркестратор №19 при лендинге движкового пака (`6ec9f8a`), проверено чтением обеих сторон сессией P11 | -| PD-203 | bug | info | `internal/pgstore/books.go` `ReadUsage` | **Аккаунт объявляется исчерпанным по паузе ОДНОГО прогона.** `/usage` ставит `paused_reason` аккаунта, если у какой-нибудь книги последний прогон стоит `paused/credit_exhausted` — а это потолок ПРОГОНА (сколько глав купил пользователь), а не баланс: на аккаунте может лежать сколько угодно денег, и другой прогон стартует. Контракт про это поле говорит «Set when the account itself is in a halted state». Существовало до этого пака и не им создано; отдельной строкой, потому что различение потолков (PD-199) сделало вопрос «чей это потолок» отвечаемым ⚠ **СУЖЕНО P7:** `Usage.halt_reason` получил СВОЙ словарь (`AccountHaltReason`), и `PD-241` убрал самый частый ложный источник — стоп пользователя, приезжавший `credit_exhausted` ⚠ **ПАК P8-REVIEW 24.08 ПРЕДЛОЖИЛ ЗАКРЫТЬ, проверив предикат по коду:** `internal/pgstore/books.go` `ReadUsage` читает БАЛАНС аккаунта, а не сканирует книги (`platform/internal/pgstore/books.go:1154`=`case balance <= 0:`), а ⚠-комментарий рядом прямо описывает замену (`platform/internal/pgstore/books.go:1123`=`It used to be read off the`); единственный писатель `Usage.PausedReason` — эта же строка (греп `PausedCreditExhausted` по `internal/pgstore/books.go`), приехало `9b23e8c` ⚠ **ЗАКРЫТА пак P12 (30–31.08): предикат пере-проверен и предложение P8-REVIEW подтверждено.** `internal/pgstore/books.go` `ReadUsage` читает БАЛАНС аккаунта и ставит аккаунтную причину только при `balance <= 0`; книги не сканируются. Канон на той же стороне: `AccountHaltReason` — «a state of the account, not of a run», отдельный словарь ровно затем, чтобы прогонная причина не зажигала аккаунтный флаг. То есть дефект строки не воспроизводится, и это закрытие, а не пере-открытие. | fixed(пак P12) | сессия P6 (самопроверка вокруг PD-199) | +| PD-203 | bug | info | `internal/pgstore/books.go` `ReadUsage` | **Аккаунт объявляется исчерпанным по паузе ОДНОГО прогона.** `/usage` ставит `paused_reason` аккаунта, если у какой-нибудь книги последний прогон стоит `paused/credit_exhausted` — а это потолок ПРОГОНА (сколько глав купил пользователь), а не баланс: на аккаунте может лежать сколько угодно денег, и другой прогон стартует. Контракт про это поле говорит «Set when the account itself is in a halted state». Существовало до этого пака и не им создано; отдельной строкой, потому что различение потолков (PD-199) сделало вопрос «чей это потолок» отвечаемым ⚠ **СУЖЕНО P7:** `Usage.halt_reason` получил СВОЙ словарь (`AccountHaltReason`), и `PD-241` убрал самый частый ложный источник — стоп пользователя, приезжавший `credit_exhausted` ⚠ **ПАК P8-REVIEW 24.08 ПРЕДЛОЖИЛ ЗАКРЫТЬ, проверив предикат по коду:** `internal/pgstore/books.go` `ReadUsage` читает БАЛАНС аккаунта, а не сканирует книги (`platform/internal/pgstore/books.go:1167`=`case balance <= 0:`), а ⚠-комментарий рядом прямо описывает замену (`platform/internal/pgstore/books.go:1136`=`It used to be read off the`); единственный писатель `Usage.PausedReason` — эта же строка (греп `PausedCreditExhausted` по `internal/pgstore/books.go`), приехало `9b23e8c` ⚠ **ЗАКРЫТА пак P12 (30–31.08): предикат пере-проверен и предложение P8-REVIEW подтверждено.** `internal/pgstore/books.go` `ReadUsage` читает БАЛАНС аккаунта и ставит аккаунтную причину только при `balance <= 0`; книги не сканируются. Канон на той же стороне: `AccountHaltReason` — «a state of the account, not of a run», отдельный словарь ровно затем, чтобы прогонная причина не зажигала аккаунтный флаг. То есть дефект строки не воспроизводится, и это закрытие, а не пере-открытие. | fixed(пак P12) | сессия P6 (самопроверка вокруг PD-199) | | PD-380 | hardening | minor | `internal/pgstore/sessions.go:96`=`AbsoluteExpiresAt: now.Add(maxAge),`, `internal/config/config_test.go` | **Абсолютный потолок сессии не запинен в единственном месте, где он становится фактом в базе.** `CreateSession` — единственный писатель `sessions.absolute_expires_at`, и мутация этого выражения проходит ПОЛНЫЙ пакет `pgstore`: ни один сессионный тест не краснеет. Пин, который `STACK_DECISIONS` §13 называет носителем потолка, смотрит только на результат `config.Load()` (что значение конфигурации не выше ASVS-предела), то есть проверяет НАСТРОЙКУ, а не то, что она доезжает до строки. Родня `PD-86`, но на шаг раньше: там не запинены клаузы ЧТЕНИЯ и потолок держится транзитивно через `Touch`, здесь не запинена сама ЗАПИСЬ, а транзитивной страховки у неё нет. Воспроизведение: `docs/p8-review/axis2-auth/mutations-axis2.sh` ⚠ **Пере-проверено на ПОЛНОЙ батарее координатором пака (мутация M12):** `now.Add(maxAge)` → `now.Add(100*maxAge)` в `CreateSession`, дельта против чистой копии ПУСТА на всех 18 пакетах. ⚠ **ВЕС ПОДНЯТ info → minor закрывающим ревью, довод — симметрия с `PD-375`:** форма идентична (единственная точка принуждения объявленной границы не исполняется ни одним тестом, мутация переживает ПОЛНУЮ батарею, транзитивной страховки нет — `Touch` зажимает по значению ИЗ ТОЙ ЖЕ испорченной строки), а вес расходился только по валюте: там деньги и minor, здесь механизм ASVS 7.3.2 уровня 2 при объявленной зоной базовой линии L2 и info. Асимметрия была отпечатком того самого храповика «вреда сегодня нет», который это ревью и нашло ⚠ **Якорь пере-нацелен паком `sqlc` (29.08):** прежний токен — `s.pool.Exec` в `CreateSession` — исчез, потому что SQL этого запроса уехал в `internal/pgstore/queries/sessions.sql` и исполняется генерённым кодом. Новая цель — строка, где `maxAge` СТАНОВИТСЯ значением (`AbsoluteExpiresAt: now.Add(maxAge)`): именно она несёт факт, о котором строка, и она переживёт следующую генерацию. Сам дефект не тронут — потолок по-прежнему не запинен. ⚠ **ЗАКРЫТО паком `sqlc` (`63fcee5`, D39.172), пере-проверено ПОСАДКОЙ, а не рассуждением.** Пин — `pgstore.TestTheTwoSessionDeadlinesAreNotInterchangeable` (`sessions_test.go`): он создаёт сессию с РАЗНЕСЁННЫМИ сроками (`idleTTL` 1 ч против `maxAge` 24 ч — фикстура, где они совпадают, здесь ничего не доказывает, потому что `Touch` зажимает idle к absolute) и утверждает `AbsoluteExpiresAt == now.Add(maxAge)` после `CreateSession`, то есть ровно в единственном месте, где потолок становится фактом в базе. Именно та мутация, которой строка заведена — M12, `now.Add(maxAge)` → `now.Add(100*maxAge)` — теперь КРАСНАЯ адресно (замер 29.08: `AbsoluteExpiresAt = 2026-12-07…, want 2026-08-30…`). Пак строку не искал: тест писался против перестановки двух сроков, и потолок оказался запинен тем же утверждением — поэтому закрытие подтверждено пере-прогоном ИМЕННО M12, а не сходством формулировок | fixed | ревью-пак P8-REVIEW, ось 2 (посадка мутации, пере-посажена рефутером на полном пакете) | | PD-44 | hardening | info | `internal/pgstore/` | `sqlc` не взят, хотя направление §3 предписывает взять его ДО появления денежных таблиц. Весь денежный SQL — сырые строки pgx ⚠ **P8-FIX: половина, которая ЛЕЧИТ класс, построена; сам инструмент — вопрос владельцу.** Рантайм-ошибки «нет такой колонки» (`r.stop_for_signing`, `chapters_before`) случились в СКЛЕЕННОМ SQL read-модели, куда sqlc по построению не доходит, поэтому тем же пунктом заведён постоянный гейт, который доходит: `pgstore.TestEverySQLStatementParsesAgainstTheMigratedSchema` сворачивает КАЖДЫЙ SQL пакета из исходника (литералы, конкатенации, именованные константы) и планирует его Postgres'ом (`explain (generic_plan)`) против мигрированной схемы — **162 оператора, все планируются**. Посадка мутации в СКЛЕЕННЫЙ фрагмент (`c.units_edit_done` → несуществующая колонка) гейтом ловится; несворачиваемый SQL — ОШИБКА гейта, а не пропуск (единственное исключение — `store.go` `Ready`, где имя таблицы принадлежит goose, и оно выписано таблицей в самом гейте). Тем же гейтом закрыт открытый вопрос фикс-листа «есть ли в read-модели запрос, которого не касается ни один тест»: теперь его касаются все, на каждом прогоне батареи. **ГРАНИЦА sqlc:** read-модель для него недостижима по построению — склеек в пакете **25 мест из 147**; конвертируем только блок из пяти файлов без склейки (`credits`·`identity`·`idempotency`·`sessions`·`observe`). ⚠ **РЕШЕНО владельцем 22.08 (D39.154): sqlc берётся ОТДЕЛЬНОЙ СЕССИЕЙ, не внутри пака** — генерённый код в дереве, пин версии инструмента, гейт актуальности, `WithTx` для денежных запросов; мешать это с содержательной работой нельзя. ⚠ **ЗАКРЫТО: инструмент взят и заленджен** — `63fcee5`, ратификация `D39.172`, отдельным паком, как решил владелец 22.08 (`D39.154`). Конвертировано 40 запросов из этого самого блока; носители — `platform/sqlc.yaml`, `internal/pgstore/queries/*.sql`, генерённые `*.sql.go` в том же пакете. Актуальность генерации гейчена дважды: `sqlc diff` пререквизитом `make check` и `pgstore.TestEveryGeneratedQueryMatchesItsSourceFile` в батарее (работает без установленного sqlc). ⚠ **Замер набора на 29.08** (прежний счёт «41 запрос в 5 файлах» снят — он был сделан до пака P11, добавившего `StillLive`): в наборе **42 места вызова / 41 различный текст SQL** (константа `read` в `idempotency.go` исполнялась из двух мест), из них **конвертируемых 40**. Сороковой не `observe.go`: `Observe` спрашивает `river_job` через `to_regclass`, а эту таблицу мигрирует River сам, вне goose-миграций, поэтому sqlc отвергает запрос — и добавить схему River в конфиг значило бы завести ВТОРОЙ носитель чужой схемы. Гейт `sqlgate` после конверсии видит **172** оператора против пола 140 | fixed | ревью «вне карты»; гейт и граница — P8-FIX | diff --git a/platform/docs/platform-PROGRESS.md b/platform/docs/platform-PROGRESS.md index 8a2607d4..8dae3546 100644 --- a/platform/docs/platform-PROGRESS.md +++ b/platform/docs/platform-PROGRESS.md @@ -1,11 +1,19 @@ # Журнал зоны «Платформа» -## ПАК «ФОРМА ЗАКАЗА ПЕРЕВОДА» — ОТЧЁТ (05.09, `textmachine-main-34`) +## ПАК «ФОРМА ЗАКАЗА ПЕРЕВОДА» — ОТЧЁТ (05–06.09, `textmachine-main-34`) > Пак `docs/ORDER_FORM_SESSION_PROMPT.md`. Дерево передано оркестратору `textmachine-main-27` НЕ > закоммиченным; зона не коммитит. Записка-план стояла на этом месте и заменена отчётом. > ⚠ **Батарея сдаётся с ОДНИМ названным красным** — см. «Батарея», он ратифицирован и закрывается > вторым актом оркестратора. +> +> ⛔ **ПАК БЫЛ ЗАЛЕНДЕН 06.09 (`628cc56`, `36ea8b8`, акт `D39.208`), И ПОСЛЕ ЛЕНДИНГА ОТКРЫТ СНОВА.** +> Владелец заказал зоне проконтролировать, что оркестратор действительно закрыл переданные ему +> находки. Контроль нашёл ЧЕТЫРЕ вещи, ТРИ из которых — мои, и одна из них уже ехала ложью на проводе +> в ратифицированном каноне. Разбор — секция «**ПЯТЫЙ ЗАХОД**»; следствия — новый минор **0.12.0**, +> новая форма `Run` (`ordered_units`), закрытый шов материализатора и перевёрнутый пин. +> ⚠ Числа батареи, стоящие в секции «Батарея» ВЫШЕ блока «ФИНАЛ 06.09», — прежние и оставлены +> историей захода; действующие ниже. ### ЧТО ИЗМЕНИЛОСЬ ПО СУЩЕСТВУ, одним абзацем @@ -211,7 +219,34 @@ r.ordered_units is not null then` — считают полосу в ЮНИТА оркестратору: греп по `Test…` этого класса не ловит — имена были полевые и wire-овые. 4. ⛔ **ТРИ ФАЙЛА ЭТОГО ПАКА НЕ ЗАЛЕНДИЛИСЬ** и лежали в дереве как ` M`: `pgstore/readmodel.go` · `readmodel/readmodel.go` · `pgstore/price_test.go` — вся починка п.3 четвёртого захода вместе с её - пином. В `HEAD` их нет; оркестратору сказано. + пином. Сказано оркестратору, он забрал их коммитом `32bbadb` в 02:06. +5. ⛔⛔ **И Я ПОПРОСИЛА ЕГО ЗАБРАТЬ ТО, ЧТО НИКОГДА НЕ БЫЛО ЗЕЛЁНЫМ.** Прогон, шедший в тот момент, + уже держал в логе красноту п.2 — я написала письмо раньше, чем прочла его лог. `32bbadb` уехал в + историю с падающим тестом, и снял это только следующий коммит. **Это ВТОРОЙ раз за пак:** четвёртый + заход поймал ровно тот же отказ (п.4 там — «объявила починку сделанной, имея только код»). Разница + в том, что теперь цена вышла за пределы зоны — чужой рукой в общую историю. ⇒ норма, записанная + себе и следующей смене: **ничего не отдавать на лендинг без прогона ПАКЕТА, которого правка + касалась**; «код написан и пин заведён» — не то же, что «прогнано». + +6. ⛔ **ВТОРАЯ ЛОЖЬ В ТОМ ЖЕ ЗАЛЕНДЁННОМ КАНОНЕ, и на этот раз ЕГО текст:** `Run.ordered_chapters` + обещает `0` для символьного заказа. ИЗМЕРЕНО — приезжает СПАН, ≥1 и завышающий (пять юнитов из + двенадцати читаются как «2»). Подробности и предложенная формулировка — в составе минора выше. + ⚠ Мой первый различитель, отданный оркестратору письмом, опирался на этот ноль и был НЕВЕРЕН. + Поправка послана. **Настоящий различитель ОДИН: `delivered_chapters`.** +7. ⛔ **«UNIT-SHAPED» БЫЛО ОПРЕДЕЛЕНО НЕ ТАК, КАК РАБОТАЕТ, В ПЯТИ МЕСТАХ.** Везде стояло «the order + does not close whole chapters», а `q.UnitShaped = true` ставится БЕЗУСЛОВНО для каждого заказа, + выраженного знаками. `UnitsFor` возвращает первый юнит, чья нарастающая сумма покрывает + запрошенное ⇒ заказ садится ровно на границу главы примерно так же часто, как главы делят юниты: + **при трёх юнитах на главу — каждый третий символьный заказ.** Такой заказ закрывает целые главы и + всё равно получает юнитовую полосу и `delivered_chapters: null`. Четыре носителя мои — исправлены + (`pricing.Quote.UnitShaped`, `runs.go` у `OrderedUnits` и у спана, `unitShapedOrder`, + `httpapi.wireRun.DeliveredChapters`); пятый — канон, отдан оркестратору. **Поведение зона считает + верным и НЕ меняла:** две покупки, которые человек не отличает друг от друга, не должны рисоваться + по-разному, а при определении «по факту попадания» одинаковые на вид заказы получали бы то юнитовую + полосу, то главную. Ложны были ОПРЕДЕЛЕНИЯ. Пин — + `TestACharacterOrdersVolumeIsPublishedInTheUnitItWasSoldIn`, он же ловит и находку 6. ⚠ Пин заведён + под прежним именем (`…ReportsTheChapterSpan…`) и ПЕРЕИМЕНОВАН вместе с переворотом — см. п.0 + заявления `D39.183`. ✅ **ЧТО ПРОВЕРЕНО КАК ЗАКРЫТОЕ ОРКЕСТРАТОРОМ** (по его дереву, а не со слов): канон `info.version: 0.11.0` · `delivered_chapters` нуллабелен и объяснён · `ordered_chapters: minimum 0` с объяснением @@ -355,6 +390,22 @@ NULLABLE денежных колонок (сегодня не задета: ру ### ЗАЯВЛЕНИЕ О ПРАВКАХ ТЕСТОВ (`D39.183`) — что изменилось, что это описывало, куда уехала гарантия +0. ⛔ **ПИН, КОТОРЫЙ Я ЖЕ ЗАВЕЛА ЧАСОМ РАНЬШЕ, ПЕРЕВЁРНУТ — 06.09, и это самая неудобная запись здесь.** + `TestACharacterOrderReportsTheChapterSpanItReachesIntoAndNotZero` фиксировал, что символьный заказ + публикует `ordered_chapters` = СПАН (замер: 2 при пяти купленных юнитах из двенадцати). Я завела + его, чтобы описание не уехало обратно, — то есть **закрепила поведение, которое сама же через час + признала дефектом**. Что изменилось в поведении: `ordered_chapters` для такого прогона стал `null`, + рядом появился `ordered_units`. Что описывал старый тест: величину спана и её ненулевость. Куда + уехала гарантия: в `TestACharacterOrdersVolumeIsPublishedInTheUnitItWasSoldIn` (обе половины пары — + null у одной, 5 у другой) и в `TestAChapterOrderKeepsTheBarItAlwaysHad`, куда добавлена ОБРАТНАЯ + половина: у главного заказа `ordered_chapters` = 2, `ordered_units` = nil. Имя тоже сменено — старое + стало бы ложью в списке тестов. Заказано ратификацией оркестратора после окея коллеги-Fable, не + зеленью: старый пин был ЗЕЛЁН и остался бы зелёным. + ⚠ **Оба новых пина проверены МУТАЦИЯМИ, а не объявлены рабочими:** публикация спана снова красит + символьную половину, безусловный `null` красит главную. Дерево восстановлено и зелено. + ⚠ **Урок для читателя:** пин, заведённый в тот же заход, что и находка, закрепляет ПОНИМАНИЕ этого + захода, а не истину. Мой закрепил величину, не спросив, ту ли величину мы публикуем. + 1. **`internal/pricing/pricing_test.go` переписан ЦЕЛИКОМ.** Он описывал шкалу в главах и ставку (`Scale`, `Ceiling(глав)`, `DefaultPerChapter`, полосу провенанса $0.0252–0.0274) — всё это удалено строкой 280. Гарантии уехали: «максимум = баланс КАК ЕСТЬ, холды не вычитаются дважды» → @@ -412,10 +463,32 @@ NULLABLE денежных колонок (сегодня не задета: ру ### СОСТАВ КОНТРАКТНОГО МИНОРА — НА РАТИФИКАЦИЮ ОРКЕСТРАТОРУ (канон своей рукой НЕ трогала) -`info.version` **0.10.0 → 0.11.0**. Сборка уже объявляет `0.11.0` -(`internal/httpapi/capabilities.go`), поэтому гейт `TestTheAnnouncedContractVersionIsTheOneTheCanonRatified` -КРАСНЫЙ по истинной причине — пара не закрыта. Порядок лендинга ратифицирован: код первым, канон -вторым. +`info.version` **0.10.0 → 0.11.0** ✅ **ЗАКРЫТО ОРКЕСТРАТОРОМ 06.09, сверено** — а затем +**0.11.0 → 0.12.0**, и этот минор прожил несколько часов. + +⛔ **ПОЧЕМУ 0.11.0 ЖИЛ ЧАСЫ — чтобы следующая смена не гадала.** Пятый заход нашёл в нём две лжи и +исправил ВТОРУЮ поведением: `Run.ordered_chapters` стал нуллабельным, рядом приехало `ordered_units` +(разбор — блоком ниже). Это смена ОТДАВАЕМОГО ПРОВОДА, а правило у самой константы велит поднимать +номер тем же изменением, что и код, «never as a courtesy afterwards». + +⚠ **Решение принято НЕ мной и не молча — и след его честен.** Зона поставила вопрос оркестратору, +назвав СВОЙ интерес («внутрь дешевле, константу править не надо»). Оркестратор ответил +ПРЕДВАРИТЕЛЬНО «внутрь 0.11.0», опираясь на то, что сам правил текст этого минора трижды после +лендинга и не бампал, — и **отозвал свой же ответ**, проверив те три: открытый словарь, выросший на +значение (канон прямо говорит, что версию не двигает), ⛔-пометка дефекта (проза) и ЭРРАТА, +исправлявшая ложь канона о членах, которые провод и так нёс. Ни одна не была сменой ФОРМЫ ⇒ бамп за +них не причитался, непоследовательности нет. Плюс его же критерий «минор закрыт с момента акта +приёмки» — акт `D39.208` и есть акт приёмки 0.11.0. Окончательный ответ: **0.12.0**. +⚠ Мой довод против бампа («номер начнёт считать НАШИ заходы») верен для прозы и эррат и потому здесь +не применяется: правило константы говорит про КОД И ПРОВОД, а не про число читателей. + +⇒ **гейт `TestTheAnnouncedContractVersionIsTheOneTheCanonRatified` СНОВА КРАСЕН, и это верно:** порядок +ратифицирован — код первым, канон вторым (эррата 04.09-в: обратный порядок стоил суток лжи на +проводе). Гасит его канон, а не подгонка. Позиции НИЖЕ, помеченные ⛔, в канон ещё не приехали. + +⚠ **И дыра гейта, найденная тем же вопросом и заведённая оркестратором строкой бэклога 309:** он +сверяет ВЕРСИИ, а не ФОРМЫ. Все три сегодняшние лжи канона он пропустил именно поэтому — число +совпадало. Бамп не чинит дыру, он только не даёт номеру солгать. **`RunRequest`** — `ceiling_chapters` УДАЛЁН; `chapters: integer|null` (сколько ОСТАВШИХСЯ глав) · `characters: integer|null` (сколько исходного текста в рунах) · `re_pass` как был. Взаимно @@ -442,10 +515,23 @@ blocked: Blocked | null клиент обязан деградировать на незнакомом слове, а не отказывать. `covers_none` при `chapters_left: 0` это ЗАКОНЧЕННАЯ книга, при положительном — счёт, не покрывающий следующую главу. -**`Run`** — `ceiling_chapters` → `ordered_chapters`; добавлены `delivered_chapters: integer|null` и -`term_consistency_funded: boolean`. ⚠ `null` у первого — не «ноль»: заказ, выраженный ЗНАКАМИ, -останавливается ВНУТРИ главы и не закрывает ни одной. **Залендено оркестратором в 0.11.0, сверено -06.09** (`openapi.yaml:1827-1835`). +**`Run`** — `ceiling_chapters` → `ordered_chapters`; добавлены `delivered_chapters: integer|null`, +`term_consistency_funded: boolean` и (правкой 06.09, ниже) `ordered_units: integer|null`. +**Залендено оркестратором в 0.11.0, сверено 06.09** (`openapi.yaml:1827-1835`). + +⚠ **`null` у `delivered_chapters` — не «ноль», и ОБОСНОВАНИЕ его в каноне НЕВЕРНО.** Канон говорит: +«an order expressed in CHARACTERS stops inside a chapter and **closes none**». Измерено — закрывает: +`UnitsFor` возвращает первый юнит, чья нарастающая сумма покрывает запрошенное, поэтому заказ садится +ровно на границу главы примерно так же часто, как главы делят юниты (при трёх юнитах на главу — +каждый третий), и такой заказ закрывает целые главы, оставаясь юнитовым. Мой собственный пин +`TestACharacterOrdersVolumeIsPublishedInTheUnitItWasSoldIn` покупает всю главу один и ещё юнит главы +два. ⇒ верное обоснование: `null` стоит потому, что заказ **ВЫРАЖЕН В ЗНАКАХ и меряется в них +насквозь**, а не потому, что он «не закрывает глав». Довод за такое поведение: две покупки, которые +человек не отличает друг от друга, не должны рисоваться по-разному, а при определении «по факту +попадания» одинаковые на вид заказы получали бы то юнитовую полосу, то главную. ⚠ Это ЛОЖНОЕ +ОПРЕДЕЛЕНИЕ жило в ВОСЬМИ носителях; семь моих исправлены (`pricing.Quote.UnitShaped`, два места +`runs.go`, `unitShapedOrder`, `pgstore.StartRunInput.OrderedUnits`, `httpapi.wireRun.DeliveredChapters`, +этот абзац состава), восьмой — канон, в двух его местах, отдан оркестратору. ⛔ **`Progress` — ТРЕТИЙ СЛУЧАЙ, КОТОРОГО В СОСТАВЕ НЕ БЫЛО И В КАНОНЕ 0.11.0 НЕТ. Внесено пятым заходом 06.09; до него состав нёс мою ложную фразу «полоса осталась в главах».** Канон говорит «in @@ -454,9 +540,44 @@ chapters» в шапке схемы и в обоих счётчиках (`openap `when r.ordered_units is not null`; пин `runs.TestACharacterOrdersBarIsCountedInWhatItActuallyBought`). ⇒ клиент, читающий канон, нарисует «3 из 10 глав» там, где это 3 из 10 юнитов. Требуется абзац той же формы, что уже стоит у РЕ-ПРОХОДА («declared shape, not chapters — do not render it as “0 of 1 -chapters”»), плюс **имя различителя**: `delivered_chapters: null` ⇒ полоса юнитовая; -`delivered_chapters: 0` при `ordered_chapters: 0` ⇒ ре-проход; `ordered_chapters > 0` ⇒ главы. Сегодня -этот различитель существует на проводе, но нигде не назван, и клиент обязан его УГАДАТЬ. +chapters”»), плюс **имя различителя**. Различитель на проводе ОДИН: **`delivered_chapters: null` ⇒ +полоса юнитовая**; `delivered_chapters: 0` при `ordered_chapters: 0` ⇒ ре-проход; `delivered_chapters` +числом ⇒ главы. Сегодня он существует, но нигде не назван, и клиент обязан его УГАДАТЬ. + +⛔ **ВТОРАЯ ЛОЖЬ В ТОЙ ЖЕ ЗАЛЕНДЁННОЙ СХЕМЕ — НАЙДЕНА, ИЗМЕРЕНА И ПОЧИНЕНА ПОВЕДЕНИЕМ.** +`Run.ordered_chapters` описан как «a re-pass buys no chapters, and an order phrased in CHARACTERS spans +no whole chapter — **both report `0` here**». Для символьного заказа это было неверно: `resolveOrder` +звал `QuoteUnits(…, chaptersSpanning(pb, n))`, а `QuoteUnits` ставит `Chapters: max(chapters, 1)` ⇒ +поле было ВСЕГДА ≥ 1 и означало не «сколько глав заказано», а «скольких глав заказ КАСАЕТСЯ». + +**ЗАВЫШЕНИЕ ЗАМЕРЕНО, И ОНО ХУДШЕЕ НА САМОМ ДЕШЁВОМ ЗАКАЗЕ:** один юнит из четырёх в первой главе +публиковался как «1 глава» — сто процентов завышения ровно на том заказе, которым сервис пробуют +впервые. Зона сперва защищала спан доводом «это единственная величина, по которой видно, докуда дошли +деньги», **и сама этот довод сняла**: `runs.ordered_units` лежит колонкой с миграции 00033 и уже +проецируется латералью `lastRun` — наружу не выходил только по умолчанию. Посылка была ложной. + +⇒ **ФОРМА ИЗМЕНЕНА (ратифицировано оркестратором 06.09 после окея коллеги-Fable):** +``` +ordered_chapters: integer|null // null, когда прогон продан НЕ в главах +ordered_units: integer|null // НОВОЕ: сколько юнитов купил символьный заказ +``` +Ровно ОДНО из двух заполнено, и какое именно — говорит, в какой единице меряется весь прогон: полоса, +доставка, остаток. Различитель перестаёт быть угадыванием. + +⚠ **НУЛЛИТСЯ ПРЕДСТАВЛЕНИЕ, А НЕ КОЛОНКА** (`pgstore.runOrderedChapters`). `ceiling_chapters = 0` — +собственная метка РЕ-ПРОХОДА, и её читают ТРИ внутренних места: `runs.maxUnitsFor` (`LiveRun`), +резюм-гейт реконсилятора (`LiveRun`) и допуск самого стора (`StartRunInput`). Запись нуля или null в +СТРОКУ сделала бы символьный заказ неотличимым от ре-прохода для всех трёх, и у первого цена — деньги: +ноль есть движковое слово «без объёмного предела вовсе». ⚠ Ревью предлагало пере-ключить эти три на +`OrderedUnits != nil`; зона ОТКАЗАЛАСЬ и отказ приняли: они спрашивают «ре-проход ли это», а не +«символьный ли заказ», и переключение инвертировало бы вопрос — у ре-прохода `OrderedUnits` nil, и +ветвь не сработала бы НИКОГДА. Пояс `&& OrderedUnits == nil` тоже НЕ поставлен, по доводу +оркестратора: гейт, защищающий от несуществующего состояния, завтра прочтут как свидетельство, что +состояние бывает. + +⚠ **Первый различитель, который зона отдала оркестратору письмом, опирался на обещанный каноном нуль и +потому был НЕВЕРЕН.** Зона измерила его сама, опровергла и послала поправку до того, как он доехал до +канона. ⚠ И само слово «юнит» на этой поверхности не определено: наружу оно не выходило никогда, а `source_chars` меряет тот же заказ ЗНАКАМИ. Либо канон вводит единицу, либо счётчики этого случая переводятся в знаки — это решение оркестратора, не зоны; зона называет цену обоих: во втором случае двигается `unitDone`/ @@ -505,7 +626,66 @@ chapters”»), плюс **имя различителя**: `delivered_chapters: **Строка 301** (`a411c10`) — шовная дыра словаря `--max-units`, заведена оркестратором по моему пингу. -### БАТАРЕЯ — ЧИСЛАМИ И С НАЗВАННЫМИ УСЛОВИЯМИ +### БАТАРЕЯ — ФИНАЛ 06.09, СНЯТО ПОСЛЕ ПОСЛЕДНЕЙ ПРАВКИ + +⚠ **Норма, которой этот блок обязан своим существованием, и она куплена ошибкой:** «код написан и пин +заведён» — НЕ то же, что «прогнано». За этот пак я дважды объявила починку сделанной, имея код и пин, +но не имея прогона; второй раз это стоило оркестратору красноты в `HEAD` (`32bbadb`). С тех пор числа +снимаются **после ПОСЛЕДНЕЙ правки, без исключений для комментарных** — четыре прогона (v10, v12 и +два ранних) были ОСТАНОВЛЕНЫ и не цитируются, потому что дерево под ними менялось. Наполовину снятые +числа опаснее отсутствующих: они выглядят как результат. + +``` +условия: TM_PLATFORM_TEST_DSN (PostgreSQL 18.4, /tmp:55433; у роли ЕСТЬ CREATEDB) · + TM_PLATFORM_TEST_ENGINE_BIN (tmctl, собран из коммита 0801abc) · + TM_PLATFORM_TEST_BOOK_TEMPLATE (шаблон на СНАПШОТЕ движковых конфигов того же коммита, вне + репозитория — рабочее дерево бэкенда правит параллельная сессия) · + TM_PLATFORM_TEST_PGDUMP/PGRESTORE · достижимый пользовательский systemd + load average 5.3 перед стартом — режим, в котором время не врёт (D39.197 п.5) + +make check → MAKE-EXIT=2 + golangci-lint: 0 issues · gofmt чист · go vet чист · sqlc diff чист + ПАКЕТОВ 21: ok 20, FAIL 1 + ТЕСТОВ верхнеуровневых 850: PASS 844 · FAIL 1 · SKIP 5 (плюс 252 подтеста) + ⚠ счёт снят из `.check.log` (`make check` гонит `-v`); `go test` БЕЗ `-v` строк + `--- SKIP` не печатает вовсе, и греп по нему даёт ЛОЖНЫЙ НОЛЬ — этой ошибкой + четвёртый заход уже был пойман +ALARM PD-count: 12 (baseline 12) — ПЕРЕ-СНЯТА базой: PD-375 и PD-422 удалены из `alarmBaseline` + по прямой инструкции самого гейта («ОБА ОБЯЗАНЫ ПОКИНУТЬ КЛАСС НА ЛЕНДИНГЕ») и + прецеденту PD-168; было 14 (baseline 14) с двумя объявленными исключениями + +КРАСНЫЙ ОДИН, И ОН РАТИФИЦИРОВАН: + internal/gates.TestTheAnnouncedContractVersionIsTheOneTheCanonRatified + «this build announces contract 0.12.0 and the ratified canon is 0.11.0» + Истинная причина: пара «код + канон» не закрыта по НОВОМУ минору. Порядок ратифицирован — + код первым, канон вторым (эррата 04.09-в: обратный порядок стоил суток лжи на проводе). + Гасит его канон, а не подгонка константы. + +СКИПОВ ПЯТЬ, ВСЕ НАЗВАНЫ, ВСЕ ОДНОЙ ПРИЧИНЫ — артефакта контраста (`mining-contrast.zh.txt`) нет на +этом хосте ни под одним путём: он многомегабайтный, поставляется деплоем и намеренно не в git. +Все пять — ПИШУЩИЕ живые пробы, и условие честно их: + TestTheRealEngineNamesItsRestorePointInTheLineThisPlatformParses + TestALivePreviewWritesNothingAndALiveApplyWrites + TestALiveBuildOfAHollowBookWritesTheMarkedCopyInsteadOfRefusing + TestWithoutPartialTheSameBookIsRefusedWithTheBuildsOwnNumber + TestTheSnapshotGuardIsLoudWithoutTheFlagsAndPassesWithThem + +док-гейты → counts.py --lint: в зоне `platform/docs/**` красных якорей **0**. ⚠ Двенадцать якорей + зоны уехали от правок пятого захода и ПЕРЕ-НАЦЕЛЕНЫ — каждый с проверкой, что + ожидаемый токен в новой строке ЕДИНСТВЕННЫЙ. Остались 2 красных в `docs/PROGRESS.md`, + целящие в канон (`openapi.yaml:2070`, `:2246`) — обе цели уехали от правок канона + ОРКЕСТРАТОРОМ, зона чужого журнала не трогает; сказано ему. + → counts.py --check: **РАСХОЖДЕНИЯ, 2, и они ЖДУТ ОРКЕСТРАТОРА.** `docs/PROGRESS.md` + несёт «открытых рядов регистра платформы — 107 (major 5)»; пере-счёт даёт **102** и + **major 3**. Причина названа: пять рядов помечены `fixed(628cc56)` — каждый своим же + текстом говорил «статус флипает ЛЕНДИНГ», лендинг состоялся, и держать их открытыми + значило бы врать следующей смене о том, что осталось сделать. Регистр — моя зона, + журнал — его. +миграция → 00033: up → down → up на отдельной базе, чисто, в одной транзакции (пере-снято после + двух последних правок схемы — четвёртый заход поймал, что этого не делали) +``` + +### БАТАРЕЯ — ЧИСЛАМИ И С НАЗВАННЫМИ УСЛОВИЯМИ (ПРЕЖНИЙ ЗАХОД, 05.09 — ИСТОРИЯ) ⚠⚠ **ПОПРАВКА К САМОЙ СЕБЕ, И ОНА ТОГО ЖЕ КЛАССА, ЧТО ЛОВИТ КАНОН: моё «скипов 0» было снято командой, которая скипы НЕ ПЕЧАТАЕТ.** Я мерила `go test ./... -count=1` без `-v` и грепала вывод на @@ -518,6 +698,10 @@ chapters”»), плюс **имя различителя**: `delivered_chapters: теста, и вердикт обоих прогонов НЕОТЛИЧИМ по коду возврата. ``` +⛔⛔ ЧИСЛА НИЖЕ — ПРЕЖНИЕ (v4, 05.09) И СОХРАНЕНЫ КАК ИСТОРИЯ ЗАХОДА. ДЕЙСТВУЮЩИЕ — В БЛОКЕ «ФИНАЛ +06.09» СРАЗУ ЗА ЭТИМ. Прежние сняты до лендинга канона и до пятого захода: тогда гейт версии был +красен по истинной причине, скипов было пять при других именах, а ALARM стоял на 14. + СНЯТО ПОСЛЕДНИМ ДЕЙСТВИЕМ, на ТИХОЙ машине (load average 1.07 перед стартом; два предыдущих прогона под load 12–19 упирались в 600-с таймаут пакетов pgstore/runs — правило D39.197 п.5, красный по времени под нагрузкой не результат). @@ -659,6 +843,16 @@ status` на входе сессии — clean). В него ВХОДИТ `81a89 «ДА» — исправлено. `Resume` для `paused` по-прежнему отвечает `ErrCeilingReached`, а его комментарий обещает ремедиа «новый прогон с бо́льшим потолком»: форму, которую провод после этого минора выразить не умеет — потолка в главах больше нет. +- ⛔ **ВОПРОС ВЛАДЕЛЬЦУ О ПРОДУКТЕ, НЕ ЗАКРЫТЫЙ И НАМЕРЕННО НЕ ЗАКРЫТЫЙ МОЛЧА: число ЗНАКОВ, которое + человек ввёл, не хранится нигде.** `resolveOrder` разрешает `*in.Characters` в `n` юнитов через + `pricing.UnitsFor` и само число выбрасывает: колонки нет, поля нет. После перезагрузки экран + ответит «5 фрагментов» там, где вопрос был «4500 знаков». ⚠ **Это НЕ то же, что чинит третий + вариант, и важно не спутать:** `ordered_units` — единица, в которой мы ОТВЕЧАЕМ; знаки — единица, в + которой человек ЗАКАЗЫВАЛ. Публикация `ordered_units` делает несовпадение ВИДИМЫМ, а не создаёт его + — то есть это довод ЗА эхо, а не против. Нужно ли эхо вообще — продуктовое решение владельца, не + зоны. Цена, если да: одна колонка `runs.ordered_characters` и поле на проводе; ничьей арифметики это + не трогает. Найдено ревью коллеги-Fable 06.09, проверено зоной по коду. **В состав минора НЕ + внесено** — по слову оркестратора. - **Стоимость чтения формы заказа выросла и НЕ ЗАМЕРЕНА.** Отчёт празднует снятие квадратичности в `Affordable` на этом же пути — честно тогда сказать и про добавленное: остаток книги теперь агрегируется ПО ЮНИТАМ с коррелированным `not exists` на каждый, вместо одной строки на главу. На @@ -670,8 +864,13 @@ status` на входе сессии — clean). В него ВХОДИТ `81a89 `books.source_chars` — nullable `bigint`, для которого подстановки нет, тогда как конфиг обещает указатель для nullable-целых; ⑶ новые фикстуры пишут колонки `pgstore` рукописным SQL ИЗ пакета `internal/runs`, куда не достаёт ни `sqlgate` (читает свой каталог), ни `sqlcgate` — то есть эти - вставки не судит никто; ⑷ материализатор манифеста (`readmodel.refresh`) — единственный шов между - двумя протестированными половинами — по-прежнему без собственного теста; ⑸ форма над книгой с ЖИВЫМ + вставки не судит никто; ⑷ ✅ **ЗАКРЫТО 06.09 — и закрывать пришлось потому, что шов СЛОМАЛСЯ, ровно + как эта строка предупреждала.** Материализатор манифеста (`readmodel.refreshStructure`) был + единственным швом между двумя протестированными половинами без собственного теста; баг второго + носителя `source_chars` завёлся именно в нём и вышел наружу только батареей. Пиньнут двумя тестами + без базы (`internal/readmodel`, `fakeStore`): все три денежных члена доезжают в `pgstore.Projection`, + счёт знаков едет НА РАЗРЕЗЕ и переживает цену, которую сборка не смогла прочесть. Пины проверены + мутациями (`SourceChars: 0` красит оба, потеря `BookOnce` — первый); ⑸ форма над книгой с ЖИВЫМ прогоном отвечает `covers_all` и молчит о том, что клик получит `run_in_flight`; ⑹ глава ЗАДРАФЧЕННАЯ, но не отредактированная, оценивается полностью (счётчики стадию не различают), ошибка ВВЕРХ; ⑺ откат миграции стирает различение двух причин паузы, ради которого пак их и @@ -1133,11 +1332,21 @@ owes that and when». Это стало неверным: `tmplatformctl token i собственный отчёт о сломанных якорях краснел бы как сломанный якорь. Читать как «файл, строка, искомый токен → новая строка». -✅ **Счёт открытых рядов регистра — УЖЕ ПОПРАВЛЕН ТОБОЙ, проверено:** `docs/PROGRESS.md` несёт -«открытых рядов регистра платформы — 107 (major 5), всего рядов 453», пере-счёт -`python3 docs/scripts/counts.py --check` даёт ровно это и печатает «Литералы сходятся с пере-счётом -(7 проверок)». Я завёл ПЯТЬ рядов (`PD-449`…`PD-453`); если между этим отчётом и лендингом появятся -ещё чьи-то, число надо пере-снять той же командой. +⛔ **СЧЁТ ОТКРЫТЫХ РЯДОВ РЕГИСТРА РАЗЪЕХАЛСЯ ПОСЛЕ ЛЕНДИНГА, И ПОПРАВИТЬ ЕГО МОЖЕШЬ ТОЛЬКО ТЫ.** +`docs/PROGRESS.md` несёт «открытых рядов регистра платформы — 107 (major 5), всего рядов 453». +Это было верно до 06.09. Пять рядов, чьё лечение лендинг внёс в дерево, помечены `fixed(628cc56)` +ЭТОЙ правкой — `PD-375`, `PD-410`, `PD-422`, `PD-440`, `PD-446`; каждый из них своим же текстом +говорил «статус флипает ЛЕНДИНГ», лендинг состоялся, и держать их открытыми значило бы врать +следующей смене о том, что осталось сделать. ⇒ `python3 docs/scripts/counts.py --check` теперь +печатает **РАСХОЖДЕНИЯ** по двум литералам `docs/PROGRESS.md`: пере-счёт даёт **102 открытых** +и **major 3** (было 107 и 5; из ушедших major-ами были `PD-410` и `PD-440`), всего рядов 453. +Своей рукой чужой журнал не правлю. ⚠ Числа надо пере-снять ТОЙ ЖЕ командой в момент правки: +между этим отчётом и ею могут появиться чужие ряды. + +⚠ **Той же правкой `PD-375` и `PD-422` УДАЛЕНЫ из `alarmBaseline`** (`internal/gates/register_test.go`) +— по прямой инструкции самого гейта («ОБА ОБЯЗАНЫ ПОКИНУТЬ КЛАСС НА ЛЕНДИНГЕ, и тогда их ids отсюда +УДАЛЯЮТСЯ той же правкой») и по прецеденту `PD-168`, записанному в его шапке. Исключение, пережившее +свою причину, — это то, как база перестаёт читаться. Гейт зелёный: `go test ./internal/gates/` ok. **Три якоря в ТВОИХ файлах уехали от моих правок. Цели проверены, новые строки:** 1. `docs/PROGRESS.md`, строка 10 — цель `platform/internal/config/config.go`, строка **293**, токен @@ -3641,7 +3850,7 @@ $0.005460). Накладные масштабируются КНИГОЙ, а н прогон и CLI печатал «холд возвращён целиком». Теперь цикл по всем, `settled_at` — только когда не осталось ни одного; гонку со свипом (`ErrNoReservation`) терпит, как терпит живая ветка. ⚠ «Чужой холд» при этом невозможен ПО ПОСТРОЕНИЮ: всё ключуется `runID#attemptNo` - (`platform/internal/pgstore/runs.go:237`=`fmt.Sprintf("%s#%d", runID, attempt)`) и идёт через + (`platform/internal/pgstore/runs.go:243`=`fmt.Sprintf("%s#%d", runID, attempt)`) и идёт через `closeReservation`+`releaseHold` (символы в `platform/internal/pgstore/credits.go`), которые сверяют владельца. 5. **Дефект, который пак внёс в ЧУЖОЙ гейт и который поймала собственная мутационная обвязка:** @@ -3886,7 +4095,7 @@ caught:` того теста, который она обязана валить вне идемпотентности ключа запроса); повтор после успеха отвечает `run_in_flight` либо `ErrRePassUnavailable` — факт погашен финишем (символ `ErrRePassUnavailable`: `platform/internal/runs/runs.go:149`=`var ErrRePassUnavailable = errors.New(`; ветка провода — - `platform/internal/httpapi/v0.go:1135`=`case errors.Is(err, runs.ErrRePassUnavailable):`). + `platform/internal/httpapi/v0.go:1149`=`case errors.Is(err, runs.ErrRePassUnavailable):`). Вырожденных дублей не нашёл, но специального пина нет. - ⚠ **Для оркестратора — находка опровергателя P10, носителя ни в регистре, ни в бэклоге у неё нет:** якоря §2.12 компаньона контракта (`docs/architecture/14-api-contract/README.md`, греп diff --git a/platform/internal/httpapi/capabilities.go b/platform/internal/httpapi/capabilities.go index db60e811..96ed84f0 100644 --- a/platform/internal/httpapi/capabilities.go +++ b/platform/internal/httpapi/capabilities.go @@ -10,13 +10,30 @@ import "net/http" // client generated against another one refuses to work and says so — which is why this must be // raised in the same commit as the code that implements a new minor, and never as a courtesy // afterwards. -// ⚠ 0.11.0 is the ORDER FORM's minor (D39.196, unified backlog row 279), and while the canon on disk -// still reads 0.10.0 the gate internal/gates.TestTheAnnouncedContractVersionIsTheOneTheCanonRatified -// is RED — by its true cause, which is that the pair is not closed yet. The order was ratified by the -// orchestrator on 05.09: the CODE lands first and the canon follows in the second act. The reverse -// order was refused deliberately — errata 04.09-в is the day the canon moved first and the wire spent -// a working day announcing a version it did not serve. -const ContractVersion = "0.11.0" +// ⚠ 0.11.0 was the ORDER FORM's minor (D39.196, unified backlog row 279) and it lived for HOURS. +// 0.12.0 is the same form's correction: `Run.ordered_chapters` became nullable and `ordered_units` +// arrived beside it, because the chapter figure a character order used to publish was the SPAN it +// reached into and overstated what was bought by up to a whole chapter — worst on the cheapest order +// there is. That changes the wire this build serves, so the number moves with it, in this same +// change and not as a courtesy afterwards. +// +// ⚠ Why a bump and not an amendment inside 0.11.0, decided rather than assumed: 0.11.0's canon text +// was edited three times after its landing, and none of those was a change of FORM — an open +// vocabulary growing by one value, a defect note, and an ERRATUM correcting what the document said +// about members the wire already served. Prose and errata do not move a number; a served shape does. +// And 0.11.0 already has its acceptance act (D39.208), which closes it. See the pack's report. +// +// ⚠ THE GATE IS RED WHILE THE CANON READS 0.11.0, and that is the ratified order and its true cause: +// the CODE lands first and the canon follows in the second act. The reverse was refused deliberately +// — errata 04.09-в is the day the canon moved first and the wire spent a working day announcing a +// version it did not serve. +// +// ⛔ AND WHAT THE GATE CANNOT SEE, said here because this constant is where a reader comes looking: it +// compares VERSIONS, not SHAPES. Three separate untruths in the 0.11.0 text passed it in one day — +// the bar described in chapters while the wire sent units, `ordered_chapters: 0` promised where the +// wire sent 2, three values of a vocabulary that had four — because the NUMBER matched each time. The +// bump keeps the number from lying; it does not close the blind spot, which is unified backlog row 309. +const ContractVersion = "0.12.0" // Capabilities is what this deployment can do: one flat document, the same for every account. type Capabilities struct { diff --git a/platform/internal/httpapi/control_test.go b/platform/internal/httpapi/control_test.go index 669c89f1..6d93711f 100644 --- a/platform/internal/httpapi/control_test.go +++ b/platform/internal/httpapi/control_test.go @@ -28,7 +28,7 @@ func TestStopAndResumeAnswerWithTheRunTheContractDescribes(t *testing.T) { {"resume", func(f *fakeRuns) string { return f.resumed }}, } { rn := &fakeRuns{run: pgstore.Run{ - ID: "run_9", Revision: 12, Status: "stopped", VerifyBank: true, OrderedChapters: 100, + ID: "run_9", Revision: 12, Status: "stopped", VerifyBank: true, OrderedChapters: ptr(100), StartedAt: time.Unix(0, 0).UTC(), }} w := call(t, v0Server(t, &fakeLibrary{}, rn), "POST", "/v0/runs/run_9/"+tc.path, "") diff --git a/platform/internal/httpapi/idempotency_test.go b/platform/internal/httpapi/idempotency_test.go index e3e1e8b3..77e5d507 100644 --- a/platform/internal/httpapi/idempotency_test.go +++ b/platform/internal/httpapi/idempotency_test.go @@ -178,7 +178,7 @@ func TestTheClaimCarriesTheRequestsOwnOperation(t *testing.T) { // Mutation caught: claiming the route pattern instead of the request's path. func TestTheClaimOfARunCarriesTheBooksOwnPath(t *testing.T) { keys := newFakeKeys() - rn := &fakeRuns{run: pgstore.Run{ID: "run_1", BookID: "bk_1", Status: "translating", OrderedChapters: 10}} + rn := &fakeRuns{run: pgstore.Run{ID: "run_1", BookID: "bk_1", Status: "translating", OrderedChapters: ptr(10)}} h := v0ServerWith(t, Deps{Library: &fakeLibrary{}, Runs: rn, Keys: keys}) r := httptest.NewRequest("POST", "/v0/books/bk_1/runs", strings.NewReader(`{"stop_for_signing":false,"chapters":10}`)) diff --git a/platform/internal/httpapi/project.go b/platform/internal/httpapi/project.go index e3520b05..702bdf87 100644 --- a/platform/internal/httpapi/project.go +++ b/platform/internal/httpapi/project.go @@ -57,6 +57,7 @@ func projectRun(r pgstore.Run) wireRun { // can look at the terms", and the flag is how that reaches the engine. StopForSigning: r.VerifyBank, OrderedChapters: r.OrderedChapters, + OrderedUnits: r.OrderedUnits, DeliveredChapters: r.DeliveredChapters, TermConsistencyFunded: r.BondFunded, // The user's own click, which no `status` answers: a stop asked for mid-translation can meet diff --git a/platform/internal/httpapi/v0.go b/platform/internal/httpapi/v0.go index 83220c06..ff0d4953 100644 --- a/platform/internal/httpapi/v0.go +++ b/platform/internal/httpapi/v0.go @@ -231,13 +231,27 @@ type wireRun struct { Status string `json:"status"` StopForSigning bool `json:"stop_for_signing"` StopRequested bool `json:"stop_requested"` - // OrderedChapters and DeliveredChapters are what the run bought and what it has handed over. - // `ordered_chapters` is what `ceiling_chapters` was called until 0.11.0, renamed because the word - // «ceiling» belonged to money and this number never was money — it is how much BOOK was sold. - OrderedChapters int `json:"ordered_chapters"` - // DeliveredChapters is null for a run whose order does not close whole chapters — a CHARACTER - // order, which buys a prefix of one. Such a run has delivered no chapter, and `0` would read as - // «nothing happened»; its progress is the `progress` pair, counted in the unit it was sold in. + // OrderedChapters, OrderedUnits and DeliveredChapters are what the run bought and what it has + // handed over. `ordered_chapters` is what `ceiling_chapters` was called until 0.11.0, renamed + // because the word «ceiling» belonged to money and this number never was money — it is how much + // BOOK was sold. + // + // ⛔ EXACTLY ONE OF THE FIRST TWO IS SET, and which one says what unit this run is measured in — + // its bar, its delivery, all of it. A run sold in chapters carries `ordered_chapters`; one sold in + // CHARACTERS carries `ordered_units` and a null here, because the chapter figure such a run used + // to publish was the SPAN it reached into and overstated what was bought by up to a whole chapter. + OrderedChapters *int `json:"ordered_chapters"` + // OrderedUnits is how much a run sold in CHARACTERS bought, in the engine's own output units, and + // null for a run sold in chapters. ⚠ It is not the number of characters the buyer typed: that + // figure is resolved into units when the order is placed and is not stored. + OrderedUnits *int `json:"ordered_units"` + // DeliveredChapters is null for a run whose order was phrased in CHARACTERS. Such a run is + // measured in units end to end, and `0` here would read as «nothing happened» while work is being + // done and paid for; its progress is the `progress` pair, counted in the unit it was sold in. + // + // ⚠ NULL IS THE DISCRIMINATOR THE WIRE ACTUALLY HAS, and it is the only one: `ordered_chapters` is + // a positive span for a character order too, so it separates nothing. Null here means «read the + // bar as units»; `0` with `ordered_chapters: 0` means a re-pass. DeliveredChapters *int `json:"delivered_chapters"` // TermConsistencyFunded is the order form's promise, kept after the click: whether THIS run's // reservation has room for the book-wide pass that keeps a book's terms consistent. The form diff --git a/platform/internal/httpapi/v0_test.go b/platform/internal/httpapi/v0_test.go index fc393b9d..0ac62287 100644 --- a/platform/internal/httpapi/v0_test.go +++ b/platform/internal/httpapi/v0_test.go @@ -285,7 +285,7 @@ func TestTheBookCardCarriesItsRunOrAnExplicitNull(t *testing.T) { finished := time.Unix(0, 0).UTC() lib.book.Revision = 1900 lib.run = &pgstore.Run{ID: "run_1", BookID: "bk_1", Revision: 12, Status: "paused", VerifyBank: true, - OrderedChapters: 100, Progress: pgstore.Progress{Done: 40, Total: 100}, + OrderedChapters: ptr(100), Progress: pgstore.Progress{Done: 40, Total: 100}, PausedReason: "credit_exhausted", StartedAt: finished, FinishedAt: &finished} got = decode(t, call(t, v0Server(t, lib, &fakeRuns{}), "GET", "/v0/books/bk_1", "")) run, _ := got["run"].(map[string]any) @@ -446,7 +446,7 @@ func TestAnExhaustedAccountAndAFinishedBookAreToldApartByTheCount(t *testing.T) // a missing field: what `ceiling_chapters` used to guard is now guarded by the hold, which the // server computes from the order it resolved itself. func TestARunTakesTheThreeKindsOfOrderAndDefaultsToTheWholeBook(t *testing.T) { - rn := &fakeRuns{run: pgstore.Run{ID: "run_1", Status: "translating", OrderedChapters: 100, + rn := &fakeRuns{run: pgstore.Run{ID: "run_1", Status: "translating", OrderedChapters: ptr(100), VerifyBank: true, StartedAt: time.Unix(0, 0).UTC()}} h := v0Server(t, &fakeLibrary{}, rn) @@ -690,7 +690,7 @@ func TestAVolumeBelowTheSchemaMinimumIsARejectedRequestAndNotAMovedBound(t *test // a server that refused the whole request would break a client generated against a later 0.x for a // field it was free to ignore. func TestAnUnknownRequestPropertyIsIgnoredRatherThanRefused(t *testing.T) { - rn := &fakeRuns{run: pgstore.Run{ID: "run_1", Status: "translating", OrderedChapters: 10}} + rn := &fakeRuns{run: pgstore.Run{ID: "run_1", Status: "translating", OrderedChapters: ptr(10)}} w := call(t, v0Server(t, &fakeLibrary{}, rn), "POST", "/v0/books/bk_1/runs", `{"stop_for_signing":true,"chapters":10,"a_field_from_a_later_minor":"x"}`) if w.Code != http.StatusAccepted { diff --git a/platform/internal/pgstore/books.go b/platform/internal/pgstore/books.go index 6a05a34f..ce2c4bcd 100644 --- a/platform/internal/pgstore/books.go +++ b/platform/internal/pgstore/books.go @@ -813,7 +813,20 @@ type Run struct { // ⚠ The column behind OrderedChapters is still called `ceiling_chapters`, and the word is the one // thing about it that was wrong: it never was a ceiling in the money sense — the money ceiling is // the hold — it was always how much book the run was sold. - OrderedChapters int + // + // ⛔ NIL FOR A RUN SOLD IN CHARACTERS — see runOrderedChapters for why the row still carries a + // number there and only the view is null. OrderedUnits is the figure such a run was sold in. + OrderedChapters *int + // OrderedUnits is how much this run bought in the engine's own output units, and nil for a run + // bought in chapters. It is the other half of OrderedChapters: exactly one of the two is set, and + // which one says what unit this run — its bar, its allowance, its delivery — is measured in. + // + // ⚠ It is NOT the number the buyer typed. A character order is resolved into units at admission + // (`runs.resolveOrder` through `pricing.UnitsFor`) and the characters themselves are not stored + // anywhere; a reloaded screen therefore answers in units even though the question was asked in + // characters. Whether that echo is owed to the buyer is a product question, named in the pack's + // report rather than decided here. + OrderedUnits *int // DeliveredChapters is NIL for a run whose order does not close whole chapters: such a run has // delivered no CHAPTER, and reporting `0` would read as «nothing happened» rather than as «this // is not the unit this run is measured in». Progress carries what it HAS done instead, and for @@ -842,13 +855,13 @@ type Run struct { // run and `b` its book. Written once because the three that used it wrote the same fourteen columns // three times, in three orders that had to stay in step by hand. const runRow = `r.id, b.id, b.revision, r.status, r.verify_bank, r.stop_requested_at is not null, - r.ceiling_chapters, ` + runDelivered + `, r.bond_funded, ` + runDone + `, ` + runTotal + `, ` + runStage + `, r.eta_seconds, + ` + runOrderedChapters + `, r.ordered_units, ` + runDelivered + `, r.bond_funded, ` + runDone + `, ` + runTotal + `, ` + runStage + `, r.eta_seconds, coalesce(r.paused_reason, ''), coalesce(r.failure_reason, ''), r.started_at, r.finished_at` // scanRun reads runRow into a Run, plus whatever the caller selected after it. func scanRun(row pgx.Row, out *Run, extra ...any) error { return row.Scan(append([]any{&out.ID, &out.BookID, &out.Revision, &out.Status, &out.VerifyBank, - &out.StopRequested, &out.OrderedChapters, &out.DeliveredChapters, &out.BondFunded, + &out.StopRequested, &out.OrderedChapters, &out.OrderedUnits, &out.DeliveredChapters, &out.BondFunded, &out.Progress.Done, &out.Progress.Total, &out.Progress.Stage, &out.Progress.ETASeconds, &out.PausedReason, &out.FailureReason, &out.StartedAt, &out.FinishedAt}, extra...)...) diff --git a/platform/internal/pgstore/books_test.go b/platform/internal/pgstore/books_test.go index 59819227..44c67a41 100644 --- a/platform/internal/pgstore/books_test.go +++ b/platform/internal/pgstore/books_test.go @@ -119,7 +119,10 @@ func TestTheBookCardCarriesARevisionEitherWay(t *testing.T) { if err != nil { t.Fatal(err) } - if run == nil || run.ID != started.ID || run.OrderedChapters != 10 || run.VerifyBank { + // ⚠ A CHAPTER-SHAPED run, so the figure is present; nil here would mean «this run is not measured + // in chapters» (runOrderedChapters), which is a different answer from a wrong number. + if run == nil || run.ID != started.ID || run.OrderedChapters == nil || *run.OrderedChapters != 10 || + run.VerifyBank { t.Fatalf("card run: %+v", run) } if run.PausedReason != "" || run.FinishedAt != nil { diff --git a/platform/internal/pgstore/readmodel.go b/platform/internal/pgstore/readmodel.go index cc6ca24b..01ae2c28 100644 --- a/platform/internal/pgstore/readmodel.go +++ b/platform/internal/pgstore/readmodel.go @@ -688,6 +688,25 @@ const ( then 'editing' else 'drafting' end)` ) +// runOrderedChapters is what the contract calls `ordered_chapters`: how much BOOK this run was sold, +// in chapters — and NULL for a run that was not sold in chapters at all. +// +// ⛔ THE NULL IS THE POINT, and the figure it replaces was a lie of a particular kind: not a wrong +// number, but a right number answering a question nobody asked. The column underneath +// (`ceiling_chapters`) holds, for an order phrased in CHARACTERS, the chapter SPAN — how many +// chapters the order reaches INTO — and that overstates what was bought by as much as a whole +// chapter: an order of one unit out of four in the first chapter stored `1`, which is the cheapest +// order there is and the one a person tries the service with. Measured, not reasoned: see +// `runs.TestACharacterOrdersVolumeIsPublishedInTheUnitItWasSoldIn`. +// +// ⚠ IT NULLS THE VIEW AND NOT THE COLUMN, deliberately. `ceiling_chapters = 0` is the RE-PASS's own +// mark, and three places read it as exactly that — `runs.maxUnitsFor`, the reconciler's resume gate, +// and this package's own admission check. Writing a zero or a null into the row would make a +// character order indistinguishable from a re-pass to all three, and in the first of them the cost is +// money: zero is the engine's word for «no volume bound at all». So the row keeps the span, every +// internal reader keeps its meaning, and only what goes OUT changes. +const runOrderedChapters = `(case when r.ordered_units is not null then null else r.ceiling_chapters end)` + // newestRun is the order in which a book RESOLVES to one of its runs: the LIVE one first, however the // clocks fell, and only then the newest-started. // diff --git a/platform/internal/pgstore/runs.go b/platform/internal/pgstore/runs.go index 8da13e36..b52757c3 100644 --- a/platform/internal/pgstore/runs.go +++ b/platform/internal/pgstore/runs.go @@ -45,10 +45,15 @@ type StartRunInput struct { // a blanket yes (D20.2-Q2). Resnapshot bool AcceptRebill money.MicroUSD - // OrderedUnits is set ONLY for a run whose order does not close whole chapters — a CHARACTER - // order. Nil is the ordinary shape and leaves the run's bar counted in chapters, exactly as - // before this pack. See migration 00033 for why such a run needs a bar of its own: counted in - // chapters it reads `0/N` for its whole life, which is the state the admission refuses a book for. + // OrderedUnits is set for every run whose order was phrased in CHARACTERS, and nil for every other + // shape. Nil is the ordinary case and leaves the run's bar counted in chapters, exactly as before + // this pack. See migration 00033 for why such a run needs a bar of its own: counted in chapters an + // order that stops inside a chapter reads `0/N` for its whole life, which is the state the + // admission refuses a book for. + // + // ⚠ THE CONDITION IS THE PHRASING, NOT WHERE THE ORDER LANDED. An earlier edition of this line + // said «only for a run whose order does not close whole chapters», and that is measurably false — + // see pricing.Quote.UnitShaped for the measurement and for why the phrasing is the right test. OrderedUnits *int // BondFunded is whether this run's hold has room for the book-level consistency passes on top of // the work it bought. Recorded on the RUN because the order form's answer is a QUOTE and the @@ -153,12 +158,13 @@ func (s *Store) StartRun(ctx context.Context, in StartRunInput, journalOffset in where c.book_id = b.id and c.units_total > 0 and c.units_draft_done >= c.units_total), ` + bookUnitsEditDone + `, ` + bookUnitsDraftDone + ` from books b where b.id = $2 and b.owner_id = $6 - returning id, book_id, revision, status, verify_bank, ceiling_chapters, - coalesce(paused_reason, ''), started_at, finished_at` + returning id, book_id, revision, status, verify_bank, + (case when ordered_units is not null then null else ceiling_chapters end), + ordered_units, coalesce(paused_reason, ''), started_at, finished_at` err := tx.QueryRow(ctx, insertRun, runID, in.BookID, in.VerifyBank, in.OrderedChapters, in.Now, in.UserID, in.Resnapshot, int64(in.AcceptRebill), in.BondFunded, in.OrderedUnits). Scan(&out.ID, &out.BookID, &out.Revision, &out.Status, &out.VerifyBank, &out.OrderedChapters, - &out.PausedReason, &out.StartedAt, &out.FinishedAt) + &out.OrderedUnits, &out.PausedReason, &out.StartedAt, &out.FinishedAt) if errors.Is(err, pgx.ErrNoRows) { return ErrNoBook // the book is missing, or it is not this account's } diff --git a/platform/internal/pricing/pricing.go b/platform/internal/pricing/pricing.go index 6d351c5e..a2f13ece 100644 --- a/platform/internal/pricing/pricing.go +++ b/platform/internal/pricing/pricing.go @@ -140,9 +140,20 @@ type Quote struct { ThroughChapter int // Units is how many output units the order buys — the figure `--max-units` is derived from. Units int - // UnitShaped says the order does not close whole chapters: it stops inside one. Only a CHARACTER - // order can, and it is the reason such a run needs a bar counted in units — in chapters it would - // read `0/N` for its whole life, because a chapter counts only once every unit in it is done. + // UnitShaped says the order was phrased in CHARACTERS, and therefore is measured in units all the + // way through: the bar counts units, and `delivered_chapters` is null. + // + // ⚠ IT IS ABOUT THE PHRASING, NOT ABOUT WHERE THE ORDER LANDED — an earlier edition of this line + // said «does not close whole chapters», and that is measurably false: `UnitsFor` returns the first + // unit whose running sum covers what was asked, so an order lands on a chapter boundary roughly as + // often as chapters divide units (a third of them at three units a chapter), and such an order + // closes whole chapters while staying unit-shaped. Pinned by + // `runs.TestACharacterOrdersVolumeIsPublishedInTheUnitItWasSoldIn`. + // + // The reason to measure in the unit the buyer SPOKE, rather than in whatever the order happens to + // cover, is that two purchases a person cannot tell apart must not render differently. The reason + // it cannot be chapters is older: a chapter counts only once every unit in it is done, so an order + // that stops inside one would read `0/N` for its whole life. UnitShaped bool // Expected is what the engine expects the ordered work to be BILLED. It is the honest half of the // pair a buyer is shown («ожидаемо ≈ …, зарезервируем до …»). diff --git a/platform/internal/readmodel/readmodel_test.go b/platform/internal/readmodel/readmodel_test.go index f5e6d457..31cf0dff 100644 --- a/platform/internal/readmodel/readmodel_test.go +++ b/platform/internal/readmodel/readmodel_test.go @@ -13,6 +13,7 @@ import ( "time" "textmachine/platform/internal/ingest" + "textmachine/platform/internal/money" "textmachine/platform/internal/pgstore" ) @@ -644,3 +645,77 @@ func TestTheMaterializationBackoffGrowsAndIsCapped(t *testing.T) { t.Errorf("the retry of a long-dead book is %v, want the cap", got) } } + +// ⛔ THE SEAM BETWEEN THE ENGINE'S DOCUMENT AND THE STORE'S ROW, and it went untested until it broke. +// +// Both halves had tests: `ingest` decides whether a manifest is priced, `pgstore` writes what it is +// handed. What nobody asked was whether this function hands over the RIGHT THING — and the answer was +// no. `pgstore.Structure` carried the book's character count in two places at once (its own field and +// the wire struct's), this mapping filled one of them, and the row went in null. The type no longer +// admits that, so what is pinned here is the mapping's SHAPE: every money figure arrives, and the +// character count arrives on the CUT, where it survives a price this build cannot read. +// +// Mutation caught: dropping a member of the Projection literal; reading SourceChars off the price +// object again; gating the character count on `Priced`. +func TestTheProjectionReachesTheStoreWholeAndTheCharacterCountTravelsBesideIt(t *testing.T) { + m := tree() + m.Structure = ingest.StructureDetected + m.Price = &ingest.BookPrice{ExpectedUSD: 2_090_000, BookOnceUSD: 2_000_000, StepMaxUSD: 69_828, SourceChars: 3000} + pricedTree(&m) + store := &fakeStore{} + svc := &Service{Store: store, Binary: "tmctl", Engine: &fakeEngine{manifest: m, noEnvelope: true}} + _ = svc.Refresh(t.Context(), owedBook(t.TempDir())) // the bank half fails; the tree lands anyway + if !store.saved { + t.Fatal("the tree did not land at all") + } + got := store.structure + if got.Price == nil { + t.Fatal("a manifest this build read whole reached the store with no projection") + } + if got.Price.Expected != 2_090_000 || got.Price.BookOnce != 2_000_000 || got.Price.StepMax != 69_828 { + t.Errorf("a money figure was lost in the mapping: %+v", *got.Price) + } + // ⛔ ON THE CUT, and this is the assertion the defect would have failed: the count is a property of + // the TEXT and the store keeps it apart from the money. + if got.SourceChars != 3000 { + t.Errorf("the engine's rune count reached the store as %d", got.SourceChars) + } + if got.Structure != ingest.StructureDetected { + t.Errorf("the cut's provenance reached the store as %q", got.Structure) + } +} + +// …and the other direction of the same independence: a manifest whose MONEY this build cannot read +// still delivers the count of its text. The refusal is `ingest`'s (a chapter with no price), so what +// is pinned here is that this mapping does not widen it into the character count. +func TestAPriceThisBuildCannotReadDoesNotCostTheStoreItsCharacterCount(t *testing.T) { + m := tree() + m.Structure = ingest.StructureDetected + m.Price = &ingest.BookPrice{ExpectedUSD: 2_090_000, BookOnceUSD: 2_000_000, StepMaxUSD: 69_828, SourceChars: 3000} + pricedTree(&m) + // One unit loses its bill, and `ingest` then refuses the projection as a whole — the half-read + // shape is the dangerous one. The book's own `source_chars` is untouched and must still travel. + m.Chapters[0].Units[0].Price = nil + store := &fakeStore{} + svc := &Service{Store: store, Binary: "tmctl", Engine: &fakeEngine{manifest: m, noEnvelope: true}} + _ = svc.Refresh(t.Context(), owedBook(t.TempDir())) + if store.structure.Price != nil { + t.Errorf("a projection this build could not read whole was written anyway: %+v", *store.structure.Price) + } + if store.structure.SourceChars != 3000 { + t.Errorf("the character count went down with the price: %d", store.structure.SourceChars) + } +} + +// pricedTree gives `tree()` a projection its own witness accepts: 30_000 micro-USD and 1000 runes a +// unit, the chapters' roll-up summing to the book's figure less the flat book-level bond. +func pricedTree(m *ingest.Manifest) { + for i := range m.Chapters { + total := money.MicroUSD(0) + for j := range m.Chapters[i].Units { + m.Chapters[i].Units[j].Price = &ingest.UnitPrice{ExpectedUSD: 30_000, SourceChars: 1000} + total += 30_000 + } + m.Chapters[i].Price = &ingest.UnitPrice{ExpectedUSD: total, SourceChars: int64(len(m.Chapters[i].Units)) * 1000} + } +} diff --git a/platform/internal/runs/order_test.go b/platform/internal/runs/order_test.go index 7b281dc4..0d86cca0 100644 --- a/platform/internal/runs/order_test.go +++ b/platform/internal/runs/order_test.go @@ -627,8 +627,8 @@ func TestTheVolumeAllowanceCountsUnitsAndNotChapters(t *testing.T) { } // …and what was SOLD is still counted in chapters, because that is what the buyer chose and what // the run's own bar is measured in. - if got := f.run(t, run.ID).OrderedChapters; got != 2 { - t.Errorf("the run says it bought %d chapters", got) + if got := f.run(t, run.ID).OrderedChapters; got == nil || *got != 2 { + t.Errorf("the run says it bought %v chapters", got) } // The hold is the units' money, not the chapters': eight units at the fixture's rate. if got, want := f.account(t).Reserved, fixtureHold(8); got != want { @@ -712,6 +712,16 @@ func TestAChapterOrderKeepsTheBarItAlwaysHad(t *testing.T) { if got.DeliveredChapters == nil || *got.DeliveredChapters != 0 { t.Errorf("a chapter order reports delivered chapters as %v, want 0 rather than null", got.DeliveredChapters) } + // …and the pair that says which unit this run is measured in points the other way from the + // character order's: the chapter figure is present, the unit figure is not. Exactly one is set, + // and this is the half that would go silently null if the branch asked «is this a partial order» + // instead of «was this sold in characters». + if got.OrderedChapters == nil || *got.OrderedChapters != 2 { + t.Errorf("a two-chapter order publishes ordered_chapters %v, want 2", got.OrderedChapters) + } + if got.OrderedUnits != nil { + t.Errorf("a run sold in chapters publishes ordered_units %d; the two are exclusive", *got.OrderedUnits) + } // One whole chapter delivered moves both the bar and the delivered count. if _, err := f.store.Pool().Exec(f.ctx, ` update chapters set units_draft_done = 2, units_edit_done = 2 @@ -726,3 +736,54 @@ func TestAChapterOrderKeepsTheBarItAlwaysHad(t *testing.T) { t.Errorf("one whole chapter through both passes reads %d/%d", got.Progress.Done, got.Progress.Total) } } + +// ⛔ A CHARACTER ORDER PUBLISHES ITS VOLUME IN THE UNIT IT WAS SOLD IN, and says NOTHING in chapters. +// +// ⚠ THIS TEST REPLACES ONE THAT PINNED THE OPPOSITE, and the replacement is ordered rather than +// convenient (D39.183 — declared in the pack's report). Its predecessor, +// `TestACharacterOrderReportsTheChapterSpanItReachesIntoAndNotZero`, pinned the SPAN — how many +// chapters the order reaches into — because that was what the build published and the contract +// described a zero. Measuring it is what killed it: the span OVERSTATES what was bought by as much as +// a whole chapter, and worst on the cheapest order there is. One unit out of four in the first +// chapter published «1 chapter», which a person reads as «my money reached the end of chapter one». +// The guarantee did not disappear — it moved: what the buyer got is now published as `ordered_units`, +// in the unit they asked in, and `ordered_chapters` is null, which is the honest answer to a question +// that does not apply. +// +// ⚠ The row underneath still holds the span — see pgstore.runOrderedChapters — because +// `ceiling_chapters = 0` is the RE-PASS's mark and three internal readers depend on it. +// +// Mutation caught: publishing the span again; nulling BOTH figures; nulling the chapter figure for a +// CHAPTER order (the branch is `ordered_units is not null`, not «is this a partial order»). +func TestACharacterOrdersVolumeIsPublishedInTheUnitItWasSoldIn(t *testing.T) { + f := newFixture(t, "10", 500) + book := multiUnitBook(t, f, "span", 3, 4) // three chapters, four units each, 1000 runes a unit + // 4500 runes ⇒ five units ⇒ the whole of chapter one and one unit of chapter two. + chars := int64(4500) + run, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: book, Characters: &chars}) + if err != nil { + t.Fatal(err) + } + got := f.run(t, run.ID) + if got.OrderedChapters != nil { + t.Errorf("a run sold in characters reports ordered_chapters %d: the chapter figure it used to "+ + "publish was the SPAN it reached into, and it overstated what was bought", *got.OrderedChapters) + } + if got.OrderedUnits == nil || *got.OrderedUnits != 5 { + t.Errorf("a five-unit order publishes ordered_units %v, want 5 — the unit it was sold in", got.OrderedUnits) + } + // …and the two figures that DO speak the order's own unit say so, which is what makes the span + // readable rather than misleading: the bar counts units and no chapter is claimed as delivered. + if got.Progress.Total != 10 { + t.Errorf("the bar of a five-unit order over two waves reads /%d, want /10", got.Progress.Total) + } + if got.DeliveredChapters != nil { + t.Errorf("delivered_chapters is %d rather than null on a unit-shaped run", *got.DeliveredChapters) + } + // ⚠ AND THE ORDER CLOSED A WHOLE CHAPTER — chapter one, all four of its units — while still being + // unit-shaped. So «unit-shaped» means «phrased in characters», NOT «closes no whole chapter», + // which is how three doc comments and the canon describe it. + if got.Progress.Done != 0 { + t.Errorf("nothing has been delivered yet and the bar reads %d", got.Progress.Done) + } +} diff --git a/platform/internal/runs/runs.go b/platform/internal/runs/runs.go index 2c880862..8902d77d 100644 --- a/platform/internal/runs/runs.go +++ b/platform/internal/runs/runs.go @@ -474,8 +474,14 @@ func (s *Service) Start(ctx context.Context, in StartRequest) (pgstore.Run, erro UserID: in.UserID, BookID: in.BookID, VerifyBank: in.VerifyBank, - // The RUN's own bar is still counted in chapters (canon §Progress), so it carries how many the - // order spans; the ORDER itself lives on the book, in units. + // How many chapters the order SPANS. For an order phrased in chapters that is what was bought, + // and it is what the bar counts and what the wire publishes. For one phrased in CHARACTERS it + // is only stored: the span overstates what was bought — a single unit of a four-unit chapter + // spans `1` — so the wire publishes `ordered_units` instead and nulls this + // (pgstore.runOrderedChapters). ⚠ It is never 0 for either shape (`max(chapters, 1)` in + // QuoteUnits), and that is load-bearing: 0 is the RE-PASS's mark. Pinned by + // runs.TestACharacterOrdersVolumeIsPublishedInTheUnitItWasSoldIn and its chapter-shaped twin. + // The ORDER itself lives on the book, in units. OrderedChapters: quote.Chapters, Ceiling: quote.Hold, Now: s.now(), @@ -486,9 +492,10 @@ func (s *Service) Start(ctx context.Context, in StartRequest) (pgstore.Run, erro // that is not — and the passes degrade rather than halt, which makes the difference invisible // in the book. The run carries what it really got. BondFunded: quote.BondFunded, - // ⚠ SET ONLY FOR AN ORDER THAT DOES NOT CLOSE WHOLE CHAPTERS. It is what switches the run's bar - // to units — see migration 00033: counted in chapters, such a run reads `0/N` for its whole - // life, which is the state this very file refuses a book for at admission (PD-405). + // ⚠ SET FOR EVERY ORDER PHRASED IN CHARACTERS — including one that happens to land on a chapter + // boundary, see pricing.Quote.UnitShaped. It is what switches the run's bar to units: counted + // in chapters, an order that stops inside one reads `0/N` for its whole life, which is the + // state this very file refuses a book for at admission (PD-405). See migration 00033. OrderedUnits: unitShapedOrder(quote), Order: &order, }, offset, s.enqueue) @@ -562,9 +569,12 @@ func (s *Service) resolveOrder(ctx context.Context, in StartRequest, priced pgst // ⛔ PRICED FROM THE UNITS THEMSELVES, not from the chapters they fall in. Quoting a unit // order at the price of every chapter it touches is what made this order meaningless exactly // where it is the only one available: a book with no chapter structure is ONE chapter, so a - // thousand characters of it reserved the whole book. The chapter span below is carried only - // for the RUN's bar, which is still counted in chapters (canon §Progress), and it is the - // coarser of the two figures — a character order can stop inside a chapter. + // thousand characters of it reserved the whole book. The chapter span below goes into the ROW + // and no further: nothing publishes it any more (pgstore.runOrderedChapters nulls the view for + // a unit-shaped run, because the span overstates what was bought), and the bar counts units + // because `UnitShaped` is set two lines down. What the stored span is still FOR is the re-pass + // discriminator — `ceiling_chapters = 0` is the re-pass's own mark, and a zero written here + // would make this order look like one to three internal readers. q := s.Pricing.QuoteUnits(pb, balance, units, n, chaptersSpanning(pb, n)) q.UnitShaped = true return q, pgstore.BookOrder{ThroughUnitID: units[n-1].ID}, nil @@ -629,8 +639,11 @@ func (s *Service) ceilingFor(amount money.MicroUSD) ([]string, error) { return s.Cfg.Ceiling.Args(amount) } -// unitShapedOrder is the run's own volume when its order does not close whole chapters, and nil when -// it does. Nil is the ordinary shape and leaves the bar counted in chapters exactly as before. +// unitShapedOrder is the run's own volume when its order was phrased in CHARACTERS, and nil for every +// other shape. Nil is the ordinary case and leaves the bar counted in chapters exactly as before. +// +// ⚠ The condition is the PHRASING (`q.UnitShaped`), not where the order landed — see +// pricing.Quote.UnitShaped for why, and for the measurement that killed the older wording. func unitShapedOrder(q pricing.Quote) *int { if !q.UnitShaped || q.Units <= 0 { return nil