diff --git a/CLAUDE.md b/CLAUDE.md index 1ac7ac8..f91c313 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,7 +18,7 @@ code in this repository. ## Форма правила - Четыре обязательные части: `### <ПРЕФИКС>-. Заголовок`, абзац - `**МОДАЛЬНОСТЬ.** норма`, абзац `**Почему.** …`. Правило без «Почему» не + `**МОДАЛЬНОСТЬ.** норма`, абзац `**ПОЧЕМУ.** …`. Правило без обоснования не принимается. - Норма — одна фраза; если в неё не влезает, это два правила. - Шкала инвариантна, словарь — параметр языка набора. Канон русский, значит @@ -34,14 +34,17 @@ code in this repository. обсуждается. - **МЕХАНИЗИРОВАНО** — не модальность, а способ проверки: отметка стоит рядом с модальным словом (`**ДОЛЖЕН. МЕХАНИЗИРОВАНО.**`), а не вместо него. +- Метки правила — **ПОЧЕМУ** и **МЕХАНИЗИРОВАНО** — тоже словарь набора и + перечислены в строке о версии языка наравне с модальными словами. - Заглавные модальные слова не употребляются вне правил: ни в «Область действия», ни в «Связано», ни в локальной части копии, ни во вводной прозе. Исключение — строка о версии языка, которая их перечисляет. -- «Почему» отвечает на «что сломается, если сделать иначе», а не +- Обоснование отвечает на «что сломается, если сделать иначе», а не пересказывает норму. «Потому что так принято» — не обоснование. -- Форма «Почему» не ограничена: рамки смысловые. Длина, рассуждение, примеры, - ссылки на внешние практики и чужие проекты — всё допустимо; запрещённых слов - нет. Обязательность несёт норма, и путаницу исключает правило о заглавных. +- Форма обоснования не ограничена: рамки смысловые. Длина, рассуждение, + примеры, ссылки на внешние практики и чужие проекты — всё допустимо; + запрещённых слов нет. Обязательность несёт норма, и путаницу исключает + правило о заглавных. - Служебные слова сценарного блока — тоже словарь набора: **КОГДА**, **ТОГДА**, **И**, **ИЛИ** (по-английски `WHEN`/`THEN`/`AND`/`OR`). Одна форма на роль, заглавными. Модальностью не являются, в строку о версии @@ -72,9 +75,9 @@ code in this repository. ## Ссылки - META-20: норму можно исполнить, имея один этот файл. Ссылка на правило - чужой темы допустима в «Почему», в «Связано» и в разграничении области + чужой темы допустима в обосновании, в «Связано» и в разграничении области действия — но не в самой норме. Нужен концепт соседней темы — коротко - повторить его здесь, соседа назвать в «Почему». + повторить его здесь, соседа назвать в обосновании. - META-21: на соседнюю конвенцию ссылаются именем темы (конвенция `logging`), на правило — идентификатором (`SLOG-27`). Пути файлов канона в тексте конвенции нет (в обвязке — можно). @@ -93,7 +96,7 @@ code in this repository. - META-6: ДОЛЖЕН без механической проверки либо механизируется, либо понижается в СЛЕДУЕТ. Правило, непроверяемое машиной в принципе (вкус формулировки, суждение о ситуации), — СЛЕДУЕТ по построению. -- META-10: блок «Почему» не удаляется никогда, в том числе после того, как +- META-10: блок ПОЧЕМУ не удаляется никогда, в том числе после того, как норма уехала в линтер. - META-1: один файл — ровно один повторяющийся выбор. META-2: конвенция заводится, когда решение принимается третий раз. diff --git a/GUIDE.md b/GUIDE.md index 69afa98..1b8d2a1 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -62,7 +62,7 @@ prefix: META **ДОЛЖЕН.** Файл описывает ровно один повторяющийся выбор. -**Почему.** Подписка перечисляется по темам, и тему берут целиком. Файл, +**ПОЧЕМУ.** Подписка перечисляется по темам, и тему берут целиком. Файл, собравший две темы, вынуждает репозиторий взять правила, которые ему не нужны, и вычитывать чужую половину при каждом обновлении. Разрезать позже дорого: перенос правила в другой файл — это новый префикс и новая @@ -73,7 +73,7 @@ prefix: META **СЛЕДУЕТ.** Поводом служит одно и то же решение, принятое третий раз и каждый раз чуть по-другому. -**Почему.** По одному-двум случаям не видно, что в решении повторяется, а +**ПОЧЕМУ.** По одному-двум случаям не видно, что в решении повторяется, а что было частностью места: правило, выведенное из первого случая, кодирует частность и дальше мешает больше, чем помогает. Единичный выбор, к тому же спорный, читателю нужен вместе с мотивом — это ADR, где мотив и есть @@ -85,7 +85,7 @@ prefix: META удаление прозы» делаются в репозитории, где случилась находка; в канон продвигается общая часть. -**Почему.** Правило, написанное сразу в общем виде, не проверено ни одним +**ПОЧЕМУ.** Правило, написанное сразу в общем виде, не проверено ни одним применением, и условие применимости у него придумано, а не найдено, — платят за это все потребители сразу. Формулировка, обкатанная на одном репозитории, приезжает в канон уже с известной границей. @@ -95,7 +95,7 @@ prefix: META **НЕ ДОЛЖЕН.** Норма пишется в настоящем предписывающем времени, без описаний того, как сейчас устроен конкретный репозиторий. -**Почему.** Такое утверждение устаревает молча и подменяет норму описанием: +**ПОЧЕМУ.** Такое утверждение устаревает молча и подменяет норму описанием: читатель перестаёт понимать, что от него требуется, а что просто констатировано. В копиях у остальных потребителей чужой факт вдобавок ложен с первого дня. «Так сделано у нас» — содержимое региона отступлений, где оно @@ -104,12 +104,12 @@ prefix: META ### META-20. Норма самодостаточна, наружу смотрит только обоснование **ДОЛЖЕН.** Норму правила можно исполнить, имея один этот файл. Ссылка на -правило чужой темы допустима в «Почему», в «Связано» и в разграничении +правило чужой темы допустима в обосновании, в «Связано» и в разграничении области действия — но не в самой норме. Если норме нужен концепт соседней -темы, он коротко повторяется здесь, а сосед называется в «Почему» как +темы, он коротко повторяется здесь, а сосед называется в обосновании как источник решения. -**Почему.** Репозиторий подписывается на произвольное подмножество +**ПОЧЕМУ.** Репозиторий подписывается на произвольное подмножество конвенций, и графа зависимостей у него нет по построению. Норма, которую нельзя исполнить без отсутствующего файла, делает такое подмножество невалидным молча: читатель видит связный текст и не замечает, что часть @@ -124,7 +124,7 @@ prefix: META `logging`), на конкретное правило — идентификатором (`SLOG-27`); путь файла канона в тексте конвенции не употребляется. -**Почему.** В репозитории конвенция лежит собранной: слои одной темы — это +**ПОЧЕМУ.** В репозитории конвенция лежит собранной: слои одной темы — это секции одного файла, и пути `lang/go/logging.md` там не существует. Ссылка на путь канона умирает при сборке, причём молча — текст остаётся связным. Имя темы и идентификатор правила переживают и сборку, и переезд файла между @@ -136,7 +136,7 @@ prefix: META **ДОПУСКАЕТСЯ.** Правило языкового или стекового слоя называет идентификатор правила арх-слоя своей темы прямо в норме. -**Почему.** Подписываются темой, а не слоем: собранный файл начинается с +**ПОЧЕМУ.** Подписываются темой, а не слоем: собранный файл начинается с арх-слоя независимо от того, какие язык и стек выбраны, — базовое правило в копии всегда рядом, и ссылка на него никуда не ведёт. Повтор его концепта здесь заводил бы второй источник правды внутри одного документа: META-20 @@ -152,7 +152,7 @@ prefix: META фактическую ошибку, внутреннее противоречие или условие применимости, которое не даёт ответа. -**Почему.** Правило, подогнанное под текущий код, перестаёт что-либо +**ПОЧЕМУ.** Правило, подогнанное под текущий код, перестаёт что-либо требовать — оно описывает то, что и так происходит, и первое же расхождение переписывает его снова. Направление «конвенция → код» держится ровно тем, что факт не считается аргументом. @@ -162,7 +162,7 @@ prefix: META **ДОЛЖЕН.** Правило, нарушение которого объявлено ошибкой, получает машинную проверку или переводится в СЛЕДУЕТ. -**Почему.** Без проверки правило держится на внимании: нарушения копятся +**ПОЧЕМУ.** Без проверки правило держится на внимании: нарушения копятся молча и всплывают выборочно — на том ревью, куда дошли руки. Для СЛЕДУЕТ это честно, а ДОЛЖЕН при этом обещает то, чего не делает, и через несколько таких случаев обесценивает остальные ДОЛЖЕН в файле. @@ -173,10 +173,10 @@ prefix: META ### META-25. Высшая модальность выбирается, только когда назван вред -**СЛЕДУЕТ.** Правило получает ДОЛЖЕН или НЕ ДОЛЖЕН, если в «Почему» сказано, -что́ ломается при нарушении. +**СЛЕДУЕТ.** Правило получает ДОЛЖЕН или НЕ ДОЛЖЕН, если в обосновании +сказано, что́ ломается при нарушении. -**Почему.** Машинная проверка — условие необходимое (META-6), но не +**ПОЧЕМУ.** Машинная проверка — условие необходимое (META-6), но не достаточное: проверяемых мелочей больше, чем важных вещей, и без второго условия единственным фильтром остаётся удобство проверки. Шкала наполняется опрятностью, читатель перестаёт отличать «уронит прод» от «неаккуратно» — и @@ -190,7 +190,7 @@ prefix: META **ДОЛЖЕН.** Запись о механизации называет идентификатор правила и конкретную проверку. -**Почему.** Механизация — состояние конкретного репозитория, канон о ней не +**ПОЧЕМУ.** Механизация — состояние конкретного репозитория, канон о ней не знает, а без записи следующий автор либо заведёт вторую проверку того же, либо будет вычитывать глазами уже проверенное машиной. Без идентификатора читатель догадывается сам, к какому утверждению относится проверка, — и @@ -201,7 +201,7 @@ prefix: META **НЕ ДОЛЖЕН.** Норма остаётся в каноне, пока хотя бы у одного потребителя машинной проверки нет. -**Почему.** У кого линтера нет, тот после удаления остаётся без правила +**ПОЧЕМУ.** У кого линтера нет, тот после удаления остаётся без правила вообще: ни проверки, ни текста. Механизация у одного потребителя ничего не говорит о прочих, поэтому удалять по факту «у нас уже проверяется» — значит чинить свой файл за чужой счёт. @@ -216,7 +216,7 @@ prefix: META **ДОПУСКАЕТСЯ.** Правило, уехавшее в общий конфиг линтера или в общую роль, удаляется из канона. -**Почему.** Формулировка, дублирующая работающую у всех проверку, +**ПОЧЕМУ.** Формулировка, дублирующая работающую у всех проверку, размазывает внимание: файл на несколько сотен строк заставляет человека и агента добросовестно вычитывать тривиальное именование и не доходить до формы решения. Явное разрешение нужно, чтобы META-8 не читался как запрет @@ -224,10 +224,10 @@ prefix: META ### META-10. Обоснование не удаляется никогда -**НЕ ДОЛЖЕН.** Блок «Почему» остаётся и после того, как норма уехала в +**НЕ ДОЛЖЕН.** Блок ПОЧЕМУ остаётся и после того, как норма уехала в линтер. -**Почему.** Линтер сообщает, что нарушено, но не сообщает, зачем правило +**ПОЧЕМУ.** Линтер сообщает, что нарушено, но не сообщает, зачем правило существует. Без обоснования не видно, когда причина отпала, — проверка продолжает работать по инерции, и возразить ей нечем, кроме как отключив. @@ -237,7 +237,7 @@ prefix: META называет, к чему применяется: к новым таблицам и миграциям, а не к состоянию схемы. -**Почему.** Здесь не работает привычное «новое пишем правильно, старое +**ПОЧЕМУ.** Здесь не работает привычное «новое пишем правильно, старое переезжает по мере касания»: таблица не переезжает от того, что её потрогали. Без явной рамки правило читается как требование к текущему состоянию, и оба исхода плохи — миграция живых данных ради опрятности либо @@ -248,7 +248,7 @@ prefix: META **ДОЛЖЕН.** Проверка запрещает нарушение в новых миграциях, а не в уже существующей схеме. -**Почему.** Проверка состояния краснеет на легаси с первого дня: её +**ПОЧЕМУ.** Проверка состояния краснеет на легаси с первого дня: её отключают или обвешивают вечным списком исключений — и она перестаёт ловить новое, ради чего заводилась. Проверка границы оставляет старое в покое и делает новую ошибку невозможной. @@ -258,7 +258,7 @@ prefix: META **ДОЛЖЕН.** Перечисленные старые таблицы и раскладки живут как есть, а не как задачи на дочистку. -**Почему.** Список, записанный долгом, требует либо мигрировать живые данные +**ПОЧЕМУ.** Список, записанный долгом, требует либо мигрировать живые данные без выгоды, либо год за годом объяснять невыполненный план. Второе кончается тем, что список перестают вести, — и пропадает единственное место, где видно, где именно правило не действует. @@ -268,7 +268,7 @@ prefix: META **ДОЛЖЕН.** В локальной части копии перечислены отступления, которые уже есть в коде, с идентификатором правила и причиной. -**Почему.** Иначе репозиторий выглядит соблюдающим конвенцию, а проверить +**ПОЧЕМУ.** Иначе репозиторий выглядит соблюдающим конвенцию, а проверить это можно только чтением всего кода. Со ссылками отступления счётны: видно, сколько правил конвенции репозиторий реально не соблюдает. Пустой список при этом почти всегда означает не отсутствие отступлений, а то, что их не искали. @@ -283,7 +283,7 @@ prefix: META | META-15.2 | правило не применяется в репозитории целиком | чинится условие применимости — в каноне | | META-15.3 | конвенция репозиторию не нужна | копия удаляется, подписки нет | -**Почему.** Отступление описывает исключение, и по нему видно, какая часть +**ПОЧЕМУ.** Отступление описывает исключение, и по нему видно, какая часть правила нарушена. Запись «мы это правило вообще не применяем» такой информации не несёт и маскирует одну из двух чинимых причин: неверную рамку правила в каноне, которую чинят один раз для всех, или лишнюю подписку, @@ -295,7 +295,7 @@ prefix: META потребителей; ссылки на ADR, код и файлы конкретного репозитория — в локальной части копии. -**Почему.** Текст канона приезжает ко всем потребителям, и ссылка на чужой +**ПОЧЕМУ.** Текст канона приезжает ко всем потребителям, и ссылка на чужой файл у них битая с первого дня. Ниже маркера та же ссылка никого не задевает и переживает обновление, потому что обновление её не трогает. @@ -304,7 +304,7 @@ prefix: META **ДОЛЖЕН.** Правки репозитория вносятся ниже маркера, а не в текст, пришедший из канона. -**Почему.** Обновление перезаписывает всё, что выше маркера, поэтому правка +**ПОЧЕМУ.** Обновление перезаписывает всё, что выше маркера, поэтому правка там живёт до первого `pull`. Заметить пропажу можно, только вычитав `git diff` целиком — а он в этот момент и без того полон изменений канона, и своя строка теряется среди чужих. @@ -314,7 +314,7 @@ prefix: META **НЕ ДОЛЖЕН.** Файл, который развели с каноном намеренно, шапку `origin:` не сохраняет. -**Почему.** По `origin:` решается, какие файлы пересобирать из канона. Форк, +**ПОЧЕМУ.** По `origin:` решается, какие файлы пересобирать из канона. Форк, оставивший шапку, при первом же обновлении теряет ровно то, ради чего его заводили. Происхождение такого документа остаётся в истории коммита, где оно никого не вводит в заблуждение. @@ -323,7 +323,7 @@ prefix: META **СЛЕДУЕТ.** Одна плоская таблица: файл и строка о том, про что он. -**Почему.** Подписка — это набор лежащих файлов, и без описаний вопрос +**ПОЧЕМУ.** Подписка — это набор лежащих файлов, и без описаний вопрос «какая из них про мой случай» решается открыванием каждой. Ценой в десяток файлов это означает, что не открывают ни одной. @@ -332,7 +332,7 @@ prefix: META **ДОЛЖЕН.** В `AGENTS.md` / `CLAUDE.md` едет одна строка на правило с его идентификатором; детали остаются в конвенции. -**Почему.** Сама по себе конвенция агенту не видна: он дойдёт до неё, только +**ПОЧЕМУ.** Сама по себе конвенция агенту не видна: он дойдёт до неё, только если его туда отправили, — а безусловно он читает точку входа. Строка с идентификатором служит и напоминанием, и адресом, по которому за подробностями идут; перенос деталей туда же вернул бы задачу поддержки двух @@ -347,4 +347,4 @@ prefix: META | Номер | Что было | Почему снято | |---|---|---| | META-16 | имя файла — kebab-case | вреда от нарушения нет (META-25); осталось прозой в «Оформлении» | -| META-26 | запрет слов обязательства в «Почему» | правило о заглавных уже делает строчное слово ненормативным, а форму обоснования рамками не ограничивают | +| META-26 | запрет слов обязательства в обосновании | правило о заглавных уже делает строчное слово ненормативным, а форму обоснования рамками не ограничивают | diff --git a/LANGUAGE.md b/LANGUAGE.md index 2bfb07c..d90bb44 100644 --- a/LANGUAGE.md +++ b/LANGUAGE.md @@ -1,5 +1,5 @@ --- -version: 1 +version: 2 --- # Язык конвенций @@ -8,7 +8,7 @@ version: 1 правилом, чем оно отличается от прозы вокруг, какими словами задаётся обязательность и как на правило сослаться извне. -Версия языка — **1**. Номер называется в каждой конвенции: словарь может +Версия языка — **2**. Номер называется в каждой конвенции: словарь может пополниться, и текст, написанный по предыдущей версии, должен читаться по той, по которой написан. @@ -77,20 +77,24 @@ version: 1 **ДОЛЖЕН.** Идентификатор, пришедший снаружи, проходит разбор до запроса к базе. -**Почему.** Разбор валидирует формат и нормализует регистр. Сравнение строк +**ПОЧЕМУ.** Разбор валидирует формат и нормализует регистр. Сравнение строк в базе побайтовое, поэтому без нормализации запрос молча не находит существующую запись — отладка такого случая стоит дороже, чем сам разбор. ``` Четыре обязательные части: **идентификатор**, **заголовок**, **модальность -с нормой**, **обоснование**. Норма — одна фраза; если в неё не влезает, это -два правила. Требование единичности взято из ISO/IEC/IEEE 29148: составная -норма не проверяема целиком, и нарушение одной её половины нечем -адресовать. +с нормой**, **обоснование под меткой ПОЧЕМУ**. Норма — одна фраза; если в неё +не влезает, это два правила. Требование единичности взято из ISO/IEC/IEEE +29148: составная норма не проверяема целиком, и нарушение одной её половины +нечем адресовать. + +Обе метки правила — модальное слово и ПОЧЕМУ — пишутся заглавными и +принадлежат словарю набора: скелет правила читается одинаково в любом языке, +на который канон переведён. ## Обоснование обязательно -Правило без блока «Почему» не принимается. Это требование к форме, а не +Правило без блока ПОЧЕМУ не принимается. Это требование к форме, а не пожелание; в 29148 обоснование — атрибут требования наравне с самим требованием, и по тем же причинам: @@ -104,8 +108,9 @@ version: 1 Если причина не формулируется, перед нами привычка или вкусовщина; ей место в черновиках, а не в конвенции. -«Почему» отвечает на «что сломается, если сделать иначе», а не пересказывает -норму другими словами. «Потому что так принято» — не обоснование. +Обоснование отвечает на «что сломается, если сделать иначе», а не +пересказывает норму другими словами. «Потому что так принято» — не +обоснование. Форма обоснования при этом ничем не ограничена: рамки здесь только смысловые. Абзац может быть длинным, вести рассуждение, приводить пример, @@ -172,14 +177,12 @@ Directives, Part 2, по одной форме записи на ступень, ## Словарь другого языка -Словарь набора — это два перечня: модальные слова и служебные слова -сценарного блока (`КОГДА`, `ТОГДА`, `И`, `ИЛИ` — см. «Таблицы решений»). -Требования к ним одни и те же. +Словарь набора — три перечня, и требования к ним одни и те же. -Для английского готовый словарь модальных слов даёт BCP 14. Для любого -другого языка слова берут из перевода стандарта, если он есть, или переводят -сами: шкала и семантика ступеней при этом не меняются — меняется только -запись. +**Шкала обязательности.** Для английского готовый словарь даёт BCP 14; для +любого другого языка слова берут из перевода стандарта, если он есть, или +переводят сами. Шкала и семантика ступеней при этом не меняются — меняется +только запись. | Ступень | Русский | Английский (BCP 14) | |---|---|---| @@ -188,22 +191,32 @@ Directives, Part 2, по одной форме записи на ступень, | рекомендация | СЛЕДУЕТ | SHOULD | | рекомендация против | НЕ СЛЕДУЕТ | SHOULD NOT | | разрешение | ДОПУСКАЕТСЯ | MAY | -| отметка о способе проверки | МЕХАНИЗИРОВАНО | MECHANIZED | -Последняя строка стандартом не даётся ни в одном языке: способа проверки в -шкале BCP 14 нет, слово подбирается под язык так же, как остальные. +**Метки правила.** Обязательности не задают, а размечают его части. +Стандартом не даются ни в одном языке: в BCP 14 таких понятий нет, слова +подбираются под язык так же, как остальные. + +| Метка | Русский | Английский | +|---|---|---| +| обоснование | ПОЧЕМУ | WHY | +| способ проверки | МЕХАНИЗИРОВАНО | MECHANIZED | + +**Служебные слова сценарного блока** — `КОГДА`, `ТОГДА`, `И`, `ИЛИ`; таблица +и объяснение в разделе «Таблицы решений». Что требуется от любого словаря: -- **одна форма на ступень.** Синонимы отклонены не из аскетизма: проверка - «модальное слово вне правила» перечисляет формы, и синонимический ряд - превращает перечисление в разбор. +- **одна форма на ступень и на метку.** Синонимы отклонены не из аскетизма: + проверка «модальное слово вне правила» перечисляет формы, и синонимический + ряд превращает перечисление в разбор. - **слово заглавными не встречается в обычной прозе этого языка.** Иначе правило «нормативно только заглавное» перестаёт спасать: проверка ловит оформление, а не модальность. -- **словарь перечислен целиком в строке о версии языка.** Читателю копии он - известен из самого файла, без обращения к этому документу, — иначе - конвенция в чужом репозитории теряет ключ к собственному тексту. +- **модальные слова и метки перечислены в строке о версии языка.** Читателю + копии они известны из самого файла, без обращения к этому документу, — + иначе конвенция в чужом репозитории теряет ключ к собственному тексту. + Служебные слова сценария в строку не входят: структура блока читается из + самого блока, и в файле без стыков правил их нет вовсе. - **словарь один на канон.** Два словаря параллельно дают две формы записи одного требования и удваивают каждую проверку; выбор языка — свойство набора, а не отдельного файла. @@ -215,16 +228,16 @@ Directives, Part 2, по одной форме записи на ступень, Каждая конвенция называет язык одной строкой во вводной прозе: -> Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и -> отметка МЕХАНИЗИРОВАНО толкуются как описано в языке конвенций версии 1 — +> Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки +> ПОЧЕМУ и МЕХАНИЗИРОВАНО толкуются как описано в языке конвенций версии 2 — > тогда и только тогда, когда написаны заглавными. Слова в строке — из словаря того языка, на котором написан набор. Для англоязычного набора та же строка выглядит так: -> The key words MUST, MUST NOT, SHOULD, SHOULD NOT, MAY and the mark -> MECHANIZED are to be interpreted as described in the conventions language, -> version 1, and only when written in capitals. +> The key words MUST, MUST NOT, SHOULD, SHOULD NOT, MAY and the marks WHY +> and MECHANIZED are to be interpreted as described in the conventions +> language, version 2, and only when written in capitals. Форма скопирована у BCP 14, где та же задача решается тем же способом: спецификация не прикладывает к себе словарь и не указывает путь к нему, а @@ -248,14 +261,14 @@ Directives, Part 2, по одной форме записи на ступень, **ДОЛЖЕН. МЕХАНИЗИРОВАНО.** Проверяется общим правилом линтера; формулировка удалена, потому что дублировала работающую проверку. -**Почему.** Дефолт превращает забытую вставку в тихо работающий код… +**ПОЧЕМУ.** Дефолт превращает забытую вставку в тихо работающий код… ``` - Идентификатор и заголовок сохраняются: ссылки из репозиториев продолжают указывать на то же утверждение. - Модальность сохраняется, поэтому «все ли ДОЛЖЕН механизированы» остаётся вычислимым вопросом, а не предметом чтения всего канона. -- «Почему» остаётся навсегда — линтер сообщает, что нарушено, но не +- Обоснование остаётся навсегда — линтер сообщает, что нарушено, но не сообщает, зачем правило существует, и без обоснования нельзя понять, когда проверку пора отменять. @@ -386,7 +399,7 @@ MIGR-6 не соблюдается в легаси-таблицах `show_histor - заголовки правил файла используют только его собственный префикс; - номера уникальны внутри файла и не имеют пропусков вниз (новое правило берёт следующий свободный, а не первый освободившийся); -- у каждого `### <ПРЕФИКС>-` есть модальное слово и блок «Почему»; +- у каждого `### <ПРЕФИКС>-` есть модальное слово и блок ПОЧЕМУ; отметка МЕХАНИЗИРОВАНО стоит рядом с модальностью, а не вместо неё; - вводная проза содержит строку о версии языка; - ссылки вида `<ПРЕФИКС>-` — хоть в тексте канона, хоть в локальной части @@ -405,3 +418,15 @@ MIGR-6 не соблюдается в легаси-таблицах `show_histor - перечисленные в таблице случаи покрывают область действия; - норма исполнима без обращения к другим файлам; - обоснование отвечает на «что сломается», а не пересказывает норму. + +## История версий + +Номер версии называется в каждой конвенции, поэтому изменение формы, способное +изменить чтение уже написанного текста, меняет и номер. Смена словаря под +другой естественный язык версию не двигает: версия принадлежит шкале, меткам и +правилам формы, а не буквам. + +| Версия | Что изменилось | +|---|---| +| 1 | первая запись языка: шкала из BCP 14, обязательное обоснование, идентификаторы, таблицы решений | +| 2 | обоснование получило метку ПОЧЕМУ заглавными и вошло в словарь набора; метки правила выделены из шкалы в отдельный перечень | diff --git a/README.md b/README.md index 0406783..b7e8ab0 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ | Файл | Что описывает | |---|---| | `README.md` | устройство канона, оси, сборка копий, жизненный цикл | -| [LANGUAGE.md](LANGUAGE.md) | язык записи правил: идентификаторы, модальность, «Почему» | +| [LANGUAGE.md](LANGUAGE.md) | язык записи правил: идентификаторы, модальность, обоснование | | [GUIDE.md](GUIDE.md) | как ведут конвенции: когда заводить, механизация, отступления | | `prefixes.toml` | реестр префиксов правил | | `conv` | сборка копий | diff --git a/conventions/arch/app-directories.md b/conventions/arch/app-directories.md index 480c872..4c17b67 100644 --- a/conventions/arch/app-directories.md +++ b/conventions/arch/app-directories.md @@ -10,9 +10,9 @@ prefix: DIRS **кто создаёт** содержимое и **что будет, если его потерять**. Из категорий механически выводится состав бэкапа. -Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и отметка -МЕХАНИЗИРОВАНО толкуются как описано в языке конвенций версии 1 — тогда и -только тогда, когда написаны заглавными. +Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки +ПОЧЕМУ и МЕХАНИЗИРОВАНО толкуются как описано в языке конвенций версии 2 — +тогда и только тогда, когда написаны заглавными. ## Область действия @@ -37,7 +37,7 @@ prefix: DIRS Имена в таблице — умолчание для случая «одна директория на категорию». -**Почему.** Все дальнейшие решения — что попадает в бэкап (DIRS-4), что можно +**ПОЧЕМУ.** Все дальнейшие решения — что попадает в бэкап (DIRS-4), что можно снести при нехватке места, что переживает переезд на другой диск — читаются из категории, а не выясняются по коду приложения. Без единой классификации каждое такое решение принимается заново и каждый раз чуть @@ -49,7 +49,7 @@ prefix: DIRS **ДОПУСКАЕТСЯ.** Директорий в категории столько, сколько нужно раскладке; принадлежность к категории задаётся не именем, а участием в списке бэкапа. -**Почему.** Явное разрешение снимает вопрос, не читается ли DIRS-1 как «ровно +**ПОЧЕМУ.** Явное разрешение снимает вопрос, не читается ли DIRS-1 как «ровно три директории». Крупные файлы отделяют от базы, чтобы двигать их между дисками независимо (`media/`, `uploads/` — та же категория «данные», что и `data/`); запрет на такое деление вынуждал бы либо держать всё на одном @@ -67,7 +67,7 @@ prefix: DIRS | DIRS-3.2 | миниатюры, распакованные ассеты, кеш внешних ответов, перестраиваемые индексы | кеш | | DIRS-3.3 | спорный случай | `rm -rf` и запуск приложения заново: поднялось и наверстало само — кеш; не поднялось или поднялось пустым — данные | -**Почему.** Без внешнего теста граница проводится по ощущению «жалко +**ПОЧЕМУ.** Без внешнего теста граница проводится по ощущению «жалко потерять», а оно смещено в одну сторону: дорогой в пересборке кеш переезжает в данные и раздувает каждый снапшот. Дорого ≠ невосполнимо, и разделяет эти два свойства именно способность приложения пересоздать @@ -80,7 +80,7 @@ prefix: DIRS **ДОЛЖЕН.** Директории категории «данные» — в списке бэкапа; конфигурация и кеш — нет. -**Почему.** Кеш раздувает снапшот содержимым, которое приложение +**ПОЧЕМУ.** Кеш раздувает снапшот содержимым, которое приложение восстановит само. Конфигурацию бэкапить не только незачем, но и вредно: там лежат секреты, а бэкапы уезжают в облако — источник истины для конфигурации остаётся в репозитории и хранилище секретов, а не в снапшоте. @@ -97,7 +97,7 @@ prefix: DIRS буквально: переменная деплоя, константа, поле конфигурации. Всё остальное на него ссылается. -**Почему.** Правило вывода механическое, но применяет его человек или +**ПОЧЕМУ.** Правило вывода механическое, но применяет его человек или шаблон — то есть ошибиться можно. Общая ссылка делает целый класс ошибок невозможным: переименование директории отражается в обоих местах сразу. Независимо набранный список расходится тихо — ни деплой, ни прогон бэкапа @@ -113,7 +113,7 @@ prefix: DIRS | DIRS-6.1 | файлы самодостаточны на любой момент времени | копированием | | DIRS-6.2 | консистентность обеспечивает только сама СУБД | дампом: бэкапится директория дампов, сырой каталог базы — нет | -**Почему.** Файловый снапшот работающей СУБД не гарантирует +**ПОЧЕМУ.** Файловый снапшот работающей СУБД не гарантирует консистентности: скопированный каталог может не восстановиться, и узнают об этом при восстановлении. Директория дампов — тоже данные, просто производные, поэтому DIRS-4 покрывает её без оговорок. Сырой каталог базы из @@ -125,7 +125,7 @@ prefix: DIRS **ДОЛЖЕН.** Решение «копировать или дампить» (DIRS-6) принимается, когда приложение заводят. -**Почему.** Неверный выбор ничем себя не проявляет, пока бэкап не +**ПОЧЕМУ.** Неверный выбор ничем себя не проявляет, пока бэкап не понадобился: прогоны копирования проходят успешно и накапливают снапшоты, из которых база не поднимется. Отложить решение — значит принять его по факту первой неудачной попытки восстановления, то есть тогда, когда данных уже @@ -136,7 +136,7 @@ prefix: DIRS **ДОЛЖЕН.** Конфигурация приложения задаёт отдельные пути для данных и для кеша, а не один каталог на всё. -**Почему.** Снаружи категория определяется только тогда, когда разным +**ПОЧЕМУ.** Снаружи категория определяется только тогда, когда разным категориям соответствуют разные директории. Всё, сложенное в один каталог, заставляет составлять список бэкапа вручную, читая код приложения, — и пересматривать его при каждом обновлении, потому что новый подкаталог @@ -148,7 +148,7 @@ prefix: DIRS **НЕ ДОЛЖЕН.** Записываемые пути приложения не указывают внутрь конфигурации. -**Почему.** Конфигурация восстанавливается прогоном деплоя (DIRS-1.1), поэтому +**ПОЧЕМУ.** Конфигурация восстанавливается прогоном деплоя (DIRS-1.1), поэтому всё, что приложение туда записало, следующий деплой затирает без предупреждения. Вдобавок директория конфигурации может быть подключена только на чтение — тогда запись отказывает в рантайме. Ни то ни другое не diff --git a/conventions/arch/config.md b/conventions/arch/config.md index 7fe43ad..da421cc 100644 --- a/conventions/arch/config.md +++ b/conventions/arch/config.md @@ -7,9 +7,9 @@ prefix: CONF Как устроена конфигурация: где лежит, как попадает в процесс, что с секретами и когда падает. -Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и отметка -МЕХАНИЗИРОВАНО толкуются как описано в языке конвенций версии 1 — тогда и -только тогда, когда написаны заглавными. +Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки +ПОЧЕМУ и МЕХАНИЗИРОВАНО толкуются как описано в языке конвенций версии 2 — +тогда и только тогда, когда написаны заглавными. ## Область действия @@ -25,7 +25,7 @@ prefix: CONF **ДОЛЖЕН.** Приложение читает параметры из файла конфигурации; переменные окружения источником конфигурации не служат. -**Почему.** Три довода, по убыванию веса: +**ПОЧЕМУ.** Три довода, по убыванию веса: - **Один типизированный источник.** Файл несёт секции, комментарии, единицы измерения и валидируется целиком. Окружение — плоский набор @@ -48,7 +48,7 @@ prefix: CONF **СЛЕДУЕТ.** Конкретный формат (TOML, YAML) выбирается по стеку. -**Почему.** Комментарий у каждого поля (CONF-9) — часть того, ради чего конфиг +**ПОЧЕМУ.** Комментарий у каждого поля (CONF-9) — часть того, ради чего конфиг вообще читают; формат, в котором комментарий негде разместить, делает CONF-9 невыполнимым. Секции дают структуру, которую валидатор проверяет целиком, — плоский список пар такой возможности не даёт и возвращает нас к тем же @@ -59,7 +59,7 @@ prefix: CONF **СЛЕДУЕТ.** Имя по умолчанию ищется в рабочей директории процесса, а путь задаётся опцией командной строки. -**Почему.** Запуск без аргументов работает одинаково в разработке, в +**ПОЧЕМУ.** Запуск без аргументов работает одинаково в разработке, в контейнере и на сервере, и способ запуска не приходится помнить отдельно для каждой среды. Опция нужна ровно для случаев, когда конфигов несколько (тесты, второй инстанс): без неё их разводят переменной окружения — тем @@ -71,7 +71,7 @@ prefix: CONF рабочей директории (CONF-3), приложение не стартует: сообщение называет искомый путь, код возврата ненулевой. -**Почему.** Конфиг — артефакт деплоя (CONF-12), и его отсутствие означает, что +**ПОЧЕМУ.** Конфиг — артефакт деплоя (CONF-12), и его отсутствие означает, что развёртывание не довело работу до конца, а не что приложение попросили работать на умолчаниях. Умолчания (CONF-7) существуют, чтобы работал **неполный** файл, а не отсутствующий, — именно здесь читатель спотыкается @@ -89,7 +89,7 @@ prefix: CONF **НЕ ДОЛЖЕН.** Реальный конфиг не коммитится; в репозитории — образец. -**Почему.** Рабочий конфиг содержит отрендеренные секреты (CONF-12), а секрет, +**ПОЧЕМУ.** Рабочий конфиг содержит отрендеренные секреты (CONF-12), а секрет, попавший в историю, чинится ротацией, а не удалением файла. Кроме того, закоммиченный конфиг конкретной среды становится вторым источником истины: он расходится с тем, что реально развёрнуто, и расходится молча. @@ -99,7 +99,7 @@ prefix: CONF **ДОЛЖЕН.** Разбор — при старте, в одну типизированную структуру; чтения файла конфигурации в бизнес-коде нет. -**Почему.** Второе место чтения — это второй момент времени: две части кода +**ПОЧЕМУ.** Второе место чтения — это второй момент времени: две части кода начинают видеть разные значения одного параметра, и расхождение не воспроизводится, потому что зависит от того, когда файл потрогали. Типизированная структура вдобавок переносит ошибку формата в старт (CONF-17), @@ -109,7 +109,7 @@ prefix: CONF **ДОЛЖЕН.** Смена параметров — рестарт процесса. -**Почему.** Изменяемый конфиг делает поведение функцией момента: один +**ПОЧЕМУ.** Изменяемый конфиг делает поведение функцией момента: один запрос обслуживается наполовину старыми, наполовину новыми значениями, а разбор инцидента требует знать хронологию правок файла, а не его текущее содержимое. @@ -121,7 +121,7 @@ prefix: CONF **ДОЛЖЕН.** Значение по умолчанию задаётся в коде, файл его перекрывает. -**Почему.** Умолчание, живущее в образце, действует только для тех, кто +**ПОЧЕМУ.** Умолчание, живущее в образце, действует только для тех, кто образец скопировал: конфиг без этого поля даёт другое поведение, и ни одно из двух значений не выглядит ошибкой. Умолчание в коде — одно определённое поведение для неполного конфига и одно место, где это значение меняется. @@ -131,7 +131,7 @@ prefix: CONF **ДОЛЖЕН.** В образце присутствуют все секции и все поля, включая те, у которых есть умолчание (CONF-7). -**Почему.** Поле, живущее только в коде, для читателя конфига не +**ПОЧЕМУ.** Поле, живущее только в коде, для читателя конфига не существует: он не знает, что параметр вообще можно менять, и добивается нужного поведения обходным путём. Полнота образца — цена, которой CONF-7 покупает себе видимость. @@ -145,7 +145,7 @@ prefix: CONF - **единицы измерения**, если применимо: секунды/миллисекунды, байты, доля `0–1`. -**Почему.** Так конфиг читается без открывания кода — этим он и полезен; +**ПОЧЕМУ.** Так конфиг читается без открывания кода — этим он и полезен; без комментария читатель всё равно идёт в код, и образец перестаёт быть справочником. Единицы стоят отдельно: перепутанные секунды и миллисекунды дают валидное значение и работающий процесс, а ошибка обнаруживается по @@ -161,7 +161,7 @@ prefix: CONF | CONF-10.1 | поддерживаемое | обязательны поля этого варианта; поля прочих вариантов не требуются | | CONF-10.2 | неизвестное | ошибка на старте с перечислением поддерживаемых значений | -**Почему.** Фиксированный на секцию набор обязательных полей оставляет +**ПОЧЕМУ.** Фиксированный на секцию набор обязательных полей оставляет выбор из двух плохих: заполнять поля бекенда, который не используется, или не проверять обязательность вовсе — то есть выключить валидацию ровно там, где вариантов много и ошибиться легче всего. Перечисление поддерживаемых @@ -174,7 +174,7 @@ prefix: CONF альтернативные — блоками-комментариями ниже, каждый со своим описанием полей. -**Почему.** Иначе набор вариантов виден только из кода валидации, и образец +**ПОЧЕМУ.** Иначе набор вариантов виден только из кода валидации, и образец теряет свойство справочника (CONF-8, CONF-9) ровно на той секции, где выбор действительно есть. Закомментированный блок вдобавок переключается правкой на месте, а не сборкой секции с нуля по документации. @@ -184,7 +184,7 @@ prefix: CONF **ДОЛЖЕН.** Деплой рендерит значения секретов прямо в файл конфигурации; отдельного слоя секретов в приложении нет. -**Почему.** Источник истины секрета — внешнее хранилище деплоя, не +**ПОЧЕМУ.** Источник истины секрета — внешнее хранилище деплоя, не репозиторий и не окружение. Любой второй канал — переменная окружения рядом с файлом, собственный клиент к хранилищу внутри приложения — возвращает вопрос «откуда взялось значение, которое сейчас в процессе». Приложение при @@ -196,7 +196,7 @@ prefix: CONF **ДОЛЖЕН.** Права `0600`, владелец — пользователь, от имени которого работает процесс. -**Почему.** После CONF-1 и CONF-12 файл конфигурации — единственная +**ПОЧЕМУ.** После CONF-1 и CONF-12 файл конфигурации — единственная поверхность, на которой секреты лежат, и весь довод «файл вместо окружения» держится на его правах: конфиг, читаемый всеми на машине, раздаёт секреты шире, чем раздало бы окружение, — и тогда CONF-1 меняет одну утечку на @@ -207,7 +207,7 @@ prefix: CONF **ДОЛЖЕН.** Значение секретного поля в образце — пустая строка, а не пример. -**Почему.** Правдоподобная заглушка доезжает до продакшена как настоящее +**ПОЧЕМУ.** Правдоподобная заглушка доезжает до продакшена как настоящее значение: шаблон отрендерился криво, поле осталось от образца, и проверка непустоты (CONF-15) его пропускает. Пустая строка делает недорендеренный конфиг механически отличимым от заполненного. @@ -216,7 +216,7 @@ prefix: CONF **ДОЛЖЕН.** Непустота обязательных секретов проверяется на старте. -**Почему.** Это ловит криво отрендеренный шаблон до того, как он превратится +**ПОЧЕМУ.** Это ловит криво отрендеренный шаблон до того, как он превратится в 401 от внешнего API через час работы, — то есть в момент, когда причина ещё очевидна и связана с деплоем. @@ -225,7 +225,7 @@ prefix: CONF **НЕ ДОЛЖЕН.** Значение секретного поля не появляется в записи лога ни на одном уровне. -**Почему.** У логов круг доступа шире, чем у файла под `0600` (CONF-13): они +**ПОЧЕМУ.** У логов круг доступа шире, чем у файла под `0600` (CONF-13): они собираются, пересылаются и попадают в бэкапы, где права исходного файла уже ничего не значат. Попавший в лог секрет чинится ротацией, а не удалением записи. Типичный источник утечки — отладочный дамп разобранного конфига при @@ -236,7 +236,7 @@ prefix: CONF **ДОЛЖЕН.** Невалидный конфиг — запись уровня `ERROR` и выход с ненулевым кодом; процесс не стартует «наполовину». -**Почему.** Наполовину стартовавший процесс проходит проверку живости и +**ПОЧЕМУ.** Наполовину стартовавший процесс проходит проверку живости и падает позже — на первом запросе, который трогает испорченный параметр, — и падение выглядит дефектом кода, а не ошибкой деплоя. Ненулевой код нужен, чтобы неудачный старт увидел супервизор: без него он неотличим от штатного @@ -254,7 +254,7 @@ prefix: CONF | CONF-18.4 | строки, которые парсятся во что-то (длительности, зоны, URL, идентификаторы сущностей), реально парсятся | при первом обращении к тому, что за ними стоит | | CONF-18.5 | включённые секции консистентны: у включённой интеграции заданы все обязательные поля | при первом вызове интеграции, часто по расписанию | -**Почему.** Список минимальный и собран по одному признаку — правый +**ПОЧЕМУ.** Список минимальный и собран по одному признаку — правый столбец: каждая из этих ошибок иначе всплывает там, где связь с деплоем уже потеряна, и диагностируется как дефект приложения. Проверка на старте сводит их все к одному моменту и одному сообщению. @@ -264,7 +264,7 @@ prefix: CONF **ДОЛЖЕН.** Валидация собирает все найденные проблемы и выводит их одним списком, а не падает на первой. -**Почему.** Иначе цикл «запуск — одна ошибка — правка» повторяется столько +**ПОЧЕМУ.** Иначе цикл «запуск — одна ошибка — правка» повторяется столько раз, сколько в конфиге ошибок, а на сервере каждая итерация — это ещё и деплой. Криво отрендеренный шаблон обычно ломает не одно поле, а все поля одного источника: разом они читаются как одна причина, по одной — как @@ -280,7 +280,7 @@ prefix: CONF | CONF-21.1 | несекретное | имя поля, ожидание и полученное значение: «ожидалось 0–1, получено `1.5`» | | CONF-21.2 | секретное | имя поля и суть нарушения, без значения | -**Почему.** Сообщение без значения отправляет читателя в файл — сличать +**ПОЧЕМУ.** Сообщение без значения отправляет читателя в файл — сличать глазами каждую строку списка CONF-19; ошибки вида «секунды вместо миллисекунд» или пробел в конце значения из такого сообщения не читаются вовсе. Значение секретного поля при этом печатать некуда: вывод старта уходит в лог diff --git a/conventions/arch/db-identifiers.md b/conventions/arch/db-identifiers.md index 4f5fc9f..54f65d6 100644 --- a/conventions/arch/db-identifiers.md +++ b/conventions/arch/db-identifiers.md @@ -6,9 +6,9 @@ prefix: KEYS Как выбираются и как выглядят первичные ключи сущностей. -Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и отметка -МЕХАНИЗИРОВАНО толкуются как описано в языке конвенций версии 1 — тогда и -только тогда, когда написаны заглавными. +Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки +ПОЧЕМУ и МЕХАНИЗИРОВАНО толкуются как описано в языке конвенций версии 2 — +тогда и только тогда, когда написаны заглавными. ## Область действия @@ -27,7 +27,7 @@ prefix: KEYS который порождает приложение, — во **всех** таблицах, включая те, что снаружи не адресуются. -**Почему.** Ветвления здесь нет намеренно, хотя напрашивается: «эту +**ПОЧЕМУ.** Ветвления здесь нет намеренно, хотя напрашивается: «эту сущность снаружи не адресуют, ей хватит целого числа». Внутренние сущности имеют привычку становиться внешними — и тогда целочисленный идентификатор утекает в URL задним числом, а миграция ключа на живых данных стоит @@ -47,7 +47,7 @@ prefix: KEYS **ДОЛЖЕН.** Значение ключа известно до вставки строки. -**Почему.** Идентификатор нужен раньше, чем база ответит: его пишут в лог +**ПОЧЕМУ.** Идентификатор нужен раньше, чем база ответит: его пишут в лог начатой операции, кладут в связанные записи одной транзакции и возвращают клиенту. Генерация на стороне базы вынуждает либо ждать `last_insert_rowid` и достраивать связи вторым проходом, либо иметь два источника истины о @@ -58,7 +58,7 @@ prefix: KEYS **ДОЛЖЕН.** Один модуль порождает идентификаторы, он же их разбирает. Самодельных генераторов и парсеров в коде нет. -**Почему.** Нормализация регистра (KEYS-4) и проверка формата обязаны +**ПОЧЕМУ.** Нормализация регистра (KEYS-4) и проверка формата обязаны применяться ко всем идентификаторам без исключения. Любая вторая точка входа рано или поздно окажется той, где нормализацию забыли, — и дефект проявится не там, где создан. @@ -67,7 +67,7 @@ prefix: KEYS **ДОЛЖЕН.** Идентификаторы порождаются и хранятся в нижнем регистре. -**Почему.** Сравнение строк в базе обычно побайтовое, поэтому регистр — не +**ПОЧЕМУ.** Сравнение строк в базе обычно побайтовое, поэтому регистр — не косметика, а корректность поиска. Спецификация ULID канонизирует **верхний** регистр, и библиотеки по умолчанию отдают именно его: без единой точки (KEYS-3) разный регистр появится в базе сам собой. @@ -83,7 +83,7 @@ prefix: KEYS | KEYS-5.1 | путь или query URL | «не найдено» без обращения к хранилищу | | KEYS-5.2 | собственная форма, данные кнопки | «некорректный ввод» либо «элемент устарел» | -**Почему.** Синтаксически невалидное значение не может соответствовать +**ПОЧЕМУ.** Синтаксически невалидное значение не может соответствовать записи, поэтому поход в базу за ним — заведомо холостой; отсекая его на границе, мы дёшево снимаем целый класс мусорного трафика. @@ -106,7 +106,7 @@ prefix: KEYS **ДОПУСКАЕТСЯ.** Когда ключ есть по природе данных, отдельный сгенерированный идентификатор не заводится. -**Почему.** Суррогат поверх естественного ключа создаёт второй способ +**ПОЧЕМУ.** Суррогат поверх естественного ключа создаёт второй способ адресовать ту же строку — а значит, возможность рассинхрона между ними и лишний вопрос «по какому из них искать» на каждом запросе. Дополнительной информации он не несёт. @@ -117,7 +117,7 @@ prefix: KEYS задание, корреляционный ключ), порождаются тем же модулем (KEYS-3) и в том же формате. -**Почему.** Единый формат делает работающим главный побочный эффект +**ПОЧЕМУ.** Единый формат делает работающим главный побочный эффект строковых идентификаторов: `grep` по голому значению собирает все упоминания сущности в логах независимо от имени поля. Второй формат идентификаторов эту возможность отменяет ровно для тех записей, где она diff --git a/conventions/arch/time.md b/conventions/arch/time.md index b1a3460..3192470 100644 --- a/conventions/arch/time.md +++ b/conventions/arch/time.md @@ -7,9 +7,9 @@ prefix: TIME Как приложение записывает моменты и длительности: в каком формате, откуда берётся значение и где появляется не-UTC. -Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и отметка -МЕХАНИЗИРОВАНО толкуются как описано в языке конвенций версии 1 — тогда и -только тогда, когда написаны заглавными. +Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки +ПОЧЕМУ и МЕХАНИЗИРОВАНО толкуются как описано в языке конвенций версии 2 — +тогда и только тогда, когда написаны заглавными. ## Область действия @@ -26,7 +26,7 @@ prefix: TIME **ДОЛЖЕН.** Момент времени записывается как `2026-06-28T11:23:45Z` — одинаково в хранении, логах, API и обмене с внешними системами. -**Почему.** Разные форматы в разных слоях требуют преобразования на каждой +**ПОЧЕМУ.** Разные форматы в разных слоях требуют преобразования на каждой границе, а ошибка в таком преобразовании не видна сразу: она всплывает через полгода, на переходе на летнее время, когда реальное смещение перестаёт совпадать с тем, которое подразумевалось при написании кода. UTC с явным `Z` @@ -37,7 +37,7 @@ prefix: TIME **ДОЛЖЕН.** Внутри одной колонки БД и внутри одного потока логов длина строки времени одна и от записи к записи не плавает. -**Почему.** Лексикографическая сортировка совпадает с хронологией только +**ПОЧЕМУ.** Лексикографическая сортировка совпадает с хронологией только среди строк одинаковой длины: `…00.123Z` сортируется раньше `…00.12Z`, хотя произошло позже. Ради этого ширина и фиксируется — `ORDER BY created_at` по текстовому полю обязан давать порядок событий. Плавающая ширина (типичный @@ -49,7 +49,7 @@ prefix: TIME **ДОПУСКАЕТСЯ.** У колонки БД и у потока логов каждая своя точность. -**Почему.** Квантор в TIME-2 — на носитель, а не на приложение, потому что +**ПОЧЕМУ.** Квантор в TIME-2 — на носитель, а не на приложение, потому что строки разных носителей между собой не сравниваются: сортировка идёт внутри колонки, чтение — внутри потока. Явное разрешение нужно, чтобы TIME-2 не читался как «одна точность на всё приложение»: от подгонки формата логов под формат @@ -61,7 +61,7 @@ prefix: TIME **НЕ ДОЛЖЕН.** Ни в базе, ни в логах, ни в JSON API нет меток в локальной зоне. -**Почему.** Метка без зоны неинтерпретируема вне процесса, который её +**ПОЧЕМУ.** Метка без зоны неинтерпретируема вне процесса, который её записал: чтобы понять, какому моменту она соответствует, читателю нужно знать настройки чужой машины на момент записи. И даже зная их, он не разберёт час перехода на зимнее время: этот час идёт дважды, две записи @@ -73,7 +73,7 @@ prefix: TIME долями секунды принимается от внешней системы и приводится к каноническому виду (TIME-1) в точке разбора (TIME-5). -**Почему.** Канонический вид — обязательство нашего писателя, а не +**ПОЧЕМУ.** Канонический вид — обязательство нашего писателя, а не контракт, наложенный на внешние системы: `…14:23:45+03:00` называет тот же момент, что `…11:23:45Z`, и отклонять его — значит ломать интеграцию со стороной, которая стандарт соблюла. Пропущенное же как есть, такое значение @@ -88,7 +88,7 @@ prefix: TIME **ДОЛЖЕН.** Один модуль отдаёт текущий момент, он же форматирует и разбирает метки; прямые вызовы часов по коду не разбросаны. -**Почему.** Формат, зона (TIME-1) и ширина (TIME-2) обязаны выполняться для всех +**ПОЧЕМУ.** Формат, зона (TIME-1) и ширина (TIME-2) обязаны выполняться для всех меток без исключения, а каждый прямой вызов часов заводит ещё одно место, где их можно не соблюсти. Промах такого вызова проявляется не в коде, а в данных, и обнаруживается, когда испорченных записей уже накопилось. @@ -98,7 +98,7 @@ prefix: TIME **НЕ ДОЛЖЕН.** Колонки времени не имеют `DEFAULT` с текущим моментом. -**Почему.** Дефолт превращает забытую вставку `created_at` в тихо работающий +**ПОЧЕМУ.** Дефолт превращает забытую вставку `created_at` в тихо работающий код: значение появляется, но приходит от сервера БД — то есть с других часов и в формате, который выбирала не единая точка (TIME-5). Без дефолта та же ошибка падает громко и чинится в момент написания, а не при разборе расхождения @@ -110,7 +110,7 @@ prefix: TIME **ДОЛЖЕН.** Длительность операции записывается числом (обычно миллисекундами) в поле вида `duration_ms`. -**Почему.** Метка отвечает на вопрос «когда», длительность — на «сколько». +**ПОЧЕМУ.** Метка отвечает на вопрос «когда», длительность — на «сколько». Пара меток заставляет каждого потребителя знать, какие именно две из них образуют операцию, и вычитать их самому — в запросе, в дашборде и глазами в логе; число сравнивается, агрегируется и попадает в перцентили без этого @@ -121,7 +121,7 @@ prefix: TIME **СЛЕДУЕТ.** Замер живёт там же, где вызов, границы которого он измеряет. -**Почему.** Обе границы операции видит только этот слой: замер уровнем выше +**ПОЧЕМУ.** Обе границы операции видит только этот слой: замер уровнем выше приписывает операции чужие накладные расходы, уровнем ниже — теряет часть вызова. В обоих случаях число остаётся правдоподобным и потому не оспаривается, хотя отвечает не на тот вопрос, который к нему задают. @@ -135,7 +135,7 @@ prefix: TIME | TIME-9.1 | момент события | стенные часы через единую точку (TIME-5) | | TIME-9.2 | длительность операции | монотонные часы процесса | -**Почему.** Стенные часы подводит NTP: они могут шагнуть назад, и тогда +**ПОЧЕМУ.** Стенные часы подводит NTP: они могут шагнуть назад, и тогда интервал, посчитанный вычитанием, выйдет отрицательным, а при шаге вперёд — правдоподобным, но выдуманным. Монотонные часы, наоборот, не годятся для меток: их ноль произволен и не переживает перезапуск процесса, так что вне @@ -148,7 +148,7 @@ prefix: TIME **ДОЛЖЕН.** Преобразование в зону пользователя происходит при выводе и не проникает в хранение, сортировку и логи. -**Почему.** Как только конвертация уходит вглубь, результат вычислений +**ПОЧЕМУ.** Как только конвертация уходит вглубь, результат вычислений начинает зависеть от настроек конкретного зрителя: одна и та же выборка даёт разные группировки, а порядок записей перестаёт быть общим для всех. Ещё хуже, что при конвертации в нескольких слоях её легко выполнить дважды — @@ -160,7 +160,7 @@ prefix: TIME **ДОЛЖЕН.** Значение приходит из конфигурации, значение по умолчанию — `UTC`. -**Почему.** Зашитая в код зона превращает переезд или второго пользователя в +**ПОЧЕМУ.** Зашитая в код зона превращает переезд или второго пользователя в другом поясе в правку кода и релиз. Значение по умолчанию `UTC` выбрано потому, что оно не притворяется настроенным: показанное время совпадает с тем, что лежит в базе и в логах, поэтому несовпадение с ожиданиями читается @@ -171,7 +171,7 @@ prefix: TIME **ДОЛЖЕН.** «Сегодня», «за месяц» и прочие календарные границы считаются с явно переданной зоной, а не с системной зоной процесса. -**Почему.** Системная зона разная на ноутбуке разработчика и в контейнере на +**ПОЧЕМУ.** Системная зона разная на ноутбуке разработчика и в контейнере на сервере, поэтому граница суток уезжает, а вместе с ней — содержимое отчёта: расхождение не воспроизводится там, где его заметили, и объясняется средой, а не кодом. Явно переданная зона делает результат функцией от аргументов. diff --git a/conventions/lang/go/config.md b/conventions/lang/go/config.md index 736ea70..26c7417 100644 --- a/conventions/lang/go/config.md +++ b/conventions/lang/go/config.md @@ -8,9 +8,9 @@ extends: arch/config.md Как базовый слой выглядит в Go-приложении: формат, загрузчик, границы запрета на окружение. -Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и отметка -МЕХАНИЗИРОВАНО толкуются как описано в языке конвенций версии 1 — тогда и -только тогда, когда написаны заглавными. +Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки +ПОЧЕМУ и МЕХАНИЗИРОВАНО толкуются как описано в языке конвенций версии 2 — +тогда и только тогда, когда написаны заглавными. Секретов Go-специфика не касается: они приходят из деплоя уже в файле, а проверка их непустоты идёт вместе с остальной валидацией — как описано в @@ -22,7 +22,7 @@ extends: arch/config.md **ДОЛЖЕН.** Конфиг — файл TOML. -**Почему.** Базовая конвенция оставляет формат за стеком, и этот выбор +**ПОЧЕМУ.** Базовая конвенция оставляет формат за стеком, и этот выбор делается один раз на язык, а не в каждом приложении: разные форматы в соседних сервисах означают разные загрузчики, разные шаблоны рендера конфига в деплое и разное поведение при синтаксической ошибке. TOML при @@ -35,7 +35,7 @@ extends: arch/config.md **ДОЛЖЕН.** Чтение файла, наложение умолчаний и проверки живут в `internal/config`; наружу пакет отдаёт готовую структуру `Config`. -**Почему.** Пока значение не покинуло пакет, оно может быть невалидным; +**ПОЧЕМУ.** Пока значение не покинуло пакет, оно может быть невалидным; после — уже нет, и это единственная граница, на которой такое утверждение проверяемо. Валидация, размазанная по потребителям, отвечает на вопрос «проверено ли это поле» только чтением всех вызывающих, часть полей @@ -48,7 +48,7 @@ extends: arch/config.md **ДОЛЖЕН.** Конфиг представлен одним значением типа `Config`, собранным из под-структур по секциям. -**Почему.** Один корень даёт одну точку, после которой конфиг проверен +**ПОЧЕМУ.** Один корень даёт одну точку, после которой конфиг проверен целиком, и дальше передаётся как обычный аргумент. Несколько независимых структур конфига означают несколько загрузок и вопрос «какая из них уже провалидирована» на каждом использовании; связанные между собой поля @@ -59,7 +59,7 @@ extends: arch/config.md **СЛЕДУЕТ.** Имя под-структуры совпадает с именем секции TOML. -**Почему.** Имя секции из файла — то, с чего начинают, когда конфиг ведёт +**ПОЧЕМУ.** Имя секции из файла — то, с чего начинают, когда конфиг ведёт себя не так, как ожидали. При совпадении имён поиск по нему сразу приводит в код и обратно; при расхождении связь между полем файла и полем структуры восстанавливается чтением тегов, и проделывать это приходится для каждой @@ -70,7 +70,7 @@ extends: arch/config.md **ДОЛЖЕН.** Умолчания собраны в функции `Default()`, разобранный файл накладывается поверх. -**Почему.** Нулевое значение в Go неотличимо от «поле не задано»: нулевой +**ПОЧЕМУ.** Нулевое значение в Go неотличимо от «поле не задано»: нулевой таймаут и отсутствующий таймаут — один и тот же `0`. Умолчание, подставленное по месту использования (`if x == 0 { x = … }`), поэтому не видно ни целиком, ни из образца, и два потребителя одного поля со временем @@ -83,7 +83,7 @@ extends: arch/config.md путь переопределяет флаг `--config=path`, образец рядом — `config.example.toml`. -**Почему.** Фиксированное имя и переопределение из командной строки требует +**ПОЧЕМУ.** Фиксированное имя и переопределение из командной строки требует базовая конвенция, но конкретные имена она не задаёт. Одинаковые имена во всех сервисах означают, что unit-файл, `docker run` и инструкция по запуску пишутся, не открывая код приложения. Соседство `config.toml` и @@ -102,7 +102,7 @@ func (d *Duration) UnmarshalText(b []byte) error { … } func (d Duration) Std() time.Duration { … } ``` -**Почему.** `time.Duration` — это `int64`, и TOML разбирает его в голое +**ПОЧЕМУ.** `time.Duration` — это `int64`, и TOML разбирает его в голое число: в конфиге остаётся `poll_interval = 5000`, о котором читатель не знает, секунды это, миллисекунды или наносекунды. Ошибка в тысячу раз проходит и разбор, и проверку диапазона, а проявляется нагрузкой или @@ -118,7 +118,7 @@ func (d Duration) Std() time.Duration { … } **НЕ ДОЛЖЕН.** Значения конфигурации не берутся из переменных окружения. -**Почему.** Второй канал конфигурации — то, против чего написана базовая +**ПОЧЕМУ.** Второй канал конфигурации — то, против чего написана базовая конвенция, но в Go он ещё и бесследный: `os.Getenv` вызывается из любого пакета, не появляется ни в `Config`, ни в образце и не проходит валидацию. Заведённый так параметр нельзя ни увидеть в конфиге, ни узнать иначе как @@ -134,7 +134,7 @@ func (d Duration) Std() time.Duration { … } ^os\.(Getenv|LookupEnv|Environ|ExpandEnv)$ ``` -**Почему.** `LookupEnv`, `Environ` и `ExpandEnv` читают ровно то же самое, +**ПОЧЕМУ.** `LookupEnv`, `Environ` и `ExpandEnv` читают ровно то же самое, поэтому паттерн на одно имя оставляет три обхода. Хуже, что оставляет незаметно: правило числится механизированным, и глазами его больше никто не проверяет. @@ -155,7 +155,7 @@ func (d Duration) Std() time.Duration { … } | GCFG-10.1 | тесты, включая интеграционные | допустимо: креды и адреса внешних сервисов взять больше неоткуда | | GCFG-10.2 | Go-рантайм и ОС, а не наш код (`GOMEMLIMIT`, `GOMAXPROCS`, `GODEBUG`, `TZ`) | вне запрета: значение читает не приложение | -**Почему.** GCFG-8 — про конфигурацию приложения; расширенный до «никто не +**ПОЧЕМУ.** GCFG-8 — про конфигурацию приложения; расширенный до «никто не трогает окружение», он запрещает то, чем не управляет: `GOMEMLIMIT` читает рантайм, а тесту креды внешнего сервиса взять неоткуда — в репозиторий их не положишь, а отдельный конфиг тестов пришлось бы заводить как ещё один @@ -167,7 +167,7 @@ func (d Duration) Std() time.Duration { … } **ДОЛЖЕН.** Исходящий прокси приходит полем конфига и явным `Transport`. -**Почему.** `HTTP_PROXY`/`HTTPS_PROXY` формально читает не наш код, а +**ПОЧЕМУ.** `HTTP_PROXY`/`HTTPS_PROXY` формально читает не наш код, а дефолтный `http.Transport` — но читает он их от имени приложения и меняет поведение приложения, а не рантайма. Оставленные окружению, они дают ровно тот второй канал, который запрещает GCFG-8, и притом самый неудобный: маршрут @@ -180,7 +180,7 @@ func (d Duration) Std() time.Duration { … } **ДОЛЖЕН.** Проверки не прерываются на первой неудаче, результат — одна ошибка, собранная `errors.Join`. -**Почему.** Возврат первой ошибки превращает починку конфига в серию +**ПОЧЕМУ.** Возврат первой ошибки превращает починку конфига в серию перезапусков по одному полю за раз, причём каждый следующий запуск обнаруживает ещё одно. `errors.Join` даёт разом весь список, не требуя своего типа ошибки, и `errors.Is`/`errors.As` продолжают работать по каждой @@ -190,7 +190,7 @@ func (d Duration) Std() time.Duration { … } **ДОЛЖЕН.** IANA-зона из конфига загружается на старте, в общей валидации. -**Почему.** Проверить имя зоны нечем, кроме загрузки: оно валидно ровно +**ПОЧЕМУ.** Проверить имя зоны нечем, кроме загрузки: оно валидно ровно тогда, когда база зон его знает, и никакая проверка формата не отличит `Europe/Moscow` от `Europe/Moskow`. Без загрузки на старте опечатка доживает до первого форматирования времени — то есть до рантайма, мимо @@ -201,7 +201,7 @@ fail-fast (GCFG-15). **ДОЛЖЕН.** Импорт `_ "time/tzdata"` стоит в `main`, а не в библиотечном пакете. -**Почему.** Импорт в библиотеке навязывает ~450 КБ zoneinfo каждому +**ПОЧЕМУ.** Импорт в библиотеке навязывает ~450 КБ zoneinfo каждому импортёру, включая тех, кому зоны не нужны: выбор «встраивать базу или полагаться на системную» принадлежит собираемой программе. Со встроенной базой ошибка `LoadLocation` (GCFG-13) означает ровно одно — битое имя зоны; без @@ -213,7 +213,7 @@ zoneinfo, а сообщение указывает не на ту причину **ДОЛЖЕН.** `main` пишет `slog` уровня `ERROR` и вызывает `os.Exit(1)` до старта серверов и воркеров. -**Почему.** Выход именно из `main`: `os.Exit` в библиотечном пакете не +**ПОЧЕМУ.** Выход именно из `main`: `os.Exit` в библиотечном пакете не оставляет вызывающему возможности ни залогировать причину, ни дописать контекст, и отложенные `defer` при нём не выполняются вовсе. Выход именно до старта воркеров: горутина, поднятая раньше валидации, успевает сходить diff --git a/conventions/lang/go/db-identifiers.md b/conventions/lang/go/db-identifiers.md index 5058c19..9376768 100644 --- a/conventions/lang/go/db-identifiers.md +++ b/conventions/lang/go/db-identifiers.md @@ -7,9 +7,9 @@ extends: arch/db-identifiers.md Как базовый слой выглядит в Go-приложении. -Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и отметка -МЕХАНИЗИРОВАНО толкуются как описано в языке конвенций версии 1 — тогда и -только тогда, когда написаны заглавными. +Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки +ПОЧЕМУ и МЕХАНИЗИРОВАНО толкуются как описано в языке конвенций версии 2 — +тогда и только тогда, когда написаны заглавными. Единая точка из `KEYS-3` — пакет `internal/ident`: он порождает идентификаторы (`NewID`, `NewIDAt`) и он же их разбирает @@ -22,7 +22,7 @@ extends: arch/db-identifiers.md **ДОЛЖЕН.** Идентификаторы порождаются и разбираются функциями пакета `internal/ident`; других генераторов и парсеров id в коде нет. -**Почему.** Реализация `KEYS-3` и `KEYS-4`. Вызов +**ПОЧЕМУ.** Реализация `KEYS-3` и `KEYS-4`. Вызов ULID-библиотеки — одна строка, доступная из любого пакета, и на ревью он не выглядит нарушением: значение получается валидное, просто мимо нормализации регистра. Отдельный пакет переводит запрет в проверяемое свойство — импорт @@ -35,7 +35,7 @@ ULID-библиотеки — одна строка, доступная из л **ДОЛЖЕН.** Новая сущность получает значение PK вызовом `ident.NewID()` внутри `Create`-метода слоя store. -**Почему.** `KEYS-2` требует, чтобы значение было +**ПОЧЕМУ.** `KEYS-2` требует, чтобы значение было известно до вставки, но не говорит, кто его присваивает. Store — последний слой, через который проходят все пути создания строки, включая импорт, фоновые задания и тесты. Генерация выше по стеку делает присвоение @@ -48,7 +48,7 @@ ULID-библиотеки — одна строка, доступная из л **ДОЛЖЕН.** Идентификатор батча, задания или корреляционный ключ создаётся вызовом `ident.NewID()` там, где операция начинается. -**Почему.** Смысл такого идентификатора (`KEYS-7`) — +**ПОЧЕМУ.** Смысл такого идентификатора (`KEYS-7`) — сшивать записи лога всей операции. Созданный ниже по стеку или в момент первой записи в базу, он не покрывает начальные шаги — а именно они нужны, когда операция упала до того, как что-либо записала: без общего ключа эти @@ -59,7 +59,7 @@ ULID-библиотеки — одна строка, доступная из л **ДОЛЖЕН.** Идентификаторы, проставляемые существующим строкам в Go-миграции, порождаются с историческим временем строки, а не с текущим. -**Почему.** Сортировка id тогда сохраняет историческую хронологию, а не +**ПОЧЕМУ.** Сортировка id тогда сохраняет историческую хронологию, а не момент прогона миграции. Иначе все затронутые строки получают метку одного момента, склеиваются в нём и встают в порядке обхода — `ORDER BY id` начинает врать ровно на том массиве данных, который старше всего. @@ -71,7 +71,7 @@ Go-миграции, порождаются с историческим врем **ДОЛЖЕН.** `ident.Parse()` вызывается в обработчике HTTP-роута, формы или callback'а бота — раньше, чем идентификатор попадёт в store. -**Почему.** Реализация `KEYS-5`. Граница выбрана +**ПОЧЕМУ.** Реализация `KEYS-5`. Граница выбрана транспортная, потому что только на ней известен источник значения, от которого зависит реакция (GKEY-8): store видит одинаковую строку независимо от того, пришла она из URL или из собственной формы, и ответить по-разному @@ -82,7 +82,7 @@ callback'а бота — раньше, чем идентификатор поп **СЛЕДУЕТ.** Поля идентификаторов в доменных и store-структурах имеют тип `string`. -**Почему.** Отдельный тип окупается только тогда, когда компилятор ловит им +**ПОЧЕМУ.** Отдельный тип окупается только тогда, когда компилятор ловит им ошибку. От перепутывания двух идентификаторов одной семьи (`userID` и `authorID`) он не спасает — оба будут одного типа, и различают их имена параметров. Зато он требует конверсий на каждой границе с sql-драйвером, @@ -93,7 +93,7 @@ json и шаблонами, то есть даёт цену без выгоды. **ДОПУСКАЕТСЯ.** Когда в коде оказываются два вида идентификаторов, которые можно перепутать, для них заводятся различимые типы. -**Почему.** Явное разрешение нужно, чтобы GKEY-6 не читался как запрет на +**ПОЧЕМУ.** Явное разрешение нужно, чтобы GKEY-6 не читался как запрет на типизацию навсегда. Условие названо ровно то, при котором тип начинает работать: пока все идентификаторы — `string`, подстановка одного вида вместо другого компилируется и обнаруживается только на данных. @@ -108,7 +108,7 @@ json и шаблонами, то есть даёт цену без выгоды. | GKEY-8.1 | путь или query URL | 404 без обращения к store | | GKEY-8.2 | собственная форма, callback-данные кнопки | 400 либо понятное сообщение («кнопка устарела») | -**Почему.** Реализация `KEYS-5.1` и `KEYS-5.2` в терминах +**ПОЧЕМУ.** Реализация `KEYS-5.1` и `KEYS-5.2` в терминах HTTP-кодов. В случае GKEY-8.1 снаружи это неотличимо от несуществующей записи — и хорошо: чужая или протухшая ссылка описывается так точно. В случае GKEY-8.2 значение сформировало само приложение, и невалидность означает баг @@ -121,7 +121,7 @@ HTTP-кодов. В случае GKEY-8.1 снаружи это неотличи **НЕ ДОЛЖЕН.** Обработчик не конструирует доменный sentinel (например `ErrNotFound`), чтобы тут же сопоставить его со своим ответом. -**Почему.** Инверсия правила «трансляция у источника» из конвенции +**ПОЧЕМУ.** Инверсия правила «трансляция у источника» из конвенции `errors`. Sentinel — сообщение от слоя, который знает факт: строка не найдена, потому что store её искал. Сфабрикованный транспортом, он утверждает непроверенное, и по типу ошибки перестаёт быть видно, был ли diff --git a/conventions/lang/go/db-schema.md b/conventions/lang/go/db-schema.md index 98306e6..4364d64 100644 --- a/conventions/lang/go/db-schema.md +++ b/conventions/lang/go/db-schema.md @@ -7,9 +7,9 @@ prefix: MIGR Как пишутся миграции и какие типы колонок выбираются для схемы SQLite в Go-приложении. -Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и отметка -МЕХАНИЗИРОВАНО толкуются как описано в языке конвенций версии 1 — тогда и -только тогда, когда написаны заглавными. +Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки +ПОЧЕМУ и МЕХАНИЗИРОВАНО толкуются как описано в языке конвенций версии 2 — +тогда и только тогда, когда написаны заглавными. ## Область действия @@ -25,7 +25,7 @@ Go-приложении. **ДОЛЖЕН.** Набор миграций репозитория применяется одним инструментом — goose. -**Почему.** Журнал применённых версий goose держит в самой базе +**ПОЧЕМУ.** Журнал применённых версий goose держит в самой базе (`goose_db_version`) и по нему решает, что ещё не накатывалось. Второй инструмент заводит второй журнал: миграция, применённая одним, для другого выглядит неприменённой, и попытка накатить её повторно упирается в уже @@ -37,7 +37,7 @@ goose. **СЛЕДУЕТ.** Миграции хранятся рядом с кодом, который работает с этой схемой. -**Почему.** Миграция и код, читающий схему, — одно изменение: колонка +**ПОЧЕМУ.** Миграция и код, читающий схему, — одно изменение: колонка появляется вместе с полем структуры и запросом. Лежащие в другом конце дерева миграции выпадают из поля зрения при правке store, и уезжает либо код без миграции, либо миграция без кода; расходятся они на сервере, где @@ -52,7 +52,7 @@ goose. | MIGR-3.1 | DDL: создание таблиц, индексы, изменение структуры | SQL-файл | | MIGR-3.2 | требует кода: генерация идентификаторов, backfill, перенос данных между формами | Go-миграция (`goose.AddMigrationContext`) | -**Почему.** DDL ничего не вычисляет, и SQL-файл показывает ровно тот текст, +**ПОЧЕМУ.** DDL ничего не вычисляет, и SQL-файл показывает ровно тот текст, который уедет в базу; обёртка на Go вокруг него добавляет место, где можно ошибиться, не добавляя ничего к результату. @@ -68,7 +68,7 @@ goose. **НЕ ДОЛЖЕН.** Откат схемы на сервере не выполняется down-миграцией; ошибка исправляется новой миграцией вперёд. -**Почему.** Down на сервере не возвращает прежнее состояние, а имитирует +**ПОЧЕМУ.** Down на сервере не возвращает прежнее состояние, а имитирует его: колонка, которую убрал up, восстанавливается пустой, а строки, записанные уже по новой схеме, в старую форму не ложатся. Потеря при этом происходит молча — миграция отчитывается об успехе. Исправление, приехавшее @@ -84,7 +84,7 @@ goose. | MIGR-5.1 | добавляет структуру: таблицу, колонку, индекс | пишется, убирает добавленное | | MIGR-5.2 | необратимо преобразует данные | не пишется | -**Почему.** Down — инструмент разработки, где ветку переключают туда-сюда, +**ПОЧЕМУ.** Down — инструмент разработки, где ветку переключают туда-сюда, и именно там он обязан действительно обращать up. Имитация опаснее отсутствия: разработчик применяет её, получает схему прежней формы и продолжает работу, не заметив, что колонка вернулась пустой. Отсутствующий @@ -96,7 +96,7 @@ down останавливает сразу и заставляет пересо **ДОЛЖЕН.** Изменение структуры и правка ER-схемы в спеках едут одним изменением. -**Почему.** Диаграмму читают вместо DDL — в этом весь её смысл. +**ПОЧЕМУ.** Диаграмму читают вместо DDL — в этом весь её смысл. Разошедшаяся с базой, она не бесполезна, а даёт неверный ответ, и заметить это можно, только сверив её с миграциями, то есть проделав работу, которую диаграмма экономит. Отложенное обновление не делается: изменение уже @@ -112,7 +112,7 @@ down останавливает сразу и заставляет пересо **ДОЛЖЕН.** Поле-перечисление (`state`, `kind`, …) объявляется как `TEXT` без `CHECK`-ограничения на список значений. -**Почему.** `ALTER TABLE` в SQLite не умеет менять ограничения ни в одной +**ПОЧЕМУ.** `ALTER TABLE` в SQLite не умеет менять ограничения ни в одной версии. Поэтому каждое новое значение перечисления в `CHECK (... IN (...))` превращается из строки в коде в пересоздание таблицы по 12-шаговой процедуре, с копированием данных и восстановлением внешних ключей. @@ -128,7 +128,7 @@ down останавливает сразу и заставляет пересо **ДОЛЖЕН.** Колонка с меткой времени объявляется как `TEXT`, значения пишутся как RFC 3339 в UTC с суффиксом `Z` и фиксированной шириной. -**Почему.** Типа даты в SQLite нет, поэтому единственное, что делает +**ПОЧЕМУ.** Типа даты в SQLite нет, поэтому единственное, что делает значения сравнимыми, — договорённость о формате; сам формат выбран не здесь, а конвенцией `time` (`TIME-1`, `TIME-2`). Текст в нём сортируется лексикографически в том же порядке, что и @@ -141,7 +141,7 @@ down останавливает сразу и заставляет пересо **НЕ ДОЛЖЕН.** Колонка с меткой времени не получает значение по умолчанию на уровне схемы. -**Почему.** Время ставит приложение, и умолчание в схеме заводит второй +**ПОЧЕМУ.** Время ставит приложение, и умолчание в схеме заводит второй источник этого значения: пропущенное приложением поле не падает, а тихо получает время сервера базы — расхождение обнаруживается по данным, а не по ошибке. @@ -154,7 +154,7 @@ down останавливает сразу и заставляет пересо **ДОЛЖЕН.** Булево значение хранится как `INTEGER` 0/1. -**Почему.** Отдельного булева типа в SQLite нет, поэтому от разнобоя +**ПОЧЕМУ.** Отдельного булева типа в SQLite нет, поэтому от разнобоя колонку удерживает только договорённость о представлении. Цена ошибки здесь несимметрична: строка `'true'` в булевом контексте приводится к **0**, то есть даёт противоположный ответ, а не пустую выборку и не ошибку @@ -166,7 +166,7 @@ down останавливает сразу и заставляет пересо **ДОЛЖЕН.** Колонка ключа объявляется как `TEXT`, значение приходит из приложения. -**Почему.** Здесь конвенция схемы ничего не решает — она реализует решение, +**ПОЧЕМУ.** Здесь конвенция схемы ничего не решает — она реализует решение, принятое конвенцией `db-identifiers` (`KEYS-1`, `KEYS-2`). Повторить там ветвление или условие значило бы завести второй источник правды, и соседние таблицы разъехались бы по разным ответам на один вопрос. @@ -179,7 +179,7 @@ down останавливает сразу и заставляет пересо **ДОЛЖЕН.** Там, где первичный ключ всё-таки целочисленный — существующая схема, миграция легаси-таблицы, — он объявляется с `AUTOINCREMENT`. -**Почему.** Без него SQLite выдаёт rowid как `max(rowid)+1`, поэтому после +**ПОЧЕМУ.** Без него SQLite выдаёт rowid как `max(rowid)+1`, поэтому после удаления последней строки номер переиспользуется. Протухшая ссылка на удалённую запись — из закладки, из чужой таблицы, из старого лога — молча наводится на другую сущность и возвращает правдоподобный, но чужой ответ. diff --git a/conventions/lang/go/errors.md b/conventions/lang/go/errors.md index b6ef410..d34ef50 100644 --- a/conventions/lang/go/errors.md +++ b/conventions/lang/go/errors.md @@ -8,9 +8,9 @@ prefix: GERR **логировать** — в конвенции `logging` (коротко: лог один раз на доменной границе). -Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и отметка -МЕХАНИЗИРОВАНО толкуются как описано в языке конвенций версии 1 — тогда и -только тогда, когда написаны заглавными. +Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки +ПОЧЕМУ и МЕХАНИЗИРОВАНО толкуются как описано в языке конвенций версии 2 — +тогда и только тогда, когда написаны заглавными. Две границы, о которых говорят правила ниже: @@ -30,7 +30,7 @@ prefix: GERR **ДОЛЖЕН.** Ошибки создаются и оборачиваются через `errors` и `fmt.Errorf`; библиотеки со стек-трейсами не подключаются. -**Почему.** Стек и цепочка обёрток решают одну задачу — локализацию места. +**ПОЧЕМУ.** Стек и цепочка обёрток решают одну задачу — локализацию места. При дисциплине «каждый слой добавляет свой контекст» (GERR-3) цепочка сообщений локализует не хуже, а стоит ничего: остальной контекст ошибки уже несёт `slog`. Библиотека со стеками добавляет зависимость, собственный тип ошибки @@ -45,7 +45,7 @@ prefix: GERR **НЕ ДОЛЖЕН.** Пакет со стек-трейсами не заводится в отдельном месте кодовой базы ради конкретной отладки. -**Почему.** В коде появляются два способа устроить ошибку, и вызывающий +**ПОЧЕМУ.** В коде появляются два способа устроить ошибку, и вызывающий перестаёт знать, какой перед ним: обёртки склеиваются по-разному, `errors.Is` работает не везде одинаково. Хуже второе: боль, снятая локально, перестаёт накапливаться — а накопление и есть единственный @@ -56,7 +56,7 @@ prefix: GERR **ДОЛЖЕН.** Ошибка, возвращаемая на уровень выше, оборачивается с контекстом: `fmt.Errorf("parse magnet: %w", err)`. -**Почему.** На этом держится GERR-1: цепочка заменяет стек ровно настолько, +**ПОЧЕМУ.** На этом держится GERR-1: цепочка заменяет стек ровно настолько, насколько слои в неё пишут. Слой, пробросивший ошибку без своего контекста, стирает участок пути — по итоговому сообщению нельзя сказать, через какую операцию ошибка прошла, и отладка «no such file» начинается с чтения всего @@ -72,7 +72,7 @@ prefix: GERR | GERR-4.1 | вызывающий может инспектировать причину (обычный случай) | `%w` | | GERR-4.2 | причину сознательно не раскрываем | `%v` | -**Почему.** Возражение против дефолтного `%w` — «обёрнутая ошибка +**ПОЧЕМУ.** Возражение против дефолтного `%w` — «обёрнутая ошибка становится частью API» — относится к библиотекам с внешними потребителями. Сервис же — приложение: внешнего Go-API нет, весь код наш, контракт меняется вместе с вызывающими. Зато `%v` в середине цепочки обрывает @@ -86,7 +86,7 @@ prefix: GERR **НЕ ДОЛЖЕН.** `%v` не используется как средство не пустить внутреннюю ошибку наружу. -**Почему.** Обрыв цепочки внутри кода не мешает тексту уехать наружу +**ПОЧЕМУ.** Обрыв цепочки внутри кода не мешает тексту уехать наружу целиком: наружу отдаёт внешняя граница, и если она отдаёт `err.Error()`, детали утекут при любом глаголе. Подмена не решает задачу, ради которой сделана, а плату берёт сразу — `errors.Is` ломается у всех вызывающих. @@ -96,7 +96,7 @@ prefix: GERR **СЛЕДУЕТ.** Без точки в конце, без «failed to» и «error». -**Почему.** Цепочка склеивается в одну строку через `": "`, и обёртка +**ПОЧЕМУ.** Цепочка склеивается в одну строку через `": "`, и обёртка читается как «контекст: причина» — заглавные буквы и точки рвут эту строку на середине. Слова «failed» и «error» не несут информации: то, что перед нами ошибка, известно из того, что это ошибка. Зато повторяются они на @@ -106,7 +106,7 @@ prefix: GERR **СЛЕДУЕТ.** В обёртку идёт то, что делал слой: `"link target: %w"`. -**Почему.** Обёртка ценна ровно тем, что сужает место (GERR-3). «something +**ПОЧЕМУ.** Обёртка ценна ровно тем, что сужает место (GERR-3). «something failed» не сужает ничего и при этом занимает в сообщении место, которое мог бы занять единственный полезный здесь факт — имя операции. @@ -115,7 +115,7 @@ failed» не сужает ничего и при этом занимает в **НЕ СЛЕДУЕТ.** Обёртка не пересказывает то, что уже сказал уровень ниже: `"add to qbt: %w"`, а не `"add download failed: add to qbt failed: …"`. -**Почему.** Повтор удлиняет сообщение, не добавляя локализации: одно и то +**ПОЧЕМУ.** Повтор удлиняет сообщение, не добавляя локализации: одно и то же событие названо дважды. Читателю приходится проверять, не два ли это разных места в коде, — то есть заикание не просто бесполезно, оно стоит времени при каждом чтении лога. @@ -132,7 +132,7 @@ failed» не сужает ничего и при этом занимает в возникла: `sql.ErrNoRows` → `store.ErrNotFound` в слое store; то же для HTTP-клиентов, файловой системы, внешних SDK. -**Почему.** Иначе тип зависимости становится частью контракта всех слоёв +**ПОЧЕМУ.** Иначе тип зависимости становится частью контракта всех слоёв выше: чтобы отличить «нет записи», доменный код импортирует `database/sql` и сравнивает с его sentinel'ом. Замена хранилища или SDK правит тогда не адаптер, а все ветвления в приложении — притом что снаружи адаптера @@ -148,7 +148,7 @@ HTTP-клиентов, файловой системы, внешних SDK. | GERR-10.1 | ветвление по условию: нет записи, дубликат, неподдерживаемый источник | sentinel `var ErrNotFound = errors.New("not found")`, проверка `errors.Is` | | GERR-10.2 | данные ошибки: поле валидации, код, лимит | тип с полями и методом `Error()`, извлечение `errors.As` | -**Почему.** Sentinel — одно значение; сравнение с ним не зависит от +**ПОЧЕМУ.** Sentinel — одно значение; сравнение с ним не зависит от структуры ошибки и переживает добавление полей. Тип заводится ради данных, и тип без данных отвечает вызывающему ровно то же, что sentinel, но ценой объявления, `errors.As` и вопроса «сравнивать по типу или по значению» на @@ -159,7 +159,7 @@ HTTP-клиентов, файловой системы, внешних SDK. **НЕ ДОЛЖЕН.** Ветвление по содержимому `err.Error()` не используется. -**Почему.** Текст сообщения — не контракт: GERR-6–GERR-8 разрешают +**ПОЧЕМУ.** Текст сообщения — не контракт: GERR-6–GERR-8 разрешают переписывать его свободно. Правка формулировки в нижнем слое молча ломает ветвление наверху, и компилятор этого не видит. Это то же самое, что публичный API из строки лога. @@ -175,7 +175,7 @@ HTTP-клиентов, файловой системы, внешних SDK. **ДОЛЖЕН.** В лог уходит вся цепочка `%w` с контекстом; где и когда именно — конвенция `logging`. -**Почему.** Цепочка — единственный носитель диагностики (GERR-1), и +**ПОЧЕМУ.** Цепочка — единственный носитель диагностики (GERR-1), и единственный канал, где её можно показать целиком, — тот, который видит владелец. Не записанная там, она не сохранится нигде: наружу идёт нейтральное сообщение (GERR-13), и восстанавливать причину будет не из чего. @@ -185,7 +185,7 @@ HTTP-клиентов, файловой системы, внешних SDK. **ДОЛЖЕН.** Наружу идёт человекочитаемый текст по доменной ошибке, а не `err.Error()` и не детали реализации (`database/sql`, пути, стек). -**Почему.** Внутренние детали пользователю нечитаемы, а владельцу не нужны +**ПОЧЕМУ.** Внутренние детали пользователю нечитаемы, а владельцу не нужны — у него есть лог (GERR-12). Зато они раскрывают устройство системы — имена таблиц, пути на диске, версии зависимостей — тому, кто их знать не должен, причём раскрывают именно в момент, когда что-то пошло не так. @@ -196,7 +196,7 @@ HTTP-клиентов, файловой системы, внешних SDK. «При обработке загрузки произошла ошибка, download_id=…» вместо «произошла ошибка». -**Почему.** GERR-13 забирает у пользователя всю фактуру; без ключа его +**ПОЧЕМУ.** GERR-13 забирает у пользователя всю фактуру; без ключа его обращение звучит как «у меня что-то не работает», и владелец ищет запись в логе по времени и догадкам. Ключ соединяет нейтральный ответ с полной ошибкой в логе, не раскрывая наружу ничего сверх того, что пользователь уже @@ -208,7 +208,7 @@ HTTP-клиентов, файловой системы, внешних SDK. задаётся один раз; транспорт без статусов (бот) берёт из него только сообщение. -**Почему.** Иначе одна и та же ошибка отвечает по-разному в HTTP и в боте, +**ПОЧЕМУ.** Иначе одна и та же ошибка отвечает по-разному в HTTP и в боте, и расхождение обнаруживается не как дефект, а как жалоба. Вторая причина важнее: единственная точка — это место, куда механически дописывается новая ветвь (GERR-16). Маппинг, размазанный по хендлерам, требование «дописать везде» @@ -219,7 +219,7 @@ HTTP-клиентов, файловой системы, внешних SDK. **ДОЛЖЕН.** Ожидаемый отказ (конфликт, валидация) заводится sentinel'ом и добавляется в маппинг (GERR-15) тем же изменением. -**Почему.** Ветка `default` врёт в обе стороны: транспорт отдаёт 500 +**ПОЧЕМУ.** Ветка `default` врёт в обе стороны: транспорт отдаёт 500 «внутренняя ошибка» на нормальный конфликт, а логирующая граница списывает его в `ERROR` вместо `DEBUG`. Второе хуже первого — штатные отказы начинают шуметь в логе ровно там, где по нему ищут настоящие поломки. @@ -230,7 +230,7 @@ HTTP-клиентов, файловой системы, внешних SDK. наружу 500 и нейтральное «внутренняя ошибка», а в лог идёт `ERROR` с признаком того, что маппинг её не знает. -**Почему.** Непокрытая ошибка — не класс отказа, а дефект: ветвь забыли +**ПОЧЕМУ.** Непокрытая ошибка — не класс отказа, а дефект: ветвь забыли завести вопреки GERR-16. Адресат у неё владелец в смысле «надо чинить», отсюда `ERROR` — уровень выбирается по адресату (конвенция `logging`). Статус тоже не выбирается: известное пользовательское состояние лежало бы в @@ -256,7 +256,7 @@ HTTP-клиентов, файловой системы, внешних SDK. Появился второй зритель или публичный доступ к экрану состояния — поверхность стала публичным каналом, и на неё распространяется GERR-17.1. -**Почему.** Транзиентный ответ читает тот, кто нажал кнопку: сырой текст +**ПОЧЕМУ.** Транзиентный ответ читает тот, кто нажал кнопку: сырой текст ему ничего не объясняет, а владельцу не нужен — у него лог. Персистентную диагностику читает владелец, и она отвечает на вопрос «почему сломалась вот эта запись» через месяц, когда лог уже ротировался; нейтральное «произошла @@ -269,7 +269,7 @@ HTTP-клиентов, файловой системы, внешних SDK. **НЕ ДОЛЖЕН.** Токены, пароли и ключи не попадают ни в транзиентный ответ, ни в персистентную диагностику; источник вычищается на границе клиента. -**Почему.** Запрет абсолютен, потому что персистентная диагностика живёт в +**ПОЧЕМУ.** Запрет абсолютен, потому что персистентная диагностика живёт в БД: уезжает в бэкапы, попадает в скриншоты и выгрузки и переживает ротацию самого секрета. Вычистка на границе клиента — единственное место, где ещё известно, какие поля запроса секретны: дальше ошибка едет как текст, и @@ -280,7 +280,7 @@ HTTP-клиентов, файловой системы, внешних SDK. **ДОЛЖЕН.** Персистентная диагностика не кладётся в доменное поле, которое показывают пользователю. -**Почему.** Различие GERR-17.1 и GERR-17.2 держится на том, что у поверхностей +**ПОЧЕМУ.** Различие GERR-17.1 и GERR-17.2 держится на том, что у поверхностей разные поля. Одно поле на оба назначения означает, что при первом же показе записи наружу сырой текст уедет туда же — не по решению, а потому что поле одно. @@ -292,7 +292,7 @@ HTTP-клиентов, файловой системы, внешних SDK. **ДОЛЖЕН.** Паникой отмечается нарушенный инвариант (баг программиста) и ошибка инициализации, из которой нельзя стартовать. -**Почему.** Паника не оставляет вызывающему выбора: обработать её на месте +**ПОЧЕМУ.** Паника не оставляет вызывающему выбора: обработать её на месте нельзя, можно только уронить единицу обработки. Это верный ответ, когда состояние процесса перестало описываться кодом: работа с нарушенным инвариантом опаснее падения, а сервис, стартовавший без обязательной @@ -303,7 +303,7 @@ HTTP-клиентов, файловой системы, внешних SDK. **НЕ ДОЛЖЕН.** Паника не используется для управления потоком: нет сети, плохой ввод, отсутствующая запись возвращаются как `error`. -**Почему.** Сигнатура — единственное, что сообщает вызывающему о возможном +**ПОЧЕМУ.** Сигнатура — единственное, что сообщает вызывающему о возможном отказе; отказ, брошенный паникой, из неё не виден, и компилятор не заставит его обработать. Дальше такая паника долетает до recover-границы (GERR-22), где неотличима от бага: штатный отказ попадает в лог со стеком и с интонацией @@ -318,7 +318,7 @@ HTTP-клиентов, файловой системы, внешних SDK. | GERR-22.1 | HTTP-хендлер | `net/http` восстанавливает панику сам и процесс не роняет; свой `recover` нужен, чтобы отдать контролируемый 500 и записать событие в `slog`, а не в stdlib-логгер | | GERR-22.2 | цикл обработки апдейтов бота, фоновый воркер | паника в горутине роняет процесс; `recover` ставится в той же горутине | -**Почему.** `recover` работает только в той горутине, где случилась паника, +**ПОЧЕМУ.** `recover` работает только в той горутине, где случилась паника, поэтому «у нас есть recover в HTTP» не защищает воркер — граница нужна у каждой единицы отдельно. Без неё один плохой апдейт бота или одна запись с неожиданным полем гасят весь сервис, включая части, к этой ошибке @@ -330,7 +330,7 @@ HTTP-клиентов, файловой системы, внешних SDK. **ДОЛЖЕН.** Логирующий `recover` кладёт в запись стек. -**Почему.** Это единственное место, где стек нужен (GERR-1): у восстановленной +**ПОЧЕМУ.** Это единственное место, где стек нужен (GERR-1): у восстановленной паники цепочки `%w` нет вовсе. «index out of range» без стека не диагностируется в принципе — сообщение не называет ни файла, ни операции, по нему нельзя сказать даже, в каком пакете упало. @@ -347,7 +347,7 @@ HTTP-клиентов, файловой системы, внешних SDK. | GERR-26.1 | обработчик HTTP-запроса | ответ 500, если он ещё не начат; процесс и прочие запросы не затрагиваются | | GERR-26.2 | итерация цикла обработки — бот, воркер | цикл продолжается со следующего элемента, упавший элемент повторно не берётся | -**Почему.** Паника внутри обработки одного элемента почти всегда говорит о +**ПОЧЕМУ.** Паника внутри обработки одного элемента почти всегда говорит о баге в работе с данными этого элемента, а не о порче общего состояния, — останавливать всё остальное не за что. Довод «let it crash» здесь работает не буквально: в OTP падает изолированный процесс под супервизором, а не узел @@ -378,7 +378,7 @@ HTTP-клиентов, файловой системы, внешних SDK. **СЛЕДУЕТ.** Валидация конфига и подобные проверки отдают все проблемы разом; проверка собранного — по-прежнему через `errors.Is`. -**Почему.** Возврат первой ошибки превращает починку конфига в серию +**ПОЧЕМУ.** Возврат первой ошибки превращает починку конфига в серию перезапусков, по одной проблеме за прогон. Склейка сообщений в строку даёт тот же список, но убивает ветвление: `errors.Is` по такому результату не находит ничего, и вызывающий остаётся с текстом, матчить который запрещено diff --git a/conventions/lang/go/logging.md b/conventions/lang/go/logging.md index efd1253..94753f7 100644 --- a/conventions/lang/go/logging.md +++ b/conventions/lang/go/logging.md @@ -9,9 +9,9 @@ extends: arch/time.md спецификация поведения: наблюдаемые требования к логам, входящие в контракт функциональности, живут в спеках. -Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и отметка -МЕХАНИЗИРОВАНО толкуются как описано в языке конвенций версии 1 — тогда и -только тогда, когда написаны заглавными. +Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки +ПОЧЕМУ и МЕХАНИЗИРОВАНО толкуются как описано в языке конвенций версии 2 — +тогда и только тогда, когда написаны заглавными. Лог читают инструментами, а не глазами: повседневно — `jq` (`jq 'select(.download_id=="a1b2")' app.jsonl`), тяжёлое (агрегации, JOIN) — @@ -29,7 +29,7 @@ DuckDB поверх JSONL прямо из файла. Отсюда почти в **ДОЛЖЕН.** Хендлер — `slog.JSONHandler`, одинаково в разработке и в проде. -**Почему.** Довод не в том, что текстовый вывод «расходит поля»: смена +**ПОЧЕМУ.** Довод не в том, что текстовый вывод «расходит поля»: смена хендлера структуру атрибутов не меняет. Довод в читателе — с текстовым dev-выводом перестаёшь ежедневно гонять собственные `jq`-пайплайны, и поломки словаря (опечатка в имени поля, потерянный атрибут, склеенное @@ -40,7 +40,7 @@ dev-выводом перестаёшь ежедневно гонять собс **ДОЛЖЕН.** Каждая величина — отдельный ключ со значением своего типа. -**Почему.** Фильтрация и агрегация работают по ключам; величина, вклеенная +**ПОЧЕМУ.** Фильтрация и агрегация работают по ключам; величина, вклеенная в текст, достаётся только регуляркой, а регулярка ломается при первой же правке формулировки. Тип важен отдельно от ключа: число внутри строки не сравнивается и не суммируется, то есть попадает в лог, но не в отчёт. @@ -50,7 +50,7 @@ dev-выводом перестаёшь ежедневно гонять собс **ДОЛЖЕН.** `time` приводится к UTC через `ReplaceAttr` по `slog.TimeKey` (см. конвенцию `time`). -**Почему.** По умолчанию UTC не получится: встроенные хендлеры пишут время +**ПОЧЕМУ.** По умолчанию UTC не получится: встроенные хендлеры пишут время в зоне самого `time.Time`, то есть в локальной зоне процесса. Записи одного процесса до и после смены TZ (или записи рядом с данными из БД) перестают складываться в одну хронологию, причём сдвиг на целые часы глазом не виден @@ -68,7 +68,7 @@ dev-выводом перестаёшь ежедневно гонять собс **ДОЛЖЕН.** Текст сообщения не собирается из переменных: `log.Info("download accepted", "download_id", id)`. -**Почему.** `msg` — то, по чему записи группируют и считают. Интерполяция +**ПОЧЕМУ.** `msg` — то, по чему записи группируют и считают. Интерполяция превращает одну категорию в множество уникальных строк, и вопрос «сколько раз это случилось» перестаёт решаться группировкой. Нижний регистр — чтобы одна категория не двоилась на варианты, различающиеся только заглавной @@ -79,7 +79,7 @@ dev-выводом перестаёшь ежедневно гонять собс **НЕ ДОЛЖЕН.** `recognition done`, а не `recognize: done`; подсистема — отдельное поле. -**Почему.** Префикс кладёт в текст ровно то, по чему потом фильтруют, и +**ПОЧЕМУ.** Префикс кладёт в текст ровно то, по чему потом фильтруют, и фильтр по подсистеме становится сопоставлением с началом строки вместо сравнения значения поля. Заодно это второй способ записать одно и то же: категория дробится на варианты с префиксом и без, а совпадать они обязаны @@ -90,7 +90,7 @@ dev-выводом перестаёшь ежедневно гонять собс **ДОЛЖЕН.** `state transition` с полями `from`/`to`/`code`; какое именно состояние и по какой причине — данные, а не текст. -**Почему.** С отдельной категорией на каждый переход жизненный цикл +**ПОЧЕМУ.** С отдельной категорией на каждый переход жизненный цикл сущности собирается перечислением всех известных `msg` — и переход, добавленный в код позже, в это перечисление не попадёт: выборка тихо останется неполной. Единая категория даёт весь цикл одним фильтром и не @@ -101,7 +101,7 @@ dev-выводом перестаёшь ежедневно гонять собс **НЕ ДОЛЖЕН.** Запись о действии, сопровождающем переход, не подменяет запись самого перехода. -**Почему.** Иначе из выборки по SLOG-6 выпадают именно те переходы, у которых +**ПОЧЕМУ.** Иначе из выборки по SLOG-6 выпадают именно те переходы, у которых был заметный эффект, — то есть самые интересные. Вторая запись стоит одной строки в логе; восстановление пропущенного перехода не стоит ничего, потому что невозможно. @@ -120,7 +120,7 @@ dev-выводом перестаёшь ежедневно гонять собс | SLOG-8.3 | `WARN` | владельцу, «может стать проблемой» | | SLOG-8.4 | `ERROR` | владельцу, в разбор | -**Почему.** Адресат — единственный признак, по которому разные авторы в +**ПОЧЕМУ.** Адресат — единственный признак, по которому разные авторы в разных местах кода выберут уровень одинаково. «Насколько серьёзно» каждый оценивает по-своему, шкала расползается — и вместе с ней теряет смысл базовый порог в проде (SLOG-40), потому что он отсекает уже не то, что @@ -131,7 +131,7 @@ dev-выводом перестаёшь ежедневно гонять собс **НЕ ДОЛЖЕН.** Происхождение записи на выбор уровня не влияет: `ERROR` везде одинаково серьёзен. -**Почему.** Фильтр по уровню собирает записи из всех подсистем сразу. Если +**ПОЧЕМУ.** Фильтр по уровню собирает записи из всех подсистем сразу. Если в шумной подсистеме `ERROR` «дешевле», читателю приходится помнить происхождение каждой записи, чтобы понять, надо ли реагировать, — то есть уровень перестаёт быть фильтром и становится подсказкой, требующей знания @@ -141,7 +141,7 @@ dev-выводом перестаёшь ежедневно гонять собс **ДОЛЖЕН.** Если «может» не про эту запись, уровень — `INFO`. -**Почему.** `WARN` разбирают вручную и целиком. Как только в нём заводится +**ПОЧЕМУ.** `WARN` разбирают вручную и целиком. Как только в нём заводится «ничего страшного», его перестают читать — и вместе с шумом теряется то единственное, ради чего уровень существует: предупреждение, на которое ещё есть время отреагировать. @@ -155,7 +155,7 @@ dev-выводом перестаёшь ежедневно гонять собс | SLOG-11.1 | по реальному действию или изменению | `INFO` | | SLOG-11.2 | повторяющаяся служебная, по таймеру или поллингу, сама по себе события не несущая (healthcheck, опрос статуса, авто-рефреш UI) | `DEBUG` | -**Почему.** `INFO` — аудит постфактум (SLOG-8.2), и его пригодность +**ПОЧЕМУ.** `INFO` — аудит постфактум (SLOG-8.2), и его пригодность определяется долей записей, за которыми что-то стоит. Периодическая операция даёт ровный поток при нулевой информации, в котором настоящие события тонут количественно: их не отфильтровать, потому что фильтровать @@ -166,7 +166,7 @@ dev-выводом перестаёшь ежедневно гонять собс **ДОЛЖЕН.** Фатальный сбой на старте пишется как `ERROR` и завершает процесс ненулевым кодом. -**Почему.** `slog` не разделяет CRITICAL и FATAL, поэтому недостающую степень +**ПОЧЕМУ.** `slog` не разделяет CRITICAL и FATAL, поэтому недостающую степень выражает не уровень записи, а сам факт завершения. Супервизор (docker, journald, systemd) отличает падение от штатной остановки по коду возврата, а не по уровню последней записи. Процесс, который написал `ERROR` и продолжил @@ -180,7 +180,7 @@ journald, systemd) отличает падение от штатной оста **ДОЛЖЕН.** Не `mediaType`/`media`/`media_type` вперемешку. -**Почему.** Имя поля — и есть интерфейс запроса к логам. Второе имя для той +**ПОЧЕМУ.** Имя поля — и есть интерфейс запроса к логам. Второе имя для той же величины делает любую выборку по ней молча неполной: фильтр отработает, часть записей в него не попадёт, и заметить это можно, только заранее зная, что они должны были быть. @@ -194,7 +194,7 @@ journald, systemd) отличает падение от штатной оста | SLOG-14.1 | бизнес-поле | плоский `snake_case`: `download_id`, `media_type` | | SLOG-14.2 | системный домен | точечная иерархия (адаптация OpenTelemetry): `http.*`, `ext.*` | -**Почему.** Точка отделяет поля, приходящие от инфраструктуры и одинаковые +**ПОЧЕМУ.** Точка отделяет поля, приходящие от инфраструктуры и одинаковые в любом проекте, от доменных, которые в каждом свои: по общему префиксу запрос «все внешние вызовы» пишется без перечисления имён. Заимствование словаря OpenTelemetry снимает необходимость изобретать имена тому, что уже @@ -205,7 +205,7 @@ journald, systemd) отличает падение от штатной оста **НЕ ДОЛЖЕН.** Вложенных объектов в записи нет; точка в имени — часть имени, а не уровень вложенности. -**Почему.** Плоский ключ адресуется одинаково в `jq`, в DuckDB и в любой +**ПОЧЕМУ.** Плоский ключ адресуется одинаково в `jq`, в DuckDB и в любой записи независимо от её категории. Вложенность требует знать глубину заранее, а она у разных категорий разная — и один запрос перестаёт покрывать весь лог, распадаясь на запрос под каждую форму записи. @@ -221,7 +221,7 @@ journald, systemd) отличает падение от штатной оста | SLOG-16.3 | запись об ошибке | `error` | | SLOG-16.4 | вызов внешнего сервиса | `ext.service`, `ext.operation` (логическая операция, не URL), `ext.status_code`, `duration_ms`, `retry` | -**Почему.** Набор задан не «на всякий случай»: без него запись не отвечает +**ПОЧЕМУ.** Набор задан не «на всякий случай»: без него запись не отвечает на свой вопрос. HTTP-запись без `duration_ms` не показывает деградацию, `ext`-запись без `ext.service` не отделяет «легла зависимость» от «у нас баг», запись о сущности без идентификатора не корреллируется (SLOG-19). Полный @@ -233,7 +233,7 @@ journald, systemd) отличает падение от штатной оста **НЕ СЛЕДУЕТ.** Поле, значение которого одинаково во всех записях, не заводится — для одного бинаря на одном хосте это `service.*` и `host.*`. -**Почему.** Такое поле не несёт информации, но стоит места в каждой строке +**ПОЧЕМУ.** Такое поле не несёт информации, но стоит места в каждой строке и внимания при чтении. Критерий один на все поля словаря — им же решается, нужен ли `transport` (SLOG-16.1): пока транспорт один, поле постоянно. Условие названо явно, поэтому правило отпадёт вместе со своей причиной: с @@ -248,7 +248,7 @@ journald, systemd) отличает падение от штатной оста сущностей есть стабильные уникальные идентификаторы. (Как их выбирают — конвенция `db-identifiers`, если взята.) -**Почему.** Идентификатор сущности уже существует, стабилен между +**ПОЧЕМУ.** Идентификатор сущности уже существует, стабилен между процессами и во времени — по нему собираются записи не одного прохода, а всей истории сущности, включая вчерашнюю. `trace_id` даёт то же самое только внутри одной операции, то есть дублирует ключ и добавляет второй @@ -259,7 +259,7 @@ journald, systemd) отличает падение от штатной оста **ДОЛЖЕН.** Поле `_id` в каждой записи, относящейся к сущности. -**Почему.** Принадлежность записи восстанавливается только в момент +**ПОЧЕМУ.** Принадлежность записи восстанавливается только в момент записи; постфактум её не вывести — остаётся воспроизводить инцидент заново. Это же условие, при котором работает SLOG-18: отказ от `trace_id` оплачен тем, что идентификатор стоит везде, а не в удобных местах. @@ -279,7 +279,7 @@ log := log.With("download_id", id) ctx = logctx.With(ctx, log) // достаём логгер из ctx в каждой стадии ``` -**Почему.** Ручное дописывание ключа пропускают не в основном сценарии, а в +**ПОЧЕМУ.** Ручное дописывание ключа пропускают не в основном сценарии, а в редких ветках — обработке ошибок и ранних выходах, где корреляция нужнее всего. Логгер из контекста дописывает ключ сам, и запись без идентификатора становится невозможной, а не маловероятной. @@ -290,7 +290,7 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд **ДОЛЖЕН.** `log.Error("layout failed", "error", err, "download_id", id)`. -**Почему.** Ошибка, вклеенная в текст сообщения, дробит категорию (SLOG-4) и +**ПОЧЕМУ.** Ошибка, вклеенная в текст сообщения, дробит категорию (SLOG-4) и уносит текст туда, где по нему нельзя отфильтровать. Ключ единый — так же, как по умолчанию в zap/zerolog: выборка «все записи с ошибкой» не должна зависеть от того, кто писал конкретный вызов, и ради этого единообразия @@ -301,7 +301,7 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд **НЕ ДОЛЖЕН.** Слой, возвращающий ошибку выше, её не логирует — только оборачивает (`%w`). -**Почему.** Иначе один сбой даёт столько записей, сколько слоёв он прошёл, +**ПОЧЕМУ.** Иначе один сбой даёт столько записей, сколько слоёв он прошёл, и количество `ERROR` перестаёт соответствовать количеству отказов — а считают именно его. Контекст при этом не теряется: он накапливается в цепочке обёрток и попадает в единственную запись на границе (SLOG-23). @@ -310,7 +310,7 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд **ДОЛЖЕН.** Логирует единый чокпоинт, определяющий исход операции. -**Почему.** У ошибки нужен ровно один логирующий, иначе неизбежны дубли; и +**ПОЧЕМУ.** У ошибки нужен ровно один логирующий, иначе неизбежны дубли; и этим местом выбрана доменная граница, а не транспорт, потому что там известен исход операции целиком и, значит, класс отказа (SLOG-25) — транспорт знает лишь то, что ему вернули ошибку. Побочный эффект того же выбора: @@ -321,7 +321,7 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд **НЕ ДОЛЖЕН.** Транспорт переводит возвращённую ошибку в свой ответ (статус, сообщение пользователю) и на этом останавливается. -**Почему.** Запись уже сделана на границе (SLOG-23); вторая отличается от неё +**ПОЧЕМУ.** Запись уже сделана на границе (SLOG-23); вторая отличается от неё только формулировкой и читается как второй сбой. Когда транспортов над одним доменом несколько, дублирование ещё и множится, а расследование начинается с вопроса, один это инцидент или два. @@ -338,7 +338,7 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд | SLOG-25.2 | расхождение производного или учётного состояния, первичные данные целы | владельцу, «может стать проблемой» | `WARN` | | SLOG-25.3 | сбой БД, ФС, недоступность зависимости | владельцу, в разбор | `ERROR` | -**Почему.** Это применение SLOG-8 к отказам: пользователь уже увидел причину на +**ПОЧЕМУ.** Это применение SLOG-8 к отказам: пользователь уже увидел причину на экране — владельцу разбирать нечего; целостность первичных данных отделяет «надо посмотреть» от «надо чинить сейчас». Привязка к месту дала бы разный уровень для одного и того же отказа в зависимости от того, какой транспорт @@ -359,7 +359,7 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд владельцу как деградация автоматики: коллизия в ручном действии — `DEBUG`, она же в авто-обработке — `WARN`. -**Почему.** В ручном действии человек видит причину на экране и сам решает, +**ПОЧЕМУ.** В ручном действии человек видит причину на экране и сам решает, что делать дальше; запись нужна только для отладки. В автоматике не увидел никто, задача осталась недоведённой, и лог — единственное место, где это вообще проявится. @@ -369,7 +369,7 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд **ДОЛЖЕН.** Тот же класс сбоя внутри синхронной операции — `ERROR`: уровень задаёт наличие штатного повтора, а не текст ошибки. -**Почему.** Одиночный промах тика транзиентен — следующий тик повторит, и +**ПОЧЕМУ.** Одиночный промах тика транзиентен — следующий тик повторит, и вмешательство не требуется; `ERROR` на каждый такой промах обесценивает уровень, на который смотрят в первую очередь. Синхронная операция повтора не имеет: она провалилась целиком, результат никто не восстановит, и это @@ -381,7 +381,7 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд **ДОЛЖЕН.** Все вызовы, включая успешные; поля — по SLOG-16.4. -**Почему.** Это единственный способ отличить «у нас баг» от «зависимость +**ПОЧЕМУ.** Это единственный способ отличить «у нас баг» от «зависимость легла»: на своей стороне видно лишь то, что операция не удалась. Выборочное логирование ломает и второе применение — доля неуспехов и распределение `duration_ms` считаются, только если знаменатель полный. @@ -397,7 +397,7 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд | SLOG-29.3 | попытка не удалась, делается retry | `WARN` | | SLOG-29.4 | ретраи исчерпаны, сервис недоступен | `ERROR` | -**Почему.** Неудачная попытка, за которой следует повтор, — ещё не отказ: +**ПОЧЕМУ.** Неудачная попытка, за которой следует повтор, — ещё не отказ: операция может завершиться успешно, и `ERROR` на каждую попытку сделал бы уровень непригодным для главного вопроса «зависимость доступна?». Исчерпание ретраев и есть момент, когда транспорт сдался и дальше @@ -431,7 +431,7 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд (`ext.status_code` записан); решение «это ошибка» принимает доменный вызывающий. -**Почему.** Транспорт своё дело сделал: запрос доставлен, ответ получен и +**ПОЧЕМУ.** Транспорт своё дело сделал: запрос доставлен, ответ получен и разобран. Классифицировать 4xx как сбой транспорта значит смешать «сервис недоступен» с «сервис ответил нам нет» — это разные инциденты с разной реакцией, и различает их как раз `ext`-уровень. Что 404 значит для @@ -444,7 +444,7 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд **ДОЛЖЕН.** Поля по SLOG-16.1; 4xx остаётся `INFO`-записью доступа. -**Почему.** Это аудит обращений, а не отладка: запись отвечает на «кто и +**ПОЧЕМУ.** Это аудит обращений, а не отладка: запись отвечает на «кто и когда приходил», и ценность у неё одинаковая при любом коде ответа. Уровень, зависящий от кода, делает аудит неполным именно на тех запросах, которые чаще всего разбирают. Доменную оценку исхода даёт отдельная запись @@ -454,7 +454,7 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд **ДОПУСКАЕТСЯ.** Это отдельный слой от корреляции по сущности. -**Почему.** Явное разрешение снимает вопрос, не запрещает ли `request_id` +**ПОЧЕМУ.** Явное разрешение снимает вопрос, не запрещает ли `request_id` правило SLOG-18. Не запрещает: SLOG-18 отказывается от случайного ключа там, где уже есть стабильный идентификатор сущности, а у HTTP-запроса собственной сущности нет — связать его записи между собой больше нечем. @@ -463,7 +463,7 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд **ДОЛЖЕН.** Периодические проверки живости пишутся на отладочном уровне. -**Почему.** Частный случай SLOG-11.2, названный отдельно, потому что нарушают +**ПОЧЕМУ.** Частный случай SLOG-11.2, названный отдельно, потому что нарушают его чаще всего: проверку дёргают по таймеру, и на `INFO` она вытесняет из аудита всё остальное — в проде с базовым `INFO` (SLOG-40) лог превратился бы в опрос самого себя. На `DEBUG` она не пишется вовсе и при этом остаётся @@ -477,7 +477,7 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд API-ключи и токены, `Authorization`-заголовки, аутентификационные параметры в ссылках. -**Почему.** Лог уезжает целиком в чужое хранилище, читается шире, чем код, +**ПОЧЕМУ.** Лог уезжает целиком в чужое хранилище, читается шире, чем код, и переживает ротацию самого секрета. Попавший в него секрет скомпрометирован с момента записи, а не с момента, когда это заметили, и вычистить его задним числом из уже собранных копий нельзя. @@ -487,7 +487,7 @@ API-ключи и токены, `Authorization`-заголовки, аутент **ДОЛЖЕН.** Тела запросов и ответов внешних API, сырой вывод LLM — `DEBUG`, с вычисткой секретов и обрезкой по длине. -**Почему.** Содержимое пришло снаружи: размер не ограничен, состав +**ПОЧЕМУ.** Содержимое пришло снаружи: размер не ограничен, состав неизвестен, а секрет в нём возможен по недосмотру той стороны. `DEBUG` выключен в проде (SLOG-40), поэтому цена ошибки ограничена отладочной сессией; обрезка не даёт одной записи вытеснить весь остальной лог за период. @@ -496,7 +496,7 @@ API-ключи и токены, `Authorization`-заголовки, аутент **СЛЕДУЕТ.** `"has_api_key", true` вместо самого значения. -**Почему.** Для отладки почти всегда достаточно ответа «значение было или +**ПОЧЕМУ.** Для отладки почти всегда достаточно ответа «значение было или не было» — потеря полезности близка к нулю, а риск снимается целиком. Правило нужно потому, что решение принимается в момент написания строки, когда чувствительность значения ещё неочевидна, а перечитывать этот выбор @@ -507,7 +507,7 @@ API-ключи и токены, `Authorization`-заголовки, аутент **ДОЛЖЕН.** Ошибка разворачивается в первопричину **до** лога и до обёртки — раньше трансляции в доменную (конвенция `errors`). -**Почему.** `*url.Error` встраивает полный URL запроса, а секрет живёт +**ПОЧЕМУ.** `*url.Error` встраивает полный URL запроса, а секрет живёт прямо в нём: токен в пути, `api_key` в query. Go редактирует только пароль из userinfo, остального не трогает, поэтому ошибка уносит секрет и в обёртку, и в лог целиком. Порядок — часть нормы: санитизация после @@ -521,7 +521,7 @@ API-ключи и токены, `Authorization`-заголовки, аутент **НЕ ДОЛЖЕН.** Аутентификация параметром ссылки — только когда другого способа нет. -**Почему.** Секрет в URL попадает не только в ошибку транспорта (SLOG-37), но и +**ПОЧЕМУ.** Секрет в URL попадает не только в ошибку транспорта (SLOG-37), но и в любую запись, куда URL попал целиком, — то есть обязывает помнить про санитизацию в каждой такой точке, и одна забытая сводит остальные на нет. Заголовок снимает задачу в источнике: чего нет в URL, того нет и в ошибке. @@ -533,7 +533,7 @@ API-ключи и токены, `Authorization`-заголовки, аутент **ДОЛЖЕН.** Сбор и ротацию делает окружение (docker, journald); по файлам не маршрутизируем. -**Почему.** Приложение, которое само решает, что куда писать, дублирует +**ПОЧЕМУ.** Приложение, которое само решает, что куда писать, дублирует работу супервизора и расходится с ней при первой же смене окружения: срок хранения, сжатие и ротация оказываются настроены в двух местах и по-разному. Один поток вдобавок сохраняет порядок записей — маршрутизация по файлам @@ -543,7 +543,7 @@ API-ключи и токены, `Authorization`-заголовки, аутент **ДОЛЖЕН.** `DEBUG` в проде включается конфигом. -**Почему.** Уровень — единственный регулятор объёма, доступный без +**ПОЧЕМУ.** Уровень — единственный регулятор объёма, доступный без пересборки; если `DEBUG` в проде включается только правкой кода, его не включают, и разбор инцидента идёт вслепую. `INFO` выбран базовым потому, что на нём аудит полон (SLOG-8.2), а рутинно-частое уже отсечено (SLOG-11.2). diff --git a/conventions/lang/go/time.md b/conventions/lang/go/time.md index b42257b..23767df 100644 --- a/conventions/lang/go/time.md +++ b/conventions/lang/go/time.md @@ -8,9 +8,9 @@ extends: arch/time.md Как требования базового слоя выполняются в Go-коде: откуда берётся «сейчас», в каком виде время попадает в базу и в логи, что делать с зонами. -Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и отметка -МЕХАНИЗИРОВАНО толкуются как описано в языке конвенций версии 1 — тогда и -только тогда, когда написаны заглавными. +Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки +ПОЧЕМУ и МЕХАНИЗИРОВАНО толкуются как описано в языке конвенций версии 2 — +тогда и только тогда, когда написаны заглавными. ## Правила @@ -19,7 +19,7 @@ extends: arch/time.md **ДОЛЖЕН.** Текущее время приходит из `store.Now()`, возвращающего `time.Now().UTC()`, а не из `time.Now()` по коду. -**Почему.** Единая точка даёт гарантированный UTC и один формат: ни одна +**ПОЧЕМУ.** Единая точка даёт гарантированный UTC и один формат: ни одна ветка кода не может о них забыть. Разъехавшиеся зоны чинятся только чтением всех записей — по метке `2026-06-28T11:23:45Z` уже не видно, была ли она когда-то локальной, и восстановить смещение задним числом не по чему. @@ -33,7 +33,7 @@ extends: arch/time.md **ДОЛЖЕН.** Пара функций поверх `time.RFC3339` — единственный способ получить строку времени и прочитать её обратно. -**Почему.** Layout, набранный по месту вызова, превращает формат хранения в +**ПОЧЕМУ.** Layout, набранный по месту вызова, превращает формат хранения в свойство каждой отдельной строки кода. Фиксированная ширина (GTIM-4) и взаимная обратимость записи и чтения держатся ровно до первого второго layout — а расхождение проявится не на записи, а при сравнении значений, @@ -49,7 +49,7 @@ layout — а расхождение проявится не на записи, | GTIM-3.1 | сама точка `store.Now()` | внутри неё вызов `time.Now()` и происходит | | GTIM-3.2 | обёртка измерения длительности | приведение к UTC срезает монотонную составляющую (GTIM-10) | -**Почему.** GTIM-1 без механической проверки держится на внимании, а +**ПОЧЕМУ.** GTIM-1 без механической проверки держится на внимании, а `time.Now()` пишется рефлекторно — и в новом коде, и в мелкой правке; нарушение молчит до тех пор, пока процесс не окажется в не-UTC зоне. Исключения перечисляются исчерпывающе, потому что каждое из них — само по @@ -63,7 +63,7 @@ layout — а расхождение проявится не на записи, `//nolint:forbidigo // <причина>` на строке вызова; exclude-записи в конфигурации линтера для него не заводятся. -**Почему.** Запись в конфиге — второй реестр тех же двух мест: она адресует +**ПОЧЕМУ.** Запись в конфиге — второй реестр тех же двух мест: она адресует исключение путём к файлу, отвязывается при переносе кода и продолжает разрешать `time.Now()` там, где исключения уже нет, — молча. Директива переезжает вместе с кодом, и grep по `nolint:forbidigo` даёт весь список — @@ -77,7 +77,7 @@ layout — а расхождение проявится не на записи, **ДОЛЖЕН.** Каноническое значение в колонке — `2026-06-28T11:23:45Z`. -**Почему.** Строки в колонке `TEXT` сравниваются побайтово, поэтому +**ПОЧЕМУ.** Строки в колонке `TEXT` сравниваются побайтово, поэтому лексикографический порядок совпадает с хронологическим только при одинаковых ширине и форме. Значение с долями секунды сортируется **раньше** целой секунды того же момента (`.` меньше `Z`), то есть ломаются и @@ -91,7 +91,7 @@ layout — а расхождение проявится не на записи, **НЕ ДОЛЖЕН.** Ни как layout вывода, ни как формат хранения. -**Почему.** Он отбрасывает хвостовые нули, из-за чего длина строки зависит +**ПОЧЕМУ.** Он отбрасывает хвостовые нули, из-за чего длина строки зависит от значения: соседние записи получают разную ширину, и свойство, на котором держится GTIM-4, исчезает незаметно. Проверка «формат корректен» при этом проходит — отказывает только порядок. @@ -102,7 +102,7 @@ layout — а расхождение проявится не на записи, к каноническому виду явно, а не считается каноническим по факту успешного разбора. -**Почему.** `time.Parse(time.RFC3339, …)` принимает и доли секунды, и +**ПОЧЕМУ.** `time.Parse(time.RFC3339, …)` принимает и доли секунды, и офсеты, отличные от `Z`, — разбор шире вывода. Канонический вид гарантирует **писатель**, а не читатель; пока писатель один, этого достаточно, но значение из чужой системы, положенное в базу как пришло, нарушает GTIM-4 и @@ -114,7 +114,7 @@ Go-механика, из-за которой его легко нарушить **СЛЕДУЕТ.** В запрос идёт результат `FormatTime`. -**Почему.** Колонка — `TEXT`, и передача `time.Time` отдаёт форматирование +**ПОЧЕМУ.** Колонка — `TEXT`, и передача `time.Time` отдаёт форматирование драйверу: появляется вторая точка формата вне `FormatTime` (GTIM-2), с собственным layout, который меняется вместе с версией драйвера, а не вместе с конвенцией. @@ -132,7 +132,7 @@ func utcTime(_ []string, a slog.Attr) slog.Attr { } ``` -**Почему.** `slog` по умолчанию UTC не даёт: встроенные хендлеры пишут +**ПОЧЕМУ.** `slog` по умолчанию UTC не даёт: встроенные хендлеры пишут время в зоне самого `time.Time`, то есть в локальной зоне процесса — на ноутбуке разработчика логи молча уезжают в `+03:00`. Умолчание тихое, неверная зона выглядит как совершенно валидное время, а записи из разных @@ -143,7 +143,7 @@ func utcTime(_ []string, a slog.Attr) slog.Attr { **ДОПУСКАЕТСЯ.** `JSONHandler` пишет миллисекунды — три знака, и это не приводится к секундной точности GTIM-4. -**Почему.** Явное разрешение нужно, чтобы GTIM-4 не читался как требование +**ПОЧЕМУ.** Явное разрешение нужно, чтобы GTIM-4 не читался как требование одной точности везде. Ширина фиксируется на носитель: три знака в логе — такая же фиксированная ширина, и свойство, ради которого GTIM-4 существует, не нарушено. Общее у лога и базы одно — зона (GTIM-8). @@ -153,7 +153,7 @@ func utcTime(_ []string, a slog.Attr) slog.Attr { **ДОПУСКАЕТСЯ.** Обёртка вызывает `time.Now()` и считает `time.Since` — с локальным `//nolint`. -**Почему.** `store.Now()` приводит время к UTC через `.UTC()`, а это +**ПОЧЕМУ.** `store.Now()` приводит время к UTC через `.UTC()`, а это срезает монотонную составляющую `time.Time`. Интервал, посчитанный по таким меткам, зависит от подводки часов: перевод назад даёт отрицательную длительность, скачок вперёд — выброс в измерениях, и оба случая @@ -164,7 +164,7 @@ func utcTime(_ []string, a slog.Attr) slog.Attr { **ДОЛЖЕН.** База зон вшивается в бинарь. -**Почему.** Без неё `LoadLocation` зависит от файлов зон в системе, которых +**ПОЧЕМУ.** Без неё `LoadLocation` зависит от файлов зон в системе, которых в минимальном образе нет: отказ происходит в рантайме, на первой же попытке применить зону, — то есть после выкладки, а не на сборке. Импорт именно в `main` держит это решение в одном видимом месте, а не в случайном пакете, @@ -175,7 +175,7 @@ func utcTime(_ []string, a slog.Attr) slog.Attr { **ДОЛЖЕН.** Зона из конфига используется в шаблонах и форматтерах представления, но не в хранимых значениях и не в вычислениях. -**Почему.** Зона отображения — настройка, и её меняют. Протекая в +**ПОЧЕМУ.** Зона отображения — настройка, и её меняют. Протекая в вычисления и хранение, она делает уже записанные данные зависимыми от текущего значения настройки: смена зоны задним числом сдвигает границы суток у того, что давно посчитано и сохранено. diff --git a/conventions/stack/ansible/app-directories.md b/conventions/stack/ansible/app-directories.md index 2483bd9..54ba011 100644 --- a/conventions/stack/ansible/app-directories.md +++ b/conventions/stack/ansible/app-directories.md @@ -7,9 +7,9 @@ extends: arch/app-directories.md Как категории из базового слоя раскладываются на сервере плейбуком. -Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и отметка -МЕХАНИЗИРОВАНО толкуются как описано в языке конвенций версии 1 — тогда и -только тогда, когда написаны заглавными. +Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки +ПОЧЕМУ и МЕХАНИЗИРОВАНО толкуются как описано в языке конвенций версии 2 — +тогда и только тогда, когда написаны заглавными. ## Область действия @@ -27,7 +27,7 @@ extends: arch/app-directories.md состоит из нескольких директорий, имя даётся по содержимому (`media_dir`, `uploads_dir`, `dumps_dir`). -**Почему.** Переменная — единственная ссылка, которую разделяют задача +**ПОЧЕМУ.** Переменная — единственная ссылка, которую разделяют задача создания директории и список бэкапа (ANSD-4). Литерал пути в одном из этих мест означает, что переименование директории молча разойдётся с бэкапом, и обнаружится это при восстановлении. @@ -36,7 +36,7 @@ extends: arch/app-directories.md **СЛЕДУЕТ.** Список директорий в единственной задаче создания. -**Почему.** Этот список — единственное место, где декларировано всё, что +**ПОЧЕМУ.** Этот список — единственное место, где декларировано всё, что приложение пишет на диск. Разнесённое по нескольким задачам создание отвечает на вопрос «какие директории есть у приложения» только чтением всего плейбука, а именно этот вопрос задают при заведении бэкапа и при @@ -48,7 +48,7 @@ extends: arch/app-directories.md (`app_owner_uid == app_owner_gid`) или общий `primary_user` — выбирается на репозиторий и фиксируется ниже. -**Почему.** Правило про соответствие владельца рантайму, а не про +**ПОЧЕМУ.** Правило про соответствие владельца рантайму, а не про конкретную модель: приложение в контейнере пишет от определённого uid, и если директория принадлежит другому, отказ произойдёт не при деплое, а при первой записи — то есть после того, как плейбук отчитался об успехе. Выбор @@ -61,7 +61,7 @@ extends: arch/app-directories.md **ДОЛЖЕН.** Плейбук кладёт в `base_dir` файл `backup-targets`, строки которого ссылаются на переменные `*_dir` из ANSD-1, а не на литеральные пути. -**Почему.** Правило вывода списка механическое (ANSD-5), но применяет его +**ПОЧЕМУ.** Правило вывода списка механическое (ANSD-5), но применяет его человек или шаблон — то есть ошибиться можно. Общая переменная делает целый класс ошибок невозможным: переименовал директорию — переименовалось в обоих местах. Независимо набранный список расходится тихо и проявляется в @@ -72,7 +72,7 @@ extends: arch/app-directories.md **ДОЛЖЕН.** Директории категории «данные», включая директорию дампов, — в списке; конфигурация и кеш — нет. -**Почему.** Реализация правила базовой конвенции. Кеш раздувает снапшот без +**ПОЧЕМУ.** Реализация правила базовой конвенции. Кеш раздувает снапшот без пользы, а конфигурация содержит отрендеренные секреты — бэкап уезжает в облако, и источником истины для секретов остаётся vault, а не снапшот. @@ -80,7 +80,7 @@ extends: arch/app-directories.md **СЛЕДУЕТ.** В compose конфигурация подключается с `:ro`. -**Почему.** Плейбук — источник истины для конфигурации, и `:ro` превращает +**ПОЧЕМУ.** Плейбук — источник истины для конфигурации, и `:ro` превращает это из договорённости в свойство системы: приложение, которое втихую переписывает свой конфиг, падает сразу, а не расходится с репозиторием незаметно. Приложение, которому запись в конфиг нужна по устройству, @@ -90,7 +90,7 @@ extends: arch/app-directories.md **ДОЛЖЕН.** Файл не переносится во вложенную директорию. -**Почему.** Туда смотрит `project_src` модуля `docker_compose_v2`. Правило +**ПОЧЕМУ.** Туда смотрит `project_src` модуля `docker_compose_v2`. Правило внешнее по происхождению, но нарушается легко — при попытке «навести порядок» и убрать compose в `config/`, где ему по смыслу категорий было бы место. @@ -100,7 +100,7 @@ extends: arch/app-directories.md **СЛЕДУЕТ.** Значения приходят из vault-переменных и попадают в файл, принадлежащий пользователю приложения. -**Почему.** Файл под `0600` не наследуется дочерними процессами, не виден в +**ПОЧЕМУ.** Файл под `0600` не наследуется дочерними процессами, не виден в `docker inspect` и не оседает в compose-файле на диске. Это те же три довода, по которым базовая конвенция конфигурации выбирает файл вместо окружения. @@ -109,7 +109,7 @@ extends: arch/app-directories.md **ДОПУСКАЕТСЯ.** Задача рендера идёт с `no_log: true`. -**Почему.** Явное разрешение нужно, чтобы ANSD-8 не читался как запрет на +**ПОЧЕМУ.** Явное разрешение нужно, чтобы ANSD-8 не читался как запрет на деплой такого приложения. Способ вынужденный: секрет попадает в метаданные контейнера и в compose-файл на диске. Приложение, научившееся читать секреты из файла, переводится на ANSD-8 при ближайшем касании. diff --git a/conventions/stack/htmx/web-ui.md b/conventions/stack/htmx/web-ui.md index 7ace540..ec698ae 100644 --- a/conventions/stack/htmx/web-ui.md +++ b/conventions/stack/htmx/web-ui.md @@ -8,9 +8,9 @@ prefix: HTMX обработчики действий, деградация без JS, ошибки. Что именно UI показывает и какие действия поддерживает — в спеках, не здесь. -Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и отметка -МЕХАНИЗИРОВАНО толкуются как описано в языке конвенций версии 1 — тогда и -только тогда, когда написаны заглавными. +Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки +ПОЧЕМУ и МЕХАНИЗИРОВАНО толкуются как описано в языке конвенций версии 2 — +тогда и только тогда, когда написаны заглавными. Логирование запросов — конвенция `logging` (HTTP-поля, рутинно-частое на `DEBUG`). Трансляция доменных ошибок наружу — конвенция `errors` @@ -30,7 +30,7 @@ prefix: HTMX **ДОЛЖЕН.** UI собирается из серверных шаблонов и htmx — без шага сборки, без Node и бандлера, без реактивного фреймворка. -**Почему.** Шаг сборки — это второй язык, второй менеджер зависимостей и +**ПОЧЕМУ.** Шаг сборки — это второй язык, второй менеджер зависимостей и артефакт, который расходится с исходником; приложению, где разметку целиком отдаёт сервер, он не покупает ничего. Реактивный фреймворк добавляет вторую модель состояния рядом с серверной (HTMX-2), и дальше на каждом экране @@ -44,7 +44,7 @@ prefix: HTMX (копирование в буфер обмена и подобное); доменное состояние считает сервер, клиент свопит присланную разметку. -**Почему.** Пересчёт на клиенте — вторая реализация той же логики, которую +**ПОЧЕМУ.** Пересчёт на клиенте — вторая реализация той же логики, которую никто не сверяет: расходится она тихо, а проявляется как «на экране одно, в базе другое». Вдобавок клиентский пересчёт по определению не работает в деградированном режиме (HTMX-11, HTMX-12) — значит, серверную версию того же @@ -56,7 +56,7 @@ prefix: HTMX только когда есть виджет, которому он действительно нужен, и отдельным решением. -**Почему.** Реактивный слой, попавший в проект ради одного выпадающего +**ПОЧЕМУ.** Реактивный слой, попавший в проект ради одного выпадающего списка, немедленно доступен всему остальному коду — и граница HTMX-1/HTMX-2 перестаёт держаться сама собой. Отдельное решение — единственный момент, когда цену видно целиком: она не в килобайтах, а в том, что дальше на @@ -70,7 +70,7 @@ prefix: HTMX `partials/`, и он же рендерится инлайн на странице и как ответ-фрагмент обработчика; отдельной разметки под фрагмент нет. -**Почему.** Две копии одной разметки расходятся молча: правку вносят в ту, +**ПОЧЕМУ.** Две копии одной разметки расходятся молча: правку вносят в ту, что открыта, и страница начинает выглядеть иначе, чем результат свопа того же региона. Заметно это становится только на глаз и только тому, кто открыл оба пути подряд. @@ -80,7 +80,7 @@ prefix: HTMX **ДОЛЖЕН.** Корневой узел шаблона несёт тот `id`, по которому адресуют регион, и ответный фрагмент несёт тот же `id`. -**Почему.** `hx-swap="outerHTML"` заменяет корневой узел целиком, вместе с +**ПОЧЕМУ.** `hx-swap="outerHTML"` заменяет корневой узел целиком, вместе с его атрибутами. Если пришедший фрагмент несёт другой `id` или не несёт его вовсе, первый своп проходит успешно, а следующее действие и поллер уже не находят таргет: регион застывает без единой ошибки — ни в консоли, ни в @@ -91,7 +91,7 @@ prefix: HTMX **СЛЕДУЕТ.** Один view-builder зовут и обработчик полной страницы, и htmx-ветка. -**Почему.** Общий шаблон (HTMX-4) гарантирует одинаковую разметку, но не +**ПОЧЕМУ.** Общий шаблон (HTMX-4) гарантирует одинаковую разметку, но не одинаковые данные: скопированная сборка view расходится по набору полей, и фрагмент начинает показывать не то, что показала бы страница. Это ровно тот класс расхождений, который HTMX-4 закрывает для разметки. @@ -124,7 +124,7 @@ if actionErr != nil { s.render(w, "source_block", view) // фрагмент = тот же шаблон ``` -**Почему.** Ветвление до вызова даёт две реализации одного действия, и +**ПОЧЕМУ.** Ветвление до вызова даёт две реализации одного действия, и дальше дефект воспроизводится только на одной поверхности — причём деградированный путь (HTMX-11) открывают реже, то есть чинить будут не тот. Перечитанное состояние в htmx-ветке нужно потому, что своп заменяет регион @@ -136,7 +136,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб **ДОЛЖЕН.** Именованный шаблон собирается целиком в буфер, и только затем буфер пишется в ответ. -**Почему.** Прямая запись в ответ отправляет клиенту статус и часть +**ПОЧЕМУ.** Прямая запись в ответ отправляет клиенту статус и часть разметки раньше, чем шаблон дошёл до ошибки: сообщить об отказе уже нечем, а htmx свопит в DOM полученный обрывок. Внешне это «исчезла половина региона», и причина по такому симптому не читается. @@ -149,7 +149,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб отдаётся в том же ответе с `hx-swap-oob="true"` — обычным именованным партиалом с тем же `id`, что и на странице (HTMX-4, HTMX-5). -**Почему.** Второй запрос с клиента вводит гонку: два ответа считают +**ПОЧЕМУ.** Второй запрос с клиента вводит гонку: два ответа считают состояние в разные моменты и приезжают в произвольном порядке, поэтому панель действий может отразить состояние до действия. Плюс лишний раунд-трип на каждое действие. @@ -159,7 +159,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб **ДОПУСКАЕТСЯ.** `HX-Trigger` в ответе плюс отдельный `hx-get`, если второй регион меняется не на каждое действие. -**Почему.** Явное разрешение нужно, чтобы HTMX-9 не читался как запрет любого +**ПОЧЕМУ.** Явное разрешение нужно, чтобы HTMX-9 не читался как запрет любого второго запроса. Когда регион обновляется редко, oob-ветка гоняет одинаковую разметку на каждое действие и связывает два шаблона там, где связи нет; гонка же тем менее наблюдаема, чем реже обновление. @@ -172,7 +172,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб `hx-post`/`hx-target`/`hx-swap` накладываются сверху; `action` ведёт на рабочий обработчик. -**Почему.** htmx может не загрузиться — ошибка вендоринга, блокировщик, +**ПОЧЕМУ.** htmx может не загрузиться — ошибка вендоринга, блокировщик, медленная сеть, — и без рабочего `action` форма в этот момент не отправляет ничего, молча. Тот же `action` — единственное, что делает действие проверяемым без браузера с JS. @@ -182,7 +182,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб **ДОЛЖЕН.** Отбор списка задаётся GET-параметрами и выполняется на сервере; клиентской фильтрации загруженной разметки нет. -**Почему.** Клиент видит только текущую страницу списка, поэтому клиентский +**ПОЧЕМУ.** Клиент видит только текущую страницу списка, поэтому клиентский фильтр отвечает по неполным данным и делает это молча — результат выглядит валидным. Вдобавок состояние отбора в query переживает своп (HTMX-25) и перезагрузку, его можно послать ссылкой и увидеть в логе. @@ -196,7 +196,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб | HTMX-13.1 | действия и навигация | работают полностью (HTMX-11, HTMX-12) | | HTMX-13.2 | интерактивный виджет выбора, у которого нет осмысленного не-JS поведения | может не работать; записывается в отступления | -**Почему.** Без явной границы правило деградации читается как запрет на +**ПОЧЕМУ.** Без явной границы правило деградации читается как запрет на любой JS-виджет — и тогда его либо тихо нарушают, либо отказываются от виджета, который был нужен. Запись в отступления держит список честным: видно, какие именно места ломаются с выключенным JS, а не «где-то @@ -209,7 +209,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб **ДОЛЖЕН.** Провалившееся действие отдаёт статус 200 и фрагмент с сообщением; доменная ошибка на htmx-пути не транслируется в HTTP-статус. -**Почему.** В htmx 2.x ответы 4xx/5xx по умолчанию не свопят DOM — то есть +**ПОЧЕМУ.** В htmx 2.x ответы 4xx/5xx по умолчанию не свопят DOM — то есть пользователь не увидит ничего. Своп ошибочных ответов настраивается (`htmx.config.responseHandling`, расширение `response-targets`), но любая такая настройка — свой JS-конфиг на клиенте, и платится она из HTMX-1 и HTMX-2. @@ -228,7 +228,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб ошибочных ответов в целевые регионы (`htmx.config.responseHandling`, `response-targets`) не настраивается. -**Почему.** HTMX-14 закрывает доменный отказ, до которого обработчик дошёл. +**ПОЧЕМУ.** HTMX-14 закрывает доменный отказ, до которого обработчик дошёл. Паника, сбой шаблона и обрыв сети отдают 5xx или ничего, htmx 2.x такое не свопит — регион не меняется, интерфейс замирает без единого признака сбоя, и пользователь повторяет действие, которое могло уже примениться. Слушатель @@ -244,7 +244,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб **ДОЛЖЕН.** Во фрагмент попадает нейтральный текст публичного канала; `err.Error()` в разметку не рендерится. -**Почему.** htmx-фрагмент выглядит внутренней деталью приложения, и на нём +**ПОЧЕМУ.** htmx-фрагмент выглядит внутренней деталью приложения, и на нём легче всего забыть, что это тот же публичный канал, что и страница: разметка уезжает в браузер пользователя целиком. Статус 200 (HTMX-14) дополнительно снимает ощущение «это ошибочный ответ, его никто не увидит». @@ -254,7 +254,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб **ДОЛЖЕН.** У view есть поле под ошибку действия; доменные поля под сообщение не переиспользуются. -**Почему.** У доменного поля может быть своё непустое значение, и сообщение +**ПОЧЕМУ.** У доменного поля может быть своё непустое значение, и сообщение его перекроет: пользователь получит текст ошибки вместо данных, а шаблон — необходимость угадывать, что сейчас лежит в поле. Отдельное поле делает оба состояния — данные и ошибку — выразимыми одновременно, а это ровно то, чего @@ -265,7 +265,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб **НЕ ДОЛЖЕН.** Фрагмент, отданный после неудачного действия, показывает прежний выбор плюс сообщение. -**Почему.** Своп заменяет регион целиком, поэтому фрагмент — единственное, +**ПОЧЕМУ.** Своп заменяет регион целиком, поэтому фрагмент — единственное, что пользователь узнает о состоянии. Показав намеренное состояние вместо фактического, интерфейс расходится с сервером, и следующее действие человек делает по ложной картине — на сервере оно применится к другому объекту. @@ -288,7 +288,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб **ДОЛЖЕН.** Когда состояние вышло из «живого», фрагмент возвращается без `hx-*`-атрибутов. -**Почему.** Иначе опрос не прекращается никогда: каждая открытая вкладка +**ПОЧЕМУ.** Иначе опрос не прекращается никогда: каждая открытая вкладка держит постоянный поток запросов за неизменными данными, и закрывает его только пользователь. Условие остановки живёт в разметке ответа, потому что это единственный канал, которым сервер управляет поллером. @@ -302,7 +302,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб **ДОЛЖЕН.** Признак «живо ли ещё» вычисляется по состоянию, которым владеет приложение, а не по ответу внешнего сервиса. -**Почему.** Внешний сервис отвечает не всегда и не одинаково: на его +**ПОЧЕМУ.** Внешний сервис отвечает не всегда и не одинаково: на его недоступности поллер либо останавливается, пока работа идёт, либо не останавливается никогда. Приложение — единственный участник, который знает про операцию всё и может ответить на каждом тике. @@ -312,7 +312,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб **ДОЛЖЕН.** Тик заменяет весь фрагмент (`hx-swap="outerHTML"`), а не его содержимое. -**Почему.** `outerHTML` удаляет старый узел вместе с его `hx-trigger` и +**ПОЧЕМУ.** `outerHTML` удаляет старый узел вместе с его `hx-trigger` и инициализирует новый — так поллер живёт ровно в одном экземпляре и так же выключается (HTMX-18). Своп содержимого оставил бы старый узел с его таймером, и через несколько обновлений опрос шёл бы в несколько потоков. Работает это @@ -323,7 +323,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб **НЕ ДОЛЖЕН.** Живость включается только в тех состояниях фрагмента, где редактировать нечего. -**Почему.** Своп поддерева теряет фокус, выделение и незасабмиченный текст +**ПОЧЕМУ.** Своп поддерева теряет фокус, выделение и незасабмиченный текст внутри него. У поллера это происходит по таймеру, то есть в момент, который пользователь не выбирал: текст исчезает посреди набора и воспроизводится как «приложение стирает мой ввод». @@ -332,7 +332,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб **НЕ ДОЛЖЕН.** Поллинг и прочие запросы страницы идут на свой сервер. -**Почему.** Прямой запрос из браузера выносит наружу адрес и учётные данные +**ПОЧЕМУ.** Прямой запрос из браузера выносит наружу адрес и учётные данные внешнего сервиса и делает страницу заложником его CORS-политики. Вдобавок контракт внешнего сервиса протекает в разметку: его смена перестаёт быть серверным изменением. @@ -346,7 +346,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб | HTMX-23.1 | состояние внешнего сервиса | in-memory снимок, обновляемый воркером | | HTMX-23.2 | собственное состояние приложения | своё хранилище; снимок не требуется | -**Почему.** Тик умножается на число открытых вкладок, поэтому сеть на +**ПОЧЕМУ.** Тик умножается на число открытых вкладок, поэтому сеть на каждом тике превращает интерфейс в генератор нагрузки на внешний сервис — и его недоступность становится недоступностью страницы. Снимок разрывает эту связь: частоту обращений к внешнему сервису задаёт воркер, а не @@ -365,7 +365,7 @@ hx-get="/item/{{.ID}}" hx-trigger="every 3s" hx-select="#item-main" hx-swap="outerHTML" ``` -**Почему.** Отдельный `/fragments/…`-роут в этом случае дублирует +**ПОЧЕМУ.** Отдельный `/fragments/…`-роут в этом случае дублирует обработчик страницы целиком — вместе с перечитыванием состояния и сборкой view, — и дальше два обработчика расходятся по тому же сценарию, что и две копии разметки (HTMX-4). @@ -379,7 +379,7 @@ view, — и дальше два обработчика расходятся п **НЕ ДОЛЖЕН.** Такое действие свопит свой регион на месте. -**Почему.** Своп сохраняет прокрутку и не трогает серверные фильтр, поиск и +**ПОЧЕМУ.** Своп сохраняет прокрутку и не трогает серверные фильтр, поиск и пагинацию — они в query (HTMX-12). Полная навигация ради изменения одного региона возвращает пользователя в начало списка и стоит перерисовки всей страницы. Не сохраняется при свопе только контекст внутри самого @@ -390,7 +390,7 @@ view, — и дальше два обработчика расходятся п **ДОЛЖЕН.** Действие, после которого предмет покидает страницу, остаётся обычной POST-формой без htmx-атрибутов, то есть полной навигацией. -**Почему.** Своп для такого действия оставил бы на месте регион, +**ПОЧЕМУ.** Своп для такого действия оставил бы на месте регион, описывающий объект, которого на странице больше нет. Отсутствие htmx-атрибутов при этом само работает маркером «это выход»: намерение видно прямо в разметке, и не нужен `HX-Redirect` — то есть ещё один способ @@ -401,7 +401,7 @@ htmx-атрибутов при этом само работает маркеро **ДОЛЖЕН.** Если работу доделывает воркер, ответ на действие показывает промежуточное состояние, а итог догоняет самозавершающийся поллер (HTMX-18). -**Почему.** Мнимый результат расходится с сервером до следующего тика, и +**ПОЧЕМУ.** Мнимый результат расходится с сервером до следующего тика, и всё это время пользователь принимает решения по несуществующему исходу — включая повтор действия, которое на самом деле выполняется. Промежуточное состояние вдобавок объясняет, почему регион продолжает обновляться сам. @@ -414,7 +414,7 @@ htmx-атрибутов при этом само работает маркеро фрагментом, поверхность передаётся явным скрытым полем (`surface=list|detail`), а не выводится из `HX-Target` или `Referer`. -**Почему.** `HX-Target` — свойство разметки вызывающей страницы, `Referer` +**ПОЧЕМУ.** `HX-Target` — свойство разметки вызывающей страницы, `Referer` может не прийти вовсе; и то и другое меняется без участия обработчика, и ответ начинает приходить не тем фрагментом. Поле же видно в форме рядом с действием, поэтому связь «эта страница → этот фрагмент» читается там, где @@ -426,7 +426,7 @@ htmx-атрибутов при этом само работает маркеро 400, когда поля `surface` в запросе нет; поверхность по умолчанию не выбирается. -**Почему.** Поле кладёт в форму наш же шаблон, поэтому его отсутствие — +**ПОЧЕМУ.** Поле кладёт в форму наш же шаблон, поэтому его отсутствие — дефект формы, а не вход пользователя. Поверхность по умолчанию маскирует такой дефект молча неверным фрагментом: своп с чужим `id` проходит, после чего регион перестаёт находиться таргетом (HTMX-5), и ошибка воспроизводится @@ -446,7 +446,7 @@ htmx-атрибутов при этом само работает маркеро **ДОЛЖЕН.** Статика подключается через `go:embed` и отдаётся с `Cache-Control: public, max-age=31536000, immutable`. -**Почему.** Встроенные ассеты делают деплой одним артефактом: нет второго +**ПОЧЕМУ.** Встроенные ассеты делают деплой одним артефактом: нет второго шага раскладки файлов, который может отстать от бинаря и оставить новую разметку со старым css. Иммутабельный кэш безопасен ровно потому, что URL меняется вместе с содержимым (HTMX-30, HTMX-31); без этого условия год кэша @@ -457,7 +457,7 @@ htmx-атрибутов при этом само работает маркеро **ДОЛЖЕН.** css и js адресуются с `?v=<короткий sha256 содержимого>`, и URL строит хелпер шаблона. -**Почему.** Хеш содержимого — единственная версия, которую невозможно +**ПОЧЕМУ.** Хеш содержимого — единственная версия, которую невозможно забыть обновить: она меняется от самой правки. Ручной номер и дата сборки от этого не защищают, а цена промаха при иммутабельном кэше (HTMX-29) — устаревший файл у пользователя до ручной очистки кэша. Хелпер нужен, чтобы @@ -468,7 +468,7 @@ htmx-атрибутов при этом само работает маркеро **ДОПУСКАЕТСЯ.** Вендор адресуется по неизменному имени файла, без параметра версии. -**Почему.** Содержимое под этим именем не меняется: обновление вендора +**ПОЧЕМУ.** Содержимое под этим именем не меняется: обновление вендора приходит новым именем файла, то есть новым URL. Кэш-бастер защищает от подмены содержимого под тем же адресом, а такой ситуации здесь нет — и явное разрешение снимает вопрос, не нарушает ли это HTMX-30. @@ -479,7 +479,7 @@ htmx-атрибутов при этом само работает маркеро (`путь url sha256`) с проверкой контрольной суммы; сборка зависит от этой задачи. -**Почему.** Манифест делает версию и происхождение ассета видимыми в +**ПОЧЕМУ.** Манифест делает версию и происхождение ассета видимыми в diff'е — у закоммиченного минифицированного файла обновление выглядит стеной непрозрачных изменений, и подмену в ней не разглядеть. Sha256 — единственная проверка, что скачали то же самое, что проверяли; зависимость @@ -489,7 +489,7 @@ diff'е — у закоммиченного минифицированного **ДОЛЖЕН.** Внешних хостов во время выполнения нет. -**Почему.** Каждый внешний хост — это чужой аптайм внутри своей страницы и +**ПОЧЕМУ.** Каждый внешний хост — это чужой аптайм внутри своей страницы и третья сторона, видящая каждый запрос пользователя. Самодостаточный бинарь вдобавок разворачивается в сети без выхода наружу, где CDN просто не отвечает.