diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index a8c3b233..00e035d3 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -1,6 +1,6 @@ # Журнал прогресса -> **⟶ ТЕКУЩЕЕ СОСТОЯНИЕ** (на 2026-09-16, голова D39.261 — КУРС: движок и платформа до «работает и отдаёт результат», фронт ЗАМОРОЖЕН и P7 его НЕ размораживает (D39.147). **ОЧЕРЕДЬ №23** (единственный носитель — здесь; роль передана 06.09, №22 закрыт нотой передачи D39.217; ⚠ испр. 06.09: коммит передачи `c992ee5` бампнул голову и НЕ тронул номер — гейта на номер очереди нет вовсе, `counts.py` сверяет только голову, поэтому носитель разошёлся молча). ⚠ **ЗАКАЗ ВЛАДЕЛЬЦА 01.09 — ИСПОЛНЕН, испр. 08.09 (висел как «первым» неделю после исполнения):** (0а) ревизия документации на протухшее ОТРАБОТАНА 01.09 воркфлоу `docs-staleness-revision-A` (15 срезов), провенанс находок — `D39.185`; (0б) планы доработок в бэкенд и платформу — исполняются ПАКАМИ, за 07–08.09 закрыты два движковых (`D39.225`, `D39.226`); (0в) вынос неактуального в архив идёт батчами `DOC_CLEANUP_PLAN.md` (Б14/Б15/Б17 живы). ⇒ строка ниже — не заказ, а история: (0а) ревизия документации на ПРОТУХШЕЕ — по всем зонам; (0б) планы доработок в БЭКЕНД и ПЛАТФОРМУ; (0в) вынос неактуального в АРХИВ (`docs/archive/`, `platform/docs/archive/`) — за сутки 31.08 закрыто много, и часть носителей стала историей. ⚠ Трезвость по масштабу (пере-считано 02.09): **98** живых дока в `docs/` (пере-счёт 17.09 после выноса каталога консилиума; прежние 97 разошлись с фактом 144 и это заметил ревизор, а не гейт) (пере-счёт 04.09: `find docs -name '*.md' -not -path 'docs/archive/*' | wc -l`), 8 в `platform/docs`, **12** в `backend/docs`, **7** в `frontend/docs` (испр. 17.09: стояло 9) — ⚠ ⚠ испр. 11.09: из 97 ВРЕМЕННЫХ **три** (`DOC_CLEANUP_PLAN`, живой до закрытия батчей Б14/Б15/Б17); два прежних временных уехали в `archive/reports/` 02.09; счёт бэклога — бюллетенем ниже, открытых рядов регистра платформы — 108 (major 1) (major 1), всего рядов 466 (пере-счёт `python3 docs/scripts/counts.py`; с 04.09 оба числа под гардом `--check`, прежние 95/3 разошлись молча) ⚠ (ревизией 02.09 ряды **154** и **157** переведены из «скоро» в «когда-нибудь»: их гейтом стоял первый холодный прогон, он ОТРАБОТАЛ 31.08 и оба предусловия оказались другими — разбор в самих ячейках, ни одна НЕ закрыта). ⚠ Числа доков `counts.py` НЕ сторожит — при переносе файлов пере-считывать руками командой `find docs -name '*.md' -not -path 'docs/archive/*' | wc -l`. ⚠ И предупреждение о МЕТОДЕ, купленное сменой №21: «выглядит протухшим» ≠ «протухло». Три факта оркестратора опровергнуты ЗАМЕРОМ сессий, а якоря `15-money-path.md` в девяти случаях из двенадцати РОДИЛИСЬ верными и сгнили дрейфом — то есть ревизия обязана быть исполнением, а не чтением. ⚠ **ОЧЕРЕДЬ, унаследованная от №21** (три лендинга 31.08 — секция «СОСТОЯНИЕ ПАКОВ» ниже): **(1) ~~РАЗРЫВ ЦИКЛА~~ ЗАМКНУТ ЖИВЬЁМ 04.09** — пользователь получил EPUB настоящего ПЛАТНОГО перевода ЧЕРЕЗ API, `epubcheck` 5.3.0 на СКАЧАННОМ файле 0/0/0/0 (книга `bk_SS5VES2JELESJSTR`, потрачено $0.278319 из гранта $0.60 при потолке пака $1.5, санкция D39.189). Дверь выдачи построена и проверена исполнением: `202`+`Location`, поллинг с `Retry-After`, Range 206 · второй клиент 200 · аноним 401 · чужая книга 404, TTL с GC, идемпотентность третьего создающего вызова. Все ТРИ сценария строки 216 предъявлены живьём (подпись банка · halt на потолке с exit 4 · `409 run_not_resumable/ceiling_reached` и лечение новым прогоном). +> **⟶ ТЕКУЩЕЕ СОСТОЯНИЕ** (на 2026-09-16, голова D39.262 — КУРС: движок и платформа до «работает и отдаёт результат», фронт ЗАМОРОЖЕН и P7 его НЕ размораживает (D39.147). **ОЧЕРЕДЬ №23** (единственный носитель — здесь; роль передана 06.09, №22 закрыт нотой передачи D39.217; ⚠ испр. 06.09: коммит передачи `c992ee5` бампнул голову и НЕ тронул номер — гейта на номер очереди нет вовсе, `counts.py` сверяет только голову, поэтому носитель разошёлся молча). ⚠ **ЗАКАЗ ВЛАДЕЛЬЦА 01.09 — ИСПОЛНЕН, испр. 08.09 (висел как «первым» неделю после исполнения):** (0а) ревизия документации на протухшее ОТРАБОТАНА 01.09 воркфлоу `docs-staleness-revision-A` (15 срезов), провенанс находок — `D39.185`; (0б) планы доработок в бэкенд и платформу — исполняются ПАКАМИ, за 07–08.09 закрыты два движковых (`D39.225`, `D39.226`); (0в) вынос неактуального в архив идёт батчами `DOC_CLEANUP_PLAN.md` (Б14/Б15/Б17 живы). ⇒ строка ниже — не заказ, а история: (0а) ревизия документации на ПРОТУХШЕЕ — по всем зонам; (0б) планы доработок в БЭКЕНД и ПЛАТФОРМУ; (0в) вынос неактуального в АРХИВ (`docs/archive/`, `platform/docs/archive/`) — за сутки 31.08 закрыто много, и часть носителей стала историей. ⚠ Трезвость по масштабу (пере-считано 02.09): **98** живых дока в `docs/` (пере-счёт 17.09 после выноса каталога консилиума; прежние 97 разошлись с фактом 144 и это заметил ревизор, а не гейт) (пере-счёт 04.09: `find docs -name '*.md' -not -path 'docs/archive/*' | wc -l`), 8 в `platform/docs`, **12** в `backend/docs`, **7** в `frontend/docs` (испр. 17.09: стояло 9) — ⚠ ⚠ испр. 11.09: из 97 ВРЕМЕННЫХ **три** (`DOC_CLEANUP_PLAN`, живой до закрытия батчей Б14/Б15/Б17); два прежних временных уехали в `archive/reports/` 02.09; счёт бэклога — бюллетенем ниже, открытых рядов регистра платформы — 111 (major 1) (major 1), всего рядов 469 (пере-счёт `python3 docs/scripts/counts.py`; с 04.09 оба числа под гардом `--check`, прежние 95/3 разошлись молча) ⚠ (ревизией 02.09 ряды **154** и **157** переведены из «скоро» в «когда-нибудь»: их гейтом стоял первый холодный прогон, он ОТРАБОТАЛ 31.08 и оба предусловия оказались другими — разбор в самих ячейках, ни одна НЕ закрыта). ⚠ Числа доков `counts.py` НЕ сторожит — при переносе файлов пере-считывать руками командой `find docs -name '*.md' -not -path 'docs/archive/*' | wc -l`. ⚠ И предупреждение о МЕТОДЕ, купленное сменой №21: «выглядит протухшим» ≠ «протухло». Три факта оркестратора опровергнуты ЗАМЕРОМ сессий, а якоря `15-money-path.md` в девяти случаях из двенадцати РОДИЛИСЬ верными и сгнили дрейфом — то есть ревизия обязана быть исполнением, а не чтением. ⚠ **ОЧЕРЕДЬ, унаследованная от №21** (три лендинга 31.08 — секция «СОСТОЯНИЕ ПАКОВ» ниже): **(1) ~~РАЗРЫВ ЦИКЛА~~ ЗАМКНУТ ЖИВЬЁМ 04.09** — пользователь получил EPUB настоящего ПЛАТНОГО перевода ЧЕРЕЗ API, `epubcheck` 5.3.0 на СКАЧАННОМ файле 0/0/0/0 (книга `bk_SS5VES2JELESJSTR`, потрачено $0.278319 из гранта $0.60 при потолке пака $1.5, санкция D39.189). Дверь выдачи построена и проверена исполнением: `202`+`Location`, поллинг с `Retry-After`, Range 206 · второй клиент 200 · аноним 401 · чужая книга 404, TTL с GC, идемпотентность третьего создающего вызова. Все ТРИ сценария строки 216 предъявлены живьём (подпись банка · halt на потолке с exit 4 · `409 run_not_resumable/ceiling_reached` и лечение новым прогоном). > ⛔ **ПЯТЬ ПОТОКОВ РАБОТЫ — состояние на 07.09 (пере-снято лендингами смены №23).** > **(1) ПОЛИГОН — «ремонт прибора»** (`docs/POLYGON_INSTRUMENT_REPAIR_SESSION_PROMPT.md`, строки 319 · 300 · 265 · 143): > ⚠ **ЕДИНСТВЕННЫЙ ПОТОК, НЕ СДВИНУВШИЙСЯ ЗА СМЕНУ — сессия по промту так и не стартовала.** Блокирующая линза diff --git a/docs/README.md b/docs/README.md index 9df879dd..25d9910e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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; остальные — отчёты экспериментов. ⚠ **У каждого отчёта статус в его ревью-шапке: читай шапку прежде тела** — она первична и говорит, ратифицированы выводы или заморожены. diff --git a/docs/architecture/05-decisions-index.md b/docs/architecture/05-decisions-index.md index 8ad73c3b..21e37029 100644 --- a/docs/architecture/05-decisions-index.md +++ b/docs/architecture/05-decisions-index.md @@ -1,4 +1,4 @@ -# Реестр D-нот — карта актуальности v2 (D1–D39.261; титул — носитель головы, бампать при каждом аппенде) +# Реестр D-нот — карта актуальности v2 (D1–D39.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` | жив | контракт банк платформа процесс | diff --git a/docs/architecture/05-decisions-log.md b/docs/architecture/05-decisions-log.md index a4718301..8d9a6137 100644 --- a/docs/architecture/05-decisions-log.md +++ b/docs/architecture/05-decisions-log.md @@ -1,4 +1,4 @@ -# Журнал решений оркестратора — контракт D1–D39.261 (живой файл: карта · эрраты · живые тела · голова D39.124+ (подрезка D39.139); тела закрытых эр — в слайсах `docs/archive/architecture/`, указатель ниже; реестр всех нот — `05-decisions-index.md`) +# Журнал решений оркестратора — контракт D1–D39.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. Зелёный ЦЕЛИКОМ снят зоной на своём прогоне и мною не воспроизведён. ⚠ И числа скипов у меня НЕТ, а не ноль: сводку условий хоста печатает цель гейта ПОСЛЕ тестов, а оба прогона до неё не дожили. diff --git a/docs/architecture/14-api-contract/README.md b/docs/architecture/14-api-contract/README.md index c14e9313..f153ae91 100644 --- a/docs/architecture/14-api-contract/README.md +++ b/docs/architecture/14-api-contract/README.md @@ -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 | diff --git a/docs/architecture/14-api-contract/openapi.yaml b/docs/architecture/14-api-contract/openapi.yaml index 8d4bf97d..53f42eb0 100644 --- a/docs/architecture/14-api-contract/openapi.yaml +++ b/docs/architecture/14-api-contract/openapi.yaml @@ -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 diff --git a/platform/docs/DEFECT_REGISTER.md b/platform/docs/DEFECT_REGISTER.md index 4ea515ca..fe82f0c3 100644 --- a/platform/docs/DEFECT_REGISTER.md +++ b/platform/docs/DEFECT_REGISTER.md @@ -79,6 +79,9 @@ | ID | Класс | Серьёзность | Где | Суть | Статус | Источник | |---|---|---|---|---|---|---| +| PD-469 | bug | info | `internal/books/walk_test.go` (греп `TestTheCutOfAnUploadIsBoundedByTheWalkAndNotByItsOwnBudget`, строка сообщения — `walk_test.go:140`) | **ФИКСТУРА СЖИМАЕТ 220-СЕКУНДНЫЙ ПРОДУКТОВЫЙ БЮДЖЕТ ДО 700 мс И ДЕЛАЕТ ВНУТРИ НЕГО НАСТОЯЩИЙ КОММИТ В POSTGRES — под параллельными батареями он в этот срез не укладывается.** Замерено 17.09 на полном гейте зоны: единственный красный на всю батарею, `pgstore: commit: timeout: context deadline exceeded`, и в ТОМ ЖЕ логе все пакеты шли примерно в 2.4 раза дольше обычного (`internal/pgstore` 435 с против 180, `internal/runs` 370 против 151, `cmd/tmplatformctl` 46 против 17) — на машине в этот момент шла чужая мутационная кампания движковой зоны. ⚠ **«Флейк» здесь НЕ вывод из цвета, а цепь, проверенная звеньями:** (1) текст падения называет звено — коммит перерос свой срез; (2) фикстура ставит `uploadSettle = 700ms` и `writeBudget = 20ms` НАМЕРЕННО, потому что против настоящих тридцати секунд разрез не удержал бы ни одного среза; (3) звено, через которое могла бы войти чужая правка, — длительность УСТАНОВКИ схемы, и она лежит ВНЕ измеряемого окна: пере-снято, лишняя миграция стоит ≈0.12 с на прогон, а окно фикстуры отсчитывается после неё. ⛔ Частотное свидетельство при этом СЛАБОЕ и названо слабым: изолированно 6 из 6 зелёных в дереве пака и 6 из 6 в развёрнутом чистом `HEAD`, под синтетической нагрузкой ещё 4 и 4 — то есть 0 против 0, что не различает ничего, кроме отсутствия грубой регрессии. Родня — **PD-420** того же класса (краснеет под параллельными батареями, диагноз не установлен), и лечение у обоих общее: либо фикстура перестаёт держать настоящий коммит внутри суб-секундного среза, либо зона объявляет, что её рецепт мутаций и её батарея не сосуществуют. ⭐ **РЕШАЮЩИЙ ЗАМЕР 17.09, и он снят ПРАВИЛЬНЫМ прибором — двумя полными батареями ОДНОВРЕМЕННО, а не по очереди:** развёрнутый `git archive HEAD` (35 миграций, ни строки пака) и дерево пака запущены в одну секунду под одной чужой нагрузкой. **Красны ОБА, оба в `internal/books`, и на РАЗНЫХ тестах:** чистый HEAD — `TestAnIntakeStoppedByTheCapNamesTheReasonInWordsAnOperatorCanFind` (4.48 с) и `TestTheCutOfAnUploadIsBoundedByTheWalkAndNotByItsOwnBudget` (2.81 с); дерево пака — таймаут 600 с на `TestAnUploadThatRunsOutOfBudgetWaitingForASlotIsAcceptedRatherThanRefused` (висел 7 м 26 с, фикстура ставит `uploadSettle = 300ms`). ⇒ **краснота принадлежит ПАКЕТУ, а не правке**, и то, что тесты каждый раз РАЗНЫЕ, — подпись голодания планировщика, а не логического дефекта. ⚠ По очереди этот вывод не снимается: условия между двумя последовательными прогонами не совпадают, и сравнение было бы о разной нагрузке. Изолированно же пакет целиком зелен в ОБОИХ деревьях даже при load average 11 — то есть губит его не нагрузка сама по себе, а нагрузка ПЛЮС собственная батарея зоны под `-race`. ⭐⭐ **МЕХАНИЗМ ВТОРОГО ТЕСТА НАЙДЕН ПРИЁМКОЙ И ПЕРЕ-СНЯТ ЗОНОЙ — И ОН ДРУГОЙ, ЧЕМ У ПЕРВОГО; обе прежние формулировки этой строки были неточны.** Приёмка 17.09 воспроизвела красноту своим прибором и заметила то, чего не было в разборе зоны: фикстура НАЗЫВАЕТ свою посылку вслух и нигде её не проверяет — текст падения `limit_test.go:198` гласит `an upload on an idle host failed: pgstore: append frame: timeout`, а хост нёс load 8.2. Слово `idle` встречается в файле **4 раза**, `Skip`/`loadavg`/`NumCPU` — **0**: посылка сказана четырежды и не проверена ни разу. ⛔ **Но СЖИМАЕТ там не то, что кажется, и это пере-снято по коду:** в `TestAnUploadThatRunsOutOfBudgetWaitingForASlot…` строка 50 ставит `uploadSettle = 0`, а `walk` читает его через `if s.uploadSettle > 0` (`parse.go:443`) — то есть НОЛЬ ВОЗВРАЩАЕТ ПРОДУКТОВЫЙ бюджет 210 с, и половина «на простаивающем хосте» идёт вовсе не на сжатом сроке. Сжат другой рычаг: `writeBudget = 20 * time.Millisecond`, поставленный строкой 11 для ПЕРВОЙ половины теста и **никогда не сброшенный**, и каждый шаг второй половины поэтому берёт `min(20 мс, остаток)`. ⇒ падает утверждение «настоящий коммит в Postgres укладывается в 20 мс», а называет себя оно «загрузка на простаивающем хосте». ⚠ Это ТРЕТЬЯ редакция механизма в одной строке, и предыдущие две названы неверными прямо здесь: «фикстуры сжимают 220 с до сотен миллисекунд» верно про `TestTheCutOfAnUpload…` (там действительно 700 мс) и НЕ объясняет это падение; «дело в непроверенной посылке» верно по классу, но сжимает не посылка, а забытый рычаг. ⇒ **лечение конкретно и дёшево: сбрасывать `writeBudget` вместе с `uploadSettle`** (или дать второй половине собственные бюджеты), а сверх того — либо УТВЕРЖДАТЬ посылку явно (замер простоя и внятный скип на занятом хосте), либо перестать зависеть от абсолютного времени. ⭐ **СЕМЕЙСТВО ТЕПЕРЬ ПОСЧИТАНО, А НЕ ВСТРЕЧЕНО (17.09, зона; прежняя редакция строки честно говорила «встреченное»).** Прогон по всем тест-файлам пакета с разрешением констант: **73** теста в `internal/books`, из них **6** кладут настоящую операцию Postgres внутрь суб-секундного бюджета — `failfast_test.go` 1 (`writeBudget = 50 мс`), `limit_test.go` 4 (`uploadSettle = 300/60 мс`, `writeBudget = 20/25 мс`), `walk_test.go` 1 (`700 мс` / `20 мс`). **Все четыре теста, которые за смену краснели, — внутри этих шести**, ни одного за их пределами. ⚠ И ЧЕТВЁРТЫЙ случай снял довод, на котором держались первые три: `TestAnUploadTheHostCouldNotCutStillLeavesSomebodyToFinishTheBook` упал (`limit_test.go:500`, `pgstore: commit: timeout`) при длительностях пакетов, ПОЧТИ РАВНЫХ зелёному прогону (`pgstore` 322 против 317 с, `runs` 284 против 278, `books` 113 против 115) ⇒ **грубого замедления не требуется, хватает мгновенной заминки** — что для бюджета в 20 мс поверх сетевого коммита ожидаемо, а для вывода «виновата нагрузка» разрушительно. Структурная недостижимость чужой правки пере-снята тем же прогоном: вызовов `SaveBank`/`BankSigningStop`/`OfferedTerm` в `internal/books` — **0** при контроле «упоминаний `pgstore` — 55»; изолированно тест зелен 4 из 4 в обоих деревьях. Найдено полным гейтом пака «читатель секций стопа», механизм — приёмкой и пере-снятием зоны | open | пак «читатель секций стопа», 17.09 | +| PD-468 | doc | info | `internal/ingest/bank.go` (греп `Contradicts, BankHolds []string`), `internal/httpapi/project.go` (греп `projectOfferedTerm`), канон §`OfferedTerm` | **ДВА ПОЛЯ ПРОВОДА ОБЪЯВЛЕНЫ И НИ РАЗУ НЕ ВИДЕНЫ НА ЖИВЫХ ДАННЫХ: их проекция исполнением не проверена.** `contradicts` и `bank_holds` несут решающую для подписи информацию — «этот прогон сам себе противоречит» и «книга уже зовёт это иначе», — но на обоих купленных боевых прогонах они отсутствуют у **0 из 135** объектов (движок ставит им `omitempty`, а конфликтов ни в одном прогоне не случилось). ⇒ пины на них построены на синтетике этой же сборки, то есть отвечают на вопрос «декодер согласен сам с собой», а не «декодер согласен с движком». ⚠ Класс `PD-370`: объявленное и не увиденное. Что известно точно: движок собирает их из `ConsolidationConflict.PartLabel()` и `membank.BankKeyConflict.BankRowLabel()`, то есть это тоже СКЛЕЕННЫЕ ярлыки (`src→"dst"`), и им принадлежит та же судьба, что рядам **479** по `variants`. Лечение — не код, а УЛИКА: прогон, в котором конфликт случился, либо $0-фикстура движковой зоны; до неё утверждать «поля доезжают» нельзя | open | пак «читатель секций стопа», 17.09 | +| PD-467 | doc | info | `internal/pgstore/readmodel.go` (греп `func (s *Store) BankSigningStop`), `internal/pgstore/migrations/00036_bank_read_out.sql` (греп `engine_run_id`), канон §`readBankSigningStop` | **ЯКОРЬ СВЕЖЕСТИ ХРАНИТСЯ ЦЕЛИКОМ И НЕ СВЕРЯЕТСЯ НИ С ЧЕМ: ресурс стопа отвечает про ПОСЛЕДНИЙ стоп, а не про текущий.** Проекция несёт `as_of` и `run_id`, и движок называет их ОДНИМ якорем; платформа сохраняет оба, но сравнивает только границу. Следствие, названное прозой операции и потому не ложь, но и не полнота: после снятия стопа список живёт в БД до следующей материализации, и клиент, который не прочитал `Run.status`, покажет вопросы прогона, который давно ушёл дальше. ⚠ Сравнение построить МОЖНО и дёшево — платформа сама чеканит этот id (`pgstore.EngineStreamID` = `tm-stream--`), — но материализация идёт по долгу КНИГИ и прогона в руках не держит, так что это новый запрос и новая связь между читающей моделью и доменом прогонов. Пак её не строил намеренно: ресурс обещает ровно то, что канон уже говорит о той же популяции на квитанции правок («the surfaces the LAST signing stop offered»), и расширять обещание без механизма значило бы взять форму якоря без его гарантии. Образец сравнения, если строить, — `internal/ingest/tail.go` (греп `h.EngineRunID != want`) | open | пак «читатель секций стопа», 17.09 | | PD-465 | bug | info | `internal/pgstore/runs.go` (греп `ProofSpawns`), `internal/runs/spawn.go` (греп `RecordSpawn` и `Runner.Start`) | **ТРЕТИЙ СПОСОБ УЕХАТЬ ПОД ДОКАЗАТЕЛЬСТВОМ `run abandon` НЕ ЛОВИТСЯ: заявка на спавн коммитится ДО подъёма юнита.** Арбитр, построенный паком «деньги и правда на экране», сверяет id живой попытки и монотонный счётчик заявок под книжной блокировкой и закрывает два способа из трёх. Третий: `RecordSpawn` пишет `unit_name` и инкрементирует `spawns` одним оператором, а `Runner.Start` идёт СЛЕДУЮЩЕЙ строкой — доказательство, снятое между ними, читает счётчик УЖЕ увеличенным и слышит от systemd «юнита нет», потому что юнит ещё не создан; обе сверки сходятся, и списание попадает на попытку, чей юнит вот-вот поднимется. ⚠ Окно — миллисекунды между двумя операторами одной функции, и достижимо только если оператор снимает доказательство ровно в них. Цена та же, что у всякого неверного доказательства: живой движок тратит против своего книжного потолка, холд аккаунта закрыт, провайдеру платит деплой (ограничено потолком этого прогона, аккаунтом не эксплуатируемо). ⛔ **Лечение требует НОВОГО состояния — «заявка в полёте», которого сегодня не пишет никто**, поэтому пак его не строил: заморозка скоупа, и добавление способности — не закрытие находки. Кандидаты: писать `spawns` ПОСЛЕ подъёма юнита (меняет смысл счётчика на «успешно поднятых»), либо отдельная отметка, снимаемая на `Start`/`unclaim`. Найдено приёмкой оркестратора №23 по коду | open | приёмка пака «деньги и правда на экране», 07.09 | | PD-464 | bug | minor | `internal/books/parse.go` (греп `const UploadSettle`), бутовый гейт `internal/config/config.go` (греп `UploadSettle`) | ⛔ **КОНСТАНТА ОБЪЯВЛЯЕТ ИНВАРИАНТ, КОТОРОГО НЕ ДЕРЖИТ, И ТРИ ВЫВОДА ЕЁ ЗНАЧЕНИЯ РУКАМИ ПОДРЯД БЫЛИ НЕВЕРНЫ.** `UploadSettle` обещает покрыть всё, что загрузка делает ПОСЛЕ тела, и бутовый гейт на этом обещании допускает дедлайн. Замер 07.09 (двоичный поиск по `Load()`, хвост выведен ПО КОДУ пути): объявлено **3m30s**, реальный худший хвост **3m40s** (`StartParsing` 30 + разрез 90 + `FinishParse` 30 + `ReleaseParseClaim` 30 + `ReadBook` 30 + квитанция 10), недобор **10 с**. Гейт принимает дедлайн до **26m29s**, ложь начинается с **26m21s** — окно шириной восемь секунд, достижимое только ручной настройкой почти вплотную к потолку гейта; на дефолте `10m` запас **16m20s**. ⚠ **Наблюдаемое следствие — ДУБЛЬ КНИГИ, а не потеря:** претензия на ключ идемпотентности, пережившая `ClaimStale`, перехватывается повтором с новым токеном (`internal/pgstore/idempotency.go`, ветка сравнения возраста претензии с `ClaimStale`), и человек, повторивший «висящую» загрузку, получает две книги вместо реплея одной. ⛔ **ЛЕЧЕНИЕ — НЕ ПОДНЯТЬ ЧИСЛО:** оно выводилось руками трижды и трижды было неверным, каждый раз по новой причине (пропущен шаг · неверный бюджет квитанции · два последовательных шага записаны как альтернативы), поэтому четвёртый вывод руками — подпорка, а не починка (`D39.216`). Границу надо выводить ИЗ кода пути. ⚠ Достижимость худшего пути БЕЗ искусственного замедления **не измерена**: она требует конъюнкции «большая книга» и «три полных `writeBudget` подряд», а гейт живого движка на хосте замера не закрыт. Первый из семи пунктов, с которыми синхронный разрез уезжает отдельным паком ⚠ **ДИСПОЗИЦИЯ ПАКА «РАЗРЕЗ ПРИЁМА ДО ГОТОВНОСТИ» (08.09, ЗАЛАНДЁН `ddcbf0c`, акт `D39.229`): ЗАКРЫТО ОБЕИМИ ПОЛОВИНАМИ.** (1) Значение приведено к замеру: квитанция стала ТЕРМИНОМ суммы, `UploadSettle = CutBudget + 4*writeBudget + ReceiptBudget` = **220 с** — ровно тот худший хвост, что намерен слева; носитель у величины квитанции теперь ОДИН (`books.ReceiptBudget`, тратит её `httpapi.settleCtx`), и разойтись им нечем. (2) ⭐ И это НЕ то, что закрывает строку, потому что четвёртый вывод суммы руками был бы четвёртой заплатой: хвост целиком идёт под ОДНИМ отсоединённым дедлайном (`books.walk`), каждый шаг берёт `min(свой бюджет, остаток хвоста)` (`books.step`), и добавленный завтра шаг границу НЕ ДВИГАЕТ ПО ПОСТРОЕНИЮ — сумма теперь отвечает лишь за то, чтобы в обычном худшем случае ни один шаг не был урезан. Предъявлено: `TestNoStepOfAnUploadsTailOutlivesTheWalk` (в т.ч. 50 вложенных шагов), `TestTheCutOfAnUploadIsBoundedByTheWalkAndNotByItsOwnBudget`, `TestTheWalkLeavesTheReceiptItsShareOfTheSettleBudget`, `TestTheReceiptSpendsTheShareTheIntakeSetAsideForIt`; посадки M1/M2/M3/M7 красные с топичным текстом. ⚠ Попутно закрыт СОСЕДНИЙ промах того же гейта, которого строка не называла: он сверял дедлайн с `min(UploadGrace, ClaimStale)` и пропускал `ClaimGrace` — свип мог забрать claim у книги, чья загрузка ещё идёт (п.7 десятки). Окон в гейте теперь ТРИ. | fixed | пак «деньги и правда на экране», круг 5 самопроверки, 07.09 | | PD-463 | bug | minor | `internal/books/parse.go` (греп `ChaptersTotal < 2 && atIntake`), контракт `RejectReason` | **ВХОД СУЖЕН ДО ТОГО, ЧТО УМЕЕТ ВЫДАЧА, ТОЛЬКО НА СИНХРОННОЙ ВЕТВИ ПРИЁМА.** Отказ книге, которую движок нарезал в одну главу, стоит под условием `atIntake`: на ветви, куда книга уходит при деплойном классе или превышении бюджета разреза, тот же манифест доезжает до `FinishParse`, и «книга одним полотном» попадает в библиотеку молча. ⚠ **Закрыть на асинхронной ветви СЕГОДНЯ НЕЧЕМ, и это не недосмотр:** словарь `RejectReason` контракта закрыт четырьмя значениями, и ни одно не описывает «прочли, нарезали, а такую форму мы пока не отдаём» — ближайшее по словам `source_unreadable` ТЕРМИНАЛЬНО и УДАЛЯЕТ исходник, то есть уничтожает файл пользователя из-за НАШЕГО ограничения. ⇒ нужен либо пятый `RejectReason` (контрактный минор, зона оркестратора), либо решение, что асинхронная ветвь остаётся проницаемой. Достижимость узкая: ветвь берётся только когда синхронный разрез не дал вердикта. Найдено опровергателем по готовой работе | open | пак «деньги и правда на экране», 07.09 | diff --git a/platform/docs/STACK_DECISIONS.md b/platform/docs/STACK_DECISIONS.md index 671cd83d..c81dae13 100644 --- a/platform/docs/STACK_DECISIONS.md +++ b/platform/docs/STACK_DECISIONS.md @@ -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/ diff --git a/platform/docs/platform-PROGRESS.md b/platform/docs/platform-PROGRESS.md index 127b612b..f57fa8ad 100644 --- a/platform/docs/platform-PROGRESS.md +++ b/platform/docs/platform-PROGRESS.md @@ -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--`, `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) — ЧЕТЫРЕ АДРЕСА В ВАШЕЙ ЗОНЕ ПОСЛЕ ДВИЖКОВОГО ПАКА И РАТИФИКАЦИИ ДВУХ ОСТАНОВОК Зона ваша, рукой не трогаю. Всё ниже пере-снято моим прибором сегодня; каждый адрес — от корня репозитория. diff --git a/platform/internal/httpapi/capabilities.go b/platform/internal/httpapi/capabilities.go index e86b8926..ca9a26d6 100644 --- a/platform/internal/httpapi/capabilities.go +++ b/platform/internal/httpapi/capabilities.go @@ -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 { diff --git a/platform/internal/httpapi/project.go b/platform/internal/httpapi/project.go index 702bdf87..91a1f4ff 100644 --- a/platform/internal/httpapi/project.go +++ b/platform/internal/httpapi/project.go @@ -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, diff --git a/platform/internal/httpapi/reading.go b/platform/internal/httpapi/reading.go index 857f488c..9906ce03 100644 --- a/platform/internal/httpapi/reading.go +++ b/platform/internal/httpapi/reading.go @@ -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 diff --git a/platform/internal/httpapi/signingstop_test.go b/platform/internal/httpapi/signingstop_test.go new file mode 100644 index 00000000..2dcc151c --- /dev/null +++ b/platform/internal/httpapi/signingstop_test.go @@ -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 +} diff --git a/platform/internal/httpapi/v0.go b/platform/internal/httpapi/v0.go index 8de2cd06..193dff16 100644 --- a/platform/internal/httpapi/v0.go +++ b/platform/internal/httpapi/v0.go @@ -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. diff --git a/platform/internal/httpapi/v0_test.go b/platform/internal/httpapi/v0_test.go index 838fa510..69cc7429 100644 --- a/platform/internal/httpapi/v0_test.go +++ b/platform/internal/httpapi/v0_test.go @@ -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 { diff --git a/platform/internal/ingest/bank.go b/platform/internal/ingest/bank.go index 5a0cb486..a3fc4635 100644 --- a/platform/internal/ingest/bank.go +++ b/platform/internal/ingest/bank.go @@ -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": diff --git a/platform/internal/ingest/bankreadout_test.go b/platform/internal/ingest/bankreadout_test.go new file mode 100644 index 00000000..6a8d3fb3 --- /dev/null +++ b/platform/internal/ingest/bankreadout_test.go @@ -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 +} diff --git a/platform/internal/pgstore/bankreadout_test.go b/platform/internal/pgstore/bankreadout_test.go new file mode 100644 index 00000000..afbfe800 --- /dev/null +++ b/platform/internal/pgstore/bankreadout_test.go @@ -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]) + } +} diff --git a/platform/internal/pgstore/events_test.go b/platform/internal/pgstore/events_test.go index a2c0c088..79059437 100644 --- a/platform/internal/pgstore/events_test.go +++ b/platform/internal/pgstore/events_test.go @@ -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) } diff --git a/platform/internal/pgstore/migrations.sha256 b/platform/internal/pgstore/migrations.sha256 index 8319e6dc..48bd8c9d 100644 --- a/platform/internal/pgstore/migrations.sha256 +++ b/platform/internal/pgstore/migrations.sha256 @@ -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 diff --git a/platform/internal/pgstore/migrations/00036_bank_read_out.sql b/platform/internal/pgstore/migrations/00036_bank_read_out.sql new file mode 100644 index 00000000..cb143211 --- /dev/null +++ b/platform/internal/pgstore/migrations/00036_bank_read_out.sql @@ -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; diff --git a/platform/internal/pgstore/readmodel.go b/platform/internal/pgstore/readmodel.go index 9475c1d3..a5910dc1 100644 --- a/platform/internal/pgstore/readmodel.go +++ b/platform/internal/pgstore/readmodel.go @@ -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 { diff --git a/platform/internal/pgstore/readmodel_test.go b/platform/internal/pgstore/readmodel_test.go index 027760b6..8de45aec 100644 --- a/platform/internal/pgstore/readmodel_test.go +++ b/platform/internal/pgstore/readmodel_test.go @@ -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) } } diff --git a/platform/internal/readmodel/readmodel.go b/platform/internal/readmodel/readmodel.go index aedd46ce..3050d643 100644 --- a/platform/internal/readmodel/readmodel.go +++ b/platform/internal/readmodel/readmodel.go @@ -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. diff --git a/platform/internal/readmodel/readmodel_test.go b/platform/internal/readmodel/readmodel_test.go index 31cf0dff..ce5604ea 100644 --- a/platform/internal/readmodel/readmodel_test.go +++ b/platform/internal/readmodel/readmodel_test.go @@ -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) + } +}