150 KiB
Журнал зоны «Платформа»
Весь прогресс платформы — ЗДЕСЬ (решение владельца 04.08): пинги, итоги сессий, открытые вопросы, предложения на ратификацию. В
docs/PROGRESS.mdплатформа не пишет; оркестратор читает этот журнал при каждом лендинге зоны (свип «решений владельца» — норма D39.99 п.4).
Текущее состояние
- 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.
Ратификация приёмкой 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 (ноль на бете, агрегатный потолок) ждёт владельца.
Три вещи, которые приёмке стоит перепроверить в первую очередь.
- Вторая копия снятого транспортного ответа в ЭТОМ журнале. Фикс-лист сказал «секцию журнала про stdout я уже пере-поставил — не трогай», и секция «Транспорт потока событий (вопрос зоны №4)» действительно помечена SUPERSEDED. Но блок «⚠ ОТВЕЧЕНО приёмкой (PD-59): переезд канала отклонён» в списке «Открытые вопросы после P1», п.4 — остался без пометки, а PD-59 сам снят D39.106 п.3. Это ровно тот текст, который эмиттер-сессия прочтёт как задание, то есть предмет PD-95. Текст оркестратора не переписан — над ним поставлен баннер SUPERSEDED с ратифицированной формой. Если это чужая зона правки — снимать баннер приёмке, не мне.
- Одна собственная посадка оказалась негодной, и это поймала мутация, а не чтение. Первая
редакция теста разбора флагов CLI сверяла только «ошибка непуста» — а команда, прошедшая свои
проверки, всё равно падала на соединении с несуществующей БД. Две посадки («grant без
--usdидёт как ноль», «adjust больше не требует--note») её ПЕРЕЖИЛИ. Тест переписан на сверку сообщения; обе посадки теперь падают. Это форма ложно-зелёного прогона, которую стоит искать и в остальных моих тестах. - 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-Client403 · с заголовком 401 ·Sec-Fetch-Site: cross-site403 · мусорный Bearer 401 · неразобранныйAuthorization403) · редирект/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_keySQLSTATE 23505, три аккаунта целы,Up()возвращает схему на версию 8. Заявление зоны воспроизвелось дословно. - Фаззеры:
FuzzSafeReturnTo3.0 млн исполнений,FuzzDecoder3.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).
- Абсолютный срок сессии 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 обоснования не было. ⚠ Видимое следствие продуктовое — вынесено владельцу (ниже).
- Против 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»). Реализация проверена живьём, включая отказ на сорванном параметре. STACK_DECISIONS §13(политика сессий) — ПРИНЯТО как документ соответствия ASVS 7.1.1/7.1.2/ 7.1.3. Рассогласование с федеративной сессией названо прямо — это и есть то, чего требует норма, а не то, что она запрещает.- Ломающие изменения зоны — ПРИНЯТЫ:
httpapi.NewServerбезTimeouts(устранение КЛАССА PD-66 сильнее теста),Prober.Ping→Ready,login.Failбез*http.Request. Внешних потребителей у этих подписей нет: фронт говорит с зоной по HTTP. MemoryMax=80%+ явныйOOMPolicy=continue— ПРИНЯТО. Аргумент сверен сsystemd.resource-control(5)(«last line of defense», OOM-killer внутри юнита) иsystemd.service(5)(системный дефолтstop). ⚠ Под systemd не исполнялось — вывод из доки.- PD-71 — ПРИНЯТ РИСКОМ в форме, которую предложила зона: правило append-only дороже
доступности отката ниже версии 5, и место такой записи —
deploy/README.mdу оператора, а не сноска в архитектурном доке. Пере-проверено моим прогоном (см. выше).
Вынесено владельцу — два вопроса. (1) Сроки сессии: не заходивший месяц человек увидит экран
входа; если это против замысла — одна переменная TM_PLATFORM_SESSION_MAX_AGE и явная запись
отклонения в §13. (2) Новое, из PD-104: фри-тир печатается НЕАУТЕНТИФИЦИРОВАННЫМ потоком по $5
за каждую новую подтверждённую пару (provider, subject), и агрегатного потолка нет нигде — нужен
ли суточный лимит грантов и счётчик аномалий до открытия беты.
Фикс-лист (порядок мой; строки регистра — носители).
- PD-80 — доступность входа. Единственная major. Ведро лимитера общее у
/auth/loginи/auth/callback, и 429 колбэка приходит уже ПОСЛЕ очистки login-куки: анонимный поток ~3 rps закрывает вход всем и добивает начатые входы. Воспроизведено мной на бинаре и независимо панелью. Фикс дешёвый: лимитер прежде очистки куки + раздельные ведра. - PD-79 — деньги. Строковый
"null"вcommitted_usdчитается как ноль. Обязан быть закрыт ДО того, как появится вызывающий уSettle(то есть до воркера). - PD-83/PD-84/PD-85 — «закрыто, но не запинено» по собственному правилу шапки регистра: половина PD-3, лимитер PD-29 и ветка обновления адреса.
- PD-91 — деплой. Установка по наброску даёт нестартующий юнит; и
ProtectHome=yesпротив «книги в~/books». - PD-106 — админ-CLI, единственный писатель денег в дереве, не имеет ни одного теста — включая правило «после коммита нельзя отчитаться провалом», которое сам же называет несущим.
- PD-95 — переписать доккоммент
events.go:6-12под форму D39.106, и это ЕДИНСТВЕННОЕ, что тут осталось на зону. Ратифицированный транспорт:events.jsonlв каталоге книги как outbox-проекция коммитов SQLite, платформа его ТЕЙЛИТ, движок — транзиентный systemd-юнит и платформе не ребёнок; «родитель + пайп» и «выделенный fd» отвергнуты с нулём голосов (research/25§Форма). Секцию журнала про stdout и строки PD-92/PD-105/PD-60 уже пере-поставил я — тебе их не трогать. Срок: ДО того, как владелец выдаст промт эмиттера, иначе та сессия прочтёт доккоммент как задание. - PD-100 — колбэк глотает ошибку стора и ошибку discovery, репортя их обычным отказом. Это
класс PD-5, который зона закрыла в
auth/, но не вlogin/. - Граница пака — жёсткая, это не «по весу строк». НЕ трогать: PD-92 · PD-93 · PD-96 · PD-99 · PD-102 · PD-105 · PD-107. Все они латентны за воркером и эмиттером, и правка сейчас означает угадывание формы, которую задаёт строка 103 единого бэклога. Остальные открытые строки реестра — тоже не в этом паке: он закрыт списком выше.
Опровергнуто приёмкой — включая свои промахи (дисциплина «заявление=команда» действует и на приёмку).
- Панель: «удаление аккаунта падает на композитном FK даже при закрытых резервациях» —
опровергнуто моим прогоном:
delete from usersпроходит и без резервации, и с закрытой. - Панель: «
money.UnmarshalJSONчитает JSONnullкак ноль» — опровергнуто в этой форме: голый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) — включая те, где вердикт приёмки в итоге уточнён.
ClearReadDeadlineудалён, а не оставлен «страховкой». Приёмка проверила корректные запросы и заключила «код безвреден». На ПОЛУ-КОРМЛЕННОМ запросе он вреден: дренаж внутри записи заголовка — единственная граница соединения, снятие дедлайна до заголовка её убирает, и хендлер остаётся внутриWriteHeaderчерез 4 с после ухода клиента (замерено). Случая, где функция помогает, нет: на корректном запросеnet/httpснимает дедлайн сам. PD-51 закрыт, PD-63 заведён.- Абсолютный срок сессии 90 → 30 суток. ASVS 7.1.1 требует обосновать отклонение от NIST SP
800-63B; у 90 суток обоснования не нашлось (баланс тратимый, повторный вход у залогиненного в
Google — один клик, прогон истечение сессии переживает). Обосновывать нечего — цифра выровнена по
норме. Политика целиком (оба срока, одновременные сессии, рассогласование с федеративной) —
STACK_DECISIONS §13. - Против mix-up —
issавторизационного ответа (RFC 9207), решение принято до второго провайдера. Альтернатива «issиз ID-токена» при чистом code flow не работает: токен приходит после отдачи кода. Фолбэк «раздельные redirect URI» норма разрешает только когда другого нет, а Google RFC 9207 поддерживает (сверено живьём).auth_states.issuer+ сверка до обмена кода + отказ на СОРВАННОМ параметре. MemoryMax— потолок машины, а не сервиса. По собственному аргументу зоны дети-tmctlживут в cgroup юнита, значит «2G на контрол-плейн» — это 2G на платформу и все прогоны вместе, а OOM-killer внутри юнита выберет движок с эксклюзивным локом.80%+ явныйOOMPolicy=continue.- Пин на СВОЙСТВО там, где слои перекрываются. У
safeReturnToчетыре проверки, и снятие любой одной таблица входов переживала. Добавлены входы-различители плюсFuzzSafeReturnToс НЕЗАВИСИМЫМ оракулом (ResolveReferenceпротив базового URL сайта) — 3,1 млн исполнений. Побочно установлено и записано в код: условияu.Scheme/u.Host/u.Opaqueнедостижимы как отказ, пин на них невозможен, оставлены бэкстопом. - Состояние входа сравнивается структурой целиком.
State.StartIDне персистился — колонки не было, — и обе строки логаlogin_start_idв проде были пустыми, пока in-memory стор тестов показывал их заполненными. Тот же класс, что PD-46: свойство проверено не на том объекте. PD-62; теперьreflect.DeepEqualна всей структуре, следующее поле без колонки упадёт здесь же.
Что нашло собственное ревью P2 (author≠reviewer)
Пять ревьюверов по разным линзам, зажатые промты, журнал зоны и регистр от четырёх из пяти скрыты; каждая находка потом отдана адверсариальному верификатору с установкой «опровергни, по умолчанию считай неподтверждённой». 34 кандидата, 11 подтверждено, 23 опровергнуты с разбором. Из подтверждённых шесть потребовали правки кода:
- Обмен кода и JWKS шли без дедлайна (PD-65), хотя discovery рядом ограничивает себя пятью
секундами. Набор ключей у go-oidc ОБЩИЙ — значит одна зависшая загрузка паркует все параллельные
входы, а не только свой. Замерено верификатором на боевой проводке (
httpClient= nil). - Мой же фикс PD-46 закрывал половину и утверждал, что обе (PD-66). Тест смотрел на сервер,
который сам и построил; проводка демона осталась ненаблюдаемой — подмена аргумента в
main.goоставлялаmake checkзелёным, а бинарь снова пиннил соединения. Закрыто устранением КЛАССА:NewServerбольше не принимаетTimeouts, передавать нечего. FOR UPDATEне был запинен, а комментарий утверждал обратное (PD-67). Последовательный тест лока не видит, а инвариант «кэш = леджер» тоже: без лока обе величины уезжают в минус ВМЕСТЕ./readyzрапортовал «готов» на базе без схемы (PD-68) — то есть в нормальной середине выката, потому что миграция по инструкции отдельный шаг.- Явный
pool_max_connsиз DSN молча отбрасывался (PD-69): сравнение с дефолтом pgx не отличает «оператор промолчал» от «оператор выбрал это же число». - Скользящее окно бездействия для браузера не работало (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 в регистре. Здесь только то, что поменяло РЕШЕНИЕ:
- Миграции append-only без исключений (было: «до первого деплоя правим на месте»). goose
применяет по НОМЕРУ — ни имени, ни хеша: база на версии 3 приняла бы новый набор как применённый
и не получила ни одной таблицы,
DownToна ней ломается навсегда. Гейт —migrations.sha256+TestReleasedMigrationsAreUnchanged. Подробности вSTACK_DECISIONS§8. - Ключ идемпотентности леджера —
(user_id, source, source_id), пустой ключ запрещён DDL. Ключ без аккаунта проглатывал грант, выданный другому. reservations.book_id— составной FK кbooks(id, owner_id)с RESTRICT. Каскад делал холд невозвратным, а освободившийсяengine_run_idдавал холд без списания. Владение книгой теперь проверяет база, а не вызывающий (это денежная форма API1 BOLA).lockBalanceпервым во всех денежных операциях — иначе Hold↔Settle дают взаимоблокировку. ⚠ Замер этой сессии «41 на 300 раундов» не воспроизвели ни приёмка, ни P2 — нагрузка не была описана; остаётся СО СЛОВ. Дефект при этом настоящий: воспроизведён независимо дважды, действующий замер — в PD-52.- Расчёт capped потолком холда. Завышенное
committed_usdуводило баланс в минус. - Грант фри-тира — только подтверждённой личности (вопрос владельцу ниже).
- Имя провайдера — конфигурация,
State.Providerсверяется в колбэке. Захардкоженное «google» при смене issuer кладёт чужиеsubв старое пространство имён. - Исход прогона читается из
ProcessState, а не из ошибкиWait— иначе штатный SIGTERM помечает все идущие прогоны провалившимися. - Лимит тела — пер-маршрутный, иначе загрузка книги не может поднять свой потолок.
- Просроченный дренаж — не отказ процесса (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, но тогда отклонение от нормы придётся записать туда явно.
Оркестратору — четыре.
-
Спека не знает про
/auth/*. Ручки входа (GET /auth/login,GET /auth/callback,POST /auth/logout,POST /auth/logout-all) живут ВНЕ версионного префикса, как/healthz: это не контрактная поверхность, а механика сессии. Если фронт должен на них ссылаться — нужна строка в спеке или в компаньоне. Наше предложение: описать их в компаньоне, вopenapi.yamlне тащить. -
Требование
X-TM-Client(пинг P0) всё ещё не в спеке. Повторяем: реализовано, значение любое, несущей является ПРИСУТСТВИЕ заголовка на небезопасных запросах cookie-пути. -
Форма ответа
GET /v0/usageизменилась вместе с моделью денег (баланс вместо окон), а спека этого ещё не отражает. Ручку НЕ строили намеренно — контракт первичен. Предлагаемая форма в разделе «Что предлагаем в спеку» ниже. -
Транспорт потока событий: увести с 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.
- Оркестратору (контракт). Поток событий привязан к ПРОГОНУ (
/runs/{runId}/events), а статусыuploading/parsingсуществуют ДО прогона, и библиотека охватывает книги без прогонов. Живого канала у них нет вовсе. Нужна либо строка в спеке «до старта прогона состояние опрашивается», либо пользовательский поток (он же закрыл бы К-12 пушем). Решение — не наше. - Оркестратору (спека). Третий слой 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:
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):
- Миграции append-only без исключений — ПРИНЯТО. Аргумент проверен:
goose_db_versionдержит только номер.STACK_DECISIONS §8— норма зоны. - Почта не ключ ни в какой форме — ПРИНЯТО, и это прямо буква нормы, а не наш вкус: OIDC Core
§5.7 — стабильный идентификатор только
(iss, sub),emailиспользовать как идентификатор MUST NOT, потому что издатель вправе переиспользовать адрес между людьми. Цена (дубль аккаунта у человека с двумя провайдерами) названа честно и меньше альтернативы (захват аккаунта по унаследованному адресу). ⚠ Отклонение: ключуем по НАШЕМУ имени провайдера, не поiss; причина в коде названа и принимается. - Админ-поверхность — CLI — ПРИНЯТО. ⚠ Владельцу сказано прямо: «админка» сегодня = команда в шелле на машине, не веб-страница. Для беты этого достаточно; браузерная панель обернёт те же вызовы стора.
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 уже есть):
// 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» выше.
- PD-46 — тест на ЗНАЧЕНИЯ
DefaultTimeouts(). Это буквально та дыра, в которой PD-2 прожил P0. - PD-58 — обосновать 14 суток/90 суток письменно (ASVS 7.1.1 требует именно документа, а не значения), заодно 7.1.2 и 7.1.3. Самый дешёвый пункт списка и единственное несоответствие базовой линии, которую зона объявила себе сама.
- PD-48, PD-49, PD-47 — три пина к трём «закрытым» строкам регистра.
- PD-52 — регрессионный тест порядка блокировок (готовый выше).
- PD-51 — привести §12 и таблицу пинов к тому, что делает
net/http; решить судьбуClearReadDeadline(оставить как страховку — законно, но с честным комментарием). - PD-54 — снять устаревшую подписочную форму
/v0/usageиз журнала, пока S3 её не прочитал. - PD-55 —
MemoryMax/TasksMaxв юните: либо потолок для платформы отдельно от детей (Slice=/отдельный юнит), либо честный комментарий, что потолок общий на прогоны. - 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.goP1 = дев-режим; ответ PD-13 переезжает на cgroup юнита прогона». Читать текст ниже как историю аргументации, не как действующее решение; носители работы — строка 103 единого бэклога (эмиттер) и строки 138/139 (реконсилятор · пиннинг версии). Приёмка №15 этот supersede при лендинге пропустила и в первой редакции PD-95 сама сослалась на снятый PD-59 — исправлено эрратой; SIGPIPE-замер зоны остаётся верным фактом, но он больше не несущий, потому что родителя у движка в проде нет.
Транспорт потока событий (вопрос зоны №4): диагноз ПРИНЯТ, переезд канала ОТКЛОНЁН. PD-59, PD-60, PD-61.
Этот пункт переписывался четырежды. Три первые редакции спорили о том, чьи прецеденты лучше, — это не инженерный аргумент. Четвёртая получена методом владельца: задача сформулирована АБСТРАКТНО (два Go-сервиса, родитель читает NDJSON у ребёнка, никакого контекста репозитория и никакого намёка на мою позицию) и отдана двум независимым чистым агентам — одному с доступом в сеть, другому только со своими знаниями. По самому каналу они разошлись (сетевой — переезжать, офлайновый — остаться), и именно поэтому упражнение оказалось полезным: ценность не в их вердикте, а в том, на чём они сошлись НЕЗАВИСИМО и чего не было ни в записке зоны, ни в трёх моих редакциях.
Сошлись на трёх вещах, и первая решает вопрос.
- SIGPIPE зависит от НОМЕРА дескриптора.
os/signal: обрыв пайпа на fd 1 или 2 убивает программу сигналом; на любом другом дескрипторе запись просто возвращаетEPIPE. Замерено мной, не со слов: поток на fd 1 — ребёнок убитbroken pipe; на fd 3 —writeвернул EPIPE и процесс спокойно доработал до конца. Для нас это не сноска, а деньги: сегодня падение платформы убивает движок на следующей же записи события; после переезда движок стал бы сиротой и часами жёг бы оплаченные вызовы, пока холд висит в леджере и закрыть его некому. То есть stdout даёт нам бесплатную остановку сироты, а предложенный переезд её ЛОМАЕТ. Свойство несущее и до сих пор нигде не записано. - Настоящая защита — не выбор канала, а перехват на уровне дескриптора:
dup(1)в приватный fd, затемdup3(2,1,0), вmainдвижка. Он работает при ЛЮБОМ канале и герметичен там, где предложенный мной ранееos.Stdout = os.Stderrдыряв: переживаетvar out = os.Stdout, захваченный зависимостью, cgo и унаследованный fd 1 у внуков. Направлять fd 1 в/dev/nullне надо — пусть посторонние записи видны в человеческом логе прогона. - Дискриминатор, при котором переезд был бы прав: ребёнок исполняет чужой код, наследующий
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 — ревизия: пер-ресурсная со скоупом КНИГА; чтения её несут
Обе половины вопроса:
- Чтения ревизию несут — да. Без неё правило «отбрось чтение старше уже применённого
события» нечем реализовать, а рефетч по возврату фокуса окна включён у
react-queryпо умолчанию — то есть гонка на каждое переключение вкладки, а не редкий случай. - Счётчик — один на КНИГУ. Все книго-скоупные чтения (карточка, главы, юниты, замечания,
банк, прогон) возвращают ОДНО и то же число —
books.revision, +1 за материализующую транзакцию; строки, которых она коснулась, штампуются новым значением.idSSE-кадра прогона — ТО ЖЕ число, поэтому события и чтения книги полностью упорядочены между собой.
Почему книга, а не сквозной счётчик:
- сравнивать ревизии осмысленно только внутри скоупа, а книга — минимальный скоуп, в котором лежит всё, что поток может протухнуть;
- единственный писатель на книгу уже гарантирован (сериализация очереди по
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).
Находки (грунтованные)
- Ключ идемпотентности
(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 выше.
- Банк: канала нет — но не полностью. Подтверждаем находку оркестратора и уточняем состав:
сегодня добываемы (а) ПРЕДЛОЖЕННЫЕ термины стопа — сайдкар/строка 101 и (б) термины, которые
промотировала сама платформа — она же ПИШЕТ mined-delta и сид. Недобываемы
auto/ruby-строки, материализованные внутри движка. То естьGET /bankчастично реализуем уже сейчас; полностью — после артефакта экспорта банка. - Ре-синк не восстанавливает пофазный прогресс: в
status --jsonразбивки нет (строка 99). После обрыва и до следующего события прогресса клиент увидит агрегат. Записано в коде. status --json— ремонтный путь, не поллинг: каждый вызов заново ингестит и режет исходник (1.4–1.5 с CPU на книге 23 МБ — замер фронт-сессии 02.08, не наш; строка 100).- Деньги движка живут в его stderr на уровне INFO. Поэтому супервизор пишет stderr движка в ФАЙЛ попытки и не тейлит его в структурный лог платформы — иначе суммы попадут в наш INFO (запрет D39.84 + норма P0-промта).
- Мелочи в чужой зоне (не трогали, лендить оркестратору): в ратифицированной копии
docs/architecture/14-api-contract/openapi.yamlinfo.descriptionвсё ещё называет файл черновиком S3 и ссылается на../API_CONTRACT_DRAFT.md; в README той же папки ссылка «нормативная поверхность →api-contract/openapi.yaml» бьёт мимо (файл лежит рядом:./openapi.yaml). Решения D39.100 (paused,eta_seconds) в YAML ещё не внесены — это работа S3; схема платформы их уже держит. - Стенд: 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 по доке ошибку
не возвращает вовсе (падает), поэтому ветки её обработки убраны, а не оставлены изображать проверку.
Дерево не коммичено — лендит оркестратор.