ПОЧЕМУ стало ключевым словом, язык поднят до версии 2

- метка обоснования пишется заглавными и вошла в словарь набора: скелет
  правила теперь целиком из ключевых слов, а не смесь `**ДОЛЖЕН.**` и
  `**Почему.**`; в переводе на другой язык метка меняется как остальные слова
  (ПОЧЕМУ / WHY), 235 вхождений заменены
- метки правила выделены из шкалы в отдельный перечень: ПОЧЕМУ и
  МЕХАНИЗИРОВАНО обязательности не задают, а размечают части, и стандартом не
  даются ни в одном языке — раньше МЕХАНИЗИРОВАНО висело строкой в таблице
  модальности
- версия языка поднята до 2, потому что изменение формы меняет чтение уже
  написанного текста; строка о версии в двенадцати конвенциях перечисляет
  теперь и метки, а служебные слова сценария в неё по-прежнему не входят
This commit is contained in:
av
2026-07-26 14:28:37 +03:00
parent c8071dc438
commit 72d77d74bf
16 changed files with 346 additions and 318 deletions
+11 -8
View File
@@ -18,7 +18,7 @@ code in this repository.
## Форма правила
- Четыре обязательные части: `### <ПРЕФИКС>-<N>. Заголовок`, абзац
`**МОДАЛЬНОСТЬ.** норма`, абзац `**Почему.** …`. Правило без «Почему» не
`**МОДАЛЬНОСТЬ.** норма`, абзац `**ПОЧЕМУ.** …`. Правило без обоснования не
принимается.
- Норма — одна фраза; если в неё не влезает, это два правила.
- Шкала инвариантна, словарь — параметр языка набора. Канон русский, значит
@@ -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: конвенция
заводится, когда решение принимается третий раз.
+30 -30
View File
@@ -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 | запрет слов обязательства в обосновании | правило о заглавных уже делает строчное слово ненормативным, а форму обоснования рамками не ограничивают |
+59 -34
View File
@@ -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
- заголовки правил файла используют только его собственный префикс;
- номера уникальны внутри файла и не имеют пропусков вниз (новое правило
берёт следующий свободный, а не первый освободившийся);
- у каждого `### <ПРЕФИКС>-<n>` есть модальное слово и блок «Почему»;
- у каждого `### <ПРЕФИКС>-<n>` есть модальное слово и блок ПОЧЕМУ;
отметка МЕХАНИЗИРОВАНО стоит рядом с модальностью, а не вместо неё;
- вводная проза содержит строку о версии языка;
- ссылки вида `<ПРЕФИКС>-<n>` — хоть в тексте канона, хоть в локальной части
@@ -405,3 +418,15 @@ MIGR-6 не соблюдается в легаси-таблицах `show_histor
- перечисленные в таблице случаи покрывают область действия;
- норма исполнима без обращения к другим файлам;
- обоснование отвечает на «что сломается», а не пересказывает норму.
## История версий
Номер версии называется в каждой конвенции, поэтому изменение формы, способное
изменить чтение уже написанного текста, меняет и номер. Смена словаря под
другой естественный язык версию не двигает: версия принадлежит шкале, меткам и
правилам формы, а не буквам.
| Версия | Что изменилось |
|---|---|
| 1 | первая запись языка: шкала из BCP 14, обязательное обоснование, идентификаторы, таблицы решений |
| 2 | обоснование получило метку ПОЧЕМУ заглавными и вошло в словарь набора; метки правила выделены из шкалы в отдельный перечень |
+1 -1
View File
@@ -10,7 +10,7 @@
| Файл | Что описывает |
|---|---|
| `README.md` | устройство канона, оси, сборка копий, жизненный цикл |
| [LANGUAGE.md](LANGUAGE.md) | язык записи правил: идентификаторы, модальность, «Почему» |
| [LANGUAGE.md](LANGUAGE.md) | язык записи правил: идентификаторы, модальность, обоснование |
| [GUIDE.md](GUIDE.md) | как ведут конвенции: когда заводить, механизация, отступления |
| `prefixes.toml` | реестр префиксов правил |
| `conv` | сборка копий |
+12 -12
View File
@@ -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), поэтому
всё, что приложение туда записало, следующий деплой затирает без
предупреждения. Вдобавок директория конфигурации может быть подключена
только на чтение — тогда запись отказывает в рантайме. Ни то ни другое не
+24 -24
View File
@@ -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
- **единицы измерения**, если применимо: секунды/миллисекунды, байты, доля
`01`.
**Почему.** Так конфиг читается без открывания кода — этим он и полезен;
**ПОЧЕМУ.** Так конфиг читается без открывания кода — этим он и полезен;
без комментария читатель всё равно идёт в код, и образец перестаёт быть
справочником. Единицы стоят отдельно: перепутанные секунды и миллисекунды
дают валидное значение и работающий процесс, а ошибка обнаруживается по
@@ -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; ошибки вида «секунды вместо миллисекунд»
или пробел в конце значения из такого сообщения не читаются вовсе. Значение
секретного поля при этом печатать некуда: вывод старта уходит в лог
+10 -10
View File
@@ -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` по голому значению собирает все
упоминания сущности в логах независимо от имени поля. Второй формат
идентификаторов эту возможность отменяет ровно для тех записей, где она
+16 -16
View File
@@ -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
**ДОЛЖЕН.** «Сегодня», «за месяц» и прочие календарные границы считаются с
явно переданной зоной, а не с системной зоной процесса.
**Почему.** Системная зона разная на ноутбуке разработчика и в контейнере на
**ПОЧЕМУ.** Системная зона разная на ноутбуке разработчика и в контейнере на
сервере, поэтому граница суток уезжает, а вместе с ней — содержимое отчёта:
расхождение не воспроизводится там, где его заметили, и объясняется средой,
а не кодом. Явно переданная зона делает результат функцией от аргументов.
+18 -18
View File
@@ -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` при нём не выполняются вовсе. Выход именно
до старта воркеров: горутина, поднятая раньше валидации, успевает сходить
+12 -12
View File
@@ -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 её искал. Сфабрикованный транспортом,
он утверждает непроверенное, и по типу ошибки перестаёт быть видно, был ли
+15 -15
View File
@@ -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`, поэтому после
удаления последней строки номер переиспользуется. Протухшая ссылка на
удалённую запись — из закладки, из чужой таблицы, из старого лога — молча
наводится на другую сущность и возвращает правдоподобный, но чужой ответ.
+29 -29
View File
@@ -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` по такому результату не
находит ничего, и вызывающий остаётся с текстом, матчить который запрещено
+43 -43
View File
@@ -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) отличает падение от штатной оста
**ДОЛЖЕН.** Поле `<entity>_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).
+16 -16
View File
@@ -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 {
**ДОЛЖЕН.** Зона из конфига используется в шаблонах и форматтерах
представления, но не в хранимых значениях и не в вычислениях.
**Почему.** Зона отображения — настройка, и её меняют. Протекая в
**ПОЧЕМУ.** Зона отображения — настройка, и её меняют. Протекая в
вычисления и хранение, она делает уже записанные данные зависимыми от
текущего значения настройки: смена зоны задним числом сдвигает границы
суток у того, что давно посчитано и сохранено.
+12 -12
View File
@@ -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 при ближайшем касании.
+38 -38
View File
@@ -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 просто не
отвечает.