Pin that a re-pass answers zero on both surfaces a run is handed out on, and that a book with nothing left is refused rather than sold

This commit is contained in:
heaven 2026-09-06 07:30:15 +03:00
parent 32a60509fe
commit 33b896b1ac
3 changed files with 156 additions and 0 deletions

View file

@ -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` пере-формулирован ТОЧНЕЕ

View file

@ -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")

View file

@ -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())
}
}