diff --git a/platform/docs/platform-PROGRESS.md b/platform/docs/platform-PROGRESS.md index dff26e7b..a9f44c51 100644 --- a/platform/docs/platform-PROGRESS.md +++ b/platform/docs/platform-PROGRESS.md @@ -248,6 +248,94 @@ r.ordered_units is not null then` — считают полосу в ЮНИТА под прежним именем (`…ReportsTheChapterSpan…`) и ПЕРЕИМЕНОВАН вместе с переворотом — см. п.0 заявления `D39.183`. +### ⛔ ДОФИКС ПО КАНОНУ 06.09 — ДВА УТВЕРЖДЕНИЯ О ЗНАЧЕНИЯХ, ПЕРЕПИСАННЫЕ ЗОНОЙ ИЗ СВОИХ ПИНОВ + +Заказ оркестратора по норме авторства (строка бэклога 309 / `D39.208`): **утверждение канона о +ЗНАЧЕНИИ пишет ЗОНА из своих пинов и называет пин.** Оба утверждения были написаны им без прогона и +оказались ложными. Правился ТОЛЬКО текст и схема канона; поведение не менялось ни в одном месте. + +1. **`Run.ordered_chapters` нёс `minimum: 1` при собственной прозе «`0` is legal and means a + re-pass».** Схема противоречила своему же описанию, и валидирующий клиент, сгенерированный по ней, + отверг бы законный ответ ре-прохода. Сверено по проводу: `runOrderedChapters` для ре-прохода + отдаёт `ceiling_chapters`, а он ноль. ⇒ `minimum: 0`. ⚠ Нижняя граница НЕ снята, хотя `null` уже + отдельная ветвь типа: ноль — это ПОЛ, а не «сегодняшнее наименьшее», отрицательное отвергает сам + допуск стора, и схема теперь говорит вслух то, что сервис и так гарантирует. + ⚠ **Пина на это не было — завела.** `pgstore.TestARePassRunsBarIsOneUnitOfWork` расширен: он + держал только полосу `0/1 re_pass`, теперь держит и `ordered_chapters: 0`, и `ordered_units: null`, + и `delivered_chapters: 0` — **на ОБЕИХ поверхностях**, стартовой квитанции и пути чтения, потому + что это два разных SQL-выражения. Мутация (`delivered_chapters` → null) ловится. + ⚠ **Это ВОЗВРАТ, а не сочинение — эррата `06.09-г`.** Верная граница `minimum: 0` и абзац о ней в + каноне УЖЕ СТОЯЛИ; их снёс один заход оркестратора, писавший канон 0.12.0, — он заменил `0` на `1` + и пере-написал прозу в редакцию, говорящую ровно то же самое. То есть половина поля, которую + читает ЧЕЛОВЕК, всё это время держала правду, а половина, которую читает ГЕНЕРАТОР КЛИЕНТА, — + ложь, и стояли они в шести строках друг от друга. Разница для урока существенна: не «зона написала + правило», а «зона вернула правило, снесённое вместе с прозой, которая его же и повторяла». ⚠ Свою + сверку я вела от ПРОВОДА и ПИНА, а не от его письма, поэтому вывод от авторства не зависел. + +2. **Абзац `Progress` объявлял единственным различителем `Run.delivered_chapters`, и это ложь.** Для + ре-прохода `runDelivered` отдаёт `0` — ЧИСЛО, — значит по этому правилу клиент прочёл бы ре-проход + как ГЛАВЫ и нарисовал «0 of 1 chapters», ровно то, что абзац запрещает двумя строками ниже. + ⇒ переписано на пару, описывающую ЗАКАЗ, с явным порядком проверок: `ordered_chapters == 0` ⇒ + ре-проход · `== null` ⇒ юниты · иначе главы. Порядок несущий: проверка ре-прохода обязана быть + ПЕРВОЙ. + ⚠ **Отклонена вторая половина предложения оркестратора** — он назвал `stage: re_pass` равноправной + альтернативой. `stage` — ОТКРЫТЫЙ словарь (новые значения версию не двигают), и клиент обязан ему + деградировать, а не решать по нему. В канон вписано «corroborates; does not discriminate». + +⛔ **И КРАЙ, КОТОРЫЙ ВСКРЫЛСЯ ТОЛЬКО ОТ ЭТОЙ ПРАВКИ — правило эксклюзивности едва не оказалось +неверным.** Канон теперь утверждает «ноль ⇒ ре-проход и ничто иное». Проверено: `pricing.Quote` +клампит заказ всей книги к остатку, поэтому над ПОЛНОСТЬЮ ДОСТАВЛЕННОЙ книгой он квотит **ноль глав**, +а допуск стора отвергает ноль только при `Resnapshot == false` — которое любое продолжение над +сдвинутым банком ставит в `true`. То есть без гварда `quote.Chapters == 0` в `runs.Start` такой прогон +лёг бы с `ceiling_chapters = 0` и был бы НЕОТЛИЧИМ от ре-прохода на проводе: полоса `0/1`, +`stage: re_pass`, `delivered_chapters: 0`. Гвард есть и стоит верно — ПОСЛЕ ветви ре-прохода и ДО +взятия холда, — но **не был пиньнут ничем**. Заведён +`runs.TestABookWithNothingLeftIsRefusedRatherThanSoldAsAZeroChapterRun`; мутация (снятие гварда) +ловится, деньги при отказе не двигаются. + +⚠ **Чего НЕ обнаружилось:** расхождения на стороне ПРОВОДА. Оба раза лгал документ. `delivered_chapters: 0` +у ре-прохода — не дефект: он купил ноль глав и доставил ноль, и `ordered_chapters: 0` рядом делает +это чтение однозначным; `null` там был бы ХУЖЕ, потому что `null` уже занят под «меряется в юнитах». + +### ⛔ ТО ЖЕ РАСЩЕПЛЕНИЕ В СОСЕДНИХ ПОЛЯХ — ПРОВЕРЕНО МЕХАНИЧЕСКИ, НЕ ГЛАЗАМИ + +Заказ оркестратора: «проверь заодно, нет ли того же расщепления в соседних полях». Сделан сканер по +**всем 193 свойствам схем канона**, три правила: граница `minimum > 0` при прозе, говорящей про ноль · +проза обещает `null`, а тип его не допускает · тип допускает `null`, а проза о нём молчит. Семь +флагов, **пять — ложные срабатывания** эвристики (`target` прямо говорит «never `null`»; +`RunRequest.chapters`/`characters` несут `minimum: 1` при прозе, которая ноль ЗАПРЕЩАЕТ; +`character_count_exact` и `kind` упоминают `null` СОСЕДНЕГО поля). Настоящих два: + +1. ⛔ **`OrderOptions.structure` — тип `oneOf [string, null]`, а проза не говорила, КОГДА бывает `null` + и что он значит.** Зеркало разобранного случая: про null знал генератор, не знал человек. Провод + его шлёт — `wireOrderOptions.Structure *string`, «null when no manifest has answered yet» + (`internal/httpapi/v0.go`, греп `Structure \*string`), — и это пиньнуто: + `httpapi.TestABookWhoseCutCannotBeSoldAgainstOffersCharactersAndSaysSo` проверяет ЯВНЫЙ `null`, а + не выдуманное слово. Смысл дописан из пина: **`null` — не четвёртый вид разреза и не `none`**; + `none` — книга, на которую движок посмотрел и нашёл одну главу, `null` — книга, которую он ещё не + спрашивали. Соседний `Book.structure` то же самое говорил, а этот молчал. +2. ⚠ **`Chapter.number` — `minimum: 1` при `type: [integer,'null']` и прозе «`null` законен». НЕ + ТРОНУТО:** противоречие уже объявлено ЭРРАТОЙ 05.09 внутри самого поля, с верным разбором и + адресом (структурный пак). Сверено, что довод эрраты ЖИВ на моей стороне: `ingest.Whole()` + по-прежнему отвергает `c.Number < 1` (`internal/ingest/manifest.go`, греп `chapter numbering is + 1-based`), то есть книга, которую абзац называет законной, через шов не проходит. Добавлять нечего. + +⚠ `ordered_units` с `minimum: 1` проверен оркестратором и верен (заказа на ноль юнитов нет, «не +юнитами» несёт `null`) — не тронут. + +### ⚠ И НАХОДКА О САМОМ СПОСОБЕ ПЕРЕДАЧИ, отданная оркестратору + +**Номер строки, посланный сообщением, протухает от следующей правки того же файла и ничем не +сторожится.** Я дала ему три номера для чужих якорей, потом сама же дописала в канон два блока — и все +три соврали на четыре строки. Поймала это тем, что пере-считала перед отправкой итога, а не тем, что +что-то покраснело. ⇒ надёжная форма передачи — ТОКЕН, а не номер: `counts.py --lint` сверяет по +СОДЕРЖИМОМУ и единственный способен сказать, попал ли пере-нацеливший. + +⚠ **Мои правки канона сдвинули ТРИ чужих якоря** (`docs/PROGRESS.md:237` и `:244`, +`frontend/docs/frontend-PROGRESS.md:162`) — все целят в канон. Чужие журналы не трогаю; точные новые +строки отданы оркестратору вместе с ТОКЕНАМИ, чтобы он пере-нацелил их тем же лендингом и пере-считал +номер линтом в момент правки, а не взял мой из письма. + ✅ **ЧТО ПРОВЕРЕНО КАК ЗАКРЫТОЕ ОРКЕСТРАТОРОМ** (по его дереву, а не со слов): канон `info.version: 0.11.0` · `delivered_chapters` нуллабелен и объяснён · `ordered_chapters: minimum 0` с объяснением нуля · `ceiling_chapters` deprecated + «REFUSED 400» · `run_limit_reached` пере-формулирован ТОЧНЕЕ diff --git a/platform/internal/pgstore/readmodel_test.go b/platform/internal/pgstore/readmodel_test.go index b6ed81d8..027760b6 100644 --- a/platform/internal/pgstore/readmodel_test.go +++ b/platform/internal/pgstore/readmodel_test.go @@ -1403,6 +1403,18 @@ func TestARePassRunsBarIsOneUnitOfWork(t *testing.T) { t.Fatalf("a fresh re-pass opens at %d/%d %q, want 0/1 re_pass", run.Progress.Done, run.Progress.Total, run.Progress.Stage) } + // ⛔ THE TWO VOLUME FIGURES A RE-PASS PUTS ON THE WIRE, pinned because the CONTRACT asserts them + // and a contract may only assert what a pin holds. Both are ZERO and neither is null: a re-pass + // was ordered in chapters (it buys none) and it is not measured in units, so `ordered_units` is + // the one that is absent. This is what makes `ordered_chapters == 0` the re-pass's own mark — the + // FIRST check a client owes, before it reads `delivered_chapters`, which here is a number and + // would otherwise be taken for a chapter count. + if run.OrderedChapters == nil || *run.OrderedChapters != 0 { + t.Errorf("the start receipt of a re-pass reports ordered_chapters %v, want 0", run.OrderedChapters) + } + if run.OrderedUnits != nil { + t.Errorf("a re-pass reports ordered_units %d; it is not measured in units", *run.OrderedUnits) + } exec(t, s, ctx, `update runs set status = 'ready', finished_at = $2 where id = $1`, run.ID, at.Add(time.Minute)) got, err := s.ReadRun(ctx, "u1", run.ID) @@ -1413,6 +1425,22 @@ func TestARePassRunsBarIsOneUnitOfWork(t *testing.T) { t.Fatalf("a finished re-pass reads %d/%d %q, want 1/1 re_pass", got.Progress.Done, got.Progress.Total, got.Progress.Stage) } + // ⚠ AND THE READ PATH SAYS THE SAME AS THE RECEIPT — they are two different SQL expressions + // (`insertRun`'s RETURNING and `runOrderedChapters` over the lateral), and a contract that + // promises one shape must be held to it on both. + if got.OrderedChapters == nil || *got.OrderedChapters != 0 { + t.Errorf("a re-pass reads back ordered_chapters %v, want 0", got.OrderedChapters) + } + if got.OrderedUnits != nil { + t.Errorf("a re-pass reads back ordered_units %d", *got.OrderedUnits) + } + // ⛔ ZERO, NOT NULL — and this is the value that makes `delivered_chapters` USELESS as the sole + // discriminator of the bar's unit: a re-pass answers with a NUMBER here, so the rule «a number + // means the counters are chapters» would have a client render a re-pass as «0 of 1 chapters», + // which the contract's own Progress paragraph forbids. + if got.DeliveredChapters == nil || *got.DeliveredChapters != 0 { + t.Errorf("a re-pass reads back delivered_chapters %v, want 0 rather than null", got.DeliveredChapters) + } if _, err := s.StartRun(ctx, StartRunInput{UserID: "u1", BookID: book, OrderedChapters: 0, Ceiling: money.MicroUSD(100_000), Now: at.Add(time.Hour)}, 0, nil); err == nil { t.Fatal("a zero-chapter run without the re-pass consent was admitted") diff --git a/platform/internal/runs/order_test.go b/platform/internal/runs/order_test.go index 0d86cca0..74e8071b 100644 --- a/platform/internal/runs/order_test.go +++ b/platform/internal/runs/order_test.go @@ -787,3 +787,43 @@ func TestACharacterOrdersVolumeIsPublishedInTheUnitItWasSoldIn(t *testing.T) { t.Errorf("nothing has been delivered yet and the bar reads %d", got.Progress.Done) } } + +// ⛔ A BOOK WITH NOTHING LEFT IS REFUSED, AND THAT REFUSAL IS WHAT MAKES `ordered_chapters: 0` MEAN +// «RE-PASS» AND NOTHING ELSE. +// +// The contract now tells a client to read the bar's unit off that field, and its FIRST case is «zero +// ⇒ a re-pass, `total` is 1, do not render it as chapters». That rule is only safe because no other +// shape can put a zero there — and one nearly can: `pricing.Quote` clamps a whole-book order to what +// remains, so over a fully delivered book it quotes ZERO chapters, and the store's own admission +// check refuses a zero only when `Resnapshot` is false, which a continuation over a moved bank sets +// to true. The guard in `Start` is what closes that door, ahead of the store. +// +// Without it such a run would land with `ceiling_chapters = 0` and be INDISTINGUISHABLE FROM A +// RE-PASS on the wire: bar 0/1, `stage: re_pass`, `delivered_chapters: 0`. The buyer would be shown +// somebody else's shape of work. +// +// Mutation caught: dropping the `quote.Chapters == 0` guard; moving it after the hold is taken; +// letting Quote return the remainder count without clamping. +func TestABookWithNothingLeftIsRefusedRatherThanSoldAsAZeroChapterRun(t *testing.T) { + f := newFixture(t, "10", 500) + book := multiUnitBook(t, f, "done", 3, 2) + // Every unit of every chapter delivered in the wave the book is measured in. + if _, err := f.store.Pool().Exec(f.ctx, ` + insert into unit_resolutions (book_id, chapter, unit, wave, shipped, flagged, at) + select $1, c, u - 1, 'edit', true, false, now() + from generate_series(1, 3) c, generate_series(1, 2) u`, book); err != nil { + t.Fatal(err) + } + _, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: book}) + if err == nil { + t.Fatal("a fully delivered book was sold a run; on the wire it would be indistinguishable " + + "from a re-pass, because both carry ordered_chapters 0") + } + if !errors.Is(err, ErrCeilingOutOfBounds) { + t.Errorf("the refusal came as %v; the client needs the one that means «re-read the options»", err) + } + // …and the money did not move: the refusal is ahead of the hold. + if got := f.account(t).Reserved; got != 0 { + t.Errorf("a refused order reserved %s", got.USD()) + } +}