1856 lines
226 KiB
Markdown
1856 lines
226 KiB
Markdown
# Журнал зоны «Платформа»
|
||
|
||
> Весь прогресс платформы — ЗДЕСЬ (решение владельца 04.08): пинги, итоги сессий, открытые
|
||
> вопросы, предложения на ратификацию. В `docs/PROGRESS.md` платформа не пишет; оркестратор
|
||
> читает этот журнал при каждом лендинге зоны (свип «решений владельца» — норма D39.99 п.4).
|
||
|
||
## Текущее состояние
|
||
|
||
- **P4 + дофикс + V2 ПРИНЯТЫ и ЗАЛЕНДЕНЫ приёмкой №15 — D39.123, `d29e30c`** (раздел «Ратификация
|
||
приёмкой P4» ниже; было: «дерево НЕ закоммичено — ждёт приёмки»). Все пять MAJOR приёмки закрыты:
|
||
аргумент потолка (формула ратифицирована ПОПРАВКОЙ — `committed + прирост`, PD-158) ·
|
||
живая инверсия блокировок и карантин на транзиентном сбое · два незапиненных свойства · расчёт по
|
||
устаревшему снапшоту; плюс PD-159 селф-ревью.
|
||
- **P4 (08–09.08) построила РАННЕР целиком** (раздел «Сессия P4» ниже): транзиентный systemd-юнит на
|
||
прогон · очередь River + воркер · реконсилятор (свип + бут) · тейлер `events.jsonl` с курсором ·
|
||
первые пять ручек `/v0` по контракту 0.2.0 · дев-интейк книг. Батарея зелёная целиком с живым
|
||
PostgreSQL 18.4 под `-race`, линтер 0 issues, `make vuln` чист, **скипов ноль**. Тестов после
|
||
дофикса **105 → 261** (удалённых ноль).
|
||
- ⚠ **Адверсариальное ревью (четыре независимых верификатора) нашло ТРИ денежные ошибки и три
|
||
запирающие прогон — все закрыты в этом же дереве, с пинами и повторной живой пробой.** Раздел
|
||
«Адверсариальное ревью» ниже; главная — расчёт брал пожизненную трату КНИГИ вместо траты прогона
|
||
(PD-124), и её независимо воспроизвели два верификатора. ⚠ Её СЕРДЦЕ при этом осталось
|
||
незапиненным, и это нашла уже приёмка (PD-146) — закрытая строка с пережившей посадкой.
|
||
- **Регистр после ре-чека V2 — 171 строка**: 124 закрыто (в т.ч. PD-112 и PD-158 — ратификацией) ·
|
||
3 приняты риском · 1 закрыт ратификацией (PD-59) · **43 открыто** (1 major — PD-113, 11 minor,
|
||
31 info).
|
||
- **PD-158 ЗАКРЫТ РАТИФИКАЦИЕЙ:** принята формула `committed + прирост`; оркестратор подтвердил
|
||
аргументацию исполнением обеих формул против гейта движка — ратифицированная переплачивала запасом
|
||
ровно на leftover-reserved. Вопросов зоны к оркестратору не осталось.
|
||
- ⚠ **Селф-ревью дофикса (три независимых верификатора) нашло ещё одну денежную ошибку — PD-159, и
|
||
её воспроизвели исполнением ДВОЕ независимо**: отложенный расчёт прогона оплачивал работу
|
||
следующего прогона той же книги, а тот платил за неё снова (замерено: $2.10 за прогон, стоивший
|
||
$0.10). Закрыто верхней границей по базовой линии следующей попытки, с пином. Там же — два
|
||
незапиненных свойства (PD-160/161) и десять новых открытых строк, из них две с живым замером.
|
||
Закрыты в P4 шесть СТАРЫХ строк: PD-43 · PD-81 · PD-82 · PD-97 · PD-99 · **PD-105** (ратификация
|
||
D39.119 + реализация). Заведены тридцать шесть новых, PD-108…PD-143 — счёт после дофикса
|
||
(скриптом по таблице): **27 закрыто** (8 своими руками, включая PD-112 ратификацией; 19 —
|
||
адверсариальным ревью), **9 открыто**. ⚠ Прежняя формулировка «36 = 25 + 10» была арифметически
|
||
неверна и исправлена приёмкой (PD-151). Дофиксом заведены PD-144…PD-170.
|
||
|
||
- **P3 (08.08) отработала фикс-лист приёмки P2 ЦЕЛИКОМ — все восемь пунктов** (раздел «Сессия P3»
|
||
ниже): PD-80 · PD-79 · PD-83/84/85 · PD-100 · PD-106 · PD-91 · PD-95 · PD-104 (часть док↔код).
|
||
22 посадки, 22 поймано; батарея зелёная офлайн и с живым PG 18.4 под `-race`, линтер 0 issues,
|
||
`make vuln` чист. **ПРИНЯТО и ЗАЛЕНДЕНО** приёмкой №15 — D39.114, `99f049c` (было: «дерево НЕ закоммичено — ждёт приёмки»). ⚠ Адверсариального ревью P3 не
|
||
проводила (сессия без права на субагентов): проверено исполнением, но вторым читателем — нет.
|
||
- **Регистр после P3 — 107 строк** (скриптом по таблице): 75 закрыто · 3 приняты риском ·
|
||
1 закрыт ратификацией (PD-59) · **28 открыто** — из них **1 major** (PD-105, за границей пака),
|
||
7 minor, 20 info. Закрыты в P3 девять строк: PD-79 · PD-80 · PD-83 · PD-84 · PD-85 · PD-91 ·
|
||
PD-95 · PD-100 · PD-106. Открытые: PD-6 · PD-23 · PD-43 · PD-44 · PD-45 · PD-60 · PD-61 ·
|
||
PD-72 · PD-81 · PD-82 · PD-86…PD-90 · PD-92…PD-94 · PD-96…PD-99 · PD-101…PD-105 · PD-107.
|
||
⚠ **PD-104 намеренно оставлен ОТКРЫТЫМ:** расхождение док↔код починено (ячейка PD-30), но
|
||
продуктовая половина — ноль на бете, агрегатный потолок фри-тира, счётчик — ждёт слова владельца,
|
||
и закрывать строку по половине было бы ровно тем, за что заведён PD-83.
|
||
- **P1+P2 ПРИНЯТЫ и ЗАЛЕНДЕНЫ приёмкой №15 (07.08)** — раздел «Ратификация приёмкой P2» ниже:
|
||
вердикт, метод, что ратифицировано, фикс-лист, что опровергнуто. Два вопроса ушли владельцу:
|
||
срок сессии 30 суток и агрегатный потолок фри-тира (PD-104).
|
||
- **P2 отработала очередь приёмки P1 целиком плюс ДВА собственных адверсариальных ревью** (05.08) —
|
||
разделы «Сессия P2» ниже. Риском приняты три строки (PD-22 ограничитель соединений на edge,
|
||
PD-42 глобальный лимитер входа, PD-71 откат ниже версии 5); счёт регистра — строкой выше, здесь
|
||
не дублируется. Открытыми на конец P2 были восемь: PD-6 (origin-чек SSE-хендшейка — строить нечего до SSE), PD-23 (ретеншен
|
||
журнала входов — сам свип есть, строка про политику), PD-43 (денежный контур без вызывающих до
|
||
воркера), PD-44 (`sqlc` — закрыт ратификацией, направление изменено), PD-45 (окно pid при сигнале
|
||
группе), **PD-72 — возврат общего лимита тела не наблюдаем, пока нет маршрута со своим потолком:
|
||
тест обязан приехать вместе с загрузкой книги**, **PD-60 и PD-61 — свойства ШВА, работа строки 103
|
||
единого бэклога** (обратное давление и
|
||
сброс буфера эмиттера: платформенной части у них нет, пока эмиттера нет).
|
||
- **Закрыто в P2:** вся очередь приёмки (PD-46…PD-58), три info-строки вне очереди
|
||
(PD-50, PD-53, PD-56), три собственные находки — PD-62 (потерянный `start_id`), PD-63
|
||
(`ClearReadDeadline` воспроизводил PD-2), PD-64 (`OOMPolicy=stop` уронил бы контрол-плейн) — и
|
||
**шесть находок собственного адверсариального ревью** (PD-65…PD-70), из которых одна major:
|
||
скользящее окно бездействия для браузера не работало (PD-70).
|
||
- **Два значения изменены, не обоснованы:** абсолютный срок сессии 90 → **30 суток** (цифра NIST
|
||
AAL1; отклонение без причины, выдерживающей проверку, — не отклонение, а недосмотр) и
|
||
`MemoryMax=2G` → **80%** (потолок машины вместо мнимого потолка сервиса). Оба переопределяются.
|
||
- **Построено в P1:** вход через OIDC (П-6), кредитный леджер с резервациями (П-7), админ-CLI (П-8),
|
||
деплой-юнит systemd, тест-пол на реальном `http.Server`, фаззинг NDJSON-декодера.
|
||
- **Стандарты зоны заведены** (решение владельца 04.08): `ENGINEERING_STANDARDS.md` (критерии приёмки,
|
||
индустриальные базовые линии) + `DEFECT_REGISTER.md` (отдельная колонка багов и уязвимостей).
|
||
- **P0 собран** (сессия 04.08): модуль компилируется, батарея зоны `make check` зелёная,
|
||
`/healthz` и `/readyz` проверены живым запуском против живого Postgres 18.4.
|
||
- Стек запинен и live-сверен — `STACK_DECISIONS.md` (зонный).
|
||
- Дизайн-ответы К-4 · К-7 · К-12 · форма П-5 — ниже, ПРЕДЛОЖЕНИЯМИ на ратификацию.
|
||
- Контрактных ручек нет намеренно: они ждут ратификации К-4/К-7 (форма ответов) — это П-1.
|
||
|
||
## Ратификация приёмкой P4 (оркестратор №15, 09.08)
|
||
|
||
**Вердикт: P4 + дофикс + V2 ПРИНЯТЫ и ЗАЛЕНДЕНЫ — D39.123, код `d29e30c` (53 файла, +9867/−170,
|
||
только `platform/`).** Три раунда, 13 агентов приёмки; каждое заявление пере-ранено исполнением.
|
||
|
||
**Метод и что подтвердилось.** 7-агентный воркфлоу (слепой дифф · опровергатель-Opus D39.120 ·
|
||
пере-ран батареи · 10 моих посадок · 47 wire-проверок контракта · охотник-деньги ·
|
||
охотник-systemd с РЕАЛЬНЫМ tmctl из HEAD) → фикс-лист F1–F8 → ре-чек 4 контурами (обе пробы
|
||
приёмки, пере-мутации, PD-158/159 адверсариально на Opus, охота по дельте) → V2 → финальный
|
||
ре-чек (5/5 пере-мутаций убиты поимённо). Реальный движок принял платформенный argv целиком,
|
||
имена полей `status --json` совпали с парсером побайтно, exit-контракт и маркер сошлись; четыре
|
||
systemd-клейма пере-мерены; батарея EXIT=0 с живым PG 18.4 под `-race`, скипов 0; тестов
|
||
105→261 (+156/−0); дерево на лендинге байт-равно проверенному.
|
||
|
||
**Пять MAJOR приёмки и PD-159 селф-ревью — все закрыты с пинами**; разборы в разделах «Дофикс
|
||
P4» и «Ре-чек V2» ниже. Несущие: аргумент потолка против кумулятивной семантики движка (F1;
|
||
вскрыт тем, что сквозная проба шла на фейке без кумулятива) · дедлок books↔run_attempts 258/300
|
||
с вечным карантином проекции на транзиентном 40P01 (F2; ложно закрытая PD-129 пере-открыта и
|
||
закрыта честно) · settle по устаревшему снапшоту (F5) · двойная оплата при отложенном расчёте
|
||
(PD-159, найдена селф-ревью сессии — воспроизведена приёмкой на до-фиксном дереве).
|
||
|
||
**Ратифицировано (D39.123 п.2):** linger-privilege-модель · **ПОПРАВКА формулы потолка:
|
||
`committed + прирост`, БЕЗ reserved (PD-158)** — сессия была права, отказавшись молча исполнять
|
||
формулу моего пинга: исполнение обеих против гейта движка показало, что пинг переплачивал
|
||
запасом ровно на leftover-reserved · карантин ПРОЕКЦИИ (PD-105-уточнение) · ставка $0.03 ·
|
||
`default_chapters` = верх шкалы · 503 в контракт (0.2.1; PD-112 закрыт) · PD-114 → задача
|
||
«печать эффективной конфигурации» · PD-115 — направление принято. PD-113 — единственный
|
||
открытый major, до эмиттера; его цена после PD-158 выросла (стоп по потолку = ровно исчерпание
|
||
холда). Движку заведены строки 165 (различимые exit-коды потолка/стопа) и 166 (оценка $/глава).
|
||
|
||
**Урок приёмки в обе стороны:** (а) фейк шва обязан моделировать СЕМАНТИКУ чужой стороны, не
|
||
только форму — обе жёсткие находки (F1, N1-stop→failed) прошли бы фейком; класс закрыт
|
||
кумулятивным ceilingJudge в пинах; (б) моя ошибка нумерации (PD-167 = коммент 00009, не 00011)
|
||
поймана сессией — исправлена в строке; (в) две посадки сессии, пережившие первую редакцию пина,
|
||
задекларированы, не подчищены — норма «заявление=команда» работает в обе стороны.
|
||
|
||
## Дофикс P4 (09.08): фикс-лист приёмки — F1…F8, N1…N4
|
||
|
||
Пак принят УСЛОВНО с пятью MAJOR. Ниже — что сделано по каждому пункту, чем это проверено и что
|
||
осталось. Каждое число здесь снято исполнением; команда стоит рядом с числом.
|
||
|
||
### F1 [MAJOR, деньги/шов] Аргумент потолка — по ратифицированной формуле
|
||
|
||
Приёмка права, и её посылка перепроверена мной на HEAD ОБЕИХ зон, не по памяти:
|
||
`backend/cmd/tmctl/invocation.go` объявляет флаг словами «It caps the book's CUMULATIVE
|
||
committed+reserved spend, not this run's increment», а `backend/internal/store/ledger.go` `Reserve`
|
||
сравнивает `SUM(committed_usd + reserved_usd)` книги с `c.BookUSD` на КАЖДОЙ резервации. Движковая
|
||
проба приёмки пере-прогнана в КОПИИ бэкенда (в чужую зону не писал):
|
||
`TestAcceptSecondRunDeniedWhenCeilingIsPassedAsIncrement` — при committed $3.00 потолок `3.0` даёт
|
||
`ReserveDeniedBook`, `6.0` — `ReserveOK`.
|
||
|
||
Сделано: `runs.meter` (`committed`, `reserved`) читается ОДНИМ вызовом `status --json` перед стартом
|
||
(`bookMeter`), аргумент = `committed + прирост` (`meter.bookCap`). ⚠ Слагаемое `reserved` из
|
||
ратифицированной формулы НЕ добавляется — это названное отклонение в консервативную сторону, разбор
|
||
и вопрос оркестратору ниже, PD-158. `reserved_usd`
|
||
внесён в аллоулист `ingest.StatusReport` **указателем** — отсутствие ≠ ноль ровно по той же причине,
|
||
что и у committed, только ошибка зеркальная: ноль вместо реального резерва даёт потолок НИЖЕ того, на
|
||
чём книга уже стоит. Рестарт идёт тем же путём и получает свежий отсчёт (спавн один на все случаи).
|
||
Фактически ушедшее значение хранится: `run_attempts.ceiling_arg_micro_usd` (миграция 00011) — оно не
|
||
равно `ceiling_micro_usd`, и после того как счётчик книги сдвинулся, восстановить его нечем.
|
||
|
||
Пины (посадки — ниже): `TestTheSecondRunOfABookIsGivenTheCumulativeCapAndNotItsOwnIncrement`,
|
||
`TestAResumeIsGivenACapComputedFromTheMeterAsItStandsNow`. Оба гоняют фейк `ceilingJudge`, который
|
||
судит `--ceiling-usd` **по правилу движка**, включая проход восстановления: открытие книги на запись
|
||
обнуляет остаточный `reserved_usd`, после чего потолок на уровне `committed` или ниже отвергается
|
||
первой же резервацией. Это и есть ответ на «почему батарея пака этого не поймала»: её фейк лишь
|
||
записывал аргумент, а фейк, который не может отказать, не проверяет ничего. Резюмный пин отделён от
|
||
арифметики специально: в нём смоделирован ОСТАВЛЕННЫЙ резерв убитого процесса ($0.20), и он
|
||
утверждает ДВА факта — что потолок пересчитан по свежему отсчёту и что остаток его не раздул
|
||
(PD-158).
|
||
|
||
Проба приёмки на этом дереве (её файл, только `Reserved` дописан в отчёты фейка):
|
||
`TestAcceptSecondRunCeilingArgument` — **PASS**, `run2 args: … --ceiling-usd 6.000000` при
|
||
committed книги $3.
|
||
|
||
Устаревшие посылки, названные приёмкой, сняты:
|
||
- `runner/engine.go` больше не пишет, что флага нет и что копировать форму из чужого дерева нельзя:
|
||
флаг заленден (`0e69bc1`), форма ратифицирована, поэтому `--ceiling-usd {{usd}}` стал **дефолтом**
|
||
конфигурации (`TM_PLATFORM_ENGINE_CEILING_ARG` остаётся переопределением для сборки с другим
|
||
написанием). `ErrCeilingNotWired` оставлен: тип по-прежнему допускает пустое значение, а пустой
|
||
шаблон не должен значить «без потолка». ⚠ Уточнено ревью доков: он не «недостижим» — переменная из
|
||
одних пробелов проходит подстановку дефолта и даёт пустой шаблон, то есть отказ в рантайме при
|
||
одном WARN на буте (PD-165 — тот же класс, что относительный `TM_PLATFORM_CTL_BIN`).
|
||
- `ingest/resync.go` больше не пишет, что у статуса нет фазового сплита: `progress{draft,edit}`
|
||
внесён в аллоулист и материализуется `ApplyStatus` теми же четырьмя счётчиками, что и поток. Этим
|
||
снято ⚠ затирания фаз резюнком — не гейтом, а тем, что затирать стало нечем. Гейт
|
||
«поток идёт → ре-синк не зовём» оставлен, но его обоснование переписано честно: он теперь про
|
||
ЦЕНУ (каждый вызов статуса — секунды CPU на пере-нарезку) и про то, что поток свежее.
|
||
|
||
### F2 [MAJOR] Инверсия блокировок была жива; транзиентный сбой уходил в карантин
|
||
|
||
Порядок написан в ОДНОМ месте и стал глобальным — `pgstore.lockBook`:
|
||
`books → runs → run_attempts → account_balances → reservations`. Книга блокируется первой в
|
||
`RunSink.Apply`, `RestartRun` и `StartRun` (последний добавлен не «за компанию»: он берёт баланс до
|
||
`update books`, и если бы книгу первым брал только рестарт, пара `StartRun ∥ RestartRun` дала бы ту
|
||
же инверсию на другой паре строк). `closeReservation` уже брал баланс раньше резервации — проверено,
|
||
не тронуто. Собственной сверкой всех транзакций пакета найден ещё один нарушитель — `DeleteBook`
|
||
(резервации, потом книга); цикла для него сегодня нет, но именно такое рассуждение записанный
|
||
инвариант и должен заменять, поэтому книга взята первой и там.
|
||
|
||
Классификация ошибки материализации вынесена в `runs.quarantines` и стала явной:
|
||
транзиентные SQLSTATE (`40P01`/`40001`, `pgstore.IsTransient`) и собственная отмена — повтор
|
||
следующим свипом; карантин остаётся только логическим ошибкам (пропасть, конфликт payload, битая
|
||
строка, строка длиннее буфера).
|
||
|
||
Пины. Конкурентный оставлен (`TestAMaterializerAndAReconcilerOnOneRunDoNotDeadlock`, 60×3 через
|
||
реальные API), но он **пробует**, а не доказывает: посадка «снять `lockBook` из `RestartRun`» его
|
||
ПЕРЕЖИЛА — рестарт по-настоящему берёт замок один раз за прогон, и окно слишком узкое. Поэтому
|
||
написаны два пина, утверждающие порядок ПРЯМО: `TestTheMaterializerTakesTheBookBeforeTheAttempt` и
|
||
`TestARestartTakesTheBookBeforeTheAttempt` — тест держит замок книги, ждёт, пока операция реально
|
||
заблокируется, и спрашивает строку попытки через `for update nowait`. Свободна — значит операция
|
||
ждёт книгу, ничего не держа. Занята — значит она взяла попытку по дороге, то есть в том порядке,
|
||
который и дедлочит. ⚠ Ожидание блокировки читается из `pg_stat_activity`, а не из `pg_locks`:
|
||
ждущий строку ждёт на `transactionid` держателя, а у таких строк `pg_locks.database` пуст, поэтому
|
||
запрос по своей базе их не видит (проверено — первая редакция теста падала именно так).
|
||
|
||
### F3, F4 [MAJOR, пины] Два свойства, построенных и не запиненных
|
||
|
||
- **Нечитаемый отсчёт ОТКАЗЫВАЕТ спавну** (сердце PD-124): `TestAnAttemptWhoseMeterCannotBeReadIsNotStartedAtAll`
|
||
— три формы нечитаемости (вызов упал · нет `committed_usd` · нет `reserved_usd`), в каждой
|
||
утверждается, что юнит не создан И право на спавн не заклеймлено (`unit_name` пуст), значит свип
|
||
повторит; хвост показывает, что после починки движка та же попытка стартует.
|
||
- **Устаревший exit-маркер стирается ДО старта**: `TestAStaleExitMarkerIsClearedBeforeTheUnitStarts`
|
||
— маркер создаётся до спавна, после спавна обязан отсутствовать, свип обязан оставить прогон живым
|
||
и холд открытым.
|
||
|
||
### F5 [MAJOR, деньги] Расчёт по устаревшему снапшоту
|
||
|
||
`pgstore.ReleaseUnspawned` перечитывает `run_attempts.unit_name` `for update` в ТОЙ ЖЕ транзакции,
|
||
что и деньги, и отказывает (`ErrAttemptSpawned`), если юнит есть. Реконсилятор тогда откладывает
|
||
расчёт: следующий проход видит юнит в снапшоте и считает по базовой линии попытки. Выбран SQL-гард,
|
||
а не перечитывание в Go, потому что гард атомарен с самим возвратом холда.
|
||
|
||
Пин: `TestAStaleSnapshotDoesNotGiveBackTheHoldOfAnAttemptThatSpent` — в проходе со старым снапшотом
|
||
холд не возвращается целиком, следующий свип списывает ровно потраченные $0.50.
|
||
⚠ Проба приёмки `TestAcceptStaleSnapshotReleasesTheHoldOfARunThatActuallySpent` на этом дереве **не проходит как написана** и это не
|
||
регрессия: она требует, чтобы деньги разрешились в ТОМ ЖЕ вызове `reconcile`, а исправление
|
||
откладывает их на следующий проход. Замеренная разница: было — баланс `10.000000` (весь холд назад,
|
||
списано ноль), стало — `7.000000` при `3.000000` в резерве (деньги на месте и видимы), после
|
||
следующего свипа — `9.500000`.
|
||
|
||
### F6, F7 [MINOR]
|
||
|
||
- Относительный `TM_PLATFORM_STATE_DIR` отвергается на буте (`filepath.IsAbs`), пин
|
||
`TestARelativeStateDirectoryIsRefusedAtBoot`.
|
||
- «Метка давности» ре-синка построена: `runs.last_resync_at` (миграция 00012) пишется КАЖДЫМ
|
||
ре-синком, пин `TestAResyncRecordsWhenItWasTaken` (нет метки до первого · метка равна времени
|
||
вызова · вторая переписывает первую). ⚠ Названная девиация: **на провод метка не выходит** — в
|
||
контракте v0 у прогона поля свежести нет, и заводить его самовольно не право зоны (PD-150).
|
||
|
||
### F8 [MINOR, отчёт/регистр]
|
||
|
||
Все пять исправлений внесены: числа тестов пересчитаны исполнением и приведены с командой (105 → 261,
|
||
удалённых 0); арифметика «36 новых» исправлена на 27 + 9 (после ратификации PD-112) и подтверждена
|
||
скриптом по таблице; заявление «каждый из 22 пинов проверен посадкой» снято как непроверяемое
|
||
(PD-142); строка PD-105 приведена к коду — карантинится ПОПЫТКА (её проекция), жизненный цикл
|
||
прогона продолжается; PD-122 дополнен: ревизия не двигается и на ДОБАВЛЕНИИ книги (`AddBook` пишет
|
||
`revision = 0`), класс тот же.
|
||
|
||
### Новые строки регистра (N1…N4) и ратификации
|
||
|
||
Заведены четыре по фикс-листу плюс две собственные (PD-156 — ниже, PD-151 — числа отчёта):
|
||
**PD-152** (стоп → `failed`: реальный `tmctl` ловит SIGTERM и выходит кодом 1, так что
|
||
ветка `stopped` достижима только для смерти ОТ сигнала — проба пака показала `stopped` на фейке,
|
||
который именно так и умирал) · **PD-153** (грация спавна от `started_at`, а не от времени заявки) ·
|
||
**PD-154** (`settled_at` может остаться NULL между `Settle` и `MarkSettled`; потребителей нет,
|
||
деньги целы) · **PD-155** (смена `StateDir` осиротляет маркеры — строка внесена в `deploy/README.md`).
|
||
|
||
Ратификации оркестратора внесены в регистр: **PD-112** закрыт ратификацией (`503` вносится в спеку
|
||
правкой владельца контракта) · **PD-114** переформулирован в задачу «печать эффективной конфигурации
|
||
на старте с редакцией секретов», env-only остаётся · **PD-115** — направление «по внешнему эталону на
|
||
ось» ратифицировано, носитель работы — строка · **PD-129** пере-диспозиция: строка была закрыта
|
||
ЛОЖНО, действительно закрыта дофиксом (PD-145) · `default_chapters` = верх шкалы остаётся, кода не
|
||
касается · PD-113 остаётся открытым до эмиттера.
|
||
|
||
### Батарея и посадки дофикса
|
||
|
||
- `make check` с живым PostgreSQL 18.4 под `-race`: **13 пакетов, 0 FAIL**, линтер **0 issues**.
|
||
- `go test ./... -count=1 -v | grep -c -- '--- SKIP'` = **0**.
|
||
- `make vuln`: **No vulnerabilities found**.
|
||
- Тесты: **105 → 261**, удалённых **0** (команда — раздел «Батарея и самопроверка» ниже).
|
||
- **Посадки: 24 из 24 пойманы** (скрипт восстанавливает файл после каждой):
|
||
`bookCap` возвращает прирост · записывается прирост вместо аргумента · отсутствующий
|
||
`reserved_usd` читается нулём · материализатор берёт попытку первой · рестарт берёт попытку первой ·
|
||
транзиентный сбой карантинится · нечитаемый отсчёт читается нулём · очистка маркера удалена ·
|
||
расчёт доверяет снапшоту (`Release` вместо `ReleaseUnspawned`) · относительный `StateDir`
|
||
принимается · метка ре-синка не пишется · метка пишется только однажды · ре-синк снова кладёт один
|
||
агрегат · шаблон потолка теряет дефолт · отказ деплоя проверяется ПОСЛЕ вызова движка ·
|
||
остаточная резервация раздувает потолок · `DeleteBook` берёт резервации раньше книги · отложенный
|
||
расчёт не ограничен верхней границей · транзиентный сбой карантинится (через РЕАЛЬНЫЙ путь) ·
|
||
повторная заявка перезаписывает базовую линию · рестарт Postgres карантинит проекцию · сброшенное
|
||
соединение карантинит проекцию · ретрай пересчитывает переданный потолок · счётчик назад списывает
|
||
молча.
|
||
⚠ Две посадки в первой редакции **ПЕРЕЖИЛИ** и это записано, а не подчищено: «рестарт берёт попытку
|
||
первой» (конкурентный пин её не ловил — написаны прямые пины порядка) и «метка пишется только
|
||
однажды» (тест ставил метку один раз — добавлена вторая проверка).
|
||
- Контрактная проба приёмки (47 wire-проверок, `-overlay` поверх пакета `httpapi`) пере-прогнана на
|
||
этом дереве целиком: **PASS**, формы не сломаны.
|
||
- Гоночная проба приёмки `TestAcceptConcurrentStartRunTakesExactlyOneHold`: **PASS** (8 конкурентных
|
||
`StartRun` → 1 прогон, 1 холд) — она проверяет то, что `lockBook` в `StartRun` мог бы изменить.
|
||
|
||
### Что нашла собственная сверка диффа (после фиксов, до сдачи)
|
||
|
||
Две правки, обе внесены и обе — про то, что дофикс мог испортить рядом:
|
||
|
||
1. **`DeleteBook` брал резервации раньше книги** — единственный оставшийся нарушитель порядка,
|
||
который я записал как глобальный. Цикла для него сегодня нет (проверено перебором пар: удаление
|
||
трогает только ЗАКРЫТЫЕ резервации, а `StartRun` вставляет новую), но инвариант с необъявленным
|
||
исключением перестаёт быть инвариантом — книга взята первой и там, и это запинено
|
||
(`TestDeletingABookTakesItBeforeItsReservations`; вспомогательный `assertBookFirst` обобщён на
|
||
любую вторую строку — попытку или резервацию).
|
||
2. **Отказ ДЕПЛОЯ проверялся после вызова движка.** Перенеся чтение отсчёта в начало `spawnAttempt`,
|
||
я поставил его ПЕРЕД дешёвыми отказами («нечем записать конец юнита», «нечем передать потолок»),
|
||
и инстанс с неполной конфигурацией платил бы секундами CPU движка за каждый прогон на каждом
|
||
свипе, чтобы прийти к ответу, зависящему только от конфигурации. Вынесено в `runs.runnable()`,
|
||
вызывается первым; пин `TestAMisconfiguredDeploymentIsRefusedWithoutAskingTheEngine` (посадка
|
||
«убрать ранний отказ» падает — 15-я в списке выше).
|
||
|
||
### Формула потолка: почему `reserved` в неё не входит (PD-158, ратифицировано 09.08)
|
||
|
||
Сдавалось как ВОПРОС с консервативным кодом; ре-чек V2 ратифицировал формулу зоны, пере-мерив обе
|
||
против гейта движка — та, что с `reserved`, переплачивала запасом ровно на leftover-reserved.
|
||
|
||
Первая формула — `committed + reserved + прирост` — читает `reserved_usd` из `status --json`. Но
|
||
`store.Open`, путь ЗАПИСИ, которым идёт каждый `translate`, выполняет `recoverReservations` и
|
||
обнуляет `reserved_usd` книги ДО того, как будет судима первая резервация
|
||
(`backend/internal/store/store.go:88`, тело — `:214`); `tmctl status` идёт read-only и этот проход
|
||
намеренно не делает — движок пишет об этом сам (`store.go:108`: «after a run crashes, reserved_usd
|
||
stays non-zero until the next WRITE command … status will show this leftover as reserved»).
|
||
|
||
Значит цифра, которую платформа кладёт в потолок, к моменту сравнения уже стёрта, и прогон получает
|
||
на её величину БОЛЬШЕ, чем его холд: движок останавливается позже, расчёт упирается в потолок холда,
|
||
леджер пишет «capped at the hold» — и это читается как перерасход движка, хотя это наша арифметика.
|
||
Путь достижим на каждом резюме после падения, потому что падение и оставляет резервацию.
|
||
|
||
**Что сделано:** `bookCap` считает `committed + прирост`. Отклонение в КОНСЕРВАТИВНУЮ сторону — более
|
||
узкий потолок может только остановить прогон раньше, перерасхода не даёт, — названо в коде и
|
||
запинено, и сдано ВОПРОСОМ, а не решением: отклонять ратифицированное молча не право зоны. Ответ
|
||
пришёл ре-чеком V2 — формула принята. Цифра `reserved`
|
||
по-прежнему читается и обязательна: она и есть доказательство, что отклонение безопасно — на спавне
|
||
другого писателя нет (эксклюзивный лок плюс один живой прогон на книгу), значит любой `reserved` по
|
||
построению остаток.
|
||
|
||
### Найдено при F1 и заведено, а не построено
|
||
|
||
**PD-157: дневной потолок книги `--ceiling-usd` не перекрывает.** Ратификация D39.122 говорит это
|
||
прямо, и движок требует хотя бы один из `book_usd`/`day_usd` (`backend/internal/config/book.go:250`),
|
||
а `book.yaml` пишет ОПЕРАТОР — платформа его не правит (D39.110 §2b) и в `book add` лишь проверяет
|
||
наличие. Значит книга с низким `day_usd` останавливает прогон на лимите, которого платформа не
|
||
выбирала: движок выходит кодом 1, прогон приезжает `failed`, деньги пользователя целы, причина ему
|
||
не видна. В `status --json` дневной фигуры нет вовсе, так что и диагностировать это платформа
|
||
сегодня не может. Не строил: фикс-лист этого не просил, а закрывается оно либо проверкой при
|
||
заведении книги, либо словом контракта о том, кто владеет потолками `book.yaml` у книг под
|
||
платформой — второе не право зоны.
|
||
|
||
### Селф-ревью дофикса: три верификатора, что нашли
|
||
|
||
Прогнаны три независимых ревьюера по разным линзам — один по фикс-листу и диффу, один НАМЕРЕННО без
|
||
отчёта («вне карты»), один по claim-fidelity доков против кода. Все трое работали read-only, мутации
|
||
гоняли в копиях дерева.
|
||
|
||
**Найдено и ПОЧИНЕНО в этом же дереве:**
|
||
1. **PD-159, деньги, major.** Отложенный расчёт прогона оплачивал работу СЛЕДУЮЩЕГО прогона той же
|
||
книги, а тот платил за неё ещё раз. Нашли ДВА верификатора независимо, каждый воспроизвёл
|
||
исполнением; я воспроизвёл третьим ($2.10 за прогон, стоивший $0.10) ПЕРЕД починкой. Причина —
|
||
расчёт читает пожизненный счётчик КНИГИ в момент повтора, а завершённый-но-нерассчитанный прогон
|
||
не мешает начать новый. Починено верхней границей `SpendBound` (наименьшая базовая линия среди
|
||
попыток книги, стартовавших позже — она снята после остановки этой попытки и до того, как та
|
||
что-либо добавила, то есть ТОЧНАЯ граница).
|
||
2. **PD-160, пин.** «Транзиентный сбой не карантинит» пинилось на чистой функции; удаление ветки,
|
||
которая её ВЫЗЫВАЕТ, переживало батарею. Написан пин, который гонит НАСТОЯЩИЙ дедлок через весь
|
||
путь.
|
||
3. **PD-161, деньги.** Повторная заявка права на спавн перезаписывала базовую линию — при
|
||
`systemd-run`, убитом ПОСЛЕ подачи запроса, попытка платила бы разницу от цифры, включающей её же
|
||
работу.
|
||
|
||
**Найдено, заведено и НЕ построено** (каждое — строкой с названным лечением): PD-152 расширен живым
|
||
замером — штатная перезагрузка закрывает все прогоны как `failed`, потому что `ExecStopPost`
|
||
отрабатывает и движок выходит кодом 1 (путь строки 138 покрывает только потерю питания) · PD-162
|
||
удалённый каталог книги заклинивает прогон навсегда с открытым холдом · PD-168 бюджет перезапуска
|
||
считается по текущей ставке (замерено: $5.50 вместо $2.50 при удвоении) · PD-163 ревизия области
|
||
читается вторым запросом · PD-164 неразбираемый маркер вечно валит реконсиляцию · PD-165
|
||
относительный `TM_PLATFORM_CTL_BIN` и `$` в `quoteArgv` · PD-166 `chunker_version` теряется ·
|
||
PD-167 расхождение док↔код в комментарии миграции · PD-169 бюджет свипа один на все прогоны ·
|
||
PD-170 асимметрия `Settle` с PD-97.
|
||
|
||
**Найдено в ДОКАХ и исправлено:** тело F1 противоречило собственному разделу PD-158 · описание фейка
|
||
`ceilingJudge` отстало от кода · §21 `STACK_DECISIONS` не догнал PD-158 · старый раздел «Сессия P4»
|
||
утверждал, что флага потолка в HEAD нет · числа тестов и посадок разъезжались между журналом и
|
||
регистром · PD-158 приписывал D39.122 буквальную формулу, которой в решении нет (она из пинга) ·
|
||
PD-122 требовал «свою миграцию», хотя колонка `users.library_revision` существует с 00001 и не
|
||
используется ни одним путём · «`ErrCeilingNotWired` недостижим через конфигурацию» — переменная из
|
||
пробелов до него доходит.
|
||
|
||
### Ре-чек V2 (оркестратор №15, 09.08): четыре хвоста, три — в новом коде дофикса
|
||
|
||
**V2-1 [MAJOR] — моя же классификация была неполна.** `IsTransient` знала только про дедлок и
|
||
сериализацию, поэтому ШТАТНЫЙ рестарт Postgres (57P01/57P02/57P03, класс 08, сетевой сброс) всё ещё
|
||
карантинил проекцию живого платного прогона НАВСЕГДА — пути снятия карантина в дереве нет. Клейм
|
||
PD-145 «карантин остаётся только логическим ошибкам» был в этой части неверен и пере-сформулирован.
|
||
Теперь покрыты класс 08, 57P0x, `pgconn.SafeToRetry` и любой `net.Error`; ошибки чтения ФАЙЛА при
|
||
этом остаются логическими — `*fs.PathError` не удовлетворяет `net.Error`, поэтому битый журнал
|
||
по-прежнему останавливает проекцию, как и должен. Шесть новых кейсов в таблице пина, две посадки.
|
||
|
||
**V2-2 [MINOR, деньги] — PD-161 был закрыт наполовину.** В БД значения сохранялись, а движку на
|
||
ретрае уходил потолок, пересчитанный по СВЕЖЕМУ счётчику, то есть включающий трату собственного
|
||
«призрака»; форензик-колонка 00011 на этом пути лгала (замер приёмки: handed 3.400000 против stored
|
||
3.000000 — воспроизведён посадкой на моём дереве до буквы). Теперь ретрай ПЕРЕДАЁТ сохранённый
|
||
аргумент. Пин расширен до `handed == stored`, плюс отдельный пин на гард в БД
|
||
(`TestASecondClaimOnOneAttemptKeepsWhatTheFirstRecorded`) — после правки в Go посадка на `coalesce`
|
||
переставала ловиться, и свойство без собственного пина не считается закрытым.
|
||
|
||
**V2-3 [MINOR, доки у денег]** — три текста утверждали отменённую формулу (`reconcile.go` про
|
||
рестарт, комментарий поля `Reserved`, строка PD-144) и два — несуществующий отказ загрузчика на
|
||
пустое переопределение. Всё приведено к факту. Туда же: **комментарий миграции 00011 нёс отменённую
|
||
формулу, а 00009 обещал перечитывание файла, которого тейлер не делает.** Обе исправлены, отпечатки в
|
||
`migrations.sha256` обновлены — с явной причиной в шапке файла: обе миграции написаны этим паком,
|
||
нигде не применялись, а миграция с лгущим комментарием хуже сдвинутого отпечатка до релиза.
|
||
⚠ Ре-чек назвал застывшим комментарий 00011 под номером PD-167, тогда как предмет PD-167 — 00009;
|
||
исправлены обе, расхождение в нумерации названо в строке.
|
||
|
||
**V2-4 [NOTE]** — счётчик книги ниже собственной базовой линии попытки списывал $0 молча. Клампить в
|
||
ноль правильно (платить за подмену БД проекта код решать не вправе), молчать — нет: добавлен WARN, не
|
||
INFO, с фактом и без цифр.
|
||
|
||
### Что дофикс НЕ трогал (осознанно)
|
||
|
||
Ручки стопа/резюма (PD-140) · эскроу и `uncertain` (строка 136) · `POST /books` (П-9) · собственный
|
||
счётчик ревизии области (PD-122) · грация от времени заявки (PD-153) — заведены строками, не
|
||
построены: фикс-лист их не просил, а расширять пак на приёмке — это то, за что заведён PD-83.
|
||
|
||
---
|
||
|
||
## Сессия P4 (08–09.08): раннер — юнит-на-прогон, очередь, реконсилятор, тейлер, первые ручки /v0
|
||
|
||
**Каждое число ниже — с командой, которой получено.** Дерево не коммичено; в индексе ничего не держу.
|
||
|
||
### Работа 1 — транзиентный systemd-юнит на прогон
|
||
|
||
`internal/runner/runner.go` (юнит), `marker.go` (маркер выхода), `engine.go` (инвокация движка).
|
||
|
||
**Привилегия-модель решена ДО постройки, и второй вариант отвергнут по СВОЙСТВУ, а не по вкусу.**
|
||
Выбран linger-пользователь: юниты создаются в СОБСТВЕННОМ менеджере платформы, новых привилегий в
|
||
рантайме ноль. Polkit-вариант research/25 непригоден: **polkit авторизует ГЛАГОЛ, а не свойства** —
|
||
`StartTransientUnit` отдаёт выбор `ExecStart=`/`User=` вызывающему, а транзиентный СИСТЕМНЫЙ юнит без
|
||
`User=` идёт от root, то есть правило выдаёт сервису root и сузить это нечем. Плюс до systemd v257 в
|
||
запрос авторизации не попадает даже ИМЯ юнита (systemd issue #17224 → PR #34651, влит 09.10.2024),
|
||
так что на стенде (systemd 255, `systemctl --version`) «узкое правило» не узкое вовсе. Разбор —
|
||
`STACK_DECISIONS` §15.
|
||
|
||
**Замерено на стенде, не выведено из доки** (все команды — `systemd-run --user`, стенд без root):
|
||
|
||
| Свойство | Как замерено | Результат |
|
||
|---|---|---|
|
||
| Юнит переживает того, кто его создал | спавнер-скрипт стартует юнит и выходит | `ActiveState=active`, маркер `success` |
|
||
| `--collect` ⇒ состояние systemd не истина | `systemctl --user show` после выхода | `LoadState=not-found`, `ExecMainStatus=0` для прогона, вышедшего с кодом **3** |
|
||
| Маркер несёт исход | `ExecStopPost` на трёх исходах | `exit-code/exited/3` · `success/killed/TERM` · `oom-kill/killed/TERM` |
|
||
| **Лимиты в `app.slice` НЕ применяются** | `cat app.slice/cgroup.subtree_control` | **пусто**; лист без `memory.max`; процесс, потрогавший 400 МиБ, пережил `MemoryMax=64M` |
|
||
| Лимиты в своём срезе применяются | то же в `tm-runs.slice` | `memory.max=67108864`, `pids.max=32`, тот же процесс — `oom-kill`, `Memory peak: 64.0M` |
|
||
| `%`-спецификаторы в `--property=` не раскрываются | `ExecStopPost` с `100%_done`/`%n` | доехали буквально ⇒ экранировать `%` не нужно и было бы неверно |
|
||
| `ProtectHome=yes` прячет `/run/user/<uid>` | проба под юнитом | `Permission denied` ⇒ своя же шина недостижима; `ProtectHome=tmpfs`+`BindPaths` ⇒ сокет виден |
|
||
| Шине хватает `DBUS_SESSION_BUS_ADDRESS` | `env -u XDG_RUNTIME_DIR` | работает; без обоих — `Failed to connect to bus` |
|
||
|
||
**Ответ PD-13 живёт в cgroup ПРОГОНА и измерен.** ⚠ И там же найдено собственным флейком: `MemoryMax`
|
||
БЕЗ `MemorySwapMax=0` не ограничивает прогон — ядро выдавливает страницы в своп (у среза
|
||
`swap peak: 692.5M`), процесс выживает и тормозит до скорости диска. Для перевода это хуже отказа:
|
||
тормозящий часами прогон продолжает платить за каждый прошедший вызов. Снято: `MemorySwapMax=0` идёт
|
||
вместе с `MemoryMax` (`runner.go`), три прогона пина подряд зелёные, три посадки «убрать своп-лимит»
|
||
подряд красные.
|
||
|
||
Пиннинг версии (строка 139): `run_attempts.engine_binary` пишется ДО создания юнита, новая попытка
|
||
его НАСЛЕДУЕТ, резюм исполняет именно его, а переход на другую сборку требует явного
|
||
`TM_PLATFORM_RESUME_MAY_CHANGE_ENGINE`. ⚠ Первая редакция закрывала эту строку НАПОЛОВИНУ — путь
|
||
читался каналом ремонта и не исполнялся резюмом; поймано моей же пост-сверкой диффа с промтом, не
|
||
батареей (обе половины компилировались и всё было зелёным). PD-143.
|
||
|
||
### Работа 2 — очередь River + воркер
|
||
|
||
`go.mod`: `github.com/riverqueue/river v0.42.0` + `riverdriver/riverpgxv5` (пин из `STACK_DECISIONS`
|
||
подтверждён). `internal/runs/queue.go`.
|
||
|
||
**Холд берётся ДО спавна и в ОДНОЙ транзакции с созданием прогона, попытки и записи очереди**
|
||
(`pgstore.StartRun`, `runs.go:66`): холд без прогона — деньги, зарезервированные ни за чем; прогон без
|
||
холда — трата незарезервированного; запись очереди без обоих — воркер, спавнящий движок против книги,
|
||
у которой нет бухгалтерии. Очередь вставляет своё задание через колбэк с той же `pgx.Tx`.
|
||
|
||
**Гард «один живой прогон на книгу» — исполнением:** частичный уникальный индекс
|
||
`runs_one_live_per_book` мапится в доменную `ErrRunInFlight` (не сырой SQLSTATE), ручка отдаёт 409.
|
||
Живая проба: второй `POST .../runs` на ту же книгу → **409**, `Reserved` не сдвинулся.
|
||
|
||
`MaxAttempts: 1` у задания — намеренно против рефлекса очередей: повтор здесь не доделывает работу, а
|
||
спавнит ВТОРОЙ движок; восстановление — работа реконсилятора. Живая проба: повторная доставка задания
|
||
на живой прогон не создала второго юнита (`len(started)` = 1).
|
||
|
||
### Работа 3 — реконсилятор
|
||
|
||
`internal/runs/reconcile.go`. На КАЖДЫЙ выход юнита — через **ExecStopPost-маркер**, не D-Bus: сигнал,
|
||
посланный в момент, когда платформа лежит (а она лежит несколько раз в неделю по замыслу), теряется;
|
||
файл — нет. На БУТЕ тот же свип перезапускает прерванные прогоны (строка 138): истина — каталоги книг
|
||
и Postgres, systemd спрашивается только «жив ли юнит», и это улика, не вердикт.
|
||
|
||
Разграничитель «не стартовал ещё» / «умер без слова» — время (`spawnGrace` 60 с). Перезапуск даёт
|
||
новой попытке ОСТАТОК бюджета (`budget − RunSpent`), иначе один прогон потратил бы потолок дважды;
|
||
если остатка нет — `paused`/`credit_exhausted`, не `failed`.
|
||
|
||
**Расчёт — из фигуры движка, прочитанной ПОСЛЕ выхода процесса** (`status --json`, ратифицированный
|
||
канал ремонта; лока нет, гадать не о чем). Не прочиталась — резервация остаётся ОТКРЫТОЙ и попадает в
|
||
`UnsettledRuns` для следующего свипа. ⚠ Эскроу-половина research/25 (write-ahead intent, `uncertain`,
|
||
`closing`) НЕ строилась: это строка 136 и её собственный промт.
|
||
|
||
**Резюнк — честно редкий и честно грубый:** по умолчанию 5 минут (`TM_PLATFORM_RESYNC_EVERY`), и
|
||
только там, где чинить нечего — курсор не двигался ЛИБО материализация в карантине. Причина в цене:
|
||
каждый вызов заново ингестит и режет исходник (1.4–1.5 с CPU на книге 23 МБ, строка 100).
|
||
|
||
### Работа 4 — тейлер `events.jsonl`
|
||
|
||
`internal/ingest/tail.go`. Курсор `(engine_run_id, seq)` коммитится в ОДНОЙ транзакции с эффектом
|
||
(`pgstore.RunSink.Apply`), байтовое смещение — хинт. Файла ещё нет (эмиттер = строка 103) — тейлер
|
||
спокойно ждёт (`ErrNoJournal` не ошибка свипа).
|
||
|
||
**PD-105 ратифицирован этим паком и реализован:** дубль `seq` — НОРМА, пропускается идемпотентно и
|
||
без фатала; тот же `seq` с ДРУГИМ payload — `ErrPayloadConflict` → карантин ПРОЕКЦИИ (не прогона:
|
||
движок тратит уже зарезервированные деньги, и наша неспособность читать его журнал — не повод это
|
||
выбросить). Пропасть остаётся ошибкой. Строка PD-105 реестра обновлена.
|
||
|
||
Частичная последняя строка не читается как событие (писатель ещё пишет). Журнал пер-книжный и
|
||
append-only, поэтому резюм дописывает ВТОРОЙ `hello`: читатель ведёт область и не судит чужие строки.
|
||
|
||
### Работа 5 — ручки `/v0` по контракту 0.2.0
|
||
|
||
`internal/httpapi/v0.go`: `GET /books` · `GET /books/{bookId}` · `GET /books/{bookId}/run-options` ·
|
||
`POST /books/{bookId}/runs` · `GET /usage`. Прогресс/статус прогона отдаётся внутри карточки книги —
|
||
отдельной ручки прогона в спеке НЕТ, и выдумывать её я не стал.
|
||
|
||
`CeilingBounds`: максимум = `Balance` КАК ЕСТЬ (холд — дебет в момент взятия), подрезан И остатком
|
||
книги; `max_chapters: 0` легален. `default_chapters` = верх шкалы — продуктовая политика платформы
|
||
(D39.115 §4), компромисс назван вслух в коде и вынесен вопросом ниже.
|
||
|
||
**Интейк:** дев-инструмент `tmplatformctl book add` (Go-подкоманда, а не шелл-скрипт: тестируется).
|
||
`POST /books` (multipart) НЕ взят — обоснование строкой **П-9** зонного бэклога.
|
||
|
||
**Пересчёт «главы→доллары»** — константа платформы `$0.03`, провенанс: exp08 v2 ($10.94 на 500 глав =
|
||
$0.0219) через ревизию D30.4 (+15–25% → $0.0252–0.0274), округление ВВЕРХ. Движковой поверхности
|
||
оценки не выдумывал; потребность — строка **П-10**.
|
||
|
||
### Работа 6 — регистр
|
||
|
||
Закрыты: **PD-43** (денежный контур получил вызывающих) · **PD-81** · **PD-82** · **PD-97** ·
|
||
**PD-99** · **PD-105**. Заведены **PD-108…PD-116** (из них PD-108/110/111/116 — дефекты, найденные и
|
||
закрытые внутри этого же пака; PD-112…115 открыты вопросами). Строки эмиттер-стороны (PD-60/61) не тронуты.
|
||
|
||
### Сквозная проба на боевом бинаре (не тестами)
|
||
|
||
Поднят `tmplatformd` против живого PG, с фейковым `tmctl` ($0, говорит ровно две поверхности:
|
||
инвокацию с потолком и `status --json`).
|
||
|
||
| Шаг | Наблюдение |
|
||
|---|---|
|
||
| `book add` + `GET /v0/books` | книга в библиотеке, `status: not_started` |
|
||
| `GET run-options` | `min 1 / max 333 / default 333` — $10 / $0.03 = 333, подрезано 500 главами книги |
|
||
| `POST runs` (100 глав) | `202`, форма `Run` по спеке; юнит `tm-run-<id>-1.service` **active running** |
|
||
| потолок, дошедший до движка | `--ceiling-usd 3.000000` = 100 × $0.03 |
|
||
| второй `POST` на ту же книгу | **409** |
|
||
| `/v0/usage` при открытом холде | `remaining_percent: 70` (холд — дебет) |
|
||
| прогресс из журнала | `draft 4/10, edit 0/10` — пофазно, без затирания резюнком |
|
||
| выход юнита | книга и прогон → `ready`, `finished_at` проставлен |
|
||
| расчёт | грант $10 → движок отчитался $0.42 → баланс **9.580000** (не потолок $3) |
|
||
| гигиена логов | `grep -c 'ceiling-usd\|3.000000\|bk_' daemon.log` = **0** |
|
||
| systemd после `--collect` | юнитов **0** |
|
||
|
||
**Ратифицированное свойство D39.106 — прогон переживает деплой — проверено убийством платформы:**
|
||
`pkill -9` по имени процесса → API отвечает `000`, юнит `active running`, журнал за 6 с вырос
|
||
`10 → 13` строк. Платформа поднята заново → карточка показала `draft 21/300` (догнала всё, что было
|
||
записано, пока её не было), через 6 с — `24/300`, юнит ОДИН, в логе новой платформы
|
||
`grep -c 'run unit started'` = **0** (второго движка не создано). Остановка через systemd → маркер
|
||
`success/killed/TERM` → статус **`stopped`** (не `failed`).
|
||
|
||
### Батарея и самопроверка
|
||
|
||
- `make check` с `TM_PLATFORM_TEST_DSN` (живой PostgreSQL 18.4, `-race`): все 13 пакетов зелёные,
|
||
линтер **0 issues**. Прогнана дважды подряд.
|
||
- Скипы: `go test ./... -count=1 -v | grep -c -- '--- SKIP'` = **0**.
|
||
- `make vuln`: **No vulnerabilities found**.
|
||
- Дифф `^func Test` ИСПОЛНЕНИЕМ: **105 → 261**, удалённых **0**, добавленных **156** (число после
|
||
дофикса 09.08). Команда, которой считано:
|
||
`H=$(git grep -h '^func Test' HEAD -- 'platform/**/*.go' | sed 's/(.*//' | sort -u)` ·
|
||
`T=$(grep -rh '^func Test' platform --include=*_test.go | sed 's/(.*//' | sort -u)` ·
|
||
`comm -23 <(echo "$H") <(echo "$T")` — пусто, то есть удалённых ноль.
|
||
⚠ **Исправлено приёмкой (PD-151):** здесь стояло «105 → 203, +98», и это было неверно на момент
|
||
написания — приёмка пересчитала 242/+137/−0, дофикс с ре-чеком довели до 261/+156/−0. Шапка журнала при этом
|
||
была права.
|
||
⚠ Две мои промежуточные редакции этого счёта были неверны, и это записано, а не подчищено:
|
||
сначала pathspec не тот, потом грep без `*.go` — второй ловил КОПИЮ теста, вставленную в этот же
|
||
журнал приёмкой P1 (`platform-PROGRESS.md:683`), и рисовал ложное «удалён
|
||
`TestHoldAndSettleOnTheSameAttemptDoNotDeadlock`». Тест на месте: `credits_test.go:308`.
|
||
- **Посадки (мутации): 12 из 12** в самопроверке; **139 в аудите ревью** — 107 поймано, 32 пережили
|
||
(8 не ослабления), по ним написано **22 новых пина**. ⚠ **Заявление «каждый проверен своей
|
||
посадкой» СНЯТО приёмкой (PD-142/PD-151):** прогонов посадок было девять (9/10, затем 9/9), и
|
||
поимённого соответствия пин↔посадка сессия не вела — 22 ими не покрываются. Итого посадок,
|
||
проверенных поимённо: **33 моих в паке** (ниже) и **24 в дофиксе** (раздел «Дофикс P4»), все
|
||
пойманы. Каждая восстанавливалась после прогона:
|
||
холд вне транзакции допуска · мапинг `runs_one_live_per_book` · high-water mark в синке ·
|
||
`greatest` у spend · payload-конфликт · детект пропасти · частичная строка как событие ·
|
||
область чужого потока (обе стороны: `mine := true` и `mine := false`) · срез прогона ·
|
||
половина шкалы (та самая ошибка «минус Reserved») · грация спавна · потолок как `failed` ·
|
||
argv в INFO-логе · `MemorySwapMax` · compare-and-set права на спавн.
|
||
⚠ **Одна посадка ПЕРЕЖИЛА первую редакцию пина** и это записано, а не подчищено: high-water mark
|
||
проверялся событием `progress`, а `progress` — присваивание, то есть идемпотентен сам по себе.
|
||
Настоящий пин — считающий эффект (`unit_done`): `TestARedeliveredCountingEventDoesNotCountTwice`.
|
||
|
||
### Девиации и то, чего не сделал
|
||
|
||
1. **`503` на старте прогона не входит в перечисленные спекой статусы операции** — PD-112, вопрос
|
||
владельцу контракта. Каждый разрешённый код в этой ситуации соврал бы.
|
||
2. **Стоп по потолку сегодня отдаётся как `failed`** — PD-113, контрактно видимо. Движок возвращает
|
||
потолок ошибкой (`errReserveCeiling`, сверено в HEAD), события потолка нет (строка 103). Ветка под
|
||
событие построена и запинена; выдумывать порог «сколько процентов потолка = стоп» я не стал.
|
||
3. **`POST /books` не взят** — П-9, с обоснованием. PD-72 закрывается вместе с ним.
|
||
4. **Эскроу/`uncertain`/`closing` не строились** — строка 136, следующий денежный промт.
|
||
5. **Стоп/резюм прогона как ручки контракта** не в списке работ промта — не строились; банк-стоп
|
||
заканчивает попытку и прогон, резюм = новый прогон.
|
||
6. **`Supervisor.Status` дев-пути починен** (PD-108) — он в моей зоне и это мой канал ремонта.
|
||
|
||
### Адверсариальное ревью (author≠reviewer), и почему это главный результат пака
|
||
|
||
Четыре независимых верификатора, ни один не читал отчёт (его на тот момент не существовало):
|
||
(1) промт+дифф вслепую · (2) охота за дефектами ВНЕ карты · (3) сверка провода с openapi 0.2.0 ·
|
||
(4) сила пинов посадками. Каждому запрещено писать в репозиторий; эксперименты — в копиях.
|
||
|
||
**Нашли три ДЕНЕЖНЫЕ ошибки и три запирающие прогон. Все закрыты в этом дереве, каждая с пином.**
|
||
|
||
| Находка | Чем это было | Как закрыто |
|
||
|---|---|---|
|
||
| **PD-124** (major) | Расчёт брал `committed_usd` — ПОЖИЗНЕННУЮ сумму КНИГИ (`SUM(committed_usd) … WHERE book_id`) — как трату прогона. Второй прогон книги оплачивал первый заново; после того как сумма книги перерастает потолок, каждый прогон стоит ровно свой потолок. **Воспроизвели ДВА верификатора независимо** | Попытка пишет базовую линию книги ДО старта (миграция 00010) и платит разницу; линию не прочитать = не стартуем |
|
||
| **PD-125** (major) | Перезапуск при отложенном расчёте брал ВТОРОЙ холд и терял первый навсегда: списки его не находили | Перезапуск спрашивает `AttemptReservationOpen`; список несведённых ключуется на завершённости ПОПЫТКИ |
|
||
| **PD-126** (major) | Юнит, который не удалось создать, оставлял «право на спавн» — и каждый свип перезапускал прогон, съедая потолок по свипу за раз | Неудавшийся `Start` снимает право; повторяется та же попытка |
|
||
| **PD-127** (major) | Одна нечитаемая строка журнала возвращала ошибку ДО чтения маркера: прогон навсегда `translating` с висящим холдом | Любая ошибка журнала = карантин ПРОЕКЦИИ, жизненный цикл продолжается |
|
||
| **PD-128** | Грация спавна мерилась от старта ПРОГОНА ⇒ у перезапущенной попытки её не было | Мерится от старта попытки |
|
||
| **PD-129** | Инверсия порядка блокировок `books`↔`runs` между материализатором и финишером ⇒ взаимоблокировки | Книга блокируется первой везде |
|
||
| **PD-130/131** | Любая ошибка чтения журнала выглядела как «догнали»; хендшейк не обязан был нести `seq 1` | Только настоящий EOF; `seq != 1` = плохой хендшейк |
|
||
| **PD-132** | Холд прогона, который так и не стартовал, не возвращался | Нет линии и нет юнита ⇒ холд возвращается целиком |
|
||
| **PD-133/134/135** | Инстанс без движка не отдавал даже библиотеку; пиннинг версии писался и не читался; прерванный прогон вставал `paused` без причины | Чтения монтируются отдельно; `EngineBinary` читается и используется; `PauseRun` вместо `FinishRun` |
|
||
| **PD-117…121** | Потолок ниже минимума схемы отвечал 409 вместо 400 · `Usage.paused_reason` недостижим через путь реконсилятора · карточка несла ревизию ПРОГОНА (отстающую) · чужой курсор пагинации принимался · «exhausted» при остатке, на который прогон стартует | Все пять закрыты, каждая с пином |
|
||
| **PD-136/138** | Деплой-юнит нёс обе диспозиции сразу; `go.mod` не тидинут | Переписано; `go mod tidy` |
|
||
| **PD-142** | **Аудит силы пинов: 139 посадок, 107 поймано, 32 пережили** (8 из них — не ослабления: эквивалентный код или страховка DDL). Пережившие — не дефекты кода, а отсутствующие пины, часть на свойствах, объявленных закрытыми | 22 новых пина написаны; ⚠ «каждый проверен своей посадкой» СНЯТО приёмкой (PD-142/PD-151) — прогонов посадок было девять. Самые весомые: блокировка строки попытки под КОНКУРЕНЦИЕЙ (восемь горутин на одно событие — последовательная доставка посадку не ловила) и **CSRF на контрактной поверхности** (снятие переживало всё, а это старт платного прогона с амбиентной кукой) |
|
||
|
||
**Оставлено открытым и названо:** PD-122 (`Library.revision` не монотонна при удалении книги — сегодня
|
||
недостижимо, ручки удаления нет; правильное решение — свой счётчик области, это своя миграция и
|
||
несколько путей записи, поэтому НЕ сделано, а объявлено гейтом) · PD-137 (упорядочение на
|
||
пользовательский менеджер — UID site-specific, инструкция в шапке юнита) · PD-139 (пути книг в
|
||
ERROR-логах — диспозицию надо принять осознанно) · PD-140 (`Service.Stop` не подключён: ручек
|
||
стопа/резюма в скоупе промта не было) · PD-141 · PD-112/113/123.
|
||
|
||
**Что ревью подтвердило, а не опровергло:** привилегия-модель (с одним уточнением, принятым:
|
||
«никакое правило не сузит» верно для ТРАНЗИЕНТНЫХ юнитов; шаблонный системный юнит с фиксированным
|
||
`User=`, запускаемый `StartUnit`, — вариант, которого мой разбор не касался) · арифметика
|
||
`CeilingBounds` (баланс КАК ЕСТЬ, без второго вычитания холдов — проверено на живом PG пятью
|
||
раскладами) · отсутствие кросс-аккаунтных чтений и стартов · инвариант `balance == SUM(ledger)`
|
||
(сломать не удалось) · правила тейлера по областям и курсору.
|
||
|
||
**Возражение ревью, которое я принял только частично.** Верификатор предложил различать стоп по
|
||
потолку через `ceiling_pct` из `status --json`. Не взял: `ceiling_pct` считается против
|
||
`book_ceiling_usd`, то есть против потолка ИЗ `book.yaml`, а наш потолок приходит аргументом прогона
|
||
(строка 145), которого в HEAD НА ТОТ МОМЕНТ не было — значит смысл поля зависел от ещё не залендённой
|
||
формы (флаг заленден 09.08, `0e69bc1`; альтернатива от этого не становится верной — `ceiling_pct`
|
||
по-прежнему считается против потолка из `book.yaml`, а не против переданного аргумента). Взять его
|
||
сейчас значило бы построить дискриминатор на поведении, которого я не могу проверить. Записано в
|
||
PD-113 как названная и оценённая альтернатива.
|
||
|
||
**Повторная сквозная проба на боевом бинаре после починок** (чистая база, фейковый движок с
|
||
НАКОПИТЕЛЬНЫМ счётчиком книги, как у настоящего): грант $10.00 → прогон 1 (движок насчитал $0.40) →
|
||
баланс **9.600000** → прогон 2 на ТОЙ ЖЕ книге (счётчик книги $0.40 → $0.80) → баланс **9.200000**,
|
||
резервов ноль. До починки второй прогон списал бы $0.80.
|
||
|
||
### Вопросы (канал вопросов промта; НЕ тихая интерпретация)
|
||
|
||
1. **`503` вне перечня спеки** (PD-112) — вносить в спеку или назвать другой код?
|
||
2. **`default_chapters` = верх шкалы.** Когда книга дороже баланса, верх шкалы резервирует ВЕСЬ
|
||
баланс под одну книгу, и вторую начать нельзя (D39.110 §2в). Альтернатива — доля от максимума;
|
||
какая именно — вопрос продуктовый, не инженерный.
|
||
3. **Конфигурация (вопрос ВЛАДЕЛЬЦА 08.08, PD-114).** Ответ по существу: единого stdlib-пути в Go
|
||
нет; мейнстрим — либо 12-factor «только окружение» (наш выбор, записан в доккомменте `config.go`,
|
||
деплой уже использует systemd-нативные `EnvironmentFile=`+`LoadCredential=`), либо слоёная
|
||
`флаги > env > файл > дефолты` (koanf/viper). Ниже нормы у нас другое: **27 переменных**
|
||
(грепнуто), **ноль флагов** у демона, **нет печати эффективной конфигурации** при старте.
|
||
Вопрос владельца при этом нашёл реальный дефект в этом же паке: дефолт «$/глава» лежал в двух
|
||
местах — исправлено, резолвится только в `config`.
|
||
4. **Абстрактный вопрос владельца, и он подтверждается фактом (PD-115).** `ENGINEERING_STANDARDS` §2
|
||
называет внешнюю версионированную базовую линию ровно для ОДНОЙ оси — безопасности (ASVS 5.0 L2 +
|
||
API Top-10 2023). Всё остальное — собственная проза зоны. Разница видна эмпирически: PD-57/PD-58
|
||
нашлись именно сверкой с RFC 9700/9207 и NIST SP 800-63B. Там, где эталона нет, сверять не с чем —
|
||
грепнуто на 08.08: метрик и трейсинга **ноль**, процедуры бэкапа/восстановления в `deploy/` нет,
|
||
SLO не заданы. Предложение: §2 получает по эталону на ось; ратификация — оркестратора.
|
||
|
||
## Ратификация приёмкой P3 (оркестратор №15, 08.08)
|
||
|
||
**Вердикт: фикс-пак ПРИНЯТ и заленден.** Восемь пунктов фикс-листа отработаны, девять строк
|
||
регистра закрыты (PD-79 · PD-80 · PD-83 · PD-84 · PD-85 · PD-91 · PD-95 · PD-100 · PD-106).
|
||
Приёмка по весу пака — точечные фиксы, не дизайн, — поэтому инлайн и своим исполнением, без панели.
|
||
|
||
**Мои посадки — 8 из 8 поймано поимённо** (в копии зоны вне репозитория; посадки ставились В
|
||
СВОЙСТВО каждой закрытой строки, потому что предмет проверки здесь — именно карта пинов зоны):
|
||
возврат `null`-литерала под снятие кавычек → `money.TestSpendConvertsExactlyAndRoundsUp` · одно
|
||
ведро на две ручки → `login.TestLoginCompletesAndCreatesOurOwnSession` · очистка login-куки перед
|
||
лимитером → `TestARefusedCallbackKeepsTheLoginItRefused` · сырой путь в `Recover` →
|
||
`TestPanicBecomesAProblemAndNamesTheRoute` · снятие лимитера с колбэка →
|
||
`TestBothLegsOfSignInAreRateLimited` · снятие `EmailVerified` с ветки ВОЗВРАЩАЮЩЕГОСЯ входа →
|
||
`pgstore.TestUnverifiedAddressStaysOffTheAccount` · «после коммита можно отчитаться провалом» →
|
||
`TestACommittedWriteNeverReportsFailure` · снятие обоих логов PD-100 →
|
||
`TestInfrastructureFailuresInTheCallbackAreLogged`. ⚠ Первая редакция посадки PD-100 у меня была
|
||
нацелена МИМО (регексп снял соседний лог, а не фикс) и «выжила»; точная посадка ловится — записано,
|
||
потому что дисциплина «заявление=команда» действует и на приёмку.
|
||
|
||
**Пере-прогнано мной:** батарея офлайн и с живым PostgreSQL 18.4 под `-race` — все десять пакетов
|
||
зелёные, включая новый `cmd/tmplatformctl`; линтер 0 issues; `make vuln` чист.
|
||
|
||
**Пере-проверено исполнением, а не со слов:** **PD-80** на собранном бинаре — сорок запросов
|
||
выжигают ведро `/auth/login`, после чего честный колбэк с живым state получает **400, а не 429**,
|
||
то есть бюджет завершения входа флудом начала больше не тратится (до фикса тот же сценарий давал 429
|
||
со стёртой login-кукой). **PD-91** — замер зоны воспроизведён моими руками: `systemd-run --user
|
||
--wait -p ProtectSystem=strict -p ReadWritePaths=<несуществующий>` даёт `226/NAMESPACE`, тот же путь
|
||
созданным даёт `0/SUCCESS`. Способ проверки без sudo зона нашла сама, и это лучше, чем предполагала
|
||
строка PD-91: свойства песочницы теперь ИЗМЕРЕНЫ, а не вычитаны; что НЕ проверено (юнит целиком,
|
||
`MemoryMax`/`OOMPolicy`) названо на месте. **PD-95** — доккоммент `events.go` теперь несёт
|
||
ратифицированную форму D39.106 (`events.jsonl` каталога книги как outbox-проекция, тейл платформой).
|
||
|
||
**Ратифицировано отдельно: PD-104 оставлен ОТКРЫТЫМ правильно.** Зона починила половину док↔код
|
||
(ячейка PD-30) и не закрыла строку, потому что продуктовая половина — ноль на бете, агрегатный
|
||
потолок, счётчик аномалий — ждёт слова владельца. Это ровно то правило, за которое заведён PD-83
|
||
(«свойство без пина закрытым не считается»), применённое к себе.
|
||
|
||
**Оговорка зоны принята как честная:** адверсариального ревью вторым читателем у P3 не было (сессия
|
||
без права на субагентов). Второй читатель — эта приёмка; при следующем паке того же веса второй
|
||
рубеж снова мой.
|
||
|
||
## Сессия P3 (08.08): фикс-лист приёмки P2 отработан целиком
|
||
|
||
**Что сделано: все восемь пунктов фикс-листа, в порядке приёмки.** Каждый фикс сначала
|
||
воспроизведён посадкой на копии зоны ВНЕ репозитория, потом починен, потом запинен тестом, который
|
||
эту посадку ловит поимённо. **25 посадок — 25 поймано.** Батарея: линтер 0 issues, все 10 пакетов
|
||
зелёные офлайн и против живого PostgreSQL 18.4 (`-race`), `make vuln` чист.
|
||
|
||
**Второй проход по собственному диффу (запрос владельца) снял четыре вещи и добавил четыре
|
||
посадки.** (1) `money.UnmarshalJSON` снимал кавычки `strings.Trim` — самодельный декодер строки
|
||
JSON; заменён на `encoding/json`, и тогда добавленная мною ветка «пусто или `null`» оказалась
|
||
лишней: обе формы и так ловит единственная проверка синтаксиса. (2) `pgstore.ErrNoLoginState`
|
||
заведён мною алиасом на `login.ErrNoState` — два имени у одного значения против собственного
|
||
прецедента зоны (`auth.ErrNoSession` возвращается напрямую); алиас удалён. (3) Два почти одинаковых
|
||
теста лимитера слиты в табличный по двум ручкам, и вместе с ними ушёл бесхозный `New(...)` из
|
||
третьего. (4) `TestPanicBecomesAProblem` оказался строгим подмножеством нового пина — свёрнуты
|
||
в один с двумя случаями, включая «запрос, который не сматчил ни один паттерн».
|
||
|
||
**Самое существенное из второго прохода — не стиль, а флейк:** тесты лимитера строились на
|
||
`rate.NewLimiter(1, N)`, то есть на гонке с настенными часами — на медленной машине токен успевает
|
||
восстановиться между опустошением ведра и проверкой, и пин зеленеет по неверной причине. Переведены
|
||
на нулевую ставку: `rate.NewLimiter(0, N)` выдаёт свой burst и НЕ восстанавливается никогда
|
||
(проверено отдельным прогоном на x/time v0.15.0, включая «сутки спустя»). Заодно ассерты усилены:
|
||
«на 429 куку не трогают вовсе» вместо «не стирают», и уровень `ERROR` теперь часть сверяемой
|
||
подстроки — посадка «уронить строку до DEBUG» её переживала.
|
||
|
||
| Строка | Что сделано | Чем запинено |
|
||
|---|---|---|
|
||
| **PD-80** (major, vuln) | Два ведра вместо одного (`startLimit`/`finishLimit`) + проверка лимитера ПЕРЕД `ClearLogin` | `TestFloodingTheStartOfSignInDoesNotCloseTheEnd` · `TestARefusedCallbackKeepsTheLoginItRefused` |
|
||
| **PD-79** (деньги) | Литерал `null` судится ДО снятия кавычек; строка `"null"` — отказ | `money.TestUnmarshalTellsTheNullLiteralFromTheWordNull` · `ingest.TestSpendRefusesNonsense` |
|
||
| **PD-83 / PD-84 / PD-85** | Три пина на «закрыто, но не запинено» | `TestPanicBecomesAProblemAndNamesTheRoute` · `TestBothLegsOfSignInAreRateLimited` · третий шаг в `TestUnverifiedAddressStaysOffTheAccount` |
|
||
| **PD-100** | Сбой стора и сбой discovery — в ERROR; на проводе прежний отказ. `login.ErrNoState` заведён у владельца интерфейса | `TestInfrastructureFailuresInTheCallbackAreLogged` (3 случая, включая «обычное истечение НЕ авария») |
|
||
| **PD-106** | Восемь тестов админ-CLI; несущее правило пинится через `balanceReader` | `TestACommittedWriteNeverReportsFailure` и семь других |
|
||
| **PD-91** | Каталог создаётся явной командой, `ProtectHome` оставлен с названной ценой и выходом | живой прогон `systemd-run --user` (не дока) |
|
||
| **PD-95** | Доккоммент пакета `ingest` переписан под D39.106 §2; `Supervisor` помечен дев-режимом | — (док) |
|
||
| **PD-104**, только часть док↔код | Ячейка PD-30 исправлена: «с нулём» относилось только к НЕподтверждённой личности | — (док); строка ОСТАЁТСЯ открытой: продуктовая половина за владельцем |
|
||
|
||
**Что не трогали, как велено:** PD-92 · PD-93 · PD-96 · PD-99 · PD-102 · PD-105 · PD-107 и все
|
||
прочие открытые строки. Продуктовая часть PD-104 (ноль на бете, агрегатный потолок) ждёт владельца.
|
||
|
||
**Три вещи, которые приёмке стоит перепроверить в первую очередь.**
|
||
|
||
1. **Вторая копия снятого транспортного ответа в ЭТОМ журнале.** Фикс-лист сказал «секцию журнала
|
||
про stdout я уже пере-поставил — не трогай», и секция «Транспорт потока событий (вопрос зоны
|
||
№4)» действительно помечена SUPERSEDED. Но блок «⚠ ОТВЕЧЕНО приёмкой (PD-59): переезд канала
|
||
отклонён» в списке «Открытые вопросы после P1», п.4 — остался без пометки, а PD-59 сам снят
|
||
D39.106 п.3. Это ровно тот текст, который эмиттер-сессия прочтёт как задание, то есть предмет
|
||
PD-95. **Текст оркестратора не переписан** — над ним поставлен баннер SUPERSEDED с
|
||
ратифицированной формой. Если это чужая зона правки — снимать баннер приёмке, не мне.
|
||
2. **Одна собственная посадка оказалась негодной, и это поймала мутация, а не чтение.** Первая
|
||
редакция теста разбора флагов CLI сверяла только «ошибка непуста» — а команда, прошедшая свои
|
||
проверки, всё равно падала на соединении с несуществующей БД. Две посадки («grant без `--usd`
|
||
идёт как ноль», «adjust больше не требует `--note`») её ПЕРЕЖИЛИ. Тест переписан на сверку
|
||
сообщения; обе посадки теперь падают. Это форма ложно-зелёного прогона, которую стоит искать и
|
||
в остальных моих тестах.
|
||
3. **PD-80 не закрывает отказ в обслуживании как класс.** Раздельные вёдра убирают перенос
|
||
исчерпания с одного конца входа на другой и делают отбитый колбэк восстановимым. Аноним
|
||
по-прежнему может держать пустым КАЖДОЕ из двух вёдер по отдельности — это PD-42, принятый
|
||
риском в форме «глобальный, не пер-адресный; место пер-адресного — edge». Ничего нового этой
|
||
правкой не введено, но и «вход больше не выключается» — неверное чтение.
|
||
|
||
**Оговорка о полноте.** Код этой сессии никем, кроме автора, не отревьюен: адверсариальных ревью
|
||
P3 не проводила — сессия шла без права на субагентов. Найденное фикс-листом закрыто и проверено
|
||
исполнением; «дефектов больше нет» — утверждение, которого здесь нет.
|
||
|
||
## Ратификация приёмкой P2 (оркестратор №15, 07.08)
|
||
|
||
**Вердикт: P1 и P2 ПРИНЯТЫ и залендены — одним коммитом, 30 изменённых отслеживаемых файлов и 22
|
||
новых.** ⚠ **Испр. оркестратором №15:** раздел «Ратификация приёмкой P1» ниже говорит «P1 ПРИНЯТ и
|
||
заленден» — заленден он НЕ был. До этого коммита `git ls-files platform/internal/login` возвращал
|
||
ноль: в git уехали только P0 (`eeeef89`/`954c034`), направление (`99c9cb0`) и решения владельца
|
||
(`87be7b9`/`6469479`). Строки регистра формулировку не завышали — они честно говорят
|
||
`fixed(P1, дерево сессии)`.
|
||
|
||
**Уязвимости с потерей денег, данных или захватом сессии приёмка не нашла** — и это ровно то, что
|
||
фраза означает; отказ в обслуживании при этом ЕСТЬ (PD-80, класс `vuln`, воспроизведён на бинаре).
|
||
Найденное — 29 строк регистра **PD-79…PD-107**. Счёт весов ПОСЛЕ эрраты (скриптом по колонкам):
|
||
открытых 37 — **3 major** (PD-80 доступность входа · PD-95 и PD-105 подняты эрратой как задание
|
||
эмиттеру), 14 minor, 20 info. Лендинг не блокирует ничего. Блокируют дальнейшее ТРИ поимённо: **PD-91** — первый реальный
|
||
деплой (установка по наброску даёт нестартующий юнит) · **PD-79** — постройку воркера (строковый
|
||
`"null"` = ноль денег на пути расчёта) · **PD-95** — выдачу промта эмиттера (доккоммент несёт
|
||
снятую форму транспорта). Метод панели: семь линз с
|
||
зажатыми промтами (отчётные доки зоны им запрещены), затем адверсариальный опровергатель на КАЖДУЮ
|
||
находку весом minor и выше — 20 подтверждено, 2 опровергнуто, плюс мои собственные замеры.
|
||
|
||
**Метод — исполнением, не чтением отчёта.**
|
||
|
||
- Батарея пере-прогнана мной: офлайн зелёная (линтер 0 issues); с живым PostgreSQL **18.4**,
|
||
поднятым без root по рецепту `STACK_DECISIONS`, — **скипов НОЛЬ** (26 БД-тестов отработали);
|
||
`make vuln` (govulncheck v1.6.0) — чист.
|
||
- **Свои мутации по СВОЕЙ карте несущих свойств, не по таблице пинов зоны: 45 посадок в четыре
|
||
батча — 33 поймано поимённо, 8 выжило, 4 моих посадки оказались негодными** (разобраны ниже).
|
||
Мутации ставились в КОПИИ зоны вне репозитория: незакоммиченное дерево сессии не трогалось, что
|
||
сверено хешами диффа до и после.
|
||
- Живой бинарь: `healthz`/`readyz` против живой БД · `/v0/*` → 401 problem+json · пять форм
|
||
CSRF-пробы (кука без `X-TM-Client` 403 · с заголовком 401 · `Sec-Fetch-Site: cross-site` 403 ·
|
||
мусорный Bearer 401 · неразобранный `Authorization` 403) · редирект `/auth/login` с PKCE S256 и
|
||
одноразовой кукой · ПТ-34-заголовки на каждом ответе.
|
||
- **PD-2 на том бинаре, который едет:** 10 полу-кормленных POST отпущены на **30.0 с** (в P0
|
||
держались, пока не уходил клиент).
|
||
- **RFC 9207 живьём:** Google действительно шлёт `iss`
|
||
(`authorization_response_iss_parameter_supported: true`, сверено мной у издателя); сорванный
|
||
параметр → `issuer_missing`, чужой → `issuer_mismatch`, обмена кода в обоих случаях не было.
|
||
- **PD-71 пере-проверен своим прогоном на боевых данных** (аккаунт с `email = NULL` + два аккаунта
|
||
с общим подтверждённым адресом): `DownTo(4)` падает на `users_email_key` SQLSTATE 23505, три
|
||
аккаунта целы, `Up()` возвращает схему на версию 8. Заявление зоны воспроизвелось дословно.
|
||
- Фаззеры: `FuzzSafeReturnTo` 3.0 млн исполнений, `FuzzDecoder` 3.35 млн — крэшеров нет.
|
||
- `systemd-analyze verify` (systemd 259, с подставленным существующим `ExecStart=`) — exit 0.
|
||
- **Цитаты норм сверены по первоисточникам, а не по пересказу:** RFC 9700 §4.4.2 и §4.4.2.2,
|
||
NIST SP 800-63B-4 §2.1.3, ASVS 5.0 7.1.1/7.1.2/7.1.3/7.6.1/7.6.2 — формулировки дословны,
|
||
номера разделов верны, уровень L2 верен. Это несущая проверка: на этих цитатах стоит смена
|
||
боевого значения 90 → 30 суток.
|
||
- Границы зоны: `backend/` не импортируется, SQLite движка не открывается, денежных величин в
|
||
`httpapi`/`auth`/`reqid` нет; в дереве зоны только `platform/*` (чужое — живой полигон).
|
||
|
||
**Ратифицировано (6 из 6).**
|
||
|
||
1. **Абсолютный срок сессии 30 суток — ПРИНЯТО.** Цифра — буква NIST SP 800-63B-4 §2.1.3 («A
|
||
definite reauthentication overall timeout SHALL be established, which SHOULD be no more than 30
|
||
days at AAL1»), и у прежних 90 обоснования не было. ⚠ Видимое следствие продуктовое — вынесено
|
||
владельцу (ниже).
|
||
2. **Против mix-up — `iss` авторизационного ответа (RFC 9207), миграция 00008 — ПРИНЯТО.** Норма
|
||
сверена: §4.4.2 включает требование со ВТОРОГО сервера, §4.4.2.2 объявляет раздельные redirect
|
||
URI фолбэком («SHOULD therefore only be used if other options are not available»). Реализация
|
||
проверена живьём, включая отказ на сорванном параметре.
|
||
3. **`STACK_DECISIONS §13` (политика сессий) — ПРИНЯТО как документ соответствия ASVS 7.1.1/7.1.2/
|
||
7.1.3.** Рассогласование с федеративной сессией названо прямо — это и есть то, чего требует
|
||
норма, а не то, что она запрещает.
|
||
4. **Ломающие изменения зоны — ПРИНЯТЫ:** `httpapi.NewServer` без `Timeouts` (устранение КЛАССА
|
||
PD-66 сильнее теста), `Prober.Ping` → `Ready`, `login.Fail` без `*http.Request`. Внешних
|
||
потребителей у этих подписей нет: фронт говорит с зоной по HTTP.
|
||
5. **`MemoryMax=80%` + явный `OOMPolicy=continue` — ПРИНЯТО.** Аргумент сверен с
|
||
`systemd.resource-control(5)` («last line of defense», OOM-killer внутри юнита) и
|
||
`systemd.service(5)` (системный дефолт `stop`). ⚠ Под systemd не исполнялось — вывод из доки.
|
||
6. **PD-71 — ПРИНЯТ РИСКОМ** в форме, которую предложила зона: правило append-only дороже
|
||
доступности отката ниже версии 5, и место такой записи — `deploy/README.md` у оператора, а не
|
||
сноска в архитектурном доке. Пере-проверено моим прогоном (см. выше).
|
||
|
||
**Вынесено владельцу — два вопроса.** (1) Сроки сессии: не заходивший месяц человек увидит экран
|
||
входа; если это против замысла — одна переменная `TM_PLATFORM_SESSION_MAX_AGE` и явная запись
|
||
отклонения в §13. (2) **Новое, из PD-104:** фри-тир печатается НЕАУТЕНТИФИЦИРОВАННЫМ потоком по $5
|
||
за каждую новую подтверждённую пару `(provider, subject)`, и агрегатного потолка нет нигде — нужен
|
||
ли суточный лимит грантов и счётчик аномалий до открытия беты.
|
||
|
||
**Фикс-лист (порядок мой; строки регистра — носители).**
|
||
|
||
1. **PD-80 — доступность входа.** Единственная major. Ведро лимитера общее у `/auth/login` и
|
||
`/auth/callback`, и 429 колбэка приходит уже ПОСЛЕ очистки login-куки: анонимный поток ~3 rps
|
||
закрывает вход всем и добивает начатые входы. Воспроизведено мной на бинаре и независимо
|
||
панелью. Фикс дешёвый: лимитер прежде очистки куки + раздельные ведра.
|
||
2. **PD-79 — деньги.** Строковый `"null"` в `committed_usd` читается как ноль. Обязан быть закрыт
|
||
ДО того, как появится вызывающий у `Settle` (то есть до воркера).
|
||
3. **PD-83/PD-84/PD-85 — «закрыто, но не запинено»** по собственному правилу шапки регистра:
|
||
половина PD-3, лимитер PD-29 и ветка обновления адреса.
|
||
4. **PD-91 — деплой.** Установка по наброску даёт нестартующий юнит; и `ProtectHome=yes` против
|
||
«книги в `~/books`».
|
||
5. **PD-106 — админ-CLI, единственный писатель денег в дереве, не имеет ни одного теста** —
|
||
включая правило «после коммита нельзя отчитаться провалом», которое сам же называет несущим.
|
||
6. **PD-95 — переписать доккоммент `events.go:6-12` под форму D39.106, и это ЕДИНСТВЕННОЕ, что тут
|
||
осталось на зону.** Ратифицированный транспорт: `events.jsonl` в каталоге книги как
|
||
outbox-проекция коммитов SQLite, платформа его ТЕЙЛИТ, движок — транзиентный systemd-юнит и
|
||
платформе не ребёнок; «родитель + пайп» и «выделенный fd» отвергнуты с нулём голосов
|
||
(`research/25` §Форма). Секцию журнала про stdout и строки PD-92/PD-105/PD-60 уже пере-поставил
|
||
я — тебе их не трогать. Срок: ДО того, как владелец выдаст промт эмиттера, иначе та сессия
|
||
прочтёт доккоммент как задание.
|
||
7. **PD-100** — колбэк глотает ошибку стора и ошибку discovery, репортя их обычным отказом. Это
|
||
класс PD-5, который зона закрыла в `auth/`, но не в `login/`.
|
||
8. **Граница пака — жёсткая, это не «по весу строк».** НЕ трогать: **PD-92 · PD-93 · PD-96 ·
|
||
PD-99 · PD-102 · PD-105 · PD-107**. Все они латентны за воркером и эмиттером, и правка сейчас
|
||
означает угадывание формы, которую задаёт строка 103 единого бэклога. Остальные открытые строки
|
||
реестра — тоже не в этом паке: он закрыт списком выше.
|
||
|
||
**Опровергнуто приёмкой — включая свои промахи (дисциплина «заявление=команда» действует и на
|
||
приёмку).**
|
||
|
||
- Панель: «удаление аккаунта падает на композитном FK даже при закрытых резервациях» —
|
||
**опровергнуто моим прогоном:** `delete from users` проходит и без резервации, и с закрытой.
|
||
- Панель: «`money.UnmarshalJSON` читает JSON `null` как ноль» — **опровергнуто в этой форме:**
|
||
голый `null` даёт nil-указатель, защита работает; дыра — в строке `"null"` (PD-79, диагноз
|
||
исправлен).
|
||
- Панель: «WARN на каждый отбитый вход — неограниченная запись в лог» — **опровергнуто:**
|
||
`AccessLog` и так пишет INFO-строку на КАЖДЫЙ запрос, так что нового канала WARN не создаёт.
|
||
- **Своя посадка «грант фри-тира не запинен» — НЕГОДНАЯ:** грант живёт в ветке НОВОЙ личности,
|
||
поэтому подмена его ключа на возвращающемся входе ничего не меняет. Корректная посадка
|
||
(начислять на КАЖДОМ входе) ловится `TestReturningIdentityKeepsItsAccountAndIsGrantedOnce` —
|
||
свойство запинено.
|
||
- **Своё «падение `FuzzDecoder`» — артефакт моего стенда** (голод по CPU от параллельных батчей
|
||
мутаций), не дефект: чистый прогон 3.35 млн исполнений зелёный.
|
||
- **PD-20 — калибровка, не находка:** моя посадка «один сигнал вместо лестницы» выжила в одном
|
||
прогоне, но дефект вероятностный (≈1 из 3 по замеру зоны), поэтому это свойство пина, а не новая
|
||
дыра. Пин остаётся вероятностным — знать об этом важнее, чем завести строку.
|
||
- **`TestMigrationsRollBackAndReapply` откатывает ПУСТУЮ базу,** поэтому для 00005 он не
|
||
доказывает ничего (реальный откат невозможен по построению — PD-71). Норматив зоны «down-путь
|
||
существует и гоняется тестом» выполнен буквой, но не смыслом; сказано здесь, чтобы это не
|
||
читалось как покрытие.
|
||
|
||
## Сессия P2: что изменилось в решениях
|
||
|
||
Очередь приёмки P1 отработана в её порядке. Каждый пункт сначала воспроизведён посадкой на своём
|
||
стенде (PostgreSQL 18.4, Go 1.26.5) — включая те, где вердикт приёмки в итоге уточнён.
|
||
|
||
1. **`ClearReadDeadline` удалён, а не оставлен «страховкой».** Приёмка проверила корректные запросы
|
||
и заключила «код безвреден». На ПОЛУ-КОРМЛЕННОМ запросе он вреден: дренаж внутри записи заголовка
|
||
— единственная граница соединения, снятие дедлайна до заголовка её убирает, и хендлер остаётся
|
||
внутри `WriteHeader` через 4 с после ухода клиента (замерено). Случая, где функция помогает, нет:
|
||
на корректном запросе `net/http` снимает дедлайн сам. PD-51 закрыт, PD-63 заведён.
|
||
2. **Абсолютный срок сессии 90 → 30 суток.** ASVS 7.1.1 требует обосновать отклонение от NIST SP
|
||
800-63B; у 90 суток обоснования не нашлось (баланс тратимый, повторный вход у залогиненного в
|
||
Google — один клик, прогон истечение сессии переживает). Обосновывать нечего — цифра выровнена по
|
||
норме. Политика целиком (оба срока, одновременные сессии, рассогласование с федеративной) —
|
||
`STACK_DECISIONS §13`.
|
||
3. **Против mix-up — `iss` авторизационного ответа (RFC 9207), решение принято до второго
|
||
провайдера.** Альтернатива «`iss` из ID-токена» при чистом code flow не работает: токен приходит
|
||
после отдачи кода. Фолбэк «раздельные redirect URI» норма разрешает только когда другого нет, а
|
||
Google RFC 9207 поддерживает (сверено живьём). `auth_states.issuer` + сверка до обмена кода +
|
||
отказ на СОРВАННОМ параметре.
|
||
4. **`MemoryMax` — потолок машины, а не сервиса.** По собственному аргументу зоны дети-`tmctl` живут
|
||
в cgroup юнита, значит «2G на контрол-плейн» — это 2G на платформу и все прогоны вместе, а
|
||
OOM-killer внутри юнита выберет движок с эксклюзивным локом. `80%` + явный `OOMPolicy=continue`.
|
||
5. **Пин на СВОЙСТВО там, где слои перекрываются.** У `safeReturnTo` четыре проверки, и снятие любой
|
||
одной таблица входов переживала. Добавлены входы-различители плюс `FuzzSafeReturnTo` с
|
||
НЕЗАВИСИМЫМ оракулом (`ResolveReference` против базового URL сайта) — 3,1 млн исполнений.
|
||
Побочно установлено и записано в код: условия `u.Scheme/u.Host/u.Opaque` недостижимы как отказ,
|
||
пин на них невозможен, оставлены бэкстопом.
|
||
6. **Состояние входа сравнивается структурой целиком.** `State.StartID` не персистился — колонки не
|
||
было, — и обе строки лога `login_start_id` в проде были пустыми, пока in-memory стор тестов
|
||
показывал их заполненными. Тот же класс, что PD-46: свойство проверено не на том объекте.
|
||
PD-62; теперь `reflect.DeepEqual` на всей структуре, следующее поле без колонки упадёт здесь же.
|
||
|
||
### Что нашло собственное ревью P2 (author≠reviewer)
|
||
|
||
Пять ревьюверов по разным линзам, зажатые промты, журнал зоны и регистр от четырёх из пяти скрыты;
|
||
каждая находка потом отдана адверсариальному верификатору с установкой «опровергни, по умолчанию
|
||
считай неподтверждённой». 34 кандидата, **11 подтверждено, 23 опровергнуты с разбором**. Из
|
||
подтверждённых шесть потребовали правки кода:
|
||
|
||
7. **Обмен кода и JWKS шли без дедлайна** (PD-65), хотя discovery рядом ограничивает себя пятью
|
||
секундами. Набор ключей у go-oidc ОБЩИЙ — значит одна зависшая загрузка паркует все параллельные
|
||
входы, а не только свой. Замерено верификатором на боевой проводке (`httpClient` = nil).
|
||
8. **Мой же фикс PD-46 закрывал половину и утверждал, что обе** (PD-66). Тест смотрел на сервер,
|
||
который сам и построил; проводка демона осталась ненаблюдаемой — подмена аргумента в `main.go`
|
||
оставляла `make check` зелёным, а бинарь снова пиннил соединения. Закрыто устранением КЛАССА:
|
||
`NewServer` больше не принимает `Timeouts`, передавать нечего.
|
||
9. **`FOR UPDATE` не был запинен**, а комментарий утверждал обратное (PD-67). Последовательный тест
|
||
лока не видит, а инвариант «кэш = леджер» тоже: без лока обе величины уезжают в минус ВМЕСТЕ.
|
||
10. **`/readyz` рапортовал «готов» на базе без схемы** (PD-68) — то есть в нормальной середине
|
||
выката, потому что миграция по инструкции отдельный шаг.
|
||
11. **Явный `pool_max_conns` из DSN молча отбрасывался** (PD-69): сравнение с дефолтом pgx не
|
||
отличает «оператор промолчал» от «оператор выбрал это же число».
|
||
12. **Скользящее окно бездействия для браузера не работало** (PD-70, major). Серверная строка
|
||
скользила, кука — нет: `Max-Age` пишется один раз, на входе. Человек, заходящий каждый день,
|
||
выкидывался на 14-е сутки при живой сессии, а абсолютный срок не наступал никогда. Это делало
|
||
ложным §13 — документ соответствия ASVS, написанный в этой же сессии двумя часами раньше.
|
||
|
||
### Второе ревью: по коду, которым чинили первое (05.08)
|
||
|
||
Первое ревью смотрело дерево ДО своих же фиксов. Второй проход дан по ним — плюс отдельной линзой
|
||
про воду. **42 кандидата, 22 подтверждено.** Что из этого меняет решения:
|
||
|
||
- **Мой фикс PD-65 был неполон** (PD-73). Дедлайн ограничивал ожидающего, а не саму загрузку ключей:
|
||
`Provider.Verifier` берёт набор ключей, построенный на discovery, а go-oidc хранит его через
|
||
`context.WithoutCancel` и ходит на `http.DefaultClient`. Зависшая загрузка держала вход и после
|
||
выздоровления эндпоинта — до перезапуска процесса. Закрыто устранением класса: `httpClient` теперь
|
||
никогда не nil, `New` ставит клиент с таймаутом, и его подхватывает `NewProvider`.
|
||
- **Скольжение окна залипало** (PD-74, найдено двумя линзами независимо): `Touch` прижимает idle к
|
||
абсолютному потолку, после чего условие «осталось меньше половины» истинно всегда — каждый запрос
|
||
последней четверти жизни сессии становился записью в таблицу сессий и `Set-Cookie`.
|
||
- **CLI сообщал о применённом начислении как о провале** (PD-75), а повтор без `--key` начислял
|
||
второй раз — достаточно обрыва между записью и чтением баланса.
|
||
- **Утверждение «провайдерский токен не персистится» не могло упасть** (PD-76): поле мока никто не
|
||
заполнял. Это определяющее свойство пакета, названное первой строкой его доккоммента.
|
||
- **Штатная остановка прогона поднимала тревогу о сломанном синке** (PD-77).
|
||
- **Четыре строки таблицы пинов обещали то, чего батарея не даёт.** Две закрыты новыми тестами
|
||
(гоночное потребление state; порядок блокировок), две — честной формулировкой: возврат общего
|
||
лимита тела не наблюдаем до появления маршрута с бо́льшим потолком (PD-72), а вход «пароль содержит
|
||
имя ключа» доказывает что-то только на машине, где наш дефолт не совпал с дефолтом pgxpool.
|
||
- **Свод воды** (PD-78), каждый пункт проверен удалением: мемоизация mux в `Routes` молча
|
||
игнорировала второй `guard`; шим `httpapi.Fail` существовал ради параметра, который никто не читал;
|
||
`upsertIdentityOnce` дублировал `inTx`; `Deps.APIPrefix` — ручка без вызывателей; `Ready` делал два
|
||
round trip на пробу; пять полей тестовых двойников писались и не читались.
|
||
|
||
### Самопроверка после ревью (владелец 05.08: «нет ли велосипедов и о чём умолчал»)
|
||
|
||
Перечитывание СВОЕГО кода дало ещё пять правок, три из которых — дефекты, внесённые в этой же
|
||
сессии и доехавшие бы до лендинга:
|
||
|
||
- **Кука при скольжении могла пережить абсолютный срок.** Фикс PD-70 выдавал `Max-Age` = idle-TTL
|
||
безусловно, поэтому сессия на 29-е сутки получала куку ещё на 14 — и каждый запрос после потолка
|
||
становился 401 вместо чистого «вы вышли». Ровно то, из-за чего `MaxAge` изначально и брали по
|
||
idle. Теперь `min(idle, остаток абсолютного)`, случай запинен, посадка падает.
|
||
- **Фикс PD-68 ломал слой и тёк наружу.** Ради строки «схема не накачена» в теле ответа `httpapi`
|
||
импортировал `pgstore` — при том что `Prober` интерфейсом заведён именно чтобы этого не было, — а
|
||
сама строка сообщала состояние выката на НЕаутентифицированной ручке. Причина уехала в ERROR-лог,
|
||
импорт снят.
|
||
- **Два велосипеда.** Разбор DSN руками (33 строки) — при том что `pgxpool` достаёт `pool_*` из
|
||
`RuntimeParams`, и второй `pgx.ParseConfig` отвечает на вопрос точно, вместе с кавычками и
|
||
service-файлами. И разбор номера миграции из имени файла — при том что есть
|
||
`goose.NumericComponent`. Оба сняты, `store.go` короче на 44 строки.
|
||
- **`latestMigration` считался на КАЖДУЮ пробу готовности** — величина времени сборки, теперь
|
||
`sync.OnceValues`. Имя таблицы версий отдано `goose.TableName()`, а не захардкожено.
|
||
- **Ошибка разбора discovery больше не глотается.** Флаг `authorization_response_iss_parameter_supported`
|
||
с неверным типом молча выключал бы защиту от срыва параметра — теперь WARN.
|
||
|
||
**Перепроверено, а не принято со слов:** PD-71 (`DownTo(4)` падает на `users_email_key`, SQLSTATE
|
||
23505; `Up()` возвращает схему, все три аккаунта целы). Замеры ревью по PD-65 и PD-66 в регистре
|
||
атрибутированы ревью — своими прогонами я их не воспроизводил.
|
||
|
||
Плюс PD-71 — принят риском: down-путь `00005` неисполним на данных, которые его же up-путь делает
|
||
законными, поэтому откат ниже версии 5 недоступен. Править нельзя (append-only), поэтому записано
|
||
оператору в `deploy/README.md`, а не спрятано.
|
||
|
||
**Не трогали намеренно:** PD-60 и PD-61 — обратное давление и сброс буфера эмиттера. Это свойства
|
||
ШВА, назначать их платформе в одиночку нельзя: эмиттера нет, а выбор «блокировать движок или ронять
|
||
события» меняет контракт. Идут строкой 103 единого бэклога вместе с транспортом (PD-59).
|
||
|
||
**Замеры, которые не воспроизвелись:** «41 взаимоблокировка на 300 раундов» (сессия P1) — остаётся
|
||
со слов; действующий замер по PD-52 сделан заново. Замер приёмки «2 на 150» на этом стенде дал
|
||
5–10 на 150 — та же величина, другой стенд, поэтому в тест записан свой.
|
||
|
||
## Приёмка P1: что изменилось в решениях
|
||
|
||
Пять независимых ревью (вход · деньги и SQL · стиль · логи · вне карты автора), четыре из пяти —
|
||
исполнением. Находки — строками PD-24…PD-45 в регистре. Здесь только то, что поменяло РЕШЕНИЕ:
|
||
|
||
1. **Миграции append-only без исключений** (было: «до первого деплоя правим на месте»). goose
|
||
применяет по НОМЕРУ — ни имени, ни хеша: база на версии 3 приняла бы новый набор как применённый
|
||
и не получила ни одной таблицы, `DownTo` на ней ломается навсегда. Гейт — `migrations.sha256` +
|
||
`TestReleasedMigrationsAreUnchanged`. Подробности в `STACK_DECISIONS` §8.
|
||
2. **Ключ идемпотентности леджера — `(user_id, source, source_id)`**, пустой ключ запрещён DDL.
|
||
Ключ без аккаунта проглатывал грант, выданный другому.
|
||
3. **`reservations.book_id` — составной FK к `books(id, owner_id)` с RESTRICT.** Каскад делал холд
|
||
невозвратным, а освободившийся `engine_run_id` давал холд без списания. Владение книгой теперь
|
||
проверяет база, а не вызывающий (это денежная форма API1 BOLA).
|
||
4. **`lockBalance` первым во всех денежных операциях** — иначе Hold↔Settle дают взаимоблокировку.
|
||
⚠ Замер этой сессии «41 на 300 раундов» не воспроизвели ни приёмка, ни P2 — нагрузка не была
|
||
описана; остаётся СО СЛОВ. Дефект при этом настоящий: воспроизведён независимо дважды, действующий
|
||
замер — в PD-52.
|
||
5. **Расчёт capped потолком холда.** Завышенное `committed_usd` уводило баланс в минус.
|
||
6. **Грант фри-тира — только подтверждённой личности** (вопрос владельцу ниже).
|
||
7. **Имя провайдера — конфигурация, `State.Provider` сверяется в колбэке.** Захардкоженное «google»
|
||
при смене issuer кладёт чужие `sub` в старое пространство имён.
|
||
8. **Исход прогона читается из `ProcessState`, а не из ошибки `Wait`** — иначе штатный SIGTERM
|
||
помечает все идущие прогоны провалившимися.
|
||
9. **Лимит тела — пер-маршрутный**, иначе загрузка книги не может поднять свой потолок.
|
||
10. **Просроченный дренаж — не отказ процесса** (exit 0 + WARN): под `Restart=on-failure` штатная
|
||
остановка читалась бы systemd как крах.
|
||
|
||
**Отклонено:** `user_id` в access-логе (предложение ревью логов). Норматив зоны запрещает id
|
||
пользователей в логах; ответ на «кого задело» по дизайну живёт в `login_events`. Взят смежный
|
||
вариант — исход аутентификации, он не PII.
|
||
|
||
**Проверено и дефектов не дало:** PKCE/nonce/одноразовость стейта под конкуренцией (6 колбэков с
|
||
одним стейтом → 1 успех); провайдерские токены нигде не персистятся; параллельные первые входы
|
||
(40 горутин → один аккаунт); инвариант `balance == SUM(ledger)`; отсутствие секретов, PII и денег
|
||
в логах.
|
||
|
||
## Открытые вопросы после P1
|
||
|
||
**Владельцу — оба ЗАКРЫТЫ приёмкой (05.08):** грант только подтверждённой личности остаётся; два
|
||
провайдера = два аккаунта на бете приемлемо, дефолт гранта $5. Правило гранта теперь и запинено —
|
||
PD-48.
|
||
|
||
**Новый вопрос владельцу, один — из PD-58.** Абсолютный срок сессии снижен с 90 до **30 суток**:
|
||
это цифра NIST SP 800-63B-4 для AAL1, а обоснования у 90 не нашлось (на аккаунте тратимый баланс,
|
||
повторный вход у уже залогиненного в Google — один клик, а идущий ПРОГОН истечение сессии
|
||
переживает — он серверный процесс). Видимое следствие: человек, не заходивший месяц, увидит экран
|
||
входа. Если это против замысла — это одна переменная `TM_PLATFORM_SESSION_MAX_AGE` и строка в
|
||
`STACK_DECISIONS §13`, но тогда отклонение от нормы придётся записать туда явно.
|
||
|
||
**Оркестратору — четыре.**
|
||
1. **Спека не знает про `/auth/*`.** Ручки входа (`GET /auth/login`, `GET /auth/callback`,
|
||
`POST /auth/logout`, `POST /auth/logout-all`) живут ВНЕ версионного префикса, как `/healthz`:
|
||
это не контрактная поверхность, а механика сессии. Если фронт должен на них ссылаться — нужна
|
||
строка в спеке или в компаньоне. Наше предложение: описать их в компаньоне, в `openapi.yaml`
|
||
не тащить.
|
||
2. **Требование `X-TM-Client` (пинг P0) всё ещё не в спеке.** Повторяем: реализовано, значение
|
||
любое, несущей является ПРИСУТСТВИЕ заголовка на небезопасных запросах cookie-пути.
|
||
3. **Форма ответа `GET /v0/usage` изменилась вместе с моделью денег** (баланс вместо окон), а
|
||
спека этого ещё не отражает. Ручку НЕ строили намеренно — контракт первичен. Предлагаемая форма
|
||
в разделе «Что предлагаем в спеку» ниже.
|
||
4. **Транспорт потока событий: увести с stdout на выделенный дескриптор или сокет.** Просьба
|
||
завести это строкой к 103 единого бэклога, пока эмиттера нет — потом правка станет миграцией.
|
||
|
||
> ⚠⚠ **ВОПРОС И ОТВЕТ НИЖЕ SUPERSEDED — D39.106 (05.08).** Обе стороны спора сняты: канал не
|
||
> остаётся на stdout и не переезжает на fd/сокет — движок платформе вообще НЕ ребёнок. Форма:
|
||
> транзиентный systemd-юнит на прогон, `events.jsonl` в каталоге книги как outbox-проекция
|
||
> коммитов SQLite, платформа тейлит с курсором `(engine_run_id, seq)`. «Родитель + пайп» — 0
|
||
> голосов из 15, выделенный fd 3 — 0, stdout/journald как источник — 0 (`research/25` §Форма).
|
||
> Пайп-путь `supervisor.go` = дев-режим. Живой носитель формы — доккоммент пакета
|
||
> `internal/ingest` (PD-95). Абзацы ниже оставлены как история спора; заданием они не являются.
|
||
> ⚠ Баннер поставлен сессией P3 08.08: эррата №15 пере-ставила ДРУГУЮ секцию (ниже, «Транспорт
|
||
> потока событий (вопрос зоны №4)»), эта копия ответа осталась без пометки.
|
||
>
|
||
> ⚠ **ОТВЕЧЕНО приёмкой (PD-59): диагноз принят, переезд канала отклонён.** Мотив прецедентов
|
||
> (dpkg/gpg/systemd) к нам не переносится — там stdout занят, у нас платформа даёт движку
|
||
> выделенный пайп. `ExtraFiles` задокументирован четырьмя строками, цена ошибки замерена.
|
||
> Риск закрывается guard'ом в источнике. Триггеры пересмотра названы в разделе «Ратификация
|
||
> приёмкой P1», подраздел про транспорт.
|
||
|
||
Формат менять НЕ предлагаем: NDJSON с версионным хендшейком в stdout — индустриальная норма для
|
||
«долгая команда сообщает прогресс машине» (clig.dev: машиночитаемое — в stdout, сообщения — в
|
||
stderr; так же `go test -json`, `cargo --message-format`, `terraform -json`, docker jsonmessage).
|
||
Речь только о канале.
|
||
|
||
Причина: **stdout — общий ресурс процесса.** Один `fmt.Println` в движке или в его зависимости
|
||
ломает протокол, и сегодня от этого защищает правило в `research/23 §2`, а не механизм. Индустрия
|
||
этот же вывод сделала: `hashicorp/go-plugin` (Terraform, Vault, Nomad, Packer) печатает в stdout
|
||
ОДНУ строку хендшейка `1|3|unix|/path/to/socket|grpc` и дальше уходит на unix-сокет; `runc` и
|
||
`containerd` получают канал через `--console-socket`.
|
||
|
||
Предлагаемая форма для нас: платформа передаёт путь/дескриптор в argv, движок пишет поток туда,
|
||
stdout остаётся человеку. Полный RPC (go-plugin/gRPC) сейчас НЕ нужен — управление
|
||
однонаправленное: старт argv, стоп сигналом, досинхронизация `status --json`. Триггеры, при
|
||
которых он окупится, называем заранее: пауза/продолжение без убийства процесса · поднятие потолка
|
||
на живом прогоне · подпись банка в работающий процесс вместо рестарта · управление потоком.
|
||
Два таких требования — и переезд оправдан, причём механический: хендшейк уже есть.
|
||
|
||
## Открытые вопросы к владельцу/оркестратору (P0, историческое)
|
||
|
||
**Решения владельца 05.08 (закрыли всё, что висело по деньгам):** оплаты нет и в бете не будет —
|
||
пробные аккаунты на фри-тире, ключи предоплачены владельцем · модель лимита = БАЛАНС кредитов, а не
|
||
окна с обнулением («не подписки, а покупка токенов как у OpenRouter») ⇒ `resets_at`/`usage_windows`
|
||
в подписочной форме отменены, вопрос авто-резюме после сброса окон отпал вместе с окнами · фри-тир =
|
||
грант в леджер из админки, дефолт $5, настраиваемый. ⚠ Мой вывод «покупателям из России платить
|
||
нечем» СНЯТ как необоснованный: он был выведен из целевого языка перевода, а не установлен.
|
||
Резервация решена оркестратором (вариант B — холд + пер-книжный потолок движку, жёсткий стоп исполняет
|
||
движок). Разбор — `PLATFORM_DIRECTION.md` §2.
|
||
|
||
2. **Оркестратору (контракт).** Поток событий привязан к ПРОГОНУ (`/runs/{runId}/events`), а
|
||
статусы `uploading`/`parsing` существуют ДО прогона, и библиотека охватывает книги без прогонов.
|
||
Живого канала у них нет вовсе. Нужна либо строка в спеке «до старта прогона состояние
|
||
опрашивается», либо пользовательский поток (он же закрыл бы К-12 пушем). Решение — не наше.
|
||
3. **Оркестратору (спека).** Третий слой CSRF из STACK §5 требует от браузерного клиента
|
||
заголовок `X-TM-Client` на небезопасных запросах cookie-пути. Это требование к ФРОНТУ, и его
|
||
место — в описании `sessionCookie` в спеке. Реализовано и проверено тестами.
|
||
|
||
## Решения сессии P1 (аргументация)
|
||
|
||
### Политика коллизии почты
|
||
|
||
**Почта не является ключом ни в какой форме. Ключ личности — `(provider, subject)`.**
|
||
`users.email` стала NULLABLE и потеряла уникальный индекс; неизвестная пара `(provider, subject)`
|
||
ВСЕГДА создаёт новый аккаунт, какой бы адрес с ней ни пришёл. Присоединение второго провайдера к
|
||
существующему аккаунту — отдельное аутентифицированное действие (его ещё нет), никогда не побочный
|
||
эффект входа. Адрес аккаунта обновляется только из ПОДТВЕРЖДЁННОГО; неподтверждённый остаётся на
|
||
личности и наверх не поднимается.
|
||
|
||
Почему не «связывать по подтверждённой почте». Связывание по адресу — это классический путь захвата
|
||
аккаунта, и `email_verified` его не закрывает: адрес может смениться владельцем (Google предупреждает
|
||
об этом прямым текстом), корпоративный домен может выдать освободившийся ящик другому сотруднику, а
|
||
провайдер может пометить verified то, что он верифицировал по своим правилам, а не по нашим. Цена
|
||
нашего решения — два аккаунта у одного человека при входе разными провайдерами. Это ДУБЛИКАТ:
|
||
человек его видит, оператор может слить. Цена альтернативы — чужой аккаунт достаётся тому, кто
|
||
получил адрес. Дубликат чинится, захват — нет.
|
||
|
||
Пин: `TestIdentityNeverJoinsAccountsByEmail` — два субъекта с одним адресом и третий с другим
|
||
провайдером обязаны дать ТРИ аккаунта.
|
||
|
||
⚠ Побочное следствие для денег — вопрос владельцу выше (грант на аккаунт, аккаунтов может быть два).
|
||
|
||
### Форма админ-поверхности — CLI, не защищённая ручка
|
||
|
||
`tmplatformctl grant|balance|logins|revoke`. HTTP-ручке ради четырёх операций понадобилась бы вторая
|
||
модель авторизации: роли, их хранение, эскалация, отзыв админской сессии, отдельный CSRF-режим —
|
||
и каждая из этих вещей может быть сделана неправильно. Граница доверия для этих операций уже есть и
|
||
обеспечена машиной: чтобы выполнить их, нужен шелл на VM и доступ к DSN. Браузерная панель, если
|
||
понадобится, обернёт ровно те же вызовы стора.
|
||
|
||
Идемпотентность у гранта — ОПТ-ИН через `--key`: ключ по умолчанию «аккаунт+дата» схлопывал два
|
||
законных гранта одного дня, и второй рапортовал успех, ничего не начислив. Без ключа каждый вызов
|
||
самостоятелен, а CLI печатает «применилось» или «ключ уже потрачен» по факту.
|
||
|
||
### Форма журнала входов
|
||
|
||
Таблица `login_events`: время, провайдер, исход (`success|denied`), причина отказа, ПРЕФИКС адреса
|
||
(/24 для IPv4, /48 для IPv6) и КЛАСС клиента (`browser|desktop|other`). Ни полного адреса, ни
|
||
user-agent: журнал отвечает на вопрос «откуда примерно и чем», который человек и оператор реально
|
||
задают, и не превращается в собственную базу слежки. Отказавшийся вход пишется без `user_id` —
|
||
попытка была, аккаунта у неё нет. Ручка «отозвать все сессии» — `POST /auth/logout-all`, она же
|
||
`tmplatformctl revoke`. ⚠ Ретенции у журнала пока нет — строка PD-23.
|
||
|
||
## Что предлагаем в спеку (S3)
|
||
|
||
`GET /v0/usage` в кредитной модели — БЕЗ сумм и без `resets_at`:
|
||
|
||
```yaml
|
||
Usage:
|
||
required: [state, remaining_percent]
|
||
properties:
|
||
state: { enum: [ok, low, exhausted] } # low — порог показа предупреждения
|
||
remaining_percent: { type: integer, minimum: 0, maximum: 100 } # от последнего гранта
|
||
paused_reason: { enum: [credit_exhausted], nullable: true } # почему стоит прогон
|
||
```
|
||
|
||
Процент считается от суммы грантов аккаунта, а не от «лимита периода»: периодов больше нет.
|
||
`Run.paused_reason` — то же значение на прогоне (колонка в схеме заводится вместе с ручкой).
|
||
|
||
## Ратификация приёмкой P1 (оркестратор №14, 05.08)
|
||
|
||
**Вердикт: P1 ПРИНЯТ и заленден.** ⚠ **Испр. оркестратором №15:** слово «заленден» здесь неверно —
|
||
код P1 в git не уезжал, он ушёл туда вместе с P2 при приёмке 07.08 (раздел «Ратификация приёмкой
|
||
P2» выше). Живой уязвимости приёмка не нашла. Найденное делится на три
|
||
кучки: незапиненные свойства (строка регистра говорит «закрыто», посадка её переживает), неверные
|
||
формулировки в доках и **два несоответствия внешней норме** — PD-57 (mix-up: реализована не та
|
||
контрмера, которую требует RFC 9700 §2.1) и PD-58 (ASVS 5.0 L2 требует ДОКУМЕНТИРОВАТЬ сроки
|
||
сессий; они существуют только литералами в коде). По правилу, которое сессия сама применила к PD-1
|
||
(«свойство без пинящего теста закрытым не считается»), строки **PD-30, PD-32, PD-37, PD-26 к своим
|
||
фиксам не привязаны** — заведены заново как PD-46…PD-59.
|
||
|
||
**Метод.** Батарея пере-прогнана мной: офлайн зелёная (0 issues линтера), с живым PostgreSQL 18.4 —
|
||
скипов ноль, `make vuln` чист. Собственная посадка 43 мутаций (не по следам отчёта: по СВОЕЙ карте
|
||
свойств несущего пути) — **31 поймана поимённо, 9 пережили, 3 не собрались**; дерево после каждой
|
||
восстановлено, побайтовая сверка с бэкапом в конце — совпадение. Плюс шесть собственных
|
||
тестов-проб против живой БД и четыре живые пробы на собранном бинаре. Механику см. регистр.
|
||
|
||
**Сверка с индустриальной нормой — отдельным проходом, по первоисточникам** (её отсутствие владелец
|
||
поймал на первой редакции этого раздела; тогда решения были проверены только ВНУТРИ репозитория —
|
||
механика goose, схема, поведение `net/http` — а против внешних норм не сверялись):
|
||
|
||
| Норма | Что требует | Как у нас |
|
||
|---|---|---|
|
||
| OIDC Core 1.0 §5.7 / §2 | Стабильный идентификатор — только пара `(iss, sub)`; `email`, `phone_number`, `preferred_username` **MUST NOT** использоваться как идентификатор (издатель вправе переиспользовать адрес между людьми) | ✅ решение «почта не ключ» — не наше изобретение, а буква нормы. ⚠ ключуем по НАШЕМУ имени провайдера, не по `iss`; причина названа в коде (смена URL издателя не осиротит аккаунты) — сознательное отклонение |
|
||
| RFC 9700 §2.1.1 (BCP, янв. 2025) | PKCE; `nonce` как альтернатива для OIDC-клиентов | ✅ и то, и другое, реально проверяется настоящим издателем в тесте |
|
||
| RFC 9700 §2.1 | Одноразовый `state` против CSRF; точное сравнение redirect URI | ✅ одноразовость в БД одним `DELETE … RETURNING` + привязка к куке браузера |
|
||
| RFC 9700 §2.1 + RFC 9207 | Против mix-up: SHOULD — `iss` из авторизационного ответа; MAY — раздельные redirect URI | ❌ **не выполнено** — реализована собственная сверка, которая внутри одного хендлера сравнивает конфигурацию с собой: **PD-57** |
|
||
| ASVS 5.0 V7 · 7.2.3, 7.2.4, 7.4.1, 7.4.2 (L1) | ≥128 бит энтропии; новый токен на аутентификации со сносом прежнего; отзыв прекращает использование; снос всех сессий при удалении аккаунта | ✅ все четыре, 256 бит при требуемых 128, ротация запинена тестом |
|
||
| ASVS 5.0 V7 · 7.1.1, 7.1.2, 7.1.3/7.6.1 (L2) | Сроки бездействия и абсолютный ДОКУМЕНТИРОВАНЫ с обоснованием отклонений от NIST SP 800-63B; политика одновременных сессий; согласование с федеративной сессией | ❌ **не выполнено** — 14 суток/90 суток живут литералами в `config.go`, обоснования нет нигде: **PD-58**. Это несоответствие линии, которую зона объявила себе сама (`ENGINEERING_STANDARDS §2`) |
|
||
| Миграции | Flyway/Liquibase хранят контрольные суммы и падают на расхождении; goose хранит только номер | ✅ `migrations.sha256` — не самодеятельность, а восполнение того, что другие инструменты дают из коробки |
|
||
| Деньги | Резерв→захват (hold/capture) — стандарт платёжной механики | ✅ форма стандартная. Леджер знаковый однозаписный с кэшем баланса вместо двойной записи — упрощение, оправданное отсутствием продаж; инвариант `balance == SUM(ledger)` его страхует |
|
||
|
||
**Подтверждено ИСПОЛНЕНИЕМ (не чтением отчёта):**
|
||
|
||
- **PD-2 действительно закрыт на том бинаре, который едет.** 25 полу-кормленных POST → сервер
|
||
отпустил все 25 через 29.1 с (в P0 держал, пока не уходил клиент). ⚠ Но защита от повторного
|
||
открытия стоит только на проводке — PD-46.
|
||
- **PD-25 в своей полной форме:** один ключ идемпотентности на двух аккаунтах — оба применяются;
|
||
повторный `Hold` после `DeleteBook` (когда строку резервации унесло) даёт `ErrDuplicateHold`,
|
||
баланс не двигается, резервация не остаётся. Собственный тест.
|
||
- **PD-26 воспроизведён независимо** — 2 взаимоблокировки на 150 раундов с инвертированным
|
||
порядком, 0 с фиксом. Фикс несущий; ⚠ замер сессии «41 на 300» не воспроизведён (см. PD-52).
|
||
- **PD-27 держит и на переполнении:** `Settle` с `1<<62` списывает ровно холд.
|
||
- **PD-24:** манифест закрывает все три случая — правку, новый нелистанный файл и удаление.
|
||
- **PD-34:** штатная остановка даёт exit 0; второй SIGTERM убивает (обработчик снят).
|
||
- **CSRF-слой живьём:** кука без `X-TM-Client` → 403; с заголовком → 401; `Sec-Fetch-Site:
|
||
cross-site` → 403; `Origin: evil` → 403; кука + мусорный Bearer → 401 (привилегии не даёт, PD-50).
|
||
- **Админ-CLI живьём:** грант, повтор ключа (no-op), корректировка, `balance == SUM(ledger)`,
|
||
отказы на отрицательном гранте, корректировке без причины и неизвестном аккаунте.
|
||
- **200 конкурентных денежных операций** — 0 ошибок, кэш не разъехался с леджером.
|
||
|
||
**Опровергнуто исполнением:** обоснование `ClearReadDeadline` и строка «Поток переживает
|
||
read-дедлайн» в таблице пинов — PD-51. `net/http` снимает read-дедлайн сам, до хендлера;
|
||
тест не может упасть от выхолащивания функции. Код безвреден, ложны обоснование и пин.
|
||
|
||
**Ратифицировано (4 из 4):**
|
||
|
||
1. **Миграции append-only без исключений — ПРИНЯТО.** Аргумент проверен: `goose_db_version` держит
|
||
только номер. `STACK_DECISIONS §8` — норма зоны.
|
||
2. **Почта не ключ ни в какой форме — ПРИНЯТО, и это прямо буква нормы,** а не наш вкус: OIDC Core
|
||
§5.7 — стабильный идентификатор только `(iss, sub)`, `email` использовать как идентификатор
|
||
**MUST NOT**, потому что издатель вправе переиспользовать адрес между людьми. Цена (дубль
|
||
аккаунта у человека с двумя провайдерами) названа честно и меньше альтернативы (захват аккаунта
|
||
по унаследованному адресу). ⚠ Отклонение: ключуем по НАШЕМУ имени провайдера, не по `iss`;
|
||
причина в коде названа и принимается.
|
||
3. **Админ-поверхность — CLI — ПРИНЯТО.** ⚠ Владельцу сказано прямо: «админка» сегодня = команда в
|
||
шелле на машине, не веб-страница. Для беты этого достаточно; браузерная панель обернёт те же
|
||
вызовы стора.
|
||
4. **`sqlc` (PD-44) — направление ИЗМЕНЕНО, а не долг.** Проверено исполнением: sqlc v1.31.1 читает
|
||
все goose-миграции зоны (на момент пробы семь) и генерирует под `pgx/v5` код, почти совпадающий с рукописным
|
||
(`:execrows` → `RowsAffected`). Две трения: он отвергает запрос, который Postgres принимает
|
||
(неквалифицированный `user_id` в коррелированных подзапросах), и типизует колонки как
|
||
`pgtype`/`int64` — то есть `money.MicroUSD` на границе теряется без блока `overrides`.
|
||
**Решение:** денежный пакет НЕ переписывать — он только что отревьюен и имеет батарею против
|
||
живой БД, а обмен отревьюенного кода на сгенерированный без единого нового теста ничего не
|
||
покупает. `sqlc` берётся на поверхность контрактных ручек/read-model (П-1), где запросов много
|
||
и они меняются, — там ловится именно тот класс, ради которого он нужен (запрос ссылается на
|
||
колонку, которую унесла миграция). Правка внесена в `PLATFORM_DIRECTION.md` §3.
|
||
|
||
**Вопросы владельца — ответы даны, оба «да» с одной поправкой.** Грант только подтверждённой
|
||
личности остаётся (для Google это все настоящие аккаунты, а дыру саморегистрации закрывает);
|
||
два провайдера = два аккаунта на бете приемлемо, дефолт гранта $5. ⚠ Само правило гранта тестом
|
||
не защищено — PD-48, чинить независимо от ответа.
|
||
|
||
**Регрессионный тест к PD-52** (написан приёмкой, воспроизводит цикл; вставить в
|
||
`internal/pgstore/`, хелперы `testDB`/`seedUser`/`exec` уже есть):
|
||
|
||
```go
|
||
// PD-26 в форме, которая действительно даёт цикл: Hold, переиспользующий attempt id, ждёт строку
|
||
// резервации по первичному ключу, уже держа лок баланса, а конкурентный Settle той же резервации
|
||
// держит строку и хочет баланс. С lockBalance первым в ОБОИХ цикл не складывается.
|
||
// Замерено приёмкой: с инверсией 2 взаимоблокировки на 150 раундов, с фиксом — 0.
|
||
func TestHoldAndSettleOnTheSameAttemptDoNotDeadlock(t *testing.T) {
|
||
s, ctx := testDB(t)
|
||
seedUser(t, s, ctx, "u1")
|
||
exec(t, s, ctx, `insert into books (id, owner_id, title, source_lang, target_lang, status, workdir, engine_book_id)
|
||
values ('bk1','u1','x','zh','ru','not_started','/srv/books/bk1','x')`)
|
||
now := time.Now().UTC()
|
||
if _, err := s.Grant(ctx, "u1", 100000*money.PerUSD, "admin", "g", "", now); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
var mu sync.Mutex
|
||
var deadlocks int
|
||
note := func(err error) {
|
||
if err != nil && strings.Contains(err.Error(), "deadlock") {
|
||
mu.Lock()
|
||
deadlocks++
|
||
mu.Unlock()
|
||
}
|
||
}
|
||
for i := range 150 {
|
||
id := fmt.Sprintf("run-%d", i)
|
||
if err := s.Hold(ctx, "u1", "bk1", id, money.PerUSD, now); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
var wg sync.WaitGroup
|
||
wg.Add(2)
|
||
go func() { defer wg.Done(); note(s.Settle(ctx, id, money.PerUSD/2, now)) }()
|
||
go func() { defer wg.Done(); note(s.Hold(ctx, "u1", "bk1", id, money.PerUSD, now)) }()
|
||
wg.Wait()
|
||
}
|
||
a, err := s.ReadAccount(ctx, "u1")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if a.Balance != a.LedgerSum {
|
||
t.Fatalf("кэш разъехался с леджером: %s против %s", a.Balance.USD(), a.LedgerSum.USD())
|
||
}
|
||
if deadlocks > 0 {
|
||
t.Fatalf("%d взаимоблокировок на 150 раундов", deadlocks)
|
||
}
|
||
}
|
||
```
|
||
|
||
**Что зона обязана сделать до следующего лендинга** (порядок — мой). ✅ **Все восемь отработаны в
|
||
P2 в этом порядке; расхождения с формулировкой пунктов 5 и 7 — в разделе «Сессия P2» выше.**
|
||
|
||
1. PD-46 — тест на ЗНАЧЕНИЯ `DefaultTimeouts()`. Это буквально та дыра, в которой PD-2 прожил P0.
|
||
2. PD-58 — обосновать 14 суток/90 суток письменно (ASVS 7.1.1 требует именно документа, а не
|
||
значения), заодно 7.1.2 и 7.1.3. Самый дешёвый пункт списка и единственное несоответствие
|
||
базовой линии, которую зона объявила себе сама.
|
||
3. PD-48, PD-49, PD-47 — три пина к трём «закрытым» строкам регистра.
|
||
4. PD-52 — регрессионный тест порядка блокировок (готовый выше).
|
||
5. PD-51 — привести §12 и таблицу пинов к тому, что делает `net/http`; решить судьбу
|
||
`ClearReadDeadline` (оставить как страховку — законно, но с честным комментарием).
|
||
6. PD-54 — снять устаревшую подписочную форму `/v0/usage` из журнала, пока S3 её не прочитал.
|
||
7. PD-55 — `MemoryMax`/`TasksMax` в юните: либо потолок для платформы отдельно от детей
|
||
(`Slice=`/отдельный юнит), либо честный комментарий, что потолок общий на прогоны.
|
||
8. PD-57 — решение по mix-up принять ЯВНО и до второго провайдера: чтение `iss` авторизационного
|
||
ответа (норма) либо раздельные redirect URI (допустимая альтернатива).
|
||
|
||
**Что НЕ блокирует лендинг, но блокирует первый реальный деплой:** PD-58 (без документа сроков зона
|
||
не соответствует линии, которую сама объявила), PD-55 (потолок памяти общий на платформу и все
|
||
прогоны — OOM выберет движок с эксклюзивным локом). PD-57 — до второго провайдера.
|
||
|
||
**Спека (мои решения как владельца контракта):** `/auth/*` — в компаньон, в `openapi.yaml` не
|
||
тащим (согласен: механика сессии, не контрактная поверхность). `X-TM-Client` и кредитная форма
|
||
`GET /v0/usage` — уже в списке правок промта S3, отдельного действия зоне не нужно.
|
||
|
||
> ⚠⚠ **ВСЯ СЕКЦИЯ НИЖЕ SUPERSEDED — D39.106 (05.08), эррата приёмки №15 от 07.08.** Её вывод «канал
|
||
> остаётся stdout» снят решением того же дня: движок — **транзиентный systemd-юнит на прогон**,
|
||
> платформа ему **НЕ родитель**, события — **`events.jsonl` в каталоге книги** (append-only,
|
||
> outbox-проекция уже закоммиченных строк SQLite в той же транзакции, что чекпойнт), платформа
|
||
> **тейлит** журнал с курсором `(engine_run_id, seq)`, коммитимым вместе с эффектом. В `research/25`
|
||
> вариант «платформа — родитель + пайп (stdout/fd)» получил **0 голосов из 15**; выделенный fd 3 —
|
||
> 0; stdout/journald как источник событий — 0. D39.106 п.3 дословно: «**PD-59 superseded**; пайп-путь
|
||
> `supervisor.go` P1 = дев-режим; ответ PD-13 переезжает на cgroup юнита прогона». Читать текст ниже
|
||
> как историю аргументации, не как действующее решение; носители работы — строка 103 единого бэклога
|
||
> (эмиттер) и строки 138/139 (реконсилятор · пиннинг версии). **Приёмка №15 этот supersede при
|
||
> лендинге пропустила и в первой редакции PD-95 сама сослалась на снятый PD-59 — исправлено
|
||
> эрратой; SIGPIPE-замер зоны остаётся верным фактом, но он больше не несущий, потому что родителя
|
||
> у движка в проде нет.**
|
||
|
||
**Транспорт потока событий (вопрос зоны №4): диагноз ПРИНЯТ, переезд канала ОТКЛОНЁН. PD-59,
|
||
PD-60, PD-61.**
|
||
|
||
Этот пункт переписывался четырежды. Три первые редакции спорили о том, чьи прецеденты лучше, — это
|
||
не инженерный аргумент. Четвёртая получена методом владельца: задача сформулирована АБСТРАКТНО (два
|
||
Go-сервиса, родитель читает NDJSON у ребёнка, никакого контекста репозитория и никакого намёка на
|
||
мою позицию) и отдана двум независимым чистым агентам — одному с доступом в сеть, другому только со
|
||
своими знаниями. **По самому каналу они разошлись** (сетевой — переезжать, офлайновый — остаться), и
|
||
именно поэтому упражнение оказалось полезным: ценность не в их вердикте, а в том, на чём они сошлись
|
||
НЕЗАВИСИМО и чего не было ни в записке зоны, ни в трёх моих редакциях.
|
||
|
||
**Сошлись на трёх вещах, и первая решает вопрос.**
|
||
|
||
1. **SIGPIPE зависит от НОМЕРА дескриптора.** `os/signal`: обрыв пайпа на fd 1 или 2 убивает
|
||
программу сигналом; на любом другом дескрипторе запись просто возвращает `EPIPE`. **Замерено
|
||
мной, не со слов:** поток на fd 1 — ребёнок убит `broken pipe`; на fd 3 — `write` вернул EPIPE и
|
||
процесс спокойно доработал до конца. Для нас это не сноска, а деньги: сегодня падение платформы
|
||
убивает движок на следующей же записи события; после переезда движок стал бы сиротой и часами жёг
|
||
бы оплаченные вызовы, пока холд висит в леджере и закрыть его некому. То есть stdout даёт нам
|
||
бесплатную остановку сироты, а предложенный переезд её ЛОМАЕТ. Свойство несущее и до сих пор
|
||
нигде не записано.
|
||
2. **Настоящая защита — не выбор канала, а перехват на уровне дескриптора:** `dup(1)` в приватный fd,
|
||
затем `dup3(2,1,0)`, в `main` движка. Он работает при ЛЮБОМ канале и герметичен там, где
|
||
предложенный мной ранее `os.Stdout = os.Stderr` дыряв: переживает `var out = os.Stdout`,
|
||
захваченный зависимостью, cgo и унаследованный fd 1 у внуков. Направлять fd 1 в `/dev/null` не
|
||
надо — пусть посторонние записи видны в человеческом логе прогона.
|
||
3. **Дискриминатор, при котором переезд был бы прав:** ребёнок исполняет чужой код, наследующий
|
||
stdio (хуки, плагины, шелл-аут). Это ровно мотив `dpkg --status-fd`. **Проверено: у нас нет** —
|
||
в `backend/` нет ни одного `exec.Command` вне тестов и нет cgo.
|
||
|
||
Отсюда и вывод: обсуждали не тот вопрос. Канал остаётся stdout — теперь не «потому что переезд не
|
||
окупается», а потому что переезд активно ухудшает поведение при падении платформы.
|
||
|
||
**Диагноз зоны верен и не оспаривается.** stdout — общий ресурс процесса; один посторонний
|
||
`Println` в движке или в его зависимости ломает протокол, и защищает от этого правило в
|
||
`research/23 §2`, а не механизм.
|
||
|
||
**Прецеденты зоны не держат, но сильные существуют — и их мотив к нам не переносится.**
|
||
`hashicorp/go-plugin` уходит на сокет ради ДВУНАПРАВЛЕННОГО RPC (хендшейк он как раз держит в
|
||
stdout); `runc --console-socket` — про передачу ДЕСКРИПТОРА pty через SCM_RIGHTS. Канонические
|
||
прецеденты паттерна — другие: **dpkg `--status-fd n`** («Send machine-readable package status and
|
||
progress information to file descriptor _n_», man dpkg(1)), **gpg `--status-fd`**, **systemd
|
||
`NOTIFY_SOCKET`**. Но во всех трёх stdout ЗАНЯТ полезной нагрузкой — выводом операции, шифротекстом,
|
||
выводом сервиса, — и статус выселяют потому, что ему негде жить. У нас платформа даёт `tmctl`
|
||
выделенный пайп через `cmd.StdoutPipe()`; на этот stdout не претендует никто. Мотива нет.
|
||
|
||
**Что говорит дока Go о качестве такого решения: ничего.** `os/exec.ExtraFiles` — четыре строки
|
||
доккоммента, из качества ровно одно: «not supported on Windows». Жизненный цикл пайпа на
|
||
вызывающем. **Цена ошибки замерена:** не закрыл родительскую копию пишущего конца после `Start()` —
|
||
EOF не приходит НИКОГДА, читатель висит на давно завершённом прогоне (`StdoutPipe` закрывает сам).
|
||
Идиоматичный Go для «родитель читает поток ребёнка» — `StdoutPipe`, а `ExtraFiles` — нишевый
|
||
механизм socket-activation и контейнерной обвязки.
|
||
|
||
**Изъян нашёлся и в фолбэке, который приёмка предлагала ранее.** Безусловный
|
||
`os.Stdout = os.Stderr` в `main` движка сломал бы `tmctl status --json`: ре-синк читает именно
|
||
stdout (`supervisor.go:145`, `cmd.Output()`). Guard обязан жить в области ТОЛЬКО потоковой команды.
|
||
|
||
**Решение: канал не меняем.** Переезд покупает защиту от класса, который в нашем процессе почти
|
||
пуст (cgo в движке нет, детей на потоковом пути он не спавнит), а стоит: изменение CLI движка —
|
||
то есть запрос через шов, — ручной жизненный цикл пайпа с измеренным режимом вечного зависания,
|
||
Windows вне игры и неидиоматичный для Go паттерн. По норме владельца «механизм строится только
|
||
там, где несёт качество/деньги, — не ради галочки» это механизм ради галочки.
|
||
|
||
**Строка 103 единого бэклога заводится так:**
|
||
|
||
- канал остаётся **stdout** — из-за SIGPIPE-семантики, которая нам служит;
|
||
- защита — **перехват на уровне дескриптора** в `main` движка, в области потоковой команды
|
||
(безусловный вариант сломал бы `tmctl status --json`: ре-синк читает stdout, `supervisor.go:145`);
|
||
- вместе с эмиттером задаются **сброс буфера на всех путях выхода** и **политика обратного
|
||
давления** — PD-61 и PD-60; оба свойства дешевле назначить до постройки, чем мигрировать после;
|
||
- переезд на `--events-fd` пересматривается по названным заранее триггерам: появление cgo в движке,
|
||
спавн им собственных детей на потоковом пути, реальный инцидент порчи потока. Если когда-нибудь
|
||
понадобится «платформа перезапустилась, прогон продолжается» — ответ не сокет, а журнал файлом с
|
||
чекпойнтом смещения (PD-61).
|
||
|
||
Побочно упражнение проверило нас: ловушку `bufio.Scanner` (переполнение строки читается как чистый
|
||
EOF, поток молча обрывается) оба агента назвали самым вероятным латентным багом такой системы —
|
||
у нас она закрыта, `Buffer` поднят до 1 МиБ и `sc.Err()` проверяется (`decoder.go:45,115`).
|
||
|
||
## Ратификация приёмкой (оркестратор №14, 04.08)
|
||
|
||
**Вердикт: P0 ПРИНЯТ, заленден `eeeef89`.** Метод: батарея пере-прогнана мной (офлайн зелёная; с живым
|
||
PostgreSQL 18.4, поднятым без root, все три БД-гейченных теста зелёные — 6/6 констрейнтов сработали
|
||
поимённо) · живые пробы бинаря (healthz 200 без БД · readyz 503 честно · Bearer без стора → 401, не
|
||
паника · неизвестный путь под `/v0` → 401 раньше 404 · CSRF: cookie-POST без `X-TM-Client` 403,
|
||
cross-site 403, Bearer-POST не требует заголовка · SIGTERM → «shutting down» и чистый выход) ·
|
||
**три СВОИ мутации** в несущие свойства (см. ниже) · адверсариальный воркфлоу пяти линз со
|
||
скептик-пассом (линзы анти-следовые: мнение до чтения отчёта, поиск вне его карты).
|
||
|
||
**Что подтверждено исполнением:** зонная дисциплина (в коммите только `platform/*`, чужого нет) ·
|
||
модуль-sibling, `backend/internal` не импортируется, `go.work` не заведён · живой SQLite движка нигде
|
||
не открывается · event-sourcing не построен (high-water mark) · деньги отсутствуют на проводе и в
|
||
INFO-логах (stderr движка уходит в ФАЙЛ попытки — верно) · ПТ-34-заголовки мидлварью на всём, не
|
||
дисциплиной хендлера · имена полей `status --json` сверены 1:1 с `pipeline/status.go:37-130` ·
|
||
экзит-коды сверены с `cmd/tmctl/main.go:30-52` · языко-агностичность (пар-литералов нет) ·
|
||
пин линтера идентичен движковому.
|
||
|
||
**Мои мутации (author≠reviewer):** (1) `Digest` возвращает плейнтекст вместо SHA-256 — **тесты
|
||
ВЫЖИЛИ** ⇒ свойство «в БД только хеш» истинно, но НЕ запинено (тест сверяет через ту же функцию);
|
||
строка PD-1. (2) Снятие требования `X-TM-Client` — тест упал поимённо ✓. (3) Ослабление констрейнта
|
||
`units_translated_has_text` до `check (true)` — тест упал поимённо ✓. Дерево после мутаций
|
||
восстановлено байт-в-байт (sha256-сверка).
|
||
|
||
**Главная находка приёмки — PD-2, ЖИВАЯ уязвимость, а не латентная.** Отсутствие `ReadTimeout`
|
||
позволяет пиннить соединения СЕГОДНЯ, без единой body-принимающей ручки: `net/http` дренирует
|
||
непрочитанное тело <256 КБ внутри `chunkWriter.writeHeader` ДО отправки заголовка ответа, и это
|
||
чтение наследует отсутствующий дедлайн. Репродуцировано мной на собранном бинаре (50 полу-кормленных
|
||
POST: сервер залогировал 50×401 `ms:0`, клиенты получили ноль байт, fd 7→57 до закрытия КЛИЕНТОМ);
|
||
скептик независимо пинил 500. Фикс — одна строка; **гейт: закрыть первым шагом P1**.
|
||
|
||
**Ратификация дизайн-ответов — все четыре ПРИНЯТЫ, каждый с обязательной поправкой:**
|
||
|
||
- **К-4 (ревизия пер-книжная, кадр SSE = `books.revision`) — ПРИНЯТ + три поправки.** (а) Посылка
|
||
«единственный писатель» неверна: HTTP-хендлеры тоже пишут книго-скоупное состояние (подпись банка
|
||
обязана вернуть бампнутую ревизию). Нормативный механизм — не «единственность писателя», а
|
||
**блокировка строки книги, удерживаемая до коммита** (`update books set revision = revision + 1`
|
||
сериализует и бамп, и порядок коммитов); материализатор и хендлеры обязаны ходить через неё.
|
||
(б) Одна транзакция = одна ревизия, но НЕСКОЛЬКО кадров: докачка по `Last-Event-ID` обязана читать
|
||
`revision >= R` (дельта-чтения идемпотентны), иначе теряются кадры-братья транзакции R. (в) Дельта-
|
||
чтение не выражает УДАЛЕНИЯ (банк переписывается целиком, пере-чанковка заменяет главы/юниты) ⇒
|
||
после любой транзакции-замены сервер обязан выдать `resync_required` (событие в контракте есть).
|
||
- **К-7 (keyset-курсор на всех списках, `next_cursor` всегда) — ПРИНЯТ + две поправки.** (а) Курсор
|
||
НЕ привязывать к `books.revision`: тот бампается на каждой материализации, и на книге 5000+ глав
|
||
правило «сменилась ревизия — начни цикл заново» даёт вечный рестарт пагинации во время прогона.
|
||
Привязка — к СТРУКТУРНОЙ эпохе (поколение манифеста / `books.chunker_version`), которая меняется
|
||
только при (пере-)разборе. (б) Отклонение протухшего курсора — обязанность СЕРВЕРА (MUST), не
|
||
клиента: курсор непрозрачен, клиент не может её исполнить. Ключи сортировки для банка и замечаний
|
||
назвать при правке спеки (для глав — `(book_id, number)`).
|
||
- **К-12 (опрос с `Retry-After`, 202+`Location`) — ПРИНЯТ.** Аргумент структурный и верен: поток
|
||
привязан к прогону, экспорт делают с законченной книги. Поправка формы: `Retry-After` на 200
|
||
стандартом не определён (RFC 9110 — 503 и 3xx), поэтому в спеке объявить его ЯВНЫМ заголовком
|
||
этого ответа, а не полагаться на общую семантику.
|
||
- **П-5 (`GET /v0/usage` статусом, `Run.paused_reason`, потолок поднимает платформа) — ПРИНЯТ +
|
||
три поправки.** Несущий клейм ПЕРЕПРОВЕРЕН мной по коду и подтверждён: `Ceilings` объявлены
|
||
(`backend/internal/config/book.go:106`), в канон `BriefHash` НЕ входят (`:280-297`, доккоммент
|
||
`:264` «wiring fields … deliberately excluded»), и ни один другой хеш их не сворачивает (снапшот
|
||
волны `pipeline/snapshot.go`, кортеж чекпойнта `stagerun.go:406-412`) ⇒ поднятие потолка не двигает
|
||
снапшот и не вызывает ре-билл. Поправки: (а) `Run.paused_reason` не имеет колонки — завести в схеме
|
||
(собственный мандат сессии: у каждого поля ответа есть источник); (б) `revision` в ответе usage не
|
||
имеет скоупа — назвать счётчик пользователя либо убрать поле; (в) формулировка «поток денег не
|
||
несёт и НЕ ДОЛЖЕН» подана как следствие D39.84 — это не так: D39.84 запрещает суммы на
|
||
ПОЛЬЗОВАТЕЛЬСКОМ проводе, экране и в INFO-логах, а внутренний поток движок→платформа в приватную
|
||
таблицу — другая поверхность. Вопрос «нести ли версионированное денежное событие в словаре строки
|
||
103» ОТКРЫТ (см. ниже), а не закрыт.
|
||
- **Три находки сессии подтверждены и маршрутизированы:** движковый `run_id` в кадре `hello`
|
||
(иначе resume отбрасывается high-water mark'ом) — в строку 103 единого бэклога; статусы до прогона
|
||
и библиотека без живого канала — правка спеки (S3); `X-TM-Client` в спеку — туда же, с уточнением
|
||
«значение любое, несущей является ПРИСУТСТВИЕ заголовка».
|
||
|
||
**Открытый канон-вопрос, поднятый приёмкой (нужно слово владельца/решение оркестратора):
|
||
у платформы нет санкционированного источника денег ВО ВРЕМЯ попытки.** Движок держит эксклюзивный
|
||
лок (`status --json` физически недоступен), его stderr-INFO с ценами парсить запрещено (анти-паттерн
|
||
research/23 §2 + запрет INFO-денег), а словарь строки 103 денег не несёт. Следствие: пер-пользовательские
|
||
окна отстают на целую попытку (часы), и единственный он-лайн-гард — пер-книжные потолки, которые
|
||
платформа обязана ставить консервативно. Варианты — версионированное денежное событие в словаре 103 ·
|
||
консервативная политика потолков · нарезка попыток — в ресёрч-пакете направления «биллинг».
|
||
|
||
## Дизайн-ответы на ратификацию
|
||
|
||
### К-4 — ревизия: пер-ресурсная со скоупом КНИГА; чтения её несут
|
||
|
||
Обе половины вопроса:
|
||
|
||
1. **Чтения ревизию несут — да.** Без неё правило «отбрось чтение старше уже применённого
|
||
события» нечем реализовать, а рефетч по возврату фокуса окна включён у `react-query` по
|
||
умолчанию — то есть гонка на каждое переключение вкладки, а не редкий случай.
|
||
2. **Счётчик — один на КНИГУ.** Все книго-скоупные чтения (карточка, главы, юниты, замечания,
|
||
банк, прогон) возвращают ОДНО и то же число — `books.revision`, +1 за материализующую
|
||
транзакцию; строки, которых она коснулась, штампуются новым значением. `id` SSE-кадра прогона —
|
||
ТО ЖЕ число, поэтому события и чтения книги полностью упорядочены между собой.
|
||
|
||
Почему книга, а не сквозной счётчик:
|
||
|
||
- сравнивать ревизии осмысленно только внутри скоупа, а книга — минимальный скоуп, в котором
|
||
лежит всё, что поток может протухнуть;
|
||
- единственный писатель на книгу уже гарантирован (сериализация очереди по `book_id` — П-3, плюс
|
||
EXCLUSIVE-лок движка на файл проекта), поэтому счётчику не нужны ни блокировка, ни глобальная
|
||
последовательность;
|
||
- глобальный счётчик отвергнут по двум причинам: одна горячая последовательность на всех
|
||
пользователей и утечка — по разрывам номеров любой клиент оценивает активность всей платформы;
|
||
- у библиотеки (`GET /books`) свой скоуп — счётчик на пользователя (`users.library_revision`),
|
||
потому что она охватывает книги.
|
||
|
||
Просим внести в спеку прозой: (i) `revision` монотонна В ПРЕДЕЛАХ скоупа ресурса и между скоупами
|
||
не сравнивается; (ii) кадр потока и книго-скоупные чтения несут ОДИН счётчик; (iii) отбрасывание
|
||
устаревшего чтения — обязанность клиента.
|
||
|
||
Побочная выгода: тот же штамп даёт докачку потока «строки книги с `revision > X`» БЕЗ журнала
|
||
событий — реплей истории остаётся запрещённым (D39.85, контракт §2.11).
|
||
|
||
### К-7 — курсор на каждом списке, дефолт «одна страница»
|
||
|
||
Замер (сериализация фикстур контрактной формы, случайные значения — не повторяющиеся, иначе gzip
|
||
льстит): 2284 главы = **289 КБ JSON / 46 КБ gzip**; 1200 терминов = **229 КБ / 40 КБ**. Одним
|
||
ответом влезает — но китайские вебновеллы на 5000+ глав норма, а банк растёт вместе с книгой,
|
||
поэтому «всегда одним ответом» — это отложенное молчаливое обрезание.
|
||
|
||
Предложение: **keyset-курсор на КАЖДОМ списочном ответе**, параметры `?limit=&cursor=`, поле
|
||
`next_cursor: string|null` присутствует ВСЕГДА. Дефолты: главы 5000 (обычная книга = одна
|
||
страница), банк 1000, замечания 500; юниты и библиотека курсор тоже несут, хотя практически не
|
||
пагинируются.
|
||
|
||
- **Keyset, не offset:** материализатор пишет параллельно чтению, а offset на пишущейся таблице
|
||
пропускает и дублирует строки; keyset по `(book_id, number)` устойчив к дозаписи.
|
||
- **Поле с первого дня у всех списков — намеренно.** Добавить его позже — минорное изменение,
|
||
которое у клиента, его не читающего, молча отрезает хвост.
|
||
- Курсор непрозрачный, кодирует последний ключ сортировки и `revision`; смена `revision` между
|
||
страницами обязывает клиента начать цикл заново, иначе он склеит два состояния.
|
||
|
||
### К-12 — опрос; причина структурная, а не вкусовая
|
||
|
||
Единственный поток контракта привязан к ПРОГОНУ, а экспорт делают с законченной книги — живого
|
||
прогона обычно нет. Пуш завершения потребовал бы второго потока ради одного булева.
|
||
|
||
Предложение — индустриальный async request-reply: `POST /books/{id}/exports` → `202` +
|
||
`Location`; `GET /books/{id}/exports/{id}` → `200` c `ready:false` и заголовком `Retry-After`, пока
|
||
строится, и `ready:true` + `url`, когда готов. Интервал называет СЕРВЕР, клиент не угадывает.
|
||
Просим добавить `Retry-After` в спеку. Появится пользовательский поток (вопрос 2 выше) — пуш
|
||
поедет им, опрос останется фолбэком.
|
||
|
||
### П-5 — форма API лимитов/использования
|
||
|
||
> ⚠ **Подписочная форма ответа УДАЛЕНА отсюда (PD-54, закрыт в P2).** Она несла окна, `resets_at`
|
||
> и `usage_windows` и отменена решением владельца 05.08 «не подписки, а баланс»; таблицу
|
||
> `usage_windows` снесла миграция `00006`. Баннера было мало: S3 идёт в журнал ЗА ФОРМОЙ ручки и
|
||
> скопировал бы тело, а не баннер. **Действующая форма одна — раздел «Что предлагаем в спеку (S3)»
|
||
> выше.** Ниже осталось то, что от модели денег не зависит.
|
||
|
||
- **Сумм нет ни в каком виде.** Процент — статус использования, а не деньги (D39.84 в силе,
|
||
механика «как Claude Code» — D39.100/ПТ-35).
|
||
- **Стоп по потолку:** `BookStatus: paused` + машинная причина, `Run.paused_reason`: фразу
|
||
(«перевод остановлен: кредит исчерпан») рисует клиент словами владельца (В-3), API несёт
|
||
состояние. Без поля причины второй повод для паузы станет ломающим изменением.
|
||
- **Источник цифр.** Поток событий денег не несёт и не должен (кадр `ceiling` — только факт),
|
||
поэтому платформа метрит из `tmctl status --json` (`committed_usd`) на границах попыток и на
|
||
ре-синке; хранит целыми микро-долларами в леджере (`credit_ledger`, миграция `00007`).
|
||
> ⚠ **АБЗАЦ НИЖЕ SUPERSEDED — D39.110 п.2(б) (07.08).** «Платформа владеет `book.yaml` и правит
|
||
> `ceilings.book_usd`» отменено: потолок принадлежит ПРОГОНУ и едет аргументом прогона (движку это
|
||
> ещё предстоит уметь — строка 145 единого бэклога), а не постоянной записью в конфиге книги, иначе
|
||
> пользовательское число оседает в данных движка против D39.81/D39.85. И это НЕ «политика платформы,
|
||
> не кнопка на экране»: владелец 07.08 решил обратное — управляемая шкала В ИНТЕРФЕЙСЕ, от минимума
|
||
> до доступного остатка, в ГЛАВАХ (строка 126). Верным в абзаце остаётся только проверенный факт:
|
||
> `Ceilings` в `BriefHash` не входит, поэтому смена потолка не двигает снапшот и не вызывает ре-билл.
|
||
> ⚠ Помечено при повторной верификации: эррата 07.08 применила правило «грепать зонный вывод на
|
||
> supersede» к транспорту и пропустила его здесь же, на потолке.
|
||
|
||
- **Поднятие потолка — политика платформы, не кнопка на экране.** Платформа сама владеет
|
||
`book.yaml`, поднимает `ceilings.book_usd` и перезапускает прогон. **Проверено кодом, что это
|
||
безопасно:** `Ceilings` объявлен в `backend/internal/config/book.go:106`, а в канон `BriefHash`
|
||
(`:280-297`) НЕ входит — значит поднятие потолка не двигает `brief_hash` → снапшот и не вызывает
|
||
ни дрифт, ни ре-билл. Риск «подняли лимит — переплатили книгу заново» снят фактом, не надеждой.
|
||
|
||
## Что построено (P1)
|
||
|
||
| Кусок | Где | Проверено ИСПОЛНЕНИЕМ |
|
||
|---|---|---|
|
||
| Конструкция сервера вынесена из `main` | `internal/httpapi/serve.go` | Тесты гоняют РЕАЛЬНЫЙ `http.Server` на loopback-порту; без этого PD-2 и PD-9 не видит ни один тест на mux под `httptest` |
|
||
| `ReadTimeout` + `LimitBody` (PD-2) | `serve.go`, `middleware.go` | Живая проба на бинаре: полу-кормленный POST отпускается на `ReadTimeout` (30.0 с) |
|
||
| Поток переживает `ReadTimeout` | `serve.go` (без вспомогательной функции) | `net/http` снимает дедлайн сам; помощник `ClearReadDeadline` УДАЛЁН — на полу-кормленном запросе он воспроизводил PD-2 (PD-63) |
|
||
| Дренаж по SIGTERM (PD-9) | `serve.go` | Тест: ctx-aware хендлер в полёте доигрывает и отдаёт 200 |
|
||
| Вход через OIDC (П-6) | `internal/login/` | Полный флоу против НАСТОЯЩЕГО OIDC-издателя, поднятого в тесте: discovery, JWKS, RS256-подпись, реальная проверка PKCE на токен-эндпоинте. 6 негативных сценариев (чужой nonce, чужая audience, протухший токен, подмена state, отсутствие куки, реплей) |
|
||
| Модель аккаунта | `migrations/00001`, `pgstore/identity.go` | Живой PG: три личности с одним адресом дают три аккаунта; неподтверждённый адрес не поднимается на аккаунт; state одноразовый и истекает |
|
||
| Кредитный леджер (П-7) | `migrations/00007_credits.sql`, `pgstore/credits.go` | Живой PG: инвариант `balance == SUM(ledger)` после каждого шага grant→hold→settle→release; повторный ключ — no-op; холд сверх баланса, чужая книга и повтор attempt-id отказаны |
|
||
| Деньги как тип | `internal/money/` | `big.Rat`, округление к `+∞`, синтаксис ограничен регуляркой и длиной (`big.Rat` иначе принимает `0x10` и `1/3`) |
|
||
| Админ-CLI (П-8) | `cmd/tmplatformctl/` | Живая проба против живой БД: гранты, баланс с открытыми холдами, журнал входов, отзыв сессий |
|
||
| Фаззинг декодера | `internal/ingest/fuzz_test.go` | Оракулы — инварианты PD-10; `make fuzz` для углублённого прогона |
|
||
| Остановка прогона (PD-12/13/20) | `internal/ingest/` | Тест с настоящим процессом: сбой синка завершает прогон; сигнал повторяется до подтверждения |
|
||
| Деплой-юнит (PD-13) | `deploy/tmplatformd.service` | `systemd-analyze verify` — exit 0. ⚠ Под systemd не запускался (нет sudo) |
|
||
|
||
### Какой тест что пинит (мандат приёмки §3.3)
|
||
|
||
| Свойство несущего пути | Пинящий тест | Посадка, которую он ловит |
|
||
|---|---|---|
|
||
| В БД только SHA-256 токена | `pgstore.TestStoredCredentialIsAHashNotTheToken` | `Digest` возвращает плейнтекст (посадка приёмки P0 — теперь падает) |
|
||
| Соединение нельзя запиннить | `httpapi.TestHalfFedRequestIsDroppedByTheServer` + `TestTheServerTheDaemonRunsHasEveryDeadlineSet` | Снять любой дедлайн из `serverWithTimeouts`; обнулить `DefaultTimeouts().Read` или `.Idle`; добавить `WriteTimeout` (PD-46). Проводка демона больше не проверяется, а СДЕЛАНА невозможной: `NewServer` не принимает `Timeouts` (PD-66) |
|
||
| Поток переживает `Read` без действий хендлера | `httpapi.TestStreamOutlivesReadTimeout` | Снять `Unwrap` (тогда `Flush` не дотягивается до соединения — проверяется ошибка `Flush`, а не игнорируется) |
|
||
| Полу-кормленный СТРИМИНГОВЫЙ запрос всё равно отпускается | `httpapi.TestHalfFedStreamingRequestIsCutLoose` | Снять `ReadTimeout`; вернуть снятие дедлайна в хендлер (PD-63) |
|
||
| SIGTERM дренирует, а не рубит | `httpapi.TestShutdownDrainsInFlightRequests` | `BaseContext` = сигнальный ctx |
|
||
| Idle-истёкшая сессия не воскресает | `pgstore.TestTouchCannotResurrectAnIdleExpiredSession` | Убрать клаузу `idle_expires_at` из `Touch` |
|
||
| Поток идентифицирован и монотонен | `ingest.TestHandshakeMustIdentifyTheStream` + `FuzzDecoder` | Пустой `engine_run_id`, `seq` хендшейка ≠ 1, hello в середине |
|
||
| Деньги не дрейфуют | `ingest.TestSpendConvertsExactlyAndRoundsUp`, `money.TestParseUSDIsExactAndRoundsAwayFromZero` | float64 + умножение; округление к ближайшему |
|
||
| Баланс = сумма леджера | `pgstore.TestCreditLifecycleKeepsTheCacheEqualToTheLedger` | Писать кэш вне транзакции леджера |
|
||
| Повторный грант не кредитует дважды | `pgstore.TestGrantIsIdempotentBySource` | Снять `on conflict` / вынести обновление баланса из ветки «вставилось» |
|
||
| Холд защищает баланс | `pgstore.TestHoldRefusesMoreThanTheBalance` | Убрать проверку баланса. ⚠ `for update` этим тестом НЕ ловится (последовательный тест лока не видит) — он запинен строкой ниже |
|
||
| Почта не связывает аккаунты | `pgstore.TestIdentityNeverJoinsAccountsByEmail` | Резолв аккаунта по адресу; уникальный индекс на `users.email` |
|
||
| Вход даёт НАШУ сессию и убивает прежнюю | `login.TestLoginCompletesAndCreatesOurOwnSession`, `TestLoginRevokesThePresentedSession` | Не отзывать предъявленную сессию (фиксация сессии) |
|
||
| PKCE и nonce реально проверяются | `login.TestLoginCompletesAndCreatesOurOwnSession`, `TestCallbackRefusals` | Снять `S256ChallengeOption`; не сравнивать nonce |
|
||
| `return_to` не уводит с сайта | `login.TestReturnToNeverLeavesThisSite` + `FuzzSafeReturnTo` | Ослабить до `HasPrefix("/")`; снять второй декод; снять класс символов; снять protocol-relative. Фаззер судит независимым оракулом — `ResolveReference` против базового URL сайта (PD-47) |
|
||
| State одноразовый под КОНКУРЕНЦИЕЙ | `pgstore.TestOnlyOneRacingCallbackCanConsumeAState` (+ последовательные `login.TestStateCannotBeReplayed`, `pgstore.TestLoginStateIsSingleUseAndExpires`) | Разбить `DELETE ... RETURNING` на SELECT и DELETE — последовательные тесты этого не видят, гоночный ловит (3 колбэка из 4 съедали один state) |
|
||
| Прогон не переживает свой синк | `ingest.TestFailingSinkStopsTheRun` | Убрать `stop()` после сбоя `Ingest`; убрать повтор сигнала |
|
||
| Request-id не берётся у клиента | `reqid.TestRequestIDIsNeverTakenFromTheCaller` | Читать `X-Request-Id` из запроса |
|
||
| Секреты можно подать файлом | `config.TestSecretsCanComeFromFiles` | Читать только переменную окружения |
|
||
| Грант только подтверждённой личности | `login.TestSignupGrantGoesOnlyToAVerifiedIdentity` | Убрать условие `EmailVerified` (PD-48) |
|
||
| State не redeem-ится у другого провайдера | `login.TestStateFromAnotherProviderIsRefused` | Убрать сверку `st.Provider` (PD-49) |
|
||
| `iss` авторизационного ответа проверяется | `login.TestAuthorizationResponseIssuerIsChecked` | Убрать вызов `checkIssuer` или любую из его двух веток. ⚠ Потерю `issuer` в СТОРЕ ловит не он, а `pgstore.TestLoginStateIsSingleUseAndExpires` (сравнение структурой) — атрибуция важна ровно по причине PD-46 |
|
||
| Состояние входа переживает стор ЦЕЛИКОМ | `pgstore.TestLoginStateIsSingleUseAndExpires` | Потерять любое поле `login.State` при записи или чтении — сравнение структурой, а не тремя полями (PD-62) |
|
||
| Порядок блокировок один во всех денежных путях | `pgstore.TestHoldAndSettleOnTheSameAttemptDoNotDeadlock` | Убрать `lockBalance` из `closeReservation` — 5 падений из 5 (PD-52) |
|
||
| Кука не участвует, если есть `Authorization` | `auth.TestAnAuthorizationHeaderTakesTheCookieOutOfPlay` | Падать обратно на куку при неразобранном заголовке (PD-50) |
|
||
| Лимит тела: вложение только УЖЕСТОЧАЕТ | `httpapi.TestBodyCapIsPerRouteBecauseNestingOnlyTightens`, `TestDefaultBodyCapStaysAContractSizedNumber` | Раздуть дефолт до аплоуд-размера. ⚠ Возврат ОБЩЕГО внешнего слоя в `New` не ловится ничем: наблюдаемым он станет только когда появится маршрут со своим бо́льшим потолком — до тех пор это открытая строка PD-72, а не обещание |
|
||
| Один ответ на несуществующий аккаунт | `pgstore.TestMoneyOperationsAgreeOnAMissingAccount` | Убрать мапинг констрейнта в `ErrNoAccount` (PD-56) |
|
||
| Сроки сессий в пределах объявленной линии | `config.TestSessionClocksStayWithinTheDeclaredBaseline` | Поднять абсолютный срок выше 30 суток NIST AAL1 (PD-58) |
|
||
| Зависший издатель не держит колбэк | `login.TestAStalledProviderDoesNotHoldTheCallback` | Убрать дедлайн из `identify` — обе ноги, token и keys (PD-65) |
|
||
| Кука браузера скользит вместе со строкой | `auth.TestSlidingTheIdleWindowRefreshesTheBrowsersCookie` | Не переиздавать куку при скольжении; переиздавать её на Bearer-пути (PD-70) |
|
||
| Два прогона не тратят один кредит | `pgstore.TestConcurrentHoldsCannotOvercommitAnAccount` | Убрать `for update` из `lockBalance` — 3 падения из 3, баланс в −$2 (PD-67) |
|
||
| Готовность = схема, а не достижимость | `pgstore.TestReadinessRefusesADatabaseWithoutTheSchema` | Свести `Ready` к `Ping` (PD-68) |
|
||
| Клиент go-oidc ограничен по времени | `login.TestTheDefaultProviderClientIsBounded`, `TestAHungKeyFetchDoesNotPoisonLaterSignIns` | Отдать `New` клиент без таймаута — тогда зависшая загрузка ключей держит и все последующие входы (PD-73) |
|
||
| Скольжение не залипает на потолке | `auth.TestTheSlideStopsOnceItCannotMoveTheDeadline` | Убрать сверку `IdleExpiresAt.Before(AbsoluteExpiresAt)` — каждый запрос последней четверти жизни сессии становится записью (PD-74) |
|
||
| Провайдерский токен не доезжает до стора | `login.TestLoginCompletesAndCreatesOurOwnSession` | Записать в стор что-либо выданное провайдером; ⚠ до P2 эта проверка не могла упасть — поле мока никто не заполнял (PD-76) |
|
||
| Размеры пула из DSN переживают | `pgstore.TestExplicitPoolSizesInTheDSNSurvive` | Вернуть сравнение с дефолтом pgx. ⚠ Случай «пароль содержит имя ключа» ловит поиск подстроки только на машине, где наш дефолт НЕ совпал с дефолтом pgxpool (у него `max(4, NumCPU)`) — на 16-ядерном стенде он ничего не доказывает; несущее свойство даёт разбор через `RuntimeParams`, а не этот вход |
|
||
|
||
## Что построено (P0)
|
||
|
||
| Кусок | Где | Проверено |
|
||
|---|---|---|
|
||
| Модуль, layout, батарея | `go.mod` (sibling движка, гард D39.85 соблюдён), `Makefile`, `.golangci.yml` | `make check` зелёный: build · vet · gofmt · lint 0 issues · `go test -race` |
|
||
| HTTP-скелет | `internal/httpapi/` | Живой запуск: `/healthz` 200, `/readyz` 200 против живого PG, `/v0/*` 401 problem+json, graceful shutdown по SIGTERM |
|
||
| Заголовки ПТ-34 | `internal/httpapi/middleware.go` | Живой ответ несёт `X-Robots-Tag: noindex, nofollow`, `Cache-Control: no-store`, `nosniff`, `no-referrer` |
|
||
| Сессии П-1 | `internal/auth/` + `internal/pgstore/sessions.go` | Тесты: обе презентации → principal, Bearer > cookie, истечение/отзыв/свип, токен в БД не попадает (только SHA-256), скольжение окна только во второй половине |
|
||
| CSRF | `internal/auth/csrf.go` | stdlib `http.CrossOriginProtection` + обязательный `X-TM-Client` на cookie-пути; 6 кейсов тестом + живой пробой (cookie-POST без заголовка → 403, cross-site → 403) |
|
||
| Схема read-model | `internal/pgstore/migrations/` | Миграции применены на ЖИВОМ PostgreSQL 18.4 дважды (идемпотентность), все констрейнты сработали поимённо |
|
||
| Интерфейс NDJSON-ингеста | `internal/ingest/` | Тесты: хендшейк обязателен, мажор отвергается, минор и незнакомый тип толерируются, разрыв/повтор seq ловятся; супервизор проверен НАСТОЯЩИМ процессом (exit 3 → `bank_stop`, stderr движка в файл, поток материализован) |
|
||
| Ре-синк | `internal/ingest/resync.go` | Тест на фикстуре в форме `pipeline.StatusReport` (имена полей сверены по `backend/internal/pipeline/status.go:37-130`, живого прогона не было): аллоулист берёт своё, деньги/снапшоты игнорируются |
|
||
|
||
Не построено намеренно: материализатор `Sink → Postgres` (нужен ратифицированный словарь событий,
|
||
иначе перепишется), контрактные ручки и SSE (П-1 после ратификации К-4/К-7), очередь River (П-3),
|
||
брокер лимитов (П-2).
|
||
|
||
## Находки (грунтованные)
|
||
|
||
1. **Ключ идемпотентности `(run_id, seq)` работает только если `run_id` — ДВИЖКОВЫЙ.** Resume
|
||
поднимает новый процесс, его `seq` стартует с 1; если ключом взять платформенный run, high-water
|
||
mark отбросит весь поток второй попытки. Заведено в схеме: `run_attempts.engine_run_id` (unique)
|
||
+ `last_seq`. Просьба к строке 103: кадр `hello` обязан нести этот id; идеально — вместе со
|
||
строкой 102 (внешний trace-контекст), тогда id назначает платформа и пространство ключей наше.
|
||
2. **Дыра контракта — статусы до прогона и библиотека без канала.** См. вопрос 2 выше.
|
||
3. **Банк: канала нет — но не полностью.** Подтверждаем находку оркестратора и уточняем состав:
|
||
сегодня добываемы (а) ПРЕДЛОЖЕННЫЕ термины стопа — сайдкар/строка 101 и (б) термины, которые
|
||
промотировала сама платформа — она же ПИШЕТ mined-delta и сид. Недобываемы `auto`/`ruby`-строки,
|
||
материализованные внутри движка. То есть `GET /bank` частично реализуем уже сейчас; полностью —
|
||
после артефакта экспорта банка.
|
||
4. **Ре-синк не восстанавливает пофазный прогресс:** в `status --json` разбивки нет (строка 99).
|
||
После обрыва и до следующего события прогресса клиент увидит агрегат. Записано в коде.
|
||
5. **`status --json` — ремонтный путь, не поллинг:** каждый вызов заново ингестит и режет исходник
|
||
(1.4–1.5 с CPU на книге 23 МБ — замер фронт-сессии 02.08, не наш; строка 100).
|
||
6. **Деньги движка живут в его stderr на уровне INFO.** Поэтому супервизор пишет stderr движка в
|
||
ФАЙЛ попытки и не тейлит его в структурный лог платформы — иначе суммы попадут в наш INFO
|
||
(запрет D39.84 + норма P0-промта).
|
||
7. **Мелочи в чужой зоне (не трогали, лендить оркестратору):** в ратифицированной копии
|
||
`docs/architecture/14-api-contract/openapi.yaml` `info.description` всё ещё называет файл
|
||
черновиком S3 и ссылается на `../API_CONTRACT_DRAFT.md`; в README той же папки ссылка
|
||
«нормативная поверхность → `api-contract/openapi.yaml`» бьёт мимо (файл лежит рядом:
|
||
`./openapi.yaml`). Решения D39.100 (`paused`, `eta_seconds`) в YAML ещё не внесены — это работа
|
||
S3; схема платформы их уже держит.
|
||
8. **Стенд:** Postgres как системного пакета нет и sudo нет, поэтому схема проверена на живом
|
||
PostgreSQL 18.4, поднятом БЕЗ root из бинарников zonky в скрэтчпаде (вне репозитория и вне
|
||
зависимостей модуля). Тесты с БД гейтятся `TM_PLATFORM_TEST_DSN` и создают свою базу на прогон.
|
||
|
||
## Диспозиции бэклога зоны
|
||
|
||
| ID | Диспозиция |
|
||
|---|---|
|
||
| П-1 | **ПРОДОЛЖЕНА (P1).** Добавлено: конструкция сервера с таймаутами, дренаж, снятие read-дедлайна для будущего SSE, вход как источник сессий. Осталось прежнее: контрактные ручки, SSE-эндпоинт, материализатор `Sink → Postgres`, воркер. Блокер тот же — словарь событий (строка 103) |
|
||
| П-1 (P0) | **НАЧАТА.** Готово: каркас сессий (схема + мидлварь + CSRF), HTTP-скелет, схема read-model, интерфейс ингеста и ре-синка. Осталось: контрактные ручки, SSE-эндпоинт, материализатор `Sink → Postgres`, воркер. Блокеры: ратификация К-4/К-7 (форма ответов), словарь событий (строка 103) |
|
||
| П-2 | Не трогали — гейт «до второго параллельного пользователя» в силе |
|
||
| П-3 | Не строили. В схеме заведён гард: частичный уникальный индекс «один живой прогон на книгу» (`runs_one_live_per_book`) — то, что очередь обязана соблюдать, теперь отказывает база. River запинен, но в `go.mod` НЕ добавлен |
|
||
| П-4 | **ЗАМЕНЁН П-7.** Черновик `usage_windows` удалён вместе с подписочной моделью (владелец 05.08) |
|
||
| П-5 | **ПЕРЕОПРЕДЕЛЁН.** Окон нет, `resets_at` нет; форма ответа предложена выше («Что предлагаем в спеку»). Ручка НЕ построена: контракт первичен, ждём правки спеки |
|
||
| П-6 | **ЗАКРЫТ (P1).** Вход через OIDC: PKCE + nonce + одноразовый state с привязкой к браузеру, наша серверная сессия, ротация на границе входа, журнал входов, «выйти везде». Токены провайдера не персистятся. Ждёт живого клиента Google (client_id/secret владельца) — код к этому готов, конфигурация проверена отказом на половинчатой настройке |
|
||
| П-7 | **ЗАКРЫТ по схеме и операциям (P1).** Леджер, резервации, кэш баланса, инвариант `balance == SUM(ledger)`, идемпотентность по `(source, source_id)`. Не построено: постановка холда ВОРКЕРОМ перед спавном и передача потолка движку — это часть П-1/П-3, у которых нет воркера |
|
||
| П-8 | **ЗАКРЫТ (P1).** `tmplatformctl grant/balance/logins/revoke` |
|
||
|
||
## Хроника
|
||
|
||
_(записи сессий — сверху новые)_
|
||
|
||
### 08.08.2026 — сессия P3 (платформа №4)
|
||
|
||
Фикс-лист приёмки P2 отработан целиком, восемь пунктов из восьми, в её порядке. Метод прежний:
|
||
посадить дефект на копии зоны вне репозитория, убедиться, что он воспроизводится, починить,
|
||
запинить тестом, который ловит эту посадку поимённо. 22 посадки, 22 поймано.
|
||
|
||
Единственная major (PD-80) закрыта двумя независимыми изменениями: раздельные вёдра лимитера и
|
||
перенос проверки перед очисткой login-куки. Второе важнее первого — именно очистка делала отбитый
|
||
вход невосстановимым.
|
||
|
||
Одна собственная посадка вскрыла ложно-зелёный тест ЭТОЙ же сессии: тест разбора флагов CLI сверял
|
||
только «ошибка непуста», а команда падала на соединении с несуществующей БД, а не на аргументах.
|
||
Две посадки его пережили; тест переписан на сверку сообщения. Это второй раз за две сессии, когда
|
||
ложную зелень находит мутация, а не чтение.
|
||
|
||
PD-91 проверен живым прогоном под пользовательским systemd 259, а не выведен из доки: несуществующий
|
||
`ReadWritePaths` без `-` даёт `226/NAMESPACE`, `ProtectHome=yes` действительно закрывает `/home`,
|
||
названный в юните выход (`tmpfs` + `BindPaths=`) действительно работает.
|
||
|
||
Найдена вторая, непомеченная копия снятого транспортного ответа в этом же журнале (п.4 «Открытых
|
||
вопросов после P1»). Текст оркестратора не переписан — над ним поставлен баннер SUPERSEDED.
|
||
|
||
Дерево не коммичено. Адверсариального ревью P3 не проводила.
|
||
|
||
### 05.08.2026 — сессия P2 (платформа №3)
|
||
|
||
Отработана очередь приёмки P1 целиком (PD-46…PD-58) плюс три info-строки вне очереди
|
||
(PD-50, PD-53, PD-56). Три собственные находки: PD-62 (`start_id` не персистился — обе строки лога
|
||
`login_start_id` в проде пусты), PD-63 (`ClearReadDeadline` воспроизводил PD-2 на полу-кормленном
|
||
запросе), PD-64 (`OOMPolicy=stop` уронил бы контрол-плейн из-за одного прогона).
|
||
|
||
Изменены два значения, а не обоснованы: абсолютный срок сессии 90 → 30 суток (NIST AAL1),
|
||
`MemoryMax` 2G → 80%. Реализована контрмера RFC 9207 против mix-up (миграция `00008`).
|
||
Удалён `ClearReadDeadline`. Новых зависимостей P2 не добавила.
|
||
|
||
Собственное адверсариальное ревью (пять линз, зажатые промты, отчёт скрыт от четырёх из пяти;
|
||
каждая находка через верификатора-опровергателя): 34 кандидата, 11 подтверждено, 23 опровергнуты.
|
||
Шесть потребовали кода — PD-65…PD-70, включая major PD-70 (кука не скользила вместе с сессией) и
|
||
PD-66 (мой же фикс PD-46 закрывал половину). PD-71 принят риском и записан оператору.
|
||
|
||
Открытыми оставлены PD-60 и PD-61 — свойства ШВА, решать их платформе в одиночку нельзя.
|
||
|
||
Дерево не коммичено — лендит оркестратор.
|
||
|
||
### 05.08.2026 — сессия P1 (платформа №2)
|
||
|
||
Закрыт регистр P0 (18 из 19; PD-6 ждёт SSE). Построены: вход через OIDC с PKCE/nonce/одноразовым
|
||
стейтом и своей серверной сессией (П-6), кредитный леджер с резервациями и кэшем баланса (П-7),
|
||
админ-CLI (П-8), деплой-юнит systemd, тест-пол на реальном `http.Server`, фаззинг декодера.
|
||
Приёмка пятью независимыми ревью добавила PD-24…PD-45; изменения решений — раздел «Приёмка P1».
|
||
|
||
Не построено намеренно: `GET /v0/usage` (форма изменилась вместе с моделью денег, спека не
|
||
правлена — контракт первичен), материализатор и SSE (ждут словарь событий строки 103), очередь.
|
||
|
||
Дерево не коммичено — лендит оркестратор.
|
||
|
||
### 04.08.2026 — сессия P0 (платформа №1)
|
||
|
||
Прочитано: `CLAUDE.md`, `research/23`, контракт `14-api-contract` (README + openapi.yaml целиком),
|
||
`platform/BACKLOG.md`, `frontend/docs/STACK_DECISIONS.md` §5, D39.81/84/85/99/100 по grep.
|
||
|
||
Сделано: стек live-сверен (три библиотечных пина §5 — pgx · goose · River — на 04.08 всё ещё
|
||
последние; по Go последний патч 1.26.5 от 07.07, floor модуля оставлен общим с движком) →
|
||
модуль наполнен →
|
||
скелет HTTP + сессии + CSRF → схема read-model тремя миграциями → интерфейс ингеста/супервизии/
|
||
ре-синка → батарея зоны → дизайн-ответы (выше).
|
||
|
||
Ревью исполнением: `make check` зелёный; сервер поднят живьём против живого PostgreSQL 18.4 и
|
||
опрошен curl'ом (healthz/readyz/401/CSRF-403); миграции применены дважды; констрейнты проверены
|
||
поимённо через `pgconn.PgError.ConstraintName`; супервизор проверен настоящим процессом с
|
||
контрактными кодами возврата.
|
||
|
||
Адверсариальная самопроверка (author≠reviewer) дала четыре правки, каждая внесена:
|
||
(а) вложенный mux под `StripPrefix` терял `Request.Pattern`, из-за чего лог писался бы по сырому
|
||
пути с id книг — проверено экспериментом, переделано на один mux; (б) отклонённые запросы (401/403)
|
||
вообще не логировались, потому что лог висел на маршрутах, а гард стоял снаружи — лог поднят
|
||
наружу, добавлен тест «денайл тоже виден»; (в) **дефект, найденный запуском бинарника без БД:**
|
||
предъявленный Bearer уходил в nil-хранилище сессий и падал паникой в 500 — теперь отсутствие
|
||
хранилища это отказ 401, как и любой другой промах (регрессионный тест на месте); (г) пин тулчейна
|
||
поднят до 1.26.5 — в нём security-фиксы `crypto/tls` и `os`, а этот модуль сетевой (в `go.mod`
|
||
floor остался 1.26.4, общий с движком). Плюс снят мёртвый код: `crypto/rand.Read` по доке ошибку
|
||
не возвращает вовсе (падает), поэтому ветки её обработки убраны, а не оставлены изображать проверку.
|
||
|
||
Дерево не коммичено — лендит оркестратор.
|
||
|
||
---
|
||
|
||
**Пинг оркестратора №15 (09.08, D39.122): движковый пак «блокеры контракта» ПРИНЯТ и заленден `0e69bc1` — пять поверхностей для платформы существуют. ФИНАЛЬНЫЕ формы (менялись трижды за приёмку — старые в переписке игнорировать):**
|
||
- **Манифест глав:** `<project_db>.manifest.json`, `manifest_version: "tm-manifest-v2"` (v1 движок сам отклоняет); id главы = 16 hex (стабилен через пере-нарезку); **`unit.id = <chapterID>:<cutTag>:<firstChunkIdx>`** — тег разреза 8 hex, при любой смене нарезки/данных пары unit.id УМИРАЮТ намеренно (id жив ⇒ якорь цел); поле `heading` — ВРЕМЕННЫЙ рендер движка «Глава N», НЕ метка книги (решение владельца 09.08; настоящие заголовки — строка 160 бэклога движка). $0-команда `tmctl manifest` строит дерево до первого прогона (первое касание создаёт БД проекта — то же поведение, что у status).
|
||
- **Прогресс:** `status --json` → `progress: {draft:{done,total}, edit:{done,total}}` на книге и в каждом элементе `chapters`; `done` = «разрешено волной» (ok/flagged/skipped); волна, которой нет, — `total: 0`; процент не отгружается — собирает клиент.
|
||
- **Банк:** `<project_db>.bank.json` (весь банк тремя статусами; id термов — длино-префиксированный хеш ключа уникальности, стабилен через пересборку) и `<project_db>.bank-stop.json` (полная таблица подписи; `conf: null` ≠ 0). Все сайдкары пишутся атомарно (temp+rename) — читать можно во время прогона.
|
||
- **Потолок (строка 145): `tmctl translate|redrive --ceiling-usd <usd>` — это КНИЖНЫЙ ПОТОЛОК В СИЛЕ, не бюджет прогона** (ратифицировано D39.122): леджер сравнивает значение с накопленным committed+reserved книги. Пересчёт пользовательского «прирост в главах» (D39.110) в абсолют — ОБЯЗАННОСТЬ платформы: `аргумент = committed_usd + reserved_usd (из status --json) + прирост×оценка`. Значение ниже уже потраченного откажет первой же резервации. День-потолок не перекрывается. Опция по желанию зоны: движок готов провести `--ceiling-usd` и в `status`, чтобы мониторинг видел действующий потолок capped-прогона (сегодня status показывает книжный) — скажите, заведём строку.
|