From 3524e47d4b68f8ac749ce9df7e13ff6aeba3d6d4 Mon Sep 17 00:00:00 2001 From: heaven Date: Sun, 6 Sep 2026 08:36:58 +0300 Subject: [PATCH] Pin at the store that a parsed book has no provenance yet, and declare the two new rows that carry alarm markers --- platform/docs/DEFECT_REGISTER.md | 7 +- platform/docs/platform-PROGRESS.md | 93 +++++++++++++++++++++--- platform/internal/gates/register_test.go | 13 ++++ platform/internal/httpapi/v0.go | 17 ++++- platform/internal/httpapi/v0_test.go | 24 +++++- platform/internal/pgstore/intake_test.go | 58 +++++++++++++++ 6 files changed, 195 insertions(+), 17 deletions(-) diff --git a/platform/docs/DEFECT_REGISTER.md b/platform/docs/DEFECT_REGISTER.md index 5eef1249..d59ee747 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: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-446 | doc | minor | канон `docs/architecture/14-api-contract/` §`PausedReason`; носители в зоне — `internal/pgstore/books.go:1196`=`PausedCreditExhausted = ingest.PausedCreditExhausted` и `internal/httpapi/v0.go:1017`=`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` | @@ -71,7 +71,9 @@ | PD-420 | bug | minor | `internal/pgstore/runs_test.go` `TestAClaimThatLostARaceToAReleaseIsRetriedAndNotAnError` | **Тест гонки холда против релиза краснеет под ПАРАЛЛЕЛЬНЫМИ батареями, а зона ратифицировала рецепт, который их требует.** `D39.159` §2 предписывает сажать мутации в КОПИЮ дерева, и всякая сессия, которая делает это всерьёз, гоняет несколько батарей разом. Замер пака P11: при трёх параллельных прогонах краснеет в ЧИСТЫХ копиях (2 раза из 15); в СЕРИЙНОМ прогоне зелен — пере-проверено трижды подряд отдельным прогоном, все три `ok`. ⚠ **ВТОРАЯ ТОЧКА, 29.08, внешнее ревью:** упал 1 раз из 4 ПОЛНЫХ прогонов зоны со всеми гейтами — то есть краснеет и без параллельных копий, просто редко. Изолированно 5/5, пакет целиком 2/2, три последующих полных прогона чистые; сообщение поймать не удалось, и ревьюер честно остановился на «редком флейке контенции», диагноз НЕ установлен. Пакет ни одним коммитом эры P11 не тронут. ⚠ Обе точки вместе сдвигают формулировку: это не «краснеет от параллельной нагрузки», а «редкая гонка, которую нагрузка делает вероятнее», — и мой первый диагноз («флейк параллельных батарей») был выведен из совпадения, ровно как в `PD-423`. Меряет ресурс, общий для копий на машине. ⚠ Цена не косметическая: **красная ЧИСТАЯ копия маскирует дельту**, а красный прогон посадки читается как «мутация поймана», когда она не поймана, — ровно та ложная улика, ради которой мутации и сажают. Обход пака — судить по ДЕЛЬТЕ множеств, а не по коду выхода; лечение — изоляция ресурса либо честный скип под нагрузкой ⚠ **пак P12 (30–31.08): пере-замерена ПОСЛЕ фикса `PD-369`, серийно — 80 из 80 зелёных, 0 FAIL, 0 SKIP** (`-count=80`, счёт по `^--- PASS`). Это снимает ПЕРВУЮ из двух точек строки по построению: флейк был не в тесте, а в том, что исчерпание раундов отвечало 500, и тест это честно ловил. ⚠ **ВТОРАЯ точка (редкая гонка контенции, 1 из 4 полных прогонов, диагноз НЕ установлен) пере-замерена под ТРЕМЯ параллельными полными батареями в чистых копиях: 18/18 пакетов в каждой, 0 красных, EXIT=0 ×3.** Это ОДНА точка, а не опровержение: строка сама говорит, что краснеет редко (1 из 4), и три чистых прогона такой частоты не исключают. Строка остаётся открытой на ней с дополненным замером; закрыть её может только серия, а не прогон. | open | пак P11, мутационная кампания (15 прогонов) + пере-проверка серийными прогонами | | PD-423 | standards | minor | `internal/runner/systemd_test.go` `TestARunIsBoundedByItsOwnCgroup`, `docs/STACK_DECISIONS.md` «Гейты батареи» | **Батарея зоны требует ЧЕТВЁРТОГО условия хоста, которого рецепт не называет: пользовательский менеджер systemd должен РЕАЛЬНО применять `MemoryMax` к транзиентным юнитам.** Замерено на этом хосте 29.08: тест трижды подряд зелен в полных батареях (`baseline2`, `final2`, `final4`), затем **пять раз подряд красен в изоляции** — при неизменном коде пакета, которого пак не касался вовсе. Причина установлена ВНЕ батареи и вне Go: `systemd-run --user --scope -p MemoryMax=64M …` даёт процессу спокойно занять 400 МиБ и выйти с кодом 0. ⚠ **МЕХАНИЗМ уточнён приёмкой оркестратора №19, и уточнение решает, воспроизводимо ли это:** «systemd не применяет потолок» верно по симптому и мимо по причине. Свойство ДОЕХАЛО — `systemctl --user show -p MemoryMax` печатает `67108864`; делегирование в порядке — `memory pids` и в `cgroup.controllers`, и в `subtree_control`. Пропал не потолок, а cgroup-КАТАЛОГ: в `app.slice` нет ни одного scope-каталога, а `cut -d: -f3 /proc/self/cgroup` для оболочки даёт **`/init.scope`**. То есть вызывающий процесс живёт ВНЕ `user@.service`; `systemd-run --user` заводит юнит в модели менеджера, а процесс остаётся в исходном cgroup, и лимита не получает никто — молча. ⚠ Отсюда и наблюдение «условие отваливается между двумя прогонами одной сессии»: оно зависит от того, из какого cgroup стартовал прогон. Тест при этом ПРАВ и его сообщение точное («check that the leaf cgroup of tm-runs.slice has memory.max»): он ловит ровно то, ради чего написан, — что потолок памяти прогона на этом хосте иллюзорен. ⚠ Следствие для процесса, а не только для теста: рецепт `STACK_DECISIONS` называет три условия батареи, а их четыре, и четвёртое — свойство ХОСТА, которое может отвалиться между двумя прогонами в одной сессии, что здесь и произошло. Сессия, наступившая на это, потратит время на поиск дефекта в своём диффе. ⚠ Предложенная этой строкой формулировка четвёртого условия («вызывающий процесс обязан жить ВНУТРИ `user@.service`», проверка `cut -d: -f3 /proc/self/cgroup` ≠ `/init.scope`) ОПРОВЕРГНУТА точками 2 и 3 ниже и СНЯТА из рецепта (`PD-432`). Живой остаток лечения — дать тесту различать «хост не применяет лимит» (честный скип с причиной) и «раннер не передал лимит» (настоящий отказ) ⚠⚠ **ВТОРАЯ ТОЧКА, 29.08, и она ПРОТИВОРЕЧИТ механизму выше — строку не закрывать, а пере-проверить.** Пак `sqlc` на ТОМ ЖЕ хосте и при том же `cut -d: -f3 /proc/self/cgroup` = **`/init.scope`** получил тест **зелёным 5 из 5 в ИЗОЛЯЦИИ** (протокол, в котором приёмка №19 видела 5 из 5 красных) плюс трижды в полных батареях; скипов в этих прогонах **ноль** — проверено по логам, то есть `systemdOrSkip` и проверка `python3` не срабатывали и тест НЕ был пустым: он требует настоящего `oom-kill` после касания 400 МиБ под `MemoryMax=64M`. Прямая проба механизма: `systemd-run --user --scope -p MemoryMax=64M -- sh -c 'cut -d: -f3 /proc/self/cgroup'` печатает **`/user.slice/user-1000.slice/user@1000.service/app.slice/run-….scope`**, то есть процесс ВСЁ-ТАКИ попадает внутрь `user@.service`, а не остаётся в исходном cgroup. Сам `tm-runs.slice` при этом существует и лежит глубже, чем ищут: `user@1000.service/**tm.slice**/tm-runs.slice` (`cgroup.controllers` = `memory pids`). **Следствие практическое:** предложенная этой же строкой одна команда-проверка (`/proc/self/cgroup` не должен давать `/init.scope`) на этом хосте даёт ЛОЖНЫЙ ОТРИЦАТЕЛЬНЫЙ — она говорит «условие не выполнено» там, где лимит применяется и тест честно зелёный. Значит cgroup ВЫЗЫВАЮЩЕГО процесса условие не предсказывает, диагноз строки неполон, и в рецепт `STACK_DECISIONS` эту команду в нынешнем виде вносить нельзя. Что различает две точки — не установлено; кандидат — состояние `cgroup.subtree_control` целевого среза в момент прогона (сейчас у `tm-runs.slice` он пуст, а systemd включает контроллер сам при старте юнита с лимитом). ⚠ **ТРЕТЬЯ ТОЧКА, пак P12 (30–31.08), и она согласна со второй:** у оболочки этой сессии `cut -d: -f3 /proc/self/cgroup` даёт **`/`** (не `/init.scope` и не путь внутри `user@.service`), а `TestARunIsBoundedByItsOwnCgroup` при этом ЗЕЛЁН в полной батарее со скипами 0 — проверено четырьмя полными прогонами. Прямая проба `systemd-run --user --scope` кладёт процесс в `…/user@1000.service/app.slice/run-….scope`; `tm-runs.slice` существует, `cgroup.controllers` = `memory pids`, а его `cgroup.subtree_control` ПУСТ — и тест всё равно зелен. То есть команда-проверка не предсказывает условие ни в одну сторону, и она **СНЯТА из рецепта** `STACK_DECISIONS` этим паком (см. `PD-432`). Что различает точки — по-прежнему НЕ УСТАНОВЛЕНО; строка остаётся открытой на диагнозе, а не на рецепте. | open | пак P11 (финальная батарея; воспроизведено голым `systemd-run` вне Go) | | PD-250 | vuln | minor | `cmd/tmplatformd/main.go` (слушатель метрик) | **`/metrics` отдаётся БЕЗ аутентификации; вся защита — привязка к `127.0.0.1`.** Для одной VM это честная граница, и она записана (STACK §24). Но на хосте с несколькими пользователями любой локальный процесс читает оперативную картину сервиса, а на деплое, где слушатель однажды переедет на `0.0.0.0` «чтобы Prometheus дотянулся», защиты не останется вовсе. Денег в метриках нет (D39.84), поэтому это minor, а не major. Лечение — bearer-токен на слушателе или mTLS, решать при первом внешнем Prometheus ⚠ **ПАК P8-REVIEW 24.08: это ТОТ ЖЕ факт, что `PD-179`, и он стоит в регистре в ДВУХ статусах одновременно** (`accepted-risk(платформа P5, 11.08)` против `open`). Код и ручка одни: `cmd/tmplatformd/main.go` `serveMetrics` без аутентификации, дефолт `127.0.0.1:9464`; рантбук `deploy/README.md` называет строкой риска именно `PD-179`. Своя добавка у этой строки есть (многопользовательский хост), но статус один факт должен нести один. Предложение пака: свести ⚠⚠ **Условие сведения, найденное рефутером:** у `PD-179` довод про ОДНУ VM, а добавка этой строки — многопользовательский хост, где любой локальный непривилегированный процесс скрейпит экспозицию, — в `PD-179` ОТСУТСТВУЕТ. Плюс при сведении из выборок безопасности исчезает класс `vuln` (у `PD-179` он `hardening`). Сводить только ВМЕСТЕ с перенесённой фразой и с пометкой класса | open | research/28 §9 (пинг оркестратора №17), сверено P7 | -| PD-454 | bug | minor | `Makefile` цель `check`; гейт `internal/gates.TestTheBatteryCannotReportCleanlinessWithoutItsLog` | **БАТАРЕЯ УМЕЛА ВЫДАТЬ ЧИСТУЮ СПРАВКУ О ПРОГОНЕ, КОТОРОГО НЕ ИЗМЕРЯЛА.** Каждая строка, которую печатает `make check` о прогоне — список пакетов, ряды `ALARM`, список падений, счёт скипов, — есть ГРЕП по одному файлу. Греп отвечает на ОТСУТСТВУЮЩИЙ файл ровно тем же, чем на чистый: ничем. Поэтому рецепт доходил до финального `else` и печатал «every test ran: no host condition was missing» о прогоне, чей лог исчез. ⚠ **Наблюдено 06.09, а не выведено:** три подряд `grep: .check.log: No such file or directory`, следом эта самая строка — на прогоне, где скипов было ПЯТЬ. Код возврата при этом 0, потому что статус берётся от `go test` ДО грепов. Причина — ФИКСИРОВАННОЕ имя лога, общее для всех прогонов в каталоге, а два прогона в одном каталоге здесь НОРМА: батарею гоняют и зона, и оркестратор, и первый закончивший удаляет улику второго посреди рецепта. ⇒ лечение двумя половинами, и они не дублируют друг друга: имя лога стало ПОПРОГОННЫМ (`$$` — PID шелла), что снимает сегодняшнюю ПРИЧИНУ, и добавлен гвард «нет лога ⇒ выйти красным ДО первого чтения», что снимает КЛАСС — отказавшийся редирект, полный диск, рука. Проверено исполнением: подменённый `GO`, не пишущий лога, даёт красный выход и две строки объяснения вместо справки. ⚠ Гейт держит ФОРМУ, а не эту починку: лог может быть попрогонным или нет, гвард может быть `-s` или `-f`, но ни одно чтение не смеет идти раньше проверки, которая умеет выйти. **ПЯТЬ** мутаций ловятся — гвард удалён · гвард после первого чтения · тест есть, `exit` убран · **`exit 1` → `exit 0`** · **`exit` без кода**. ⚠ **Две последние добавлены ВТОРОЙ редакцией гейта, и нашёл их не я, а приёмка оркестратора:** первая редакция требовала лишь, чтобы после гварда был шаг, начинающийся с `exit`, и `exit 0` проходил зелёным — то есть батарея объявляла вслух, что улики нет, и возвращала УСПЕХ. Замерено им исполнением (`exit 0` + подменённый `GO`: `MAKE-EXIT = 0` при напечатанном «THE BATTERY LEFT NO LOG»), воспроизведено зоной. **Это тот же дефект, переодетый, и в одном отношении ХУЖЕ исходного:** прежняя ложная справка была СТРОКОЙ, которую человек ловит глазами, эта — КОД ВОЗВРАТА, который потребляет машина (CI, лендинг). ⚠ И сообщение гейта обещало «exits RED», проверяя лишь наличие выхода, — та же болезнь, что он лечит, в нём самом; формулировка подтянута под проверяемое. Теперь требуется ЛИТЕРАЛ ненулевого кода: голый `exit` несёт статус предыдущей команды (`echo`, то есть ноль), а `exit $$var` из рецепта не судится вовсе. ⚠ Первая редакция гейта СЧИТАЛА ЧТЕНИЕМ слово `grep` внутри объясняющего `echo` самого гварда — ровно тот substring-vs-invocation капкан, о котором соседний гейт пишет абзацем выше; поймано прогоном, не рассуждением. **Девятая форма ложной зелени (`D39.202`)** ⚠ **ЛЕЧЕНИЕ В ДЕРЕВЕ, статус флипает ЛЕНДИНГ** | open | замер зоны 06.09, заказ оркестратора | +| PD-454 | bug | minor | `Makefile` цель `check`; гейт `internal/gates.TestTheBatteryCannotReportCleanlinessWithoutItsLog` | **БАТАРЕЯ УМЕЛА ВЫДАТЬ ЧИСТУЮ СПРАВКУ О ПРОГОНЕ, КОТОРОГО НЕ ИЗМЕРЯЛА.** Каждая строка, которую печатает `make check` о прогоне — список пакетов, ряды `ALARM`, список падений, счёт скипов, — есть ГРЕП по одному файлу. Греп отвечает на ОТСУТСТВУЮЩИЙ файл ровно тем же, чем на чистый: ничем. Поэтому рецепт доходил до финального `else` и печатал «every test ran: no host condition was missing» о прогоне, чей лог исчез. ⚠ **Наблюдено 06.09, а не выведено:** три подряд `grep: .check.log: No such file or directory`, следом эта самая строка — на прогоне, где скипов было ПЯТЬ. Код возврата при этом 0, потому что статус берётся от `go test` ДО грепов. Причина — ФИКСИРОВАННОЕ имя лога, общее для всех прогонов в каталоге, а два прогона в одном каталоге здесь НОРМА: батарею гоняют и зона, и оркестратор, и первый закончивший удаляет улику второго посреди рецепта. ⇒ лечение двумя половинами, и они не дублируют друг друга: имя лога стало ПОПРОГОННЫМ (`$$` — PID шелла), что снимает сегодняшнюю ПРИЧИНУ, и добавлен гвард «нет лога ⇒ выйти красным ДО первого чтения», что снимает КЛАСС — отказавшийся редирект, полный диск, рука. Проверено исполнением: подменённый `GO`, не пишущий лога, даёт красный выход и две строки объяснения вместо справки. ⚠ Гейт держит ФОРМУ, а не эту починку: лог может быть попрогонным или нет, гвард может быть `-s` или `-f`, но ни одно чтение не смеет идти раньше проверки, которая умеет выйти. **ПЯТЬ** мутаций ловятся — гвард удалён · гвард после первого чтения · тест есть, `exit` убран · **`exit 1` → `exit 0`** · **`exit` без кода**. ⚠ **Две последние добавлены ВТОРОЙ редакцией гейта, и нашёл их не я, а приёмка оркестратора:** первая редакция требовала лишь, чтобы после гварда был шаг, начинающийся с `exit`, и `exit 0` проходил зелёным — то есть батарея объявляла вслух, что улики нет, и возвращала УСПЕХ. Замерено им исполнением (`exit 0` + подменённый `GO`: `MAKE-EXIT = 0` при напечатанном «THE BATTERY LEFT NO LOG»), воспроизведено зоной. **Это тот же дефект, переодетый, и в одном отношении ХУЖЕ исходного:** прежняя ложная справка была СТРОКОЙ, которую человек ловит глазами, эта — КОД ВОЗВРАТА, который потребляет машина (CI, лендинг). ⚠ И сообщение гейта обещало «exits RED», проверяя лишь наличие выхода, — та же болезнь, что он лечит, в нём самом; формулировка подтянута под проверяемое. Теперь требуется ЛИТЕРАЛ ненулевого кода: голый `exit` несёт статус предыдущей команды (`echo`, то есть ноль), а `exit $$var` из рецепта не судится вовсе. ⚠ Первая редакция гейта СЧИТАЛА ЧТЕНИЕМ слово `grep` внутри объясняющего `echo` самого гварда — ровно тот substring-vs-invocation капкан, о котором соседний гейт пишет абзацем выше; поймано прогоном, не рассуждением. **Девятая форма ложной зелени (`D39.202`)** ⚠ **ЛЕЧЕНИЕ В ДЕРЕВЕ, статус флипает ЛЕНДИНГ** | fixed(201363b) | замер зоны 06.09, заказ оркестратора | +| PD-455 | bug | minor | `internal/httpapi/v0.go` `wireOrderOptions`; `internal/runs/runs.go` `Options` | **ФОРМА ЗАКАЗА ОТВЕЧАЕТ `covers_all` НАД КНИГОЙ, У КОТОРОЙ УЖЕ ИДЁТ ПРОГОН, и молчит о том, что клик получит отказ.** Вердикт считается из остатка и баланса и про ЖИВОЙ прогон не спрашивает; допуск же отвергает вторую покупку `ErrRunInFlight`. ⇒ человек видит «хватает на всю книгу», жмёт и получает отказ, причину которого форма знала до клика. ⚠ Денег не теряет и работы не портит — отказ случается ДО взятия холда, — ломается ровно обещание формы «вердикт ДО клика», ради которого пак и писался. ⚠ Найдено ВТОРЫМ КРУГОМ этого же пака и названо в отчёте поимённо (пункт ⑸ списка «не починено»), но носителя вне отчёта не имело до 06.09: отчёт — хроника, ряд — индекс, и находка без ряда не находится. Лечение: форма обязана нести признак живого прогона (или вердикт `blocked`), а не молчать | open | второй круг пака «форма заказа», 06.09 | +| PD-456 | bug | minor | `internal/pgstore/books.go` `ReadBookForOrder` (сумма по недоставленным юнитам) | **ГЛАВА, УЖЕ ЗАДРАФЧЕННАЯ, НО НЕ ОТРЕДАКТИРОВАННАЯ, ОЦЕНИВАЕТСЯ ПОЛНОСТЬЮ.** Остаток книги считается по юнитам, которые ещё не доставлены В ТЕКУЩЕЙ ВОЛНЕ; стадию, на которой юнит уже стоит, счётчики не различают. ⇒ юнит, у которого черновик сделан и осталась только редактура, входит в оценку по ПОЛНОЙ цене обеих волн. ⚠ Ошибка направлена ВВЕРХ — покупателю предлагают зарезервировать больше, чем нужно, — то есть безопасна по деньгам и ВРЕДНА по продукту: завышенный холд сокращает то, что человек может купить, а на коротком балансе делает покупку невозможной там, где она возможна. ⚠ Названо в отчёте пака (пункт ⑹) без носителя вне него. Лечение требует, чтобы проекция движка различала остаток по ВОЛНАМ, а не только по юнитам, — это вопрос к манифесту, а не только к платформе | open | второй круг пака «форма заказа», 06.09 | ## Открытые — info | ID | Класс | Серьёзность | Где | Суть | Статус | Источник | @@ -140,6 +142,7 @@ | 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: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 (гейт класса, первый прогон) | +| PD-457 | bug | info | `sqlc.yaml` (`overrides`), колонка `books.source_chars` | **У NULLABLE `bigint` НЕТ ПОДСТАНОВКИ ТИПА, ХОТЯ КОНФИГ ОБЕЩАЕТ УКАЗАТЕЛЬ ДЛЯ NULLABLE-ЦЕЛЫХ.** Миграция 00033 делает `books.source_chars` нуллабельной намеренно («движок не сказал» — не «ноль»), а `sqlc` для неё подстановки не имеет. Сегодня не ломает ничего: рукописный код читает колонку как `*int64` и конвертирует сам, `sqlc diff` чист. ⚠ Риск ОТЛОЖЕННЫЙ, а не отсутствующий, и наступит на ПЕРВОМ запросе `queries/*.sql`, который её выберет, — скан NULL в не-указатель. **Тот же класс, что и денежные колонки** (строка бэклога 304, заведена оркестратором), но другая колонка и другая подстановка: та про `*_micro_usd`, эта про размер текста. ⚠ Названо в отчёте пака (пункт ⑵) без носителя вне него | open | второй круг пака «форма заказа», 06.09 | ## Принятый риск diff --git a/platform/docs/platform-PROGRESS.md b/platform/docs/platform-PROGRESS.md index 5b433ef8..7067b8a2 100644 --- a/platform/docs/platform-PROGRESS.md +++ b/platform/docs/platform-PROGRESS.md @@ -349,6 +349,58 @@ r.ordered_units is not null then` — считают полосу в ЮНИТА разойтись», который этот пак вычищал из денежного пути. Вынесен в `makeRecipe(t, target)`, оба гейта читают один разбор; прежний тест зелен без правок. +### ⛔ ТРЕТИЙ ЗАХОД ПО НОРМЕ АВТОРСТВА — ОДНА ЭРА, ТРИ НОСИТЕЛЯ, ПРОТУХШИЕ ПО-РАЗНОМУ + +Найдено не зоной: строитель нашёл, оркестратор проверил, зона пишет из пина (`D39.211` п.3а). + +**⑴ `Book.structure` и `OrderOptions.structure` несли РАЗНОЕ, хотя канон утверждал обратное.** +В `OrderOptions.structure` я записала «the sibling `Book.structure` carries the same fact and says the +same in its own words; **the two cannot disagree**». Проверено — на тот момент это было заявление о +НАМЕРЕНИИ, а не о тексте: `Book.structure` говорил только «`null` while the book is still arriving» и +не различал `null` и `none` ВОВСЕ. ⇒ правило «нет провенанса ≠ есть, и он плохой» жило в схеме ЗАКАЗА, +а поле, которое клиент видит на карточке КНИГИ, о нём молчало. + +**⑵ И «still arriving» ýже факта.** Книга может приехать целиком и отвечать `null`, потому что разрез +не гоняли. Это не рассуждение: пин, на котором стоит правило, берёт книгу в статусе `not_started` — +полностью загруженную, без манифеста — и получает явный `null`. + +**⑶ Третий носитель той же эры — МОЙ КОД.** `httpapi.wireBook.Structure` перечислял ТРИ значения +(`declared`/`detected`/`none`) и `delimited` не знал: комментарий остался от трёхзначной эры и читался +как обещание. ⇒ **три носителя одной эры протухли по-разному, и один из них мой.** Перечень из +комментария УБРАН совсем: словарь открытый и растёт аддитивно, список в комментарии протухает молча — +канон держит слова, комментарий держит форму. + +**ПИН ВЫДЕЛЕН, А НЕ ПРОЦИТИРОВАН ЧУЖОЙ.** Утверждение о `null` на карточке книги держалось внутри +`TestTheCharacterCountSaysWhichNumberItIs` — постояльцем без своего имени, а канон, цитирующий пин по +имени, не может цитировать пин, названный про другое. Перенесено (не скопировано) в +`httpapi.TestABooksCutProvenanceIsTheEnginesOwnWordOrAnExplicitNull`, туда же добавлено третье +утверждение: незнакомое слово (`delimited`) едет ВЕРБАТИМ, потому что сборка, переписывающая +непонятое, спрятала бы рост словаря от клиента, который обязан на нём деградировать. + +⚠ **И правило, которое зона взяла себе из этого:** смысл значения нельзя списывать с комментария +провода — сегодня это ВТОРОЙ случай, когда так списанное утверждение оказалось ложным. Источник — +ПИН. + +⛔ **И ТУТ ЖЕ — ЧЕТВЁРТЫЙ СЛУЧАЙ, ТОНЬШЕ ВСЕХ ПРЕДЫДУЩИХ: ПИН БЫЛ, А УТВЕРЖДЕНИЕ ВСЁ РАВНО УЕХАЛО.** +Я написала в канон, что `null` бывает у книги «fully uploaded, no manifest», и сослалась на пин, чей +фейк отдаёт `not_started` без структуры. Пин настоящий и держит РОВНО ТО, ЧТО ДЕРЖИТ: пустая колонка +доезжает до клиента явным `null`. Но это **тест ОТОБРАЖЕНИЯ** — он ничего не говорит о том, бывает ли +такая пара у настоящей книги. Я прочитала ФИКСТУРУ как продуктовое состояние. Поймал оркестратор +сверкой с соседним словарём: `BookStatus` обещает у `not_started` «cut», то есть манифест прочитан. + +⇒ **Ответ дал пин уровня ХРАНИЛИЩА, заведённый под этот вопрос** — +`pgstore.TestAParsedBookIsNotStartedBeforeItHasAnyProvenance`. `FinishParse` ставит `not_started` и +ВЗВОДИТ ДОЛГ НА ДЕРЕВО в одной транзакции (его собственный комментарий: «this is the transaction that +makes the book parsed, and a book that is parsed owes a tree»). ⇒ окно «разрезана, провенанса ещё нет» +СПРОЕКТИРОВАНО, а не гонка. Ложной оказалась **моя иллюстрация**, а не словарь статусов и не +различение `null`/`none`: `not_started` говорит, что разрезан ИСХОДНИК, а не что прочитан манифест. +Три дороги к тому же `null`: долг ещё не оплачен · манифест старше поля · книга заведена с уже +лежащего файла. + +⚠ **Урок, который стоит отдельно от находки:** «есть пин» — не то же, что «пин доказывает сказанное». +Доказано было ОТОБРАЖЕНИЕ, сказано — про ЖИЗНЬ. Цитируя пин в утверждении о значении, спрашивать не +«зелен ли он», а «то ли он мерит». + ### ⛔ ТО ЖЕ РАСЩЕПЛЕНИЕ В СОСЕДНИХ ПОЛЯХ — ПРОВЕРЕНО МЕХАНИЧЕСКИ, НЕ ГЛАЗАМИ Заказ оркестратора: «проверь заодно, нет ли того же расщепления в соседних полях». Сделан сканер по @@ -786,16 +838,33 @@ ordered_units: integer|null // НОВОЕ: сколько юнитов ку make check → MAKE-EXIT=0 ⟵ ПЕРЕ-СНЯТО 06.09 ПОСЛЕ ДОФИКСА ПО КАНОНУ, красных НЕТ golangci-lint: 0 issues · gofmt чист · go vet чист · sqlc diff чист ПАКЕТОВ 20: ok 20, FAIL 0 - ТЕСТОВ верхнеуровневых 852: PASS 847 · FAIL 0 · SKIP 5 - (847-й — новый гейт прибора, `TestTheBatteryCannotReportCleanlinessWithoutItsLog`) - ⚠ ИСПР.: прежняя редакция этого блока писала «ПАКЕТОВ 21». Их ДВАДЦАТЬ. Двадцать первым - я посчитала лишнюю строку `FAIL` в выводе `make` — он печатает и падение ПАКЕТА, и - итоговое голое `FAIL`. Ошибка ровно того же рода, что ловил четвёртый заход: величина - взята из вывода прибора, не разобрав, что прибор печатает. + ТЕСТОВ верхнеуровневых 854: PASS 849 · FAIL 0 · SKIP 5 + РЕГИСТР: 457 рядов · открытых 105 · major 3 · minor 35 · info 67 + (847-й — гейт прибора `TestTheBatteryCannotReportCleanlinessWithoutItsLog`; 848-й — + выделенный пин провенанса `TestABooksCutProvenanceIsTheEnginesOwnWordOrAnExplicitNull`; + 849-й — пин ЖИЗНЕННОГО состояния `pgstore.TestAParsedBookIsNotStartedBeforeItHasAnyProvenance`, + заведённый потому, что прежний доказывал ОТОБРАЖЕНИЕ, а канон говорил про ЖИЗНЬ) + ⚠ ИСПР.: прежняя редакция этого блока писала «ПАКЕТОВ 21». Их ДВАДЦАТЬ. + ⛔ И ОБЪЯСНЕНИЕ, которое здесь стояло («двадцать первым я посчитала лишнюю голую + строку `FAIL`»), тоже НЕ ВЫДЕРЖАЛО ПРОВЕРКИ — это была третья догадка подряд об одном + числе. Пере-считано по сохранённым выводам: зелёный прогон даёт `ok` 20, `FAIL` 0 — + лишней строки нет ВОВСЕ; красный даёт `ok` 19 + `FAIL <пакет>` 1 + голых `FAIL` ДВА, + итого 22 строки при тех же 20 пакетах. ⇒ «21» получилось СЛОЖЕНИЕМ ДВУХ РАЗНЫХ + ПРОГОНОВ: `ok 20` взято из зелёного, `FAIL 1` — из красного. ⚠ **ИСПР. ПОВТОРНО:** здесь стояло «три + наблюдателя, три причины ОДНОГО числа». Неверно, и снято по замеру оркестратора: у него + `grep -c '^ok.*internal/money'` = **2** в одном логе и 1 после починки — дубль реален и + объясняет ЕГО двадцать один. **Чисел было ДВА, а не одно:** его — от дубля в общем логе, + моё — от сложения двух прогонов. Общего у них только величина. ⚠ Формулировка была + красивее правды и потому прожила дольше неё — ровно то, за чем этот отчёт весь день + гоняется. ⚠ счёт снят из `.check.log` (`make check` гонит `-v`); `go test` БЕЗ `-v` строк `--- SKIP` не печатает вовсе, и греп по нему даёт ЛОЖНЫЙ НОЛЬ — этой ошибкой четвёртый заход уже был пойман -ALARM PD-count: 12 (baseline 12) — ПЕРЕ-СНЯТА базой: PD-375 и PD-422 удалены из `alarmBaseline` +ALARM PD-count: 14 (baseline 14) — ⚠ ЧИСЛО ВЫРОСЛО 06.09 НЕ ОТ НОВОЙ ОПАСНОСТИ, а от того, что две + находки пака наконец получили РЯДЫ: `PD-455` и `PD-456` объявлены в `alarmBaseline` с + доводом, по прямой инструкции самого гейта. Маркеры они несут законно (холд, деньги, + молчание), ниже major стоят потому, что ни одна не теряет денег и не портит работы — + ломается продукт. Прежде: 12 (baseline 12), ПЕРЕ-СНЯТА базой: PD-375 и PD-422 удалены из `alarmBaseline` по прямой инструкции самого гейта («ОБА ОБЯЗАНЫ ПОКИНУТЬ КЛАСС НА ЛЕНДИНГЕ») и прецеденту PD-168; было 14 (baseline 14) с двумя объявленными исключениями @@ -1051,7 +1120,13 @@ status` на входе сессии — clean). В него ВХОДИТ `81a89 мутациями (`SourceChars: 0` красит оба, потеря `BookOnce` — первый); ⑸ форма над книгой с ЖИВЫМ прогоном отвечает `covers_all` и молчит о том, что клик получит `run_in_flight`; ⑹ глава ЗАДРАФЧЕННАЯ, но не отредактированная, оценивается полностью (счётчики стадию не различают), - ошибка ВВЕРХ; ⑺ откат миграции стирает различение двух причин паузы, ради которого пак их и + ошибка ВВЕРХ ⟵ ⑵/⑸/⑹ **ПОЛУЧИЛИ РЯДЫ 06.09** (`PD-457`/`PD-455`/`PD-456`): до этого их + единственным носителем был ЭТОТ отчёт, а отчёт — хроника, и находка без ряда не находится. + ⚠ **⑶ РЯДА НЕ ПОЛУЧИЛ И НЕ ДОЛЖЕН, но назван здесь строкой по просьбе оркестратора:** новые фикстуры + пишут колонки `pgstore` рукописным SQL ИЗ пакета `internal/runs`, куда не достаёт ни `sqlgate` + (читает свой каталог), ни `sqlcgate`. Предмет — ДЫРА В ПОКРЫТИИ ГЕЙТОВ, а не дефект поведения: + сегодня ни один сторож не судит эти вставки, и через месяц об этом никто не вспомнит. Кандидат в + расширение области `sqlgate`, не в ряд регистра; ⑺ откат миграции стирает различение двух причин паузы, ради которого пак их и разделил — для отката приемлемо, но записано. - **PLAUSIBLE, не проверено исполнением:** что `--resnapshot` на каждом продолжении не приводит к заметной пере-оплате на РЕАЛЬНОЙ книге с растущим банком. Механизм я разобрала по коду движка и @@ -4273,7 +4348,7 @@ caught:` того теста, который она обязана валить вне идемпотентности ключа запроса); повтор после успеха отвечает `run_in_flight` либо `ErrRePassUnavailable` — факт погашен финишем (символ `ErrRePassUnavailable`: `platform/internal/runs/runs.go:149`=`var ErrRePassUnavailable = errors.New(`; ветка провода — - `platform/internal/httpapi/v0.go:1149`=`case errors.Is(err, runs.ErrRePassUnavailable):`). + `platform/internal/httpapi/v0.go:1158`=`case errors.Is(err, runs.ErrRePassUnavailable):`). Вырожденных дублей не нашёл, но специального пина нет. - ⚠ **Для оркестратора — находка опровергателя P10, носителя ни в регистре, ни в бэклоге у неё нет:** якоря §2.12 компаньона контракта (`docs/architecture/14-api-contract/README.md`, греп diff --git a/platform/internal/gates/register_test.go b/platform/internal/gates/register_test.go index c965039f..bb7a1d4d 100644 --- a/platform/internal/gates/register_test.go +++ b/platform/internal/gates/register_test.go @@ -73,6 +73,19 @@ var alarmBaseline = []string{ // ровно один) и о том, что отказ приходит МОЛЧА для пользователя, дважды нажавшего кнопку. // Ниже major он стоит потому, что деньги не теряются: отказ случается ДО взятия холда. "PD-448", + // PD-455 — вошёл в класс 06.09 вторым кругом пака «форма заказа». Маркеры законны: ряд про то, что + // форма МОЛЧИТ о живом прогоне, и про ХОЛД, потому что отказ случается ровно до его взятия. Ниже + // major он стоит по той же причине, по которой его цена терпима: денег не теряется и работа не + // портится — ломается обещание «вердикт ДО клика», то есть продукт, а не деньги. + "PD-455", + // PD-456 — вошёл в класс 06.09 вторым кругом пака «форма заказа». Маркеры несёт ЗАКОННО: ряд про + // деньги и про холд, потому что дефект — завышение оценки остатка (юнит, у которого сделан только + // черновик, входит в цену обеими волнами). Ниже major он стоит потому, что **ошибка направлена + // ВВЕРХ**: денег не теряется и работа не портится, у человека просят зарезервировать больше + // нужного. Цена продуктовая — завышенный холд сокращает покупаемое, а на коротком балансе делает + // покупку невозможной там, где она возможна. Лечение упирается в манифест движка (остаток по + // ВОЛНАМ, а не только по юнитам), то есть в чужую зону, и одной платформой не закрывается. + "PD-456", "PD-94", "PD-107", "PD-162", "PD-201", "PD-212", "PD-217", "PD-244", "PD-418", "PD-420", "PD-428", "PD-433", } diff --git a/platform/internal/httpapi/v0.go b/platform/internal/httpapi/v0.go index ff0d4953..7b14d718 100644 --- a/platform/internal/httpapi/v0.go +++ b/platform/internal/httpapi/v0.go @@ -209,10 +209,19 @@ type wireBook struct { // destroy a meaning the contract already promises. Form ratified by the orchestrator, act // D39.201 §5(б), on two lawful shapes the zone put up. CharacterCountExact bool `json:"character_count_exact"` - // Structure is where the chapter boundaries came from: `declared` (the format drew them), - // `detected` (matched in the prose), `none` (the whole book is one chapter). Null until a - // manifest has said. A client needs it to know what a chapter NUMBER is worth before it offers an - // order phrased in one — see OrderOptions.chapter_orders. + // Structure is where the chapter boundaries came from, in the ENGINE's own word, passed through + // untouched. A client needs it to know what a chapter NUMBER is worth before it offers an order + // phrased in one — see OrderOptions.chapter_orders. + // + // ⚠ THE VOCABULARY IS OPEN AND GROWS ADDITIVELY, so this comment does not list it: an earlier + // edition named three words, the engine shipped a fourth (`delimited`, 06.09), and the list went + // stale in place while reading like a promise. The canon holds the words; this holds the shape. + // + // ⛔ NULL IS NOT A WORD OF THAT VOCABULARY AND NOT `none`. `none` is a book the engine LOOKED at + // and found one chapter in — a statement about the book. Null is the absence of any statement: + // no manifest has been read. ⚠ That is NOT the same as «still arriving»: a book can be fully here + // and still answer null, because the cut has not been run. Pinned by + // TestABooksCutProvenanceIsTheEnginesOwnWordOrAnExplicitNull, whose book is `not_started`. Structure *string `json:"structure"` AddedAt time.Time `json:"added_at"` NoteCount int `json:"note_count"` diff --git a/platform/internal/httpapi/v0_test.go b/platform/internal/httpapi/v0_test.go index 0ac62287..2a4730e3 100644 --- a/platform/internal/httpapi/v0_test.go +++ b/platform/internal/httpapi/v0_test.go @@ -935,8 +935,21 @@ func TestTheCharacterCountSaysWhichNumberItIs(t *testing.T) { } }) } - // The cut's provenance travels beside it, verbatim, or as an explicit null when no manifest has - // spoken. The client needs it to know what a chapter NUMBER is worth before it offers an order. +} + +// ⛔ THE CUT'S PROVENANCE ON THE BOOK CARD, and `null` there means «nobody has been asked», which is a +// different fact from every word the vocabulary carries. +// +// It used to be asserted inside the character-count test, as a tenant with no name of its own — and a +// contract that cites a pin by name cannot cite one named for something else. Moved rather than +// copied: the assertions are the same, the subject is now findable. +// +// ⚠ THE BOOK HERE IS `not_started`, WHICH IS THE POINT. It has fully arrived and still answers `null`, +// because no manifest has been read — so «null while the book is still arriving» is NARROWER than the +// fact and a client that waits for arrival waits for the wrong thing. And `null` is not `none`: `none` +// is a book the engine looked at and found one chapter in, which is a statement ABOUT the book; +// `null` is the absence of any statement. +func TestABooksCutProvenanceIsTheEnginesOwnWordOrAnExplicitNull(t *testing.T) { got := decode(t, call(t, v0Server(t, &fakeLibrary{book: pgstore.Book{ID: "bk_1", Status: "not_started"}}, &fakeRuns{}), "GET", "/v0/books/bk_1", "")) book, _ := got["book"].(map[string]any) if v, ok := book["structure"]; !ok || v != nil { @@ -947,6 +960,13 @@ func TestTheCharacterCountSaysWhichNumberItIs(t *testing.T) { if book["structure"] != "declared" { t.Errorf("structure = %v, want the engine's own word", book["structure"]) } + // An unknown word travels VERBATIM too: the vocabulary grows additively, and a build that + // re-wrote what it did not recognise would hide the growth from the client that must degrade on it. + got = decode(t, call(t, v0Server(t, &fakeLibrary{book: pgstore.Book{ID: "bk_1", Status: "not_started", Structure: "delimited"}}, &fakeRuns{}), "GET", "/v0/books/bk_1", "")) + book, _ = got["book"].(map[string]any) + if book["structure"] != "delimited" { + t.Errorf("structure = %v, want the engine's word passed through untouched", book["structure"]) + } } // ⛔ A RETIRED MEMBER IS REFUSED, NOT IGNORED, and the difference is a purchase. diff --git a/platform/internal/pgstore/intake_test.go b/platform/internal/pgstore/intake_test.go index 82001435..645dbdf3 100644 --- a/platform/internal/pgstore/intake_test.go +++ b/platform/internal/pgstore/intake_test.go @@ -297,3 +297,61 @@ func TestEveryRunTheStoreHandsOutCarriesItsBooksRevision(t *testing.T) { } } } + +// ⛔ A BOOK IS `not_started` BEFORE ITS PROVENANCE EXISTS, and the window is DESIGNED rather than a +// race. `FinishParse` sets the status and ARMS THE READ-MODEL DEBT in one transaction — its own +// comment says why: «this is the transaction that makes the book parsed, and a book that is parsed +// owes a tree». The tree, and with it `structure`, arrive later, when the materializer pays that debt. +// +// ⚠ THIS PIN EXISTS BECAUSE A CONTRACT SENTENCE WAS WRITTEN OFF A DISPLAY TEST. The canon said +// `Book.structure` is `null` for a book that is «fully uploaded, no manifest», citing a test whose +// fake store returns a `not_started` book with no structure. That test is real and proves what it +// proves — an empty column reaches the client as an explicit `null` — but it is a test of RENDERING: +// it says nothing about whether a real book ever holds that pair. The illustration was a fixture read +// as a product state. Here the pair is produced by the intake itself. +// +// ⚠ And it is not the only way to reach it: a manifest older than the `structure` field leaves the +// column null for good (pgstore.Structure.Structure, «Empty when the engine did not say»), and +// AddBook — the route for a file already on disk — creates a book `not_started` with no manifest at +// all. Three roads, one pair; the status vocabulary's «cut, never run» is about the SOURCE having +// been cut, not about the platform having read a manifest. +// +// Mutation caught: arming the read-model debt after the status instead of with it; a status written +// only once the tree is materialised (the book would then be invisible to its owner while parsing +// completed). +func TestAParsedBookIsNotStartedBeforeItHasAnyProvenance(t *testing.T) { + s, ctx := testDB(t) + now := fundedAccount(t, s, ctx, "u1", "10") + b := upload(t, s, ctx, "u1", now) + if _, err := s.StartParsing(ctx, b.ID, 42, nil); err != nil { + t.Fatal(err) + } + claim, err := s.ClaimParse(ctx, b.ID, now, now.Add(-time.Hour)) + if err != nil { + t.Fatal(err) + } + if _, err := s.FinishParse(ctx, b.ID, claim.At, ParsedBook{Chapters: 500, ChunkerVersion: "chunk-1"}); err != nil { + t.Fatal(err) + } + got, _, err := s.GetBook(ctx, "u1", b.ID) + if err != nil { + t.Fatal(err) + } + if got.Status != "not_started" { + t.Fatalf("a book whose parse just finished is %q", got.Status) + } + if got.Structure != "" { + t.Fatalf("a book that has not been materialised carries provenance %q: the pair this pin is "+ + "about does not occur, and the contract's illustration of `null` is wrong in the other "+ + "direction", got.Structure) + } + // …and it is not that the book is somehow unfinished: it owes a tree, and the debt is what will + // bring the provenance. + owed, err := s.BooksOwedReadModel(ctx, 10) + if err != nil { + t.Fatal(err) + } + if len(owed) != 1 || owed[0].ID != b.ID { + t.Fatalf("the parsed book does not owe a reading surface: %+v", owed) + } +}