Land the bank read-out pack: the signing stop reaches the reader through a new resource, contract 0.16.0, and the register literals follow it

This commit is contained in:
heaven 2026-09-17 04:11:38 +03:00
parent dc1b99e71d
commit ffd3751631
25 changed files with 2750 additions and 45 deletions

File diff suppressed because one or more lines are too long

View file

@ -10,7 +10,7 @@
- [product-requirements.md](product-requirements.md) — реестр «что продукт обязан уметь»: производное живого брифа владельца и его находок, статусы сверены кодом.
- `architecture/` — синтез и контракты:
- [05-decisions-log.md](architecture/05-decisions-log.md) — **источник истины по решениям**: ратифицированный контракт, при конфликте побеждает он. Дисциплина чтения (грепать номер, не читать целиком, баннер прежде тела) — в каноне `../CLAUDE.md`, здесь не дублируется. Статус и тело ЛЮБОГО номера одним хопом — реестр [05-decisions-index.md](architecture/05-decisions-index.md) (все ноты, колонка тем для грепа «какой закон по X»).
- [09-target-architecture.md](architecture/09-target-architecture.md) — целевая 7-слойная архитектура (статус стройки — шапка-таблица; §3 — карта находок H/L) · [10-prompt-architecture.md](architecture/10-prompt-architecture.md) — промпт-слой · [12-go-style-notes.md](architecture/12-go-style-notes.md) — норматив общности §0 · [13-tech-debt-anchors.md](architecture/13-tech-debt-anchors.md) — якоря техдолга + археология выселенных строк бэклога §Б-* (справочник к бэклогу, НЕ трекер) · [14-api-contract/](architecture/14-api-contract/) — **контракт API v0 фронт↔платформа (D39.99)**: нормативная спека OpenAPI 3.1 (типы фронта генерятся из неё) + компаньон-README с провенансом каждой строки (легенда — в нём самом; зонная копия `frontend/docs/api-contract/` ОТСТАЛА на `0.2.3` при каноне `0.15.0` (испр. оркестратором 17.09, прежде стояло `0.10.0` — разошлось на пять миноров; ⚠ версию канона брать ФАЙЛОМ — `grep '^ version:' architecture/14-api-contract/openapi.yaml`, число здесь стареет) и зеркалом сегодня НЕ является — зона заморожена, синк при разморозке; читать ТОЛЬКО канон).
- [09-target-architecture.md](architecture/09-target-architecture.md) — целевая 7-слойная архитектура (статус стройки — шапка-таблица; §3 — карта находок H/L) · [10-prompt-architecture.md](architecture/10-prompt-architecture.md) — промпт-слой · [12-go-style-notes.md](architecture/12-go-style-notes.md) — норматив общности §0 · [13-tech-debt-anchors.md](architecture/13-tech-debt-anchors.md) — якоря техдолга + археология выселенных строк бэклога §Б-* (справочник к бэклогу, НЕ трекер) · [14-api-contract/](architecture/14-api-contract/) — **контракт API v0 фронт↔платформа (D39.99)**: нормативная спека OpenAPI 3.1 (типы фронта генерятся из неё) + компаньон-README с провенансом каждой строки (легенда — в нём самом; зонная копия `frontend/docs/api-contract/` ОТСТАЛА на `0.2.3` при каноне `0.16.0` (испр. оркестратором 17.09 дважды: прежде стояло `0.10.0` — разошлось на пять миноров, затем `0.15.0` — бамп `D39.262`; ⚠ версию канона брать ФАЙЛОМ — `grep '^ version:' architecture/14-api-contract/openapi.yaml`, число здесь стареет) и зеркалом сегодня НЕ является — зона заморожена, синк при разморозке; читать ТОЛЬКО канон).
- Топикальные входы (D39.126; НЕ источники истины, при конфликте побеждает D-лог): [15-money-path.md](architecture/15-money-path.md) — деньги от гранта до settle одним маршрутом · [16-events-emitter.md](architecture/16-events-emitter.md) — сборка-норматив эмиттера шва — обязательное пре-чтение перед кодом эмиттера · **[17-seam-inbound-law.md](architecture/17-seam-inbound-law.md) — закон ВХОДНОЙ двери шва движок↔платформа — РАТИФИЦИРОВАН D39.156: семь пунктов дисциплины, двери встают паками без нового решения владельца** · **[18-bank-ontology.md](architecture/18-bank-ontology.md) — онтология банка памяти (РАТИФИЦИРОВАНА D39.158): три роли носителей, единственные писатели, дисциплина проекции — обязательное пре-чтение перед кодом, трогающим банк или его артефакты** · [STACK.md](STACK.md) — карта «роль → модель → конфиг → квирки».
- Исторические, читать через ⚠-баннеры: [01-decisions.md](architecture/01-decisions.md) (Р1Р10) · [02-mvp-plan.md](architecture/02-mvp-plan.md) · [03-implementation-notes.md](architecture/03-implementation-notes.md) · [04-unhappy-paths.md](architecture/04-unhappy-paths.md) · [06-memory-risk-registry.md](architecture/06-memory-risk-registry.md).
- `experiments/` — эмпирика полигона: [00-provider-quirks.md](experiments/00-provider-quirks.md) — **читать перед любым вызовом провайдера**; [08-cost-model-v2.md](experiments/08-cost-model-v2.md) — денежная модель; [09-pilot-protocol.md](experiments/09-pilot-protocol.md) — пилот Ф2.5; остальные — отчёты экспериментов. ⚠ **У каждого отчёта статус в его ревью-шапке: читай шапку прежде тела** — она первична и говорит, ратифицированы выводы или заморожены.

View file

@ -1,4 +1,4 @@
# Реестр D-нот — карта актуальности v2 (D1D39.261; титул — носитель головы, бампать при каждом аппенде)
# Реестр D-нот — карта актуальности v2 (D1D39.262; титул — носитель головы, бампать при каждом аппенде)
> ⚠ **Колонку «тело» `counts.py --check` НЕ сторожит по устройству:** он сверяет полноту НОМЕРОВ, а не
> место тела, поэтому колонка держится дисциплиной лендинга. Не нашёл тело по колонке — иди в слайсы,
@ -320,3 +320,4 @@
| D39.259 | 17.09 | **РЕШЕНИЕ ОРКЕСТРАТОРА: ре-спрос банк-ролей ВКЛЮЧЁН в боевых конфигах** — денежная ручка сверх заказа пака, зона назвала это сама. С нулём пак инертен: движок вычисляет вердикт о негодном ответе и выбрасывает его, а способ отказа — не счёт, а БАНК (эвристический тип форсирует передачу и уезжает в каждую главу). Цена по хранилищу, сверена с леджером до цента: ступень $0.005134, худшая $0.0232, потолок на книгу ≈$0.162 при суб-бюджете $1.00; трата РАЗОВАЯ на книгу. Пин по форме соседа, значение ровно 1, печатает знаменатель 3 из 4 | ЖИВОЕ: вторая ступень НЕ измерена и потому не разрешена | жив | деньги банк |
| D39.260 | 17.09 | **РЕШЕНИЯ ВЛАДЕЛЬЦА, вторая порция:** лестница исходов при отказе роли — ре-спрос, затем фоллбек на ДРУГУЮ фронтир-модель, и лишь при отказе обоих один черновой вариант (ряд 330 и В2 закрыты; ⚠ фоллбек лечит класс с замеренной популяцией НОЛЬ, а не текущую болезнь) · род: редактору идёт КОНТЕКСТ «пол скрыт», а не директива «бери мужские» (Д-1 снят в пользу D5 п.1) · **гейт БЛОКИРУЕТ**, редакция оркестратора №15 отменена · docs/experiments/ — общая проектная зона оркестратора, ряд 359 закрыт | ЖИВОЕ: стройка фоллбека (ряды 435 · 451), правка строки языковых данных | жив | банк деньги процесс |
| D39.261 | 17.09 | **РЕШЕНИЯ ВЛАДЕЛЬЦА, третья порция:** зависимость банка от истории покупок ПРИНЯТА как цена — книга, купленная одной покупкой и десятью, законно получает разные банки (ряд Д-11 закрыт; цель воспроизводимости уточнена: тот же прогон при ТОЙ ЖЕ истории) · вынос закрытых эр журнала РАЗРЕШЁН, вычеркивание 02.09 отменено. ⛔ Условие владельца становится нормой ВСЕЙ уборки: перед переносом проверять, не лежит ли в носителе ЗАМЫСЕЛ, ещё не построенный в коде — такой кусок уезжает строкой трекера прежде, чем файл уедет в архив | ЖИВОЕ: сам вынос эр и проверка носителей на нереализованное | жив | процесс банк |
| D39.262 | 17.09 | **ПРИЁМКА ПЛАТФОРМЕННОГО ПАКА «пустой экран подписи»** — 21 путь, ряды 224 и 253 закрыты со стороны платформы (было 0, стало 69 и 66 на купленных прогонах), контракт **0.16.0** ратифицирован. ⛔ Мажор приёмки: канон объявлял `confidence` неотрицательным, а тракт согласованно слал `-1` — и лечение оказалось КЛАССОМ: гейт, читающий диапазоны ИЗ канона, покраснел на ЧЕТЫРЁХ полях; правило «число вне объявленного диапазона читается как не названо» поставлено У ШВА. Оба варианта оркестратора отклонены зоной с доводом. ⚠ Мой полный гейт красен `internal/books` (это `PD-469`, ряд 484); предъявлена пара: изолированно пакет зелен на трёх деревьях | ЖИВОЕ: ряды 479 · 353 · 484 · 486, строки регистра `PD-467`/`PD-468`/`PD-469` | жив | контракт банк платформа процесс |

View file

@ -1,4 +1,4 @@
# Журнал решений оркестратора — контракт D1D39.261 (живой файл: карта · эрраты · живые тела · голова D39.124+ (подрезка D39.139); тела закрытых эр — в слайсах `docs/archive/architecture/`, указатель ниже; реестр всех нот — `05-decisions-index.md`)
# Журнал решений оркестратора — контракт D1D39.262 (живой файл: карта · эрраты · живые тела · голова D39.124+ (подрезка D39.139); тела закрытых эр — в слайсах `docs/archive/architecture/`, указатель ниже; реестр всех нот — `05-decisions-index.md`)
> **КАРТА АКТУАЛЬНОСТИ (ревизия D31, продлена до D38.2 [12.07]; исторические записи ниже НЕ переписываются — дисциплина D23.3).** Работая с контрактом (греп номера: живой файл → слайсы, целиком НЕ читать — D39.125), держи под рукой, что чем перекрыто:
> ⚠ **Эррата 09.08 (D39.125):** D39.111 п.1 предписывал промту S3 «максимум = баланс МИНУС открытые холды» — формула ОШИБОЧНА (вычитание дважды), исправлена D39.115 п.2(а): максимум = Balance КАК ЕСТЬ; тело D39.111 — в слайсе `../archive/architecture/05-decisions-D39-106-123.md` (испр. 05.09: прежнее «живёт ниже в этом файле» протухло подрезкой D39.139) (голова D39.106+).
@ -4147,3 +4147,97 @@ bought NOTHING». ⇒ **`tmctl manifest` есть НИЖНЯЯ граница,
СТРОКОЙ ТРЕКЕРА прежде, чем файл уедет в архив. ⚠ Ровно это назвал слабым местом своей работы ревизор документации 17.09: вердикт «в архив» он ставил по шапке
и графу ссылок, а «файл с шапкой закрыт может быть единственным носителем живого факта» его прибор не видит.
## D39.262 — ПРИЁМКА ПЛАТФОРМЕННОГО ПАКА «ЧЕЛОВЕКУ ПЕРЕСТАЛИ ПОКАЗЫВАТЬ ПУСТОЙ ЭКРАН»: ряды 224 и 253 закрыты со стороны платформы, контракт 0.16.0 ратифицирован, мажор приёмки вылечен КЛАССОМ (17.09, оркестратор №23)
**1. Что принято.** Пак `PLATFORM_BANK_READOUT` (`180c41e`), сессия зоны `textmachine-main-94`, **21 путь**
(20 заявленных плюс `platform/docs/STACK_DECISIONS.md`, дописанный после гейта с доказательством инертности).
Предмет: на единственном узле продукта, где у человека спрашивают решение, платформа отдавала **ноль** при 69 и 66
предложениях, лежащих в том же файле. Теперь отдаёт их вместе с границей, на которой они сняты. Числа ДО и ПОСЛЕ
предъявлены командой на обоих купленных прогонах: `0 → 69` (A) и `0 → 66` (B).
**Ряды 224 и 253 закрыты со стороны платформы**; остатки названы и не спрятаны: ряд **479** (части ярлыка `variants`
не опубликованы, движковый долг) и ряд **353** (у `SettledByBank` нет свидетеля — движковая зона).
**2. Чем принято — исполнением, а не чтением.** Гейт зоны на замороженной копии сданного дерева, прогон оркестратора:
`MAKE_EXIT=0`, линтер `0 issues.`, `sqlc diff` чист, **ok 20 · FAIL 0 · скипов 9**. Полнота сверена СПИСКОМ:
`go list` 20 = уникальных вердиктов 20, `comm -23` пуст, контроль с настоящей жертвой даёт 1 строку, обратный
`comm -13` — 0. Сводка условий хоста печатает `TM_PLATFORM_TEST_BANK_READOUT set` ⇒ оракул холодного прогона на
настоящем купленном документе бежал ВНУТРИ батареи, а не рядом. Мутационная кампания зоны: 17 посадок — 17 RED,
выживших 0, неизмеренных 0, каждая названа своим пином.
**3. ⛔ МАЖОР ПРИЁМКИ: канон обещал клиенту диапазон, которого код не держал, — и лечение оказалось КЛАССОМ, а не
полем.** Найдено осью приёмки, пере-снято оркестратором: канон объявляет `confidence` `minimum: 0` и говорит, что
«роль не назвала» приезжает как `null`, а движок (`bankstopparse.go:64`), ридер, хранилище и проекция согласованно
шлют **`-1`** — преобразования нет ни в одном звене (`< 0` → 0 хитов при контроле `Confidence` → 3·1·1), и состояние
запинено С ОБЕИХ сторон. Компаньон обещал читателю обратное.
⛔ **Оба варианта лечения, предложенные оркестратором, ОТКЛОНЕНЫ зоной с доводом, и оба отклонения верны.**
(а) «дать канону третье состояние» — посылка «состояний три» опровергнута замером: «поля нет» для `conf`
НЕДОСТИЖИМО, поле вошло в проекцию тем же коммитом, что и секция, 0 строк из 135 без него ⇒ канон получил бы ветку,
по которой клиент никогда не пойдёт. (б) «свернуть в проекции» — отклонено ПО АДРЕСУ: шапка ридера ратифицирует
перевод каждого пересечения У ШВА, «чтобы никакой поздний путь не мог забыть перевести», и именно поздний путь забыл.
**Принято и построено:** гейт, читающий `minimum`/`maximum` ИЗ канона, покраснел на ЧЕТЫРЁХ полях — `freq`,
`spread`, `conventions`, `confidence`. У трёх из них сентинела нет вовсе: отрицательное там просто документ, которого
сборка не понимает, и публиковать его было бы той же ложью в более тихом месте. ⇒ правило: **число вне объявленного
каноном диапазона читается как «не названо»**, у шва, той же дисциплиной, что закрытые словари. **Канон не тронут — он
говорил правду, врал код.**
**Почему старый гейт этого не видел — по построению, а не по небрежности:** он читает из канона только `required`,
то есть доказывает ЧЛЕНСТВО и не спрашивает про ДИАПАЗОН. У нового напечатан собственный знаменатель и он падает,
когда канон даёт диапазон, а гейт не знает питающего поля. ⚠ Вторая его гарантия — не скипаться при отсутствии канона —
ПЕРЕНЕСЕНА у соседнего гейта, а не изобретена; зона поправила оркестратора в свою невыгоду, и это записано так,
потому что перенос воспроизводим по образцу, а изобретение — нет.
**4. Остальные находки приёмки — восемь, все вылечены в дофикс-круге, ни одна не останавливала лендинг.**
`omitempty` делал канонный `null` у полноты недостижимым, а комментарий строкой выше его обещал → `json.RawMessage`,
потому что у члена ДВЕ разные пустоты · носителей версии в компаньоне оказалось ЧЕТЫРЕ при предупреждении «их два»,
подняты были два · литерал «19 операций из 21» при замере 20 из 22 · нового ресурса не было в таблице зависимостей,
объявляющей себя единственным местом, где это ведётся · удалён доккомментарий `DecodeBank`, несший довод гарда против
замены банка книги пустотой, при §8 «больше ничего» → восстановлен дословно · «9 вызовов» при замере 8 · семь чисел
консолидации остались простыми `int` без второй половины вывода → условие конца названо · адрес довода о выборе имени
исправлен ТРИЖДЫ.
**И одна находка приёмки была ОПРОВЕРГНУТА замером зоны, что ценнее её подтверждения:** «гард канала дублирует
`nullif`» неверно — `nullif` превращает ПУСТОЕ в null, а гард превращает НЕИЗВЕСТНОЕ непустое в пустое; без гарда
движковое слово не «ложится как неназванное», а **роняет CHECK и всю запись банка**. Это был живой отказ на том самом
рубеже, где стоит человек, а не латентный перекос. Гард добавлен и на `kind`, оба запинены.
**5. `PD-469` — конфликт ДВУХ ратифицированных вещей, а не дефект пакета. Общий ряд 484.**
Рецепт `D39.159` §2 требует сажать мутации в копию дерева ⇒ всякая приёмка гонит две батареи разом; батарея зоны
содержит фикстуры, кладущие настоящую операцию Postgres внутрь суб-секундного бюджета. Измерено с двух сторон: зоной —
двумя полными батареями, запущенными в одну секунду (красны ОБА дерева, на РАЗНЫХ тестах); оркестратором — двумя
изолированными прогонами пакета в двух деревьях одновременно (`ok 76.816s` и `ok 75.761s`, оба `GOTEST_EXIT=0`).
Механизм пере-снимался ТРИЖДЫ, и все три редакции оставлены видимыми: «фикстуры сжимают 220 с до 700 мс» (верно про
другой тест, падение не объясняет) → «фикстура называет посылку `on an idle host` четырежды и не проверяет ни разу»
(верно по классу, сжимает не она) → решающая: `uploadSettle = 0` ВОЗВРАЩАЕТ продуктовые 210 с, а режет невозвращённый
`writeBudget = 20 мс` первой половины теста. ⛔ **Четвёртый красный снёс довод о нагрузке:** пакеты шли почти вровень с
зелёным прогоном ⇒ грубого замедления не требуется, хватает мгновенной заминки. **Семейство посчитано: 6 тестов из 73,
и все четыре красневших — внутри этих шести.**
**6. Класс ошибки, названный этим кругом и стоящий отдельной строки: ИСКАТЬ ВЕЩЬ ПО УГАДАННОМУ ИМЕНИ ИЛИ МЕСТУ, А
ПОТОМ ПРИНЯТЬ МОЛЧАНИЕ ПРИБОРА ЗА ЕЁ ОТСУТСТВИЕ.** Форма коварна тем, что норма «отрицательный замер обязан доказать,
что спросил существующее» ВЫПОЛНЕНА: знаменатель напечатан, предмет существует — а спрошен другой. Оркестратор дал три
таких за один заход (гейт искал не в том каталоге · свёртку по угаданным именам · греп поймал заголовок находки вместо
кода), зона — два своих (тавтологичный контроль, сравнивавший выражение само с собой; повторно применённый контроль
инертности, не спрошенный про НОВЫЙ предмет). ⇒ **лечение: спрашивать у отрицательного результата не «сколько строк я
прочёл», а «то ли я открыл» — и предъявлять не знаменатель, а НАЙДЕННОЕ СОСЕДНЕЕ, доказывающее, что смотрел в нужный
файл.** ⚠ Родственный случай той же смены: оркестратор обрезал вывод `cut -c1-200`, прочёл видимую часть длинной
строки лога и отправил зоне «0 хитов» как ФАКТ — при том что хит был. Ложный замер, посланный как факт, дороже
неточного адреса, который он поправлял.
⛔ **И ВТОРОЕ ЛИЦО КЛАССА, найденное зоной на себе через минуту после того, как класс был сформулирован: «спросил не на
том ЯЗЫКЕ, и прибор промолчал буквально».** Замер: `grep -cE 'НЕИЗМЕРЕН\|неизмерен'`**0**, `grep -cE` без
экранирования → **1**, `grep -c` с экранированием → **1**. Под `-E` экранированная палка означает ЛИТЕРАЛЬНУЮ палку,
и прибор честно искал строку, которой никто не писал; смешаны два диалекта одной командой. ⚠ Здесь знаменатель не
спасает ВОВСЕ: файл прочитан, строк много, ноль настоящий. Спасает только соседнее — глазами открытый файл, где слово
стоит на строке 15. ⇒ **ноль от грепа проверяется КОНТРОЛЬНЫМ ТОКЕНОМ, который обязан найтись в этом же файле**
(у зоны это были `restore` → 4 и `mutate` → 6). Та же смена дала и третий повод: два предыдущих нуля она получила,
грепая английскими токенами по русскому докстрингу.
**7. Что сделано тем же лендингом оркестратором:** литералы регистра платформы в журнале прогресса 108 → 111 и
466 → 469 (счёт сошёлся двумя разными приборами, дельта +3) · голова CURRENT-STATE → `D39.262` · строка РАТИФИКАЦИИ
компаньона поднята на 0.16.0 (её поднимает оркестратор, потому что она про ратификацию, а не про номер) ·
`docs/README.md` — носитель отставания зеркала.
**8. Засчитано зоне отдельно — работа над собственным ПРИБОРОМ, а не над предметом:** состав полей в промте был
неверен, и зона доказала это ПОСТРОЕНИЕМ, а не подогнала под промт (обязательность определяется `omitempty`; отсюда же
вывод, что прогон A написан другой сборкой движка) · копия под мутации отставала на три файла, и зона нашла это раньше
приёмки · таблица исходов пере-проверена прибором после находки приёмки — пропусков оказалось три, а не один ·
стенды пере-синхронизированы под сданное дерево, приборы вынесены туда, куда не дотянется чистка скретчпада.
**9. Приёмочный прогон оркестратора — ЧЕСТНАЯ ПАРА, а не зелёный целиком, и это названо.** Своего `MAKE_EXIT=0` по дереву после дофикс-круга у меня НЕТ: оба моих полных прогона вышли красными, каждый раз единственным пакетом `internal/books` и каждый раз НА ДРУГОМ тесте (`TestAnUploadThatRunsOutOfBudgetWaitingForASlot…`, затем `TestTheCutOfAnUploadIsBoundedByTheWalk…`) — оба из тех шести, что зона посчитала. Предъявляю пару: полный гейт красен ТОЛЬКО этим пакетом (`go list` 20 = вердиктов 20, `comm -23` пуст, контроль с настоящей жертвой даёт 1 строку, линтер `0 issues.`, ok 19) **плюс** тот же пакет на том же дереве ИЗОЛИРОВАННО зелен: `ok 70.563s`, `GOTEST_EXIT=0`, при нагрузке 6.4 — то есть даже не в тишине. Всего изолированных зелёных три, на трёх деревьях: до дофиксов 76.816s, чистый `HEAD` 75.761s, после дофиксов 70.563s. Зелёный ЦЕЛИКОМ снят зоной на своём прогоне и мною не воспроизведён. ⚠ И числа скипов у меня НЕТ, а не ноль: сводку условий хоста печатает цель гейта ПОСЛЕ тестов, а оба прогона до неё не дожили.

View file

@ -3,10 +3,10 @@
батч 0.3.0 — D39.138, 16.08.2026, оркестратор №17;
синк с платформой 0.4.0 — 20.08.2026, контрактная сессия; РАТИФИЦИРОВАН D39.152, 20.08.2026, оркестратор №18)
СТАТУС: РАТИФИЦИРОВАН как контракт API v0 по **0.15.0** включительно (0.15.0 — `D39.246` п.6: `resumeRun` отвечает ПРОГОНОМ о прогоне, который уже идёт, — провенанс §2.26; 0.14.0 — `D39.244`: `Blocked.code` получает `run_in_flight`, форма выведена платформенной сессией и ратифицирована ДО стройки — провенанс §2.25; 0.13.1 — `D39.235` п.6: корректирующий минор, `Run.required` требовал отставленный член) (0.11.0 — D39.208 · 0.12.0 — D39.211 · **0.13.0 — D39.221: четыре исхода `POST /v0/books` после синхронного разреза на приёме**; перечень миноров и их
СТАТУС: РАТИФИЦИРОВАН как контракт API v0 по **0.16.0** включительно (0.16.0 — `D39.262`: экран подписи перестаёт быть пустым — ридер секций предложений и консолидации, форма выведена платформенной сессией из ДВУХ купленных боевых прогонов; число вне объявленного каноном диапазона читается как «не названо» У ШВА, канон не тронут; 0.15.0 — `D39.246` п.6: `resumeRun` отвечает ПРОГОНОМ о прогоне, который уже идёт, — провенанс §2.26; 0.14.0 — `D39.244`: `Blocked.code` получает `run_in_flight`, форма выведена платформенной сессией и ратифицирована ДО стройки — провенанс §2.25; 0.13.1 — `D39.235` п.6: корректирующий минор, `Run.required` требовал отставленный член) (0.11.0 — D39.208 · 0.12.0 — D39.211 · **0.13.0 — D39.221: четыре исхода `POST /v0/books` после синхронного разреза на приёме**; перечень миноров и их
провенанс — ниже по файлу, здесь НЕ дублируется). Нормативная поверхность — openapi.yaml РЯДОМ.
⚠ Зонная копия frontend/docs/api-contract/ ВРЕМЕННО ОТСТАЁТ (0.2.3 при каноне
0.15.0 — ТРИНАДЦАТЬ миноров; ⚠ испр. 11.09: здесь стояло «0.13.0 — ОДИННАДЦАТЬ», отставшее на два бампа — 0.14.0 `D39.244` и 0.15.0 `D39.246` п.6) — ратифицировано D39.142 п.5 на время фриза фронта; синк байт-в-байт + перегенерация типов =
0.16.0 — ЧЕТЫРНАДЦАТЬ миноров; ⚠ испр. 17.09 приёмкой: здесь стояло «0.15.0 — ТРИНАДЦАТЬ» — носителей числа в шапке не ДВА, как утверждает предупреждение ниже, а ЧЕТЫРЕ, и этот бамп поднял сперва только два; ⚠ испр. 11.09: здесь стояло «0.13.0 — ОДИННАДЦАТЬ», отставшее на два бампа — 0.14.0 `D39.244` и 0.15.0 `D39.246` п.6) — ратифицировано D39.142 п.5 на время фриза фронта; синк байт-в-байт + перегенерация типов =
первое касание зоны при разморозке; после него правило прежнее: расхождение = дефект лендинга.
Генерация типов фронта после этой ратификации идёт из ЭТОЙ копии.
@ -66,9 +66,13 @@ cmp-сверка обязательна (D39.138 п.3).
> **0.15.0 (`D39.246` п.6, 11.09) — `resumeRun` о прогоне, который УЖЕ ИДЁТ, отвечает `202` с этим
> прогоном вместо `409`, с одним вырезом: прогон, который уже попросили остановить, сохраняет отказ и
> получает причину `stop_requested`. Ломающее — меняется смысл строки собственной таблицы операции, а
> в `0.x` это минор по построению. Провенанс — §2.26**.
> в `0.x` это минор по построению. Провенанс — §2.26** ·
> **0.16.0 (17.09, форма выведена платформенной сессией из ДВУХ купленных боевых прогонов) — экран
> подписи перестаёт быть пустым: ресурс `GET /books/{bookId}/bank/signing-stop` (что спросил последний
> стоп) и агрегат `BankPage.consolidation` (насколько полон подписываемый банк). Ряды 224 и 253
> бэклога, движковые половины закрыты 07.09 и 08.09. Провенанс — §2.27**.
> Дом канона — этот каталог;
> `frontend/docs/api-contract/openapi.yaml` — байт-зеркало ⚠ НА ФРИЗЕ ЗОНЫ РАВЕНСТВО ПРИОСТАНОВЛЕНО (D39.142 п.5): зеркало 0.2.3, канон **0.15.0** (⚠ число испр. 11.09 — здесь стояло 0.13.0, отставшее на два минора: 0.14.0 — `D39.244`, 0.15.0 — `D39.246` п.6; прежде, 10.09, оно же стояло 0.10.0, отставшее на два. Тот же промах третий раз подряд: число живёт в ДВУХ местах шапки, и бампающий пак правит `openapi.yaml`, а про них забывает. Шапка файла и `openapi.yaml` всё это время несли верное).
> `frontend/docs/api-contract/openapi.yaml` — байт-зеркало ⚠ НА ФРИЗЕ ЗОНЫ РАВЕНСТВО ПРИОСТАНОВЛЕНО (D39.142 п.5): зеркало 0.2.3, канон **0.16.0** (⚠ число испр. 11.09 — здесь стояло 0.13.0, отставшее на два минора: 0.14.0 — `D39.244`, 0.15.0 — `D39.246` п.6; прежде, 10.09, оно же стояло 0.10.0, отставшее на два. Тот же промах третий раз подряд: число живёт в ДВУХ местах шапки, и бампающий пак правит `openapi.yaml`, а про них забывает. Шапка файла и `openapi.yaml` всё это время несли верное).
>
> ⚠ **0.5.0 ломающий по построению (мажор `0`): снесены путь `POST …/bank/decisions` и три его
> схемы, из `BankPage` и `EventBank` сняты `pending_decisions`/`complete` (§2.19-бис), в
@ -1069,6 +1073,75 @@ $1.6, спишем по факту», и закрытие после прого
`platform/docs/ENGINEERING_STANDARDS.md` и **`platform/README.md`** (зонные — чинит зона).
**Прозаические указатели на отзыв проставлены 05.09 везде, где носитель мой**; ПОЛЯ приезжают паком.
### 2.26-бис. Две идиомы «может быть null» — различие МЕХАНИЧЕСКОЕ, смысла в нём нет
Записано 17.09 по наблюдению сплошного аудита контракта: в спеке сосуществуют `oneOf: [$ref, null]` и
`type: [x, 'null']`, примерно поровну, и читатель ищет в различии смысл, которого там не было. Смысла
нет, а правило есть, и оно одно: **`$ref` не может нести соседний `type`** (JSON Schema разрешает
рядом с `$ref` только аннотации), поэтому значение ИМЕНОВАННОЙ схемы обнуляется через `oneOf`, а
ВСТРОЕННОГО типа — списком в `type`. Обе формы означают ровно одно и то же — «значение может
отсутствовать, и `null` значит „не известно“» (§Absence of a value спеки). Выбирать надо ту, которую
требует механика, и ничего этим выбором не сообщать. Носитель наблюдения — ряд бэклога **480**.
### 2.27. Экран подписи перестаёт быть пустым (0.16.0) — ✓ форма выведена из ДВУХ купленных боевых прогонов
**Что было.** Движок один раз за прогон останавливается, чтобы человек подписал термины книги, и в этот
момент кладёт рядом с базой книги проекцию банка. Платформа читала из неё ТОЛЬКО секцию `terms`, а эта
секция на стопе пуста ПО ПОСТРОЕНИЮ: майненные строки попадают в глоссарий в ветке авто-продолжения, а
остановленный прогон до неё не доходит. Замер на двух купленных прогонах: `terms`**0** и **0** при
`proposed`**69** и **66**, и секция консолидации в обоих. То есть в единственной точке продукта, где
у человека спрашивают решение, ему отдавали ноль — а вопрос лежал в том же файле непрочитанным. Ряды
бэклога **224** (движковая половина закрыта 07.09) и **253** (08.09).
**Почему ДВА разных места, а не одно.** Первая редакция формы клала обе секции на `BankPage` — довод был
«обе описывают один момент стопа». Довод **опровергнут кодом движка**: его результат терминологии
ставится ДО развилки «стоп / продолжаем» и переживает её, поэтому секция полноты едет на ТРЁХ границах
прогона из пяти, а секция вопросов — ровно на одной. Оси свежести разные, и один ответ не может быть
честен про обе: список появляется и исчезает вместе с ПРОГОНОМ, пока ревизия банка не двигается.
⇒ полнота — агрегат `BankPage` (буквой ряда 253: «поле на `BankPage`»), вопросы стопа — свой ресурс.
⚠ Третьим членом «только первой страницы» шапка спеки НЕ правится: её исключение названо КЛАССОМ
(«the aggregates of `BankPage`»), а не перечнем.
**Имя: почему не `proposed` и не `suggestions`.** Слово `proposed` в каноне уже значит СТАТУС строки
(`TermStatus`), а в проекции движка — имя секции; третий смысл не заводится, существующий не
переименовывается, наружу слово не идёт вовсе. `suggestions` отвергнуто по существу: «suggestion» —
то, что можно проигнорировать, а здесь **игнор есть принятие** (движок: «whatever you leave undecided is
used on the wire like any signed row»). Взято слово, которым канон УЖЕ называет этот предмет:
`BankSignatureCount` описан как «the count against the surfaces the LAST signing stop **offered**».
**Идентичности у предложения НЕТ, и она не выдумана.** Ни в одном из 135 объектов двух купленных
прогонов у строки нет `id`; выдумать его на стороне платформы значило бы пере-реализовать закон движка.
Следствия названы прозой операции, а не подразумеваются: ресурс отдаётся ЦЕЛИКОМ, без курсора и без
дельта-чтения (дельте нечего называть), а ДЕЙСТВИЕ адресуется уже ратифицированной кортежной формой
`BankCorrection` — «for a surface the bank does not list, send `sense: ""` and `null` windows».
**`unreadable` — не новая форма, а перенос ратифицированной.** Флаг взят у `BankSignatureCount` вместе с
его обоснованием: без него «не смог прочесть» байт-идентично «стоп не спросил ничего» — то единственное,
чего эта поверхность не должна сказать случайно. Он истинен по трём причинам сразу (материализации ещё
не было · документ не несёт секции · документ снят на границе, которая её нести не может), и они не
разделяются, потому что лекарство одно. ⚠ Третья причина — не формальность: секция, стоящая на границе,
которая её не несёт, означает документ, который эта сборка не понимает, и отдать её строки значило бы
нарисовать стоп, которого не держит ни один прогон.
**Числа могут быть `null`, и это НЕ ноль — замерено.** У четырёх чисел предложения (`freq` · `spread` ·
`conventions` · `confidence`) ноль есть законный ответ движка, а отсутствие означает «документ писала
сборка без этого поля». Улика: `conventions` отсутствует у ВСЕХ 69 строк прогона A и стоит у ВСЕХ 66
строк прогона B — поле без `omitempty` сериализатор опустить не может, значит A написан сборкой до
`0997e41`. Сборка, читающая это нулём, показала бы человеку 69 терминов, чьи варианты «единогласны», на
листе, весь смысл которого — разногласие. Отдельно `confidence`: движок пишет ОТРИЦАТЕЛЬНОЕ для «роль не
назвала уверенности», и наружу это идёт как `null`, потому что названный `0 %` — первая строка для
просмотра, а молчание — не строка вовсе.
**Что НЕ обещано намеренно.** (1) Длина списка не равна `BankSignatureCount.surfaces`: замер на обоих
прогонах дал совпадение (69/69 и 66/66, разность множеств 0 в обе стороны), но механизм расхождения
реален — реверс-секция капается и при выключенной роли отсутствует, — поэтому равенство в контракте не
обещано и «N из M» между ними запрещено прозой. (2) Пустой `dst` не значит «служба не смогла перевести»:
на печатном листе движка этот же символ несёт четыре разных факта, и один из них («банк уже отдаёт эту
поверхность, решать нечего») движок различает у себя и в проекцию намеренно не кладёт. (3) `variants`
ДИСПЛЕЙНЫЙ текст, а не данные: части ярлыка движок не публикует, разбирать его на своей стороне нельзя
(парсер живёт рядом с писателем не случайно), и из-за этого клиент не может отрисовать его на языке
читателя — движковый долг, заведён рядом **479**.
## 3. Зависимости: чтение → источник → строка бэклога
**Правило, введённое 0.3.0 (Б-21): предупреждение о недостроенном ОБЯЗАНО нести номер строки
@ -1084,11 +1157,13 @@ $1.6, спишем по факту», и закрытие после прого
| `POST /runs/{id}/resume` | реконсилятор | ПОСТРОЕНО, но **0.3.0 сменил поведение на паузе по потолку, а 0.4.0 — на ИСЧЕРПАННОМ прогоне**. Пауза: платформа уже отвечает `409` `ceiling_reached` (`runs/reconcile.go`, ветка `case "paused"` в `Resume`) — совпало. Исчерпанность: ⚠ **РАСХОЖДЕНИЕ ЗАКРЫТО (испр. 02.09)**`Resume` больше не отвечает `202` на исчерпанный прогон. Реконсилятор различает ДВА вердикта, и оба доезжают до клиента кодом 409 `run_not_resumable`: `ceilingSpent``cause.code: ceiling_reached` и `creditUnavailable``cause.code: credit_unavailable` (`platform/internal/runs/reconcile.go`, греп `ceilingSpent`; `platform/internal/httpapi/v0.go`, греп `run_not_resumable`). Почему молчаливый `202` был дефектом — §6в A. Полная таблица по статусам — в описании `resumeRun` | **вход P7** ⚠ ИЗРАСХОДОВАН (D39.153) (правка построенного пути, не только читающей поверхности) |
| `GET /usage` | кредиты | ПОСТРОЕНО, **не читается ни одним экраном** | зона фронта |
| `GET /capabilities` | конфигурация деплоя | **ПОСТРОЕНО P7** — маршрут смонтирован безусловно (`platform/internal/httpapi/v0.go`, таблица `contractSurface`), хендлер `capabilities.go`; версия контракта запинена ГЕЙТОМ против ЭТОГО канона (`platform/internal/gates/contract_test.go`) | закрыто D39.153 |
| `DELETE /books/{id}`, `GET /runs/{id}` | колонки есть | НЕ ПОСТРОЕНО (заведено 0.3.0) — роутер монтирует **19 операций из 21** ⚠ (испр. 05.09: `PATCH /books/{id}` СМОНТИРОВАН платформенным паком `7e2226a`, акт D39.201; счёт 18→19) (испр. 05.09: дверь выдачи построена `adf5e53`, прежнее «15 из 20» протухло) | ⚠ носитель «вход P7» ИЗРАСХОДОВАН (пак P7 принят D39.153); живой носитель — `platform/BACKLOG.md` П-17 и очередь CURRENT-STATE |
| `DELETE /books/{id}`, `GET /runs/{id}` | колонки есть | НЕ ПОСТРОЕНО (заведено 0.3.0) — роутер монтирует **20 операций из 22** (⚠ испр. 17.09: было «19 из 21»; минор 0.16.0 добавил и операцию, и её маршрут, так что двинулись ОБА числа. Пере-снято: операций в каноне 22, строк в `contractSurface` 20, без маршрута остаются ровно те две, что названы слева) ⚠ (испр. 05.09: `PATCH /books/{id}` СМОНТИРОВАН платформенным паком `7e2226a`, акт D39.201; счёт 18→19) (испр. 05.09: дверь выдачи построена `adf5e53`, прежнее «15 из 20» протухло) | ⚠ носитель «вход P7» ИЗРАСХОДОВАН (пак P7 принят D39.153); живой носитель — `platform/BACKLOG.md` П-17 и очередь CURRENT-STATE |
| `GET /books/{id}/chapters`, `/units` | материализация манифеста | **ПОСТРОЕНО P7**`httpapi/reading.go`, материализатор `internal/readmodel` | закрыто D39.153 |
| `GET /books/{id}/notes` | `unit_done` несёт флаг и причину (`runevents.go:126-135`), платформа хранит (`sink.go:227-233`), колонка `notes.reason` заведена под это | канал ЕСТЬ; не хватает карты «причина → код → фраза» (приложение А) и проекции | приложение А + ⚠ носитель «вход P7» ИЗРАСХОДОВАН (D39.153) |
| `GET /books/{id}/bank` | движок пишет сайдкар всего банка (`pipeline/bankexport.go:16-33,72`=`the bank lives in the engine`, D39.122) | ⚠ пере-снято 0.5.0: проекция платформы ПОСТРОЕНА (P7 — маршрут в `contractSurface`, `wireBankPage` в `httpapi/reading.go`, `SaveBank` в `pgstore`); форма синхронна канону с лендингом P9: счётчики `pending_decisions`/`complete` сняты с проекции (`PD-399`) | закрыто D39.162 |
| ~~`POST /bank/decisions`~~ | стоп-механика майнера | ⚠ **НЕ «не построено», а ОТМЕНЕНО**: было построено P7 и СНЯТО 22.08 вместе с пер-термной моделью подписи (D39.144, слово владельца). **0.5.0 снёс и канон-половину — `PD-370` закрыт этим минором**; преемник — строка `POST …/bank/corrections` ниже | отменено D39.144; снесено 0.5.0 |
| `GET /books/{bookId}/bank/signing-stop` | движок публикует секцию `proposed` того же сайдкара на ГРАНИЦЕ подписи и ни на какой другой (`pipeline/bankexport.go`, греп `Empty at every boundary that is not a stop`); движковая половина закрыта 07.09 | **ПОСТРОЕНО 0.16.0** — ридер секции (`platform/internal/ingest/bank.go`), хранилище (`migrations/00036_bank_read_out.sql` + `bank_offered_terms`), ручка `readBankSigningStop` (`httpapi/reading.go`). Отдаётся ЦЕЛИКОМ, без курсора и дельта-чтения: у предложения нет идентичности ни в одном из 135 замеренных объектов, и дельте нечего назвать. Решение по строке — существующая кортежная форма `BankCorrection` | строка бэклога **224**, платформенная половина |
| `BankPage.consolidation` | движок публикует полноту консолидации секцией того же сайдкара, и ОТСУТСТВИЕ её, а не ноль, означает «никто не мерил» | **ПОСТРОЕНО 0.16.0** — агрегат первой страницы; бит `complete` берётся у движка ГОТОВЫМ и на этой стороне не выводится (ратифицированное требование ряда) | строка бэклога **253**, половина (б) |
| `POST /books/{bookId}/bank/corrections` | `tmctl bank-apply` — движковая половина ПОСТРОЕНА (D39.158, лендинг `d1eb8a9`) | **ПОСТРОЕНО пакетом P9**: перевод словаря (`platform/internal/ingest/bankdecisions.go`), спавн глагола (`runner/bankapply.go`), раскладка отказов и пер-книжная сериализация (`runs/bank.go`), дверь синхронная. Флаг `bank_corrections_enabled` следует включённости прогонов (`cmd/tmplatformd/runner.go:195`) — дверь спавнит тот же глагол | закрыто D39.162 |
| `GET /books/{id}/events` (SSE) | эмиттер шва построен (D39.131) | **ПОСТРОЕНО P7**`httpapi/stream.go`, поток регистрируется ВНЕ слоя сжатия (сжатие буферизует поток — единственное, что канон запрещает этому маршруту) | закрыто D39.153 |
| `POST`/`GET /exports` | движок: `tmctl build [--format epub,txt] [--out path] [--partial]` — EPUB 3 + чистый txt (`backend/internal/bookfile`), конверт `tm-build-v1`, пути в `StatusArtifacts.book_files`, отказ exit 16 `book_incomplete` | ⚠ **ПЕРЕ-СНЯТО 05.09: ОБЕ ПОЛОВИНЫ ПОСТРОЕНЫ.** Движковая — D39.175 (`8adcb86`); ПЛАТФОРМЕННАЯ дверь заленджена `adf5e53` (D39.194) и предъявлена живьём платным EPUB через API. Прежнее «открыта только дверь платформы» протухло на сутки. Дверь СТРОИТ через `tmctl build` (не читать файл у БД: там копия прежней сборки) и сверять `BuildReport` (`config_drift`/`stale_unknown`). ⚠ Это про ФАЙЛ КНИГИ; редакторский `tmctl export`/annot-v1 (D29.1а) — ОТДЕЛЬНАЯ, не закрытая работа | движковая половина — D39.175; дверь — пинг зоне платформы (её журнал, греп `Движок отдаёт книгу файлом`); annot-v1 — строка 49 / D29.1 |

View file

@ -2,7 +2,7 @@ openapi: 3.1.0
info:
title: TextMachine API
version: 0.15.0
version: 0.16.0
summary: Ratified contract between the frontend and the TextMachine platform.
description: |
**RATIFIED contract.** Canonical copy: `docs/architecture/14-api-contract/`;
@ -507,6 +507,52 @@ paths:
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
/books/{bookId}/bank/signing-stop:
parameters:
- $ref: '#/components/parameters/BookId'
get:
tags: [bank]
operationId: readBankSigningStop
summary: What the last signing stop asked about.
description: |
The terms the LAST signing stop offered — surfaces the service found and rendered that are
NOT in the bank yet, in the order the stop ranked them. This is the one screen in the product
where the user is asked for a decision, and this operation is what fills it.
**It does NOT say whether a stop is standing.** That is `Run.status` — `awaiting_bank` — and
the past tense here is deliberate: this is the same population the correction receipt counts
against ("the surfaces the LAST signing stop offered", `BankSignatureCount`). A client reads
both and shows the terms when a stop is standing.
**Read whole: no cursor, no delta.** A term offered here has no id, and that is not an
omission this contract will fill later — the service publishes none, and the ORDER is the
information (which row to read first). With no identity there is nothing for a delta to name,
so the list is answered entire and a client replaces what it holds.
**Deciding one of these terms is a correction** (`POST /books/{bookId}/bank/corrections`) in
its TUPLE form: a term the bank does not list is named by `src` with `sense: ""` and `null`
windows, and approving it ADDS it. Nothing else is needed and nothing else works — there is
no id to send.
**Silence is not refusal.** A term left undecided is used in the translation exactly as a
signed one; signing is ONE act over the whole bank (`resumeRun`). A client MUST NOT present
this list as optional extras: what is not corrected here is what the book will say.
**The count on the correction receipt is not the length of this list.** The two are measured
from different documents of the same moment and can legitimately differ, so a client MUST NOT
derive one from the other or show them against each other as "N of M".
responses:
'200':
description: What the last signing stop asked about.
headers:
ETag: { $ref: '#/components/headers/ETag' }
content:
application/json:
schema: { $ref: '#/components/schemas/BankSigningStop' }
'304': { $ref: '#/components/responses/NotModified' }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
/books/{bookId}/bank/corrections:
parameters:
- $ref: '#/components/parameters/BookId'
@ -2341,10 +2387,209 @@ components:
corrected and NOT signed, because declining is also a correction. How many surfaces
still await a word is not derivable from these rows at all — see the note on the
operation.
consolidation:
oneOf:
- $ref: '#/components/schemas/BankConsolidation'
- type: 'null'
description: |
How complete the bank in this response is — a whole-bank aggregate, so it rides the
first page with the counts above. **`null` means nothing has measured it, which is
NOT "it is complete"**: the pass that measures completeness runs at one point of a
translation, and a bank read before or apart from it has no such measurement. A
client MUST NOT read `null` as a whole bank.
terms:
type: array
items: { $ref: '#/components/schemas/BankTerm' }
OfferedTermChannel:
type: string
description: |
How the service came to know the surface — the axis a person judges corroboration by.
- `source_text` — it was found in the book's own text;
- `translated_text` — a translated passage named it and the source text did not yield it;
- `both` — both, the strongest case a row can carry;
- `alias` — it names something the service already tracks under another term.
enum: [source_text, translated_text, both, alias]
OfferedTerm:
type: object
description: |
One term the signing stop is asking about. It is NOT a `BankTerm`: it is not in the bank, it
has no id, and it is addressed for correction by its tuple — see the operation.
⚠ **Every number here can be `null`, and `null` is never zero.** The service publishes these
figures only in the form the build that produced the read-out knew, so a deployment reading
an older read-out gets fewer of them — measured on real runs, not hypothetical. Zero is a
real answer for each, so a client MUST render "not stated" and MUST NOT substitute 0 or drop
the row.
required:
[src, dst, kind, channel, freq, spread, conventions, confidence, invented, contradicts,
bank_holds, variants]
properties:
src:
type: string
description: The surface as it stands in the book's own language.
dst:
type: string
description: |
The rendering the service arrived at; **the empty string means this read-out carries
none**, and it does not say why. A client MUST NOT render it as "the service could not
translate this": several different facts share that emptiness and the read-out does not
separate them.
kind:
oneOf:
- $ref: '#/components/schemas/TermKind'
- type: 'null'
description: |
`null` when the kind was not decided — the same state and the same obligation as
`BankTerm.kind`: show it as "kind not decided", never drop the row, never invent one.
channel:
oneOf:
- $ref: '#/components/schemas/OfferedTermChannel'
- type: 'null'
description: |
`null` when this deployment cannot name the channel — a read-out naming one this contract
does not carry. Never guessed: invented corroboration is the error reading further cannot
undo.
freq:
type: [integer, 'null']
minimum: 0
description: |
How often the surface occurs in the book's own text. **Zero is a real answer** — the
surface was named only by a translated passage — and therefore not the same as `null`.
spread:
type: [integer, 'null']
minimum: 0
description: |
How many DIFFERENT renderings the book's translated passages produced for this surface.
The disagreement signal: high means the book is calling one thing several things.
conventions:
type: [integer, 'null']
minimum: 0
description: |
How many of those are genuinely different DECISIONS, once renderings differing only in
form fold together. Published beside `spread` rather than left to be derived, because the
two answer different questions and comparing one against the length of `variants` has
already been mistaken for a defect.
confidence:
type: [integer, 'null']
minimum: 0
maximum: 100
description: |
The service's own stated confidence in `dst`, as a percentage. **`null` means it stated
none, which is not zero**: a stated 0 is the first row a person should look at, and no
statement at all is not a row about confidence. It orders a review list and decides
nothing.
invented:
type: boolean
description: |
`true` — the rendering in `dst` is NOT one the book's translated passages proposed; the
service arrived at it from the whole book. Legitimate, and **the class to read first**: a
name the service composed is what a person most needs to see before it becomes canon.
contradicts:
type: array
items: { type: string }
description: |
Other renderings decided in the same pass that this one breaks, each named by the surface
and the rendering it carries. Empty is the ordinary case.
bank_holds:
type: array
items: { type: string }
description: |
Rows the bank ALREADY holds for this surface with a different rendering. Separate from
`contradicts` because "the service disagreed with itself" and "the book already calls it
something else" are different decisions for the person signing.
variants:
type: array
items: { type: string }
description: |
The renderings the book's translated passages produced, best first — each one line the
service composed: the rendering, how many passages used it, and the surface it was
proposed for where that differs. **Display text, not data**: the parts are not published
separately, so a client shows the line and does not take it apart.
BankSigningStop:
type: object
description: |
What the last signing stop asked about — see the operation for how it is read and acted on.
required: [unreadable, offered]
properties:
unreadable:
type: boolean
description: |
`true` — `offered` means NOTHING for this book: what the stop is asking cannot be told
from what this deployment has read. Without this flag "could not read it" would be
byte-identical to "the stop asked nothing", and a client would tell a person there is
nothing to decide at the one moment the product stops to ask them.
One flag for several causes on purpose, because the remedy is one: look again later, and
if a stop is standing while this stays `true`, this deployment cannot serve the screen. A
client MUST NOT present an empty list as an answer while this is `true`.
offered:
type: array
items: { $ref: '#/components/schemas/OfferedTerm' }
description: |
The terms, in the stop's own ranking — the order IS information and a client MUST NOT
re-sort it as its primary order. Empty with `unreadable: false` means the stop asked
nothing; empty with `unreadable: true` means nothing is known.
BankConsolidation:
type: object
description: |
How complete the bank being signed actually IS. It exists because a person can otherwise sign
as whole a bank the service itself calls partial: the pass that renders terms runs on a
budget, and where the budget ran out the bank is missing renderings nobody was told about.
⚠ **Six numbers and not one, because three facts look alike and are not.** A cut RENDER pass
leaves the BANK partial. A cut CLASSIFY pass leaves the bank whole and the term KINDS
unrefined — a different remedy, and reading it as an incomplete bank is a false alarm. And
terms nobody was asked about, because the book already renders them, are a saving and not a
gap.
required:
[complete, render_batches_dropped, classify_batches_dropped, consolidated, declined,
unanswered, never_asked]
properties:
complete:
type: boolean
description: |
Whether every batch of renderings the pass planned was actually bought. `false` means the
bank being signed is missing renderings. It is the SERVICE's own answer, reported as
given: a client MUST NOT compute it from the numbers below, which do not determine it.
render_batches_dropped:
type: integer
minimum: 0
description: What makes `complete` false — batches of renderings the budget did not reach.
classify_batches_dropped:
type: integer
minimum: 0
description: |
The OTHER pass's cut, on its own budget: the one that refines term kinds. Never added to
the number above — different budgets, different remedies.
consolidated:
type: integer
minimum: 0
description: Terms that came back with a rendering.
declined:
type: integer
minimum: 0
description: Terms the service explicitly could not render.
unanswered:
type: integer
minimum: 0
description: |
⚠ **Two facts in one number, and the service says so.** Where `complete` is `true` this is
silence — terms the pass was asked about and did not answer. Where `complete` is `false`,
part of it is terms never asked because the budget stopped first. A client MUST NOT
present it as "the service had nothing to say" without reading `complete`.
never_asked:
type: integer
minimum: 0
description: |
The opposite of a gap: terms nobody had to render, because the bank already carries the
surface and every translated passage agreed with it. A client MUST NOT add this to the
numbers above.
BankCorrection:
type: object
additionalProperties: false

File diff suppressed because one or more lines are too long

View file

@ -772,6 +772,15 @@ export TM_PLATFORM_TEST_DSN='postgres://postgres@/postgres?host=/tmp&port=55432&
/sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/tm-runs.slice/cgroup.subtree_control`.
- **Демон нельзя убивать `pkill -f <путь к бинарю>`** — шаблон совпадает с собственной командной
строкой оболочки, и она убивает сама себя (exit 144). Убивать по PID.
- ⛔ **TCP-ПРОБА СТЕНДА ДАЁТ `Connection refused` НА ЖИВОМ СЕРВЕРЕ, И ЭТО НЕ ПОЛОМКА, А ЕГО УСТРОЙСТВО.**
Рецепт выше поднимает Postgres с `-c listen_addresses=''`, то есть он слушает ТОЛЬКО Unix-сокет.
Проба `127.0.0.1:55433` отвечает отказом всегда, и «порт закрыт» неотличимо в её выводе от
«сервер намеренно не слушает TCP» — вопрос задан не тому предмету. Замер 17.09, обе стороны в один
момент: `/dev/tcp/127.0.0.1/55433``Connection refused`, при этом `/tmp/.s.PGSQL.55433` на месте и
`psql 'postgres://postgres@/postgres?host=/tmp&port=55433&sslmode=disable' -c 'select 1'``1`.
**живость стенда проверяется тем же DSN, которым его читают тесты**, либо `pg_ctl … status`, а
не портом. Цена ошибки названа тем, кто её совершил: объявив стенд недоступным, приёмка пере-сняла бы
4 PASS как 4 SKIP и сравнила бы две стороны РАЗНЫМИ приборами.
- **Postgres стенда переживает не всё:** сокет живёт в `/tmp`, и уборка `/tmp` (или smart shutdown)
роняет соединение — `pg_ctl … status` скажет «no server running». Поднимать заново по разделу выше.
- **Замер страницы библиотеки** (когда трогаете проекции книги): `go test ./internal/pgstore/

View file

@ -3,6 +3,580 @@
> **Что это.** Состояние зоны и её живые остатки. Обратно-хронологический: свежее выше.
> Отработавшие эры вынесены срезами в [`archive/`](archive/) — читать только по конкретной ссылке.
## ОТЧЁТ 17.09 — ЧЕЛОВЕКУ ПЕРЕСТАЛИ ПОКАЗЫВАТЬ ПУСТОЙ ЭКРАН (ряды 224 · 253, контрактный минор 0.16.0)
Пак `docs/PLATFORM_BANK_READOUT_SESSION_PROMPT.md` (180c41e), сессия `textmachine-main-94`. НЕ коммичу,
дерево передаю оркестратору. Записка-план — ниже этим же блоком, она писалась ДО первой правки кода и
намеренно не переписана задним числом: два её решения я потом ПЕРЕМЕНИЛА, и это видно.
### 1. Числа ДО и ПОСЛЕ — командой, на обоих купленных файлах
Собственный контроль ряда **224** (он в теле ряда: «`json:"proposed` в `platform/` — 0 хитов»):
```
git grep -l 'json:"proposed' HEAD -- 'platform/**/*.go' → 0 файлов
grep -rn 'json:"proposed' platform/ --include=*.go → 1 (internal/ingest/bank.go:184)
```
Собственный контроль ряда **253** («`grep -rn 'consolidat\|unanswered\|batches_dropped' platform/internal/
--include=*.go` → 0»). ⚠ **Пере-снят и на HEAD — там НЕ 0, а 3**, и это первая мелкая находка отчёта:
```
git grep -l '…' HEAD -- 'platform/internal/**/*.go' → 3 файла (manifest.go · pricing.go · sweep_test.go)
grep -rln '…' platform/internal/ --include=*.go → 10 файлов, из них 7 мои
контроль, что прибор читал населённое дерево: 194 файла при HEAD, 198 сейчас
```
Три хита на HEAD — прозаические упоминания в комментариях, читателей среди них нет, так что СУТЬ ряда
верна. Неверно ЧИСЛО, напечатанное рядом с нулём: команда ряда даёт 3, а не 0. Класс знакомый — контроль
у нуля обязан быть воспроизводимым той командой, которая рядом написана.
Исполнением, на самих купленных проекциях (оракул `TestARealReadOutIsReadTheWayThisBuildClaims`, копии
на чтение, `sha256` совпал, `mtime` оригиналов не тронут):
```
прогон A: boundary=signature_requested offered=69 terms=0 consolidation=true
прогон B: boundary=signature_requested offered=66 terms=0 consolidation=true
```
**Было 0, стало 69 и 66.** `terms=0` осталось нулём — и это правда движка на стопе, а не дефект.
### 2. Что построено
| Слой | Что | Файл |
|---|---|---|
| Ридер | две секции + якорь свежести (`as_of`, `run_id`), которого ридер не читал вовсе | `internal/ingest/bank.go` |
| Хранилище | миграция `00036`: `bank_read_out` (граница · идентичность прогона · ДВА флага наличия) + `bank_offered_terms` | `internal/pgstore/migrations/00036_bank_read_out.sql` |
| Шов | `SaveBank` берёт ВЕСЬ развёрток, а не его строки; одна транзакция | `internal/pgstore/readmodel.go`, `internal/readmodel/readmodel.go` |
| Провод | ресурс `GET /books/{bookId}/bank/signing-stop` + агрегат `BankPage.consolidation` | `internal/httpapi/{v0,reading,project,capabilities}.go` |
| Контракт | минор **0.16.0** + компаньон: §2.27 (провенанс минора) и §2.26-бис (две идиомы «может быть null», по наблюдению сплошного аудита оркестратора, ряд 480). ⚠ испр. 17.09: здесь стояла ОДНА секция вместо двух | `docs/architecture/14-api-contract/{openapi.yaml,README.md}` |
### 3. Исход КАЖДОГО пункта заказа
⚠ Сверено механически, а не глазами: заголовки пака (`^## N.` и `^### N.N.`) против строк этой таблицы.
Заказов с §4 и дальше — **14**, строк здесь **15** (у §4.1 и §4.3 по две: у каждого две разные обязанности).
⚠ Первую редакцию таблицы поймала приёмка: в ней не было строки под §9, и тем же прибором нашлись ещё
две — §11 и §12. Довод «пункт исполнен, не хватает лишь исхода в таблице» верен, но таблица, объявляющая
себя полной по КАЖДОМУ пункту, и есть носитель этой полноты: три пропуска в ней — это три места, где
читатель не смог бы отличить «сделано» от «не рассматривалось».
| Пункт пака | Исход |
|---|---|
| §4.1 прочитать `proposed` и `consolidation`, поля снять ЗАМЕРОМ | **сделано**, и замер дал находку — см. §4 |
| §4.1 не потерять `invented` | **сделано**; пин `TestTheClassAPersonReadsFirstSurvivesTheReadOut`, и поле доезжает до провода |
| §4.2 перенести КЛАСС гарантий ридера | **сделано**: «секции нет» ≠ «секция пуста» (указатели на всех трёх ярусах), «поля нет» ≠ «ноль» (четыре числа — указатели), граница читается ВМЕСТЕ с секцией и является УСЛОВИЕМ выдачи |
| §4.3 контрактный минор, форму решаю сама | **сделано**, форма ПЕРЕМЕНЕНА относительно записки — довод в §5 |
| §4.3 развилка идентичности предложения | **решено без пинга**: контракт уже отвечает — см. §5 |
| §4.4 хранилище назвать явно | **в БД**, миграция часть пака; довод и доказательство, что дельта и ревизия не ломаются — §6 |
| §4.5 не строить UI, не трогать движок, не подпирать заплаткой | **соблюдено**; рефакторинг — §8 |
| §5 самопроверка исполнением + адверсариальный проход | **сделано**: гейт зоны целиком, мутационная кампания, один старший советчик, чьи посылки я проверила и одну опровергла |
| §6 три оси ревью | **§9** |
| §7 купленное сырьё только на чтение | **соблюдено**: работала с копиями, база рядом с B не открывалась вовсе |
| §8 записка-план до кода | **сделано**, лежит ниже |
| §9 эхо-протокол старта: десять строк своими словами ДО работы | **сделано первым действием** — блок в `/tmp/textmachine-channel` и письмо оркестратору до первой правки: скоуп, инварианты, чего не делаю |
| §10 что не удалось и где прибор слеп | **§10** |
| §11 канал вопросов и право отказа | **использован**: восемь писем оркестратору по ходу пака, два из них — улики, менявшие работу (состав полей, смена формы). Правом отказа не пользовалась: предмета для «этого делать не надо» не нашлось |
| §12 критерий завершённости | **§12**, по пунктам |
### 4. ⚠ ГЛАВНАЯ НАХОДКА ЗАМЕРА: состав полей в промте был неверен, и это не мелочь
Промт давал контроль «восемь обязательных (`src dst kind channel freq spread conf variants`) и четыре
опциональных (`conventions invented contradicts bank_holds`)». Снято с писателя
(`backend/internal/pipeline/bankexport.go:125-160`) и с обоих файлов: **счёт 8+4 верен, состав — нет.**
Обязательность определяется наличием `omitempty` у писателя, и по нему обязательны
`src dst kind channel freq spread` **`conventions`** `conf`, а опциональны
`invented contradicts bank_holds` **`variants`**.
**И объяснение «в A поля нет ни у одной строки» через опциональность — ЛОЖНОЕ.** У `conventions` нет
`omitempty`, значит сериализатор опустить его не мог ⇒ **прогон A написан сборкой движка до `0997e41`
(11.09), добавившего поле.** Доказательство построением, а не совпадением двух снимков.
Следствие несущее и оно в коде: **у обязательного числового поля «поля нет» ≠ «ноль»**, потому что ноль
у каждого из четырёх — собственный ответ движка. `freq: 0` — «поверхность видела только черновая
сторона» (`terminology.Candidate.Freq`); `conf: 0` — «роль сказала 0 %», по её же комментарию самая
важная строка листа; отрицательный `conf` — «роль не сказала ничего». Сборка, читающая отсутствие нулём,
показала бы человеку 69 терминов, чьи варианты «единогласны», на листе, весь смысл которого —
разногласие.
### 5. Две развилки, где я ПЕРЕДУМАЛА после записки — с доводами
**(а) Форма. Записка обещала обе секции на `BankPage`. Довод записки оказался ложным по коду.**
Я писала «обе описывают ОДИН момент стопа»; это опроверг мой советчик, и я проверила деревом:
`r.lastTerminology` ставится в `backend/internal/pipeline/mining.go:171` — ДО развилки «стоп/не стоп» —
и живёт в Runner до конца процесса ⇒ секция полноты едет на ТРЁХ границах из пяти, а секция вопросов —
ровно на одной («Empty at every boundary that is not a stop»). Оси свежести разные уже у движка.
**полнота — агрегат `BankPage`** (буква ряда 253: «поле на `BankPage`», и по сути это свойство банка),
**вопросы стопа — свой ресурс**, потому что они появляются и исчезают вместе с ПРОГОНОМ, пока ревизия
банка не двигается.
⚠ Проверка советчика поймала его перегиб: он утверждал, что третий член «только первой страницы» требует
правки шапки спеки про «Two exceptions». Шапка называет КЛАСС («the aggregates of `BankPage`»), а не
перечень ⇒ новый агрегат входит в существующее исключение, шапка не тронута.
**(б) Имя. Записка обещала `suggestions`. Отвергла по существу.** «Suggestion» — то, что можно
проигнорировать, а здесь **игнор есть ПРИНЯТИЕ**. ⚠ Адрес довода испр. 17.09 приёмкой, и дважды: я
писала `mining.go:281`, настоящая строка — `:280` (внутри многострочного `WarnContext`), а **сильнейший
носитель фразы вообще не там** — `backend/cmd/tmctl/render.go:225-227`: «whatever you leave undecided
still goes to the model, as part of the same law block as the terms you signed (D39.104: the bank on the
wire is law for every row)». Это операторский ВЫВОД — текст, который человек читает в момент подписи, —
и он ссылается на ратифицированное `D39.104`, то есть довод от поправки стал крепче, а не слабее.
⚠ И поправка к поправке: приёмка заявила «`leave undecided` в `mining.go` — 0 хитов»; пере-снято мной —
**1 хит** (`:280`; контроль: `bank` в том же файле — 114). Носителей фразы в движке три. Взято слово, которым канон УЖЕ называет этот предмет: `BankSignatureCount`
описан как «the count against the surfaces the LAST signing stop **offered**» ⇒ `offered[]` / `OfferedTerm`
/ `readBankSigningStop`. Слово `proposed` наружу не идёт ВОВСЕ, третьего смысла нет, существующий
`TermStatus.proposed` не тронут.
**⭐ И там же нашлась ратифицированная ФОРМА моей гарантии — я её не изобретала.** У
`BankSignatureCount` есть `unreadable` с ровно моим обоснованием: «`true` — the two numbers mean NOTHING…
Without this flag, „could not count“ would be byte-identical to „nothing left undecided“ — the one thing
this schema must never say by accident». Взяла буквой. `unreadable: true` истинен по трём причинам сразу
(материализации ещё не было · документ не несёт секции · документ снят на границе, которая её нести не
может) — они не разделяются, потому что лекарство одно.
**Вот где граница у меня НЕСУЩАЯ, а не прочитанная и выброшенная:** секция, стоящая на границе, которая
её не несёт, — документ, который эта сборка не понимает, и отдать его строки значило бы нарисовать стоп,
которого не держит ни один прогон. Это условие выдачи, и его стережёт посадка.
### 6. Идентичность предложения — закрыто контрактом, пинга не потребовалось
`id` нет ни в одном из **135** объектов; выдумывать запрещено. Развилка разбивается надвое, и обе
половины закрываются без выдумки:
- **чтение:** дельта над списком не выразима ПО ПОСТРОЕНИЮ (нечего называть), ресурс отдаётся целиком,
без курсора. Написано прозой операции, а не подразумевается;
- **действие:** контракт УЖЕ говорит, чем адресуется поверхность, которой в банке нет — кортежная форма
`BankCorrection`: «for a surface the bank does not list, send `sense: ""` and `null` windows».
**Дельта и ревизия не ломаются, и вот чем это предъявлено.** Строки стопа лежат ОТДЕЛЬНОЙ таблицей,
заменяемой целиком, и в машинерию `bank_reset_revision` не входят. Втянуть их туда было бы дефектом: без
идентичности КАЖДАЯ материализация читалась бы как «строки исчезли» и держала бы книгу в вечном сбросе
ревизии. Пин `TestTheStopsRankingIsTheOrderThatComesBackAndTheListIsReplacedWhole`; вся прежняя батарея
`internal/pgstore` (включая тесты ревизии и дельта-чтения) зелёная.
### 7. Гейт зоны — и КРАСНЫЙ ПАКЕТ, который оказался не моим
`make check` = `build vet fmt lint sqlc-check` + батарея. Прогонов было пять, и стоит назвать все,
потому что три из них учат разному.
**Прогон 1 — `MAKE_EXIT=2`, а уведомление харнесса сказало «exit code 0».** Гейт умер на `tools-check`:
`sqlc` лежал в `~/go/bin` вне `PATH`. Первая цель, упавшая первой, отменила все последующие; ни одного
теста прогнано не было. Код обёртки — не код работы, и вердикт я читаю строкой `MAKE_EXIT` из лога.
**Прогон 2 — ЗЕЛЁНЫЙ ЦЕЛИКОМ, и полнота сверена СПИСКОМ, а не отсутствием слова FAIL:**
```
MAKE_EXIT=0 · линтер 0 issues · sqlc diff чист · FAIL 0
go list ./... → 20 · вердиктов → 20 · comm -23 список вердикты → 0 строк
контроль, что сравнение умеет говорить: убрать один вердикт → 1 строка
```
**Прогоны 3 и 4 — красные, и оба единственным красным дали `internal/books`, пакет, которого мой диф не
касается.** Прогон 3: `pgstore: commit: timeout: context deadline exceeded` за 145 с. Прогон 4: паника
таймаута 600 с, тест висел 7 м 26 с. Разные тесты, один пакет.
**Флейком по цвету я это не назвала — проверила цепь ЗВЕНЬЯМИ.** (1) Текст падения сам называет
звено: коммит перерос свой срез. (2) Фикстуры `internal/books` НАМЕРЕННО сжимают 220-секундный
продуктовый бюджет до 700 мс и 300 мс и делают внутри настоящий коммит в Postgres — иначе разрез не
удержал бы ни одного среза. (3) Звено, через которое могла бы войти МОЯ правка, — длительность установки
схемы; пере-снято: лишняя миграция стоит ≈0.12 с на прогон, и она лежит ВНЕ измеряемого окна.
(4) В том же логе все пакеты шли примерно в 2.4 раза дольше обычного — на машине работала чужая
мутационная кампания.
⭐ **РЕШАЮЩИЙ ЗАМЕР снят правильным прибором — ДВУМЯ полными батареями ОДНОВРЕМЕННО, а не по очереди.**
Развёрнут `git archive HEAD` (35 миграций, ни строки пака) и запущен в одну секунду с батареей пака,
под одной и той же чужой нагрузкой:
| дерево | вердикт | что упало |
|---|---|---|
| чистый `HEAD` | FAIL `internal/books` 201 с | `TestAnIntakeStoppedByTheCap…` (4.48 с) · `TestTheCutOfAnUploadIsBoundedByTheWalk…` (2.81 с) |
| дерево пака | FAIL `internal/books` 600 с | таймаут на `TestAnUploadThatRunsOutOfBudgetWaitingForASlot…` |
**Красны ОБА, в одном пакете, на РАЗНЫХ тестах** ⇒ краснота принадлежит ПАКЕТУ, а не правке, и разные
тесты при каждом падении — подпись голодания планировщика, а не логического дефекта. ⚠ Последовательным
прогоном этот вывод не снимался бы: условия между двумя прогонами не совпадают, и сравнение вышло бы о
разной нагрузке. Изолированно пакет целиком зелен в ОБОИХ деревьях даже при load average 11 — губит его
не нагрузка сама по себе, а нагрузка ПЛЮС собственная батарея зоны под `-race`.
Заведено **PD-469**, родня — **PD-420** того же класса. Не чиню: не мой предмет и не мой заказ.
**Прогон 5 — ФИНАЛЬНЫЙ, на дереве, которое я передаю, с поднятой переменной оракула: ЗЕЛЁНЫЙ ЦЕЛИКОМ.**
Гейт, снятый ДО двух последних правок (пина на владение и строк регистра), я за финальный не выдавала —
именно за это канон и бьёт, — и дождалась окна, когда чужая кампания отпустила процессор (load 3.18).
```
MAKE_EXIT=0 · линтер 0 issues · sqlc diff чист · FAIL 0 · скипов 9
go list ./... → 20 · вердиктов → 20 · comm -23 список вердикты → 0 строк
контроль перевёрнутого сравнения: убрать один вердикт → 1 строка
```
**Что оракул РЕАЛЬНО бежал внутри батареи, доказано разностью, а не словом:** скипов было 10 в
прогоне 2 и 9 в прогоне 5, и единственная разница — `TestARealReadOutIsReadTheWayThisBuildClaims`;
ничего нового скипаться не начало (`comm` в обратную сторону — 0 строк).
**Условия гейта на этом хосте, названные вместе с числом скипов (стандарт зоны §3.1).** Скипов **10** в
зелёном прогоне 2. `TM_PLATFORM_TEST_DSN` был UNSET — **подняла** по рецепту `STACK_DECISIONS.md`
(Postgres без root, порт 55433); контроль, что гейт РЕАЛЬНО открыт, а не объявлен открытым: тот же
селектор `-run Bank` в `internal/pgstore` даёт **4 SKIP** без переменной и **4 PASS** с ней. НЕ подняты
`TM_PLATFORM_TEST_ENGINE_BIN` + `TM_PLATFORM_TEST_BOOK_TEMPLATE` (пара): шаблон на машине есть, но его
`pipeline:`/`models:` указывают на пути ДРУГОГО хоста (`/home/ubuntu-26/…`). Они открывают 9 из 10
скипов — живые движковые пробы, ни одна не про читатель проекции; `artifacts_test.go`, где живёт мой
предмет, этой парой НЕ гейтится. Десятый скип — МОЙ оракул, он гейтится новой переменной
`TM_PLATFORM_TEST_BANK_READOUT`, которая выводится в `make conditions` автоматически (перечень условий
там ПАРСИТСЯ из исходников, руками его никто не ведёт).
### 8. Что отрефакторено и почему
1. **`SaveBank` принимает `ingest.Bank`, а не `[]ingest.BankTerm`.** Не косметика: развёрток — одна
проекция одного момента, и два сохранения дали бы экран, где вопросы этого стопа стоят рядом с банком
другого момента. Одна транзакция, одна книжная блокировка.
**Правка тестов ОБЪЯВЛЯЕТСЯ** (D39.183): **8** вызовов в `internal/pgstore/{readmodel,events}_test.go`
(⚠ испр. 17.09 приёмкой: стояло «9» — я посчитала изменённые СТРОКИ диффа, а один вызов занимает две) и
фейк в `internal/readmodel/readmodel_test.go` переписаны под новую сигнатуру. Поведение не ослаблено —
каждый передаёт тот же список термов внутри `ingest.Bank{Terms: …}`; гарантия никуда не уехала.
2. **`ingest.BoundaryOrUnknown` / `ingest.ChannelOrNone` — гарды закрытых словарей у ШВА записи.**
Заведены не для красоты: приёмочный прогон показал, что нулевое значение `ingest.Bank`, которое любой
вызывающий вправе построить, нарушает CHECK новой таблицы. Лечение по существу — значение, «заявляющее
о себе меньше всего», а не отказ: падать всем сохранением банка из-за ярлыка границы было бы худшей
сделкой.
3. **Больше ничего.** Существующий ридер, `ReadBank`, ListBank-курсор и ревизионная машинерия не тронуты:
заказ их не менял, а трогать работающее ради стройности — то, что канон запрещает.
### 9. Три оси ревью
**Контрактная.** Минор законен (мажор `0`, только добавление: новый путь, четыре схемы, один
необязательный агрегат — ни одно существующее поле не сужено и не переименовано). Полнота предъявлена
ГЕЙТОМ, а не глазами: `TestEveryMemberTheCanonRequiresIsOnTheWire` читает `required` ИЗ канона и сверяет
с реальными байтами ответа в ОБЕ стороны — объявленное и не отданное красит так же, как отданное и не
объявленное. Константу версии подняла до `0.16.0`, и её стережёт чужой гейт против самого канона.
Компаньон: §2.27 плюс ОБА носителя номера в шапке (список ратификаций и строка про отставание зеркала) —
именно на втором предыдущие три бампа спотыкались три раза подряд.
**Дисциплина ридера.** Гарантии перенесены, а не процитированы, и каждую стережёт посадка (§11). Одну
вещь я перенесла ШИРЕ образца и называю это явно: образец различает «нет» и «пусто» на уровне ДОКУМЕНТА,
а у меня то же различение стоит и на уровне ПОЛЯ — потому что замер показал, что именно там оно и
ломается.
**Человеческий узел.** Чего человеку для решения НЕ хватает, хотя я этого не строю:
1. **`variants` — склеенные ярлыки** (`"третий оборот ×1"`, с алиасом `"… (proposed for X)"`). Части не
опубликованы, разбирать нельзя (парсер живёт рядом с писателем не случайно) ⇒ клиент не может
отрисовать это на языке читателя. Движковый долг, оркестратор завёл рядом **479**.
2. **Пустой `dst` неотличим по причине.** На печатном листе движка тот же символ несёт четыре факта, и
один из них — `SettledByBank`, «решать нечего», — движок намеренно в проекцию не кладёт (ряд **353**).
Контроль, который у меня это ловит: пустых `dst`**0 и 0**, `never_asked`**0 и 0**, сходится;
класс реален, но на купленном сырье не проявился. ⚠ **Гейт ряда 353 сработал: читатель у секции
появился**, и там же висит долг «у `SettledByBank` нет свидетеля в списке полей, которых сайдкар
намеренно НЕ несёт». Это движковая зона — пинг, не правка у себя.
3. **Ни одной подсказки, СКОЛЬКО осталось.** Счёт живёт только на квитанции правок, и канон сам называет
это «named narrowness». Мой ресурс его не дублирует намеренно: популяции совпали на обоих прогонах
(69/69 и 66/66, разность множеств 0 в обе стороны), но механизм расхождения реален — реверс-секция
капается (`mining.go:143-147`), а при выключенной роли её нет вовсе ⇒ равенство в контракте не обещано
и «N из M» между ними запрещено прозой.
### 10. Что не удалось — и где прибор слеп
- ⛔ **`contradicts` и `bank_holds` — 0 из 135 объектов.** Поля объявлены и доезжают, но проверены только
синтетикой ЭТОЙ ЖЕ сборки, то есть пин спрашивает «декодер согласен сам с собой», а не «согласен с
движком». Не чинила: лечение — не код, а УЛИКА (прогон, где конфликт случился). Строка **PD-468**.
- ⛔ **`dst: ""` и отрицательный `conf` не встретились ни разу** (0 из 135). Ветки построены и запинены
синтетикой; на живых данных не наблюдались. Закрывается той же уликой.
- ⛔ **Якорь свежести хранится целиком и не сверяется.** Ресурс обещает ПОСЛЕДНИЙ стоп, а не текущий, и
прозой это сказано — но клиент, не прочитавший `Run.status`, покажет вопросы ушедшего прогона.
Сравнение построить можно и дёшево, но материализация идёт по долгу КНИГИ и прогона в руках не держит.
Не строила намеренно: расширять обещание без механизма — взять форму якоря без гарантии. Строка **PD-467**.
- ⛔ **Два гейта зоны на этом хосте не подняты** (движковый бинарь + шаблон книги): 9 скипов из 10.
Ни один не про мой предмет, но сказать «батарея без скипов» нельзя, и я этого не говорю.
- ⛔ **`unreadable` схлопывает три причины.** Оператор по нему не отличит «старая сборка движка» от «ещё
не материализовали». Сделано сознательно (лекарство одно, и контракт служит клиенту), но диагностика
беднее, чем могла бы быть.
- ⛔ **Пятая граница, `redrive/re-seeded`, живого носителя в моих уликах не имеет**обе проекции сняты
на стопе. Перевод всех пяти проверен синтетикой против ГРЕПА по вызовам движка, а не против пяти живых
документов.
### 11. Мутационная кампания — посадки и их вердикты
Копия дерева ВМЕСТЕ с каноном контракта (`cp -a --parents platform docs/architecture/14-api-contract`,
норма `D39.113`/`PD-395`), один мутатор на копию, копия защищена ПОСТРОЕНИЕМ: харнесс отказывается
работать, если по корню нет `platform/go.mod` и канона, или если корень внутри рабочего дерева.
Базовые прогоны всех четырёх пакетов ЗЕЛЁНЫЕ до первой посадки — без этого вердикт был бы о них.
Засчитывается по ТЕКСТУ падения: харнесс требует, чтобы упал ИМЕННО тот пин, который обязан назвать
посадку, и выдаёт отдельный исход «не измерена» на посадке, которая не собралась.
| Посадка | Что ломает | Вердикт |
|---|---|---|
| A | отсутствующая секция читается как пустая | RED · `…SectionTheDocumentDoesNotCarryIsNotASectionThatIsEmpty` |
| B | отсутствующее `conventions` читается нулём | RED · `…MandatoryNumberTheReadOutDoesNotCarryIsNotZero` |
| C | неназываемая граница читается как СТОП | RED · `…BoundaryIsTranslatedAndAnUnnameableOneIsNeverTheStop` |
| D | неназываемый детектор читается как «подтверждено обоими» | RED · `…DetectorVocabularyDoesNotReachAClient` |
| E | отсутствующая полнота читается как обнулённая | RED · `…SectionTheDocumentDoesNotCarryIsNotASectionThatIsEmpty` |
| F | `invented` теряется | RED · `…ClassAPersonReadsFirstSurvivesTheReadOut` |
| G | граница перестаёт быть УСЛОВИЕМ выдачи | RED · `…TermsStandingAtABoundaryThatCannotCarryThemAreNotServed` |
| H | «развёртка нет» читается как читаемый стоп | RED · `…ThreeWaysAStopCanHaveNoQuestionsAreNotOneAnswer` |
| I | наличие секции выводится из числа строк | RED · `…ThreeWaysAStopCanHaveNoQuestionsAreNotOneAnswer` |
| J | неизмеренная полнота отдаётся | RED · `…CompletenessIsAbsentUntilSomethingMeasuresIt…` |
| K | ранжирование заменено сортировкой | RED · `…StopsRankingIsTheOrderThatComesBackAndTheListIsReplacedWhole` |
| L | прежний список не очищается | RED · тот же пин (+1) |
| M | `null`-число исчезает с провода (`omitempty`) | RED · `…EveryMemberTheCanonRequiresIsOnTheWire` |
| N | неизмеренная полнота проецируется обнулённой | RED · `…CompletenessRidesTheFirstPageAndItsAbsenceIsNotWholeness` |
| O | через шов едут только строки | RED · `…WholeReadOutCrossesTheSeamAndNotOnlyItsRows` |
| P | пустой список уходит как `null` | RED · `…MeasurementNobodyTookReachesTheClientAsNullAndNotAsZero` |
| Q | проверка владения снята | RED · `TestAnotherUsersStopIsNotReadable` |
**17 посадок — 17 RED, выживших 0, неизмеренных 0.**
⭐ **И ОТДЕЛЬНО — про сам ОРАКУЛ, потому что «скип превратился в PASS» доказывает, что тест ИДЁТ, а не
что он ДЕРЖИТ.** В кампании оракул скипался (переменной у харнесса не было), то есть все посадки поймала
СИНТЕТИКА. Спросила его посадкой отдельно: посадка **B** (отсутствующее `conventions` читается нулём) при
поднятой переменной даёт `--- FAIL: TestARealReadOutIsReadTheWayThisBuildClaims` с топичным текстом
«row 0: the document does not state `conventions` and the reader reported it stated», а при снятой —
`--- SKIP`. ⇒ **эта посадка поймана ДВУМЯ независимыми приборами** — синтетическим пином и оракулом
холодного прогона на настоящем купленном документе, — значит сломать чтение отсутствующего поля и
получить зелень нельзя даже мимо синтетики. ⚠ Посадка Q прогнана ОТДЕЛЬНО и на пере-снятой
копии: пин владения написан после первой кампании, и копия его не несла. Контроль чистоты после
кампании — не `diff -rq` (он говорит о дереве, а не о числах), а вопрос «на месте ли ЦЕЛЬ каждой
правки»: **16 целей проверено, пропавших 0.**
### 10-бис. КРУГ ДОФИКСА ПО ПРИЁМКЕ 17.09 — находка → что сделано → чем предъявлено
| Находка приёмки | Что сделано | Чем предъявлено |
|---|---|---|
| **F1 (мажор, денежный экран).** Канон объявляет `confidence` как `0..100`, движок шлёт `-1` для «роль не назвала», и вся цепь несла минус на провод — при том что компаньон обещал `null`. Класс: вместо ложного НУЛЯ — ложное ЧИСЛО | **⛔ И это оказался КЛАСС, а не поле — нашёл мой же новый гейт.** Свернула у ШВА (`ingest`), где файл велит переводить все пересечения, и потом гейт показал, что канон бьёт диапазоном ЧЕТЫРЕ числа, а проходили насквозь все четыре | `statedCount`/`statedConfidence`/`inRange` в `ingest/bank.go`; `TestTheEnginesNoConfidenceSentinelNeverReachesAClient` |
| **Почему гейт этого не поймал**`TestEveryMemberTheCanonRequiresIsOnTheWire` читает из канона только `required` и по построению не видит `minimum`/`maximum`: «взять форму образца и не взять гарантию», но у ГЕЙТА | Заведён гейт, читающий из канона ДИАПАЗОНЫ и падающий на любом ранжированном члене, которого ридер не ограничивает — то есть закрыт класс, а не случай | `TestEveryRangeTheCanonDeclaresIsOneThisReaderEnforces`: «ranges read from the canon and exercised: 4»; на момент заведения краснел по ЧЕТЫРЁМ полям |
| **F2.** `omitempty` на nil-указателе ОПУСКАЕТ ключ, поэтому обещанный каноном `null` («никто не мерил») недостижим, а комментарий строкой выше утверждал обратное | У члена ДВЕ разных пустоты, и тег умеет одну: на поздней странице — отсутствие, на первой — `null`. Перешла на `json.RawMessage` | `TestCompletenessRidesTheFirstPageAndItsAbsenceIsNotWholeness` |
| **F3.** Носителей номера версии в шапке компаньона **четыре**, подняты два — и предупреждение рядом само утверждает, что их два | Поднят четвёртый (счёт отставания зеркала: тринадцать → четырнадцать). `README.md:6` не тронут намеренно: он про РАТИФИКАЦИЮ, её делает акт оркестратора | греп `ЧЕТЫРНАДЦАТЬ`; в тексте правки названо, что носителей четыре, а не два |
| **F4.** «Роутер монтирует 19 операций из 21» — минор двинул оба числа | Пере-снято: операций в каноне **22**, маршрутов **20**, без маршрута те же две, что названы в строке | `yaml`-разбор канона против таблицы `contractSurface` |
| **F5.** Нового ресурса нет в таблице зависимостей компаньона, которая объявляет себя единственным местом, где это ведётся | Заведены ДВЕ строки — ресурс стопа и агрегат полноты — с носителями 224 и 253 | греп `bank/signing-stop` в README |
| **A.** Удалён доккомментарий `DecodeBank`, несший довод гарда («пустой банк и документ, который эта сборка не умеет читать, декодируются одинаково») | Восстановлен дословно. §8 говорил «больше ничего» — удаление под «ничего» не подходило | греп `replace a book's whole bank with nothing` |
| **B/C.** Гард закрытого словаря стоит на канале и не стоит на `kind`; и сам гард якобы дублирует `nullif` | ⛔ **Замер опроверг вторую половину и усилил первую:** `nullif` делает ДРУГУЮ работу, и без гарда движковое слово не «ложится как неназванное», а **роняет CHECK и всю запись банка**. Гард добавлен на `kind`, оба запинены | `TestAnEngineWordThatLeakedThisFarIsStoredAsNotNamedRatherThanTakingTheBankDown` — до правки красный именно на `kind` |
| **D.** §8 говорит «9 вызовов», их 8 | Испр.: я посчитала изменённые СТРОКИ диффа, а один вызов занимает две | `grep -c 'SaveBank('` → 7 + 1 |
| **Семь чисел консолидации** не получили второй половины вывода — условия, при котором он перестанет держаться | Условие названо: семь полей вошли в писателя ОДНИМ коммитом, поэтому «present» сегодня всегда значит «все семь»; в день восьмого числа `unanswered: 0` станет ложным нулём, и они станут указателями | комментарий у `decodeConsolidation` |
| **Адрес довода §5(б)** неверен | Испр. трижды: `:281``:280` → настоящий носитель `cmd/tmctl/render.go:225-227`. ⚠ И поправка к поправке приёмки: «0 хитов в `mining.go`» — неверно, там **1** | §5(б), контроль напечатан рядом |
| **§2 отчёта** называл одну новую секцию компаньона вместо двух | Испр., §2.26-бис назван | таблица §2 |
### 11-бис. Опись дерева — снята ПОСЛЕ последней правки
**Двадцать один путь, и все внутри разрешённых паком зон** (`platform/` + `docs/architecture/14-api-contract/`):
```
docs/architecture/14-api-contract/README.md platform/internal/ingest/bank.go
docs/architecture/14-api-contract/openapi.yaml platform/internal/pgstore/readmodel.go
platform/docs/DEFECT_REGISTER.md platform/internal/pgstore/readmodel_test.go
platform/docs/platform-PROGRESS.md platform/internal/pgstore/events_test.go
platform/internal/httpapi/capabilities.go platform/internal/pgstore/migrations.sha256
platform/internal/httpapi/project.go platform/internal/readmodel/readmodel.go
platform/internal/httpapi/reading.go platform/internal/readmodel/readmodel_test.go
platform/internal/httpapi/v0.go
platform/internal/httpapi/v0_test.go
новые: platform/internal/pgstore/migrations/00036_bank_read_out.sql
platform/internal/ingest/bankreadout_test.go
platform/internal/pgstore/bankreadout_test.go
platform/internal/httpapi/signingstop_test.go
```
**В дереве лежит ЧУЖОЕ, и оно не моё — коммитить его нельзя:** 16 путей под `backend/` (пак лестницы
попытки, сессия `textmachine-main-12`) плюс `docs/BACKLOG.md` и `docs/experiments/00-provider-quirks.md`.
Я их не трогала ни разу. Коммит — только pathspec-формой по списку выше.
**Двадцать первый путь появился ПОСЛЕ зелёного гейта и назван отдельно**`platform/docs/STACK_DECISIONS.md`,
одна грабля в раздел «Грабли стенда, каждая стоила времени»: TCP-проба стенда отвечает
`Connection refused` на ЖИВОМ сервере, потому что рецепт поднимает его с `listen_addresses=''` и слушает
он только Unix-сокет. Правка инертна к гейту, и это ЗАМЕРЕНО, а не объявлено: файл цитируется в прозе
комментариев 12 раз и не открывается кодом НИ РАЗУ (`grep` по `platform/**/*.go` вне комментариев — 0);
исполняемых путей (`.go`/`.sql`) новее лога зелёного прогона — **0**, при контроле «новее предыдущего
лога — 1», то есть сравнение умеет говорить. Записано потому, что цена этой грабли уже уплачена
приёмкой, а знание, оставшееся в переписке, следующей смене не достаётся.
**Один побочный эффект, который надо знать при лендинге:** две мои строки в `DEFECT_REGISTER.md`
(а теперь три — `PD-467`, `PD-468`, `PD-469`) двигают числа, которые стережёт `docs/scripts/counts.py`,
а живут они в `docs/PROGRESS.md` — зона оркестратора, куда платформа не пишет. Оркестратор знает и
правит тем же коммитом.
**Стенды, оставленные для приёмки** (вне репозитория, git не видит): `/home/ubuntu/tm-mut-94-1709`
копия под мутации вместе с каноном; `/home/ubuntu/tm-head-94-1709` — развёрнутый чистый `HEAD` для
сравнения двух батарей. Postgres стенда поднят мной на порту 55433 и оставлен работать.
### 12. Критерий завершённости — по пунктам пака
- у каждого пункта заказа исход — **§3**;
- круги сошлись, находки закрыты таблицей — **§13**;
- числа ДО и ПОСЛЕ на обоих купленных файлах предъявлены командой — **§1**;
- ряды 224 и 253 — **§14**;
- список отрефакторенного — **§8**;
- гейт зоны зелёный целиком — **§7**;
- **работа завершена, править не планирую.**
### 13. Находки круга самопроверки: находка → что сделано → чем предъявлено
| Находка | Кто нашёл | Что сделано | Чем предъявлено |
|---|---|---|---|
| Состав «8 обязательных / 4 опциональных» в промте неверен; `conventions` обязательно, `variants` опционально | я, замером писателя | замер принят, промт не подгонялся | §4; оркестратор подтвердил и правит промт |
| «В A нет `conventions`» — не опциональность, а ДРУГАЯ СБОРКА движка | я | четыре числа стали указателями | посадка **B**, пин `…MandatoryNumber…` |
| «Обе секции — один момент» ЛОЖНО: полнота едет на трёх границах, вопросы на одной | советчик, проверено мной по `mining.go:171` | форма переиграна: агрегат + отдельный ресурс | §5(а), эррата над запиской |
| `suggestions` врёт читателю: игнор здесь есть принятие | советчик; адрес трижды уточнён (`:281``:280``render.go:225`) | имя `offered` из слов самого канона | §5(б) |
| Гарантию «не читается» изобретать не надо — она ратифицирована у `BankSignatureCount` | я, при проверке имени | `unreadable` взят буквой вместе с обоснованием | посадка **H**, контракт §`BankSigningStop` |
| Советчик перегнул: третий член первой страницы якобы правит шапку спеки | я, чтением шапки | шапка НЕ тронута — там КЛАСС, а не перечень | §5(а) |
| Советчик: популяции списка и счётчика подписи расходятся | я, замером карты подписи | **опровергнуто на сырье** (69/69, 66/66, разность 0), но механизм реален ⇒ равенство не обещано | §9, проза операции |
| Нулевое значение `ingest.Bank` нарушает CHECK новой таблицы | приёмочный прогон | гарды `BoundaryOrUnknown`/`ChannelOrNone` у шва записи | §8 п.2; вся батарея `pgstore` зелёная |
| Уведомление харнесса сказало «exit 0» на упавшем гейте | я, чтением лога | вердикт читается строкой `MAKE_EXIT` из лога, не уведомлением | §7 |
| Контроль ряда 253 даёт 3, а не заявленный 0 | я, пере-снятием на HEAD | суть ряда верна, число — нет; названо оркестратору | §1 |
| `contradicts`/`bank_holds` — 0 из 135, проекция исполнением не проверена | я | не закрыто: нужна улика, а не код | **PD-468** |
| Якорь свежести хранится и не сверяется | советчик, принято | не строила; обещание ресурса сужено прозой до «ПОСЛЕДНИЙ стоп» | **PD-467** |
| `variants` — склеенные ярлыки с английским фрагментом | я, по пункту 4 аддендума | пропущено как непрозрачный текст, разбирать нельзя | ряд **479** (завёл оркестратор) |
| Гейт ряда 353 сработал: у секции появился читатель | я | пинг движковой зоне через оркестратора | §9 п.2 |
### 14. Ряды 224 и 253 — что закрыто и что осталось
**224** — платформенная половина **ЗАКРЫТА**: у секции есть читатель, хранилище и поверхность; её
собственный контроль ушёл с 0 на 1. Осталось за пределами ряда — ряд **479** (части ярлыка `variants`).
**253** — платформенная половина **ЗАКРЫТА**: поле на `BankPage` есть, бит `complete` берётся у движка
ГОТОВЫМ и не выводится у меня (ратифицированное требование ряда соблюдено буквой). ⚠ Осталась
ЧУЖАЯ работа, которую этот пак только разбудил: ряд **353** обещал вернуться к исчерпывающему пину
контракта секции «когда у неё появится ЧИТАТЕЛЬ», и читатель появился — вместе с висящим там долгом
про отсутствие свидетеля у `SettledByBank`. Это движковая зона.
### 15. Аддендум владельца 17.09 — отдельным пунктом, как просил оркестратор
1. **«Тщательно проектируй решение».** Три развилки предъявлены запиской ДО кода; две из трёх я потом
переиграла, и обе — не по вкусу, а по опровержению довода кодом (§5). Эррата над запиской оставляет
видимым и прежнее решение, и причину отмены.
2. **«Комментарии — только нужные, без странных гарантий».** Аддендум пришёл, когда ридер уже был
написан, и **изменил написанное**: я прошла по своим комментариям и сократила их, оставив «почему» и
выбросив пересказ строки. Где комментарий несёт ВЫВОД, названо условие его конца — прямо у
`Invented`: «плоский bool, потому что движок опускает поле при `false`; в день, когда `omitempty`
уйдёт, отсутствие перестанет значить `false` и поле обязано стать указателем — и НИЧТО здесь в этот
день не покраснеет, гарда — эта фраза». Гарантий, которых не стережёт пин, я не писала: каждое
утверждение §4.2 имеет посадку в §11.
3. **«Прозу в код не писать»** — и плоскости не спутаны: в Go минимум, в спецификации описание, потому
что там проза НОРМАТИВНА и компилируется в исходник генерируемого клиента.
4. **«Решение общее для книг и языков».** Ревью-вопрос «заработает ли пара, которой в репо нет, без
правки Go?» — **да**: ни одно поле секций и ни одно значение их словарей не несёт пара-специфики,
ветвлений по паре/книге в добавленном Go нет, а закрытые словари (`kind`, `channel`, граница) — про
устройство пайплайна, не про язык. ⚠ И ровно этот вопрос дал находку: **`variants` несут английский
фрагмент фразы внутри данных** («proposed for X»), то есть клиент не отрисует их на языке читателя.
Не зашила и не пере-вывела — назвала; ряд **479**.
5. **«Советчиков до двух»** — использован ОДИН, с постоянным контекстом, на проектировании (развилка
формы и имени). Его посылки проверены деревом: одна опровергнута (шапка спеки), одна опровергнута
замером (популяции), остальные приняты и изменили работу.
6. **«Право грепать доки и полигон»** — воспользовалась при проверке доводов советчика.
## ЗАПИСКА-ПЛАН (17.09) — ЧИТАТЕЛЬ СЕКЦИЙ ПРЕДЛОЖЕНИЙ И КОНСОЛИДАЦИИ (ряды 224 · 253)
Пак `docs/PLATFORM_BANK_READOUT_SESSION_PROMPT.md` (180c41e), сессия `textmachine-main-94`.
Записка написана ДО первой правки кода, как требует §8 пака: по ней приёмка судит работу против
замысла, а не против домысла. Решения ниже названы явно; передумаю с доводом — напишу это здесь же.
> ⚠⚠ **ЭРРАТА 17.09 — ДВА РЕШЕНИЯ ЭТОЙ ЗАПИСКИ ОТМЕНЕНЫ, ЧИТАТЬ ВМЕСТЕ С §5 ОТЧЁТА ВЫШЕ.**
> Тело записки НЕ переписано задним числом намеренно: смена замысла и её довод — сама по себе улика,
> и затирать её значило бы предъявить приёмке замысел, которого у меня в тот момент не было.
> **(1) Имя.** Записка обещала `suggestions`**отменено**, действует **`offered`** (ресурс
> `.../bank/signing-stop`, схема `OfferedTerm`). Довод записки был «ноль хитов в спеке» — он
> доказывает, что имя СВОБОДНО, а не что оно ВЕРНО. Отменяющий довод: игнор здесь есть ПРИНЯТИЕ
> (сильнейший носитель — `backend/cmd/tmctl/render.go:225-227`, операторский вывод, который человек
> читает в момент подписи; ⚠ адрес испр. 17.09 — в первой редакции стоял `mining.go:281`),
> а «suggestion» обещает необязательность; и `offered` контракт уже употребляет
> об этом же предмете («the surfaces the LAST signing stop offered»).
> **(2) Форма.** Записка обещала обе секции на `BankPage`**отменено**: полнота осталась агрегатом
> `BankPage`, а вопросы стопа уехали в свой ресурс. Довод записки («обе описывают ОДИН момент»)
> опровергнут кодом движка: `r.lastTerminology` ставится ДО развилки «стоп/не стоп», поэтому полнота
> едет на трёх границах из пяти, а вопросы — на одной.
> Остальное записки — состав полей, три состояния уверенности, перевод словаря границ, отказ выдумывать
> идентичность, хранилище в БД — **действует без изменений**.
### Что меряю и чем (замер, не пересказ)
Обе купленные проекции прочитаны копиями в скретчпаде (`sha256` копий совпал с оригиналами, `mtime`
оригиналов не тронут). Числа сошлись с промтом по КОЛИЧЕСТВУ и **разошлись по СОСТАВУ** — это находка,
и она ушла пингом оркестратору, а не выровнялась молча:
| | A | B |
|---|---|---|
| `terms` | 0 | 0 |
| `proposed` | 69 | 66 |
| `consolidation` | объект из 7 полей | объект из 7 полей |
| `as_of` | `bank-mining/signature-stop` | `bank-mining/signature-stop` |
| различных `src` | 69 из 69 | 66 из 66 |
Восемь полей, которые движок пишет ВСЕГДА (у них нет `omitempty` в `backend/internal/pipeline/bankexport.go`):
`src dst kind channel freq spread` **`conventions`** `conf`. Четыре с `omitempty`: `invented contradicts
bank_holds` **`variants`**. ⚠ Промт называл `variants` обязательным, а `conventions` опциональным —
наоборот; счёт 8+4 верен, состав нет.
### Находка, ради которой состав и переснимался
`conventions` отсутствует у **всех 69** строк прогона A и стоит у **всех 66** строк прогона B. У поля нет
`omitempty`, значит `json.Marshal` его не мог опустить ⇒ **A написан сборкой движка ДО `0997e41` (11.09),
добавившего поле**. Это доказательство построением, а не догадка. Следствие несущее: у ОБЯЗАТЕЛЬНОГО поля
«поля нет» значит «другая сборка», и прочесть его нулём — напечатать измерение, которого никто не делал.
Ровно тот ложный ноль, ради которого пак заказан, только на ярус ниже — не у секции, а у поля.
`contradicts` и `bank_holds` не встретились НИ РАЗУ (0 из 135 объектов). Читатель их несёт, но живым
сырьём они не проверены — назову это в секции «где прибор слеп».
### Что строю
**1. Ридер (`platform/internal/ingest/bank.go`).** Две новые секции плюс якорь свежести (`as_of`, `run_id`),
которого ридер сегодня не читает вовсе. Наследую КЛАСС гарантий существующего ридера, а не цитирую его:
- «секции нет» ≠ «секция есть и пуста» — обе секции указателями; `nil` ⇔ член документа отсутствовал;
- у ОБЯЗАТЕЛЬНЫХ числовых полей «поля нет» ≠ «ноль»: `conventions` и `conf` — указатели. У `conf` три
состояния, а не два: нет поля · отрицательное («роль не назвала уверенности», закон движка) · `≥0`
(названная уверенность, включая осмысленный ноль);
- у полей с `omitempty` отсутствие есть нулевое значение ПО ЗАКОНУ ПИСАТЕЛЯ, и комментарий назовёт
условие, при котором вывод перестанет держаться (движок снимет `omitempty` — и тогда `invented`
обязан стать указателем);
- **граница читается вместе с секцией.** «Граница не та» обязано отличаться от «предложений нет»:
документ с `as_of: run-finished` и без секции — это не «нечего подписывать», это «read-out снят не в
том месте».
**2. Словарь границ ПЕРЕВОДИТСЯ, а не проходит насквозь.** Шапка `bank.go` ратифицирует: каждое
пересечение словаря переводится, движковые слова наружу не идут. `bank-mining/signature-stop` — имя
СТАДИИ конвейера, тот самый класс. Пять значений сняты грепом `exportBank(` по `backend/` (пять живых
вызовов, не из комментария), неизвестное шестое читается как то, что заявляет о себе меньше всего, и
НИКОГДА как стоп.
**3. Наружу — контрактный минор `0.16.0`** (канон `0.15.0`, полоса мажора `0`, добавление = минор),
вместе с ратифицированным компаньоном `README.md`. ⚠ Номер живёт в ДВУХ местах шапки компаньона, и
предыдущие три бампа правили только `openapi.yaml` — правлю оба.
**Коллизия имени — решение.** `proposed` в контракте уже значит СТАТУС строки (`TermStatus`), в проекции —
ИМЯ СЕКЦИИ. Третьего смысла не завожу, существующий не переименовываю, наружу слово `proposed` не беру
вовсе. Внешнее имя — **`suggestions`** (контроль: `suggestion`/`Suggestion` в `openapi.yaml` — 0 хитов,
в компаньоне — 0). Не `candidate`: это движковый тип (`terminology.Candidate`), то есть словарь, который
шапка `bank.go` велит переводить, а не заимствовать.
**Идентичность предложения — решение, а не пинг.** `id` у предложения нет ни в одном из 135 объектов, и
выдумывать его у себя запрещено. Развилка разбивается надвое, и обе половины закрываются БЕЗ выдумки:
- **чтение:** предложения в дельта-чтении не участвуют ВОВСЕ и едут всегда целиком. Дельта над ними не
выразима по построению (нет идентичности), а порядок — ранжирование стопа, которое между прогонами
меняется. Это пишется в контракт прозой, а не подразумевается;
- **действие:** контракт УЖЕ говорит, чем адресуется поверхность, которой в банке нет —
`BankCorrection`, кортежная форма: «for a surface the bank does not list, send `sense: ""` and `null`
windows». То есть дверь для решения по предложению построена и ратифицирована; своего ключа ей не надо.
**4. Хранилище — в БД, миграцией `00036`, не на лету.** Довод — исполнением, а не вкусом: реплика API
может не иметь движка вовсе (`platform/internal/readmodel/readmodel.go`: «A replica with no engine serves
what was materialized before»), а путь артефакта берётся из манифеста, чтение которого стоит вызова движка
с ре-чанком исходника (`PD-248`). Чтение на лету в HTTP-пути перенесло бы пустой экран с одной причины на
другую — и именно на том развёртывании, где его труднее всего увидеть.
Дельта-чтение и ревизию это не ломает, и вот почему: предложения ложатся ОТДЕЛЬНОЙ таблицей, заменяемой
целиком, и в машинерию `bank_reset_revision` не входят. Втянуть их туда было бы дефектом: у них нет
идентичности, значит КАЖДАЯ материализация читалась бы как «строки исчезли» и держала бы книгу в вечном
сбросе ревизии.
**5. Чего НЕ отдаю наружу.** `run_id` проекции хранится, но на провод не идёт: это движковая идентичность
(`tm-stream-<runID>-<attempt>`, `pgstore.EngineStreamID`), а шов движковых идентичностей не экспортирует.
Хранится потому, что у движка якорь свежести из ДВУХ полей, и взять форму без её гарантии — известный
класс ошибки.
### Гейт и его условия на этом хосте (называю ВМЕСТЕ с числом, как требует §3.1 стандарта зоны)
`TM_PLATFORM_TEST_DSN` был UNSET — **поднят мной** по рецепту `STACK_DECISIONS.md` («Postgres на стенде без
root», порт 55433). Контроль, что гейт действительно открыт, а не объявлен открытым: тот же селектор
`-run Bank` в `internal/pgstore` даёт **4 SKIP** без переменной и **4 PASS** с ней.
Не подняты: `TM_PLATFORM_TEST_ENGINE_BIN` + `TM_PLATFORM_TEST_BOOK_TEMPLATE` (пара). Они открывают живые
движковые пробы `internal/runner` (`backup_live` · `bankapply_live` · `build_live` · `priceprojection_live` ·
`translate_resnapshot_live`) и `internal/books` — ни одна из них не про читатель проекции; `artifacts_test.go`,
где живёт мой предмет, этой парой НЕ гейтится (контроль: греп `TM_PLATFORM_TEST_ENGINE_BIN` по
`platform/internal/runner/*_test.go` даёт 5 файлов, и `artifacts_test.go` среди них нет).
## ⚠ ПИНГ ОРКЕСТРАТОРА №23 (10.09) — ЧЕТЫРЕ АДРЕСА В ВАШЕЙ ЗОНЕ ПОСЛЕ ДВИЖКОВОГО ПАКА И РАТИФИКАЦИИ ДВУХ ОСТАНОВОК
Зона ваша, рукой не трогаю. Всё ниже пере-снято моим прибором сегодня; каждый адрес — от корня репозитория.

View file

@ -54,7 +54,7 @@ import "net/http"
// it buys is that two clicks on «continue» answer the same whichever reached the database first
// (D39.246 п.6, PD-448) — the refusal was not a rare race but the ordinary answer to every resume of
// a live run.
const ContractVersion = "0.15.0"
const ContractVersion = "0.16.0"
// Capabilities is what this deployment can do: one flat document, the same for every account.
type Capabilities struct {

View file

@ -1,6 +1,7 @@
package httpapi
import (
"encoding/json"
"textmachine/platform/internal/ingest"
"textmachine/platform/internal/pgstore"
)
@ -118,6 +119,54 @@ func projectNote(n pgstore.Note) wireNote {
return out
}
// projectOfferedTerm carries the read model's absences out unchanged. Every `null` it emits is one the
// store held, and none is manufactured here: the numbers are the service's own measurements, and a
// zero substituted for a missing one would be a figure nobody took.
func projectOfferedTerm(t pgstore.BankOfferedTerm) wireOffered {
return wireOffered{
Src: t.Src, Dst: t.Dst, Kind: nilIfEmpty(t.Kind), Channel: nilIfEmpty(t.Channel),
Freq: t.Freq, Spread: t.Spread, Conventions: t.Conventions, Confidence: t.Confidence,
Invented: t.Invented,
// The three lists are `required` and an empty collection is an empty array, never null.
Contradicts: emptyIfNil(t.Contradicts), BankHolds: emptyIfNil(t.BankHolds),
Variants: emptyIfNil(t.Variants),
}
}
// projectConsolidation renders the member the first page always carries: the section, or the literal
// `null` that says nothing measured it. Never an empty object — "consolidated 0, unanswered 0" reads
// as "nothing is missing", which is the one thing this member must not say by accident.
func projectConsolidation(c *pgstore.BankConsolidation) json.RawMessage {
if c == nil {
return json.RawMessage("null")
}
body, err := json.Marshal(wireConsolidation{
Complete: c.Complete, RenderBatchesDropped: c.RenderBatchesDropped,
ClassifyBatchesDropped: c.ClassifyBatchesDropped, Consolidated: c.Consolidated,
Declined: c.Declined, Unanswered: c.Unanswered, NeverAsked: c.NeverAsked,
})
if err != nil {
// Seven integers and a bool cannot fail to marshal; `null` is still the safe answer, because
// the alternative is a page that says the bank is whole when nobody asked.
return json.RawMessage("null")
}
return body
}
func nilIfEmpty(s string) *string {
if s == "" {
return nil
}
return &s
}
func emptyIfNil(v []string) []string {
if v == nil {
return []string{}
}
return v
}
func projectTerm(t pgstore.BankTerm) wireTerm {
out := wireTerm{
ID: t.ID, Src: t.Src, Dst: t.Dst, Status: t.Status, Origin: t.Origin, Sense: t.Sense,

View file

@ -1,6 +1,7 @@
package httpapi
import (
"encoding/json"
"net/http"
"time"
@ -90,12 +91,54 @@ type wireTerm struct {
// still undecided" is the engine's answer and rides the correction receipt (`signature`), not a
// read of this table.
type wireBankPage struct {
Revision int64 `json:"revision"`
NextCursor *string `json:"next_cursor"`
StructureVersion int `json:"structure_version"`
Total *int `json:"total,omitempty"`
Signed *int `json:"signed,omitempty"`
Terms []wireTerm `json:"terms"`
Revision int64 `json:"revision"`
NextCursor *string `json:"next_cursor"`
StructureVersion int `json:"structure_version"`
Total *int `json:"total,omitempty"`
Signed *int `json:"signed,omitempty"`
// ⛔ RAW, and not a `*wireConsolidation` with `omitempty`, because this member has TWO different
// absences and that tag can only spell one. On a later page it is ABSENT, like the counts above —
// the aggregates ride the first page only. On the FIRST page it is `null` when nothing has
// measured completeness, which the canon promises and a client must be able to tell from a bank
// that is whole. A nil pointer under `omitempty` collapses both into "no key", so the state the
// canon describes was unreachable and the comment that claimed otherwise was false by the tag one
// line below it.
Consolidation json.RawMessage `json:"consolidation,omitempty"`
Terms []wireTerm `json:"terms"`
}
type wireConsolidation struct {
Complete bool `json:"complete"`
RenderBatchesDropped int `json:"render_batches_dropped"`
ClassifyBatchesDropped int `json:"classify_batches_dropped"`
Consolidated int `json:"consolidated"`
Declined int `json:"declined"`
Unanswered int `json:"unanswered"`
NeverAsked int `json:"never_asked"`
}
// wireSigningStop is what the last signing stop asked about. `Unreadable` carries the fact an empty
// list cannot: without it "this deployment could not read the question" and "the stop asked nothing"
// are the same bytes, and a client would tell a person there is nothing to decide at the one moment
// the product stops to ask them.
type wireSigningStop struct {
Unreadable bool `json:"unreadable"`
Offered []wireOffered `json:"offered"`
}
type wireOffered struct {
Src string `json:"src"`
Dst string `json:"dst"`
Kind *string `json:"kind"`
Channel *string `json:"channel"`
Freq *int `json:"freq"`
Spread *int `json:"spread"`
Conventions *int `json:"conventions"`
Confidence *int `json:"confidence"`
Invented bool `json:"invented"`
Contradicts []string `json:"contradicts"`
BankHolds []string `json:"bank_holds"`
Variants []string `json:"variants"`
}
func (h *v0) listChapters(w http.ResponseWriter, r *http.Request) {
@ -218,6 +261,9 @@ func (h *v0) listBank(w http.ResponseWriter, r *http.Request) {
if page.First {
c := page.Counts
out.Total, out.Signed = &c.Total, &c.Signed
// Set on the first page WHATEVER the answer is, so that `null` reaches the client; the raw
// member stays nil on later pages and is then omitted.
out.Consolidation = projectConsolidation(page.Consolidation)
}
for _, t := range page.Terms {
out.Terms = append(out.Terms, projectTerm(t))
@ -225,6 +271,29 @@ func (h *v0) listBank(w http.ResponseWriter, r *http.Request) {
h.writeJSON(w, r, http.StatusOK, out)
}
// readBankSigningStop answers what the LAST signing stop asked about — the one screen in the product
// where a person is asked for a decision (engine backlog row 224).
//
// It does not say whether a stop is standing: that is the run's status, and the past tense is the same
// one the correction receipt already uses about this population. Uncursored and undeltaed, because a
// term offered here has no id to name in a delta and the stop's ranking is the information.
func (h *v0) readBankSigningStop(w http.ResponseWriter, r *http.Request) {
user, ok := principal(w, r)
if !ok {
return
}
stop, err := h.lib.BankSigningStop(r.Context(), user, r.PathValue("bookId"))
if err != nil {
h.fail(w, r, err)
return
}
out := wireSigningStop{Unreadable: stop.Unreadable, Offered: make([]wireOffered, 0, len(stop.Offered))}
for _, t := range stop.Offered {
out.Offered = append(out.Offered, projectOfferedTerm(t))
}
h.writeJSON(w, r, http.StatusOK, out)
}
// The write half of the bank lives at `POST /books/{bookId}/bank/corrections` (bank.go): the
// per-term act is a CORRECTION applied by the engine's own verb, never a signature — signing stayed
// ONE act over the whole bank, `resume` (D39.144, D39.156). What stood here before it — the

View file

@ -0,0 +1,164 @@
package httpapi
import (
"encoding/json"
"os"
"slices"
"testing"
"gopkg.in/yaml.v3"
"textmachine/platform/internal/pgstore"
)
// canon is the ratified contract, read from this package's directory. The gates package reads it the
// same way and for the same reason: a second copy of a fact is one more place to forget.
const canon = "../../../docs/architecture/14-api-contract/openapi.yaml"
// ⛔ EVERY MEMBER THE CANON DECLARES REQUIRED IS ON THE WIRE, checked against the canon and not
// against a copy of its list. A Go tag proves a field can be marshalled, not that anything fills it:
// a projection that forgets one member serves a document a generated client refuses, and nothing in
// this build would have said so.
//
// Mutations this must catch: dropping a field from the projection; giving one `omitempty` so that its
// zero value vanishes; adding a member to the canon and not to the wire.
func TestEveryMemberTheCanonRequiresIsOnTheWire(t *testing.T) {
required := requiredOf(t, "OfferedTerm")
if len(required) != 12 {
t.Fatalf("the canon declares %d required members of OfferedTerm, expected 12 — this gate is "+
"reading the wrong schema or the schema changed and nobody read this test", len(required))
}
lib := &fakeLibrary{signingStop: pgstore.BankSigningStop{
Offered: []pgstore.BankOfferedTerm{{Src: "方源"}},
}}
w := call(t, readingServer(t, lib), "GET", "/v0/books/bk_1/bank/signing-stop", "")
var body struct {
Unreadable bool `json:"unreadable"`
Offered []map[string]json.RawMessage `json:"offered"`
}
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("the response is not a document: %v\n%s", err, w.Body.String())
}
if len(body.Offered) != 1 {
t.Fatalf("one term was offered and %d came back: %s", len(body.Offered), w.Body.String())
}
// The term above carries NOTHING but its surface, on purpose: a member that only appears when the
// store has a value for it is exactly the omission this gate exists to catch.
for _, member := range required {
if _, ok := body.Offered[0][member]; !ok {
t.Errorf("the canon requires `%s` on an offered term and the wire does not carry it: %s",
member, w.Body.String())
}
}
for member := range body.Offered[0] {
if !slices.Contains(required, member) {
t.Errorf("the wire carries `%s` on an offered term and the canon does not declare it", member)
}
}
}
// ⛔ An absent measurement reaches the client as `null` and NEVER as zero. Measured on bought runs:
// `conventions` is absent from every row of one stop read-out and present on every row of another,
// and `freq: 0` is the service's own "only a translated passage saw it". A client shown 0 for either
// is shown a figure nobody took.
//
// Mutation this must catch: an `omitempty` on any of the four numbers, or a non-pointer type.
func TestAMeasurementNobodyTookReachesTheClientAsNullAndNotAsZero(t *testing.T) {
lib := &fakeLibrary{signingStop: pgstore.BankSigningStop{
Offered: []pgstore.BankOfferedTerm{{Src: "silent"}},
}}
w := call(t, readingServer(t, lib), "GET", "/v0/books/bk_1/bank/signing-stop", "")
var body struct {
Offered []map[string]any `json:"offered"`
}
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
for _, member := range []string{"freq", "spread", "conventions", "confidence", "kind", "channel"} {
if got := body.Offered[0][member]; got != nil {
t.Errorf("`%s` was never measured and reached the client as %v", member, got)
}
}
// The three lists are collections, and an empty collection is an empty array — never null, which
// would ask a client to tell "no conflicts" from "not known" where only one of the two exists.
for _, member := range []string{"contradicts", "bank_holds", "variants"} {
got, ok := body.Offered[0][member].([]any)
if !ok || got == nil {
t.Errorf("`%s` reached the client as %v, want an empty array", member, body.Offered[0][member])
}
}
}
// ⛔ "Could not read the question" must not be byte-identical to "the stop asked nothing" — the one
// thing this surface may never say by accident, at the one moment the product stops to ask a person.
func TestAnUnreadableStopIsNotAStopThatAskedNothing(t *testing.T) {
unreadable := decode(t, call(t, readingServer(t, &fakeLibrary{
signingStop: pgstore.BankSigningStop{Unreadable: true},
}), "GET", "/v0/books/bk_1/bank/signing-stop", ""))
asked := decode(t, call(t, readingServer(t, &fakeLibrary{
signingStop: pgstore.BankSigningStop{Offered: []pgstore.BankOfferedTerm{}},
}), "GET", "/v0/books/bk_1/bank/signing-stop", ""))
if unreadable["unreadable"] != true {
t.Errorf("an unreadable stop answered %v", unreadable)
}
if asked["unreadable"] != false {
t.Errorf("a stop that asked nothing answered %v", asked)
}
if len(unreadable["offered"].([]any)) != 0 || len(asked["offered"].([]any)) != 0 {
t.Errorf("both answers should carry an empty list:\n %v\n %v", unreadable, asked)
}
}
// Completeness rides the whole-bank aggregates: the first page carries it, later pages do not, and
// `null` there means nothing measured it — never that the bank is whole.
func TestCompletenessRidesTheFirstPageAndItsAbsenceIsNotWholeness(t *testing.T) {
lib := &fakeLibrary{bank: pgstore.BankPage{First: true,
Consolidation: &pgstore.BankConsolidation{RenderBatchesDropped: 5, Unanswered: 47}}}
first := decode(t, call(t, readingServer(t, lib), "GET", "/v0/books/bk_1/bank", ""))
c, ok := first["consolidation"].(map[string]any)
if !ok {
t.Fatalf("the first page carries no completeness: %v", first)
}
for _, member := range requiredOf(t, "BankConsolidation") {
if _, ok := c[member]; !ok {
t.Errorf("the canon requires `%s` on completeness and the wire does not carry it: %v", member, c)
}
}
if c["complete"] != false || c["render_batches_dropped"] != float64(5) || c["unanswered"] != float64(47) {
t.Errorf("the numbers were rewritten on the way out: %v", c)
}
nothing := decode(t, call(t, readingServer(t, &fakeLibrary{bank: pgstore.BankPage{First: true}}),
"GET", "/v0/books/bk_1/bank", ""))
if got, present := nothing["consolidation"]; present && got != nil {
t.Errorf("a bank nothing measured reported completeness as %v", got)
}
}
// requiredOf reads the `required` list of one schema from the ratified canon.
func requiredOf(t *testing.T, schema string) []string {
t.Helper()
raw, err := os.ReadFile(canon)
if err != nil {
// Not skipped: a missing canon leaves these members with no check at all except a human
// noticing, which is the thing this gate replaces.
t.Fatalf("the ratified canon could not be read: %v", err)
}
var doc struct {
Components struct {
Schemas map[string]struct {
Required []string `yaml:"required"`
} `yaml:"schemas"`
} `yaml:"components"`
}
if err := yaml.Unmarshal(raw, &doc); err != nil {
t.Fatalf("the canon does not parse: %v", err)
}
s, ok := doc.Components.Schemas[schema]
if !ok {
t.Fatalf("the canon carries no schema %q: this gate reads the wrong document", schema)
}
if len(s.Required) == 0 {
t.Fatalf("the canon's %s declares nothing required: a gate over an empty list is a coin", schema)
}
return s.Required
}

View file

@ -41,6 +41,7 @@ type Library interface {
ListUnits(ctx context.Context, userID, bookID, chapterID string, limit int, cursor string) (pgstore.UnitPage, error)
ListNotes(ctx context.Context, userID, bookID string, limit int, cursor string, after *int64) (pgstore.NotePage, error)
ListBank(ctx context.Context, userID, bookID string, limit int, cursor string, after *int64) (pgstore.BankPage, error)
BankSigningStop(ctx context.Context, userID, bookID string) (pgstore.BankSigningStop, error)
ReadStream(ctx context.Context, userID, bookID string) (pgstore.StreamState, error)
ReadFrames(ctx context.Context, bookID string, after int64, limit int) ([]pgstore.Frame, error)
}
@ -90,6 +91,10 @@ var contractSurface = []struct {
{method: "GET", path: "/books/{bookId}/chapters/{chapterId}/units", mounts: hasLibrary, handler: func(h *v0) http.HandlerFunc { return h.listUnits }},
{method: "GET", path: "/books/{bookId}/notes", mounts: hasLibrary, handler: func(h *v0) http.HandlerFunc { return h.listNotes }},
{method: "GET", path: "/books/{bookId}/bank", mounts: hasLibrary, handler: func(h *v0) http.HandlerFunc { return h.listBank }},
// What the last signing stop asked about (canon 0.16.0, engine backlog row 224). Under the library
// mount and not the correction one: reading what a stop asks is not the same capability as being
// able to answer it, and a deployment that cannot take corrections still has to show the question.
{method: "GET", path: "/books/{bookId}/bank/signing-stop", mounts: hasLibrary, handler: func(h *v0) http.HandlerFunc { return h.readBankSigningStop }},
// The correction door (canon 0.5.0). Unmounted it answers the guarded 404 the canon promises for
// `Capabilities.bank_corrections_enabled: false` — the same fact decides both. Its body cap IS
// the canon's 1 MiB document ceiling, so it rides the default deliberately.

View file

@ -23,16 +23,17 @@ import (
// a projection is exactly the kind of code that drifts from it silently: nothing in Go fails when a
// json tag is misspelled, and the client that breaks is in another repository.
type fakeLibrary struct {
lib pgstore.Library
book pgstore.Book
run *pgstore.Run
usage pgstore.Usage
chapters pgstore.ChapterPage
units pgstore.UnitPage
notes pgstore.NotePage
bank pgstore.BankPage
stream pgstore.StreamState
frames []pgstore.Frame
lib pgstore.Library
book pgstore.Book
run *pgstore.Run
usage pgstore.Usage
chapters pgstore.ChapterPage
units pgstore.UnitPage
notes pgstore.NotePage
bank pgstore.BankPage
signingStop pgstore.BankSigningStop
stream pgstore.StreamState
frames []pgstore.Frame
// onSecondRead mutates the state the SECOND time a stream asks for it, which is how a test says
// "this changed while the client was connected".
onSecondRead func(*pgstore.StreamState)
@ -94,6 +95,10 @@ func (f *fakeLibrary) ListBank(context.Context, string, string, int, string, *in
return f.bank, f.err
}
func (f *fakeLibrary) BankSigningStop(context.Context, string, string) (pgstore.BankSigningStop, error) {
return f.signingStop, f.err
}
func (f *fakeLibrary) ReadStream(context.Context, string, string) (pgstore.StreamState, error) {
f.streamReads++
if f.streamReads == 2 && f.onSecondRead != nil {

View file

@ -41,7 +41,96 @@ type BankTerm struct {
// Bank is the whole read-out.
type Bank struct {
// Boundary is which of the run's five publishing moments wrote this document, in the contract's
// words. It is read WITH the sections and not instead of them: the engine publishes `offered` at
// the signing stop and nowhere else, so a section standing at any other boundary is a document
// this build does not understand, and serving its rows would invent a stop that is not there.
Boundary string
// RunID is the anchor's other half — the id the engine announces its event stream under, which is
// the id this platform minted for the attempt (pgstore.EngineStreamID). Empty means the write had
// no run identity: unknown, never "current". Read and stored, never served: it is an engine
// identity, and the seam does not export those.
RunID string
Terms []BankTerm
// Offered is what the signing stop is asking about — mined rows NOT in the bank yet, in the stop's
// own ranking. A pointer to the slice because nil ("the document carried no section") and an empty
// slice ("the stop asked nothing") are different answers to a person looking at an empty screen,
// and a plain slice decodes both to nil.
Offered *[]OfferedTerm
// Consolidation is how complete the bank being signed is, nil when nothing measured it. The engine
// makes the section absent rather than zeroed where the paid pass never ran, because a zeroed one
// answers "consolidated 0, unanswered 0" — which reads as "nothing is missing".
Consolidation *Consolidation
}
// OfferedTerm is one row of a signing stop's table, in the contract's own words.
//
// ⛔ The numbers are pointers and the strings are not. For a field the engine writes ALWAYS, absence
// means a build without that field wrote the document — and for these four the zero value is itself a
// measurement (`freq: 0` is the engine's "only the draft side saw it"; a stated `conf: 0` is the row a
// signer must look at), so decoding absence to zero publishes a figure nobody took. Measured, not
// hypothetical: `conventions` is absent from all 69 rows of one bought stop read-out and present on
// all 66 of the other, because the field was added between the two runs. The empty string carries no
// such second meaning, so the strings lose nothing by letting absence land on them.
type OfferedTerm struct {
Src string
// Dst is the consolidated rendering, "" when this read-out carries none — and it does NOT say why.
// The engine's own sheet spends four facts on that glyph, one of which ("the bank already renders
// this surface, nothing to decide") it distinguishes as BankStopRow.SettledByBank and deliberately
// does not project here. Nothing downstream may read "" as "the service could not translate it".
Dst string
// Kind and Channel are "" for what this build cannot name — a kind the engine did not decide, a
// detector outside the closed set. Never a guess: Channel is how a person judges how well
// corroborated a row is, and invented corroboration is what reading further cannot undo.
Kind, Channel string
// Conventions is NOT re-derived from len(Variants) when absent, although the engine's projection
// makes them equal today: deriving is this side re-implementing engine law, and the engine's own
// comment records that reading one of these numbers as the other already cost two sessions a
// misfiled defect.
Freq, Spread, Conventions *int
// Confidence is nil when the service stated none — INCLUDING the engine's own spelling for it, a
// negative number. Folded here at the seam rather than at projection time, like every other
// crossing in this file and for the reason the note at the top gives: a translation left to a
// later path is one a later path can forget, and this one WAS forgotten — the canon promises
// `0..100` or null and a build that carried the minus through would have shown a person "-1 %".
//
// ⚠ It folds "the read-out carried no `conf` at all" into the same nil, and that is measured
// rather than assumed: `conf` entered the projection in the SAME commit as the section it lives
// in, so no build emitting the section omits it (0 of 135 rows across two bought read-outs).
// THE CONDITION THAT ENDS THAT: a build that starts omitting it — then absence stops being
// unreachable and the two causes need telling apart again.
Confidence *int
// Invented — the rendering is not one the drafts proposed, the class the engine names as the one to
// read first. A plain bool because the engine omits the field when false. ⚠ THE CONDITION THAT ENDS
// THAT: the day `omitempty` leaves the engine's field, absence stops meaning false and this has to
// become a pointer. Nothing here goes red on that day.
Invented bool
// Two lists and never one: "this run disagreed with itself" and "the book already calls it
// something else" are different decisions.
Contradicts, BankHolds []string
// The drafts' renderings, best-ranked first, in the engine's own labelling — opaque here and never
// parsed. It keeps a reader beside that writer precisely because a re-derived parser "would keep
// working until the day the label gains a field, and then it would report numbers rather than an
// error".
Variants []string
}
// Consolidation is what the paid terminology pass MANAGED. Six numbers and not one, because a cut
// RENDER pass leaves the bank partial while a cut CLASSIFY pass leaves it whole with the types
// unrefined — a different budget and a different remedy — and rows the role was never asked about are
// a saving rather than a gap.
type Consolidation struct {
// Complete comes from the engine ready-made: it is computed off the render pass alone, and a
// second mechanism answering that here is how the two come to disagree.
Complete bool
// Two cuts and never one sum: an operator raises one budget or the other, never "the" budget.
RenderBatchesDropped, ClassifyBatchesDropped int
Consolidated, Declined int
// Unanswered conflates the role's silence with the budget's cut — it means "the role stayed
// silent" only where Complete is true.
Unanswered int
// NeverAsked is the opposite of a gap: the bank already renders the surface and every draft agreed.
NeverAsked int
}
// The contract's TermStatus and TermOrigin values.
@ -55,10 +144,41 @@ const (
OriginFound = "found"
)
// The contract's ReadOutBoundary values, stored but never served. The engine's own names are pipeline
// vocabulary (`bank-mining` is a stage, `redrive` an operator's verb), which the note at the top of
// this file refuses to pass through.
//
// ⛔ BoundaryUnknown covers both a name this build does not know and the empty one a document from
// before the anchor carries. Safe to collapse because the consequence is identical — nothing says this
// is a stop — and the one thing neither may become is BoundarySignatureRequested.
const (
BoundaryRunStarted = "run_started"
BoundaryTermsTakenUnsigned = "terms_taken_unsigned"
BoundarySignatureRequested = "signature_requested"
BoundaryRunFinished = "run_finished"
BoundaryBookReseeded = "book_reseeded"
BoundaryUnknown = "unknown"
)
// The contract's OfferedTermChannel values. The engine's four — `mined`, `banknote`, `both`, `alias` —
// are pipeline vocabulary, one of them the very word the contract renamed in 0.3.0 so it could not
// reach a client.
const (
ChannelSourceText = "source_text"
ChannelTranslatedText = "translated_text"
ChannelBoth = "both"
ChannelAlias = "alias"
)
type wireBank struct {
Version string `json:"bank_version"`
BookID string `json:"book_id"`
Terms []struct {
// Additive on tm-bank-v1 and absent in documents written before the anchor existed, so an absent
// AsOf lands on BoundaryUnknown rather than refusing the document: such a read-out is old, not
// malformed.
AsOf string `json:"as_of"`
RunID string `json:"run_id"`
Terms []struct {
ID string `json:"id"`
Src string `json:"src"`
Dst string `json:"dst"`
@ -69,6 +189,34 @@ type wireBank struct {
SinceChapter int `json:"since_chapter"`
UntilChapter int `json:"until_chapter"`
} `json:"terms"`
// Pointers, so that an absent member stays distinguishable from an empty one — see Bank.Offered.
Proposed *[]wireOffered `json:"proposed"`
Consolidation *wireConsolidation `json:"consolidation"`
}
type wireOffered struct {
Src string `json:"src"`
Dst string `json:"dst"`
Kind string `json:"kind"`
Channel string `json:"channel"`
Freq *int `json:"freq"`
Spread *int `json:"spread"`
Conventions *int `json:"conventions"`
Conf *int `json:"conf"`
Invented bool `json:"invented"`
Contradicts []string `json:"contradicts"`
BankHolds []string `json:"bank_holds"`
Variants []string `json:"variants"`
}
type wireConsolidation struct {
Complete bool `json:"complete"`
RenderBatchesDropped int `json:"render_batches_dropped"`
ClassifyBatchesDropped int `json:"classify_batches_dropped"`
Consolidated int `json:"consolidated"`
Declined int `json:"declined"`
Unanswered int `json:"unanswered"`
NeverAsked int `json:"never_asked"`
}
// DecodeBank parses a bank read-out, and refuses anything that does not identify itself as one — the
@ -82,7 +230,10 @@ func DecodeBank(b []byte) (Bank, error) {
if doc.Version == "" {
return Bank{}, fmt.Errorf("ingest: decode bank: the document carries no bank_version, so it is not a bank")
}
out := Bank{Terms: make([]BankTerm, 0, len(doc.Terms))}
out := Bank{
Boundary: readOutBoundary(doc.AsOf), RunID: doc.RunID,
Terms: make([]BankTerm, 0, len(doc.Terms)),
}
for _, t := range doc.Terms {
status, ok := termStatus(t.Status)
if !ok {
@ -105,9 +256,146 @@ func DecodeBank(b []byte) (Bank, error) {
SinceChapter: chapterBound(t.SinceChapter), UntilChapter: chapterBound(t.UntilChapter),
})
}
out.Offered = decodeOffered(doc.Proposed)
out.Consolidation = decodeConsolidation(doc.Consolidation)
return out, nil
}
// decodeOffered reads the stop's section. The nil check IS the section's contract: no member means no
// section, a section with no rows means a stop that asked nothing.
func decodeOffered(rows *[]wireOffered) *[]OfferedTerm {
if rows == nil {
return nil
}
out := make([]OfferedTerm, 0, len(*rows))
for _, r := range *rows {
out = append(out, OfferedTerm{
Src: r.Src, Dst: r.Dst, Kind: termKind(r.Kind), Channel: offeredChannel(r.Channel),
Freq: statedCount(r.Freq), Spread: statedCount(r.Spread),
Conventions: statedCount(r.Conventions),
Confidence: statedConfidence(r.Conf), Invented: r.Invented,
Contradicts: r.Contradicts, BankHolds: r.BankHolds, Variants: r.Variants,
})
}
return &out
}
// decodeConsolidation copies the numbers and derives none of them.
//
// ⚠ A section that is PRESENT and empty decodes to "not complete, nothing consolidated" — the
// cautious direction, and the opposite of the rule for the numbers above, where a zero would have
// invented a measurement. The two look contradictory until you ask which way each zero errs.
//
// ⛔ AND THE CONDITION UNDER WHICH THAT STOPS HOLDING, because it holds only by an accident of how
// the writer grew: the seven fields entered the engine's projection in ONE commit, so no build emits
// a partial section and "present" always means all seven. The day the engine adds an eighth number,
// a document written by the older build decodes it as zero — and `unanswered: 0` says "the role
// stayed silent about nothing", which is the false zero this whole reader exists to refuse. It is
// cautious for `complete` and a lie for the counters, and the difference appears the moment the
// field set moves. Then these become pointers, like the four above.
func decodeConsolidation(c *wireConsolidation) *Consolidation {
if c == nil {
return nil
}
return &Consolidation{
Complete: c.Complete,
Consolidated: c.Consolidated, Declined: c.Declined,
Unanswered: c.Unanswered, NeverAsked: c.NeverAsked,
RenderBatchesDropped: c.RenderBatchesDropped,
ClassifyBatchesDropped: c.ClassifyBatchesDropped,
}
}
// BoundaryOrUnknown is the closed set as a GUARD, for a caller that did not come through DecodeBank.
// The zero value of a Bank carries no boundary, and a read-out whose moment nobody named is exactly
// what BoundaryUnknown is for — refusing instead would fail a whole bank save over a label.
func BoundaryOrUnknown(s string) string {
switch s {
case BoundaryRunStarted, BoundaryTermsTakenUnsigned, BoundarySignatureRequested,
BoundaryRunFinished, BoundaryBookReseeded:
return s
}
return BoundaryUnknown
}
// KindOrNone and ChannelOrNone are the same guard for the two closed vocabularies an offered term
// carries, whose "cannot name it" is "" rather than a value.
//
// ⛔ They are NOT what `nullif($, ”)` does in the statement, and the difference is a live failure
// rather than a nicety: `nullif` turns the EMPTY value into null, while these turn an UNKNOWN
// non-empty one into the empty value first. Measured — an engine word that leaked this far without a
// guard does not land as "not named", it violates the column's CHECK and takes the WHOLE bank save
// down, at the one boundary a person is standing at.
func KindOrNone(s string) string { return termKind(s) }
func ChannelOrNone(s string) string {
switch s {
case ChannelSourceText, ChannelTranslatedText, ChannelBoth, ChannelAlias:
return s
}
return ""
}
// ⛔ A NUMBER OUTSIDE THE RANGE THE CONTRACT DECLARES IS READ AS «NOT STATED» — the same discipline
// the vocabularies above follow, one floor down: a value this build cannot name becomes the one that
// claims the least, never a guess and never a pass-through.
//
// It is a CLASS and not one field, which is how it was found: the engine spells "the reply carried no
// confidence" as a negative, and a build that let that through would show a person "-1 %" on the one
// screen that asks them to decide. But the canon bounds `freq`, `spread` and `conventions` too, and
// nothing there is a sentinel — a negative count is simply a document this build does not understand,
// and publishing it would be the same lie in a quieter place.
//
// ⚠ The bounds are mirrored from the canon rather than read from it at runtime, like the closed
// vocabularies above, and like them they are gated against it:
// TestEveryRangeTheCanonDeclaresIsOneThisReaderEnforces reads `minimum`/`maximum` out of the canon and
// fails on any ranged member this file does not bound. THE CONDITION THAT ENDS THE MIRRORING: a canon
// that gives a member a range this file has no branch for — which is exactly what that gate says.
func statedCount(v *int) *int { return inRange(v, 0, -1) }
func statedConfidence(v *int) *int { return inRange(v, 0, 100) }
// inRange keeps a value the contract would accept and drops one it would not. A negative max means
// "no ceiling", which is what the canon says by declaring `minimum` alone.
func inRange(v *int, min, max int) *int {
if v == nil || *v < min || (max >= 0 && *v > max) {
return nil
}
return v
}
func readOutBoundary(engine string) string {
switch engine {
case "run-start/seeded":
return BoundaryRunStarted
case "bank-mining/auto-continue":
return BoundaryTermsTakenUnsigned
case "bank-mining/signature-stop":
return BoundarySignatureRequested
case "run-finished":
return BoundaryRunFinished
case "redrive/re-seeded":
return BoundaryBookReseeded
}
return BoundaryUnknown
}
// offeredChannel returns "" for a detector outside the closed set — the same spelling termKind uses
// for a value this build cannot name, so one convention carries "not nameable" all the way out.
func offeredChannel(engine string) string {
switch engine {
case "mined":
return ChannelSourceText
case "banknote":
return ChannelTranslatedText
case "both":
return ChannelBoth
case "alias":
return ChannelAlias
}
return ""
}
func termStatus(engine string) (string, bool) {
switch engine {
case "auto":

View file

@ -0,0 +1,392 @@
package ingest
import (
"encoding/json"
"fmt"
"os"
"testing"
"gopkg.in/yaml.v3"
)
// bankreadout_test.go: the two sections a signing stop publishes beside the bank — engine backlog
// rows 224 and 253. Until this build the reader took `terms` alone, which at a stop is EMPTY by
// construction, so the one screen in the product that asks a person for a decision was served a zero
// that meant "the reader does not look here", not "there is nothing to decide".
const bankHead = `"bank_version":"tm-bank-v1","book_id":"bk_1","terms":[]`
// ⛔ THE PIN THIS PACK EXISTS FOR, one floor down from the screen: a section that is not in the
// document and a section that is there and holds nothing are different answers, and a reader that
// returns an empty slice for both has rebuilt the false zero it was written to remove. At a stop the
// first means "this read-out cannot tell you"; the second means "the stop asked nothing".
//
// Mutation this must catch: decoding `proposed` into a plain []Suggestion — json writes nil for both
// cases and every caller downstream then reports "no suggestions" for a document that never carried
// the section.
func TestASectionTheDocumentDoesNotCarryIsNotASectionThatIsEmpty(t *testing.T) {
absent, err := DecodeBank([]byte(`{` + bankHead + `}`))
if err != nil {
t.Fatal(err)
}
if absent.Offered != nil {
t.Errorf("a document with no `proposed` member reported a section: %+v", absent.Offered)
}
if absent.Consolidation != nil {
t.Errorf("a document with no `consolidation` member reported one: %+v", absent.Consolidation)
}
empty, err := DecodeBank([]byte(`{` + bankHead + `,"proposed":[],"consolidation":{}}`))
if err != nil {
t.Fatal(err)
}
if empty.Offered == nil {
t.Fatal("a `proposed` section that is present and empty was reported as absent")
}
if len(*empty.Offered) != 0 {
t.Errorf("an empty section decoded %d rows", len(*empty.Offered))
}
if empty.Consolidation == nil {
t.Fatal("a `consolidation` section that is present and empty was reported as absent")
}
// The premise the assertion above stands on, asserted so the fixture cannot lose its power to
// tell the two apart: an empty consolidation errs toward warning the signer, the opposite
// direction from the numbers below, and if that ever flips the test is measuring nothing.
if empty.Consolidation.Complete {
t.Error("an empty consolidation section claimed the bank is complete")
}
}
// ⛔ The measured case, not a hypothetical. `conventions` has no `omitempty` on the engine's side, so
// it is written ALWAYS — and it is absent from all 69 rows of one bought stop read-out and present on
// all 66 of the other, because the field was added to the projection between the two runs. An absent
// mandatory number therefore means "an engine build without this field wrote the document", and zero
// is a measurement: `freq: 0` is the engine's own "only the draft side saw it".
//
// Mutation this must catch: decoding any of the four numbers into a plain int.
func TestAMandatoryNumberTheReadOutDoesNotCarryIsNotZero(t *testing.T) {
doc := `{` + bankHead + `,"proposed":[{"src":"丙等","dst":"третий разряд","kind":"title","channel":"banknote","variants":["третий ранг ×1"]}]}`
bank, err := DecodeBank([]byte(doc))
if err != nil {
t.Fatal(err)
}
got := (*bank.Offered)[0]
for name, p := range map[string]*int{
"freq": got.Freq, "spread": got.Spread, "conventions": got.Conventions, "confidence": got.Confidence,
} {
if p != nil {
t.Errorf("%s was absent from the read-out and came back as %d, which is a figure nobody measured", name, *p)
}
}
stated := `{` + bankHead + `,"proposed":[{"src":"丙等","freq":0,"spread":0,"conventions":0,"conf":0}]}`
bank, err = DecodeBank([]byte(stated))
if err != nil {
t.Fatal(err)
}
got = (*bank.Offered)[0]
for name, p := range map[string]*int{
"freq": got.Freq, "spread": got.Spread, "conventions": got.Conventions, "confidence": got.Confidence,
} {
if p == nil {
t.Errorf("%s was stated as 0 in the read-out and came back as «not stated»", name)
} else if *p != 0 {
t.Errorf("%s was stated as 0 and came back as %d", name, *p)
}
}
}
// ⛔ THE ENGINE'S SENTINEL NEVER REACHES A CLIENT. It spells "the reply carried no confidence" as a
// NEGATIVE number, which is a sentinel and not a measurement; the canon promises `0..100` or null, so
// a build that carried the minus through would show a person "-1 %" on the one screen that asks them
// to decide. Folded at the SEAM, where every other crossing in this file is folded, because a
// translation left to a later path is one a later path can forget — and this one WAS forgotten once,
// between a canon that said `minimum: 0` and a projection that passed the value straight out.
//
// ⚠ A stated ZERO must survive: "the service said it was 0 % sure" is the first row a person should
// look at, and "the service said nothing" is not a row about confidence at all. The two are one byte
// apart in the engine's encoding and opposite in meaning.
//
// Mutation this must catch: returning `conf` unchanged from statedConfidence, or testing `<= 0`.
func TestTheEnginesNoConfidenceSentinelNeverReachesAClient(t *testing.T) {
bank, err := DecodeBank([]byte(`{` + bankHead + `,"proposed":[{"src":"a","conf":-1},{"src":"b","conf":0},{"src":"c"},{"src":"d","conf":95}]}`))
if err != nil {
t.Fatal(err)
}
rows := *bank.Offered
if rows[0].Confidence != nil {
t.Errorf("the engine's «no confidence stated» (-1) reached the wire as %d", *rows[0].Confidence)
}
if rows[1].Confidence == nil || *rows[1].Confidence != 0 {
t.Errorf("a stated confidence of 0 was folded into «not stated»: %v", rows[1].Confidence)
}
if rows[2].Confidence != nil {
t.Errorf("a row whose read-out carries no `conf` at all came back as %d", *rows[2].Confidence)
}
if rows[3].Confidence == nil || *rows[3].Confidence != 95 {
t.Errorf("a stated confidence of 95 came back as %v", rows[3].Confidence)
}
// The canon's own range, asserted over every row rather than over the one that motivated this:
// a value outside it is a document a generated client refuses.
for i, r := range rows {
if r.Confidence != nil && (*r.Confidence < 0 || *r.Confidence > 100) {
t.Errorf("row %d carries a confidence of %d, outside the canon's 0..100", i, *r.Confidence)
}
}
}
// `invented` is the class the engine names as the one to read FIRST — the consolidated rendering is
// not one any draft proposed. A reader that loses it shows a person a list in which nothing says the
// service made the name up. Measured: 11 of 69 rows in one bought read-out, 17 of 66 in the other.
func TestTheClassAPersonReadsFirstSurvivesTheReadOut(t *testing.T) {
bank, err := DecodeBank([]byte(`{` + bankHead + `,"proposed":[{"src":"a","invented":true},{"src":"b"}]}`))
if err != nil {
t.Fatal(err)
}
if !(*bank.Offered)[0].Invented {
t.Error("the engine said it invented this rendering and the reader dropped it")
}
if (*bank.Offered)[1].Invented {
t.Error("a row the engine did not mark came back marked")
}
}
// ⛔ A section is only ever read TOGETHER with the boundary that wrote it: the read-out is published
// at five of them and its own export warns three times over that the file may be a previous one's.
// "This document is not from a signing stop" and "the stop had nothing to ask" are different answers
// to a person staring at an empty screen.
//
// The engine's words are pipeline vocabulary and none passes through. Anything this build cannot name
// — including the empty string a document from before the freshness anchor carries — is the value
// that claims the LEAST, and the one thing it may never become is the stop.
func TestTheBoundaryIsTranslatedAndAnUnnameableOneIsNeverTheStop(t *testing.T) {
for engine, want := range map[string]string{
"run-start/seeded": BoundaryRunStarted,
"bank-mining/auto-continue": BoundaryTermsTakenUnsigned,
"bank-mining/signature-stop": BoundarySignatureRequested,
"run-finished": BoundaryRunFinished,
"redrive/re-seeded": BoundaryBookReseeded,
"bank-mining/some-boundary-added-later": BoundaryUnknown,
"": BoundaryUnknown,
} {
bank, err := DecodeBank([]byte(`{` + bankHead + `,"as_of":` + quote(engine) + `}`))
if err != nil {
t.Fatal(err)
}
if bank.Boundary != want {
t.Errorf("boundary %q read as %q, want %q", engine, bank.Boundary, want)
}
if engine != "bank-mining/signature-stop" && bank.Boundary == BoundarySignatureRequested {
t.Errorf("boundary %q was read as the signing stop", engine)
}
}
// The run identity is the anchor's other half and is read, not dropped: half an anchor is a
// freshness claim nothing supports.
bank, err := DecodeBank([]byte(`{` + bankHead + `,"run_id":"tm-stream-run_X-1"}`))
if err != nil {
t.Fatal(err)
}
if bank.RunID != "tm-stream-run_X-1" {
t.Errorf("the read-out's run identity came back as %q", bank.RunID)
}
}
// The detector's name is pipeline vocabulary — `mined` is the very word the contract renamed in 0.3.0
// so that it could not reach a client — so all four are translated and an unrecognised one becomes
// `unknown` rather than a guess. This axis is how a person judges how well corroborated a suggestion
// is, and inventing corroboration is the error reading further cannot undo.
func TestTheDetectorVocabularyDoesNotReachAClient(t *testing.T) {
for engine, want := range map[string]string{
"mined": ChannelSourceText, "banknote": ChannelTranslatedText,
"both": ChannelBoth, "alias": ChannelAlias, "surveyor": "", "": "",
} {
bank, err := DecodeBank([]byte(`{` + bankHead + `,"proposed":[{"src":"a","channel":` + quote(engine) + `}]}`))
if err != nil {
t.Fatal(err)
}
if got := (*bank.Offered)[0].Channel; got != want {
t.Errorf("channel %q read as %q, want %q", engine, got, want)
}
}
}
// The completeness numbers are COPIED and never re-derived: the engine computes `complete` off the
// render pass alone, and a classifier cut does not mean an incomplete bank. A second mechanism
// answering the same question on this side is how the two come to disagree — which is why the whole
// section is taken ready-made.
func TestTheCompletenessNumbersAreTakenReadyMade(t *testing.T) {
raw := `{"complete":false,"render_batches_dropped":5,"classify_batches_dropped":2,` +
`"consolidated":38,"declined":1,"unanswered":47,"never_asked":7}`
bank, err := DecodeBank([]byte(`{` + bankHead + `,"consolidation":` + raw + `}`))
if err != nil {
t.Fatal(err)
}
var want wireConsolidation
if err := json.Unmarshal([]byte(raw), &want); err != nil {
t.Fatal(err)
}
got := *bank.Consolidation
if got.Complete != want.Complete || got.RenderBatchesDropped != want.RenderBatchesDropped ||
got.ClassifyBatchesDropped != want.ClassifyBatchesDropped || got.Consolidated != want.Consolidated ||
got.Declined != want.Declined || got.Unanswered != want.Unanswered || got.NeverAsked != want.NeverAsked {
t.Errorf("the section was rewritten on the way in:\n got %+v\nwant %+v", got, want)
}
}
// The ORACLE: a read-out a real run actually published, handed in by the operator. The fixtures above
// are this build's own idea of the document; this one is the engine's, and the two bought stop
// read-outs it was written against differ from each other in exactly the way that matters — one
// carries `conventions` on every row and the other on none.
//
// Gated like every test that needs something this host may not have, and it SAYS what is missing.
func TestARealReadOutIsReadTheWayThisBuildClaims(t *testing.T) {
path := os.Getenv("TM_PLATFORM_TEST_BANK_READOUT")
if path == "" {
t.Skip("TM_PLATFORM_TEST_BANK_READOUT not set: the reader is not checked against a read-out a real run published")
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
bank, err := DecodeBank(raw)
if err != nil {
t.Fatalf("a read-out a real run published was refused: %v", err)
}
// What the document itself says, read independently of the type under test — otherwise the
// assertion is the decoder agreeing with itself.
var doc map[string]json.RawMessage
if err := json.Unmarshal(raw, &doc); err != nil {
t.Fatal(err)
}
section, present := doc["proposed"]
if present != (bank.Offered != nil) {
t.Fatalf("the document %s a `proposed` member and the reader reported the section %s",
ifElse(present, "carries", "does not carry"), ifElse(bank.Offered != nil, "present", "absent"))
}
if present {
var rows []map[string]any
if err := json.Unmarshal(section, &rows); err != nil {
t.Fatal(err)
}
if len(rows) != len(*bank.Offered) {
t.Fatalf("the document carries %d suggestions and the reader returned %d", len(rows), len(*bank.Offered))
}
for i, row := range rows {
_, stated := row["conventions"]
if got := (*bank.Offered)[i].Conventions; stated != (got != nil) {
t.Fatalf("row %d: the document %s `conventions` and the reader reported it %s",
i, ifElse(stated, "states", "does not state"), ifElse(got != nil, "stated", "absent"))
}
if got := (*bank.Offered)[i].Channel; got == "" {
t.Errorf("row %d: this build cannot name the detector %q the engine wrote", i, row["channel"])
}
}
t.Logf("read-out %s: boundary=%s offered=%d terms=%d consolidation=%v",
path, bank.Boundary, len(*bank.Offered), len(bank.Terms), bank.Consolidation != nil)
}
if _, present := doc["consolidation"]; present != (bank.Consolidation != nil) {
t.Errorf("the document %s a `consolidation` member and the reader reported it %s",
ifElse(present, "carries", "does not carry"), ifElse(bank.Consolidation != nil, "present", "absent"))
}
if bank.Boundary == BoundaryUnknown {
t.Errorf("this build cannot name the boundary the engine wrote; the document's `as_of` is %s", doc["as_of"])
}
}
func quote(s string) string {
b, err := json.Marshal(s)
if err != nil {
panic(err)
}
return string(b)
}
func ifElse(cond bool, yes, no string) string {
if cond {
return yes
}
return no
}
// ⛔ EVERY RANGE THE CANON DECLARES IS ONE THIS READER ENFORCES — and the gate reads the canon rather
// than a copy of its numbers, so a field given a range tomorrow is covered without anybody
// remembering this test.
//
// It exists because the neighbouring gate proves MEMBERSHIP and stops there: it reads `required` and
// never `minimum`, which is the shape "take the form and not the guarantee" — in a gate. That gap let
// a canon promising `0..100` stand beside a chain that carried the engine's `-1` all the way out,
// pinned on both sides, for a whole delivery.
//
// Mutation this must catch: any numeric field whose canon range is not honoured by the decoder.
func TestEveryRangeTheCanonDeclaresIsOneThisReaderEnforces(t *testing.T) {
raw, err := os.ReadFile("../../../docs/architecture/14-api-contract/openapi.yaml")
if err != nil {
// Not skipped: a missing canon would leave these ranges with nothing to be checked against.
t.Fatalf("the ratified canon could not be read: %v", err)
}
var doc struct {
Components struct {
Schemas map[string]struct {
Properties map[string]struct {
Minimum *int `yaml:"minimum"`
Maximum *int `yaml:"maximum"`
} `yaml:"properties"`
} `yaml:"schemas"`
} `yaml:"components"`
}
if err := yaml.Unmarshal(raw, &doc); err != nil {
t.Fatal(err)
}
// The engine's field names, against the contract's — the reader is the thing that maps between
// them, so a gate over both has to carry the pair.
engineName := map[string]string{"freq": "freq", "spread": "spread",
"conventions": "conventions", "confidence": "conf"}
ranged := 0
for member, spec := range doc.Components.Schemas["OfferedTerm"].Properties {
if spec.Minimum == nil && spec.Maximum == nil {
continue
}
ranged++
engine, ok := engineName[member]
if !ok {
t.Errorf("the canon gives `%s` a range and this gate does not know which engine field feeds it", member)
continue
}
for _, outside := range outsideOf(spec.Minimum, spec.Maximum) {
bank, err := DecodeBank([]byte(fmt.Sprintf(`{%s,"proposed":[{"src":"x","%s":%d}]}`,
bankHead, engine, outside)))
if err != nil {
t.Fatal(err)
}
got := map[string]*int{
"freq": (*bank.Offered)[0].Freq, "spread": (*bank.Offered)[0].Spread,
"conventions": (*bank.Offered)[0].Conventions, "confidence": (*bank.Offered)[0].Confidence,
}[member]
if got == nil {
continue // read as "not stated", which is inside the contract
}
if (spec.Minimum != nil && *got < *spec.Minimum) || (spec.Maximum != nil && *got > *spec.Maximum) {
t.Errorf("the canon bounds `%s` and the reader passed %d straight through from the engine's %d",
member, *got, outside)
}
}
}
// The gate's own denominator: a canon whose ranges this gate silently found none of would pass
// while proving nothing.
if ranged == 0 {
t.Fatal("the canon's OfferedTerm declares no ranges at all: this gate is reading the wrong schema")
}
t.Logf("ranges read from the canon and exercised: %d", ranged)
}
// outsideOf returns values just outside a declared range — one below the floor, one above the ceiling.
func outsideOf(min, max *int) []int {
var out []int
if min != nil {
out = append(out, *min-1)
}
if max != nil {
out = append(out, *max+1)
}
return out
}

View file

@ -0,0 +1,313 @@
package pgstore
import (
"errors"
"testing"
"textmachine/platform/internal/ingest"
)
// bankreadout_test.go: the storage half of engine backlog rows 224 and 253. Before it the read model
// took the read-out's `terms` alone, which at a signing stop is empty by construction — so the one
// screen that asks a person for a decision answered zero while 69 and 66 questions lay unread in the
// same document.
func stopReadOut(offered []ingest.OfferedTerm) ingest.Bank {
return ingest.Bank{
Boundary: ingest.BoundarySignatureRequested, RunID: "tm-stream-run_X-1",
Offered: &offered,
}
}
func intp(n int) *int { return &n }
// ⛔ THE THREE ABSENCES, and the pin exists because they look identical on a screen and are not: a book
// whose runs never published a read-out · a read-out carrying no such section · a section that is there
// and holds nothing. Only the last means "nothing to sign".
//
// Mutations this must catch: answering Unreadable false when no read-out exists; storing "the section
// was there" as a row count; dropping the `offered_present` column and deriving presence from rows.
func TestTheThreeWaysAStopCanHaveNoQuestionsAreNotOneAnswer(t *testing.T) {
s, ctx := testDB(t)
book := readingBook(t, s, ctx, "u1")
never, err := s.BankSigningStop(ctx, "u1", book)
if err != nil {
t.Fatal(err)
}
if !never.Unreadable {
t.Error("a book whose runs never published a read-out reported the stop as readable")
}
if err := s.SaveBank(ctx, book, ingest.Bank{Boundary: ingest.BoundarySignatureRequested}); err != nil {
t.Fatal(err)
}
noSection, err := s.BankSigningStop(ctx, "u1", book)
if err != nil {
t.Fatal(err)
}
if !noSection.Unreadable {
t.Error("a read-out that carries no section reported the stop as readable — an engine build older " +
"than the section is then indistinguishable from a stop that asked nothing")
}
if err := s.SaveBank(ctx, book, stopReadOut(nil)); err != nil {
t.Fatal(err)
}
empty, err := s.BankSigningStop(ctx, "u1", book)
if err != nil {
t.Fatal(err)
}
if empty.Unreadable {
t.Error("a section that is present and holds nothing was reported as unreadable")
}
if len(empty.Offered) != 0 {
t.Errorf("an empty section answered %d terms", len(empty.Offered))
}
}
// ⛔ The boundary is a CONDITION, not a label. The engine publishes this section at the signing stop and
// nowhere else, so rows standing at another boundary mean the document is not what this build takes it
// for — and serving them puts a stop on the screen that no run is holding.
//
// Mutation this must catch: reading `offered_present` alone and ignoring the boundary.
func TestTermsStandingAtABoundaryThatCannotCarryThemAreNotServed(t *testing.T) {
s, ctx := testDB(t)
book := readingBook(t, s, ctx, "u1")
offered := []ingest.OfferedTerm{{Src: "方源", Dst: "Фан Юань", Channel: ingest.ChannelBoth}}
// The premise the assertion stands on: the SAME rows at the stop's own boundary are served. Without
// it the test could pass on a build that serves nothing at all.
if err := s.SaveBank(ctx, book, stopReadOut(offered)); err != nil {
t.Fatal(err)
}
atStop, err := s.BankSigningStop(ctx, "u1", book)
if err != nil {
t.Fatal(err)
}
if atStop.Unreadable || len(atStop.Offered) != 1 {
t.Fatalf("the stop's own boundary did not serve its rows: %+v", atStop)
}
for _, boundary := range []string{
ingest.BoundaryRunStarted, ingest.BoundaryTermsTakenUnsigned,
ingest.BoundaryRunFinished, ingest.BoundaryBookReseeded, ingest.BoundaryUnknown,
} {
bank := stopReadOut(offered)
bank.Boundary = boundary
if err := s.SaveBank(ctx, book, bank); err != nil {
t.Fatal(err)
}
got, err := s.BankSigningStop(ctx, "u1", book)
if err != nil {
t.Fatal(err)
}
if !got.Unreadable || len(got.Offered) != 0 {
t.Errorf("boundary %s served %d terms as a stop's questions: %+v", boundary, len(got.Offered), got)
}
}
}
// ⛔ An absent number is not a zero, and the read model must not flatten what the reader kept apart.
// Measured: `conventions` is absent from every row of one bought stop read-out and present on every row
// of the other. `freq = 0` is the engine's own "only the draft side saw it"; a stated `confidence = 0`
// is the row a signer must look at, and the engine spells "the reply stated none" as a negative.
//
// Mutation this must catch: a `coalesce(freq, 0)` anywhere on the way out, or non-null columns.
func TestANumberTheReadOutNeverCarriedStaysAbsentThroughTheStore(t *testing.T) {
s, ctx := testDB(t)
book := readingBook(t, s, ctx, "u1")
if err := s.SaveBank(ctx, book, stopReadOut([]ingest.OfferedTerm{
{Src: "silent"},
{Src: "stated", Freq: intp(0), Spread: intp(0), Conventions: intp(0), Confidence: intp(0)},
{Src: "high-confidence", Confidence: intp(95)},
})); err != nil {
t.Fatal(err)
}
got, err := s.BankSigningStop(ctx, "u1", book)
if err != nil {
t.Fatal(err)
}
if len(got.Offered) != 3 {
t.Fatalf("stored 3 terms and read back %d", len(got.Offered))
}
silent := got.Offered[0]
for name, p := range map[string]*int{"freq": silent.Freq, "spread": silent.Spread,
"conventions": silent.Conventions, "confidence": silent.Confidence} {
if p != nil {
t.Errorf("%s was never carried and came back as %d", name, *p)
}
}
stated := got.Offered[1]
for name, p := range map[string]*int{"freq": stated.Freq, "spread": stated.Spread,
"conventions": stated.Conventions, "confidence": stated.Confidence} {
if p == nil || *p != 0 {
t.Errorf("%s was stated as 0 and came back as %v", name, p)
}
}
// ⚠ The engine's own «the reply stated none» is a NEGATIVE, and it no longer reaches this layer:
// the reader folds it to "not stated" at the seam, where every other crossing is folded. So what
// this store must carry is a STATED number, unrounded and unclamped — pinning the negative here
// would teach a path that was abolished.
if c := got.Offered[2].Confidence; c == nil || *c != 95 {
t.Errorf("a stated confidence came back as %v", c)
}
}
// The ranking IS the information — which row to read first — and it is the only address a term has, so
// the order the read-out published is the order that comes back. The list is replaced whole for the
// same reason: without an identity, no delta over it is expressible.
//
// Mutation this must catch: `order by src` or no order at all; writing the new rows before deleting the
// old, which leaves the tail of a longer previous read-out standing.
func TestTheStopsRankingIsTheOrderThatComesBackAndTheListIsReplacedWhole(t *testing.T) {
s, ctx := testDB(t)
book := readingBook(t, s, ctx, "u1")
if err := s.SaveBank(ctx, book, stopReadOut([]ingest.OfferedTerm{
{Src: "zeta"}, {Src: "alpha"}, {Src: "mu"},
})); err != nil {
t.Fatal(err)
}
got, err := s.BankSigningStop(ctx, "u1", book)
if err != nil {
t.Fatal(err)
}
want := []string{"zeta", "alpha", "mu"}
for i, src := range want {
if got.Offered[i].Src != src {
t.Fatalf("the stop's ranking came back re-sorted: %v", srcsOf(got.Offered))
}
}
if err := s.SaveBank(ctx, book, stopReadOut([]ingest.OfferedTerm{{Src: "only"}})); err != nil {
t.Fatal(err)
}
shorter, err := s.BankSigningStop(ctx, "u1", book)
if err != nil {
t.Fatal(err)
}
if len(shorter.Offered) != 1 || shorter.Offered[0].Src != "only" {
t.Errorf("a shorter read-out left the previous tail standing: %v", srcsOf(shorter.Offered))
}
}
// ⛔ Completeness is ABSENT until something measures it, and absence is not "the bank is complete". The
// engine makes the section absent rather than zeroed for exactly this reason: a zeroed one answers
// "consolidated 0, unanswered 0", which reads as "nothing is missing".
//
// Mutation this must catch: storing a zeroed section when the read-out carried none, or dropping the
// `consolidation_measured` column and testing the numbers instead.
func TestCompletenessIsAbsentUntilSomethingMeasuresItAndAbsenceIsNotCompleteness(t *testing.T) {
s, ctx := testDB(t)
book := readingBook(t, s, ctx, "u1")
if err := s.SaveBank(ctx, book, ingest.Bank{Boundary: ingest.BoundaryRunStarted}); err != nil {
t.Fatal(err)
}
page, err := s.ListBank(ctx, "u1", book, 0, "", nil)
if err != nil {
t.Fatal(err)
}
if page.Consolidation != nil {
t.Errorf("a read-out that measured nothing reported completeness: %+v", page.Consolidation)
}
measured := ingest.Bank{Boundary: ingest.BoundarySignatureRequested,
Consolidation: &ingest.Consolidation{RenderBatchesDropped: 5, ClassifyBatchesDropped: 2,
Consolidated: 38, Declined: 1, Unanswered: 47, NeverAsked: 7}}
if err := s.SaveBank(ctx, book, measured); err != nil {
t.Fatal(err)
}
page, err = s.ListBank(ctx, "u1", book, 0, "", nil)
if err != nil {
t.Fatal(err)
}
if page.Consolidation == nil {
t.Fatal("a measured section came back absent — the signer would read a partial bank as whole")
}
got := *page.Consolidation
want := BankConsolidation{RenderBatchesDropped: 5, ClassifyBatchesDropped: 2,
Consolidated: 38, Declined: 1, Unanswered: 47, NeverAsked: 7}
if got != want {
t.Errorf("the section was rewritten on the way through:\n got %+v\nwant %+v", got, want)
}
// It is a whole-bank aggregate and rides where they do: with the counts, on the first page only.
later, err := s.ListBank(ctx, "u1", book, 1, "cursor-that-is-not-the-first-page", nil)
if err == nil && later.Consolidation != nil {
t.Error("completeness rode a page that is not the first")
}
}
func srcsOf(ts []BankOfferedTerm) []string {
out := make([]string, 0, len(ts))
for _, t := range ts {
out = append(out, t.Src)
}
return out
}
// ⛔ A new `/v0` route is a new place to read somebody else's book from (API1 BOLA, the zone's base
// line). The ownership check is the FIRST thing this read does and the only thing between one user's
// stop and another's; a reader that scoped by book alone would serve it happily.
//
// Mutation this must catch: dropping the `bookScope` call, or scoping the query by book alone.
func TestAnotherUsersStopIsNotReadable(t *testing.T) {
s, ctx := testDB(t)
book := readingBook(t, s, ctx, "u1")
if err := s.SaveBank(ctx, book, stopReadOut([]ingest.OfferedTerm{{Src: "方源"}})); err != nil {
t.Fatal(err)
}
// The premise: the owner DOES see it. Without this the test could pass on a build that serves
// nobody, and the fixture would have lost the power to tell the two apart.
mine, err := s.BankSigningStop(ctx, "u1", book)
if err != nil || len(mine.Offered) != 1 {
t.Fatalf("the owner could not read their own stop: %v %+v", err, mine)
}
if _, err := s.BankSigningStop(ctx, "u2", book); !errors.Is(err, ErrNoBook) {
t.Errorf("a stranger's read of this book's stop answered %v, want ErrNoBook", err)
}
}
// ⛔ THE CLOSED-VOCABULARY GUARDS AT THE WRITE SEAM, pinned because an unpinned data decision is one a
// later edit removes silently — and here "silently" means a whole bank save failing on a CHECK.
//
// ⚠ `nullif($, ”)` in the statement does NOT do this work: it turns the EMPTY value into null, and
// the guard turns an UNKNOWN non-empty one into the empty value first. Without the guard an engine
// word that leaked this far — `banknote`, the very kind the contract renamed so it could not reach a
// client — hits the constraint and takes the bank down with it, at the one boundary a person is
// waiting at. Measured here rather than argued: the same value with the guard lands as "not named",
// and the row survives.
//
// Mutation this must catch: dropping either guard from the insert, or widening the CHECK.
func TestAnEngineWordThatLeakedThisFarIsStoredAsNotNamedRatherThanTakingTheBankDown(t *testing.T) {
s, ctx := testDB(t)
book := readingBook(t, s, ctx, "u1")
// Both engine vocabularies, both outside the contract's closed sets.
if err := s.SaveBank(ctx, book, stopReadOut([]ingest.OfferedTerm{
{Src: "leaked", Channel: "banknote", Kind: "ruby"},
})); err != nil {
t.Fatalf("an unknown vocabulary took the whole bank save down: %v", err)
}
got, err := s.BankSigningStop(ctx, "u1", book)
if err != nil {
t.Fatal(err)
}
if len(got.Offered) != 1 {
t.Fatalf("the row did not survive: %+v", got)
}
if got.Offered[0].Channel != "" {
t.Errorf("an engine word reached the read side as %q", got.Offered[0].Channel)
}
if got.Offered[0].Kind != "" {
t.Errorf("an engine kind reached the read side as %q", got.Offered[0].Kind)
}
// The premise the assertions stand on: a value INSIDE the vocabulary is still carried, or the
// fixture would pass on a build that stores nothing at all.
if err := s.SaveBank(ctx, book, stopReadOut([]ingest.OfferedTerm{
{Src: "named", Channel: ingest.ChannelBoth, Kind: "name"},
})); err != nil {
t.Fatal(err)
}
named, err := s.BankSigningStop(ctx, "u1", book)
if err != nil {
t.Fatal(err)
}
if named.Offered[0].Channel != ingest.ChannelBoth || named.Offered[0].Kind != "name" {
t.Errorf("a value inside the vocabulary was dropped too: %+v", named.Offered[0])
}
}

View file

@ -43,9 +43,9 @@ func TestEveryFrameCarriesTheBooksRevisionAndStructureVersion(t *testing.T) {
if err := apply(t, sink, 4, ingest.TypeBankStop, ingest.BankStop{}); err != nil {
t.Fatal(err)
}
if err := s.SaveBank(ctx, "bk1", []ingest.BankTerm{
if err := s.SaveBank(ctx, "bk1", ingest.Bank{Terms: []ingest.BankTerm{
{ID: "t1", Src: "s", Dst: "d", Status: "proposed", Origin: "found", Sense: "one"},
}); err != nil {
}}); err != nil {
t.Fatal(err)
}

View file

@ -13,6 +13,11 @@
# tailer does not do. A migration whose comment lies is worse than one whose hash moved before it was
# ever released; the day either of them ships, this exception closes with it.
#
# ⚠ 00036 is listed for the FIRST time by the pack that writes it and was re-fingerprinted inside that
# same pack, when the acceptance run showed its boundary constraint refused the zero value a caller can
# legally build. Same narrowness as 00016 below: nothing has run it outside scratch databases, so no
# deployment can hold the earlier version. The day it ships, this exception closes with it.
#
# ⚠ 00016 is listed for the FIRST time by the pack that writes it and has been re-fingerprinted
# inside that same pack (an index of 00015 it made redundant, a version claim that named a pin the
# stack table does not carry). Same narrowness: nothing has run it outside scratch databases.
@ -51,3 +56,4 @@ c21877113b5966bc8a200ba69ce752d4ac295bdfd34e681afbd887523edb6ceb 00019_read_mod
d56496573d793ce1b825664e2e28bdb0f8335fc4155a578cd3d23747363b8424 00033_order_and_price.sql
ee5ea50e815479db3550eb28f1f039e417d36fcbf7799a3ac5b9f01913938eef 00034_spawn_proof_and_park.sql
463589447fed990aa1e53d3ad835cf27115c9ebf750f1724894aa11764204551 00035_settled_repair_index.sql
a30abdebab801145b9914a0fc67f87fc1c862a2a1a790d30f47473970ae525da 00036_bank_read_out.sql

View file

@ -0,0 +1,133 @@
-- +goose Up
-- The signing stop's two sections, which until now had nowhere to land: the bank table held the
-- `terms` section alone, and at a signature stop that section is EMPTY by construction — the mined
-- rows are folded into the glossary in the engine's auto-continue branch and a stopped run returns
-- before it. So the one screen in the product that asks a person for a decision was served a zero
-- that meant "nothing here reads the answer" (engine backlog rows 224 and 253; measured on two
-- bought stop read-outs: terms 0 against 69 and 66 offered terms).
-- ── The read-out itself: WHICH moment of a run published the document ───────────────────────────
--
-- One row per book, or none for a book whose runs have never published a read-out — a third state,
-- and distinct from the two below: "no read-out at all", "a read-out carrying no section", "a
-- section carrying no rows". Collapsing any pair of them puts a zero on the screen that says
-- something nobody measured.
create table bank_read_out (
book_id text primary key references books (id) on delete cascade,
-- The contract's ReadOutBoundary. `unknown` is a VALUE and not a null: the engine publishes at
-- five boundaries and warns three times over that the file on disk may be a previous one's, so
-- a read-out whose boundary this build cannot name still has to be storable — as the value that
-- claims the least, never as the stop.
boundary text not null check (boundary in (
'run_started', 'terms_taken_unsigned', 'signature_requested',
'run_finished', 'book_reseeded', 'unknown')),
-- The other half of the engine's freshness anchor: the id it announces its event stream under,
-- which is the id THIS platform minted for the attempt (pgstore.EngineStreamID). Stored and NOT
-- served: it is an engine identity and the seam does not export those. Kept because the engine
-- calls the two fields ONE anchor, and a build that took the boundary and dropped this one would
-- have taken the form of the anchor without its guarantee.
engine_run_id text not null default '',
-- Whether the document carried a `proposed` member AT ALL. Not derivable from the row count in
-- bank_offered_terms: zero rows is what BOTH "no section" and "a section that asked nothing" look
-- like there, and they are different answers to a person staring at an empty screen.
offered_present boolean not null,
-- Whether anything measured how complete the bank being signed is. The engine makes the section
-- ABSENT rather than zeroed at the four boundaries where the paid pass never ran, because a
-- zeroed one answers "consolidated 0, unanswered 0" — which reads as "nothing is missing". The
-- flag plus nullable columns is that distinction in the schema instead of in a convention.
consolidation_measured boolean not null,
-- Taken from the engine READY-MADE and never re-derived here: it computes `complete` off the
-- RENDER pass alone, and a classifier cut leaves the bank whole with the term TYPES unrefined —
-- a different budget and a different remedy. A second mechanism answering one question is how
-- the two come to disagree.
complete boolean,
-- render_batches_dropped is what makes `complete` false; classify_batches_dropped is the OTHER
-- pass's cut on its OWN budget. Two columns and never one sum, because an operator raises one
-- budget or the other, never "the" budget.
render_batches_dropped integer,
classify_batches_dropped integer,
-- consolidated came back with a rendering; declined the role explicitly could not render;
-- unanswered is the role's silence AND the budget's cut together (the engine's counter conflates
-- them, and it means "the role stayed silent" only where `complete` is true); never_asked is the
-- opposite of a gap — the bank already renders the surface and every draft agreed with it.
consolidated integer,
declined integer,
unanswered integer,
never_asked integer,
-- All seven or none. Without this a half-written section is storable, and a half-written section
-- is exactly the zeroed one the engine refuses to publish.
constraint bank_read_out_consolidation_is_whole_or_absent check (
num_nonnulls(complete, render_batches_dropped, classify_batches_dropped,
consolidated, declined, unanswered, never_asked)
= case when consolidation_measured then 7 else 0 end)
);
-- ── The terms the stop offered ─────────────────────────────────────────────────────────────────
--
-- ⛔ KEYED BY POSITION, and that is not an identity for an offered term — it is the document's own
-- ORDER. An offered term has no id in any of the 135 objects of the two bought read-outs, and minting
-- one here would be this side re-implementing engine law. What the engine does publish is a RANKING:
-- "which row to read first is the information here". So the key is (book, position in that ranking),
-- the table is replaced whole on every read, and no delta is expressible over it — which is the
-- honest consequence of there being no identity, not a limitation to work around.
--
-- It therefore stays OUT of the bank's revision machinery on purpose. bank_terms sets
-- books.bank_reset_revision when a row disappears, "because a disappeared row cannot be expressed as
-- a delta"; folding these rows into that would set it on EVERY materialization and leave the book in
-- a permanent revision reset.
create table bank_offered_terms (
book_id text not null references books (id) on delete cascade,
ordinal integer not null check (ordinal >= 0),
src text not null,
-- The consolidated rendering, '' when the read-out carries none. ⚠ It does NOT say WHY: on the
-- engine's printed sheet an empty rendering covers four different facts, one of which ("the bank
-- already renders this surface, there was nothing to decide") the engine distinguishes and
-- deliberately does not project here. This column must not be read as "the service could not
-- translate it".
dst text not null default '',
-- Null means "the kind was not decided" — legal, and the row still needs signing. The same
-- vocabulary and the same null as bank_terms.kind.
kind text check (kind in ('name', 'place', 'title', 'term', 'nickname')),
-- The contract's OfferedTermChannel: which detector found the surface. Deliberately not called
-- `origin` — on a bank row that word is the PROVENANCE, and one word meaning two things inside
-- one document is the confusion the engine's own field comment refuses.
-- Null for a detector outside the closed set — the same spelling as `kind` above, so one
-- convention carries "this build cannot name it" all the way to the wire. Never a guess: this is
-- how a person judges how well corroborated a row is.
channel text check (channel in ('source_text', 'translated_text', 'both', 'alias')),
-- ⛔ NULLABLE ON PURPOSE, AND MEASURED: the engine writes all four of these ALWAYS, so an absent
-- one means a build without the field wrote the document — and for these the zero value is
-- itself a measurement. `conventions` is absent from all 69 rows of one bought read-out and
-- present on all 66 of the other, because it was added to the projection between the two runs.
-- A build storing 0 there would have told a person that 69 terms' renderings all agree, on a
-- sheet whose whole purpose is disagreement. `freq = 0` is the engine's own "only the draft side
-- saw it"; a stated `confidence = 0` is the most important row on a signing sheet.
freq integer,
spread integer,
conventions integer,
-- Three states and not two: null for "the read-out does not carry it", NEGATIVE for the engine's
-- own "the reply carried none", and >= 0 for a stated one.
confidence integer,
-- The rendering is not one the drafts proposed — legitimate, and the class the engine names as
-- the one to read FIRST. Not null because the engine omits the field when false, so absence IS
-- false by the writer's own encoding; the day that stops being true this column becomes nullable
-- along with the Go field that feeds it.
invented boolean not null default false,
-- contradicts: this run's OTHER consolidations the rendering breaks. bank_holds: rows the bank
-- already carries for the same surface with a different rendering. Two columns, because "the run
-- disagreed with itself" and "the book already calls it something else" are different decisions.
contradicts text[] not null default '{}',
bank_holds text[] not null default '{}',
-- The drafts' renderings, best-ranked first, in the engine's OWN labelling — stored as opaque
-- text and never parsed. The engine keeps a reader for that format beside its writer precisely
-- because a second, re-derived parser "would keep working until the day the label gains a field,
-- and then it would report numbers rather than an error".
variants text[] not null default '{}',
primary key (book_id, ordinal)
);
-- +goose Down
drop table bank_offered_terms;
drop table bank_read_out;

View file

@ -344,7 +344,13 @@ func writeUnits(ctx context.Context, tx pgx.Tx, bookID, chapterID string, units
// abolished (D39.144) and removed — but a rebuild must still leave it alone: what replaces it, an
// EDIT of a term, has to survive the rebuild for the same reason, and the note left where the write
// path stood promises that the storage is ready for it.
func (s *Store) SaveBank(ctx context.Context, bookID string, terms []ingest.BankTerm) error {
//
// ⚠ It takes the WHOLE read-out rather than its rows, and one transaction writes all of it. The
// document is one projection of one moment — the bank, what a stop is asking about, and how complete
// the pass that produced it managed to be — and two saves would let half of it land: a screen showing
// this run's suggestions against the previous run's bank is a worse answer than either half alone.
func (s *Store) SaveBank(ctx context.Context, bookID string, bank ingest.Bank) error {
terms := bank.Terms
return s.inTx(ctx, func(tx pgx.Tx) error {
if err := lockBook(ctx, tx, bookID); err != nil {
return err
@ -393,6 +399,9 @@ func (s *Store) SaveBank(ctx context.Context, bookID string, terms []ingest.Bank
return fmt.Errorf("pgstore: record the bank reset: %w", err)
}
}
if err := saveBankReadOut(ctx, tx, bookID, bank); err != nil {
return err
}
counts, err := bankCountsTx(ctx, tx, bookID)
if err != nil {
return err
@ -401,6 +410,81 @@ func (s *Store) SaveBank(ctx context.Context, bookID string, terms []ingest.Bank
})
}
// saveBankReadOut stores the two sections a signing stop publishes beside the bank, and the boundary
// that published them (engine backlog rows 224 and 253).
//
// ⚠ The offered terms are deleted BEFORE they are written, the opposite of the order the bank rows
// above use, and each order is load-bearing for its own reason: a bank row goes first because
// `bank_decisions` cascades on it, while an offered term has no dependants and its key is a POSITION
// in a ranking that moves — writing first would leave the tail of a longer previous read-out standing.
func saveBankReadOut(ctx context.Context, tx pgx.Tx, bookID string, bank ingest.Bank) error {
c := bank.Consolidation
// Seven values or none: the schema refuses a half-written section, because a half-written one is
// exactly the zeroed section the engine refuses to publish — it would answer "consolidated 0,
// unanswered 0", which reads as "nothing is missing".
var complete *bool
var renderDropped, classifyDropped, consolidated, declined, unanswered, neverAsked *int
if c != nil {
complete = &c.Complete
renderDropped, classifyDropped = &c.RenderBatchesDropped, &c.ClassifyBatchesDropped
consolidated, declined = &c.Consolidated, &c.Declined
unanswered, neverAsked = &c.Unanswered, &c.NeverAsked
}
if _, err := tx.Exec(ctx, `
insert into bank_read_out (book_id, boundary, engine_run_id, offered_present,
consolidation_measured, complete, render_batches_dropped,
classify_batches_dropped, consolidated, declined, unanswered,
never_asked)
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
on conflict (book_id) do update set boundary = excluded.boundary,
engine_run_id = excluded.engine_run_id,
offered_present = excluded.offered_present,
consolidation_measured = excluded.consolidation_measured,
complete = excluded.complete,
render_batches_dropped = excluded.render_batches_dropped,
classify_batches_dropped = excluded.classify_batches_dropped,
consolidated = excluded.consolidated,
declined = excluded.declined,
unanswered = excluded.unanswered,
never_asked = excluded.never_asked`,
bookID, ingest.BoundaryOrUnknown(bank.Boundary), bank.RunID, bank.Offered != nil, c != nil,
complete, renderDropped, classifyDropped, consolidated, declined, unanswered, neverAsked,
); err != nil {
return fmt.Errorf("pgstore: write the bank read-out: %w", err)
}
if _, err := tx.Exec(ctx, `delete from bank_offered_terms where book_id = $1`, bookID); err != nil {
return fmt.Errorf("pgstore: drop the previously offered terms: %w", err)
}
if bank.Offered == nil {
return nil
}
for i, t := range *bank.Offered {
if _, err := tx.Exec(ctx, `
insert into bank_offered_terms (book_id, ordinal, src, dst, kind, channel, freq, spread,
conventions, confidence, invented, contradicts, bank_holds,
variants)
values ($1, $2, $3, $4, nullif($5, ''), nullif($6, ''), $7, $8, $9, $10, $11, $12, $13, $14)`,
bookID, i, t.Src, t.Dst, ingest.KindOrNone(t.Kind), ingest.ChannelOrNone(t.Channel),
t.Freq, t.Spread,
t.Conventions, t.Confidence, t.Invented,
stringList(t.Contradicts), stringList(t.BankHolds), stringList(t.Variants),
); err != nil {
return fmt.Errorf("pgstore: write an offered term: %w", err)
}
}
return nil
}
// stringList is the empty ARRAY for an absent list. The columns are `not null default '{}'` and nil
// would be a null; "the read-out named nothing here" and "the read-out does not have this field" are
// the same fact for these three, because the engine omits all of them when empty.
func stringList(v []string) []string {
if v == nil {
return []string{}
}
return v
}
// BankCounts are the whole-bank aggregates: the header of a signing screen, and INFORMATIONAL —
// never a gate. Signing is one act over the whole bank and `resume` lifts the stop with the
// decisions as they stand (D39.144), so nothing here decides whether continuing may be offered.
@ -1102,6 +1186,52 @@ type BankTerm struct {
Until *int
}
// BankOfferedTerm is one row of a signing stop's table as the client reads it — a surface the service
// mined and consolidated that is NOT in the bank yet.
//
// It has no id, here or anywhere: the engine publishes none and minting one on this side would be
// re-implementing engine law. What it has is a POSITION in the stop's ranking, which the list's order
// carries — so no delta is expressible over this list and it is always read whole.
type BankOfferedTerm struct {
Src string
Dst string // "" when the read-out carries no rendering — it does not say why
// "" for what this build cannot name: a kind the engine did not decide, a detector outside the
// closed set.
Kind, Channel string
// Nil is NOT zero: `freq = 0` is the engine's "only the draft side saw it", and `conventions` is
// absent from every row of a read-out bought before that field existed.
Freq, Spread, Conventions *int
// Nil "not carried", negative for the engine's "the reply stated none", >= 0 for a stated one.
Confidence *int
Invented bool
Contradicts, BankHolds []string
Variants []string
}
// BankConsolidation is how complete the bank being signed is, taken from the engine ready-made.
type BankConsolidation struct {
Complete bool
RenderBatchesDropped, ClassifyBatchesDropped int
Consolidated, Declined int
Unanswered, NeverAsked int
}
// BankSigningStop is what the last signing stop asked about.
//
// ⛔ Unreadable is the whole reason this is a struct and not a slice: without it "this deployment
// cannot tell you what the stop is asking" is byte-identical to "the stop asked nothing" — the one
// thing this type must never say by accident. It is true for THREE different causes, and they need no
// telling apart because the remedy is one: no read-out has been materialized yet, the read-out carries
// no such section (an engine build older than it), or the read-out was taken at a boundary that cannot
// carry one, where rows would be a stop this build invented.
//
// It does NOT answer whether a stop is standing — that is the run's status. This is the LAST stop's
// question, in the past tense the correction receipt already uses for the same population.
type BankSigningStop struct {
Unreadable bool
Offered []BankOfferedTerm
}
// BankPage is one page of the bank. The aggregates ride on the FIRST page only — any response to a
// request with no cursor — which puts them at the same moment as the oldest rows of the walk.
type BankPage struct {
@ -1109,6 +1239,14 @@ type BankPage struct {
Terms []BankTerm
First bool
Counts BankCounts
// Consolidation is another whole-bank aggregate and rides where they do — the first page only.
// Nil means nothing has measured this bank's completeness, which is NOT "it is complete".
//
// ⚠ It belongs with the bank rather than with the stop's questions, and the engine's own code is
// why: its terminology result is set BEFORE the stop/continue fork and outlives it, so this
// section rides three of the five boundaries while the questions ride one. Two different axes of
// freshness, and one read cannot be honest about both.
Consolidation *BankConsolidation
}
// ListBank returns one page of the bank, ordered by source surface then by the term's window so
@ -1168,6 +1306,9 @@ func (s *Store) ListBank(ctx context.Context, userID, bookID string, limit int,
if out.Counts, err = bankCountsTx(ctx, tx, bookID); err != nil {
return err
}
if out.Consolidation, err = bankConsolidationTx(ctx, tx, bookID); err != nil {
return err
}
}
return nil
})
@ -1177,6 +1318,95 @@ func (s *Store) ListBank(ctx context.Context, userID, bookID string, limit int,
return out, nil
}
// bankConsolidationTx reads how complete the bank being signed is. No row, or a row from a boundary
// that never measured it, both answer nil — "nothing measured this", never "it is complete".
func bankConsolidationTx(ctx context.Context, tx pgx.Tx, bookID string) (*BankConsolidation, error) {
var measured bool
var c BankConsolidation
err := tx.QueryRow(ctx, `
select consolidation_measured, coalesce(complete, false),
coalesce(render_batches_dropped, 0), coalesce(classify_batches_dropped, 0),
coalesce(consolidated, 0), coalesce(declined, 0), coalesce(unanswered, 0),
coalesce(never_asked, 0)
from bank_read_out where book_id = $1`, bookID).
Scan(&measured, &c.Complete, &c.RenderBatchesDropped, &c.ClassifyBatchesDropped,
&c.Consolidated, &c.Declined, &c.Unanswered, &c.NeverAsked)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("pgstore: read the bank consolidation: %w", err)
}
// The coalesces keep the scan targets plain and are safe ONLY because the schema refuses a
// partly-written section: the zeroes they produce are read exclusively when `measured` is false,
// and nothing then sees them.
if !measured {
return nil, nil
}
return &c, nil
}
// BankSigningStop answers what the book's last signing stop asked about.
//
// Its own read and its own resource, because it moves on a different axis from the bank: these rows
// appear and vanish with a RUN while the bank's revision does not move, and there is no identity to
// express a delta over. So it is served whole, uncursored, and stamped by nothing.
func (s *Store) BankSigningStop(ctx context.Context, userID, bookID string) (BankSigningStop, error) {
var out BankSigningStop
err := s.inReadTx(ctx, func(tx pgx.Tx) error {
if _, err := bookScope(ctx, tx, userID, bookID); err != nil {
return err
}
var boundary string
var present bool
err := tx.QueryRow(ctx, `select boundary, offered_present from bank_read_out where book_id = $1`,
bookID).Scan(&boundary, &present)
if errors.Is(err, pgx.ErrNoRows) {
out.Unreadable = true
return nil
}
if err != nil {
return fmt.Errorf("pgstore: read the bank read-out: %w", err)
}
// ⛔ The boundary is a condition and not a label. The engine publishes this section at the
// signing stop and nowhere else, so rows standing at any other boundary mean the document is
// not what this build takes it for — and serving them would put a stop on the screen that no
// run is holding.
if !present || boundary != ingest.BoundarySignatureRequested {
out.Unreadable = true
return nil
}
rows, err := tx.Query(ctx, `
select src, dst, coalesce(kind, ''), coalesce(channel, ''), freq, spread, conventions,
confidence, invented, contradicts, bank_holds, variants
from bank_offered_terms where book_id = $1 order by ordinal`, bookID)
if err != nil {
return fmt.Errorf("pgstore: list the offered terms: %w", err)
}
defer rows.Close()
// Non-nil before the loop: a stop that asked nothing must read as an empty list, not as a
// document that carried no section — `Unreadable` is the only carrier of the second fact.
out.Offered = []BankOfferedTerm{}
for rows.Next() {
var t BankOfferedTerm
if err := rows.Scan(&t.Src, &t.Dst, &t.Kind, &t.Channel, &t.Freq, &t.Spread,
&t.Conventions, &t.Confidence, &t.Invented, &t.Contradicts, &t.BankHolds,
&t.Variants); err != nil {
return fmt.Errorf("pgstore: scan an offered term: %w", err)
}
out.Offered = append(out.Offered, t)
}
if err := rows.Err(); err != nil {
return fmt.Errorf("pgstore: list the offered terms: %w", err)
}
return nil
})
if err != nil {
return BankSigningStop{}, err
}
return out, nil
}
// scope is what every book-scoped read needs before it reads anything: that the caller may see the
// book at all, and the two numbers the page is stamped with.
type scope struct {

View file

@ -264,7 +264,7 @@ func TestTheBankIsReplacedFromTheEnginesReadOut(t *testing.T) {
{ID: "e1", Src: "方源", Dst: "Фан Юань", Kind: "name", Status: "proposed", Origin: "found"},
{ID: "e2", Src: "蛊", Status: "proposed", Origin: "given", SinceChapter: &since},
}
if err := s.SaveBank(ctx, book, terms); err != nil {
if err := s.SaveBank(ctx, book, ingest.Bank{Terms: terms}); err != nil {
t.Fatal(err)
}
page, err := s.ListBank(ctx, "u1", book, 0, "", nil)
@ -276,7 +276,7 @@ func TestTheBankIsReplacedFromTheEnginesReadOut(t *testing.T) {
}
// A rebuild of the same read-out answers the same bank: the identities are derived from the term
// itself, so nothing is duplicated and nothing is lost.
if err := s.SaveBank(ctx, book, terms); err != nil {
if err := s.SaveBank(ctx, book, ingest.Bank{Terms: terms}); err != nil {
t.Fatal(err)
}
after, err := s.ListBank(ctx, "u1", book, 0, "", nil)
@ -287,7 +287,7 @@ func TestTheBankIsReplacedFromTheEnginesReadOut(t *testing.T) {
t.Errorf("the rebuild changed the bank: %+v", after.Counts)
}
// A term dropped from the read-out is GONE from the bank — the whole point of a replacement.
if err := s.SaveBank(ctx, book, terms[:1]); err != nil {
if err := s.SaveBank(ctx, book, ingest.Bank{Terms: terms[:1]}); err != nil {
t.Fatal(err)
}
shrunk, err := s.ListBank(ctx, "u1", book, 0, "", nil)
@ -365,7 +365,7 @@ func TestARebuildOfTheBankLeavesTheDecisionsTableAlone(t *testing.T) {
terms := []ingest.BankTerm{
{ID: "e1", Src: "方源", Dst: "Фан Юань", Kind: "name", Status: "proposed", Origin: "found"},
}
if err := s.SaveBank(ctx, book, terms); err != nil {
if err := s.SaveBank(ctx, book, ingest.Bank{Terms: terms}); err != nil {
t.Fatal(err)
}
page, err := s.ListBank(ctx, "u1", book, 0, "", nil)
@ -380,7 +380,7 @@ func TestARebuildOfTheBankLeavesTheDecisionsTableAlone(t *testing.T) {
values ($1, $2, 'approve', 'Фан Юань', 'u1')`, book, page.Terms[0].ID); err != nil {
t.Fatal(err)
}
if err := s.SaveBank(ctx, book, terms); err != nil {
if err := s.SaveBank(ctx, book, ingest.Bank{Terms: terms}); err != nil {
t.Fatal(err)
}
var left int
@ -398,10 +398,10 @@ func TestARebuildOfTheBankLeavesTheDecisionsTableAlone(t *testing.T) {
func TestTheBankAggregatesAreAnsweredOnTheFirstPageOnly(t *testing.T) {
s, ctx := testDB(t)
book := readingBook(t, s, ctx, "u1")
if err := s.SaveBank(ctx, book, []ingest.BankTerm{
if err := s.SaveBank(ctx, book, ingest.Bank{Terms: []ingest.BankTerm{
{ID: "e1", Src: "a", Status: "proposed", Origin: "found"},
{ID: "e2", Src: "b", Status: "approved", Origin: "given"},
}); err != nil {
}}); err != nil {
t.Fatal(err)
}
first, err := s.ListBank(ctx, "u1", book, 1, "", nil)
@ -500,8 +500,8 @@ func TestFramesAreMintedPerBookAndPruned(t *testing.T) {
s, ctx := testDB(t)
book := readingBook(t, s, ctx, "u1")
for i := range bufferFrames + 10 {
if err := s.SaveBank(ctx, book, []ingest.BankTerm{
{ID: fmt.Sprintf("e%d", i), Src: fmt.Sprintf("src%d", i), Status: "proposed", Origin: "found"}}); err != nil {
if err := s.SaveBank(ctx, book, ingest.Bank{Terms: []ingest.BankTerm{
{ID: fmt.Sprintf("e%d", i), Src: fmt.Sprintf("src%d", i), Status: "proposed", Origin: "found"}}}); err != nil {
t.Fatal(err)
}
}

View file

@ -34,7 +34,7 @@ type Engine interface {
// Store is the write side of the read model, plus the ledger of what it still owes.
type Store interface {
SaveStructure(ctx context.Context, bookID string, in pgstore.Structure) error
SaveBank(ctx context.Context, bookID string, terms []ingest.BankTerm) error
SaveBank(ctx context.Context, bookID string, bank ingest.Bank) error
BooksOwedReadModel(ctx context.Context, limit int) ([]pgstore.OwedBook, error)
ClaimReadModelDebt(ctx context.Context, bookID string, owedAt time.Time, window time.Duration) (time.Time, error)
ClearReadModelDebt(ctx context.Context, bookID string, owedAt time.Time) error
@ -385,7 +385,11 @@ func (s *Service) refreshBank(ctx context.Context, bookID, bankExport string) er
if err != nil {
return err
}
return s.Store.SaveBank(ctx, bookID, bank.Terms)
// The WHOLE read-out, not its rows: the bank, what a signing stop is asking about and how complete
// the pass behind it managed to be are one projection of one moment (engine backlog rows 224, 253).
// Handing over the terms alone is what made the signing screen answer zero — the section it needed
// was in the document the whole time, and nothing on this side reached for it.
return s.Store.SaveBank(ctx, bookID, bank)
}
// manifestSourceChars is the book's ingested text in runes as the engine counted it, or 0.

View file

@ -44,6 +44,7 @@ type fakeStore struct {
structure pgstore.Structure
saved bool
bank []ingest.BankTerm
readOut ingest.Bank
bankSaved bool
owed []pgstore.OwedBook
cleared []pgstore.OwedBook
@ -69,8 +70,8 @@ func (f *fakeStore) SaveStructure(_ context.Context, _ string, in pgstore.Struct
return nil
}
func (f *fakeStore) SaveBank(_ context.Context, _ string, terms []ingest.BankTerm) error {
f.bank, f.bankSaved = terms, true
func (f *fakeStore) SaveBank(_ context.Context, _ string, bank ingest.Bank) error {
f.bank, f.readOut, f.bankSaved = bank.Terms, bank, true
return nil
}
@ -719,3 +720,48 @@ func pricedTree(m *ingest.Manifest) {
m.Chapters[i].Price = &ingest.UnitPrice{ExpectedUSD: total, SourceChars: int64(len(m.Chapters[i].Units)) * 1000}
}
}
// ⛔ THE WHOLE read-out crosses the seam, not its rows. The bank, what a signing stop is asking about
// and how complete the pass behind it managed to be are one projection of one moment, and handing the
// terms alone across is exactly what made the signing screen answer zero: the questions were in the
// document the whole time and nothing on this side reached for them (engine backlog rows 224, 253).
//
// Mutation this must catch: passing `bank.Terms` to the store again — every assertion below goes red
// while the terms themselves keep arriving, which is what made the defect invisible for so long.
func TestTheWholeReadOutCrossesTheSeamAndNotOnlyItsRows(t *testing.T) {
workdir := t.TempDir()
doc := `{"bank_version":"tm-bank-v1","book_id":"bk_1","as_of":"bank-mining/signature-stop",` +
`"run_id":"tm-stream-run_X-1","terms":[],` +
`"proposed":[{"src":"方源","dst":"Фан Юань","kind":"name","channel":"both","invented":true}],` +
`"consolidation":{"complete":false,"render_batches_dropped":5,"unanswered":47}}`
if err := os.WriteFile(filepath.Join(workdir, "bk_1.db.bank.json"), []byte(doc), 0o644); err != nil {
t.Fatal(err)
}
store := &fakeStore{}
svc := &Service{Store: store, Binary: "tmctl", Engine: &fakeEngine{manifest: tree()}}
if err := svc.Refresh(t.Context(), owedBook(workdir)); err != nil {
t.Fatal(err)
}
got := store.readOut
// The terms section is EMPTY here on purpose: that is what a real stop publishes, and it is the
// state in which every assertion below is the only thing standing between a person and a blank
// screen.
if len(got.Terms) != 0 {
t.Fatalf("the fixture's bank is empty and %d terms arrived", len(got.Terms))
}
if got.Boundary != ingest.BoundarySignatureRequested {
t.Errorf("the boundary that published the read-out arrived as %q", got.Boundary)
}
if got.RunID != "tm-stream-run_X-1" {
t.Errorf("the read-out's run identity arrived as %q", got.RunID)
}
if got.Offered == nil || len(*got.Offered) != 1 {
t.Fatalf("the stop's question did not cross the seam: %v", got.Offered)
}
if !(*got.Offered)[0].Invented {
t.Error("the service composed this rendering itself and the mark did not cross")
}
if got.Consolidation == nil || got.Consolidation.Complete || got.Consolidation.Unanswered != 47 {
t.Errorf("the completeness of what is being signed did not cross: %+v", got.Consolidation)
}
}