Compare commits

..
51 Commits
Author SHA1 Message Date
av 271603122d манифест приведён к машинному виду, объявлен governance
- добавлен ключ governance: без него конвенция, потерявшая topic, была
  неотличима от GUIDE.md и тихо теряла проверки об отъезде к потребителю
- комментарии из манифеста убраны — их всё равно съела бы первая же
  команда; то, чего не было в README.md, дописано туда
2026-07-28 10:16:39 +03:00
av 34d53d667d удалены TOOL.md и conv, инструкции переведены на convy
- TOOL.md был стартовой точкой разработки инструмента и свою задачу
  выполнил: решения о нём теперь живут в его собственном репозитории,
  открытые вопросы перенесены туда же
- питоновский conv собран под прежнюю модель копий (зеркальное дерево,
  именованные регионы, origin_hash) и удалён вместе с ней
- команды в README.md переписаны на convy, включая suite-сторону и sync
2026-07-28 10:05:22 +03:00
av 0d335ff58d манифест набора переименован в .conventions-suite.toml
- оба манифеста теперь данные инструмента: он их читает и переписывает
  целиком, комментариев они не держат — точка в начале ставит их рядом
  со служебными файлами, а не среди содержимого репозитория
- прежнее основание из TOOL.md («в наборе файл правится при каждой новой
  теме, и прятать его незачем») отпало: правит его convy
- ссылки в README.md, CLAUDE.md, TOOL.md и TODO.md обновлены
2026-07-28 09:54:52 +03:00
av 9a3a89358f logging: убран extends на чужую тему
- шапка `lang/go/logging.md` объявляла базой `arch/time.md` — копипаста
  из соседнего `lang/go/time.md`, единственного файла с этой базой
- у темы `logging` арх-слоя нет вовсе, расширять было нечего; ключ
  вернётся сам, когда невыделенное ядро уедет в `arch/logging.md`
- `convy suite check` на наборе проходит чисто
2026-07-28 09:48:56 +03:00
av 7fb60828db guide: граница со спекой переписана на тест наблюдаемости вердикта
- «что против как» на пограничных правилах не работает: capability
  проверяется снаружи работающей системы, конвенция — только в исходном
  тексте, и отсюда расходятся направление, распространение и шкала
- добавлен признак для спорного случая: обязательство перед внешним
  потребителем — в спеку, зависимость автора следующего патча — в конвенцию
2026-07-27 08:57:35 +03:00
av 9c86d9f2de компоненты как адресат сборки и плоский набор
- компонент — область репозитория, где выбранные слои действуют
  одновременно; сборка идёт по разу на компонент, у каждого своя директория
  копий, подписка и локальная часть, секции [components.<имя>] в манифесте
- плоский набор описан как низкий конец модели, а не отдельный режим: тема с
  одним слоем собирается копированием, ключи оси и lang/stack не пишутся
- в TODO заведён вопрос о реестре значений осей и судьбе extends:
2026-07-26 22:01:39 +03:00
av 787d0bb5ea guide: имя темы и объявленная ось — META-37, META-38
- META-37: тема называется решением и адресатом, а не ролью части проекта;
  логи сервера и браузера — logging и client-logging, а не суффиксная пара
- META-38: ось слоя объявляется ключами lang/stack в шапке, а не выводится
  из пути — переезд файла между директориями иначе молча менял состав копии
  у каждого потребителя; шапки двенадцати конвенций приведены к правилу
- в список проверок добавлены объявление оси, единственность базового слоя
  и совпадение объявленного с директорией
2026-07-26 22:01:38 +03:00
av 11fc9e1fee guide: заведены критерии границы темы — META-33…META-36
- тема определяется решением, а не веществом (META-33) и нужна потребителю
  целиком (META-34); слой сужает базу, но не отменяет её (META-35), иначе
  это другая тема, а вид приложения называется в области действия (META-36)
- добавлен раздел «Как проверить границу темы»: пять вопросов со ссылками
  на правила, включая META-20; те же строки в CLAUDE.md, а в LANGUAGE.md
  оговорка, что граница темы языку не принадлежит
- в TODO заведён прогон восьми тем по критериям с разбором подозреваемых:
  шесть правил time выносят вердикты чужих тем, logging мешает три страта
2026-07-26 21:25:24 +03:00
av b516bfb02c manifest.toml переименован в suite.toml
- родовое «манифест» заменено именем уровня: файл в наборе описывает сам
  набор, файл в проекте — подключённые конвенции, и каждый назван по тому,
  что описывает
- по имени рядом лежащего манифеста определяется контекст: suite.toml —
  набор, .conventions.toml — проект; в TOOL.md решение зафиксировано, из
  открытых вопросов убрано
2026-07-26 17:06:29 +03:00
av e8fdc98557 заведён TOOL.md — стартовая точка для convy
- имя, термины suite/project, раскладка команд: проектные наверху, ведение
  набора под подкомандой suite, check остаётся общим
- собрано в одном месте то, что инструмент делает и чего не делает: сборка
  копии, три группы проверок, независимость от конкретного набора, отказ от
  слияния, лока и обратного транспорта
- два тулинговых вопроса вынесены из TODO в раздел «Открытые вопросы»;
  вопросы про инструмент там больше не живут
2026-07-26 17:00:00 +03:00
av c96566d4b4 todo: заведён разбор шести сниппетов в блоках нормы
- GCFG-7, GCFG-9, SLOG-20, GTIM-8, HTMX-7, HTMX-24 несут код внутри нормы,
  то есть требуют ровно такой код; с появлением блока ПРИМЕРЫ часть из них
  туда и переезжает
- записана цена ошибки в обе стороны: деталь кода как требование против
  нормы, потерявшей обязательность в иллюстративном блоке
2026-07-26 16:14:26 +03:00
av 59a1c23f55 язык: заведён блок ПРИМЕРЫ
- пятая, необязательная часть правила: код парой «плохо → хорошо» после
  обоснования; метка добавлена в словарь (EXAMPLES) и в строку о версии
  языка во всех тринадцати файлах
- сказано, чем примеры не являются: требований в блоке нет, дословным
  сниппетом он не служит, при расхождении с нормой правят пример
- READING.md обновлён по META-30, в машинные проверки добавлен порядок
  блоков, в читательские — что примеры норму не расширяют
2026-07-26 16:09:58 +03:00
av 1d19e0357b язык: усиления источников названы своими
- три клетки «что взято» правились по факту: DMN даёт политику совпадения,
  но не требует полноты; в 29148 обоснование — рекомендуемый атрибут; вывод
  про обязательность шаблона в EARS не сформулирован
- заведён раздел «Где источник усилен»: полнота таблиц, обязательность
  обоснования и вывод из EARS предъявлены как наши решения с доводами
- поправлены два места, где та же натяжка повторялась прозой: «Таблицы
  решений» и «Обоснование обязательно»
2026-07-26 16:03:47 +03:00
av 682fa075bb снятое правило остаётся заглушкой, нумерация сплошная
- META-31 и META-32: номера идут без пропусков, ссылка обязана разрешаться;
  обе проверки стали механическими — данных со стороны языка им хватает
- заведена метка СНЯТО: заголовок и номер снятого правила сохраняются, норму
  с обоснованием заменяет блок с датой и причиной, отдельный реестр снятых
  номеров не нужен
- META-9, META-16 и META-26 переписаны из таблицы «Освободившиеся номера» в
  заглушки; три висячие ссылки, тянувшиеся с утра, закрылись
2026-07-26 15:59:22 +03:00
av 170c06c1da язык: короткое описание для читателя копии едет в репозиторий
- заведён READING.md: словарь со значениями, форма правила и её граница,
  ссылки, локальная часть — без разделов о ведении набора и без META-ссылок,
  примеры на X-префиксах
- сборщик кладёт его в docs/conventions/ и перезаписывает целиком; в манифест
  набора добавлена секция [language] с версией и двумя документами
- META-30: правка словаря или состава частей правила доходит до READING.md,
  иначе потребитель толкует ДОПУСКАЕТСЯ по прежней версии
2026-07-26 15:51:54 +03:00
av 67d51db212 механизация больше не разрешает удалять норму
- META-9 снят: удаление оставляло подписчика, пришедшего после, без нормы и
  без проверки, а «механизировано у всех» канону не проверить — списка
  подписчиков у него нет по построению
- META-8 переписан в запрет: норма остаётся в правиле, чем бы её ни
  проверяли; линтер сообщает, что нарушено, но не что требуется (META-6)
- МЕХАНИЗИРОВАНО объявлена свойством репозитория: в тексте конвенции отметки
  нет, её место — запись о механизации ниже маркера (META-7)
2026-07-26 15:41:52 +03:00
av fe61ecd6c5 guide: язык употребляется, значит и проверяется как в конвенции
- проверки разведены по роли слова: то, что язык употребляет (конвенции и
  GUIDE.md), проверяется; то, что цитирует (LANGUAGE.md, README.md), — нет
- список машинных проверок разбит на форму правила и распространение:
  вторая группа (тема в шапке, пути канона, чужие префиксы, локальные X)
  касается только того, что едет к потребителю
- в GUIDE.md добавлена строка о версии языка и убрано заглавное СЛЕДУЕТ из
  вводной прозы «Оформления» — единственное нарушение, которое исключение
  прятало
2026-07-26 15:34:49 +03:00
av c1cb240540 темы: определение, объявление в шапке и манифест набора
- тема — набор правил об одном фокусе разработки, имя латиницей (нижний
  kebab-case рекомендуется, годится любой идентификатор, пригодный для имени
  файла); определение в LANGUAGE.md и README.md
- заведены META-28 и META-29: тема объявляется в шапке (`topic:`), стоит в
  манифесте набора и не переиспользуется; `topic:` добавлен во все 12 файлов
- prefixes.toml и topics.toml слиты в manifest.toml — манифест набора против
  манифеста подключения `.conventions.toml`, разделы topics/prefixes с live
  и retired
2026-07-26 15:27:55 +03:00
av d5118336cb язык: примеры переведены на вымышленные X-правила
- живые идентификаторы KEYS-5, SLOG-8.1, SLOG-27, MIGR-2/4/6 в примерах
  заменены на XKEY, XMIG, XLOG: номера канона означали не то, что в примере,
  и расходились дальше при каждой перенумерации
- сказано явно, что описание языка ни на один набор конвенций не опирается;
  ссылка на обоснования канона в разделе про ПОЧЕМУ заменена на внешние
  практики
2026-07-26 15:15:30 +03:00
av df8c58671f язык: объявлена граница правила
- область правила — от его заголовка до следующего заголовка любого уровня;
  метка открывает блок, хвост после ПОЧЕМУ — продолжение обоснования, а
  таблица после модальной метки — часть нормы
- проверка «заглавных модальных слов вне правил нет» стала реализуемой:
  прозой считается то, что лежит вне областей правил
- нормы, сидевшие в хвостах, подняты в блок нормы: заведены SLOG-25.4 и
  GERR-26.3, у GTIM-12 «базовый слой» заменён на TIME-12
2026-07-26 15:10:59 +03:00
av 4943bf1dd2 второе условие ДОЛЖЕН ослаблено до воспроизводимости вердикта
- META-6 переписан: ступень требует не машинной проверки, а того, чтобы
  двое проверяющих по тексту правила выносили один вердикт; проверяющий по
  умолчанию — читатель, человек или агент
- заведён META-27: машинная проверка желательна везде, где пишется, но
  ступени не задаёт — иначе канон стоит в СЛЕДУЕТ до появления скриптов;
  обоснование META-25 пересобрано на новом условии
- LANGUAGE.md и CLAUDE.md приведены к той же формулировке, вопрос 1 из
  TODO закрыт, остальные перенумерованы
2026-07-26 15:01:51 +03:00
av 7b2869d3a4 todo: заведена секция «Язык и подход» по итогам ревью
- девять находок ревью описания языка записаны вопросами 1–9: противоречие в
  условиях ДОЛЖЕН, неопределённая граница правила, примеры на живых
  идентификаторах, «тема» без реестра, ложное исключение GUIDE из проверок,
  МЕХАНИЗИРОВАНО без защиты нового подписчика, семантика слов вне копии,
  проверки без источника данных, натяжки в опоре на стандарты
- вопросы поделены на две секции: язык с подходом идёт первым, канон с
  тулингом вторым; «Мелкое» слито в вопрос 8, куда относилось по смыслу
- нумерация сплошная 1–15, внутренние ссылки переписаны под неё; в вопрос 12
  добавлена зависимость чекера от вопросов 2 и 8
2026-07-26 14:49:41 +03:00
av 4cd0c97ed0 язык остался версией 1, правило движения номера записано
- бумп до 2 откачен: копий в природе нет, читать по версии 1 пока нечему, и
  номер сжигать незачем
- вместо истории версий записано правило: номер двигается, когда изменение
  формы способно изменить чтение уже разданной копии; правки формы до раздачи
  копий его не двигают, а смена словаря под другой язык — не двигает никогда
2026-07-26 14:30:36 +03:00
av 72d77d74bf ПОЧЕМУ стало ключевым словом, язык поднят до версии 2
- метка обоснования пишется заглавными и вошла в словарь набора: скелет
  правила теперь целиком из ключевых слов, а не смесь `**ДОЛЖЕН.**` и
  `**Почему.**`; в переводе на другой язык метка меняется как остальные слова
  (ПОЧЕМУ / WHY), 235 вхождений заменены
- метки правила выделены из шкалы в отдельный перечень: ПОЧЕМУ и
  МЕХАНИЗИРОВАНО обязательности не задают, а размечают части, и стандартом не
  даются ни в одном языке — раньше МЕХАНИЗИРОВАНО висело строкой в таблице
  модальности
- версия языка поднята до 2, потому что изменение формы меняет чтение уже
  написанного текста; строка о версии в двенадцати конвенциях перечисляет
  теперь и метки, а служебные слова сценария в неё по-прежнему не входят
2026-07-26 14:28:37 +03:00
av c8071dc438 guide: META-26 снят, форма обоснования рамками не ограничивается
- запрет слов обязательства в «Почему» был лишним по построению: правило о
  заглавных уже делает строчное «обязан» ненормативным, так что второй копии
  нормы не возникает, а цена — автор воюет со списком слов вместо объяснения
- в LANGUAGE записано прямо: рамки у обоснования только смысловые, длина,
  рассуждение, примеры и ссылки на внешние практики и чужие проекты допустимы
- заведён раздел «Освободившиеся номера»: META-16 и META-26 с причинами. Без
  такого списка упоминание номера в прозе не отличить от ссылки на исчезнувшее
  правило — это же нужно будущей проверке ссылок
2026-07-26 14:22:38 +03:00
av 3e0ec46134 guide: заведён META-26 — обоснование объясняет, а не требует
- «Почему» не пересказывает норму словами обязательства: у оригинала есть
  идентификатор, у копии нет, и расходятся они при первой правке оригинала, а
  отступление от копии адресовать нечем
- модальность рекомендательная по META-6: греп находит слово, а не нарушение
  — «за попыткой следует повтор» и «становится обязанностью вызывающего»
  описывают ход событий; утверждения о невозможности («нельзя») правилом не
  затрагиваются, это ISO-евская возможность в прозе
- SLOG-12 починен: факт о том, что `slog` не разделяет CRITICAL и FATAL, уехал
  из нормы в «Почему», а норма теперь говорит то же, что заголовок
2026-07-26 14:18:06 +03:00
av 98fc69d585 guide: заведён META-25 — высшая модальность требует названного вреда
- ДОЛЖЕН выбирается, только когда в «Почему» сказано, что ломается при
  нарушении: до сих пор обязывало лишь второе условие (META-6, машинная
  проверка), и любую проверяемую мелочь можно было пометить ДОЛЖЕН
- модальность самого правила — СЛЕДУЕТ, и это не слабость: «вред назван»
  устанавливается чтением, значит по META-6 иначе и быть не может
- kebab-case в именах файлов пересобран как пример META-25: проверяется
  регуляркой тривиально, но вреда нет — потому правилом и не записан
2026-07-26 14:07:54 +03:00
av 3fce663d7a служебные слова сценарного блока стали частью словаря набора
- `КОГДА`, `ТОГДА`, `И`, `ИЛИ` (по-английски WHEN/THEN/AND/OR) подчиняются тем
  же требованиям, что модальные слова: одна форма на роль, заглавными, набор
  один на канон и выбирается под естественный язык
- модальностью они не являются — обязательности не задают, только структуру,
  поэтому в строку о версии языка не попадают и под проверку «модальные слова
  вне правил» не подпадают
- единственный сценарный блок канона (SLOG, «Два цикла повтора») переписан с
  WHEN/AND на русские связки; отказ от `GIVEN/WHEN/THEN` уточнён — он касается
  общей формы записи, а на стыке правил эта форма взята сознательно
2026-07-26 14:03:29 +03:00
av f99a513058 guide: заведён META-24 — ссылка слоя на идентификаторы своей базы
- языковой и стековый слои называют идентификатор правила арх-слоя своей темы
  прямо в норме: подписываются темой, а не слоем, поэтому базовый слой в
  собранной копии присутствует всегда и ссылка не ведёт в пустоту
- разрешение узкое по построению — на слои других языков и стеков не
  распространяется, их состав в копии зависит от манифеста
- из META-21 убрано предписание ссылаться на свой слой словами «базовый
  слой»: слова не проверяются и не ведут к утверждению, а идентификатор ведёт
2026-07-26 13:56:43 +03:00
av 7fca0e8cb8 из канона удалены пустые локальные регионы
- 31 регион `<!-- local:имя -->` в двенадцати файлах удалён, а не перенесён:
  локальное принадлежит копии и живёт ниже маркера `<!-- conv:local -->`
  (META-22), так что наполнять регионы в каноне нечем
- вместе с ними ушли два опустевших раздела «Связано» — в arch и ansible
  слоях app-directories канонических ссылок нет, а пустой заголовок ничего
  не адресует; CLAUDE.md уточнён: раздел заводят, когда ссылки есть
- форма проверена скриптом: у всех правил модальность и «Почему», префиксы
  сходятся с реестром, дыр в нумерации нет
2026-07-26 13:52:07 +03:00
av a961aa2b40 todo: закрытые вопросы удалены, оставшиеся перенумерованы
- ушли четыре закрытых раздела (ссылка на язык, модель сборки, разбор
  прототипов, мультиязычность) и закрытые куски внутри оставшихся: принятые
  решения живут в README, GUIDE и LANGUAGE, а черновик их дублировал
- полезные остатки перенесены, а не потеряны: прототипы Vale и vendir — в
  вопрос про тулинг, вынос арх-ядра из errors и logging — в пары слоёв
- нумерация сплошная 1–11, внутренние ссылки поправлены под неё; в шапку
  добавлено правило, что закрытый вопрос отсюда удаляется
2026-07-26 13:46:48 +03:00
av c04a54ffd5 todo: заведены четыре открытые темы по языку записи
- 12: `WHEN`/`AND` в блоке стыка правил — английские служебные слова там же,
  где `SHALL` отклонён как занятый OpenSpec; либо перевод, либо явная
  оговорка, что форма спецификации в этом месте намеренна
- 13: критерий «названного вреда» для ДОЛЖЕН описан в языке, но правила под
  него нет — META-6 обязывает только понижать при отсутствии проверки
- 14 и 15: одиннадцать таблиц не прочитаны на взаимоисключительность
  (подозреваемый SLOG-11), и канон не просмотрен на факты, записанные
  модальным словом
2026-07-26 13:43:45 +03:00
av 9318087248 язык записи опёрт на стандарты, словарь стал параметром
- шкала обязательности объявлена инвариантом, а набор ключевых слов —
  параметром естественного языка набора: для английского готовый словарь
  даёт BCP 14, для прочих берут перевод стандарта или делают свой; отклонены
  синонимы ступеней и `SHALL`, занятый OpenSpec
- применены шесть дельт: нормативно только заглавное написание (RFC 8174),
  ДОЛЖЕН требует названного вреда и машинной проверки сразу, ДОПУСКАЕТСЯ
  адресовано рецензенту, МЕХАНИЗИРОВАНО выведено из шкалы в отметку рядом с
  модальностью (ISO/IEC/IEEE 29148), у таблиц объявлены политика совпадения
  и полнота (DMN)
- «Форма записи — LANGUAGE.md» в двенадцати конвенциях заменена строкой о
  версии языка по образцу boilerplate BCP 14: пути канона в копии не
  существует, а словарь и правило заглавных строка несёт сама
2026-07-26 13:42:10 +03:00
av fea0285619 todo: закрыт вопрос про модель сборки, разобраны прототипы
- вопрос 3 закрыт: модель записана в README и GUIDE, отмечены три места,
  где итог разошёлся с прежними заготовками — нет лока, нет маркеров секций,
  локальные префиксы не объявляются, а резервируются буквой `X`
- вопрос 4 переформулирован: регионы не переносятся в единую секцию, а
  удаляются из канона — наполнять их в каноне нечем
- вопрос 7 заменён разбором: транспорт переизобретался, оси и сборка аналога
  не имеют, Vale отклонён; в 1, 9 и 10 добавлены следствия
2026-07-26 12:55:16 +03:00
av c2f68e6be1 обвязка: модель копий переписана под один маркер и манифест
- именованные регионы `<!-- local:имя -->` заменены на единственный
  `<!-- conv:local -->`: всё ниже него принадлежит репозиторию, всё выше
  пересобирается, поэтому имени-которое-можно-осиротить больше нет
- лок-файла и `origin_hash` в шапке нет — «что было в прошлый раз» знает git,
  копии закоммичены, автоматического обновления не существует; транспорт
  назад (`push`) убран вместе с ними
- заведены META-22 (репозиторное пишется ниже маркера) и META-23 (форк не
  носит `origin:`), META-17 переписан под маркер; буква `X` в префиксе
  зарезервирована за локальными правилами потребителей
2026-07-26 12:55:05 +03:00
av 4de6e0f896 черновик «К обсуждению» закоммичен
- одиннадцать открытых вопросов по сборке, тулингу и разделению языка и
  набора конвенций: в переписке они теряются, а часть уже противоречит
  README, и это противоречие видно только рядом с текстом
- снята строка «Не коммитится» и поправлено то же утверждение в CLAUDE.md
2026-07-26 09:06:38 +03:00
av f2aee9e992 настройки claude code включены в репозиторий
- .claude/settings.json включает плагин av-dev-git@av-dev-skills; набор
  плагинов у канона общий с остальными репозиториями, поэтому лежит в git,
  а не в локальных настройках
2026-07-26 09:06:37 +03:00
av 044c2db267 время: починены ссылки на правила идентификаторов
- «Почему» у TIME-5 и TIME-6 после перенумерации в dcdd922 указывало на
  TIME-3 и TIME-2 вместо KEYS-3 и KEYS-2: массовая замена подставила
  префикс своего файла, и ссылки молча поехали на чужие по смыслу правила
- заодно ушли два последних нарушения META-21 — путь `arch/db-identifiers.md`
  в тексте конвенции
2026-07-26 09:06:37 +03:00
av 5c2cf35116 заведён CLAUDE.md — точка входа агента в канон
- одна строка на правило с идентификатором, как требует META-19: форма
  правила, схема префиксов, META-20/META-21, выбор оси, оформление файла
- зафиксирован стиль коммитов и то, что README описывает текущее
  устройство, а TODO.md — площадка для обсуждения, а не решения
2026-07-26 09:06:37 +03:00
av 2ed568bad1 ссылки между конвенциями переписаны на темы и идентификаторы
- 41 ссылка вида `lang/go/logging.md` заменена на «конвенция `logging`»,
  идентификатор правила или «базовый слой» для своей же темы
- MIGR-8 больше не отсылает за форматом меток времени, а называет его;
  MIGR-11 перенёс ссылку на KEYS-1/KEYS-2 из нормы в «Почему»
2026-07-25 21:12:26 +03:00
av 421374c4a2 guide: правила самодостаточности нормы и формы ссылки
- META-20: норму можно исполнить, имея один файл; на чужую тему смотрят
  только «Почему», «Связано» и разграничение области — подписка это
  произвольное подмножество, графа зависимостей нет по построению
- META-21: ссылка ведёт на имя темы или идентификатор правила; путь файла
  канона умирает при сборке, потому что слои темы становятся секциями
2026-07-25 21:12:25 +03:00
av 840904d454 реестр префиксов перенесён в корень
- реестр покрывает и обвязку тоже (GUIDE.md), поэтому внутри conventions/
  он упирался в путь `../GUIDE.md`; теперь пути даются от корня репозитория
- README дополнен разделом про префиксы и строкой в таблице обвязки
2026-07-25 20:57:54 +03:00
av 4a7cfca8f6 обвязка: идентификаторы правил описаны через префиксы
- LANGUAGE.md: раздел «Идентификаторы» переписан под префиксы, в список
  машинных проверок добавлена сверка с реестром
- GUIDE.md перенумерован под префикс META, «номер правила» заменён на
  «идентификатор»
2026-07-25 20:55:38 +03:00
av dcdd92230b заведён реестр префиксов, правила канона перенумерованы
- идентификатор правила теперь `<ПРЕФИКС>-<номер>` вместо `R<номер>`:
  префикс уникален по всему канону, поэтому ссылка больше не требует пути
  к файлу и не зависит от того, на какой оси файл лежит
- префикс выбирается под файл, а не выводится по формуле, и хранится в
  conventions/prefixes.toml вместе с выбывшими; номера сохранены один в
  один вместе с дырами
2026-07-25 20:55:37 +03:00
av 972627f641 время: приём чужого офсета и регистрация исключений линтера
- arch R13: валидное по RFC 3339 значение с чужим офсетом нормализуется при
  разборе; канонический вид — обязательство писателя, не контракт с партнёром
- go R13: исключение регистрируется директивой //nolint на месте вызова, а не
  exclude-записью в конфиге, которая адресует путём и отвязывается
2026-07-25 20:15:36 +03:00
av f073a68f75 web-ui: сбой без фрагмента и запрос без поля поверхности
- R34: глобальный слушатель htmx:responseError — на 5xx htmx ничего не
  свопит, и интерфейс замирает без признака сбоя
- R35: 400 вместо поверхности по умолчанию, иначе своп идёт с чужим id
- в R14 снято противоречие: слушатель больше не числится среди
  отвергаемых настроек
2026-07-25 20:15:36 +03:00
av 477499877d errors: непокрытая маппингом ошибка и поведение после recover
- R25: 500 и ERROR с признаком непокрытой — без признака забытая ветвь
  неотличима в логах от упавшей базы
- R26: HTTP-запрос завершается 500, цикл продолжается со следующего
  элемента, а упавший выводится из оборота — иначе poison message
2026-07-25 20:15:36 +03:00
av 9085601db2 config: отсутствие файла и содержание сообщений валидатора
- R20: конфига нет — не стартуем; умолчания существуют для неполного файла,
  а не для отсутствующего
- R21: значение поля в сообщении валидатора печатается по тому же признаку
  секретности, что у R15 и R16, второй список не заводится
- в перечень проверок R18 добавлен формат идентификаторов сущностей
2026-07-25 20:15:36 +03:00
av 0fc1994db7 идентификатор новой сущности — всегда ULID
- R1 больше не ветвится по признаку внешней адресуемости: заранее отличить
  внутренние сущности, которые станут внешними, невозможно
- целочисленный ключ остаётся у существующих схем и идёт вместе с
  AUTOINCREMENT — переиспользованный rowid молча наводит протухшую ссылку
  на другую строку (db-schema R12)
- таблица R5 ограничена внешними источниками, регион «решение» убран
2026-07-25 20:15:35 +03:00
av 6456b81d91 исправлены дефекты формулировок в конвенциях
- убраны неверные утверждения: покрытие forbidigo сужено до честного,
  таблица классов доменного отказа больше не претендует на полноту,
  механизация не подаётся как факт канона
- введены недостающие определения (доменная и внешняя границы, объявление
  пути), критерий постоянного поля сведён к одному на R16.1 и R17
- kebab-case имени файла убран из правил в прозу: обоснование не
  формулировалось, номер R16 оставлен свободным
2026-07-25 19:34:41 +03:00
av 0842850fae конвенции отделены от обвязки
- сами конвенции переехали в conventions/, описательное — в корень:
  LANGUAGE.md (язык записи) и GUIDE.md (как ведут конвенции)
- conv синхронизирует только conventions/, пути в origin даются
  относительно неё — раскладка копий в репозиториях не меняется
2026-07-25 19:23:32 +03:00
24 changed files with 3165 additions and 1919 deletions
+5
View File
@@ -0,0 +1,5 @@
{
"enabledPlugins": {
"av-dev-git@av-dev-skills": true
}
}
+34
View File
@@ -0,0 +1,34 @@
governance = "GUIDE.md"
[language]
version = 1
lang = "ru"
description = "LANGUAGE.md"
reading = "READING.md"
[topics]
[topics.live]
app-directories = "категории директорий приложения и что в каждой лежит"
config = "конфигурация: файл, валидация, секреты"
db-identifiers = "идентификаторы сущностей: вид ключа, генерация, границы"
db-schema = "схема БД и миграции: типы колонок, форма изменения"
errors = "ошибки: обёртки, границы трансляции, паники"
logging = "логирование: уровни, структура записи, что не логируем"
time = "время: хранение, зоны, форматы, календарные границы"
web-ui = "веб-UI: партиалы, свопы, поллинг"
[prefixes]
[prefixes.live]
ANSD = "conventions/stack/ansible/app-directories.md"
CONF = "conventions/arch/config.md"
DIRS = "conventions/arch/app-directories.md"
GCFG = "conventions/lang/go/config.md"
GERR = "conventions/lang/go/errors.md"
GKEY = "conventions/lang/go/db-identifiers.md"
GTIM = "conventions/lang/go/time.md"
HTMX = "conventions/stack/htmx/web-ui.md"
KEYS = "conventions/arch/db-identifiers.md"
META = "GUIDE.md"
MIGR = "conventions/lang/go/db-schema.md"
SLOG = "conventions/lang/go/logging.md"
TIME = "conventions/arch/time.md"
+221
View File
@@ -0,0 +1,221 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with
code in this repository.
Всё содержимое репозитория и общение по нему — на русском.
## Что это
Канон конвенций разработки для личных проектов. Сами конвенции лежат в
`conventions/{arch,lang/<язык>,stack/<стек>}/`; обвязка канона (`README.md`,
`LANGUAGE.md`, `GUIDE.md`, `READING.md`, `.conventions-suite.toml`) живёт в
корне. К потребителю из неё едет только `READING.md` — короткое описание языка
для читателя копий.
Ниже — короткие инварианты с идентификаторами; детали и обоснования в
`LANGUAGE.md` (форма записи) и `GUIDE.md` (процесс, префикс META).
## Форма правила
- Четыре обязательные части: `### <ПРЕФИКС>-<N>. Заголовок`, абзац
`**МОДАЛЬНОСТЬ.** норма`, абзац `**ПОЧЕМУ.** …`. Правило без обоснования не
принимается.
- `**ПРИМЕРЫ.**` — необязательный пятый блок после обоснования: код парой
«плохо → хорошо». Иллюстрация нормы, а не спецификация — требований в блоке
нет, дословным сниппетом он не является, при расхождении действует норма.
- Норма — одна фраза; если в неё не влезает, это два правила.
- Область правила — от его заголовка до следующего заголовка любого уровня;
метка открывает блок, блок длится до следующей метки или до конца области.
Абзацы после `**ПОЧЕМУ.**` — продолжение обоснования: требований в них не
живёт, требование ставят в блок нормы. Таблица и список после модальной
метки — часть нормы.
- Шкала инвариантна, словарь — параметр языка набора. Канон русский, значит
слова: **ДОЛЖЕН**, **НЕ ДОЛЖЕН**, **СЛЕДУЕТ**, **НЕ СЛЕДУЕТ**,
**ДОПУСКАЕТСЯ**. Словарь один на канон, синонимов на ступень нет.
- `SHALL` не используется ни в одном словаре — занято OpenSpec.
- Нормативно только заглавное написание (правило RFC 8174): строчное
«должен» в прозе нормой не является.
- ДОЛЖЕН требует двух условий сразу: назван вред от нарушения (META-25) и
вердикт о нарушении воспроизводим (META-6). Воспроизводимость сама по себе
до ДОЛЖЕН не повышает — иначе шкала наполняется проверяемыми мелочами.
- ДОПУСКАЕТСЯ адресовано рецензенту: помеченный им выбор на ревью не
обсуждается.
- **МЕХАНИЗИРОВАНО** — не модальность, а способ проверки, и свойство
репозитория, а не канона: в тексте конвенции отметки нет, она стоит при
записи о механизации в локальной части копии (META-7).
- META-8: норма не удаляется из канона никогда, чем бы её ни проверяли.
Механизация её не заменяет и не сокращает.
- Метки правила — **ПОЧЕМУ**, **ПРИМЕРЫ**, **МЕХАНИЗИРОВАНО** и **СНЯТО**
тоже словарь набора и перечислены в строке о версии языка наравне с
модальными словами.
- META-30: правка словаря или состава частей правила доходит до `READING.md`
документа, который едет к потребителю. Словари двух описаний совпадают.
- Заглавные модальные слова не употребляются вне правил: ни в «Область
действия», ни в «Связано», ни в локальной части копии, ни во вводной прозе.
Исключение — строка о версии языка, которая их перечисляет.
- Обоснование отвечает на «что сломается, если сделать иначе», а не
пересказывает норму. «Потому что так принято» — не обоснование.
- Форма обоснования не ограничена: рамки смысловые. Длина, рассуждение,
примеры, ссылки на внешние практики и чужие проекты — всё допустимо;
запрещённых слов нет. Обязательность несёт норма, и путаницу исключает
правило о заглавных.
- Служебные слова сценарного блока — тоже словарь набора: **КОГДА**,
**ТОГДА**, **И**, **ИЛИ** (по-английски `WHEN`/`THEN`/`AND`/`OR`). Одна
форма на роль, заглавными. Модальностью не являются, в строку о версии
языка не попадают.
- Правило, классифицирующее ситуации, пишется таблицей «ситуация → вердикт»;
строки нумеруются `KEYS-5.1`. Строки взаимоисключающи по умолчанию; иной
порядок объявляется явно, а перечисленные случаи покрывают область
действия.
- Модальность принадлежит правилу, а не файлу: `status:` в шапке отменён.
## Идентификаторы: тема и префикс
- Тема — набор правил об одном фокусе разработки и единица подписки. Имя —
латиницей, рекомендуется нижний kebab-case, годится любой идентификатор,
пригодный для имени файла.
- META-28: тема объявлена в шапке (`topic: time`) и стоит в манифесте набора
(`.conventions-suite.toml`, секция `[topics.live]`). Слои одной темы несут
одно имя — по нему собираются в один файл, как бы ни назывались их файлы;
имя файла повторяет тему из удобства.
- META-29: имя темы не переиспользуется, снятое уходит в `[topics.retired]`
с причиной и датой. Оно живёт в `origin:` копий и в подписках манифестов.
- Формат `<ПРЕФИКС>-<номер>`, нумерация сквозная внутри файла. Порядок правил
в файле — по читаемости: номер это идентификатор, а не позиция.
- Идентификаторы не переиспользуются: новое правило берёт номер, следующий за
наибольшим.
- META-31: нумерация в файле сплошная. Снятое правило не исчезает, а остаётся
заглушкой: заголовок с номером плюс блок `**СНЯТО <дата>.**` с причиной
вместо нормы и ПОЧЕМУ. Реестра снятых номеров нет — файл сам себе реестр.
- META-32: ссылок на несуществующие правила нет; неразрешённый идентификатор —
всегда ошибка, а не «правило, наверное, сняли».
- Новый файл конвенции — новый префикс: четыре заглавные латинские буквы,
уникальные по всему канону, выбираются под файл, а не выводятся по формуле.
Объявляется в шапке (`prefix: KEYS`) и регистрируется в манифесте набора,
секция `[prefixes.live]`, путём от корня репозитория.
- Удаление или разделение файла: префикс уходит в `[prefixes.retired]` с
причиной и датой, а не освобождается.
- Префиксы на букву `X` канон не занимает: они зарезервированы за локальными
правилами репозиториев-потребителей.
- Перенос правила в другой файл — смысловое изменение: новый префикс и новый
номер. Переезд самого файла между осями идентификаторы не трогает.
## Ссылки
- META-20: норму можно исполнить, имея один этот файл. Ссылка на правило
чужой темы допустима в обосновании, в «Связано» и в разграничении области
действия — но не в самой норме. Нужен концепт соседней темы — коротко
повторить его здесь, соседа назвать в обосновании.
- META-21: на соседнюю конвенцию ссылаются именем темы (конвенция
`logging`), на правило — идентификатором (`SLOG-27`). Пути файлов канона в
тексте конвенции нет (в обвязке — можно).
- META-24: слой `lang/` или `stack/` называет идентификатор правила арх-слоя
**своей** темы прямо в норме — базовый слой в собранной копии всегда рядом.
На слои других языков и стеков это не распространяется: их состав зависит
от манифеста.
## Что в каноне писать нельзя
- META-4: в тексте конвенции нет утверждений о состоянии конкретного
репозитория; норма — в настоящем предписывающем времени.
- META-5: расхождение кода с правилом — отступление, а не повод переписать
правило. Направление всегда конвенция → код; факт «в приложении уже иначе»
не является аргументом.
- META-6: ДОЛЖЕН требует воспроизводимого вердикта — двое проверяющих по
тексту правила отвечают одинаково. Правило, вердикт которого зависит от
суждения (вкус формулировки, уместность в конкретном месте), — СЛЕДУЕТ по
построению. META-27: машинная проверка желательна, но ступени не задаёт;
проверяющий по умолчанию — читатель правила, человек или агент.
- META-10: блок ПОЧЕМУ не удаляется никогда, в том числе после того, как
правило стало проверяться линтером.
- META-1: один файл — ровно один повторяющийся выбор. META-2: конвенция
заводится, когда решение принимается третий раз.
- Репозиторного в каноне нет вовсе: механизация, отступления и ссылки на код
живут в копии ниже маркера `<!-- conv:local -->` (META-22), который ставит
сборщик. Заводить пустые местные разделы в каноне не нужно.
## Граница темы
Пять вопросов, по которым тему проверяют на «не хапнули ли лишнего»; сначала
разрез темы, ось — потом.
- META-33: правило стоит в теме, чей вопрос оно решает. Тема — решение, а не
вещество: «время» проходит через несколько решений сразу, и правило о
колонках БД принадлежит схеме, а не времени.
- META-37: имя темы называет решение и адресата, а не роль части проекта:
`logging` и `client-logging`, но не `logging-backend`/`logging-frontend`.
- META-34: тема нужна потребителю целиком — подписка берёт её без остатка.
Если два правдоподобных потребителя хотят непересекающиеся части, между
ними и проходит граница.
- META-35: слой сужает базу, но не отменяет её. Приходится отменять — это не
слой, а другая тема; общим осталось слово, а не решение.
- META-36: вид приложения (веб-сервис, CLI, плейбуки, библиотека) называется
в области действия, если норма от него зависит. Осью он не является.
- META-20: норма исполнима без соседних тем.
## Выбор оси
Умирает при смене языка → `lang/<язык>/`. Умирает при смене инструмента,
хранилища или транспорта → `stack/<стек>/`. Не умирает ни от того, ни от
другого → `arch/`. Ось определяется природой правила, а не числом сегодняшних
потребителей. `extends: arch/<файл>.md` в шапке — документация связи, а не
механизм; слой только реализует и сужает базу, но не отменяет её (META-35).
META-38: ось объявлена в шапке ключами `lang:` и `stack:`, а не выведена из
пути; без обоих ключей файл — базовый слой темы. Директория повторяет
объявленное для человека. Осей может не быть вовсе: набор, где у темы один
слой, — низкий конец той же модели, а не особый режим.
## Компоненты
Компонент — область репозитория, где все выбранные слои действуют
одновременно (`sqlite` и `postgres` — да, go и javascript — никогда). Уровней
три: набор → проект → компонент. Сборка прогоняется по разу на компонент, у
каждого своя директория копий, своя подписка и своя локальная часть; в
`.conventions.toml` они записаны секциями `[components.<имя>]` с ключами
`dir`, `lang`, `stack`, `topics`. Компонент пишется всегда, даже когда он
один. Директории компонентов различны — этим копии и разводятся.
## Оформление файла
Шапка `topic:` и `prefix:` (плюс `extends:`) → `# Тема` → вводная проза →
отдельным абзацем строка о версии языка (её точный текст — в `LANGUAGE.md`,
раздел «Ссылка на язык из конвенции») → `## Область действия` (обязателен для
трудноизменяемых слоёв — META-11) → правила → `## Связано`, если канонические
ссылки есть (META-17; пустого раздела не заводят). Имя файла повторяет имя
темы. Проза переносится по ~76 колонок; таблицы и блоки кода не переносятся.
## Ревью формы
Список того, что подлежит проверке, — в `LANGUAGE.md`, раздел «Что стоит
проверять машиной». Ни одна из проверок не реализована, поэтому при ревью их
выполняют чтением.
Проверяется всё, что язык **употребляет**: конвенции и `GUIDE.md` (он несёт
правила META и строку о версии языка). `LANGUAGE.md` и `README.md` язык
цитируют — ключевые слова в них предмет описания, а не норма. Проверки
распространения (тема в шапке, самодостаточность нормы, отсутствие путей
канона) касаются только конвенций: обвязка к потребителю не едет.
## Коммиты
Русский, строчная буква, без точки в конце, прошедшее время или страдательный
залог: «заведён реестр префиксов, правила канона перенумерованы». Изредка
область через двоеточие (`guide:`, `errors:`). Тело — маркированный список на
2–3 пункта с переносом по ~76 колонок; объясняет почему и цитирует
идентификаторы правил. Conventional Commits не используются.
## Состояние репозитория
- Тестов, линтеров и CI здесь нет: репозиторий — данные, а не код. Проверяет
их `convy suite check`, живущий в своём репозитории и ставящийся бинарём.
- Модель копий, описанная в `README.md`, реализована в `convy`. Прежний
питоновский `conv` удалён вместе со своей моделью (зеркальное дерево,
именованные регионы, `origin_hash`). При расхождении обвязки с инструментом
истина — README, а не код.
- Ни один репозиторий-потребитель ещё не подключён: копий с шапкой `origin:`
в природе нет.
- `TODO.md` — площадка для обсуждения на будущее, а не принятые решения; при
работе над обвязкой его стоит прочесть, но истина о текущем устройстве —
`README.md`.
+572
View File
@@ -0,0 +1,572 @@
---
prefix: META
---
# Как мы ведём конвенции
Конвенция описывает повторяющийся выбор: как называть директории, как
раскладывать данные, как оформлять ошибки. Она отвечает на вопрос «как
принято», а не «что здесь происходит».
Как записывается сама конвенция — правила, модальность, обоснования — в
[LANGUAGE.md](LANGUAGE.md). Здесь — про то, зачем конвенции заводятся, где
живут и как соотносятся с соседними видами документов.
Правила ниже записаны тем же языком, что и сами конвенции, и проверяются так
же.
Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки
ПОЧЕМУ, ПРИМЕРЫ, МЕХАНИЗИРОВАНО и СНЯТО толкуются как описано в языке
конвенций версии 1 — тогда и только тогда, когда написаны заглавными.
## Область действия
Правила ниже адресованы автору конвенции — тому, кто заводит новый файл или
правит существующий, в каноне и в копиях репозиториев. Обязательность живёт
на отдельном правиле, а не на файле; шкала модальных слов — в
[LANGUAGE.md](LANGUAGE.md).
## Отличие от соседей
- `docs/adr/`**решение**, принятое однажды и постфактум («почему выбрали
Authelia, а не Keycloak»). Запись неизменяема.
- `docs/specs/` и OpenSpec, где они есть, — контракт наблюдаемого поведения.
Конвенция в спеки не переносится: это не capability.
- `docs/drafts/` — оперативная хроника и черновики, «что собираюсь сделать».
- `docs/conventions/`**правило на будущее**, применяемое многократно.
Живой документ: правится, когда договорённость меняется.
Со спекой конвенцию путают чаще прочего, а «что против как» на границе не
работает. Разводит их то, **где наблюдается вердикт**. У capability он виден
снаружи работающей системы: подали вход, получили выход, совпало или нет. У
конвенции — только в исходном тексте: снаружи не различить, обёрнута ошибка
или проглочена и по какому признаку выбран уровень записи.
Отсюда расходится остальное. Спека едет за системой — изменилось поведение,
меняется контракт; конвенция ведёт код, и факт «в приложении уже иначе»
аргументом не считается (META-5), а утверждений о состоянии репозитория в ней
нет вовсе (META-4). Спека принадлежит одной системе; конвенция ездит копиями
и потому знает про темы, слои и локальную часть. Capability бинарна —
реализована или нет; у конвенции есть ступени и постоянный список отступлений
(META-13). Спеку пишут до кода, конвенцию — на третий раз (META-2).
Пограничное правило разбирается признаком внешнего потребителя. Формат логов,
который собирает чужой агрегатор, — обязательство перед кем-то снаружи, и
место ему в спеке. Если от правила зависит только автор следующего патча —
это конвенция.
## Оформление
Имя файла повторяет имя темы: `app-directories.md`. Правилом это не
записано, и это случай META-25: регуляркой имя проверяется тривиально, но
вреда от нарушения нет — тема объявлена в шапке (META-28), и сборка идёт по
объявленному имени, а не по имени файла. Значит, и высшей модальности нет, а
ступенью ниже такое правило не окупает строчку.
## Канон и копии
Файлы в `docs/conventions/` с шапкой `origin:` — копии из общего канона
`dev-conventions`, а не собственные документы репозитория. Копия собирается
из канона целиком, поэтому репозиторное живёт ниже маркера локальной части
в конце файла: обновление сохраняет всё, что ниже маркера, и переписывает
всё, что выше. Откуда взяты копии и где брать обновления — в манифесте
`.conventions.toml` в корне репозитория.
Отдельного механизма отчёта о расхождении нет: обновление перезаписывает
файлы в рабочем дереве, а что именно изменилось, показывает `git diff` до
коммита. Поэтому в шапке копии хранится только `origin:` — отпечатков
канона и дат синхронизации в ней нет, историю держит git.
Правка выше маркера означает одно из двух: улучшение, которое переносят в
канон, или документ, переставший быть копией, — тогда `origin:` из шапки
убирают.
## Как проверить границу темы
Готовая тема проходится по шести вопросам; на каждый отвечает своё правило:
- на какой вопрос отвечает правило — и тот ли это вопрос, что у темы
(META-33);
- нужна ли тема правдоподобному потребителю целиком (META-34);
- слой сужает базу или отменяет её (META-35);
- зависит ли норма от вида приложения и назван ли он (META-36);
- названа ли тема решением и адресатом, а не ролью части проекта (META-37);
- исполнима ли норма, если соседних тем в репозитории нет (META-20).
Расхождение на любом из них означает, что граница проходит не там, где
нарисована: тема собрана вокруг вещества, склеила два решения или молча
предполагает вид приложения. Чинится это разрезом темы или областью
действия, а не смягчением нормы.
## Правила
### META-1. Одна конвенция — один файл
**ДОЛЖЕН.** Файл описывает ровно один повторяющийся выбор.
**ПОЧЕМУ.** Подписка перечисляется по темам, и тему берут целиком. Файл,
собравший две темы, вынуждает репозиторий взять правила, которые ему не
нужны, и вычитывать чужую половину при каждом обновлении. Разрезать позже
дорого: перенос правила в другой файл — это новый префикс и новая
нумерация, поэтому после разреза все внешние ссылки обходят руками.
### META-33. Правило стоит в теме, чей вопрос оно решает
**ДОЛЖЕН.** Тема правила определяется вердиктом, который правило выносит, а
не веществом, о котором оно говорит.
**ПОЧЕМУ.** Одно и то же вещество — время, идентификатор, конфигурация —
проходит через несколько решений сразу, и тема, собранная вокруг вещества,
склеивает чужие решения: «в каком виде хранить в базе», «что писать в лог»,
«что отдавать наружу» попадают в один файл на том основании, что все три
говорят о моментах. Подписка после этого промахивается в обе стороны:
репозиторий без базы получает правила о колонках, а репозиторий с базой, не
подписанный на время, правил о своих колонках не получает — хотя они про его
схему. Отличить одно от другого дёшево: вопрос темы выписывается одной
фразой, и норма читается как ответ на него; ответ на чужой вопрос означает,
что правило лежит не в своей теме.
### META-34. Тема нужна потребителю целиком
**СЛЕДУЕТ.** Тема нарезается так, чтобы правдоподобному потребителю
требовалась вся она, а не часть.
**ПОЧЕМУ.** Взять половину темы нечем: подписка перечисляется темами, и
сборщик кладёт файл целиком. Потребитель, которому нужна треть правил,
платит за остальные две трети вычиткой при каждом обновлении и пачкой
отступлений — а пачка отступлений неотличима от небрежности и обесценивает
список, по которому считают реальное соблюдение (META-14). Линия разреза
видна заранее: если два правдоподобных потребителя хотят непересекающиеся
части одной темы, между этими частями и проходит граница. Ступень ниже
высшей потому, что «правдоподобный потребитель» — суждение: двое разойдутся
в том, бывает ли такой репозиторий вообще.
### META-35. Слой сужает базу, но не отменяет её
**НЕ ДОЛЖЕН.** Правило языкового или стекового слоя не требует
противоположного норме арх-слоя своей темы и не снимает её требование.
**ПОЧЕМУ.** Слои темы приезжают в копию одним файлом, секция за секцией, и
исполняются подряд: база и отменяющее её уточнение стоят рядом без указания,
какое из них главнее, — читатель выбирает сам, и вердикт перестаёт быть
воспроизводимым (META-6). Отсюда же тест на границу: если ради нового случая
базу приходится отменять, это не слой, а другая тема — общим у них осталось
слово, а не решение. Сужение слоем остаётся: уточнить, ограничить, назвать
инструмент, разобрать случай, который база предусмотрела.
### META-36. Вид приложения называется, если норма от него зависит
**ДОЛЖЕН.** Норма, верная не для всякого приложения, сопровождается областью
действия, называющей вид приложения, для которого она написана.
**ПОЧЕМУ.** Вид приложения — веб-сервис, программа командной строки, набор
плейбуков, библиотека — меняет вердикт там, где язык и инструмент его не
меняют: лог сервиса читают через месяц запросом, вывод команды — сейчас и
глазами, поэтому уровень записи у них выбирается по-разному. Осями это
измерение не выражено: они отвечают на вопрос, от чего правило умирает, а не
к чему оно применяется, — и единственное место, где вид может быть назван,
область действия. Не названный, он остаётся молчаливым допущением автора:
потребитель другого вида не отличает «правило написано не про меня» от «мы
его нарушаем» и записывает второе, хотя чинится первое — условие
применимости в каноне (META-15.2).
### META-37. Имя темы называет решение и адресата, а не место в архитектуре
**СЛЕДУЕТ.** Именем темы служит решение вместе с тем, кому оно адресовано, а
не роль части конкретного проекта.
**ПОЧЕМУ.** Имя темы вечно и не переиспользуется (META-29): оно стоит в
`origin:` каждой копии, в подписках, в чужих ссылках. Роль же принадлежит
сегодняшнему устройству одного проекта — «фронтенд», который через три года
рендерится на сервере, называется по-прежнему, а означает другое, и заметить
расхождение нечем: имя ни на что не ссылается, кроме привычки. Пара имён вида
`logging-backend` и `logging-frontend` вдобавок навязывает чтение «две
разновидности одного», хотя по границе это две темы: серверную запись читают
постфактум инструментом, клиентскую — разработчик в консоли или сборщик ошибок
на той стороне сети, и общего у них остаётся три правила из сорока. Названные
по адресату — `logging` и `client-logging` — они и читаются как разные.
Ступень ниже высшей потому, что «решение против роли» — суждение о слове: на
границе двое разойдутся.
### META-38. Ось слоя объявляется в шапке файла
**ДОЛЖЕН.** Принадлежность слоя оси объявляется в шапке ключами `lang:` и
`stack:`, а не выводится из пути файла; отсутствие обоих ключей означает
базовый слой темы.
**ПОЧЕМУ.** Ось, выведенная из пути, ломается тем же способом, что и тема,
выведенная из имени файла (META-28), только тише: переезд файла между
директориями не меняет ни одного идентификатора, но меняет состав копии у
каждого потребителя — слой начинает выбираться при другом языке или всегда.
Сверить это не с чем, потому что путь ничего не утверждает, а объявления нет.
Объявление вдобавок выражает то, чего дерево директорий не выражает: слой,
осмысленный только при совпадении языка и инструмента сразу; и набор, у
которого осей нет вовсе, перестаёт требовать директорий-заглушек. Дерево при
этом остаётся — но тем же, чем уже является `extends:`, документацией связи
для человека.
### META-28. Тема объявляется в шапке файла и стоит в манифесте набора
**ДОЛЖЕН.** Шапка несёт ключ `topic:` с именем темы, и это имя стоит в
манифесте набора.
**ПОЧЕМУ.** Тема — единица подписки и единица сборки: потребитель перечисляет
темы в своём манифесте, а сборщик складывает в один файл все слои темы. Пока
имя выводится из имени файла, у сборщика нет способа узнать, что два слоя,
названные по-разному, — один документ; переименование файла при этом молча
заводит новую тему, подписка перестаёт находить прежнюю, а ссылки «конвенция
`logging`» в копиях остаются висеть. Объявленное имя вдобавок сверяется с
манифестом — выведенное сверять не с чем.
### META-29. Имя темы не переиспользуется
**НЕ ДОЛЖЕН.** Снятое или переименованное имя темы другой теме не выдаётся:
оно уходит в раздел выбывших манифеста с причиной и датой.
**ПОЧЕМУ.** Имя темы живёт в чужих репозиториях — в шапке `origin:` каждой
копии, в подписке потребителя, в тексте ссылок. Выданное второй теме, оно
начинает указывать на другой набор правил, и обнаруживается это не на сборке,
а по содержанию: файл обновится штатно, изменится текст. Та же дисциплина и по
той же причине действует для префиксов правил.
### META-30. Правка словаря или формы правила доходит до документа для читателя
**ДОЛЖЕН.** Изменение ключевых слов, их значений или состава частей правила
вносится и в короткое описание языка, которое едет в копию.
**ПОЧЕМУ.** Полное описание языка остаётся у автора набора, а по правилам код
проверяет читатель копии — человек или агент в чужом репозитории, у которого
из двух документов есть только короткий. Разошедшись, он начинает толковать
слова по прежней версии: ДОПУСКАЕТСЯ читается как бытовое «можно», отступление
от ДОЛЖЕН перестаёт требовать записи — то есть отказывает ровно то, ради чего
слова вводились, и молча. Проверить расхождение дёшево: словари в двух
документах либо совпадают, либо нет.
### META-31. Нумерация правил в файле сплошная
**ДОЛЖЕН.** Номера идут от единицы до наибольшего без пропусков: снятое
правило остаётся на месте заглушкой с меткой СНЯТО, а не исчезает.
**ПОЧЕМУ.** Дыра в нумерации неотличима от опечатки в номере и от правила,
которое забыли дописать, — проверка, увидев пропуск, не может сказать, ошибка
это или норма, поэтому либо молчит всегда, либо краснеет на живом файле.
Заглушка отвечает на тот же вопрос текстом: номер занят, правило снято
тогда-то и по такой-то причине. Переиспользовать номер по-прежнему нельзя —
ссылка из чужого репозитория обязана указывать на то же утверждение, — но и
отдельный
реестр снятых номеров не нужен: он был бы вторым источником правды рядом с
файлом, который и так всё сказал.
### META-32. Ссылка ведёт на правило, которое существует
**НЕ ДОЛЖЕН.** Идентификатор в тексте не указывает на правило, которого в
наборе нет.
**ПОЧЕМУ.** Неразрешимая ссылка означает одно из двух: опечатку в номере или
след переноса правила в другой файл. Читатель — тем более в чужом
репозитории — не различит эти случаи и решит, что правила больше нет, хотя оно
могло переехать. С заглушками (META-31) проверка становится однозначной:
идентификатор либо ведёт к правилу, либо к объяснению, почему его сняли, а
третьего исхода нет — и любой неразрешённый идентификатор точно ошибка.
### META-2. Конвенция заводится, когда решение принимается третий раз
**СЛЕДУЕТ.** Поводом служит одно и то же решение, принятое третий раз и
каждый раз чуть по-другому.
**ПОЧЕМУ.** По одному-двум случаям не видно, что в решении повторяется, а
что было частностью места: правило, выведенное из первого случая, кодирует
частность и дальше мешает больше, чем помогает. Единичный выбор, к тому же
спорный, читателю нужен вместе с мотивом — это ADR, где мотив и есть
содержание записи.
### META-3. Новая конвенция пишется там, где заболело
**СЛЕДУЕТ.** Первые шаги пути «находка → конвенция → правило линтера»
делаются в репозитории, где случилась находка; в канон продвигается общая
часть.
**ПОЧЕМУ.** Правило, написанное сразу в общем виде, не проверено ни одним
применением, и условие применимости у него придумано, а не найдено, —
платят за это все потребители сразу. Формулировка, обкатанная на одном
репозитории, приезжает в канон уже с известной границей.
### META-4. В тексте конвенции нет утверждений о состоянии репозитория
**НЕ ДОЛЖЕН.** Норма пишется в настоящем предписывающем времени, без
описаний того, как сейчас устроен конкретный репозиторий.
**ПОЧЕМУ.** Такое утверждение устаревает молча и подменяет норму описанием:
читатель перестаёт понимать, что от него требуется, а что просто
констатировано. В копиях у остальных потребителей чужой факт вдобавок ложен
с первого дня. «Так сделано у нас» — содержимое региона отступлений, где оно
и локально, и проверяемо.
### META-20. Норма самодостаточна, наружу смотрит только обоснование
**ДОЛЖЕН.** Норму правила можно исполнить, имея один этот файл. Ссылка на
правило чужой темы допустима в обосновании, в «Связано» и в разграничении
области действия — но не в самой норме. Если норме нужен концепт соседней
темы, он коротко повторяется здесь, а сосед называется в обосновании как
источник решения.
**ПОЧЕМУ.** Репозиторий подписывается на произвольное подмножество
конвенций, и графа зависимостей у него нет по построению. Норма, которую
нельзя исполнить без отсутствующего файла, делает такое подмножество
невалидным молча: читатель видит связный текст и не замечает, что часть
нормы не определена. Обоснование, потерявшее адресата, деградирует честно —
пропадает перекрёстная проверка, смысл остаётся. Цена повтора — риск
разойтись с источником; она платится сознательно и видна, в отличие от
скрытой зависимости.
### META-21. Ссылка ведёт на тему или на правило, но не на путь в каноне
**ДОЛЖЕН.** На соседнюю конвенцию ссылаются именем темы (конвенция
`logging`), на конкретное правило — идентификатором (`SLOG-27`); путь файла
канона в тексте конвенции не употребляется.
**ПОЧЕМУ.** В репозитории конвенция лежит собранной: слои одной темы — это
секции одного файла, и пути `lang/go/logging.md` там не существует. Ссылка
на путь канона умирает при сборке, причём молча — текст остаётся связным.
Имя темы и идентификатор правила переживают и сборку, и переезд файла между
осями. Слой своей темы поэтому называют идентификатором его правила, а не
словами «базовый слой»: слова не проверяются и не ведут к утверждению.
### META-24. Слой ссылается на идентификаторы своего базового слоя
**ДОПУСКАЕТСЯ.** Правило языкового или стекового слоя называет идентификатор
правила арх-слоя своей темы прямо в норме.
**ПОЧЕМУ.** Подписываются темой, а не слоем: собранный файл начинается с
арх-слоя независимо от того, какие язык и стек выбраны, — базовое правило в
копии всегда рядом, и ссылка на него никуда не ведёт. Повтор его концепта
здесь заводил бы второй источник правды внутри одного документа: META-20
требует повторять концепт там, где соседнего файла может не быть, а базовый
слой отсутствовать не может. Остальные слои темы попадают в копию по
манифесту, и такой гарантии у них нет — отсюда узость разрешения. Записано
оно явно, потому что META-20 читают строже, чем он есть, и без этой строки
базу дублируют без нужды.
### META-5. Расхождение кода с правилом — отступление, а не повод переписать правило
**ДОЛЖЕН.** Правило правится, только когда неверно по существу: содержит
фактическую ошибку, внутреннее противоречие или условие применимости,
которое не даёт ответа.
**ПОЧЕМУ.** Правило, подогнанное под текущий код, перестаёт что-либо
требовать — оно описывает то, что и так происходит, и первое же расхождение
переписывает его снова. Направление «конвенция → код» держится ровно тем,
что факт не считается аргументом.
### META-6. Высшая модальность требует воспроизводимого вердикта
**ДОЛЖЕН.** Правило со ступенью ДОЛЖЕН или НЕ ДОЛЖЕН формулируется так, что
двое проверяющих по одному его тексту выносят один и тот же вердикт.
**ПОЧЕМУ.** Проверяют конвенцию в первую очередь агент и человек — они читают
текст правила и по нему смотрят код. Проверка, стало быть, есть у каждого
правила с первого дня, и её инструмент — формулировка, а не скрипт. Отсюда
цена невоспроизводимой нормы: вердикт зависит от того, кто читал, нарушения
всплывают выборочно, а отступление нечем записать — неизвестно, нарушено ли.
Для СЛЕДУЕТ это честно, там суждение и есть содержание правила; ДОЛЖЕН в
таком виде обещает то, чего не делает, и через несколько случаев обесценивает
остальные ДОЛЖЕН в файле.
Отсюда следствие: правило, вердикт которого зависит от суждения по построению
(вкус формулировки, выбор границы, уместность в конкретном месте), не может
быть ДОЛЖЕН — его модальность СЛЕДУЕТ по природе нормы, а не по слабости.
### META-25. Высшая модальность выбирается, только когда назван вред
**СЛЕДУЕТ.** Правило получает ДОЛЖЕН или НЕ ДОЛЖЕН, если в обосновании
сказано, что́ ломается при нарушении.
**ПОЧЕМУ.** Воспроизводимость вердикта — условие необходимое (META-6), но не
достаточное: воспроизводимо проверяемых мелочей больше, чем важных вещей, и
без второго условия единственным фильтром остаётся удобство проверки. Шкала
наполняется опрятностью, читатель перестаёт отличать «уронит прод» от
«неаккуратно» — и обесцениваются все ДОЛЖЕН в файле, ровно то, от чего META-6
защищает с другой стороны. Собственная ступень этого правила — СЛЕДУЕТ:
форма обоснования ничем не ограничена, поэтому «вред назван» вердикта не
даёт — один читатель увидит названный вред там, где другой увидит объяснение
мотива.
### META-27. Механизация правила желательна, но ступени не задаёт
**СЛЕДУЕТ.** Правило со ступенью ДОЛЖЕН получает машинную проверку, когда
такую проверку можно написать.
**ПОЧЕМУ.** Линтер не забывает, ничего не стоит на каждом прогоне и краснеет
до ревью, а не на нём: там, где проверка пишется, она дешевле самого
внимательного чтения, и путь «находка → конвенция → проверка» кончается ею.
Норму она при этом не заменяет и не отменяет (META-8). Условием ступени
механизация не является:
проверяющий по умолчанию — читатель правила (META-6), а если требовать
скрипт, весь канон стоит в СЛЕДУЕТ до того дня, когда скрипты появятся.
Ступень говорит о важности нормы, а не о состоянии инструментов.
### META-7. Факт механизации фиксируется в копии со ссылкой на правило
**ДОЛЖЕН.** Запись о механизации называет идентификатор правила и
конкретную проверку.
**ПОЧЕМУ.** Механизация — состояние конкретного репозитория, канон о ней не
знает, а без записи следующий автор либо заведёт вторую проверку того же,
либо будет вычитывать глазами уже проверенное машиной. Без идентификатора
читатель догадывается сам, к какому утверждению относится проверка, — и
догадывается по-разному.
### META-8. Норма из канона не удаляется, чем бы она ни проверялась
**НЕ ДОЛЖЕН.** Формулировка нормы остаётся в правиле независимо от того, кто и
чем её проверяет.
**ПОЧЕМУ.** Проверяющий по умолчанию — читатель правила (META-6), и удаление
нормы забирает у него ровно то, чем он проверяет: линтер сообщает, что
нарушено, но не сообщает, что требуется. Условие «механизировано у всех»
спасти не может: оно измеряется в день удаления, а подписчики появляются
после. Репозиторий, подключившийся через год, получил бы правило без нормы и
без проверки — заголовок с обоснованием и никакого способа узнать, что́ именно
предписано, кроме git-истории канона, до которой он не дойдёт. Списка
подписчиков у канона к тому же нет по построению, так что «у всех» ему всё
равно не проверить.
### META-10. Обоснование не удаляется никогда
**НЕ ДОЛЖЕН.** Блок ПОЧЕМУ остаётся и после того, как правило стало
проверяться линтером.
**ПОЧЕМУ.** Линтер сообщает, что нарушено, но не сообщает, зачем правило
существует. Без обоснования не видно, когда причина отпала, — проверка
продолжает работать по инерции, и возразить ей нечем, кроме как отключив.
### META-11. У трудноизменяемого слоя область действия пишется явно
**ДОЛЖЕН.** Конвенция о схеме БД, формате хранения или раскладке директорий
называет, к чему применяется: к новым таблицам и миграциям, а не к
состоянию схемы.
**ПОЧЕМУ.** Здесь не работает привычное «новое пишем правильно, старое
переезжает по мере касания»: таблица не переезжает от того, что её
потрогали. Без явной рамки правило читается как требование к текущему
состоянию, и оба исхода плохи — миграция живых данных ради опрятности либо
молчаливый вывод, что конвенция не соблюдается совсем.
### META-12. Механизируется граница изменения, а не состояние
**ДОЛЖЕН.** Проверка запрещает нарушение в новых миграциях, а не в уже
существующей схеме.
**ПОЧЕМУ.** Проверка состояния краснеет на легаси с первого дня: её
отключают или обвешивают вечным списком исключений — и она перестаёт ловить
новое, ради чего заводилась. Проверка границы оставляет старое в покое и
делает новую ошибку невозможной.
### META-13. Список отступлений трудноизменяемого слоя — постоянный
**ДОЛЖЕН.** Перечисленные старые таблицы и раскладки живут как есть, а не
как задачи на дочистку.
**ПОЧЕМУ.** Список, записанный долгом, требует либо мигрировать живые данные
без выгоды, либо год за годом объяснять невыполненный план. Второе кончается
тем, что список перестают вести, — и пропадает единственное место, где видно,
где именно правило не действует.
### META-14. Отступления перечисляются поимённо, со ссылкой на правила
**ДОЛЖЕН.** В локальной части копии перечислены отступления, которые уже
есть в коде, с идентификатором правила и причиной.
**ПОЧЕМУ.** Иначе репозиторий выглядит соблюдающим конвенцию, а проверить
это можно только чтением всего кода. Со ссылками отступления счётны: видно,
сколько правил конвенции репозиторий реально не соблюдает. Пустой список при
этом почти всегда означает не отсутствие отступлений, а то, что их не искали.
### META-15. Запись об отступлении разбирается по масштабу
**ДОЛЖЕН.** Судьба записи зависит от того, что в ней сказано:
| № | Что записано | Куда идёт |
|---|---|---|
| META-15.1 | правилу не следуем в перечисленных местах, причина названа | остаётся отступлением |
| META-15.2 | правило не применяется в репозитории целиком | чинится условие применимости — в каноне |
| META-15.3 | конвенция репозиторию не нужна | копия удаляется, подписки нет |
**ПОЧЕМУ.** Отступление описывает исключение, и по нему видно, какая часть
правила нарушена. Запись «мы это правило вообще не применяем» такой
информации не несёт и маскирует одну из двух чинимых причин: неверную рамку
правила в каноне, которую чинят один раз для всех, или лишнюю подписку,
где файл просто не нужен. Оставленная отступлением, она прячет обе.
### META-17. Ссылки на код репозитория стоят в локальной части, а не в «Связано»
**ДОЛЖЕН.** Раздел «Связано» канона содержит только ссылки, верные у всех
потребителей; ссылки на ADR, код и файлы конкретного репозитория — в
локальной части копии.
**ПОЧЕМУ.** Текст канона приезжает ко всем потребителям, и ссылка на чужой
файл у них битая с первого дня. Ниже маркера та же ссылка никого не
задевает и переживает обновление, потому что обновление её не трогает.
### META-22. Репозиторное в копии пишется ниже маркера локальной части
**ДОЛЖЕН.** Правки репозитория вносятся ниже маркера, а не в текст,
пришедший из канона.
**ПОЧЕМУ.** Обновление перезаписывает всё, что выше маркера, поэтому правка
там живёт до первого `pull`. Заметить пропажу можно, только вычитав
`git diff` целиком — а он в этот момент и без того полон изменений канона,
и своя строка теряется среди чужих.
### META-23. Документ, переставший быть копией, не носит `origin:`
**НЕ ДОЛЖЕН.** Файл, который развели с каноном намеренно, шапку `origin:`
не сохраняет.
**ПОЧЕМУ.** По `origin:` решается, какие файлы пересобирать из канона. Форк,
оставивший шапку, при первом же обновлении теряет ровно то, ради чего его
заводили. Происхождение такого документа остаётся в истории коммита, где оно
никого не вводит в заблуждение.
### META-18. README директории перечисляет конвенции с однострочным описанием
**СЛЕДУЕТ.** Одна плоская таблица: файл и строка о том, про что он.
**ПОЧЕМУ.** Подписка — это набор лежащих файлов, и без описаний вопрос
«какая из них про мой случай» решается открыванием каждой. Ценой в десяток
файлов это означает, что не открывают ни одной.
### META-19. Короткие инварианты дублируются в точку входа агента
**ДОЛЖЕН.** В `AGENTS.md` / `CLAUDE.md` едет одна строка на правило с его
идентификатором; детали остаются в конвенции.
**ПОЧЕМУ.** Сама по себе конвенция агенту не видна: он дойдёт до неё, только
если его туда отправили, — а безусловно он читает точку входа. Строка с
идентификатором служит и напоминанием, и адресом, по которому за
подробностями идут; перенос деталей туда же вернул бы задачу поддержки двух
текстов.
## Снятые правила
Снятое правило остаётся здесь заглушкой: номер занят навсегда, ссылка на него
ведёт к объяснению, а нумерация в файле остаётся сплошной (META-31).
### META-9. Общая механизация разрешала удалить норму из канона
**СНЯТО 2026-07-26.** Удаление нормы оставляло подписчика, пришедшего позже,
без текста и без проверки, а условие «механизировано у всех» набору не
проверить: списка подписчиков у него нет. Взамен — META-8, запрет удалять
норму вообще.
### META-16. Имя файла — kebab-case
**СНЯТО 2026-07-26.** Вреда от нарушения нет, а значит нет и высшей
модальности (META-25): сборка идёт по имени темы из шапки, а не по имени
файла. Осталось прозой в разделе «Оформление».
### META-26. Запрет слов обязательства в обосновании
**СНЯТО 2026-07-26.** Правило о заглавных уже делает строчное «обязан»
ненормативным, поэтому запрет ничего не добавлял, а форму обоснования рамками
не ограничивают.
+645
View File
@@ -0,0 +1,645 @@
---
version: 1
---
# Язык конвенций
Формальный язык, на котором записаны правила этого канона: что считается
правилом, чем оно отличается от прозы вокруг, какими словами задаётся
обязательность и как на правило сослаться извне.
Версия языка — **1**. Номер называется в каждой конвенции: словарь может
пополниться, и текст, написанный по предыдущей версии, должен читаться по
той, по которой написан.
Документ адресован автору набора и в репозиторий-потребитель не едет. К
читателю копии едет короткое `READING.md`: словарь со значениями, форма
правила и её граница, ссылки, локальная часть — без разделов о ведении набора.
Словарь в двух документах обязан совпадать (META-30), и это единственное
место, где между ними возможен дрейф.
Описание языка ни на один набор конвенций не опирается, поэтому все примеры
здесь — вымышленные правила с префиксами на `X` (`XKEY`, `XMIG`, `XLOG`).
Такие префиксы канон не занимает никогда, в манифесте набора их нет — значит
пример не спутать с настоящим правилом, а перенумерация конвенций описание
языка не задевает.
## Опора на стандарты
Язык не выводится из вкуса автора. Каждое решение о форме взято из
документа, где эта задача уже решена и обкатана, и отклонения от источника
названы явно.
| Источник | Что взято | Что отклонено |
|---|---|---|
| **BCP 14** (RFC 2119 + RFC 8174) | шкала модальности; правило «нормативно только заглавное»; сдержанность в высшей модальности; англоязычный словарь как готовый | синонимы `REQUIRED`, `RECOMMENDED`, `OPTIONAL`; `SHALL` как форма требования |
| **ISO/IEC Directives, Part 2** | разделение «требование — рекомендация — разрешение — возможность»; запрет модальных глаголов в утверждениях о факте | списки равнозначных словесных форм («is to», «is required to», «only … is permitted») |
| **ISO/IEC/IEEE 29148** | характеристики хорошего требования — единичность и проверяемость; обоснование и метод верификации как отдельные атрибуты требования | остальной аппарат требований: приоритеты, источники, матрицы трассируемости |
| **DMN** | таблица решений с объявленной политикой совпадения | исполняемая семантика и всё, что предполагает движок решений |
| **EARS** | паттерн «нежелательное поведение» — в виде таблицы | шаблоны как форма записи правила: `WHEN`, `WHILE`, `WHERE` |
| **OpenSpec** | четырёхчастная форма правила; адресуемость правила идентификатором; форма «условие → следствие» для стыка правил | `SHALL`; `GIVEN/WHEN/THEN` как общая форма записи |
Три отклонения стоят объяснения, потому что выглядят как произвол.
**Синонимов нет.** BCP 14 держит `REQUIRED` рядом с `MUST` и `OPTIONAL`
рядом с `MAY` ради читаемости английской прозы. Одна форма записи на ступень
означает, что проверка «модальное слово употреблено вне правила» становится
перечислением, а не разбором синонимических рядов.
**`SHALL` не используется ни в каком словаре этого языка.** Слово занято
спецификациями (OpenSpec), и общая с ними форма стирала бы границу между
конвенцией и описанием поведения системы: `SHALL` в конвенции читался бы как
контракт, которого конвенция не даёт. Для англоязычного словаря это означает
выбор в пользу `MUST` из BCP 14, а не `shall` из ISO/IEC Directives.
**`GIVEN/WHEN/THEN` не берётся как общая форма.** У спецификации субъект —
система, и её поведение разворачивается во времени: состояние, событие,
исход. У конвенции субъект — автор кода, и разворачивать нечего: есть
ситуация выбора и вердикт. Это таблица, а не траектория. Тем же рассуждением
отклонены шаблоны EARS как форма записи правила, а взято из EARS другое —
сообщённое снижение числа дефектов после введения шаблонов. Вывод, что дело в
самой обязательности формы, а не в её конкретном виде, наш; он ниже, среди
усилений.
Исключение — стык правил, где субъект действительно система: там форма
«условие → следствие» берётся сознательно, вместе со служебными словами под
неё. Это единственное место, и оно описано в «Таблицах решений».
## Где источник усилен
Три решения идут дальше источника, и это наши решения, а не его требования.
Названы они отдельно, чтобы довод не подменялся ссылкой: спорить с ними нужно
по существу, а не со стандартом.
- **Обоснование обязательно.** В 29148 rationale — из списка рекомендуемых
атрибутов требования; обязательный костяк там другой, это характеристики
самого требования. Здесь правило без блока ПОЧЕМУ не принимается, потому что
конвенция живёт годами и переживает автора: норма без причины через год либо
отменяется первым возражением, либо соблюдается там, где вредит.
- **Полнота таблицы решений.** DMN даёт политику совпадения как именованный
атрибут, а полноты не требует: индикатор полноты был в первой версии
спецификации и из последующих убран, полноту проверяют валидаторы
инструментов. Здесь она требуется, потому что таблицу и заводят ради
видимости пропуска: неперечисленный случай в прозе не виден, а пустая
клетка видна.
- **Вывод про обязательность шаблона.** В EARS сообщается о снижении числа
дефектов в требованиях после введения шаблонов. Вывод, что выигрыш даёт сама
обязательность формы, а не её конкретный вид, — наш: он объясняет, почему мы
берём из EARS результат, но не берём сами шаблоны.
## Что даёт формализация
Адресуемое правило — не украшение формы, а условие работы трёх механизмов:
- **Механизация.** Запись о ней должна говорить «правило `XMIG-4` проверяет
`archrules`», а не «`AUTOINCREMENT` в новых миграциях — `archrules`»: во
втором случае читатель сам догадывается, к какому утверждению это
относится, и догадывается по-разному.
- **Отступления.** «У нас не так» бесполезно, пока не сказано, что именно
«не так». Со ссылкой на правило отступления становятся счётными: видно,
сколько правил конвенции репозиторий реально не соблюдает.
- **Промоут находки.** Путь «находка → конвенция → правило линтера» требует
ручки, за которую берут конкретное правило.
Общий знаменатель — **идентификатор**. Модальные слова и таблицы полезны,
но вторичны.
## Единица — правило
```markdown
### XKEY-5. Разбор внешнего идентификатора на границе
**ДОЛЖЕН.** Идентификатор, пришедший снаружи, проходит разбор до запроса
к базе.
**ПОЧЕМУ.** Разбор валидирует формат и нормализует регистр. Сравнение строк
в базе побайтовое, поэтому без нормализации запрос молча не находит
существующую запись — отладка такого случая стоит дороже, чем сам разбор.
```
Четыре обязательные части: **идентификатор**, **заголовок**, **модальность
с нормой**, **обоснование под меткой ПОЧЕМУ**. Пятый блок, ПРИМЕРЫ,
необязателен — о нём ниже. Норма — одна фраза; если в неё не влезает, это два
правила. Требование единичности взято из ISO/IEC/IEEE 29148: составная норма
не проверяема целиком, и нарушение одной её половины нечем адресовать.
Метки правила — модальное слово, ПОЧЕМУ, ПРИМЕРЫ — пишутся заглавными и
принадлежат словарю набора: скелет правила читается одинаково в любом языке,
на который канон переведён.
**Правило кончается перед следующим заголовком.** Область правила — от его
заголовка до следующего заголовка любого уровня. Внутри области текст
принадлежит последнему открытому блоку: метка блок открывает, и блок длится
до следующей метки или до конца области.
```markdown
### XKEY-3. Заголовок правила
**ДОЛЖЕН.** Норма одной фразой.
| № | ситуация | вердикт | ← блок нормы: таблица уточняет её
**ПОЧЕМУ.** Причина.
Продолжение причины, пример, ← блок обоснования продолжается
ссылка на внешнюю практику.
### XKEY-4. Следующее правило ← здесь область кончилась
```
Отсюда три следствия:
- **Хвост после ПОЧЕМУ — обоснование** до следующей метки или до конца
области, а не безымянная часть правила и не проза вокруг. Требований в нём
не живёт: то, что подлежит исполнению, стоит в блоке нормы, где у него есть
модальность и адрес. Требование, оставленное
в хвосте, требованием не является — сослаться на него нельзя и отступление
от него записать нельзя.
- **Таблица и список после модальной метки — часть нормы.** Правило,
классифицирующее ситуации, ровно так и записывается («Таблицы решений»), а
вердикт из такой таблицы адресуется номером строки.
- **Проза — это то, что лежит вне областей правил.** Тем самым проверка
«заглавных модальных слов вне правил нет» становится реализуемой: границу
считает разметка, а не читательское суждение о том, где правило кончилось.
Заглавное модальное слово внутри области правила законно, когда это
упоминание ступени в обосновании («для СЛЕДУЕТ это честно»). Метку от
упоминания отличает положение: метка стоит первой в своём абзаце, полужирным
и с точкой.
## Примеры к правилу
Пятый блок правила — необязательный, под меткой ПРИМЕРЫ. В нём код,
показывающий норму в деле, обычно парой «плохо → хорошо». Стоит он после
обоснования: сначала требование, потом причина, потом иллюстрация.
````markdown
### XKEY-5. Внешний идентификатор разбирается до обращения к базе
**ДОЛЖЕН.** Значение, пришедшее снаружи, проходит разбор и нормализацию
раньше, чем по нему делается запрос.
**ПОЧЕМУ.** Синтаксически невалидное значение не может соответствовать
записи, поэтому поход в базу за ним — заведомо холостой.
**ПРИМЕРЫ.**
Плохо — строка уходит в запрос как пришла:
```go
row := db.QueryRow("select … where id = ?", r.PathValue("id"))
```
Хорошо — разбор на границе, запроса при неудаче нет:
```go
id, err := ident.Parse(r.PathValue("id"))
if err != nil {
return notFound(w)
}
row := db.QueryRow("select … where id = ?", id)
```
````
**Пример иллюстрирует норму, а не задаёт её.** Три следствия, ради которых
это сказано:
- **требований в блоке нет.** Всё, что подлежит исполнению, стоит в блоке
нормы; деталь примера — имя переменной, конкретная функция, форма ответа —
требованием не становится. Разошёлся пример с нормой — действует норма, а
пример правят;
- **это не готовый сниппет.** Код в примере сокращён до того, что показывает
правило: обработка ошибок, контекст, импорты в нём условны, и копировать его
дословно не нужно;
- **пример стареет быстрее нормы.** Он привязан к сегодняшнему API, поэтому
расхождение примера с текущим кодом — повод поправить пример, а не отменять
правило.
Блок необязателен: он окупается там, где норму словами описать дороже, чем
показать, — форма вызова, структура записи в логе, раскладка файла. У правила
про выбор границы или про уровень лога иллюстрировать нечего.
## Обоснование обязательно
Правило без блока ПОЧЕМУ не принимается. Это требование к форме, а не
пожелание. В 29148 обоснование — отдельный атрибут требования, но из
рекомендуемых; здесь оно обязательно, и вот почему:
- **Обоснование — единственный способ увидеть, что правило устарело.**
Норма стареет молча; причина стареет заметно. Когда причина отпала, видно,
что правило пора убрать, а не соблюдать по инерции.
- **Правило без обоснования не переживает спор.** Через год ни автор, ни
агент не восстановят мотив, и правило будет либо отменено первым же
возражением, либо соблюдено там, где вредит.
- **Формулировка обоснования — проверка на то, что это вообще правило.**
Если причина не формулируется, перед нами привычка или вкусовщина; ей
место в черновиках, а не в конвенции.
Обоснование отвечает на «что сломается, если сделать иначе», а не
пересказывает норму другими словами. «Потому что так принято» — не
обоснование.
Форма обоснования при этом ничем не ограничена: рамки здесь только
смысловые. Абзац может быть длинным, вести рассуждение, приводить пример,
ссылаться на стандарты, внешние практики и чужие проекты — на устройство
OpenTelemetry, на умолчания библиотек логирования, на процедуру миграции из
документации СУБД. Запрещённых слов и обязательной
структуры у обоснования нет, и заводить их не нужно: обязательность несёт
норма, а обоснование её объясняет — путаницу между этими двумя ролями
исключает правило о заглавных.
## Модальные слова
Инвариант языка — **шкала**: пять ступеней в четырёх категориях ISO/IEC
Directives, Part 2, по одной форме записи на ступень, заглавными. Какими
словами ступени названы — параметр естественного языка набора, а не часть
языка конвенций. Этот канон написан по-русски и несёт русский словарь.
Пишутся заглавными — это ключевые слова, а не обычный текст.
| Слово | Категория | Значение | Отступление |
|---|---|---|---|
| **ДОЛЖЕН** | требование | нарушение считается ошибкой | только с записью в отступления |
| **НЕ ДОЛЖЕН** | требование | запрет | то же |
| **СЛЕДУЕТ** | рекомендация | сильная рекомендация; новый код пишем так | допустимо, причину записываем |
| **НЕ СЛЕДУЕТ** | рекомендация | обратное к СЛЕДУЕТ | то же |
| **ДОПУСКАЕТСЯ** | разрешение | выбор за автором кода; возражение на ревью не принимается | не требуется — правило ничего не запрещает |
**Нормативно только заглавное написание.** Это правило RFC 8174, и оно
здесь по той же причине, по которой понадобилось там: без него каждое
строчное «должен» во вводной прозе становится предметом спора о том, норма
это или речь. Строчное слово нормой не является никогда, поэтому проза
свободна, а проверка «модальное слово вне правила» сводится к поиску
заглавных форм.
**Четвёртая категория ISO — возможность — ключевого слова не имеет.**
Утверждения о том, что бывает и что технически осуществимо, пишутся обычной
прозой и модальных слов не несут. Модальное слово в таком утверждении
превращает описание в норму, которую никто не собирался вводить.
**ДОПУСКАЕТСЯ адресовано рецензенту.** В BCP 14 у `MAY` есть вторая
половина, которую обычно не замечают: сторона, не выбравшая опцию, обязана
работать с той, что выбрала. В конвенции этому соответствует запрет
возражать: выбор, помеченный ДОПУСКАЕТСЯ, на ревью не обсуждается. Без этой
половины слово было бы удобством читателя, а не нормой, и не работало бы в
единственной точке, где у конвенции есть принуждение.
**ДОЛЖЕН требует двух условий сразу:**
1. нарушение причиняет названный вред, а не расходится со вкусом — META-25,
он же критерий BCP 14, где высшая модальность резервируется под то, что
действительно ломается, и не употребляется для навязывания метода;
2. вердикт о нарушении воспроизводим — META-6: по тексту правила двое
проверяющих приходят к одному ответу, иначе обязательность держится на
том, кто читал.
Не выполнено первое — правилу место в СЛЕДУЕТ или нигде. Не выполнено
второе — в СЛЕДУЕТ. Воспроизводимость сама по себе не повышает правило до
ДОЛЖЕН: проверяемых мелочей больше, чем важных вещей, и безразборное
повышение обесценивает шкалу быстрее, чем её отсутствие.
Модальность живёт на **правиле**, а не на файле. Файловый статус
(`status: рекомендуемая` / `обязательная` в шапке) не используется: он
неизбежно врёт, потому что один файл смешивает жёсткие требования с
советами. В шапке остаются только `topic`, `prefix` и `extends`.
## Словарь другого языка
Словарь набора — три перечня, и требования к ним одни и те же.
**Шкала обязательности.** Для английского готовый словарь даёт BCP 14; для
любого другого языка слова берут из перевода стандарта, если он есть, или
переводят сами. Шкала и семантика ступеней при этом не меняются — меняется
только запись.
| Ступень | Русский | Английский (BCP 14) |
|---|---|---|
| требование | ДОЛЖЕН | MUST |
| запрет | НЕ ДОЛЖЕН | MUST NOT |
| рекомендация | СЛЕДУЕТ | SHOULD |
| рекомендация против | НЕ СЛЕДУЕТ | SHOULD NOT |
| разрешение | ДОПУСКАЕТСЯ | MAY |
**Метки.** Обязательности не задают, а размечают: ПОЧЕМУ — обоснование,
ПРИМЕРЫ — иллюстрации к норме, МЕХАНИЗИРОВАНО — запись о проверке в копии,
СНЯТО — заглушку на месте убранного правила. Стандартом не даются ни в одном
языке: в BCP 14 таких понятий нет, слова подбираются под язык так же, как
остальные.
| Метка | Русский | Английский |
|---|---|---|
| обоснование | ПОЧЕМУ | WHY |
| иллюстрации | ПРИМЕРЫ | EXAMPLES |
| способ проверки | МЕХАНИЗИРОВАНО | MECHANIZED |
| снятое правило | СНЯТО | RETIRED |
**Служебные слова сценарного блока** — `КОГДА`, `ТОГДА`, `И`, `ИЛИ`; таблица
и объяснение в разделе «Таблицы решений».
Что требуется от любого словаря:
- **одна форма на ступень и на метку.** Синонимы отклонены не из аскетизма:
проверка «модальное слово вне правила» перечисляет формы, и синонимический
ряд превращает перечисление в разбор.
- **слово заглавными не встречается в обычной прозе этого языка.** Иначе
правило «нормативно только заглавное» перестаёт спасать: проверка ловит
оформление, а не модальность.
- **модальные слова и метки перечислены в строке о версии языка.** Читателю
копии они известны из самого файла, без обращения к этому документу, —
иначе конвенция в чужом репозитории теряет ключ к собственному тексту.
Служебные слова сценария в строку не входят: структура блока читается из
самого блока, и в файле без стыков правил их нет вовсе.
- **словарь один на канон.** Два словаря параллельно дают две формы записи
одного требования и удваивают каждую проверку; выбор языка — свойство
набора, а не отдельного файла.
## Ссылка на язык из конвенции
Каждая конвенция называет язык одной строкой во вводной прозе:
> Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки
> ПОЧЕМУ, ПРИМЕРЫ, МЕХАНИЗИРОВАНО и СНЯТО толкуются как описано в языке
> конвенций версии 1 — тогда и только тогда, когда написаны заглавными.
Слова в строке — из словаря того языка, на котором написан набор. Для
англоязычного набора та же строка выглядит так:
> The key words MUST, MUST NOT, SHOULD, SHOULD NOT, MAY and the marks WHY,
> EXAMPLES, MECHANIZED and RETIRED are to be interpreted as described in the
> conventions language, version 1, and only when written in capitals.
Форма скопирована у BCP 14, где та же задача решается тем же способом:
спецификация не прикладывает к себе словарь и не указывает путь к нему, а
называет документ и версию. Пути в этой строке нет намеренно — конвенция
уезжает в чужой репозиторий, где путей канона не существует, а норму
исполнить всё равно можно: строка сама перечисляет ключевые слова набора и
сама несёт правило заглавных.
## Обязательность и способ проверки — разные атрибуты
Механизация не входит в шкалу модальности: она говорит не о том, насколько
правило обязательно, а о том, чем эта обязательность обеспечена. В 29148 это
два разных атрибута требования, и здесь тоже два.
**Проверяющий по умолчанию — читатель правила**, человек или агент. Канон
пишется прежде всего под агента: он читает конвенцию и по ней смотрит код,
то есть проверка есть у каждого правила с первого дня, и её инструмент —
формулировка нормы. Поэтому вторым условием ДОЛЖЕН стоит воспроизводимость
вердикта (META-6), а не наличие скрипта: ступень говорит о важности нормы и о
том, сколько внимания она получает при проверке, а не о состоянии
инструментов. Линтер сильнее чтения — он не забывает, ничего не стоит на
каждом прогоне и краснеет до ревью, — поэтому механизация желательна везде,
где проверка пишется (META-27), и остаётся концом пути «находка → конвенция →
проверка». Но обязательным условием высшей ступени она не является: иначе весь
канон стоял бы в СЛЕДУЕТ до появления скриптов, которых пока нет ни одного.
**Механизация нормы не заменяет и не сокращает.** Норма остаётся в правиле
навсегда — как и обоснование (META-8, META-10), — сколько бы проверок её ни
подпирало. Причин три:
- **линтер сообщает, что нарушено, но не сообщает, что требуется.** Без нормы
правило нечем исполнить и не с чем сверить вердикт проверки, а проверяющий
по умолчанию читает именно норму;
- **подписчики появляются позже.** Репозиторий, подключившийся через год,
получил бы правило без нормы и без линтера — ни текста, ни проверки;
- **«механизировано у всех» набору не проверить:** списка подписчиков у него
нет по построению.
**Отметка — свойство репозитория, а не набора.** Механизирована норма или нет,
зависит от того, чей это репозиторий, поэтому в тексте конвенции отметки нет:
её место — запись о механизации в локальной части копии, со ссылкой на
идентификатор правила (META-7).
```markdown
<!-- conv:local -->
XMIG-2, XMIG-4 — МЕХАНИЗИРОВАНО: `internal/archrules`, в новых миграциях.
```
Так у правила остаются оба атрибута сразу: обязательность — в норме, которая
приезжает из набора и одинакова у всех, способ проверки — в записи, которая
принадлежит репозиторию и у каждого своя.
## Таблицы решений
Часть правил **классифицирует ситуации**: какой уровень лога, какая
категория директории, что делать с невалидным вводом в зависимости от его
источника. Каноническая форма для них — таблица «ситуация → вердикт», строки
которой нумеруются как подпункты правила (`XLOG-8.1`).
Таблица плотнее прозы и не даёт пропустить ветку: пустая клетка видна, а
неупомянутый случай в абзаце — нет. От такой таблицы требуются два свойства —
первое названо в DMN, второе мы добавили сами («Где источник усилен»):
- **Политика совпадения.** По умолчанию строки взаимоисключающи: любой
ситуации соответствует ровно одна. Если это не так, таблица объявляет
порядок строкой над собой — «применяется первое совпадение». Молчание об
этом означает, что при двух подходящих строках читатель выбирает сам, и
два автора выберут по-разному.
- **Полнота.** Перечислены все случаи, попадающие в область действия. Если
возможен случай вне перечисленных, он назван отдельной строкой, а не
оставлен на догадку.
Сценарный блок остаётся точечным инструментом — для **стыка правил**, когда
два правила вместе дают неочевидный результат:
```
КОГДА зависимость недоступна И ретраи вызова исчерпаны
ТОГДА внешний вызов даёт запись ERROR,
И тик фонового цикла, упавший по той же причине, — запись WARN
```
Такой блок ставится после обоих правил и ссылается на их идентификаторы.
Если стыков нет — сценариев в файле нет.
Форма «условие → следствие» здесь взята намеренно, хотя как **общая** форма
записи она отклонена: на стыке правил субъект действительно система, и
результат разворачивается во времени — то самое, для чего эта форма и
придумана. Служебные слова блока перечислены ниже и подчиняются тем же
требованиям, что модальные: одна форма на роль, заглавными, набор один на
канон.
| Роль | Русский | Английский |
|---|---|---|
| условие | КОГДА | WHEN |
| следствие | ТОГДА | THEN |
| соединение | И | AND |
| выбор | ИЛИ | OR |
Модальными словами они не являются: обязательности не задают, только
структуру. Поэтому в строку о версии языка они не попадают — там
перечисляется то, чему нужно определение, а логическая связка читается сама,
— и под проверку «модальные слова вне правил» не подпадают.
## Идентификаторы
Идентификаторов в языке два: **правило** адресуется префиксом с номером,
**конвенция целиком** — именем темы. Ссылаться путём к файлу нельзя ни на то,
ни на другое.
**Правило.**
- Формат — `<ПРЕФИКС>-<номер>`: `XKEY-5`, `XLOG-27`. Префикс принадлежит
файлу, нумерация внутри файла сквозная и начинается с единицы.
- Строка таблицы, если на неё нужно ссылаться отдельно, — `XKEY-5.1`,
`XKEY-5.2`.
- **Идентификатор глобален.** Префикс уникален по всему канону, поэтому
путь файла в ссылке не нужен: `XKEY-5` адресует правило одинаково изнутри
файла, из соседней конвенции и из чужого репозитория. В собранной копии
слои разных осей лежат в одном документе, так что ссылка на базовый слой
из языкового вообще никуда не ведёт — правило рядом.
- **Идентификаторы стабильны и не переиспользуются.** Занять номер снятого
правила новым нельзя — иначе ссылка из чужого репозитория начнёт указывать
на другое утверждение. То же относится к префиксам: выбывшие хранит манифест
набора.
- **Снятое правило остаётся заглушкой.** Заголовок и номер сохраняются, норму
с обоснованием заменяет блок СНЯТО с датой и причиной. Поэтому нумерация в
файле сплошная, а любая ссылка разрешается — либо в правило, либо в
объяснение, почему его сняли (META-31, META-32). Отдельного реестра снятых
номеров нет: он был бы вторым источником правды рядом с файлом.
- Префикс **выбирается под файл, а не выводится по формуле**: он нужен,
чтобы по нему искать, а не чтобы его разбирать. Выводимый префикс вдобавок
привязал бы идентификатор к таксономии, которую канон перестраивает, и
упёрся бы в потолок из числа букв алфавита.
- Префиксы на букву `X` каноном не занимаются: они принадлежат локальным
правилам репозиториев-потребителей.
- Перенос правила в другой файл — смысловое изменение, а не переименование:
новый файл означает новый префикс и новую нумерацию. Переезд самого файла
между осями идентификаторы не трогает.
Порядок правил в файле выбирается по читаемости, не по номерам: номер — это
идентификатор, а не позиция.
**Тема.**
- **Тема — набор правил об одном фокусе разработки:** время, конфигурация,
схема БД. Она же — единица подписки: потребитель берёт тему целиком, а не
отдельные правила.
- Имя темы записывается латиницей. Рекомендуется нижний kebab-case
(`db-identifiers`), но годится любой идентификатор, пригодный для имени
файла: имя попадает и в файловую систему потребителя, и в его манифест.
- **Тему объявляет файл, а не файловая система.** Имя стоит в шапке
(`topic:`) и зарегистрировано в манифесте набора. Слои одной темы несут
одно и то же имя — по нему они и собираются в один документ, как бы ни
назывались их файлы.
- **Имя темы не переиспользуется** — как и префикс: оно живёт в шапке
`origin:` каждой копии, в подписке манифеста и в тексте ссылок, поэтому
снятое имя уходит в раздел выбывших манифеста, а не достаётся другой теме.
- Ссылка на конвенцию — имя темы (конвенция `logging`), ссылка на правило —
идентификатор (`XLOG-27`). Путь файла не употребляется ни там, ни там: в
собранной копии путей канона не существует.
## Что правилом не является
Заглавные модальные слова в этих частях **не употребляются** — иначе
перестанет быть понятно, что адресуемо, а что нет:
- **Область действия** — на что конвенция распространяется во времени
(«новые таблицы; существующие не переписываются»). Это рамка для всех
правил файла, а не правило.
- **Связано** — ссылки на смежные конвенции, ADR, код.
- **Локальная часть копии** — содержимое принадлежит репозиторию.
- Вводная проза, объясняющая предмет конвенции.
Все четыре части лежат вне областей правил: до первого заголовка правила или
после заголовка, которым область закрылась. Хвост обоснования сюда не
относится — он внутри правила, и модальные слова в нём законны как упоминания.
## Как на правила ссылаются копии
Ниже маркера локальной части, в репозитории:
```markdown
<!-- conv:local -->
XMIG-2, XMIG-4 — МЕХАНИЗИРОВАНО: `internal/archrules`, в новых миграциях.
XMIG-6 не соблюдается в легаси-таблицах `show_history`, `queue`: составные
ключи там появились до конвенции, переписывание требует миграции данных.
```
Отсюда видно и то, чего раньше не было видно: конвенция из восьми правил,
из которых два механизированы и одно не соблюдается.
## Что стоит проверять машиной
Проверке подлежит всё, что язык **употребляет**: файлы конвенций и документ,
которым канон сам себя ведёт (в этом каноне — `GUIDE.md`, префикс META). Файл,
который язык **цитирует**, — это описание вроде текущего: ключевые слова стоят
в нём как предмет разговора, а не как норма, и проверки к нему не применяются.
Различает не расположение файла, а роль слова в нём.
Ни одна проверка пока не реализована, поэтому при ревью их выполняют чтением.
**Форма правила** — разбором текста, в любом файле, который язык употребляет:
- модальные и служебные слова принадлежат объявленному словарю канона, а не
смеси словарей;
- префикс в шапке файла совпадает с манифестом набора, состоит из четырёх
заглавных латинских букв, не начинается на `X` и не значится в списке
выбывших;
- заголовки правил файла используют только его собственный префикс;
- нумерация внутри файла сплошная: от единицы до наибольшего номера без
пропусков, номера не повторяются, новое правило берёт следующий за
наибольшим (META-31);
- у каждого `### <ПРЕФИКС>-<n>` есть либо модальное слово с нормой и блок
ПОЧЕМУ — ни норма, ни обоснование не удаляются никогда (META-8, META-10), —
либо блок СНЯТО с датой и причиной;
- блок ПРИМЕРЫ, если он есть, стоит после обоснования и не открывает правило:
порядок блоков — норма, ПОЧЕМУ, ПРИМЕРЫ;
- вводная проза содержит строку о версии языка;
- ссылки вида `<ПРЕФИКС>-<n>` — хоть в тексте набора, хоть в локальной части
копии — разрешаются: неразрешённый идентификатор всегда ошибка (META-32);
- заглавные модальные слова не встречаются вне областей правил (область —
от заголовка правила до следующего заголовка) — кроме строки о версии
языка, которая их перечисляет по назначению;
- модальная метка стоит первой в своём абзаце: заглавное слово в середине
фразы — упоминание ступени, а не вторая норма правила.
**Распространение** — разбором текста, только в файлах конвенций: эти проверки
о том, что документ уезжает к потребителю, а документ, которым канон ведёт
себя, не уезжает никуда.
- шапка файла несёт имя темы, и это имя стоит в манифесте набора — среди
живых, а не среди выбывших;
- ось слоя объявлена в шапке, а не выведена из пути; у одной темы не больше
одного слоя без ключей оси — базовый слой единственный;
- если директории осей используются, объявленное в шапке совпадает с путём:
расхождение означает переезд файла без правки шапки;
- имя темы, названное в ссылке или в подписке потребителя, тоже разрешается по
манифесту: ссылка на снятую тему не проходит молча;
- префиксы локальных правил копии начинаются на `X`;
- отметки МЕХАНИЗИРОВАНО в тексте конвенции нет: её место — запись о
механизации в локальной части копии (META-7);
- словарь в коротком описании языка совпадает с этим: те же ступени, те же
метки, те же значения (META-30);
- префикс **чужой темы** не встречается в абзаце с модальностью (META-20);
префикс арх-слоя своей темы там допустим (META-24), префикс другого языка
или стека — нет;
- путь файла канона не встречается в тексте конвенции (META-21).
**Чтением**, потому что машине не даётся:
- строки таблицы взаимоисключающи либо политика совпадения объявлена;
- перечисленные в таблице случаи покрывают область действия;
- норма исполнима без обращения к другим файлам (в файлах конвенций: они
уезжают по одной, а обвязка ссылается на соседей свободно);
- обоснование отвечает на «что сломается», а не пересказывает норму;
- хвост обоснования не вводит требований, которых нет в блоке нормы;
- примеры иллюстрируют норму и не расширяют её: деталь примера не читается
как требование.
Проверки выше — про запись правила. Граница самой темы (не собрала ли она
два решения сразу, нужна ли потребителю целиком) языку не принадлежит: её
вопросы стоят в документе, которым набор ведёт себя.
## Версия языка
Номер версии называется в каждой конвенции, поэтому он двигается, когда
изменение формы способно изменить чтение **уже разданной** копии: копия
ссылается на номер, а не на текст, и обязана читаться по той версии, по
которой написана. Правки формы до того, как копии разошлись, номер не двигают
— читать по ним пока нечего.
Смена словаря под другой естественный язык версию не двигает никогда: версия
принадлежит шкале, меткам и правилам формы, а не буквам.
+134
View File
@@ -0,0 +1,134 @@
---
version: 1
---
# Как читать конвенцию
Файлы рядом с этим — конвенции: правила о том, как в этом репозитории пишут
код. Здесь сказано, как они записаны: что означают заглавные слова, из чего
состоит правило и как на него сослаться. Читается один раз, дальше нужен как
справка.
Файл кладёт сюда сборщик конвенций и перезаписывает целиком при каждом
обновлении. Правки в нём не живут.
## Ключевые слова
Заглавное слово в начале абзаца задаёт обязательность правила.
| Слово | Что означает | Если делаем иначе |
|---|---|---|
| **ДОЛЖЕН** | нарушение считается ошибкой | только с записью в отступления |
| **НЕ ДОЛЖЕН** | запрет, та же строгость | то же |
| **СЛЕДУЕТ** | сильная рекомендация: новый код пишем так | допустимо, причину записываем |
| **НЕ СЛЕДУЕТ** | обратное к СЛЕДУЕТ | то же |
| **ДОПУСКАЕТСЯ** | выбор за автором кода | ничего не требуется — правило не запрещает |
Две вещи, которые легко прочитать неверно:
- **ДОПУСКАЕТСЯ — не бытовое «можно».** У слова есть вторая половина: выбор,
помеченный им, на ревью не обсуждается. Возражение «сделай иначе» против
такого выбора не принимается — иначе разрешение ничего не значило бы.
- **Отступление от ДОЛЖЕН — не запрет на отступление.** Нарушать можно, но
тогда об этом появляется запись: какое правило, где именно, почему. Разница
между ДОЛЖЕН и СЛЕДУЕТ — в том, чем платят за отклонение, а не в том,
возможно ли оно.
Ещё четыре метки заглавными: **ПОЧЕМУ** открывает обоснование правила,
**ПРИМЕРЫ** — код, показывающий норму в деле, **МЕХАНИЗИРОВАНО** стоит при
записи о том, что правило проверяет линтер или скрипт, **СНЯТО** — на месте
правила, которое убрали.
Заглушка со СНЯТО занимает место убранного правила вместе с его номером:
так нумерация остаётся сплошной, а ссылка на снятое правило приводит к
объяснению, а не в пустоту. Требований в такой заглушке нет.
```markdown
### XLOG-4. Уровень записи выбирался по громкости отказа
**СНЯТО 2026-05-14.** Заменено на XLOG-8: громкость каждый оценивал
по-своему, и шкала расползалась.
```
**Нормативно только заглавное написание.** Строчное «должен» в прозе — обычная
речь, а не норма; спорить с ней как с правилом не нужно.
## Из чего состоит правило
Правило в примере вымышленное: префиксы на `X` общий набор не занимает
никогда, поэтому пример нельзя спутать с настоящим правилом.
```markdown
### XLOG-8. Уровень выбирается по адресату
**ДОЛЖЕН.** Уровень отвечает на вопрос «кому сообщение», а не «насколько
громко сломалось».
| № | Уровень | Кому и когда |
|---|---|---|
| XLOG-8.1 | `DEBUG` | разработчику при отладке; в проде выключен |
| XLOG-8.2 | `INFO` | владельцу, аудит постфактум |
**ПОЧЕМУ.** Адресат — единственный признак, по которому разные авторы в
разных местах кода выберут уровень одинаково…
```
- **Идентификатор и заголовок.** `XLOG-8` — адрес правила: по нему на правило
ссылаются, им помечают отступления и механизацию.
- **Модальность с нормой.** Собственно требование, одной фразой. Таблица или
список сразу за модальным словом — часть нормы: она уточняет вердикт, и на
её строку ссылаются номером (`XLOG-8.2`).
- **ПОЧЕМУ.** Зачем правило существует и что сломается, если сделать иначе.
Обоснование ничего не требует — по нему решают, применимо ли правило к
случаю, и видно, когда причина отпала.
- **ПРИМЕРЫ** — необязательный последний блок: код, обычно парой «плохо →
хорошо». Иллюстрация, а не спецификация: деталь примера требованием не
становится, дословно копировать его не нужно, а если пример разошёлся с
нормой — действует норма.
**Правило кончается перед следующим заголовком.** Абзацы после ПОЧЕМУ — это
продолжение обоснования: примеры, разбор границ, ссылки на внешние практики.
Требований в них нет; всё, что подлежит исполнению, стоит в блоке нормы.
## Как ссылаться
- На **правило** — идентификатором: `XLOG-27`. Путь к файлу не нужен,
идентификатор уникален.
- На **конвенцию целиком** — именем темы: конвенция `logging`. Имя темы стоит
в шапке файла (`origin:`).
- Строка таблицы адресуется номером с точкой: `XLOG-8.2`.
## Что ниже маркера
```markdown
<!-- conv:local -->
```
Всё выше маркера приезжает из общего набора и перезаписывается при
обновлении. Всё ниже принадлежит этому репозиторию и обновление переживает.
Там живёт:
- **отступления** — какое правило не соблюдается, где и почему: «`XMIG-6` не
соблюдается в `queue`: составные ключи там появились до конвенции»;
- **механизация** — кто проверяет правило машинно: «`XMIG-4`
МЕХАНИЗИРОВАНО: `internal/archrules`»;
- **разрешение условий**, которые правило оставило открытыми;
- **свои правила** — по той же форме, но с префиксом на `X` (`XLOG-1`).
Префиксы на `X` общий набор не занимает никогда, так что столкнуться они не
могут.
Правка выше маркера живёт до первого обновления и исчезает молча. Если
исправить нужно приехавший текст — либо правку переносят в общий набор, либо
файл перестаёт быть копией: из шапки убирают `origin:`.
## Чего в конвенции не бывает
- **Утверждений о том, как сейчас устроен этот репозиторий.** Правило пишется
в предписывающем времени; «у нас пока не так» — это отступление, и его
место ниже маркера.
- **Заглавных ключевых слов вне правил.** Во вводной прозе, в разделах
«Область действия» и «Связано» их нет, поэтому искать там требования не
нужно.
Язык записи — версия 1. Полное описание живёт в наборе конвенций, у автора;
здесь ровно то, что нужно читателю.
+323 -73
View File
@@ -3,7 +3,23 @@
Общие конвенции для личных проектов. Репозитории берут отсюда копии в свой Общие конвенции для личных проектов. Репозитории берут отсюда копии в свой
`docs/conventions/`, коммитят их и живут дальше самостоятельно — как с `docs/conventions/`, коммитят их и живут дальше самостоятельно — как с
`ansible-roles`: канон не источник истины во время работы, а лавка, из `ansible-roles`: канон не источник истины во время работы, а лавка, из
которой берут и в которую возвращают улучшения. которой берут.
Сами конвенции лежат в `conventions/`, обвязка — в корне:
| Файл | Что описывает |
|---|---|
| `README.md` | устройство канона, оси, сборка копий, жизненный цикл |
| [LANGUAGE.md](LANGUAGE.md) | язык записи правил: идентификаторы, модальность, обоснование |
| [GUIDE.md](GUIDE.md) | как ведут конвенции: когда заводить, механизация, отступления |
| [READING.md](READING.md) | как читать конвенцию: то, что едет к потребителю |
| `.conventions-suite.toml` | манифест набора: язык, темы, префиксы правил |
К потребителю едет содержимое `conventions/` и один файл обвязки —
`READING.md`; остальная обвязка остаётся в каноне. Самодостаточность копии это
не нарушает: конвенция называет язык записи одной
строкой с номером версии и не ссылается на путь (`LANGUAGE.md`, раздел «Ссылка
на язык из конвенции»).
Правило то же, что у ролей: **деплоится и читается только то, что лежит в Правило то же, что у ролей: **деплоится и читается только то, что лежит в
git репозитория**. Канон никем не подключается на лету. git репозитория**. Канон никем не подключается на лету.
@@ -13,7 +29,7 @@ git репозитория**. Канон никем не подключаетс
Конвенция формулируется независимо от конкретного приложения. Она задаёт Конвенция формулируется независимо от конкретного приложения. Она задаёт
правило; код ему следует. Обратное направление запрещено: то, что правило; код ему следует. Обратное направление запрещено: то, что
приложение уже делает иначе, **не является аргументом против правила** — это приложение уже делает иначе, **не является аргументом против правила** — это
отступление, и его место в локальном регионе того репозитория, а не в отступление, и его место в локальной части копии того репозитория, а не в
переформулировке канона. переформулировке канона.
Отсюда практические следствия: Отсюда практические следствия:
@@ -30,12 +46,34 @@ git репозитория**. Канон никем не подключаетс
## Оси ## Оси
``` ```
common/ как вести сами конвенции conventions/
arch/ решения, переживающие смену языка и инструментов arch/ решения, переживающие смену языка и инструментов
lang/<язык>/ как решение реализуется и механизируется в языке lang/<язык>/ как решение реализуется и механизируется в языке
stack/<стек>/ привязка к инструменту, хранилищу, транспорту stack/<стек>/ привязка к инструменту, хранилищу, транспорту
``` ```
Ось файл **объявляет в шапке**, а не наследует от директории (META-38):
```yaml
topic: logging
prefix: SLOG
lang: go
```
Ключей оси нет — базовый слой темы. Слова те же, что в подписке потребителя
(`lang`, `stack`), так что переводить между двумя сторонами нечего. Дерево
директорий повторяет объявленное для человека и остаётся раскладкой
**канона**: в репозитории копия лежит плоско, файлом на тему. Пути файлов
даются относительно `conventions/` (`arch/db-identifiers.md`) и адресуют
исходник канона, а не место в копии. На **правила** ссылаются идентификатором
без пути: `KEYS-5`. Префикс уникален по всему канону (он перечислен в
манифесте набора), поэтому идентификатор не зависит ни от оси, ни от того, как
собран файл у потребителя.
Объявление вдобавок выражает то, чего дерево не умеет: слой, осмысленный
только при совпадении языка и инструмента сразу (`lang: go` и `stack: slog` в
одной шапке).
Тест — по тому, замена чего убивает правило: Тест — по тому, замена чего убивает правило:
> Умирает при смене **языка** → `lang/`. Умирает при смене **инструмента, > Умирает при смене **языка** → `lang/`. Умирает при смене **инструмента,
@@ -59,9 +97,92 @@ stack/<стек>/ привязка к инструменту, хранили
пласт `stack/sqlite/` (типы колонок). Это не принцип, а незавершённая пласт `stack/sqlite/` (типы колонок). Это не принцип, а незавершённая
работа. работа.
## Плоский набор
Осей может не быть вовсе. Набор, где у каждой темы ровно один слой, — не
особый режим, а низкий конец той же модели: сборка «база → язык → стек»
на нём даёт просто копию файла.
```
conventions/
logging.md topic: logging, prefix: LOGS
errors.md topic: errors, prefix: ERRS
time.md topic: time, prefix: TIME
```
Ключей оси в шапках нет, `lang` и `stack` в подписке не пишутся — выбирать
не из чего. Так дешевле начинать и так выглядит чужой набор, которому три оси
объяснять незачем, чтобы записать пять правил.
Цена платится при росте, и она не в инструменте: когда плоская тема
расслаивается, уехавшие в новый файл правила получают новый префикс и новую
нумерацию. Смягчается это тем, что уехавшее правило остаётся на месте
заглушкой СНЯТО с новым адресом в причине (META-31, META-32), и тем, что
резать нужно правильной стороной: база остаётся в исходном файле со своими
идентификаторами, а наружу уезжает специфичное. Если второй язык виден
заранее, дешевле сразу разложить по осям.
## Темы
**Тема — набор правил об одном фокусе разработки:** время, конфигурация,
схема БД. Она же единица подписки и единица сборки: потребитель берёт тему
целиком, а сборщик складывает в один файл все её слои.
Имя темы записывается латиницей; рекомендуется нижний kebab-case, но годится
любой идентификатор, пригодный для имени файла — имя попадает и в файловую
систему потребителя, и в его манифест. Файл конвенции объявляет тему в шапке:
```yaml
topic: db-identifiers
prefix: KEYS
```
Слои одной темы несут одно и то же имя — по нему они и собираются в один
документ, как бы ни назывались их файлы. Имя файла повторяет тему из
удобства, но истина — в шапке.
Темы перечислены в манифесте набора — `.conventions-suite.toml`,
секция `[topics.live]`: имя и однострочное описание. Имя темы не
переиспользуется по той же причине, что и префикс: оно живёт в чужих
репозиториях — в шапке `origin:` каждой копии, в подписке манифеста, в тексте
ссылок, — и выданное второй теме начинает указывать на другой набор правил.
Раз имя вечно, называют тему **решением и его адресатом**, а не ролью части
конкретного проекта (META-37). Логи сервера и логи браузера — это `logging` и
`client-logging`, а не `logging-backend` и `logging-frontend`: роль
принадлежит сегодняшнему устройству одного репозитория и молча начинает врать,
а суффиксная пара вдобавок навязывает чтение «две разновидности одного», хотя
по границе темы это разные решения — общего у них три правила из сорока.
## Префиксы
Каждый файл канона объявляет в шапке свой префикс правил:
```yaml
prefix: KEYS
```
Четыре заглавные латинские буквы, уникальные по всему канону; перечислены в
манифесте набора, секция `[prefixes.live]`, путём **от корня репозитория**, а
не от `conventions/` — манифест покрывает и обвязку тоже. Префикс выбирается
под файл, а не выводится по формуле, и не переиспользуется никогда. Правила
адресуются идентификатором `KEYS-5` — без пути к файлу. Подробности формы —
`LANGUAGE.md`.
`GUIDE.md` тоже несёт префикс и тоже проверяется как конвенция: правила в нём
записаны тем же языком и цитируются по номерам. Темы у него нет — подписаться
на него нельзя, к потребителю он не едет, — и манифест называет его отдельным
ключом `governance`, чтобы конвенция, потерявшая `topic`, не сошла за него.
Буква `X` в начале префикса зарезервирована за репозиториями: канон её не
занимает никогда, а локальные правила потребителя берут префиксы только на
неё (`XTIM`, `XLOG`). Так столкновение локального префикса с будущим
префиксом канона невозможно по построению, и согласовывать заранее ничего не
нужно.
## Расширение ## Расширение
Файл в `lang/` или `stack/` может объявить в шапке: Файл в `lang/` или `stack/` может объявить в шапке ещё и базу:
```yaml ```yaml
extends: arch/db-identifiers.md extends: arch/db-identifiers.md
@@ -72,67 +193,170 @@ extends: arch/db-identifiers.md
неверно сформулировано условие применимости (чинится в каноне), либо неверно сформулировано условие применимости (чинится в каноне), либо
репозиторий на базу просто не подписан. репозиторий на базу просто не подписан.
`extends` — документация связи, а не механизм: `conv` о ней только `extends` — документация связи, а не механизм: за тем, чтобы база лежала
напоминает при `add` и никак не следит за тем, чтобы база лежала рядом. рядом, никто не следит. С объявленной осью база к тому же находится сама —
это слой той же темы без ключей `lang` и `stack`, — так что ключ остаётся
подсказкой человеку и ничего не выбирает.
## Служебная разметка ## Компонент — адресат сборки
**Шапка копии** ставится при `conv add` и в каноне не хранится: Подписка принадлежит репозиторию, а собранный документ адресован не
репозиторию, а куску кода. Пока проект однороден, разницы нет; два языка её
проявляют: `lang = ["go", "javascript"]` склеили бы в один файл го-слой и
js-слой, из которых к правимому коду относится ровно половина.
**Компонент — область репозитория, где все выбранные слои действуют
одновременно.** `sqlite` и `postgres` в теме схемы действуют вместе — разные
таблицы одного сервиса; го-слой и js-слой не действуют вместе никогда, потому
что строка кода написана на чём-то одном. Компонент поэтому совпадает с тем,
у чего один язык, один набор инструментов и один вид приложения (META-36).
Уровней в модели становится три: набор → проект → компонент. Сборка не
меняется — та же линейка «база → язык → стек», прогнанная по разу на
компонент.
## Копия в репозитории
Копия плоская: **один файл на тему**, слои осей идут внутри него секциями в
порядке база → язык → стек. Пути канона в копии не воспроизводятся. Каждый
компонент получает свою директорию:
```
.conventions.toml
backend/docs/conventions/
README.md собственный, не собирается
READING.md как читать конвенцию — приезжает из канона
logging.md база + lang/go + stack/slog
time.md arch/time.md + lang/go/time.md
web/docs/conventions/
READING.md
client-logging.md база + lang/javascript + stack/express
```
Так конвенция остаётся самодостаточным документом: тот, кто проверяет по ней
код — человек или агент, — читает один файл и не собирает тему из трёх мест.
При одном компоненте это ровно прежняя раскладка — `docs/conventions/` в
корне.
Директории компонентов различны, и это единственное, что разводит копии:
`logging.md` двух компонентов — разные файлы с одинаковым `origin: logging`,
и какой из них какой, сборщик знает по манифесту, а читатель — по пути.
Локальные части у них независимы, ради чего всё и затевается: правило,
механизированное линтером в go-компоненте, в js-компоненте не механизировано,
и один общий файл этого не записал бы.
`READING.md` лежит рядом с копиями, то есть по одному на компонент. Файл
генерируемый, а директория с правилами обязана объяснять себя тому, кто в неё
попал.
Имена в директории делятся на три вида: `README.md` принадлежит репозиторию и
сборщик его не трогает, `READING.md` принадлежит канону и перезаписывается
целиком, остальные файлы — копии тем с шапкой `origin:` и локальной частью.
**Шапка копии** ставится при сборке и в каноне не хранится:
```yaml ```yaml
--- ---
origin: arch/time.md # откуда взято origin: time
origin_hash: a1b2c3d4 # отпечаток канона на момент синхронизации
synced: 2026-07-25
local: нет # или: чем и почему разошлись
--- ---
``` ```
`origin_hash` — контент-отпечаток, а не git-SHA. Он позволяет отличать В `origin:` стоит имя темы — то же, что в манифесте набора и в шапках
«канон обновился» от «изменено локально»; без него `status` умеет только `topic:` слоёв, из которых файл собран. Больше в шапке ничего нет: отпечатка
«differs», а такой отчёт быстро перестают читать. Прочие ключи шапки канона и даты синхронизации в ней не хранится, потому что обновление
(`status`, `extends`) — часть документа: они сравниваются наравне с телом. перезаписывает файл в рабочем дереве, и что именно изменилось, показывает
`git diff` до коммита. Второй механизм сравнения рядом с git не нужен.
**Локальные регионы** — куски, принадлежащие репозиторию по определению. **Маркер локальной части** — единственная машинно значимая разметка внутри
Из сравнения исключаются, поэтому вечного шума в `diff` не дают: файла:
```markdown ```markdown
<!-- local:механизировано --> <!-- conv:local -->
`AUTOINCREMENT` в новых миграциях — `internal/archrules`.
<!-- /local --> MIGR-2, MIGR-4 — МЕХАНИЗИРОВАНО: `internal/archrules`.
MIGR-6 не соблюдается в `show_history`, `queue`: составные ключи там
появились до конвенции, миграция данных не окупается.
``` ```
Имя обязательно — перенос при `pull` идёт по именам, безымянные регионы Всё ниже маркера принадлежит репозиторию и переживает обновление; всё выше —
`conv` отвергает. Что всегда локально: пересобирается из канона. Маркер один и безымянный, поэтому у него нет
имени, которое можно осиротить переименованием.
- **механизация** — канон не знает, у кого линтер уже настроен; Ниже маркера живёт то, чего канон о репозитории не знает: механизация,
- **отступления** — «у нас пока не так», честно и поимённо; отступления, разрешение условий («Здесь: INTEGER PK, id наружу не выходят»),
- **разрешение условия** — «Здесь: INTEGER PK, id наружу не выходят»; ссылки на ADR и код, а также **собственные правила**с префиксом на `X`,
- **эталоны и ссылки** — имена функций, файлов, ADR конкретного репозитория; по тем же правилам формы, что и канон.
- **список конвенций** в README репозитория.
Путь файла в каноне и имя региона — это API: переименование осиротит все Если местных правок стало больше, чем каноничного текста, копия перестаёт
копии (`origin` строковый). Переименовывать — только вместе с обходом быть копией: `origin:` из шапки убирают, и дальше это обычный документ
потребителей. репозитория. Файл, оставивший шапку, при следующем обновлении потеряет
всё, что выше маркера.
После `pull` копию нужно перечитать глазами: содержимое региона могло ## Язык записи едет вместе с копиями
устареть относительно переписанного вокруг текста, и автоматика этого не
увидит.
## Раскладка в репозитории Конвенция называет язык одной строкой с номером версии и без пути — строка
работает и сама по себе. Но семантика заглавных слов живёт в описании языка, а
описание в репозиторий-потребитель раньше не попадало: агент, читающий копию,
принимал ДОПУСКАЕТСЯ за бытовое «можно» и терял ровно то, ради чего слово
введено.
Копии повторяют структуру канона: Поэтому в `docs/conventions/` сборщик кладёт `READING.md` — короткое описание
для читателя правил: словарь со значениями, правило заглавных, из чего состоит
правило и где его граница, как ссылаться, что живёт ниже маркера. Полное
[LANGUAGE.md](LANGUAGE.md) остаётся в каноне: три его раздела адресованы
автору набора и ссылаются на правила `GUIDE.md`, которых у потребителя нет.
``` Два документа — один словарь, и это единственное место, где возможен дрейф.
docs/conventions/ Правка ключевых слов или состава частей правила обязана дойти до `READING.md`
README.md собственный, не синхронизируется (META-30), а сверить их дёшево: таблицы либо совпадают, либо нет.
arch/db-identifiers.md
lang/go/db-identifiers.md ## Два манифеста
Манифестов в модели два, и они отвечают на разные вопросы:
| Файл | Где лежит | Что описывает |
|---|---|---|
| `.conventions-suite.toml` | в наборе | сам набор: язык, темы, префиксы правил |
| `.conventions.toml` | в проекте | подключение: откуда копии, компоненты и их подписки |
Манифест набора — единственное место, где перечислены оба идентификатора
канона; правила у них общие, поэтому и файл один. Манифест подключения
отвечает, откуда взяты копии и где брать обновления:
```toml
source = "ssh://git@git.vakhrushev.me:2222/av/dev-conventions.git"
[components.backend]
dir = "backend/docs/conventions"
lang = ["go"]
stack = ["slog", "sqlite"]
topics = ["logging", "errors", "time"]
[components.web]
dir = "web/docs/conventions"
lang = ["javascript"]
stack = ["express"]
topics = ["client-logging"]
``` ```
Подписка не описана отдельным файлом — она **и есть** набор лежащих файлов, `lang` и `stack` выбирают строку разреженной матрицы: файл темы собирает
видимый в `git ls-files`. README директории перечисляет их одной плоской только те слои, которые компоненту подходят, и совпадают со словами, которыми
таблицей, чтобы вложенность не мешала навигации. слой объявил свою ось. `topics` — подписка, именами из манифеста набора;
списка подписчиков у канона по-прежнему нет, список подписок есть только у
потребителя.
Компонент пишется всегда, даже когда он один: сокращённая плоская форма
сэкономила бы три строки и завела бы второй способ сказать то же самое.
Имя компонента при этом не служебное — им сборщик отвечает, что и куда
собрал. У плоского набора `lang` и `stack` в компоненте просто отсутствуют.
Как именно инструмент добирается до канона — путь на диске, git, HTTP —
дело инструмента, а не модели. Манифест отвечает на два вопроса: откуда это
взято и где искать обновления.
Отдельного лок-файла нет. Он отвечал бы на «что было в прошлый раз», а на
это отвечает git: копии закоммичены, автоматического обновления не
существует, и любое изменение проходит через чтение диффа человеком.
## Контракт с агентом ## Контракт с агентом
@@ -140,46 +364,72 @@ docs/conventions/
любого другого документа. Это главный канал тихого дрейфа, поэтому любого другого документа. Это главный канал тихого дрейфа, поэтому
`AGENTS.md` каждого потребителя должен явно говорить: `AGENTS.md` каждого потребителя должен явно говорить:
> Файлы в `docs/conventions/` с шапкой `origin:` — копии из канона > Файлы с шапкой `origin:` в директориях конвенций (пути — в
> `dev-conventions`. Репозиторное пишется только внутрь > `.conventions.toml`) — копии из канона `dev-conventions`. Репозиторное
> `<!-- local:… -->`. Правка вне регионов — либо `conv push` в канон, либо > пишется только ниже `<!-- conv:local -->`; всё выше маркера
> запись причины в `local:`. > перезаписывается при обновлении. Своё правило — с префиксом на `X`.
## Команды ## Команды
```bash Копии собирает `convy` — отдельный инструмент, живущий в своём репозитории и
conv list # что есть в каноне ставящийся бинарём. Запускают его из корня репозитория-потребителя:
conv add arch/time.md # взять к себе (можно несколько за раз)
conv status # ok / изменено локально / канон обновился / разошлись
conv diff [arch/time.md] # чем копия отличается, без учёта локальных регионов
conv pull arch/time.md # забрать обновление канона (регионы переносятся)
conv push arch/time.md # вернуть локальное улучшение в канон
conv push --new lang/go/x.md # завести в каноне новую конвенцию
```
`status` и `diff` всегда завершаются кодом 0: это отчёт, а не проверка.
Расхождение — нормальное состояние, а постоянный шум в `diff` означает не
«догони канон», а «пора разрезать файл». Симметрично: разросшийся до спора
с базой локальный регион означает «пора чинить условие применимости в
каноне».
Запускать из корня репозитория:
```bash ```bash
~/projects/private/dev-conventions/conv status convy init --source <ссылка на канон> --component backend \
--dir docs/conventions --lang go
convy add time # подписаться на тему и собрать файл
convy add time --for backend # то же, когда компонентов несколько
convy pull # пересобрать всё, что перечислено в манифесте
# (и обновить READING.md рядом с копиями)
convy pull --for web # только один компонент
convy sync # подвести раскладку файлов под манифест
convy list # что подключено и что ещё есть в каноне
convy check # проверить форму того, что лежит здесь
``` ```
Обёртка в раннере репозитория (`inv conventions -- status` для ansible, При одном компоненте `--for` не нужен. При нескольких команда без него не
`task conventions -- status` для Go) — тонкий проброс аргументов, чтобы угадывает, а отказывает и перечисляет имена.
Манифест подключения правится и руками — это данные, а не текст с
комментариями. Что бы в нём ни поменяли, раскладку под него подводит `convy
sync`: чего не хватает — соберёт, что осиротело — уберёт, а копию с локальной
частью не тронет и назовёт.
Отчёт о том, что изменилось, отдельной командой не выдаётся: после `pull`
его показывает `git diff`, а решение — принять, поправить или откатить —
принимает человек перед коммитом.
Транспорт обратно в канон не предусмотрен. Улучшение, найденное в
репозитории, переносится в канон руками: это редкая операция, и её цена —
не аргумент против того, чтобы направление оставалось односторонним.
Сам канон ведут те же командой под `suite`: `convy suite add` заводит
конвенцию, `convy suite rule` дописывает правило, `convy suite retire`
снимает, `convy suite check` проверяет целостность набора.
Обёртка в раннере репозитория (`inv conventions -- pull` для ansible,
`task conventions -- pull` для Go) — тонкий проброс аргументов, чтобы
логика не размножалась по репозиториям в двух диалектах. логика не размножалась по репозиториям в двух диалектах.
## Жизненный цикл ## Жизненный цикл
- **В канон.** Новая конвенция пишется в том репозитории, где заболело, и - **В канон.** Новая конвенция пишется в том репозитории, где заболело, и
продвигается `conv push --new`. Локальные регионы при этом опустошаются: переносится в канон, когда стало ясно, что общего в ней больше, чем
в канон едет только норма. местного. Локальная часть при этом не едет: в канон попадает только норма,
а префикс на `X` меняется на канонический — то есть правила получают новые
идентификаторы.
- **Из канона.** Устаревшая конвенция удаляется вместе с обходом - **Из канона.** Устаревшая конвенция удаляется вместе с обходом
потребителей — тихо осиротить копии нельзя. потребителей — тихо осиротить копии нельзя.
- **История.** Канон коммитится при каждом `push`: `origin_hash` отвечает - **История.** Канон коммитится при каждой правке: только git канона
на «отличается ли», но только git канона отвечает на «почему база отвечает на вопрос, почему база сформулирована так.
сформулирована так».
## Состояние
Модель выше реализована в `convy`: сборка копий, отбор слоёв по объявленной
оси, маркер локальной части, `READING.md` рядом с копиями, проверка
целостности набора. Прежний питоновский `conv` — с зеркальным деревом,
именованными регионами и `origin_hash` — удалён вместе со своей моделью.
Ни один репозиторий-потребитель ещё не подключён: копий с шапкой `origin:` в
природе нет. Пока это так, непроверенным остаётся главное — как всё это живёт
в чужом репозитории через полгода после первой сборки.
+144
View File
@@ -0,0 +1,144 @@
# К обсуждению
Черновик для следующего разговора: вопросы и варианты, а не принятые
решения. Закрытый вопрос отсюда удаляется — принятое решение живёт в
`README.md`, `GUIDE.md` или `LANGUAGE.md`, а не в этом файле.
Вопросы про инструмент здесь не живут — они собраны в его собственном
репозитории.
Две секции: сначала язык и подход, потом сам набор и подключение.
# Язык и подход
## 1. Одиннадцать таблиц не прочитаны на взаимоисключительность
`LANGUAGE.md` объявил, что строки таблицы решений взаимоисключающи по
умолчанию, а иной порядок объявляется явно. Объявление не делает таблицы
такими: нумерованные строки есть в одиннадцати файлах канона, и ни одна
таблица под новое требование не прочитана.
Конкретный подозреваемый — SLOG-11: «по реальному действию или изменению»
против «повторяющаяся служебная, по таймеру или поллингу». Периодическая
операция, которая всё-таки меняет данные, подходит под обе строки, и уровень
из таблицы не выводится однозначно.
Работа читательская, машине не даётся; в список проверок она уже записана в
разделе «Чтением, потому что машине не даётся».
## 2. Шесть сниппетов сидят в блоке нормы
С появлением блока ПРИМЕРЫ у кода в правиле есть своё место, но шесть правил
несут сниппет **внутри блока нормы** — там, где он по границе правила читается
как «требуется ровно такой код»: GCFG-7, GCFG-9, SLOG-20, GTIM-8, HTMX-7,
HTMX-24. Кода внутри обоснований в каноне нет ни одного, так что разбирать
нужно только эти шесть.
Разбор по одному, вердикт из двух: сниппет — часть требования или иллюстрация
к нему. У GTIM-8 (`ReplaceAttr` с приведением к UTC) это похоже на норму: там
важна конкретная точка вмешательства. У HTMX-7 и SLOG-20 — скорее иллюстрация
формы вызова, и ей место в ПРИМЕРЫ.
Цена ошибки в обе стороны понятна. Оставленный в норме пример превращает
деталь кода в требование, которое никто не имел в виду, и устаревает вместе с
API, а норму при этом нельзя поправить, не задев требование. Унесённая в
ПРИМЕРЫ норма, наоборот, перестаёт быть обязательной — блок иллюстративный.
## 3. Описание языка отдельно от набора конвенций
`LANGUAGE.md` и `GUIDE.md` описывают, **как** пишутся конвенции;
`conventions/`**один конкретный** набор. Сейчас они склеены в одном
репозитории, и из одного описания нельзя собрать второй набор (рабочий,
доменный, чужой).
Срочности нет: версия языка объявлена в самом `LANGUAGE.md` (ключ
`version:`), и конвенции ссылаются на неё номером, а не путём, — то есть
самодостаточность копии выноса не требует. Примеры в `LANGUAGE.md` вдобавок
переведены на вымышленные `X`-правила, так что на конкретный набор описание
языка больше не ссылается вовсе.
Что осталось поводом:
- из одного описания по-прежнему нельзя собрать второй набор;
- тулинг валидирует правила, зашитые в его код, а не объявленную версию
языка.
Оба повода включаются, только когда появится второй набор. Цена — ещё одна
сущность и ещё одна синхронизация; при одном наборе лечение выходит хуже
болезни.
# Канон и подключение
## 4. Значения осей нигде не зарегистрированы
Ось теперь объявлена в шапке (META-38), но её значения не сверяются ни с чем:
`lang: golang` вместо `lang: go` соберётся молча — слой просто не попадёт ни в
одну копию. Это ровно та болезнь, от которой лечили тему (META-28): объявление
без реестра проверяется только глазами.
Напрашивается секция в `.conventions-suite.toml` рядом с `[topics.live]` и
`[prefixes.live]` — перечень живых языков и стеков с однострочным описанием, и
те же правила выбытия. Против: третий реестр в манифесте, а значений сегодня
три (`go`, `ansible`, `htmx`). За: словарь общий у двух сторон — им же
потребитель пишет `lang` и `stack` в своём компоненте, и опечатка там стоит
столько же.
Заодно решается судьба `extends:`: с объявленной осью база находится сама —
это слой той же темы без ключей оси, — так что ключ остался подсказкой
человеку и кандидат на снятие.
## 5. Пары слоёв и темы без базы
Отложено сознательно, но список стоит держать перед глазами:
- `SLOG` объявляет `extends: arch/time.md` — расширение **чужой** темы.
Сборщик темы `logging` на это наткнётся: базового слоя с темой `logging`
нет, а `arch/time.md` он тянуть не должен. Чинится переводом в обычную
ссылку «связано». Вдобавок это ломает гарантию META-24: она верна только
для базы своей темы, а `arch/time.md` объявляет тему `time`, не `logging`.
С объявленной темой расхождение стало проверяемым машинно.
- Темы без арх-слоя: `db-schema`, `errors`, `web-ui`. Собираются в файл с
одной секцией — само по себе не ломается, но это и есть тот невыделенный
арх-слой из известного долга.
- Имена тем в паре не совпадают: `arch/db-identifiers.md` против
`lang/go/db-schema.md`. При сборке по имени темы это две разные темы —
проверить, что так и задумано.
- В `lang/go/db-schema.md` сидит целый пласт `stack/sqlite/` (типы колонок),
тоже из известного долга README.
- Вынос арх-ядра из `errors` и `logging` закроет две хрупкие ссылки из
`web-ui` (стек) в go-слой — единственные ссылки стек → язык в каноне.
## 6. Восемь тем не прогнаны по границе
Критерии границы записаны правилами (META-33 … META-37), но ни одна тема по
ним не прочитана. Работа по одной теме: выписать вопрос темы одной фразой и
пройти по правилам, помечая чужие.
Два подозреваемых видно уже сейчас.
`time` собрана вокруг вещества, а не решения (META-33): TIME-2 и TIME-3
(ширина и точность на носитель), TIME-6 (дефолтов в схеме БД нет), GTIM-4
(20 символов в БД), GTIM-8 и GTIM-9 (UTC и точность в логах) выносят вердикты
тем `db-schema` и `logging`. Остаток — представление момента, единая точка
«сейчас», длительность и часы, не-UTC на отображении — тема настоящая. Разрез
попутно снимает `extends: arch/time.md` из вопроса 5.
`logging` лежит целиком в `lang/go`, хотя внутри три страта: уровень по
адресату, «ошибка логируется один раз на границе», секреты — не про Go;
`JSONHandler`, `stdout`, разбор через `jq`/DuckDB — про стек; SLOG-30…33
(входящий запрос, healthcheck, 4xx) — про вид приложения (META-36), то есть
про веб-сервис. Здесь же лежит невыделенное арх-ядро из вопроса 5.
Отдельно проверить обратное: `errors` без арх-слоя — не дефект. Обработка
отказа в плейбуке (`failed_when`, `block`/`rescue`, идемпотентность) го-шные
правила не сужает, а решает другую задачу, значит для плейбуков это своя
тема, а не слой в `errors` (META-35).
## 7. Подключение к репозиториям
Ничего ещё не подключено. Кандидаты — jellybit и pet-project-server.
Понадобится: заполнить локальную часть копий тем, что сейчас в этих
репозиториях записано по факту; обёртка в раннере (`inv conventions` /
`task conventions`, единый интерфейс команд у трёх ansible-репозиториев);
строка в `AGENTS.md` каждого потребителя про то, что файлы в директориях
конвенций — копии. Компонент у обоих кандидатов один, но записывается явно.
-137
View File
@@ -1,137 +0,0 @@
# Идентификаторы сущностей
Как выбираются и как выглядят первичные ключи сущностей. Форма записи —
`common/language.md`.
## Область действия
Схема базы меняется тяжело: таблица не переезжает от того, что её
потрогали. Поэтому правила распространяются на **новые таблицы**;
существующие живут как есть и перечисляются в отступлениях, причём этот
список постоянный, а не список задач на дочистку.
## Правила
### R1. Вид первичного ключа выбирается один раз на репозиторий
**ДОЛЖЕН.** Репозиторий отвечает на один вопрос и держит ответ для всех
своих таблиц:
> Есть ли в приложении хотя бы одна сущность, которую адресуют **извне** —
> по идентификатору из URL, запроса API или callback-данных?
| № | Ответ | Вид ключа |
|---|---|---|
| R1.1 | да, хотя бы одна | сортируемый строковый идентификатор, который генерирует приложение (ULID) — во **всех** таблицах, включая внутренние |
| R1.2 | ни одной | автоинкремент |
**Почему.** Квантор репозиторный, а не потабличный, по двум причинам.
Внутренние сущности имеют привычку становиться внешними — и тогда
целочисленный идентификатор утекает в URL задним числом, а миграция ключа
на живых данных стоит несопоставимо дороже, чем взять строковый сразу.
Вторая причина дешевле, но важнее в быту: одна ментальная модель избавляет
от спора при заведении каждой таблицы.
Критерий — именно **адресация**: снаружи по этому идентификатору
возвращаются к системе. Не «виден в логе»: туда рано или поздно попадает
любой идентификатор, и по такому критерию ветка R1.2 была бы недостижима.
Запрет смешивания касается двух видов **сгенерированных суррогатных**
ключей. Естественные и составные ключи у таблиц-деталей (R6) — третья
категория, они допустимы при любом ответе.
<!-- local:решение -->
<!-- /local -->
### R2. При выборе R1.1 идентификатор генерирует приложение, а не база
**ДОЛЖЕН.** Значение ключа известно до вставки строки.
**Почему.** Идентификатор нужен раньше, чем база ответит: его пишут в лог
начатой операции, кладут в связанные записи одной транзакции и возвращают
клиенту. Генерация на стороне базы вынуждает либо ждать `last_insert_rowid`
и достраивать связи вторым проходом, либо иметь два источника истины о
моменте создания.
### R3. Генерация и разбор идентификаторов — в единственной точке
**ДОЛЖЕН.** Один модуль порождает идентификаторы, он же их разбирает.
Самодельных генераторов и парсеров в коде нет.
**Почему.** Нормализация регистра (R4) и проверка формата обязаны
применяться ко всем идентификаторам без исключения. Любая вторая точка
входа рано или поздно окажется той, где нормализацию забыли, — и дефект
проявится не там, где создан.
### R4. Канонический вид — нижний регистр
**ДОЛЖЕН.** Идентификаторы порождаются и хранятся в нижнем регистре.
**Почему.** Сравнение строк в базе обычно побайтовое, поэтому регистр — не
косметика, а корректность поиска. Спецификация ULID канонизирует **верхний**
регистр, и библиотеки по умолчанию отдают именно его: без единой точки (R3)
разный регистр появится в базе сам собой.
### R5. Внешний идентификатор разбирается до обращения к базе
**ДОЛЖЕН.** Значение, пришедшее снаружи, проходит разбор и нормализацию
раньше, чем по нему делается запрос. Реакция на неудачный разбор зависит от
источника:
| № | Откуда пришёл | Разбор не удался → |
|---|---|---|
| R5.1 | путь или query URL | «не найдено» без обращения к хранилищу |
| R5.2 | собственная форма, данные кнопки | «некорректный ввод» либо «элемент устарел» |
**Почему.** Синтаксически невалидное значение не может соответствовать
записи, поэтому поход в базу за ним — заведомо холостой; отсекая его на
границе, мы дёшево снимаем целый класс мусорного трафика.
Разделение R5.1 и R5.2 нужно, потому что источники значат разное. Мусор в
URL — это чужая или протухшая ссылка, и «не найдено» описывает ситуацию
точно. Мусор из собственной формы — это баг интерфейса или устаревший
экран; ответ «не найдено» здесь скрывает дефект и лишает диагностики
единственный момент, когда он заметен.
### R6. У таблиц-деталей допустим естественный или составной ключ
**ДОПУСКАЕТСЯ.** Когда ключ есть по природе данных, отдельный
сгенерированный идентификатор не заводится.
**Почему.** Суррогат поверх естественного ключа создаёт второй способ
адресовать ту же строку — а значит, возможность рассинхрона между ними и
лишний вопрос «по какому из них искать» на каждом запросе. Дополнительной
информации он не несёт.
### R7. Прочие генерируемые идентификаторы — через ту же точку
**ДОЛЖЕН.** Идентификаторы, не являющиеся первичными ключами (батч,
задание, корреляционный ключ), порождаются тем же модулем (R3) и в том же
формате.
**Почему.** Единый формат делает работающим главный побочный эффект
строковых идентификаторов: `grep` по голому значению собирает все
упоминания сущности в логах независимо от имени поля. Второй формат
идентификаторов эту возможность отменяет ровно для тех записей, где она
чаще всего нужна.
## Почему ULID, а не UUID
Ветка R1.1 требует **сортируемый** строковый идентификатор. UUIDv4 не
сортируется по времени вовсе. UUIDv7 (RFC 9562) сортируется — и против него
остаются два довода: 36 символов против 26 и дефисы, из-за которых
идентификатор не берётся ни двойным кликом, ни `grep`-ом как одно слово.
Сортировка даёт `ORDER BY id` = хронология с точностью до миллисекунды;
внутри одной миллисекунды порядок произволен, если генератор не монотонный,
— на порядок событий это не влияет.
<!-- local:отступления -->
<!-- /local -->
## Связано
- `arch/time.md` — метки времени тоже генерирует приложение, а не схема.
<!-- local:связано -->
<!-- /local -->
-247
View File
@@ -1,247 +0,0 @@
# Как мы ведём конвенции
Конвенция описывает повторяющийся выбор: как называть директории, как
раскладывать данные, как оформлять ошибки. Она отвечает на вопрос «как
принято», а не «что здесь происходит».
Как записывается сама конвенция — правила, модальность, обоснования — в
[language.md](language.md). Здесь — про то, зачем конвенции заводятся, где
живут и как соотносятся с соседними видами документов.
## Область действия
Правила ниже адресованы автору конвенции — тому, кто заводит новый файл или
правит существующий, в каноне и в копиях репозиториев. Обязательность живёт
на отдельном правиле, а не на файле; шкала модальных слов — в
[language.md](language.md).
## Отличие от соседей
- `docs/adr/`**решение**, принятое однажды и постфактум («почему выбрали
Authelia, а не Keycloak»). Запись неизменяема.
- `docs/specs/` и OpenSpec, где они есть, — **что** система делает,
наблюдаемое поведение как контракт. Конвенция — **как** написан код;
в спеки она не переносится, это не capability.
- `docs/drafts/` — оперативная хроника и черновики, «что собираюсь сделать».
- `docs/conventions/`**правило на будущее**, применяемое многократно.
Живой документ: правится, когда договорённость меняется.
## Канон и копии
Файлы в `docs/conventions/` с шапкой `origin:` — копии из общего канона
`dev-conventions`, а не собственные документы репозитория. Репозиторное
живёт только внутри локальных регионов `<!-- local:имя --> … <!-- /local -->`:
они исключены из сравнения с каноном, и расхождение по ним — норма, а не
дрейф. Правка вне регионов означает одно из двух: улучшение, которое
возвращают в канон, или сознательное расхождение, записанное в ключ `local:`
шапки. Состояние копий показывает `conv status`, различия — `conv diff`,
обновление из канона — `conv pull`; всё через раннер репозитория.
Имя региона обязательно и стабильно: перенос содержимого при обновлении
идёт по именам, и переименование осиротит содержимое во всех копиях.
## Правила
### R1. Одна конвенция — один файл
**ДОЛЖЕН.** Файл описывает ровно один повторяющийся выбор.
**Почему.** Подписка — это набор лежащих в репозитории файлов, и берут файл
целиком. Файл, собравший две темы, вынуждает репозиторий взять правила,
которые ему не нужны, и получать шум в `diff` по чужой половине. Разрезать
позже дорого: путь файла — часть адреса правила, и после разреза внешние
ссылки указывают не туда.
### R2. Конвенция заводится, когда решение принимается третий раз
**СЛЕДУЕТ.** Поводом служит одно и то же решение, принятое третий раз и
каждый раз чуть по-другому.
**Почему.** По одному-двум случаям не видно, что в решении повторяется, а
что было частностью места: правило, выведенное из первого случая, кодирует
частность и дальше мешает больше, чем помогает. Единичный выбор, к тому же
спорный, читателю нужен вместе с мотивом — это ADR, где мотив и есть
содержание записи.
### R3. Новая конвенция пишется там, где заболело
**СЛЕДУЕТ.** Первые шаги пути «находка → конвенция → правило линтера →
удаление прозы» делаются в репозитории, где случилась находка; в канон
продвигается общая часть.
**Почему.** Правило, написанное сразу в общем виде, не проверено ни одним
применением, и условие применимости у него придумано, а не найдено, —
платят за это все потребители сразу. Формулировка, обкатанная на одном
репозитории, приезжает в канон уже с известной границей.
### R4. В тексте конвенции нет утверждений о состоянии репозитория
**НЕ ДОЛЖЕН.** Норма пишется в настоящем предписывающем времени, без
описаний того, как сейчас устроен конкретный репозиторий.
**Почему.** Такое утверждение устаревает молча и подменяет норму описанием:
читатель перестаёт понимать, что от него требуется, а что просто
констатировано. В копиях у остальных потребителей чужой факт вдобавок ложен
с первого дня. «Так сделано у нас» — содержимое региона отступлений, где оно
и локально, и проверяемо.
### R5. Расхождение кода с правилом — отступление, а не повод переписать правило
**ДОЛЖЕН.** Правило правится, только когда неверно по существу: содержит
фактическую ошибку, внутреннее противоречие или условие применимости,
которое не даёт ответа.
**Почему.** Правило, подогнанное под текущий код, перестаёт что-либо
требовать — оно описывает то, что и так происходит, и первое же расхождение
переписывает его снова. Направление «конвенция → код» держится ровно тем,
что факт не считается аргументом.
### R6. `ДОЛЖЕН` без механической проверки либо механизируется, либо понижается
**ДОЛЖЕН.** Правило, нарушение которого объявлено ошибкой, получает
машинную проверку или переводится в СЛЕДУЕТ.
**Почему.** Без проверки правило держится на внимании: нарушения копятся
молча и всплывают выборочно — на том ревью, куда дошли руки. Для СЛЕДУЕТ
это честно, а ДОЛЖЕН при этом обещает то, чего не делает, и через несколько
таких случаев обесценивает остальные ДОЛЖЕН в файле.
### R7. Факт механизации фиксируется в локальном регионе со ссылкой на номер
**ДОЛЖЕН.** Регион `механизировано` называет номер правила и конкретную
проверку.
**Почему.** Механизация — состояние конкретного репозитория, канон о ней не
знает, а без записи следующий автор либо заведёт вторую проверку того же,
либо будет вычитывать глазами уже проверенное машиной. Без номера правила
читатель догадывается сам, к какому утверждению относится проверка, — и
догадывается по-разному.
### R8. Формулировка не удаляется из канона, пока механизирована не у всех
**НЕ ДОЛЖЕН.** Норма остаётся в каноне, пока хотя бы у одного потребителя
машинной проверки нет.
**Почему.** У кого линтера нет, тот после удаления остаётся без правила
вообще: ни проверки, ни текста. Механизация у одного потребителя ничего не
говорит о прочих, поэтому удалять по факту «у нас уже проверяется» —
значит чинить свой файл за чужой счёт.
### R9. Общая механизация разрешает удалить норму из канона
**ДОПУСКАЕТСЯ.** Правило, уехавшее в общий конфиг линтера или в общую роль,
удаляется из канона одним `push`.
**Почему.** Формулировка, дублирующая работающую у всех проверку,
размазывает внимание: файл на несколько сотен строк заставляет человека и
агента добросовестно вычитывать тривиальное именование и не доходить до
формы решения. Явное разрешение нужно, чтобы R8 не читался как запрет
удалять вообще.
### R10. Обоснование не удаляется никогда
**НЕ ДОЛЖЕН.** Блок «Почему» остаётся и после того, как норма уехала в
линтер.
**Почему.** Линтер сообщает, что нарушено, но не сообщает, зачем правило
существует. Без обоснования не видно, когда причина отпала, — проверка
продолжает работать по инерции, и возразить ей нечем, кроме как отключив.
### R11. У трудноизменяемого слоя область действия пишется явно
**ДОЛЖЕН.** Конвенция о схеме БД, формате хранения или раскладке директорий
называет, к чему применяется: к новым таблицам и миграциям, а не к
состоянию схемы.
**Почему.** Здесь не работает привычное «новое пишем правильно, старое
переезжает по мере касания»: таблица не переезжает от того, что её
потрогали. Без явной рамки правило читается как требование к текущему
состоянию, и оба исхода плохи — миграция живых данных ради опрятности либо
молчаливый вывод, что конвенция не соблюдается совсем.
### R12. Механизируется граница изменения, а не состояние
**ДОЛЖЕН.** Проверка запрещает нарушение в новых миграциях, а не в уже
существующей схеме.
**Почему.** Проверка состояния краснеет на легаси с первого дня: её
отключают или обвешивают вечным списком исключений — и она перестаёт ловить
новое, ради чего заводилась. Проверка границы оставляет старое в покое и
делает новую ошибку невозможной.
### R13. Список отступлений трудноизменяемого слоя — постоянный
**ДОЛЖЕН.** Перечисленные старые таблицы и раскладки живут как есть, а не
как задачи на дочистку.
**Почему.** Список, записанный долгом, требует либо мигрировать живые данные
без выгоды, либо год за годом объяснять невыполненный план. Второе кончается
тем, что список перестают вести, — и пропадает единственное место, где видно,
где именно правило не действует.
### R14. Отступления перечисляются поимённо, со ссылкой на номера правил
**ДОЛЖЕН.** В локальном регионе перечислены отступления, которые уже есть в
коде, с номером правила и причиной.
**Почему.** Иначе репозиторий выглядит соблюдающим конвенцию, а проверить
это можно только чтением всего кода. Со ссылками отступления счётны: видно,
сколько правил конвенции репозиторий реально не соблюдает. Пустой регион при
этом почти всегда означает не отсутствие отступлений, а то, что их не искали.
### R15. Запись в регионе отступлений разбирается по масштабу
**ДОЛЖЕН.** Судьба записи зависит от того, что в ней сказано:
| № | Что записано | Куда идёт |
|---|---|---|
| R15.1 | правилу не следуем в перечисленных местах, причина названа | остаётся отступлением |
| R15.2 | правило не применяется в репозитории целиком | чинится условие применимости — в каноне |
| R15.3 | конвенция репозиторию не нужна | копия удаляется, подписки нет |
**Почему.** Отступление описывает исключение, и по нему видно, какая часть
правила нарушена. Запись «мы это правило вообще не применяем» такой
информации не несёт и маскирует одну из двух чинимых причин: неверную рамку
правила в каноне, которую чинят один раз для всех, или лишнюю подписку,
где файл просто не нужен. Оставленная отступлением, она прячет обе.
### R16. Имя файла — kebab-case по теме
**СЛЕДУЕТ.** `app-directories.md`, а не вариации регистра и разделителя.
**Почему.** Имя файла — часть глобального адреса правила
(`stack/ansible/app-directories.md R4`) и значение ключа `origin` в каждой
копии. Один способ записи избавляет от нескольких написаний одного адреса,
а ошибка в адресе обнаруживается только тем, кто по нему пришёл и ничего не
нашёл.
### R17. Репо-специфичная часть «Связано» — в локальном регионе
**ДОЛЖЕН.** Раздел «Связано» стоит в конце файла; канонические ссылки — в
общем тексте, ссылки на ADR, код и файлы конкретного репозитория — в
локальном регионе.
**Почему.** Общий текст уезжает `push`-ем ко всем потребителям, и ссылка на
чужой файл у них битая с первого дня. Регион исключён из сравнения, поэтому
та же ссылка внутри него никого не задевает и не даёт вечного шума в `diff`.
### R18. README директории перечисляет конвенции с однострочным описанием
**СЛЕДУЕТ.** Одна плоская таблица: файл и строка о том, про что он.
**Почему.** Подписка — это набор лежащих файлов, и без описаний вопрос
«какая из них про мой случай» решается открыванием каждой. Ценой в десяток
файлов это означает, что не открывают ни одной.
### R19. Короткие инварианты дублируются в точку входа агента
**ДОЛЖЕН.** В `AGENTS.md` / `CLAUDE.md` едет одна строка на правило с его
номером; детали остаются в конвенции.
**Почему.** Сама по себе конвенция агенту не видна: он дойдёт до неё, только
если его туда отправили, — а безусловно он читает точку входа. Строка с
номером служит и напоминанием, и адресом, по которому за подробностями
идут; перенос деталей туда же вернул бы задачу поддержки двух текстов.
<!-- local:точки-входа -->
<!-- /local -->
-205
View File
@@ -1,205 +0,0 @@
# Язык конвенций
Как записываются правила в этом каноне. Документ описывает форму, а не
содержание: что такое правило, чем оно отличается от прозы вокруг и как на
него сослаться.
Форма подсмотрена у OpenSpec, но взято оттуда не всё — см. «Чего мы не
берём».
## Зачем формализовать
Не ради строгости. Три конкретные вещи, которые без адресуемых правил не
работают:
- **Механизация.** Регион `механизировано` должен говорить «правило R4
проверяет `archrules`», а не «`AUTOINCREMENT` в новых миграциях —
`archrules`»: во втором случае читатель сам догадывается, к какому
утверждению это относится, и догадывается по-разному.
- **Отступления.** «У нас не так» бесполезно, пока не сказано, что именно
«не так». Со ссылкой на правило отступления становятся счётными: видно,
сколько правил конвенции репозиторий реально не соблюдает.
- **Промоут находки.** Путь «находка → конвенция → правило линтера →
удаление прозы» требует ручки, за которую берут конкретное правило.
Общий знаменатель — **идентификатор**. Модальные слова и таблицы полезны,
но вторичны.
## Единица — правило
```markdown
### R5. Разбор внешнего идентификатора на границе
**ДОЛЖЕН.** Идентификатор, пришедший снаружи, проходит разбор до запроса
к базе.
**Почему.** Разбор валидирует формат и нормализует регистр. Сравнение строк
в базе побайтовое, поэтому без нормализации запрос молча не находит
существующую запись — отладка такого случая стоит дороже, чем сам разбор.
```
Четыре обязательные части: **номер**, **заголовок**, **модальность с
нормой**, **почему**. Норма — одна фраза; если в неё не влезает, это два
правила.
## Правило без «почему» не принимается
Это жёсткое требование к форме, а не пожелание. Причины:
- **«Почему» — единственный способ понять, когда правило перестало
действовать.** Норма стареет молча; обоснование стареет заметно. Когда
причина отпала, видно, что правило пора убрать, а не соблюдать по
инерции.
- **Правило без обоснования не переживает спор.** Через год ни автор, ни
агент не восстановят мотив, и правило будет либо отменено первым же
возражением, либо соблюдено там, где вредит.
- **Формулировка «почему» — проверка на то, что это вообще правило.** Если
причина не формулируется, перед нами привычка или вкусовщина; ей место в
черновиках, а не в конвенции.
«Почему» отвечает на «что сломается, если сделать иначе», а не пересказывает
норму другими словами. «Потому что так принято» — не обоснование.
## Модальные слова
Пишутся капсом — это ключевые слова, а не обычный текст.
| Слово | Значение | Отступление |
|---|---|---|
| **ДОЛЖЕН** | нарушение считается ошибкой | только с записью в регион `отступления` |
| **НЕ ДОЛЖЕН** | запрет | то же |
| **СЛЕДУЕТ** | сильная рекомендация; новый код пишем так | допустимо, причину записываем |
| **НЕ СЛЕДУЕТ** | обратное к СЛЕДУЕТ | то же |
| **ДОПУСКАЕТСЯ** | явное разрешение | не требуется — правило ничего не запрещает |
**ДОПУСКАЕТСЯ** нужно не для симметрии: оно снимает вопрос «а так можно?»
там, где соседнее правило звучит строго и его легко перечитать шире, чем
задумано.
Модальность живёт на **правиле**, а не на файле. Прежний файловый статус
(`status: рекомендуемая` / `обязательная` в шапке) отменён: он неизбежно
врал, потому что один файл смешивает жёсткие требования с советами. В шапке
остаются только `extends` и служебные ключи копии.
Мы **не используем SHALL и прочие английские ключевые слова**. Они заняты
спецификациями (OpenSpec), и общий словарь стирал бы границу «конвенция —
не capability». Разный словарь эту границу держит бесплатно.
## Идентификаторы
- Формат — `R<номер>`, сквозная нумерация внутри файла, начиная с `R1`.
- Строка таблицы, если на неё нужно ссылаться отдельно, — `R5.1`, `R5.2`.
- **Номера стабильны и не переиспользуются.** Удалённое правило оставляет
дыру в нумерации; занимать её новым правилом нельзя — иначе ссылка из
чужого репозитория начнёт указывать на другое утверждение.
- Глобальный адрес — путь файла плюс номер: `arch/db-identifiers.md R5`.
В пределах одного файла достаточно `R5`.
Порядок правил в файле выбирается по читаемости, не по номерам: номер — это
идентификатор, а не позиция.
## Правило, чья норма уехала в линтер
Когда правило механизировано у всех потребителей, его норма из канона
удаляется, а обоснование — нет. Остаётся **правило без модальности**, и
чтобы оно не выглядело недописанным, место нормы занимает отметка:
```markdown
### R6. Дефолтов времени в схеме БД нет
**МЕХАНИЗИРОВАНО.** Проверяется общим правилом линтера; формулировка
удалена, потому что дублировала работающую проверку.
**Почему.** Дефолт превращает забытую вставку в тихо работающий код…
```
- Номер и заголовок сохраняются: ссылки из репозиториев продолжают
указывать на то же утверждение.
- «Почему» остаётся навсегда — линтер сообщает, что нарушено, но не
сообщает, зачем правило существует, и без обоснования нельзя понять,
когда проверку пора отменять.
- **МЕХАНИЗИРОВАНО** — не шестое модальное слово: оно не задаёт
обязательность, а сообщает, что обязательность теперь обеспечена машиной.
В остальном такое правило равно ДОЛЖЕН.
Факт «механизировано у всех» устанавливается вручную: канон по построению
не знает списка подписчиков, и обойти репозитории перед удалением нормы —
часть работы, а не то, что можно проверить автоматически.
## Таблицы вместо сценариев
Часть правил **классифицирует ситуации**: какой уровень лога, какая
категория директории, что делать с невалидным вводом в зависимости от его
источника. Для них каноническая форма — таблица «ситуация → вердикт»,
строки которой при необходимости нумеруются.
Таблица плотнее прозы и не даёт пропустить ветку: пустая клетка видна, а
неупомянутый случай в абзаце — нет.
## Чего мы не берём из OpenSpec
**GIVEN/WHEN/THEN.** У спецификации субъект — система, и её поведение
разворачивается во времени: состояние, событие, исход. У конвенции субъект
— автор кода, и разворачивать нечего: есть ситуация выбора и вердикт. Это
таблица, а не траектория.
**SHALL.** См. выше про словарь.
**Сценарии как общая форма.** Прозаический сценарий остаётся точечным
инструментом — для **стыка правил**, когда два правила вместе дают
неочевидный результат:
```
WHEN зависимость недоступна и ретраи вызова исчерпаны → ext-запись ERROR
AND тик фонового цикла упал по той же причине → доменная запись WARN
```
Такой блок ставится после обоих правил и ссылается на их номера. Если
стыков нет — сценариев в файле нет.
## Что правилом не является
Модальные слова в этих частях **не употребляются** — иначе перестанет быть
понятно, что адресуемо, а что нет:
- **Область действия** — на что конвенция распространяется во времени
(«новые таблицы; существующие не переписываются»). Это рамка для всех
правил файла, а не правило.
- **Связано** — ссылки на смежные конвенции, ADR, код.
- **Локальные регионы** — содержимое принадлежит репозиторию.
- Вводная проза, объясняющая предмет конвенции.
## Как на правила ссылаются копии
В репозитории:
```markdown
<!-- local:механизировано -->
R2, R4 — `internal/archrules` (проверяются в новых миграциях).
<!-- /local -->
<!-- local:отступления -->
R6 — не соблюдается в легаси-таблицах `show_history`, `queue`: составные
ключи там появились до конвенции, переписывание требует миграции данных.
<!-- /local -->
```
Отсюда видно и то, чего раньше не было видно: конвенция из восьми правил,
из которых два механизированы и одно не соблюдается.
## Что стоит проверять машиной
Сейчас не реализовано; список — на будущее для `conv`:
- номера уникальны внутри файла и не имеют пропусков вниз (новое правило
берёт следующий свободный, а не первый освободившийся);
- у каждого `### R<n>` есть модальное слово (или отметка МЕХАНИЗИРОВАНО) и
блок «Почему»;
- ссылки вида `R<n>` в локальных регионах копии указывают на правила,
которые в каноне ещё существуют;
- модальные слова не встречаются вне правил.
## Порядок перевода
Все конвенции канона записаны на этом языке. Новая конвенция пишется на нём
сразу; смешение форм в каноне больше не предполагается.
-515
View File
@@ -1,515 +0,0 @@
#!/usr/bin/env python3
"""conv — синхронизация конвенций между каноном и репозиторием.
Канон — эта директория. Репозиторий держит закоммиченные копии нужных
конвенций в docs/conventions/, повторяя структуру канона. Копия — источник
правды для репозитория; канон — лавка, из которой берут.
Служебная разметка копии:
---
origin: arch/time.md # откуда взято
origin_hash: a1b2c3d4 # отпечаток канона на момент синхронизации
synced: 2026-07-25
local: нет # или текст: чем и почему разошлись
---
Прочие ключи шапки (status, extends) — часть документа: они сравниваются
наравне с телом и приезжают из канона.
Локальные регионы — куски, которые по определению принадлежат репозиторию
(механизация, отступления, «здесь решили так»). Из сравнения исключаются:
<!-- local:механизировано -->
...
<!-- /local -->
Имя региона обязательно: перенос при pull идёт по именам.
Команды:
conv list что есть в каноне
conv add arch/time.md [...] взять конвенцию в репозиторий
conv status состояние копий репозитория
conv diff [arch/time.md] чем копия отличается от канона
conv pull arch/time.md забрать обновление канона
conv push arch/time.md вернуть локальное улучшение в канон
conv push --new lang/go/x.md завести в каноне новую конвенцию
Везде можно указать --repo <path> (по умолчанию — текущая директория)
и --dir <subpath> (по умолчанию docs/conventions), до или после команды.
"""
from __future__ import annotations
import argparse
import datetime
import difflib
import hashlib
import re
import sys
from pathlib import Path
from typing import NoReturn
CANON = Path(__file__).resolve().parent
CANON_TREES = ("common", "arch", "lang", "stack")
SERVICE_KEYS = ("origin", "origin_hash", "synced", "local")
DEFAULT_DIR = "docs/conventions"
ENC = "utf-8"
# Маркеры распознаются только в начале строки: так пример разметки внутри
# текста конвенции не превращается в настоящий регион.
REGION_RE = re.compile(
r"^<!--[ \t]*local(?::[ \t]*([^>]*?))?[ \t]*-->(.*?)^<!--[ \t]*/local[ \t]*-->",
re.DOTALL | re.MULTILINE,
)
OPEN_RE = re.compile(r"^<!--[ \t]*local", re.MULTILINE)
CLOSE_RE = re.compile(r"^<!--[ \t]*/local", re.MULTILINE)
def die(message: str) -> NoReturn:
print(f"conv: {message}", file=sys.stderr)
sys.exit(1)
# --- разметка --------------------------------------------------------------
def read(path: Path) -> str:
return path.read_text(encoding=ENC)
def write(path: Path, text: str) -> None:
path.write_text(text, encoding=ENC)
def split_front(text: str) -> tuple[dict[str, str], str]:
"""Отделяет YAML-шапку (плоский key: value) от тела."""
if not text.startswith("---\n"):
return {}, text
end = text.find("\n---\n", 4)
if end == -1:
return {}, text
meta: dict[str, str] = {}
for line in text[4:end].splitlines():
if ":" in line:
key, value = line.split(":", 1)
meta[key.strip()] = value.strip()
return meta, text[end + 5 :]
def join_front(meta: dict[str, str], body: str) -> str:
if not meta:
return body
lines = "\n".join(f"{k}:{' ' + v if v else ''}" for k, v in meta.items())
return f"---\n{lines}\n---\n{body}"
def doc_keys(meta: dict[str, str]) -> dict[str, str]:
return {k: v for k, v in meta.items() if k not in SERVICE_KEYS}
def regions(body: str) -> dict[str, str]:
"""Содержимое локальных регионов по имени.
Поднимает ValueError на разметке, из-за которой регион молча превратился
бы в обычный текст и потерялся при pull.
"""
matched = len(REGION_RE.findall(body))
if len(OPEN_RE.findall(body)) != matched or len(CLOSE_RE.findall(body)) != matched:
raise ValueError("непарный или нераспознанный маркер локального региона")
found: dict[str, str] = {}
for match in REGION_RE.finditer(body):
name = (match.group(1) or "").strip()
content = match.group(2)
if not name:
if content.strip():
raise ValueError(
"безымянный локальный регион с содержимым — дай ему имя"
)
continue
if name in found:
raise ValueError(f"локальный регион '{name}' встречается дважды")
found[name] = content
return found
def checked_regions(body: str, where: str) -> dict[str, str]:
try:
return regions(body)
except ValueError as exc:
die(f"{where}: {exc}")
def blank_regions(body: str) -> str:
"""Тело с опустошёнными локальными регионами — то, что сравнивается."""
def repl(match: re.Match[str]) -> str:
raw = (match.group(1) or "").strip()
head = f"<!-- local:{raw} -->" if raw else "<!-- local -->"
return f"{head}\n<!-- /local -->"
return REGION_RE.sub(repl, body)
def fill_regions(body: str, values: dict[str, str]) -> tuple[str, list[str]]:
"""Вставляет содержимое регионов по имени. Возвращает тело и имена,
которым не нашлось места."""
used: set[str] = set()
def repl(match: re.Match[str]) -> str:
raw = (match.group(1) or "").strip()
head = f"<!-- local:{raw} -->" if raw else "<!-- local -->"
if raw in values:
used.add(raw)
return f"{head}{values[raw]}<!-- /local -->"
return match.group(0)
filled = REGION_RE.sub(repl, body)
lost = [n for n, v in values.items() if n not in used and v.strip()]
return filled, lost
def fingerprint(meta: dict[str, str], body: str) -> str:
"""Отпечаток документа: ключи шапки плюс тело без локальных регионов."""
head = "\n".join(f"{k}={v}" for k, v in sorted(doc_keys(meta).items()))
return hashlib.sha256(f"{head}\n\n{blank_regions(body)}".encode(ENC)).hexdigest()[
:8
]
def today() -> str:
return datetime.date.today().isoformat()
# --- канон и репозиторий ---------------------------------------------------
def canon_list() -> list[str]:
out: list[str] = []
for tree in CANON_TREES:
root = CANON / tree
if root.is_dir():
out += [str(p.relative_to(CANON)) for p in sorted(root.rglob("*.md"))]
return out
def canon_read(origin: str) -> tuple[dict[str, str], str]:
path = CANON / origin
if not path.is_file():
die(f"в каноне нет {origin}")
return split_front(read(path))
def normalize_origin(name: str, *, must_exist: bool = True) -> str:
"""Принимает 'arch/time.md', 'arch/time' и однозначный хвост вроде 'time'."""
name = name.strip("/")
if not name.endswith(".md"):
name += ".md"
candidate = (CANON / name).resolve()
if candidate.is_relative_to(CANON):
rel = str(candidate.relative_to(CANON))
if rel.split("/")[0] in CANON_TREES and (not must_exist or candidate.is_file()):
return rel
matches = [c for c in canon_list() if c == name or c.endswith("/" + name)]
if len(matches) == 1:
return matches[0]
if not matches:
die(f"в каноне нет {name} (путь должен начинаться с {'/'.join(CANON_TREES)})")
die(f"неоднозначно: {name} → {', '.join(matches)}")
def repo_dir(args: argparse.Namespace) -> Path:
return (Path(str(args.repo)) / str(args.dir)).resolve()
def repo_copies(base: Path) -> tuple[dict[str, Path], list[Path], list[str]]:
"""origin → копия; плюс .md без шапки и сообщения о нечитаемых файлах."""
found: dict[str, Path] = {}
untracked: list[Path] = []
problems: list[str] = []
if not base.is_dir():
return found, untracked, problems
for path in sorted(base.rglob("*.md")):
try:
meta, _ = split_front(read(path))
except (OSError, UnicodeDecodeError) as exc:
problems.append(f"{path.name}: не читается ({type(exc).__name__})")
continue
origin = meta.get("origin")
if not origin:
if path.name != "README.md":
untracked.append(path)
continue
if origin in found:
problems.append(
f"{origin}: две копии ({found[origin]}, {path}) — вторая скрыта"
)
continue
found[origin] = path
return found, untracked, problems
def locate(args: argparse.Namespace, origin: str) -> Path:
"""Путь копии: по шапке, если она лежит не по канонному пути."""
base = repo_dir(args)
copies, _, _ = repo_copies(base)
return copies.get(origin, base / origin)
# --- состояние -------------------------------------------------------------
def state(
meta: dict[str, str], body: str, canon_meta: dict[str, str], canon_body: str
) -> str:
copy_fp = fingerprint(meta, body)
canon_fp = fingerprint(canon_meta, canon_body)
if copy_fp == canon_fp:
return "ok"
base = meta.get("origin_hash")
if not base:
return "нет origin_hash в шапке"
if base == canon_fp:
return "изменено локально"
if base == copy_fp:
return "канон обновился"
return "разошлись"
# --- команды ---------------------------------------------------------------
def cmd_list(args: argparse.Namespace) -> int:
for origin in canon_list():
meta, _ = canon_read(origin)
marks = []
if "extends" in meta:
marks.append(f"расширяет {meta['extends']}")
if "status" in meta:
marks.append(meta["status"])
tail = f" ({'; '.join(marks)})" if marks else ""
print(f"{origin}{tail}")
return 0
def cmd_add(args: argparse.Namespace) -> int:
base = repo_dir(args)
added = False
for raw in args.names:
origin = normalize_origin(raw)
target = base / origin
if target.exists():
print(f"{origin}: уже есть ({target}), пропускаю")
continue
canon_meta, canon_body = canon_read(origin)
checked_regions(canon_body, f"канон/{origin}")
meta: dict[str, str] = {
"origin": origin,
"origin_hash": fingerprint(canon_meta, canon_body),
"synced": today(),
"local": "нет",
}
meta.update(doc_keys(canon_meta))
target.parent.mkdir(parents=True, exist_ok=True)
write(target, join_front(meta, canon_body))
added = True
print(f"{origin} → {target}")
if "extends" in canon_meta:
print(f" расширяет {canon_meta['extends']} — возможно, нужна и она")
if added:
print("не забудь строку в docs/conventions/README.md")
return 0
def cmd_status(args: argparse.Namespace) -> int:
base = repo_dir(args)
copies, untracked, problems = repo_copies(base)
if not copies and not untracked and not problems:
print(f"в {base} нет копий конвенций")
return 0
width = max((len(o) for o in copies), default=0)
for origin, path in copies.items():
try:
meta, body = split_front(read(path))
except (OSError, UnicodeDecodeError) as exc:
print(f"{origin:<{width}} не читается ({type(exc).__name__})")
continue
if not (CANON / origin).is_file():
print(f"{origin:<{width}} нет в каноне")
continue
canon_meta, canon_body = canon_read(origin)
try:
regions(body)
except ValueError as exc:
print(f"{origin:<{width}} разметка: {exc}")
continue
local = meta.get("local", "нет")
note = "" if local == "нет" else f" [{local}]"
print(f"{origin:<{width}} {state(meta, body, canon_meta, canon_body)}{note}")
for path in untracked:
print(f"{path.name}: без шапки origin — не отслеживается")
for problem in problems:
print(problem)
return 0
def cmd_diff(args: argparse.Namespace) -> int:
base = repo_dir(args)
copies, _, _ = repo_copies(base)
targets = [normalize_origin(args.name)] if args.name else list(copies)
for origin in targets:
path = copies.get(origin)
if path is None:
print(f"{origin}: нет копии в репозитории")
continue
if not (CANON / origin).is_file():
print(f"{origin}: нет в каноне")
continue
meta, body = split_front(read(path))
canon_meta, canon_body = canon_read(origin)
if fingerprint(meta, body) == fingerprint(canon_meta, canon_body):
continue
sys.stdout.writelines(
difflib.unified_diff(
join_front(doc_keys(canon_meta), blank_regions(canon_body)).splitlines(
keepends=True
),
join_front(doc_keys(meta), blank_regions(body)).splitlines(
keepends=True
),
fromfile=f"канон/{origin}",
tofile=f"репо/{origin}",
)
)
return 0
def cmd_pull(args: argparse.Namespace) -> int:
origin = normalize_origin(args.name)
path = locate(args, origin)
if not path.is_file():
die(f"нет копии {origin} — сначала conv add {origin}")
meta, body = split_front(read(path))
canon_meta, canon_body = canon_read(origin)
checked_regions(canon_body, f"канон/{origin}")
local = checked_regions(body, f"репо/{origin}")
st = state(meta, body, canon_meta, canon_body)
if st == "ok":
fresh = fingerprint(canon_meta, canon_body)
if meta.get("origin_hash") != fresh:
meta["origin_hash"] = fresh
meta["synced"] = today()
write(path, join_front(meta, body))
print(f"{origin}: тексты совпадают, отпечаток освежён")
else:
print(f"{origin}: уже совпадает")
return 0
if st in ("изменено локально", "разошлись") and not args.force:
die(
f"{origin}: {st} — правки вне локальных регионов будут потеряны.\n"
f" посмотри conv diff {origin}, затем conv pull --force "
f"или conv push {origin}"
)
merged, lost = fill_regions(canon_body, local)
if lost and not args.force:
die(
f"{origin}: в каноне нет регионов {', '.join(lost)} — их содержимое "
f"пропадёт.\n перенеси вручную или conv pull --force"
)
for name in lost:
print(f" потерян локальный регион {name}")
new_meta = {k: meta[k] for k in SERVICE_KEYS if k in meta}
new_meta["origin_hash"] = fingerprint(canon_meta, canon_body)
new_meta["synced"] = today()
new_meta.update(doc_keys(canon_meta))
write(path, join_front(new_meta, merged))
print(f"{origin}: обновлено из канона — перечитай глазами, регионы могли устареть")
return 0
def cmd_push(args: argparse.Namespace) -> int:
origin = normalize_origin(args.name, must_exist=not args.new)
path = locate(args, origin)
if not path.is_file():
die(f"нет копии {origin}")
meta, body = split_front(read(path))
checked_regions(body, f"репо/{origin}")
target = CANON / origin
if not target.is_file():
if not args.new:
die(f"в каноне нет {origin} — заведи новую конвенцию через conv push --new")
target.parent.mkdir(parents=True, exist_ok=True)
write(target, join_front(doc_keys(meta), blank_regions(body)))
meta["origin_hash"] = fingerprint(doc_keys(meta), blank_regions(body))
meta["synced"] = today()
write(path, join_front(meta, body))
print(f"{origin}: заведена в каноне")
return 0
canon_meta, canon_body = canon_read(origin)
st = state(meta, body, canon_meta, canon_body)
if st == "ok":
print(f"{origin}: канон уже такой")
return 0
if st == "канон обновился":
die(
f"{origin}: копия не менялась, а канон ушёл вперёд — пушить нечего, нужен pull"
)
if st == "разошлись" and not args.force:
die(
f"{origin}: разошлись — канон менялся после синхронизации, "
f"его правки затрутся.\n посмотри conv diff {origin}, "
f"затем conv push --force"
)
write(target, join_front(doc_keys(meta), blank_regions(body)))
meta["origin_hash"] = fingerprint(doc_keys(meta), blank_regions(body))
meta["synced"] = today()
write(path, join_front(meta, body))
print(f"{origin}: канон обновлён из репозитория")
return 0
def main() -> int:
common = argparse.ArgumentParser(add_help=False)
common.add_argument("--repo", default=".", help="корень репозитория")
common.add_argument("--dir", default=DEFAULT_DIR, help="где лежат конвенции")
parser = argparse.ArgumentParser(prog="conv", parents=[common], description=__doc__)
sub = parser.add_subparsers(dest="cmd", required=True)
sub.add_parser("list", parents=[common], help="что есть в каноне").set_defaults(
fn=cmd_list
)
p_add = sub.add_parser(
"add", parents=[common], help="взять конвенцию в репозиторий"
)
p_add.add_argument("names", nargs="+")
p_add.set_defaults(fn=cmd_add)
sub.add_parser("status", parents=[common], help="состояние копий").set_defaults(
fn=cmd_status
)
p_diff = sub.add_parser(
"diff", parents=[common], help="чем копия отличается от канона"
)
p_diff.add_argument("name", nargs="?")
p_diff.set_defaults(fn=cmd_diff)
p_pull = sub.add_parser("pull", parents=[common], help="забрать обновление канона")
p_pull.add_argument("name")
p_pull.add_argument("--force", action="store_true")
p_pull.set_defaults(fn=cmd_pull)
p_push = sub.add_parser("push", parents=[common], help="вернуть улучшение в канон")
p_push.add_argument("name")
p_push.add_argument("--force", action="store_true")
p_push.add_argument("--new", action="store_true", help="завести новый файл канона")
p_push.set_defaults(fn=cmd_push)
args = parser.parse_args()
return int(args.fn(args))
if __name__ == "__main__":
sys.exit(main())
@@ -1,10 +1,19 @@
---
topic: app-directories
prefix: DIRS
---
# Категории директорий приложения # Категории директорий приложения
Всё, что приложение пишет на диск, делится на три категории по принципу Всё, что приложение пишет на диск, делится на три категории по принципу
создания и ценности содержимого: конфигурация, данные, кеш. Категория сразу создания и ценности содержимого: конфигурация, данные, кеш. Категория сразу
отвечает на два вопроса, которые иначе выясняются чтением кода приложения: отвечает на два вопроса, которые иначе выясняются чтением кода приложения:
**кто создаёт** содержимое и **что будет, если его потерять**. Из категорий **кто создаёт** содержимое и **что будет, если его потерять**. Из категорий
механически выводится состав бэкапа. Форма записи — `common/language.md`. механически выводится состав бэкапа.
Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки
ПОЧЕМУ, ПРИМЕРЫ, МЕХАНИЗИРОВАНО и СНЯТО толкуются как описано в языке
конвенций версии 1 — тогда и только тогда, когда написаны заглавными.
## Область действия ## Область действия
@@ -16,32 +25,32 @@
## Правила ## Правила
### R1. Записываемые пути разложены по трём категориям ### DIRS-1. Записываемые пути разложены по трём категориям
**ДОЛЖЕН.** Каждая директория, в которую пишет приложение или деплой, **ДОЛЖЕН.** Каждая директория, в которую пишет приложение или деплой,
относится к одной из трёх категорий: относится к одной из трёх категорий:
| № | Категория | Директория | Создаёт | Потеря содержимого | В бэкапе | | № | Категория | Директория | Создаёт | Потеря содержимого | В бэкапе |
|---|---|---|---|---|---| |---|---|---|---|---|---|
| R1.1 | конфигурация | `config/` | деплой | восстанавливается прогоном деплоя | нет | | DIRS-1.1 | конфигурация | `config/` | деплой | восстанавливается прогоном деплоя | нет |
| R1.2 | данные | `data/` | приложение | невосполнима | да | | DIRS-1.2 | данные | `data/` | приложение | невосполнима | да |
| R1.3 | кеш | `cache/` | приложение | приложение перегенерирует | нет | | DIRS-1.3 | кеш | `cache/` | приложение | приложение перегенерирует | нет |
Имена в таблице — умолчание для случая «одна директория на категорию». Имена в таблице — умолчание для случая «одна директория на категорию».
**Почему.** Все дальнейшие решения — что попадает в бэкап (R4), что можно **ПОЧЕМУ.** Все дальнейшие решения — что попадает в бэкап (DIRS-4), что можно
снести при нехватке места, что переживает переезд на другой диск — снести при нехватке места, что переживает переезд на другой диск —
читаются из категории, а не выясняются по коду приложения. Без единой читаются из категории, а не выясняются по коду приложения. Без единой
классификации каждое такое решение принимается заново и каждый раз чуть классификации каждое такое решение принимается заново и каждый раз чуть
по-другому, а цена ошибки несимметрична: лишний кеш в снапшоте стоит места, по-другому, а цена ошибки несимметрична: лишний кеш в снапшоте стоит места,
потерянные данные не стоят ничего, потому что их больше нет. потерянные данные не стоят ничего, потому что их больше нет.
### R2. Категория может состоять из нескольких директорий ### DIRS-2. Категория может состоять из нескольких директорий
**ДОПУСКАЕТСЯ.** Директорий в категории столько, сколько нужно раскладке; **ДОПУСКАЕТСЯ.** Директорий в категории столько, сколько нужно раскладке;
принадлежность к категории задаётся не именем, а участием в списке бэкапа. принадлежность к категории задаётся не именем, а участием в списке бэкапа.
**Почему.** Явное разрешение снимает вопрос, не читается ли R1 как «ровно **ПОЧЕМУ.** Явное разрешение снимает вопрос, не читается ли DIRS-1 как «ровно
три директории». Крупные файлы отделяют от базы, чтобы двигать их между три директории». Крупные файлы отделяют от базы, чтобы двигать их между
дисками независимо (`media/`, `uploads/` — та же категория «данные», что и дисками независимо (`media/`, `uploads/` — та же категория «данные», что и
`data/`); запрет на такое деление вынуждал бы либо держать всё на одном `data/`); запрет на такое деление вынуждал бы либо держать всё на одном
@@ -49,17 +58,17 @@
задавать именем ровно поэтому: имён в категории несколько, и выбираются они задавать именем ровно поэтому: имён в категории несколько, и выбираются они
по содержимому. по содержимому.
### R3. Данные и кеш разделяются по тесту на пересоздание ### DIRS-3. Данные и кеш разделяются по тесту на пересоздание
**ДОЛЖЕН.** Записываемый путь относят к данным или к кешу по содержимому: **ДОЛЖЕН.** Записываемый путь относят к данным или к кешу по содержимому:
| № | Что лежит | Категория | | № | Что лежит | Категория |
|---|---|---| |---|---|---|
| R3.1 | база, загруженные файлы, сгенерированные артефакты | данные | | DIRS-3.1 | база, загруженные файлы, сгенерированные артефакты | данные |
| R3.2 | миниатюры, распакованные ассеты, кеш внешних ответов, перестраиваемые индексы | кеш | | DIRS-3.2 | миниатюры, распакованные ассеты, кеш внешних ответов, перестраиваемые индексы | кеш |
| R3.3 | спорный случай | `rm -rf` и запуск приложения заново: поднялось и наверстало само — кеш; не поднялось или поднялось пустым — данные | | DIRS-3.3 | спорный случай | `rm -rf` и запуск приложения заново: поднялось и наверстало само — кеш; не поднялось или поднялось пустым — данные |
**Почему.** Без внешнего теста граница проводится по ощущению «жалко **ПОЧЕМУ.** Без внешнего теста граница проводится по ощущению «жалко
потерять», а оно смещено в одну сторону: дорогой в пересборке кеш потерять», а оно смещено в одну сторону: дорогой в пересборке кеш
переезжает в данные и раздувает каждый снапшот. Дорого ≠ невосполнимо, и переезжает в данные и раздувает каждый снапшот. Дорого ≠ невосполнимо, и
разделяет эти два свойства именно способность приложения пересоздать разделяет эти два свойства именно способность приложения пересоздать
@@ -67,88 +76,81 @@
обнаруживается в тот же момент, но дёшево: при попытке пересоздать, а не обнаруживается в тот же момент, но дёшево: при попытке пересоздать, а не
при попытке восстановить. при попытке восстановить.
### R4. В бэкап идут данные, и только они ### DIRS-4. В бэкап идут данные, и только они
**ДОЛЖЕН.** Директории категории «данные» — в списке бэкапа; конфигурация и **ДОЛЖЕН.** Директории категории «данные» — в списке бэкапа; конфигурация и
кеш — нет. кеш — нет.
**Почему.** Кеш раздувает снапшот содержимым, которое приложение **ПОЧЕМУ.** Кеш раздувает снапшот содержимым, которое приложение
восстановит само. Конфигурацию бэкапить не только незачем, но и вредно: там восстановит само. Конфигурацию бэкапить не только незачем, но и вредно: там
лежат секреты, а бэкапы уезжают в облако — источник истины для лежат секреты, а бэкапы уезжают в облако — источник истины для
конфигурации остаётся в репозитории и хранилище секретов, а не в снапшоте. конфигурации остаётся в репозитории и хранилище секретов, а не в снапшоте.
Ошибка в другую сторону дороже: директория данных, не попавшая в список, Ошибка в другую сторону дороже: директория данных, не попавшая в список,
обнаруживается в единственный момент, когда исправить её уже нечем. обнаруживается в единственный момент, когда исправить её уже нечем.
### R5. Список бэкапа ссылается на те же пути, что и создание директорий ### DIRS-5. Список бэкапа ссылается на те же пути, что и создание директорий
**ДОЛЖЕН.** Список выводится из категорий по R4 и ссылается на те же **ДОЛЖЕН.** Список выводится из категорий по DIRS-4 и ссылается на те же
объявления путей, по которым директории создаются, а не набирается **объявления путей**, по которым директории создаются, а не набирается
независимо. независимо.
**Почему.** Правило вывода механическое, но применяет его человек или Объявление пути — то единственное место, где путь директории записан
буквально: переменная деплоя, константа, поле конфигурации. Всё остальное
на него ссылается.
**ПОЧЕМУ.** Правило вывода механическое, но применяет его человек или
шаблон — то есть ошибиться можно. Общая ссылка делает целый класс ошибок шаблон — то есть ошибиться можно. Общая ссылка делает целый класс ошибок
невозможным: переименование директории отражается в обоих местах сразу. невозможным: переименование директории отражается в обоих местах сразу.
Независимо набранный список расходится тихо — ни деплой, ни прогон бэкапа Независимо набранный список расходится тихо — ни деплой, ни прогон бэкапа
на это не жалуются, — и расхождение между тем, что бэкапится, и тем, что на это не жалуются, — и расхождение между тем, что бэкапится, и тем, что
нужно, проявляется при восстановлении. нужно, проявляется при восстановлении.
### R6. Способ попадания данных в бэкап зависит от того, кто отвечает за консистентность ### DIRS-6. Способ попадания данных в бэкап зависит от того, кто отвечает за консистентность
**ДОЛЖЕН.** Способ выбирается по тому, самодостаточны ли файлы на диске: **ДОЛЖЕН.** Способ выбирается по тому, самодостаточны ли файлы на диске:
| № | Данные | В бэкап | | № | Данные | В бэкап |
|---|---|---| |---|---|---|
| R6.1 | файлы самодостаточны на любой момент времени | копированием | | DIRS-6.1 | файлы самодостаточны на любой момент времени | копированием |
| R6.2 | консистентность обеспечивает только сама СУБД | дампом: бэкапится директория дампов, сырой каталог базы — нет | | DIRS-6.2 | консистентность обеспечивает только сама СУБД | дампом: бэкапится директория дампов, сырой каталог базы — нет |
**Почему.** Файловый снапшот работающей СУБД не гарантирует **ПОЧЕМУ.** Файловый снапшот работающей СУБД не гарантирует
консистентности: скопированный каталог может не восстановиться, и узнают консистентности: скопированный каталог может не восстановиться, и узнают
об этом при восстановлении. Директория дампов — тоже данные, просто об этом при восстановлении. Директория дампов — тоже данные, просто
производные, поэтому R4 покрывает её без оговорок. Сырой каталог базы из производные, поэтому DIRS-4 покрывает её без оговорок. Сырой каталог базы из
списка при этом исключается: он удваивает объём снапшота и добавляет к списка при этом исключается: он удваивает объём снапшота и добавляет к
надёжной копии заведомо ненадёжную. надёжной копии заведомо ненадёжную.
### R7. Способ выбирается при заведении приложения ### DIRS-7. Способ выбирается при заведении приложения
**ДОЛЖЕН.** Решение «копировать или дампить» (R6) принимается, когда **ДОЛЖЕН.** Решение «копировать или дампить» (DIRS-6) принимается, когда
приложение заводят. приложение заводят.
**Почему.** Неверный выбор ничем себя не проявляет, пока бэкап не **ПОЧЕМУ.** Неверный выбор ничем себя не проявляет, пока бэкап не
понадобился: прогоны копирования проходят успешно и накапливают снапшоты, из понадобился: прогоны копирования проходят успешно и накапливают снапшоты, из
которых база не поднимется. Отложить решение — значит принять его по факту которых база не поднимется. Отложить решение — значит принять его по факту
первой неудачной попытки восстановления, то есть тогда, когда данных уже первой неудачной попытки восстановления, то есть тогда, когда данных уже
нет. нет.
### R8. Приложение разводит записываемые пути по категориям ### DIRS-8. Приложение разводит записываемые пути по категориям
**ДОЛЖЕН.** Конфигурация приложения задаёт отдельные пути для данных и для **ДОЛЖЕН.** Конфигурация приложения задаёт отдельные пути для данных и для
кеша, а не один каталог на всё. кеша, а не один каталог на всё.
**Почему.** Снаружи категория определяется только тогда, когда разным **ПОЧЕМУ.** Снаружи категория определяется только тогда, когда разным
категориям соответствуют разные директории. Всё, сложенное в один каталог, категориям соответствуют разные директории. Всё, сложенное в один каталог,
заставляет составлять список бэкапа вручную, читая код приложения, — и заставляет составлять список бэкапа вручную, читая код приложения, — и
пересматривать его при каждом обновлении, потому что новый подкаталог пересматривать его при каждом обновлении, потому что новый подкаталог
появляется молча. Приложение, которое не умеет разделять, тем самым появляется молча. Приложение, которое не умеет разделять, тем самым
дефектно; раскладка под этот дефект не подстраивается. дефектно; раскладка под этот дефект не подстраивается.
### R9. Приложение не пишет в директорию конфигурации ### DIRS-9. Приложение не пишет в директорию конфигурации
**НЕ ДОЛЖЕН.** Записываемые пути приложения не указывают внутрь **НЕ ДОЛЖЕН.** Записываемые пути приложения не указывают внутрь
конфигурации. конфигурации.
**Почему.** Конфигурация восстанавливается прогоном деплоя (R1.1), поэтому **ПОЧЕМУ.** Конфигурация восстанавливается прогоном деплоя (DIRS-1.1), поэтому
всё, что приложение туда записало, следующий деплой затирает без всё, что приложение туда записало, следующий деплой затирает без
предупреждения. Вдобавок директория конфигурации может быть подключена предупреждения. Вдобавок директория конфигурации может быть подключена
только на чтение — тогда запись отказывает в рантайме. Ни то ни другое не только на чтение — тогда запись отказывает в рантайме. Ни то ни другое не
видно в момент, когда приложение настраивают. видно в момент, когда приложение настраивают.
<!-- local:отступления -->
<!-- /local -->
## Связано
<!-- local:эталон -->
<!-- /local -->
<!-- local:связано -->
<!-- /local -->
+110 -69
View File
@@ -1,7 +1,16 @@
---
topic: config
prefix: CONF
---
# Конфигурация приложения # Конфигурация приложения
Как устроена конфигурация: где лежит, как попадает в процесс, что с Как устроена конфигурация: где лежит, как попадает в процесс, что с
секретами и когда падает. Форма записи — `common/language.md`. секретами и когда падает.
Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки
ПОЧЕМУ, ПРИМЕРЫ, МЕХАНИЗИРОВАНО и СНЯТО толкуются как описано в языке
конвенций версии 1 — тогда и только тогда, когда написаны заглавными.
## Область действия ## Область действия
@@ -12,12 +21,12 @@
## Правила ## Правила
### R1. Конфигурация — файл, а не окружение ### CONF-1. Конфигурация — файл, а не окружение
**ДОЛЖЕН.** Приложение читает параметры из файла конфигурации; переменные **ДОЛЖЕН.** Приложение читает параметры из файла конфигурации; переменные
окружения источником конфигурации не служат. окружения источником конфигурации не служат.
**Почему.** Три довода, по убыванию веса: **ПОЧЕМУ.** Три довода, по убыванию веса:
- **Один типизированный источник.** Файл несёт секции, комментарии, - **Один типизированный источник.** Файл несёт секции, комментарии,
единицы измерения и валидируется целиком. Окружение — плоский набор единицы измерения и валидируется целиком. Окружение — плоский набор
@@ -36,52 +45,72 @@
`PTRACE_MODE_READ`, то есть доступен ровно тому же кругу, что и файл под `PTRACE_MODE_READ`, то есть доступен ровно тому же кругу, что и файл под
`0600`. `0600`.
### R2. Формат конфигурации — текстовый, с секциями и комментариями ### CONF-2. Формат конфигурации — текстовый, с секциями и комментариями
**СЛЕДУЕТ.** Конкретный формат (TOML, YAML) выбирается по стеку. **СЛЕДУЕТ.** Конкретный формат (TOML, YAML) выбирается по стеку.
**Почему.** Комментарий у каждого поля (R9) — часть того, ради чего конфиг **ПОЧЕМУ.** Комментарий у каждого поля (CONF-9) — часть того, ради чего конфиг
вообще читают; формат, в котором комментарий негде разместить, делает R9 вообще читают; формат, в котором комментарий негде разместить, делает CONF-9
невыполнимым. Секции дают структуру, которую валидатор проверяет целиком, — невыполнимым. Секции дают структуру, которую валидатор проверяет целиком, —
плоский список пар такой возможности не даёт и возвращает нас к тем же плоский список пар такой возможности не даёт и возвращает нас к тем же
свойствам, из-за которых отвергнуто окружение (R1). свойствам, из-за которых отвергнуто окружение (CONF-1).
### R3. Имя файла фиксировано, путь переопределяется опцией ### CONF-3. Имя файла фиксировано, путь переопределяется опцией
**СЛЕДУЕТ.** Имя по умолчанию ищется в рабочей директории процесса, а путь **СЛЕДУЕТ.** Имя по умолчанию ищется в рабочей директории процесса, а путь
задаётся опцией командной строки. задаётся опцией командной строки.
**Почему.** Запуск без аргументов работает одинаково в разработке, в **ПОЧЕМУ.** Запуск без аргументов работает одинаково в разработке, в
контейнере и на сервере, и способ запуска не приходится помнить отдельно контейнере и на сервере, и способ запуска не приходится помнить отдельно
для каждой среды. Опция нужна ровно для случаев, когда конфигов несколько для каждой среды. Опция нужна ровно для случаев, когда конфигов несколько
(тесты, второй инстанс): без неё их разводят переменной окружения — тем (тесты, второй инстанс): без неё их разводят переменной окружения — тем
самым каналом, который закрывает R1. самым каналом, который закрывает CONF-1.
### R4. В репозитории лежит образец, а не рабочий конфиг ### CONF-20. Отсутствие файла конфигурации — ошибка старта
**ДОЛЖЕН.** Если файла нет ни по пути из опции, ни по имени по умолчанию в
рабочей директории (CONF-3), приложение не стартует: сообщение называет
искомый путь, код возврата ненулевой.
**ПОЧЕМУ.** Конфиг — артефакт деплоя (CONF-12), и его отсутствие означает, что
развёртывание не довело работу до конца, а не что приложение попросили
работать на умолчаниях. Умолчания (CONF-7) существуют, чтобы работал
**неполный** файл, а не отсутствующий, — именно здесь читатель спотыкается
чаще всего.
Старт без файла ничего не спасает: у приложения с обязательными полями или
секретами всё равно упадёт валидация (CONF-18), только вместо одного сообщения
«нет `config.toml`» получится каскад «поле пусто», за которым настоящая
причина — деплой не отрендерил файл — не видна.
Приложение, которое запускается вообще без конфигурации, этой конвенцией не
описывается: это отдельный случай и отдельная конвенция.
### CONF-4. В репозитории лежит образец, а не рабочий конфиг
**НЕ ДОЛЖЕН.** Реальный конфиг не коммитится; в репозитории — образец. **НЕ ДОЛЖЕН.** Реальный конфиг не коммитится; в репозитории — образец.
**Почему.** Рабочий конфиг содержит отрендеренные секреты (R12), а секрет, **ПОЧЕМУ.** Рабочий конфиг содержит отрендеренные секреты (CONF-12), а секрет,
попавший в историю, чинится ротацией, а не удалением файла. Кроме того, попавший в историю, чинится ротацией, а не удалением файла. Кроме того,
закоммиченный конфиг конкретной среды становится вторым источником истины: закоммиченный конфиг конкретной среды становится вторым источником истины:
он расходится с тем, что реально развёрнуто, и расходится молча. он расходится с тем, что реально развёрнуто, и расходится молча.
### R5. Конфиг разбирается один раз при старте ### CONF-5. Конфиг разбирается один раз при старте
**ДОЛЖЕН.** Разбор — при старте, в одну типизированную структуру; чтения **ДОЛЖЕН.** Разбор — при старте, в одну типизированную структуру; чтения
файла конфигурации в бизнес-коде нет. файла конфигурации в бизнес-коде нет.
**Почему.** Второе место чтения — это второй момент времени: две части кода **ПОЧЕМУ.** Второе место чтения — это второй момент времени: две части кода
начинают видеть разные значения одного параметра, и расхождение не начинают видеть разные значения одного параметра, и расхождение не
воспроизводится, потому что зависит от того, когда файл потрогали. воспроизводится, потому что зависит от того, когда файл потрогали.
Типизированная структура вдобавок переносит ошибку формата в старт (R17), Типизированная структура вдобавок переносит ошибку формата в старт (CONF-17),
где она видна сразу, а не в первый вызов ветки, которая это поле читает. где она видна сразу, а не в первый вызов ветки, которая это поле читает.
### R6. Конфиг неизменяем после старта ### CONF-6. Конфиг неизменяем после старта
**ДОЛЖЕН.** Смена параметров — рестарт процесса. **ДОЛЖЕН.** Смена параметров — рестарт процесса.
**Почему.** Изменяемый конфиг делает поведение функцией момента: один **ПОЧЕМУ.** Изменяемый конфиг делает поведение функцией момента: один
запрос обслуживается наполовину старыми, наполовину новыми значениями, а запрос обслуживается наполовину старыми, наполовину новыми значениями, а
разбор инцидента требует знать хронологию правок файла, а не его текущее разбор инцидента требует знать хронологию правок файла, а не его текущее
содержимое. содержимое.
@@ -89,26 +118,26 @@
Горячая перезагрузка — отдельное решение с отдельным обоснованием, а не Горячая перезагрузка — отдельное решение с отдельным обоснованием, а не
умолчание. умолчание.
### R7. Умолчания живут в коде ### CONF-7. Умолчания живут в коде
**ДОЛЖЕН.** Значение по умолчанию задаётся в коде, файл его перекрывает. **ДОЛЖЕН.** Значение по умолчанию задаётся в коде, файл его перекрывает.
**Почему.** Умолчание, живущее в образце, действует только для тех, кто **ПОЧЕМУ.** Умолчание, живущее в образце, действует только для тех, кто
образец скопировал: конфиг без этого поля даёт другое поведение, и ни одно образец скопировал: конфиг без этого поля даёт другое поведение, и ни одно
из двух значений не выглядит ошибкой. Умолчание в коде — одно определённое из двух значений не выглядит ошибкой. Умолчание в коде — одно определённое
поведение для неполного конфига и одно место, где это значение меняется. поведение для неполного конфига и одно место, где это значение меняется.
### R8. Образец перечисляет все поля ### CONF-8. Образец перечисляет все поля
**ДОЛЖЕН.** В образце присутствуют все секции и все поля, включая те, у **ДОЛЖЕН.** В образце присутствуют все секции и все поля, включая те, у
которых есть умолчание (R7). которых есть умолчание (CONF-7).
**Почему.** Поле, живущее только в коде, для читателя конфига не **ПОЧЕМУ.** Поле, живущее только в коде, для читателя конфига не
существует: он не знает, что параметр вообще можно менять, и добивается существует: он не знает, что параметр вообще можно менять, и добивается
нужного поведения обходным путём. Полнота образца — цена, которой R7 нужного поведения обходным путём. Полнота образца — цена, которой CONF-7
покупает себе видимость. покупает себе видимость.
### R9. У каждого поля образца есть комментарий ### CONF-9. У каждого поля образца есть комментарий
**ДОЛЖЕН.** Каждое поле сопровождается комментарием, из которого ясно: **ДОЛЖЕН.** Каждое поле сопровождается комментарием, из которого ясно:
@@ -117,142 +146,154 @@
- **единицы измерения**, если применимо: секунды/миллисекунды, байты, доля - **единицы измерения**, если применимо: секунды/миллисекунды, байты, доля
`01`. `01`.
**Почему.** Так конфиг читается без открывания кода — этим он и полезен; **ПОЧЕМУ.** Так конфиг читается без открывания кода — этим он и полезен;
без комментария читатель всё равно идёт в код, и образец перестаёт быть без комментария читатель всё равно идёт в код, и образец перестаёт быть
справочником. Единицы стоят отдельно: перепутанные секунды и миллисекунды справочником. Единицы стоят отдельно: перепутанные секунды и миллисекунды
дают валидное значение и работающий процесс, а ошибка обнаруживается по дают валидное значение и работающий процесс, а ошибка обнаруживается по
последствиям — таймаут в тысячу раз не тот. последствиям — таймаут в тысячу раз не тот.
### R10. Обязательность полей определяется дискриминатором `type` ### CONF-10. Обязательность полей определяется дискриминатором `type`
**ДОЛЖЕН.** Когда набор полей секции зависит от поля-дискриминатора (выбор **ДОЛЖЕН.** Когда набор полей секции зависит от поля-дискриминатора (выбор
бекенда или внешнего сервиса), валидация идёт по его значению: бекенда или внешнего сервиса), валидация идёт по его значению:
| № | Значение `type` | Валидация | | № | Значение `type` | Валидация |
|---|---|---| |---|---|---|
| R10.1 | поддерживаемое | обязательны поля этого варианта; поля прочих вариантов не требуются | | CONF-10.1 | поддерживаемое | обязательны поля этого варианта; поля прочих вариантов не требуются |
| R10.2 | неизвестное | ошибка на старте с перечислением поддерживаемых значений | | CONF-10.2 | неизвестное | ошибка на старте с перечислением поддерживаемых значений |
**Почему.** Фиксированный на секцию набор обязательных полей оставляет **ПОЧЕМУ.** Фиксированный на секцию набор обязательных полей оставляет
выбор из двух плохих: заполнять поля бекенда, который не используется, или выбор из двух плохих: заполнять поля бекенда, который не используется, или
не проверять обязательность вовсе — то есть выключить валидацию ровно там, не проверять обязательность вовсе — то есть выключить валидацию ровно там,
где вариантов много и ошибиться легче всего. Перечисление поддерживаемых где вариантов много и ошибиться легче всего. Перечисление поддерживаемых
значений (R10.2) нужно потому, что опечатка в `type` иначе неотличима от значений (CONF-10.2) нужно потому, что опечатка в `type` иначе неотличима от
неподдерживаемого варианта, и за списком приходится идти в код. неподдерживаемого варианта, и за списком приходится идти в код.
### R11. Образец показывает все варианты `type` ### CONF-11. Образец показывает все варианты `type`
**СЛЕДУЕТ.** Основной вариант предзаполнен рабочими значениями, **СЛЕДУЕТ.** Основной вариант предзаполнен рабочими значениями,
альтернативные — блоками-комментариями ниже, каждый со своим описанием альтернативные — блоками-комментариями ниже, каждый со своим описанием
полей. полей.
**Почему.** Иначе набор вариантов виден только из кода валидации, и образец **ПОЧЕМУ.** Иначе набор вариантов виден только из кода валидации, и образец
теряет свойство справочника (R8, R9) ровно на той секции, где выбор теряет свойство справочника (CONF-8, CONF-9) ровно на той секции, где выбор
действительно есть. Закомментированный блок вдобавок переключается правкой действительно есть. Закомментированный блок вдобавок переключается правкой
на месте, а не сборкой секции с нуля по документации. на месте, а не сборкой секции с нуля по документации.
### R12. Секреты в конфиг приносит деплой ### CONF-12. Секреты в конфиг приносит деплой
**ДОЛЖЕН.** Деплой рендерит значения секретов прямо в файл конфигурации; **ДОЛЖЕН.** Деплой рендерит значения секретов прямо в файл конфигурации;
отдельного слоя секретов в приложении нет. отдельного слоя секретов в приложении нет.
**Почему.** Источник истины секрета — внешнее хранилище деплоя, не **ПОЧЕМУ.** Источник истины секрета — внешнее хранилище деплоя, не
репозиторий и не окружение. Любой второй канал — переменная окружения рядом репозиторий и не окружение. Любой второй канал — переменная окружения рядом
с файлом, собственный клиент к хранилищу внутри приложения — возвращает с файлом, собственный клиент к хранилищу внутри приложения — возвращает
вопрос «откуда взялось значение, которое сейчас в процессе». Приложение при вопрос «откуда взялось значение, которое сейчас в процессе». Приложение при
этом остаётся тривиальным: оно читает файл и про секреты не знает ничего этом остаётся тривиальным: оно читает файл и про секреты не знает ничего
особенного. особенного.
### R13. Рендеренный конфиг — `0600` и владелец-рантайм ### CONF-13. Рендеренный конфиг — `0600` и владелец-рантайм
**ДОЛЖЕН.** Права `0600`, владелец — пользователь, от имени которого **ДОЛЖЕН.** Права `0600`, владелец — пользователь, от имени которого
работает процесс. работает процесс.
**Почему.** После R1 и R12 файл конфигурации — единственная поверхность, на **ПОЧЕМУ.** После CONF-1 и CONF-12 файл конфигурации — единственная
которой секреты лежат, и весь довод «файл вместо окружения» держится на его поверхность, на которой секреты лежат, и весь довод «файл вместо окружения»
правах: конфиг, читаемый всеми на машине, раздаёт секреты шире, чем раздало держится на его правах: конфиг, читаемый всеми на машине, раздаёт секреты
бы окружение, — и тогда R1 меняет одну утечку на другую. шире, чем раздало бы окружение, — и тогда CONF-1 меняет одну утечку на
другую.
### R14. В образце секретные поля — пустые строки ### CONF-14. В образце секретные поля — пустые строки
**ДОЛЖЕН.** Значение секретного поля в образце — пустая строка, а не **ДОЛЖЕН.** Значение секретного поля в образце — пустая строка, а не
пример. пример.
**Почему.** Правдоподобная заглушка доезжает до продакшена как настоящее **ПОЧЕМУ.** Правдоподобная заглушка доезжает до продакшена как настоящее
значение: шаблон отрендерился криво, поле осталось от образца, и проверка значение: шаблон отрендерился криво, поле осталось от образца, и проверка
непустоты (R15) его пропускает. Пустая строка делает недорендеренный конфиг непустоты (CONF-15) его пропускает. Пустая строка делает недорендеренный конфиг
механически отличимым от заполненного. механически отличимым от заполненного.
### R15. Загрузчик проверяет, что обязательные секреты не пусты ### CONF-15. Загрузчик проверяет, что обязательные секреты не пусты
**ДОЛЖЕН.** Непустота обязательных секретов проверяется на старте. **ДОЛЖЕН.** Непустота обязательных секретов проверяется на старте.
**Почему.** Это ловит криво отрендеренный шаблон до того, как он превратится **ПОЧЕМУ.** Это ловит криво отрендеренный шаблон до того, как он превратится
в 401 от внешнего API через час работы, — то есть в момент, когда причина в 401 от внешнего API через час работы, — то есть в момент, когда причина
ещё очевидна и связана с деплоем. ещё очевидна и связана с деплоем.
### R16. Секреты не попадают в логи ### CONF-16. Секреты не попадают в логи
**НЕ ДОЛЖЕН.** Значение секретного поля не появляется в записи лога ни на **НЕ ДОЛЖЕН.** Значение секретного поля не появляется в записи лога ни на
одном уровне. одном уровне.
**Почему.** У логов круг доступа шире, чем у файла под `0600` (R13): они **ПОЧЕМУ.** У логов круг доступа шире, чем у файла под `0600` (CONF-13): они
собираются, пересылаются и попадают в бэкапы, где права исходного файла уже собираются, пересылаются и попадают в бэкапы, где права исходного файла уже
ничего не значат. Попавший в лог секрет чинится ротацией, а не удалением ничего не значат. Попавший в лог секрет чинится ротацией, а не удалением
записи. Типичный источник утечки — отладочный дамп разобранного конфига при записи. Типичный источник утечки — отладочный дамп разобранного конфига при
старте. старте.
<!-- local:секретные-поля --> ### CONF-17. Конфиг валидируется на старте, до приёма трафика
<!-- /local -->
### R17. Конфиг валидируется на старте, до приёма трафика
**ДОЛЖЕН.** Невалидный конфиг — запись уровня `ERROR` и выход с ненулевым **ДОЛЖЕН.** Невалидный конфиг — запись уровня `ERROR` и выход с ненулевым
кодом; процесс не стартует «наполовину». кодом; процесс не стартует «наполовину».
**Почему.** Наполовину стартовавший процесс проходит проверку живости и **ПОЧЕМУ.** Наполовину стартовавший процесс проходит проверку живости и
падает позже — на первом запросе, который трогает испорченный параметр, — и падает позже — на первом запросе, который трогает испорченный параметр, — и
падение выглядит дефектом кода, а не ошибкой деплоя. Ненулевой код нужен, падение выглядит дефектом кода, а не ошибкой деплоя. Ненулевой код нужен,
чтобы неудачный старт увидел супервизор: без него он неотличим от штатного чтобы неудачный старт увидел супервизор: без него он неотличим от штатного
завершения, и приложение считается развёрнутым. завершения, и приложение считается развёрнутым.
### R18. Минимальный набор проверок ### CONF-18. Минимальный набор проверок
**ДОЛЖЕН.** Валидация покрывает как минимум: **ДОЛЖЕН.** Валидация покрывает как минимум:
| № | Что проверяется | Когда всплывёт без проверки | | № | Что проверяется | Когда всплывёт без проверки |
|---|---|---| |---|---|---|
| R18.1 | обязательные поля заданы (непустота секретов — R15) | в ветке, которая это поле читает | | CONF-18.1 | обязательные поля заданы (непустота секретов — CONF-15) | в ветке, которая это поле читает |
| R18.2 | пути существуют и доступны на запись/чтение по назначению | при первой записи, уже после отчёта об успешном старте | | CONF-18.2 | пути существуют и доступны на запись/чтение по назначению | при первой записи, уже после отчёта об успешном старте |
| R18.3 | числовые диапазоны и единицы: доли, таймауты, счётчики попыток | искажённым поведением без единого сообщения об ошибке | | CONF-18.3 | числовые диапазоны и единицы: доли, таймауты, счётчики попыток | искажённым поведением без единого сообщения об ошибке |
| R18.4 | строки, которые парсятся во что-то (длительности, зоны, URL), реально парсятся | при первом обращении к тому, что за ними стоит | | CONF-18.4 | строки, которые парсятся во что-то (длительности, зоны, URL, идентификаторы сущностей), реально парсятся | при первом обращении к тому, что за ними стоит |
| R18.5 | включённые секции консистентны: у включённой интеграции заданы все обязательные поля | при первом вызове интеграции, часто по расписанию | | CONF-18.5 | включённые секции консистентны: у включённой интеграции заданы все обязательные поля | при первом вызове интеграции, часто по расписанию |
**Почему.** Список минимальный и собран по одному признаку — правый **ПОЧЕМУ.** Список минимальный и собран по одному признаку — правый
столбец: каждая из этих ошибок иначе всплывает там, где связь с деплоем уже столбец: каждая из этих ошибок иначе всплывает там, где связь с деплоем уже
потеряна, и диагностируется как дефект приложения. Проверка на старте потеряна, и диагностируется как дефект приложения. Проверка на старте
сводит их все к одному моменту и одному сообщению. сводит их все к одному моменту и одному сообщению.
<!-- local:проверки --> ### CONF-19. Проблемы конфига показываются разом
<!-- /local -->
### R19. Проблемы конфига показываются разом
**ДОЛЖЕН.** Валидация собирает все найденные проблемы и выводит их одним **ДОЛЖЕН.** Валидация собирает все найденные проблемы и выводит их одним
списком, а не падает на первой. списком, а не падает на первой.
**Почему.** Иначе цикл «запуск — одна ошибка — правка» повторяется столько **ПОЧЕМУ.** Иначе цикл «запуск — одна ошибка — правка» повторяется столько
раз, сколько в конфиге ошибок, а на сервере каждая итерация — это ещё и раз, сколько в конфиге ошибок, а на сервере каждая итерация — это ещё и
деплой. Криво отрендеренный шаблон обычно ломает не одно поле, а все поля деплой. Криво отрендеренный шаблон обычно ломает не одно поле, а все поля
одного источника: разом они читаются как одна причина, по одной — как одного источника: разом они читаются как одна причина, по одной — как
череда несвязанных мелочей. череда несвязанных мелочей.
### CONF-21. Значение поля в сообщении валидатора — по признаку секретности
**ДОЛЖЕН.** Состав сообщения определяется тем же признаком секретности
поля, которым уже пользуются CONF-15 и CONF-16:
| № | Поле | В сообщении |
|---|---|---|
| CONF-21.1 | несекретное | имя поля, ожидание и полученное значение: «ожидалось 0–1, получено `1.5`» |
| CONF-21.2 | секретное | имя поля и суть нарушения, без значения |
**ПОЧЕМУ.** Сообщение без значения отправляет читателя в файл — сличать
глазами каждую строку списка CONF-19; ошибки вида «секунды вместо миллисекунд»
или пробел в конце значения из такого сообщения не читаются вовсе. Значение
секретного поля при этом печатать некуда: вывод старта уходит в лог
супервизора и вывод CI, где круг доступа шире прав файла `0600`, — тот же
канал утечки, который закрывает CONF-16. Отдельный список «что не печатать»
не заводится: признак один на CONF-15, CONF-16 и CONF-21, а второй список
разошёлся бы с первым — и поле оказалось бы секретным для логов, но
печатаемым валидатором.
## Связано ## Связано
- `arch/time.md` — формат времени; зона отображения — единственный - конвенция `time` — формат времени; зона отображения — единственный
конфигурируемый параметр времени, семантика описана там. конфигурируемый параметр времени, семантика описана там.
- `arch/app-directories.md` — конфиг лежит в категории «конфигурация» и - конвенция `app-directories` — конфиг лежит в категории «конфигурация» и
доступен приложению только на чтение. доступен приложению только на чтение.
<!-- local:связано -->
<!-- /local -->
+140
View File
@@ -0,0 +1,140 @@
---
topic: db-identifiers
prefix: KEYS
---
# Идентификаторы сущностей
Как выбираются и как выглядят первичные ключи сущностей.
Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки
ПОЧЕМУ, ПРИМЕРЫ, МЕХАНИЗИРОВАНО и СНЯТО толкуются как описано в языке
конвенций версии 1 — тогда и только тогда, когда написаны заглавными.
## Область действия
Схема базы меняется тяжело: таблица не переезжает от того, что её
потрогали. Поэтому правила распространяются на **новые таблицы**;
существующие живут как есть и перечисляются в отступлениях, причём этот
список постоянный, а не список задач на дочистку. Целочисленные ключи
существующих приложений — именно такой случай: они не мигрируют, и правила
их работы описаны в конвенции схемы, а не здесь.
## Правила
### KEYS-1. Первичный ключ новой сущности — ULID
**ДОЛЖЕН.** Новая сущность получает сортируемый строковый идентификатор,
который порождает приложение, — во **всех** таблицах, включая те, что
снаружи не адресуются.
**ПОЧЕМУ.** Ветвления здесь нет намеренно, хотя напрашивается: «эту
сущность снаружи не адресуют, ей хватит целого числа». Внутренние сущности
имеют привычку становиться внешними — и тогда целочисленный идентификатор
утекает в URL задним числом, а миграция ключа на живых данных стоит
несопоставимо дороже, чем взять строковый сразу. Заранее отличить те, с
кем это случится, не получается: если бы получалось, они бы уже назывались
внешними.
Второй довод дешевле, но важнее в быту: одна ментальная модель избавляет от
спора при заведении каждой таблицы и делает идентификатор **глобальным**
уникальным across таблиц, а не только внутри своей. На этом держится
корреляция по логам (KEYS-7).
Правило про **сгенерированные суррогатные** ключи. Естественные и составные
ключи у таблиц-деталей (KEYS-6) — третья категория, они допустимы всегда.
### KEYS-2. Идентификатор генерирует приложение, а не база
**ДОЛЖЕН.** Значение ключа известно до вставки строки.
**ПОЧЕМУ.** Идентификатор нужен раньше, чем база ответит: его пишут в лог
начатой операции, кладут в связанные записи одной транзакции и возвращают
клиенту. Генерация на стороне базы вынуждает либо ждать `last_insert_rowid`
и достраивать связи вторым проходом, либо иметь два источника истины о
моменте создания.
### KEYS-3. Генерация и разбор идентификаторов — в единственной точке
**ДОЛЖЕН.** Один модуль порождает идентификаторы, он же их разбирает.
Самодельных генераторов и парсеров в коде нет.
**ПОЧЕМУ.** Нормализация регистра (KEYS-4) и проверка формата обязаны
применяться ко всем идентификаторам без исключения. Любая вторая точка
входа рано или поздно окажется той, где нормализацию забыли, — и дефект
проявится не там, где создан.
### KEYS-4. Канонический вид — нижний регистр
**ДОЛЖЕН.** Идентификаторы порождаются и хранятся в нижнем регистре.
**ПОЧЕМУ.** Сравнение строк в базе обычно побайтовое, поэтому регистр — не
косметика, а корректность поиска. Спецификация ULID канонизирует **верхний**
регистр, и библиотеки по умолчанию отдают именно его: без единой точки (KEYS-3)
разный регистр появится в базе сам собой.
### KEYS-5. Внешний идентификатор разбирается до обращения к базе
**ДОЛЖЕН.** Значение, пришедшее снаружи, проходит разбор и нормализацию
раньше, чем по нему делается запрос. Реакция на неудачный разбор зависит от
источника:
| № | Откуда пришёл | Разбор не удался → |
|---|---|---|
| KEYS-5.1 | путь или query URL | «не найдено» без обращения к хранилищу |
| KEYS-5.2 | собственная форма, данные кнопки | «некорректный ввод» либо «элемент устарел» |
**ПОЧЕМУ.** Синтаксически невалидное значение не может соответствовать
записи, поэтому поход в базу за ним — заведомо холостой; отсекая его на
границе, мы дёшево снимаем целый класс мусорного трафика.
Разделение KEYS-5.1 и KEYS-5.2 нужно, потому что источники значат разное.
Мусор в URL — это чужая или протухшая ссылка, и «не найдено» описывает
ситуацию точно. Мусор из собственной формы — это баг интерфейса или
устаревший экран; ответ «не найдено» здесь скрывает дефект и лишает
диагностики единственный момент, когда он заметен.
Таблица перечисляет **внешние** источники — те, откуда значение приходит
вместе с запросом, и правило говорит, что отдать в ответ. Идентификатор из
конфигурации, из собственной базы или из фикстуры сюда не относится: он
ничего не отдаёт наружу, а его невалидность означает, что сломано у нас.
Формат идентификатора в конфигурации проверяется на старте
(`CONF-18`), невалидное значение в собственной базе — нарушенный
инвариант единой точки (KEYS-3).
### KEYS-6. У таблиц-деталей допустим естественный или составной ключ
**ДОПУСКАЕТСЯ.** Когда ключ есть по природе данных, отдельный
сгенерированный идентификатор не заводится.
**ПОЧЕМУ.** Суррогат поверх естественного ключа создаёт второй способ
адресовать ту же строку — а значит, возможность рассинхрона между ними и
лишний вопрос «по какому из них искать» на каждом запросе. Дополнительной
информации он не несёт.
### KEYS-7. Прочие генерируемые идентификаторы — через ту же точку
**ДОЛЖЕН.** Идентификаторы, не являющиеся первичными ключами (батч,
задание, корреляционный ключ), порождаются тем же модулем (KEYS-3) и в том же
формате.
**ПОЧЕМУ.** Единый формат делает работающим главный побочный эффект
строковых идентификаторов: `grep` по голому значению собирает все
упоминания сущности в логах независимо от имени поля. Второй формат
идентификаторов эту возможность отменяет ровно для тех записей, где она
чаще всего нужна.
## Почему ULID, а не UUID
KEYS-1 требует **сортируемый** строковый идентификатор. UUIDv4 не
сортируется по времени вовсе. UUIDv7 (RFC 9562) сортируется — и против него
остаются два довода: 36 символов против 26 и дефисы, из-за которых
идентификатор не берётся ни двойным кликом, ни `grep`-ом как одно слово.
Сортировка даёт `ORDER BY id` = хронология с точностью до миллисекунды;
внутри одной миллисекунды порядок произволен, если генератор не монотонный,
— на порядок событий это не влияет.
## Связано
- конвенция `time` — метки времени тоже генерирует приложение, а не схема.
+63 -47
View File
@@ -1,8 +1,16 @@
---
topic: time
prefix: TIME
---
# Время # Время
Как приложение записывает моменты и длительности: в каком формате, откуда Как приложение записывает моменты и длительности: в каком формате, откуда
берётся значение и где появляется не-UTC. Форма записи — берётся значение и где появляется не-UTC.
`common/language.md`.
Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки
ПОЧЕМУ, ПРИМЕРЫ, МЕХАНИЗИРОВАНО и СНЯТО толкуются как описано в языке
конвенций версии 1 — тогда и только тогда, когда написаны заглавными.
## Область действия ## Область действия
@@ -14,23 +22,23 @@
## Правила ## Правила
### R1. Единый формат — RFC 3339, UTC, суффикс `Z` ### TIME-1. Единый формат — RFC 3339, UTC, суффикс `Z`
**ДОЛЖЕН.** Момент времени записывается как `2026-06-28T11:23:45Z` **ДОЛЖЕН.** Момент времени записывается как `2026-06-28T11:23:45Z`
одинаково в хранении, логах, API и обмене с внешними системами. одинаково в хранении, логах, API и обмене с внешними системами.
**Почему.** Разные форматы в разных слоях требуют преобразования на каждой **ПОЧЕМУ.** Разные форматы в разных слоях требуют преобразования на каждой
границе, а ошибка в таком преобразовании не видна сразу: она всплывает через границе, а ошибка в таком преобразовании не видна сразу: она всплывает через
полгода, на переходе на летнее время, когда реальное смещение перестаёт полгода, на переходе на летнее время, когда реальное смещение перестаёт
совпадать с тем, которое подразумевалось при написании кода. UTC с явным `Z` совпадать с тем, которое подразумевалось при написании кода. UTC с явным `Z`
убирает из данных и смещение, и сам вопрос «в какой зоне это записано». убирает из данных и смещение, и сам вопрос «в какой зоне это записано».
### R2. Ширина строки фиксируется на каждый носитель ### TIME-2. Ширина строки фиксируется на каждый носитель
**ДОЛЖЕН.** Внутри одной колонки БД и внутри одного потока логов длина **ДОЛЖЕН.** Внутри одной колонки БД и внутри одного потока логов длина
строки времени одна и от записи к записи не плавает. строки времени одна и от записи к записи не плавает.
**Почему.** Лексикографическая сортировка совпадает с хронологией только **ПОЧЕМУ.** Лексикографическая сортировка совпадает с хронологией только
среди строк одинаковой длины: `…00.123Z` сортируется раньше `…00.12Z`, хотя среди строк одинаковой длины: `…00.123Z` сортируется раньше `…00.12Z`, хотя
произошло позже. Ради этого ширина и фиксируется — `ORDER BY created_at` по произошло позже. Ради этого ширина и фиксируется — `ORDER BY created_at` по
текстовому полю обязан давать порядок событий. Плавающая ширина (типичный текстовому полю обязан давать порядок событий. Плавающая ширина (типичный
@@ -38,81 +46,97 @@
везде, а только на тех парах записей, где дробная часть оказалась короче, — везде, а только на тех парах записей, где дробная часть оказалась короче, —
то есть редко, выборочно и невоспроизводимо. то есть редко, выборочно и невоспроизводимо.
### R3. Точность разных носителей может различаться ### TIME-3. Точность разных носителей может различаться
**ДОПУСКАЕТСЯ.** У колонки БД и у потока логов каждая своя точность. **ДОПУСКАЕТСЯ.** У колонки БД и у потока логов каждая своя точность.
**Почему.** Квантор в R2 — на носитель, а не на приложение, потому что **ПОЧЕМУ.** Квантор в TIME-2 — на носитель, а не на приложение, потому что
строки разных носителей между собой не сравниваются: сортировка идёт внутри строки разных носителей между собой не сравниваются: сортировка идёт внутри
колонки, чтение — внутри потока. Явное разрешение нужно, чтобы R2 не читался колонки, чтение — внутри потока. Явное разрешение нужно, чтобы TIME-2 не читался
как «одна точность на всё приложение»: от подгонки формата логов под формат как «одна точность на всё приложение»: от подгонки формата логов под формат
колонки ни одна пара строк не становится сравнимой, зато точность режется до колонки ни одна пара строк не становится сравнимой, зато точность режется до
худшего из носителей. худшего из носителей.
### R4. Локальное время не хранится и не передаётся ### TIME-4. Локальное время не хранится и не передаётся
**НЕ ДОЛЖЕН.** Ни в базе, ни в логах, ни в JSON API нет меток в локальной **НЕ ДОЛЖЕН.** Ни в базе, ни в логах, ни в JSON API нет меток в локальной
зоне. зоне.
**Почему.** Метка без зоны неинтерпретируема вне процесса, который её **ПОЧЕМУ.** Метка без зоны неинтерпретируема вне процесса, который её
записал: чтобы понять, какому моменту она соответствует, читателю нужно записал: чтобы понять, какому моменту она соответствует, читателю нужно
знать настройки чужой машины на момент записи. И даже зная их, он не знать настройки чужой машины на момент записи. И даже зная их, он не
разберёт час перехода на зимнее время: этот час идёт дважды, две записи разберёт час перехода на зимнее время: этот час идёт дважды, две записи
получают одинаковую метку, и порядок между ними не восстанавливается ничем. получают одинаковую метку, и порядок между ними не восстанавливается ничем.
### R5. Единая точка получения «сейчас», форматирования и разбора ### TIME-13. Чужой вход нормализуется при разборе, а не отклоняется
**ДОЛЖЕН.** Валидное по RFC 3339 значение с офсетом, отличным от `Z`, или с
долями секунды принимается от внешней системы и приводится к каноническому
виду (TIME-1) в точке разбора (TIME-5).
**ПОЧЕМУ.** Канонический вид — обязательство нашего писателя, а не
контракт, наложенный на внешние системы: `…14:23:45+03:00` называет тот же
момент, что `…11:23:45Z`, и отклонять его — значит ломать интеграцию со
стороной, которая стандарт соблюла. Пропущенное же как есть, такое значение
нарушает форму и ширину носителя (TIME-1, TIME-2) и портит сортировку
выборочно — только на записях, пришедших извне, и далеко от места разбора.
Нормализация
в единой точке разбора оставляет ровно одно место, где неканонический вид
существует, — по ту сторону границы его уже нет.
### TIME-5. Единая точка получения «сейчас», форматирования и разбора
**ДОЛЖЕН.** Один модуль отдаёт текущий момент, он же форматирует и разбирает **ДОЛЖЕН.** Один модуль отдаёт текущий момент, он же форматирует и разбирает
метки; прямые вызовы часов по коду не разбросаны. метки; прямые вызовы часов по коду не разбросаны.
**Почему.** Формат, зона (R1) и ширина (R2) обязаны выполняться для всех **ПОЧЕМУ.** Формат, зона (TIME-1) и ширина (TIME-2) обязаны выполняться для всех
меток без исключения, а каждый прямой вызов часов заводит ещё одно место, меток без исключения, а каждый прямой вызов часов заводит ещё одно место,
где их можно не соблюсти. Промах такого вызова проявляется не в коде, а в где их можно не соблюсти. Промах такого вызова проявляется не в коде, а в
данных, и обнаруживается, когда испорченных записей уже накопилось. данных, и обнаруживается, когда испорченных записей уже накопилось.
Соображение то же, что для идентификаторов (`arch/db-identifiers.md R3`). Соображение то же, что для идентификаторов (KEYS-3).
### R6. Дефолтов времени в схеме БД нет ### TIME-6. Дефолтов времени в схеме БД нет
**НЕ ДОЛЖЕН.** Колонки времени не имеют `DEFAULT` с текущим моментом. **НЕ ДОЛЖЕН.** Колонки времени не имеют `DEFAULT` с текущим моментом.
**Почему.** Дефолт превращает забытую вставку `created_at` в тихо работающий **ПОЧЕМУ.** Дефолт превращает забытую вставку `created_at` в тихо работающий
код: значение появляется, но приходит от сервера БД — то есть с других часов код: значение появляется, но приходит от сервера БД — то есть с других часов
и в формате, который выбирала не единая точка (R5). Без дефолта та же ошибка и в формате, который выбирала не единая точка (TIME-5). Без дефолта та же ошибка
падает громко и чинится в момент написания, а не при разборе расхождения падает громко и чинится в момент написания, а не при разборе расхождения
между временем в записи и временем в логе. Правило то же, что для между временем в записи и временем в логе. Правило то же, что для
идентификаторов (`arch/db-identifiers.md R2`). идентификаторов (KEYS-2).
### R7. Длительность — отдельная величина, а не пара меток ### TIME-7. Длительность — отдельная величина, а не пара меток
**ДОЛЖЕН.** Длительность операции записывается числом (обычно **ДОЛЖЕН.** Длительность операции записывается числом (обычно
миллисекундами) в поле вида `duration_ms`. миллисекундами) в поле вида `duration_ms`.
**Почему.** Метка отвечает на вопрос «когда», длительность — на «сколько». **ПОЧЕМУ.** Метка отвечает на вопрос «когда», длительность — на «сколько».
Пара меток заставляет каждого потребителя знать, какие именно две из них Пара меток заставляет каждого потребителя знать, какие именно две из них
образуют операцию, и вычитать их самому — в запросе, в дашборде и глазами в образуют операцию, и вычитать их самому — в запросе, в дашборде и глазами в
логе; число сравнивается, агрегируется и попадает в перцентили без этого логе; число сравнивается, агрегируется и попадает в перцентили без этого
шага. Кроме того, разность сохранённых меток считается по стенным часам и шага. Кроме того, разность сохранённых меток считается по стенным часам и
наследует их дефект (R9). наследует их дефект (TIME-9).
### R8. Длительность засекает слой, который делает вызов ### TIME-8. Длительность засекает слой, который делает вызов
**СЛЕДУЕТ.** Замер живёт там же, где вызов, границы которого он измеряет. **СЛЕДУЕТ.** Замер живёт там же, где вызов, границы которого он измеряет.
**Почему.** Обе границы операции видит только этот слой: замер уровнем выше **ПОЧЕМУ.** Обе границы операции видит только этот слой: замер уровнем выше
приписывает операции чужие накладные расходы, уровнем ниже — теряет часть приписывает операции чужие накладные расходы, уровнем ниже — теряет часть
вызова. В обоих случаях число остаётся правдоподобным и потому не вызова. В обоих случаях число остаётся правдоподобным и потому не
оспаривается, хотя отвечает не на тот вопрос, который к нему задают. оспаривается, хотя отвечает не на тот вопрос, который к нему задают.
### R9. Момент и интервал берутся с разных часов ### TIME-9. Момент и интервал берутся с разных часов
**ДОЛЖЕН.** Источник зависит от того, что записывается: **ДОЛЖЕН.** Источник зависит от того, что записывается:
| № | Величина | Источник | | № | Величина | Источник |
|---|---|---| |---|---|---|
| R9.1 | момент события | стенные часы через единую точку (R5) | | TIME-9.1 | момент события | стенные часы через единую точку (TIME-5) |
| R9.2 | длительность операции | монотонные часы процесса | | TIME-9.2 | длительность операции | монотонные часы процесса |
**Почему.** Стенные часы подводит NTP: они могут шагнуть назад, и тогда **ПОЧЕМУ.** Стенные часы подводит NTP: они могут шагнуть назад, и тогда
интервал, посчитанный вычитанием, выйдет отрицательным, а при шаге вперёд — интервал, посчитанный вычитанием, выйдет отрицательным, а при шаге вперёд —
правдоподобным, но выдуманным. Монотонные часы, наоборот, не годятся для правдоподобным, но выдуманным. Монотонные часы, наоборот, не годятся для
меток: их ноль произволен и не переживает перезапуск процесса, так что вне меток: их ноль произволен и не переживает перезапуск процесса, так что вне
@@ -120,52 +144,44 @@
упустить: источник меток времени и источник интервалов — разные, даже если упустить: источник меток времени и источник интервалов — разные, даже если
оба называются «часы». оба называются «часы».
### R10. Не-UTC существует только на слое отображения ### TIME-10. Не-UTC существует только на слое отображения
**ДОЛЖЕН.** Преобразование в зону пользователя происходит при выводе и не **ДОЛЖЕН.** Преобразование в зону пользователя происходит при выводе и не
проникает в хранение, сортировку и логи. проникает в хранение, сортировку и логи.
**Почему.** Как только конвертация уходит вглубь, результат вычислений **ПОЧЕМУ.** Как только конвертация уходит вглубь, результат вычислений
начинает зависеть от настроек конкретного зрителя: одна и та же выборка даёт начинает зависеть от настроек конкретного зрителя: одна и та же выборка даёт
разные группировки, а порядок записей перестаёт быть общим для всех. Ещё разные группировки, а порядок записей перестаёт быть общим для всех. Ещё
хуже, что при конвертации в нескольких слоях её легко выполнить дважды — хуже, что при конвертации в нескольких слоях её легко выполнить дважды —
смещение удваивается, результат остаётся похожим на правду, а найти смещение удваивается, результат остаётся похожим на правду, а найти
виновный слой можно только перечитав их все. виновный слой можно только перечитав их все.
### R11. Зона отображения берётся из конфигурации, по умолчанию `UTC` ### TIME-11. Зона отображения берётся из конфигурации, по умолчанию `UTC`
**ДОЛЖЕН.** Значение приходит из конфигурации (`arch/config.md`), значение **ДОЛЖЕН.** Значение приходит из конфигурации, значение по умолчанию —
по умолчанию — `UTC`. `UTC`.
**Почему.** Зашитая в код зона превращает переезд или второго пользователя в **ПОЧЕМУ.** Зашитая в код зона превращает переезд или второго пользователя в
другом поясе в правку кода и релиз. Значение по умолчанию `UTC` выбрано другом поясе в правку кода и релиз. Значение по умолчанию `UTC` выбрано
потому, что оно не притворяется настроенным: показанное время совпадает с потому, что оно не притворяется настроенным: показанное время совпадает с
тем, что лежит в базе и в логах, поэтому несовпадение с ожиданиями читается тем, что лежит в базе и в логах, поэтому несовпадение с ожиданиями читается
как «зону не задали», а не как «где-то потерялось смещение». как «зону не задали», а не как «где-то потерялось смещение».
### R12. В календарных вычислениях зона указывается явно ### TIME-12. В календарных вычислениях зона указывается явно
**ДОЛЖЕН.** «Сегодня», «за месяц» и прочие календарные границы считаются с **ДОЛЖЕН.** «Сегодня», «за месяц» и прочие календарные границы считаются с
явно переданной зоной, а не с системной зоной процесса. явно переданной зоной, а не с системной зоной процесса.
**Почему.** Системная зона разная на ноутбуке разработчика и в контейнере на **ПОЧЕМУ.** Системная зона разная на ноутбуке разработчика и в контейнере на
сервере, поэтому граница суток уезжает, а вместе с ней — содержимое отчёта: сервере, поэтому граница суток уезжает, а вместе с ней — содержимое отчёта:
расхождение не воспроизводится там, где его заметили, и объясняется средой, расхождение не воспроизводится там, где его заметили, и объясняется средой,
а не кодом. Явно переданная зона делает результат функцией от аргументов. а не кодом. Явно переданная зона делает результат функцией от аргументов.
Зона по умолчанию здесь та же, что и для отображения (R11); календарная Зона по умолчанию здесь та же, что и для отображения (TIME-11); календарная
логика, которой нужна другая, получает её тем же явным аргументом. логика, которой нужна другая, получает её тем же явным аргументом.
<!-- local:механизировано -->
<!-- /local -->
<!-- local:отступления -->
<!-- /local -->
## Связано ## Связано
- `arch/config.md` — где задаётся зона отображения. - конвенция `config` — где задаётся зона отображения.
- `arch/db-identifiers.md` — то же правило «генерирует приложение» для id. - конвенция `db-identifiers` — то же правило «генерирует приложение» для
идентификаторов.
<!-- local:связано -->
<!-- /local -->
@@ -1,11 +1,18 @@
--- ---
topic: config
prefix: GCFG
lang: go
extends: arch/config.md extends: arch/config.md
--- ---
# Конфигурация: реализация на Go # Конфигурация: реализация на Go
Как `arch/config.md` выглядит в Go-приложении: формат, загрузчик, границы Как базовый слой выглядит в Go-приложении: формат, загрузчик, границы
запрета на окружение. Форма записи — `common/language.md`. запрета на окружение.
Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки
ПОЧЕМУ, ПРИМЕРЫ, МЕХАНИЗИРОВАНО и СНЯТО толкуются как описано в языке
конвенций версии 1 — тогда и только тогда, когда написаны заглавными.
Секретов Go-специфика не касается: они приходят из деплоя уже в файле, а Секретов Go-специфика не касается: они приходят из деплоя уже в файле, а
проверка их непустоты идёт вместе с остальной валидацией — как описано в проверка их непустоты идёт вместе с остальной валидацией — как описано в
@@ -13,11 +20,11 @@ extends: arch/config.md
## Правила ## Правила
### R1. Формат конфигурации — TOML ### GCFG-1. Формат конфигурации — TOML
**ДОЛЖЕН.** Конфиг — файл TOML. **ДОЛЖЕН.** Конфиг — файл TOML.
**Почему.** Базовая конвенция оставляет формат за стеком, и этот выбор **ПОЧЕМУ.** Базовая конвенция оставляет формат за стеком, и этот выбор
делается один раз на язык, а не в каждом приложении: разные форматы в делается один раз на язык, а не в каждом приложении: разные форматы в
соседних сервисах означают разные загрузчики, разные шаблоны рендера соседних сервисах означают разные загрузчики, разные шаблоны рендера
конфига в деплое и разное поведение при синтаксической ошибке. TOML при конфига в деплое и разное поведение при синтаксической ошибке. TOML при
@@ -25,67 +32,67 @@ extends: arch/config.md
поправленный руками на сервере, ломается заметно, а не меняет вложенность поправленный руками на сервере, ломается заметно, а не меняет вложенность
молча. молча.
### R2. Разбор и валидация — целиком в `internal/config` ### GCFG-2. Разбор и валидация — целиком в `internal/config`
**ДОЛЖЕН.** Чтение файла, наложение умолчаний и проверки живут в **ДОЛЖЕН.** Чтение файла, наложение умолчаний и проверки живут в
`internal/config`; наружу пакет отдаёт готовую структуру `Config`. `internal/config`; наружу пакет отдаёт готовую структуру `Config`.
**Почему.** Пока значение не покинуло пакет, оно может быть невалидным; **ПОЧЕМУ.** Пока значение не покинуло пакет, оно может быть невалидным;
после — уже нет, и это единственная граница, на которой такое утверждение после — уже нет, и это единственная граница, на которой такое утверждение
проверяемо. Валидация, размазанная по потребителям, отвечает на вопрос проверяемо. Валидация, размазанная по потребителям, отвечает на вопрос
«проверено ли это поле» только чтением всех вызывающих, часть полей «проверено ли это поле» только чтением всех вызывающих, часть полей
неизбежно окажется непроверенной, и fail-fast (R15) выродится в отказ неизбежно окажется непроверенной, и fail-fast (GCFG-15) выродится в отказ
посреди работы. Экспортированный разбор вдобавок даёт второй способ посреди работы. Экспортированный разбор вдобавок даёт второй способ
получить конфиг — мимо умолчаний (R5). получить конфиг — мимо умолчаний (GCFG-5).
### R3. Весь конфиг — одна корневая структура ### GCFG-3. Весь конфиг — одна корневая структура
**ДОЛЖЕН.** Конфиг представлен одним значением типа `Config`, собранным из **ДОЛЖЕН.** Конфиг представлен одним значением типа `Config`, собранным из
под-структур по секциям. под-структур по секциям.
**Почему.** Один корень даёт одну точку, после которой конфиг проверен **ПОЧЕМУ.** Один корень даёт одну точку, после которой конфиг проверен
целиком, и дальше передаётся как обычный аргумент. Несколько независимых целиком, и дальше передаётся как обычный аргумент. Несколько независимых
структур конфига означают несколько загрузок и вопрос «какая из них уже структур конфига означают несколько загрузок и вопрос «какая из них уже
провалидирована» на каждом использовании; связанные между собой поля провалидирована» на каждом использовании; связанные между собой поля
(включена интеграция — заданы все её поля) при этом перестают быть (включена интеграция — заданы все её поля) при этом перестают быть
проверяемыми в одном месте. проверяемыми в одном месте.
### R4. Под-структуры названы по секциям файла ### GCFG-4. Под-структуры названы по секциям файла
**СЛЕДУЕТ.** Имя под-структуры совпадает с именем секции TOML. **СЛЕДУЕТ.** Имя под-структуры совпадает с именем секции TOML.
**Почему.** Имя секции из файла — то, с чего начинают, когда конфиг ведёт **ПОЧЕМУ.** Имя секции из файла — то, с чего начинают, когда конфиг ведёт
себя не так, как ожидали. При совпадении имён поиск по нему сразу приводит себя не так, как ожидали. При совпадении имён поиск по нему сразу приводит
в код и обратно; при расхождении связь между полем файла и полем структуры в код и обратно; при расхождении связь между полем файла и полем структуры
восстанавливается чтением тегов, и проделывать это приходится для каждой восстанавливается чтением тегов, и проделывать это приходится для каждой
секции заново. секции заново.
### R5. Умолчания задаёт `Default()` ### GCFG-5. Умолчания задаёт `Default()`
**ДОЛЖЕН.** Умолчания собраны в функции `Default()`, разобранный файл **ДОЛЖЕН.** Умолчания собраны в функции `Default()`, разобранный файл
накладывается поверх. накладывается поверх.
**Почему.** Нулевое значение в Go неотличимо от «поле не задано»: нулевой **ПОЧЕМУ.** Нулевое значение в Go неотличимо от «поле не задано»: нулевой
таймаут и отсутствующий таймаут — один и тот же `0`. Умолчание, таймаут и отсутствующий таймаут — один и тот же `0`. Умолчание,
подставленное по месту использования (`if x == 0 { x = … }`), поэтому не подставленное по месту использования (`if x == 0 { x = … }`), поэтому не
видно ни целиком, ни из образца, и два потребителя одного поля со временем видно ни целиком, ни из образца, и два потребителя одного поля со временем
подставляют разное. `Default()` — единственное место, откуда список подставляют разное. `Default()` — единственное место, откуда список
умолчаний читается разом и переносится в образец. умолчаний читается разом и переносится в образец.
### R6. Имя файла фиксировано, путь переопределяется флагом ### GCFG-6. Имя файла фиксировано, путь переопределяется флагом
**СЛЕДУЕТ.** По умолчанию читается `config.toml` в рабочей директории, **СЛЕДУЕТ.** По умолчанию читается `config.toml` в рабочей директории,
путь переопределяет флаг `--config=path`, образец рядом — путь переопределяет флаг `--config=path`, образец рядом —
`config.example.toml`. `config.example.toml`.
**Почему.** Фиксированное имя и переопределение из командной строки требует **ПОЧЕМУ.** Фиксированное имя и переопределение из командной строки требует
базовая конвенция, но конкретные имена она не задаёт. Одинаковые имена во базовая конвенция, но конкретные имена она не задаёт. Одинаковые имена во
всех сервисах означают, что unit-файл, `docker run` и инструкция по запуску всех сервисах означают, что unit-файл, `docker run` и инструкция по запуску
пишутся, не открывая код приложения. Соседство `config.toml` и пишутся, не открывая код приложения. Соседство `config.toml` и
`config.example.toml` вдобавок делает расхождение образца с реальным `config.example.toml` вдобавок делает расхождение образца с реальным
конфигом видимым обычным `diff`, а не вычиткой. конфигом видимым обычным `diff`, а не вычиткой.
### R7. Длительности — собственный тип с `UnmarshalText` ### GCFG-7. Длительности — собственный тип с `UnmarshalText`
**ДОЛЖЕН.** Поля-длительности объявляются своим типом, отдающим **ДОЛЖЕН.** Поля-длительности объявляются своим типом, отдающим
`time.Duration`: `time.Duration`:
@@ -97,7 +104,7 @@ func (d *Duration) UnmarshalText(b []byte) error { … }
func (d Duration) Std() time.Duration { … } func (d Duration) Std() time.Duration { … }
``` ```
**Почему.** `time.Duration` — это `int64`, и TOML разбирает его в голое **ПОЧЕМУ.** `time.Duration` — это `int64`, и TOML разбирает его в голое
число: в конфиге остаётся `poll_interval = 5000`, о котором читатель не число: в конфиге остаётся `poll_interval = 5000`, о котором читатель не
знает, секунды это, миллисекунды или наносекунды. Ошибка в тысячу раз знает, секунды это, миллисекунды или наносекунды. Ошибка в тысячу раз
проходит и разбор, и проверку диапазона, а проявляется нагрузкой или проходит и разбор, и проверку диапазона, а проявляется нагрузкой или
@@ -105,46 +112,52 @@ func (d Duration) Std() time.Duration { … }
в себе и разбирается тем же `time.ParseDuration`, что и остальной код. в себе и разбирается тем же `time.ParseDuration`, что и остальной код.
У обёртки есть цена: `UnmarshalText` вызывается на разборе TOML, то есть У обёртки есть цена: `UnmarshalText` вызывается на разборе TOML, то есть
раньше, чем начинает работать сбор проблем (R12). Ошибка в длительности раньше, чем начинает работать сбор проблем (GCFG-12). Ошибка в длительности
приходит отдельно и первой, а остальные проблемы конфига в этом запуске не приходит отдельно и первой, а остальные проблемы конфига в этом запуске не
показываются. показываются.
### R8. Приложение не читает окружение ### GCFG-8. Приложение не читает окружение
**НЕ ДОЛЖЕН.** Значения конфигурации не берутся из переменных окружения. **НЕ ДОЛЖЕН.** Значения конфигурации не берутся из переменных окружения.
**Почему.** Второй канал конфигурации — то, против чего написана базовая **ПОЧЕМУ.** Второй канал конфигурации — то, против чего написана базовая
конвенция, но в Go он ещё и бесследный: `os.Getenv` вызывается из любого конвенция, но в Go он ещё и бесследный: `os.Getenv` вызывается из любого
пакета, не появляется ни в `Config`, ни в образце и не проходит валидацию. пакета, не появляется ни в `Config`, ни в образце и не проходит валидацию.
Заведённый так параметр нельзя ни увидеть в конфиге, ни узнать иначе как Заведённый так параметр нельзя ни увидеть в конфиге, ни узнать иначе как
чтением всего кода — а узнают о нём обычно на сервере, где переменная не чтением всего кода — а узнают о нём обычно на сервере, где переменная не
выставлена. выставлена.
### R9. Проверка запрета покрывает все входы в окружение ### GCFG-9. Проверка запрета покрывает всю семью `os`
**ДОЛЖЕН.** Механическая проверка R8 (`forbidigo`) ловит не только **ДОЛЖЕН.** Механическая проверка GCFG-8 (`forbidigo`) ловит не только
`os.Getenv`: `os.Getenv`:
``` ```
^os\.(Getenv|LookupEnv|Environ|ExpandEnv)$ ^os\.(Getenv|LookupEnv|Environ|ExpandEnv)$
``` ```
**Почему.** `LookupEnv`, `Environ` и `ExpandEnv` читают ровно то же самое, **ПОЧЕМУ.** `LookupEnv`, `Environ` и `ExpandEnv` читают ровно то же самое,
поэтому паттерн на одно имя оставляет три обхода. Хуже, что оставляет поэтому паттерн на одно имя оставляет три обхода. Хуже, что оставляет
незаметно: правило числится механизированным, и глазами его больше никто не незаметно: правило числится механизированным, и глазами его больше никто не
проверяет. проверяет.
### R10. За границей приложения запрет не действует Полного покрытия этот паттерн не даёт и дать не может: мимо него проходят
`syscall.Getenv`, вызов через алиас пакета и чтение `/proc/self/environ`.
Проверка закрывает обычные способы — те, которыми окружение читают не
нарочно; сознательный обход она не ловит, и считать GCFG-8 полностью
механизированным нельзя.
### GCFG-10. За границей приложения запрет не действует
**ДОПУСКАЕТСЯ.** Чтение окружения там, где читающий — не конфигурируемое **ДОПУСКАЕТСЯ.** Чтение окружения там, где читающий — не конфигурируемое
приложение: приложение:
| № | Кто читает | Вердикт | | № | Кто читает | Вердикт |
|---|---|---| |---|---|---|
| R10.1 | тесты, включая интеграционные | допустимо: креды и адреса внешних сервисов взять больше неоткуда | | GCFG-10.1 | тесты, включая интеграционные | допустимо: креды и адреса внешних сервисов взять больше неоткуда |
| R10.2 | Go-рантайм и ОС, а не наш код (`GOMEMLIMIT`, `GOMAXPROCS`, `GODEBUG`, `TZ`) | вне запрета: значение читает не приложение | | GCFG-10.2 | Go-рантайм и ОС, а не наш код (`GOMEMLIMIT`, `GOMAXPROCS`, `GODEBUG`, `TZ`) | вне запрета: значение читает не приложение |
**Почему.** R8 — про конфигурацию приложения; расширенный до «никто не **ПОЧЕМУ.** GCFG-8 — про конфигурацию приложения; расширенный до «никто не
трогает окружение», он запрещает то, чем не управляет: `GOMEMLIMIT` читает трогает окружение», он запрещает то, чем не управляет: `GOMEMLIMIT` читает
рантайм, а тесту креды внешнего сервиса взять неоткуда — в репозиторий их рантайм, а тесту креды внешнего сервиса взять неоткуда — в репозиторий их
не положишь, а отдельный конфиг тестов пришлось бы заводить как ещё один не положишь, а отдельный конфиг тестов пришлось бы заводить как ещё один
@@ -152,73 +165,64 @@ func (d Duration) Std() time.Duration { … }
лечится `//nolint` наугад: там, где легальные случаи приходится глушить лечится `//nolint` наугад: там, где легальные случаи приходится глушить
руками, вместе с ними проходят и нелегальные. руками, вместе с ними проходят и нелегальные.
### R11. Прокси задаётся конфигом, а не `HTTP_PROXY` ### GCFG-11. Прокси задаётся конфигом, а не `HTTP_PROXY`
**ДОЛЖЕН.** Исходящий прокси приходит полем конфига и явным `Transport`. **ДОЛЖЕН.** Исходящий прокси приходит полем конфига и явным `Transport`.
**Почему.** `HTTP_PROXY`/`HTTPS_PROXY` формально читает не наш код, а **ПОЧЕМУ.** `HTTP_PROXY`/`HTTPS_PROXY` формально читает не наш код, а
дефолтный `http.Transport` — но читает он их от имени приложения и меняет дефолтный `http.Transport` — но читает он их от имени приложения и меняет
поведение приложения, а не рантайма. Оставленные окружению, они дают ровно поведение приложения, а не рантайма. Оставленные окружению, они дают ровно
тот второй канал, который запрещает R8, и притом самый неудобный: маршрут тот второй канал, который запрещает GCFG-8, и притом самый неудобный: маршрут
исходящих запросов отличается от машины к машине без единого следа в исходящих запросов отличается от машины к машине без единого следа в
конфиге и в образце, а расследование начинается с вопроса «почему на конфиге и в образце, а расследование начинается с вопроса «почему на
сервере ходит не так, как локально». сервере ходит не так, как локально».
### R12. Проблемы конфига собираются `errors.Join` ### GCFG-12. Проблемы конфига собираются `errors.Join`
**ДОЛЖЕН.** Проверки не прерываются на первой неудаче, результат — одна **ДОЛЖЕН.** Проверки не прерываются на первой неудаче, результат — одна
ошибка, собранная `errors.Join`. ошибка, собранная `errors.Join`.
**Почему.** Возврат первой ошибки превращает починку конфига в серию **ПОЧЕМУ.** Возврат первой ошибки превращает починку конфига в серию
перезапусков по одному полю за раз, причём каждый следующий запуск перезапусков по одному полю за раз, причём каждый следующий запуск
обнаруживает ещё одно. `errors.Join` даёт разом весь список, не требуя обнаруживает ещё одно. `errors.Join` даёт разом весь список, не требуя
своего типа ошибки, и `errors.Is`/`errors.As` продолжают работать по каждой своего типа ошибки, и `errors.Is`/`errors.As` продолжают работать по каждой
вложенной проблеме. вложенной проблеме.
### R13. Имя зоны проверяется `time.LoadLocation` ### GCFG-13. Имя зоны проверяется `time.LoadLocation`
**ДОЛЖЕН.** IANA-зона из конфига загружается на старте, в общей валидации. **ДОЛЖЕН.** IANA-зона из конфига загружается на старте, в общей валидации.
**Почему.** Проверить имя зоны нечем, кроме загрузки: оно валидно ровно **ПОЧЕМУ.** Проверить имя зоны нечем, кроме загрузки: оно валидно ровно
тогда, когда база зон его знает, и никакая проверка формата не отличит тогда, когда база зон его знает, и никакая проверка формата не отличит
`Europe/Moscow` от `Europe/Moskow`. Без загрузки на старте опечатка `Europe/Moscow` от `Europe/Moskow`. Без загрузки на старте опечатка
доживает до первого форматирования времени — то есть до рантайма, мимо доживает до первого форматирования времени — то есть до рантайма, мимо
fail-fast (R15). fail-fast (GCFG-15).
### R14. `time/tzdata` импортируется в `main` ### GCFG-14. `time/tzdata` импортируется в `main`
**ДОЛЖЕН.** Импорт `_ "time/tzdata"` стоит в `main`, а не в библиотечном **ДОЛЖЕН.** Импорт `_ "time/tzdata"` стоит в `main`, а не в библиотечном
пакете. пакете.
**Почему.** Импорт в библиотеке навязывает ~450 КБ zoneinfo каждому **ПОЧЕМУ.** Импорт в библиотеке навязывает ~450 КБ zoneinfo каждому
импортёру, включая тех, кому зоны не нужны: выбор «встраивать базу или импортёру, включая тех, кому зоны не нужны: выбор «встраивать базу или
полагаться на системную» принадлежит собираемой программе. Со встроенной полагаться на системную» принадлежит собираемой программе. Со встроенной
базой ошибка `LoadLocation` (R13) означает ровно одно — битое имя зоны; без базой ошибка `LoadLocation` (GCFG-13) означает ровно одно — битое имя зоны; без
неё тот же конфиг валиден на машине разработчика и падает в контейнере без неё тот же конфиг валиден на машине разработчика и падает в контейнере без
zoneinfo, а сообщение указывает не на ту причину. zoneinfo, а сообщение указывает не на ту причину.
### R15. Невалидный конфиг — `ERROR` и выход из `main` ### GCFG-15. Невалидный конфиг — `ERROR` и выход из `main`
**ДОЛЖЕН.** `main` пишет `slog` уровня `ERROR` и вызывает `os.Exit(1)` до **ДОЛЖЕН.** `main` пишет `slog` уровня `ERROR` и вызывает `os.Exit(1)` до
старта серверов и воркеров. старта серверов и воркеров.
**Почему.** Выход именно из `main`: `os.Exit` в библиотечном пакете не **ПОЧЕМУ.** Выход именно из `main`: `os.Exit` в библиотечном пакете не
оставляет вызывающему возможности ни залогировать причину, ни дописать оставляет вызывающему возможности ни залогировать причину, ни дописать
контекст, и отложенные `defer` при нём не выполняются вовсе. Выход именно контекст, и отложенные `defer` при нём не выполняются вовсе. Выход именно
до старта воркеров: горутина, поднятая раньше валидации, успевает сходить до старта воркеров: горутина, поднятая раньше валидации, успевает сходить
во внешний сервис и записать в базу от имени процесса, который потом во внешний сервис и записать в базу от имени процесса, который потом
объявит, что не стартовал. объявит, что не стартовал.
<!-- local:поля -->
<!-- /local -->
<!-- local:механизировано -->
<!-- /local -->
## Связано ## Связано
- `lang/go/time.md` — зона отображения и формат времени. - конвенция `time` — зона отображения и формат времени.
- `lang/go/logging.md``slog`, которым падает невалидный конфиг. - конвенция `logging``slog`, которым падает невалидный конфиг.
<!-- local:связано -->
<!-- /local -->
@@ -1,24 +1,30 @@
--- ---
topic: db-identifiers
prefix: GKEY
lang: go
extends: arch/db-identifiers.md extends: arch/db-identifiers.md
--- ---
# Идентификаторы: реализация на Go # Идентификаторы: реализация на Go
Как `arch/db-identifiers.md` выглядит в Go-приложении, выбравшем ULID Как базовый слой выглядит в Go-приложении.
(ветка `arch/db-identifiers.md` R1.1). Форма записи — `common/language.md`.
Единая точка из `arch/db-identifiers.md` R3 — пакет `internal/ident`: он Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки
ПОЧЕМУ, ПРИМЕРЫ, МЕХАНИЗИРОВАНО и СНЯТО толкуются как описано в языке
конвенций версии 1 — тогда и только тогда, когда написаны заглавными.
Единая точка из `KEYS-3` — пакет `internal/ident`: он
порождает идентификаторы (`NewID`, `NewIDAt`) и он же их разбирает порождает идентификаторы (`NewID`, `NewIDAt`) и он же их разбирает
(`Parse`). Правила ниже говорят, из каких мест кода эти функции зовутся. (`Parse`). Правила ниже говорят, из каких мест кода эти функции зовутся.
## Правила ## Правила
### R1. Генерация и разбор — только через `internal/ident` ### GKEY-1. Генерация и разбор — только через `internal/ident`
**ДОЛЖЕН.** Идентификаторы порождаются и разбираются функциями пакета **ДОЛЖЕН.** Идентификаторы порождаются и разбираются функциями пакета
`internal/ident`; других генераторов и парсеров id в коде нет. `internal/ident`; других генераторов и парсеров id в коде нет.
**Почему.** Реализация `arch/db-identifiers.md` R3 и R4. Вызов **ПОЧЕМУ.** Реализация `KEYS-3` и `KEYS-4`. Вызов
ULID-библиотеки — одна строка, доступная из любого пакета, и на ревью он не ULID-библиотеки — одна строка, доступная из любого пакета, и на ревью он не
выглядит нарушением: значение получается валидное, просто мимо нормализации выглядит нарушением: значение получается валидное, просто мимо нормализации
регистра. Отдельный пакет переводит запрет в проверяемое свойство — импорт регистра. Отдельный пакет переводит запрет в проверяемое свойство — импорт
@@ -26,12 +32,12 @@ ULID-библиотеки — одна строка, доступная из л
модуля, а «забытая нормализация» не находится ничем, пока запрос молча не модуля, а «забытая нормализация» не находится ничем, пока запрос молча не
перестанет находить существующую запись. перестанет находить существующую запись.
### R2. Первичный ключ генерируется в `Create`-методах store ### GKEY-2. Первичный ключ генерируется в `Create`-методах store
**ДОЛЖЕН.** Новая сущность получает значение PK вызовом `ident.NewID()` **ДОЛЖЕН.** Новая сущность получает значение PK вызовом `ident.NewID()`
внутри `Create`-метода слоя store. внутри `Create`-метода слоя store.
**Почему.** `arch/db-identifiers.md` R2 требует, чтобы значение было **ПОЧЕМУ.** `KEYS-2` требует, чтобы значение было
известно до вставки, но не говорит, кто его присваивает. Store — последний известно до вставки, но не говорит, кто его присваивает. Store — последний
слой, через который проходят все пути создания строки, включая импорт, слой, через который проходят все пути создания строки, включая импорт,
фоновые задания и тесты. Генерация выше по стеку делает присвоение фоновые задания и тесты. Генерация выше по стеку делает присвоение
@@ -39,92 +45,86 @@ ULID-библиотеки — одна строка, доступная из л
строку в колонку ключа: для строкового PK это валидное значение, база его строку в колонку ключа: для строкового PK это валидное значение, база его
не отклонит, и дефект обнаружится на второй такой вставке. не отклонит, и дефект обнаружится на второй такой вставке.
### R3. Прочие идентификаторы генерируются в точке начала операции ### GKEY-3. Прочие идентификаторы генерируются в точке начала операции
**ДОЛЖЕН.** Идентификатор батча, задания или корреляционный ключ создаётся **ДОЛЖЕН.** Идентификатор батча, задания или корреляционный ключ создаётся
вызовом `ident.NewID()` там, где операция начинается. вызовом `ident.NewID()` там, где операция начинается.
**Почему.** Смысл такого идентификатора (`arch/db-identifiers.md` R7) — **ПОЧЕМУ.** Смысл такого идентификатора (`KEYS-7`) —
сшивать записи лога всей операции. Созданный ниже по стеку или в момент сшивать записи лога всей операции. Созданный ниже по стеку или в момент
первой записи в базу, он не покрывает начальные шаги — а именно они нужны, первой записи в базу, он не покрывает начальные шаги — а именно они нужны,
когда операция упала до того, как что-либо записала: без общего ключа эти когда операция упала до того, как что-либо записала: без общего ключа эти
записи из лога не собираются вообще. записи из лога не собираются вообще.
### R4. Бэкфилл в миграциях — `ident.NewIDAt(t)` ### GKEY-4. Бэкфилл в миграциях — `ident.NewIDAt(t)`
**ДОЛЖЕН.** Идентификаторы, проставляемые существующим строкам в **ДОЛЖЕН.** Идентификаторы, проставляемые существующим строкам в
Go-миграции, порождаются с историческим временем строки, а не с текущим. Go-миграции, порождаются с историческим временем строки, а не с текущим.
**Почему.** Сортировка id тогда сохраняет историческую хронологию, а не **ПОЧЕМУ.** Сортировка id тогда сохраняет историческую хронологию, а не
момент прогона миграции. Иначе все затронутые строки получают метку одного момент прогона миграции. Иначе все затронутые строки получают метку одного
момента, склеиваются в нём и встают в порядке обхода — `ORDER BY id` момента, склеиваются в нём и встают в порядке обхода — `ORDER BY id`
начинает врать ровно на том массиве данных, который старше всего. начинает врать ровно на том массиве данных, который старше всего.
Исправить это потом нельзя: исходное время в идентификаторе не Исправить это потом нельзя: исходное время в идентификаторе не
восстановить. восстановить.
### R5. Разбор — на входных границах, до обращения к store ### GKEY-5. Разбор — на входных границах, до обращения к store
**ДОЛЖЕН.** `ident.Parse()` вызывается в обработчике HTTP-роута, формы или **ДОЛЖЕН.** `ident.Parse()` вызывается в обработчике HTTP-роута, формы или
callback'а бота — раньше, чем идентификатор попадёт в store. callback'а бота — раньше, чем идентификатор попадёт в store.
**Почему.** Реализация `arch/db-identifiers.md` R5. Граница выбрана **ПОЧЕМУ.** Реализация `KEYS-5`. Граница выбрана
транспортная, потому что только на ней известен источник значения, от транспортная, потому что только на ней известен источник значения, от
которого зависит реакция (R8): store видит одинаковую строку независимо от которого зависит реакция (GKEY-8): store видит одинаковую строку независимо от
того, пришла она из URL или из собственной формы, и ответить по-разному того, пришла она из URL или из собственной формы, и ответить по-разному
оттуда уже невозможно. оттуда уже невозможно.
### R6. Id в структурах — обычный `string` ### GKEY-6. Id в структурах — обычный `string`
**СЛЕДУЕТ.** Поля идентификаторов в доменных и store-структурах имеют тип **СЛЕДУЕТ.** Поля идентификаторов в доменных и store-структурах имеют тип
`string`. `string`.
**Почему.** Отдельный тип окупается только тогда, когда компилятор ловит им **ПОЧЕМУ.** Отдельный тип окупается только тогда, когда компилятор ловит им
ошибку. От перепутывания двух идентификаторов одной семьи (`userID` и ошибку. От перепутывания двух идентификаторов одной семьи (`userID` и
`authorID`) он не спасает — оба будут одного типа, и различают их имена `authorID`) он не спасает — оба будут одного типа, и различают их имена
параметров. Зато он требует конверсий на каждой границе с sql-драйвером, параметров. Зато он требует конверсий на каждой границе с sql-драйвером,
json и шаблонами, то есть даёт цену без выгоды. json и шаблонами, то есть даёт цену без выгоды.
### R7. Отдельный тип — когда появляется вторая семья идентификаторов ### GKEY-7. Отдельный тип — когда появляется вторая семья идентификаторов
**ДОПУСКАЕТСЯ.** Когда в коде оказываются два вида идентификаторов, которые **ДОПУСКАЕТСЯ.** Когда в коде оказываются два вида идентификаторов, которые
можно перепутать, для них заводятся различимые типы. можно перепутать, для них заводятся различимые типы.
**Почему.** Явное разрешение нужно, чтобы R6 не читался как запрет на **ПОЧЕМУ.** Явное разрешение нужно, чтобы GKEY-6 не читался как запрет на
типизацию навсегда. Условие названо ровно то, при котором тип начинает типизацию навсегда. Условие названо ровно то, при котором тип начинает
работать: пока все идентификаторы — `string`, подстановка одного вида работать: пока все идентификаторы — `string`, подстановка одного вида
вместо другого компилируется и обнаруживается только на данных. вместо другого компилируется и обнаруживается только на данных.
### R8. Реакция на невалидный id зависит от источника ### GKEY-8. Реакция на невалидный id зависит от источника
**ДОЛЖЕН.** Когда разбор не удался, ответ определяется тем, откуда пришло **ДОЛЖЕН.** Когда разбор не удался, ответ определяется тем, откуда пришло
значение: значение:
| № | Источник | Ответ | | № | Источник | Ответ |
|---|---|---| |---|---|---|
| R8.1 | путь или query URL | 404 без обращения к store | | GKEY-8.1 | путь или query URL | 404 без обращения к store |
| R8.2 | собственная форма, callback-данные кнопки | 400 либо понятное сообщение («кнопка устарела») | | GKEY-8.2 | собственная форма, callback-данные кнопки | 400 либо понятное сообщение («кнопка устарела») |
**Почему.** Реализация `arch/db-identifiers.md` R5.1 и R5.2 в терминах **ПОЧЕМУ.** Реализация `KEYS-5.1` и `KEYS-5.2` в терминах
HTTP-кодов. В случае R8.1 снаружи это неотличимо от несуществующей записи — HTTP-кодов. В случае GKEY-8.1 снаружи это неотличимо от несуществующей записи —
и хорошо: чужая или протухшая ссылка описывается так точно. В случае R8.2 и хорошо: чужая или протухшая ссылка описывается так точно. В случае GKEY-8.2
значение сформировало само приложение, и невалидность означает баг значение сформировало само приложение, и невалидность означает баг
интерфейса или устаревший экран; ответ «не найдено» здесь выглядит штатно, интерфейса или устаревший экран; ответ «не найдено» здесь выглядит штатно,
в логах не оставляет аномалии и тем самым съедает единственный момент, в логах не оставляет аномалии и тем самым съедает единственный момент,
когда дефект заметен. когда дефект заметен.
### R9. Транспорт не создаёт доменные ошибки ### GKEY-9. Транспорт не создаёт доменные ошибки
**НЕ ДОЛЖЕН.** Обработчик не конструирует доменный sentinel (например **НЕ ДОЛЖЕН.** Обработчик не конструирует доменный sentinel (например
`ErrNotFound`), чтобы тут же сопоставить его со своим ответом. `ErrNotFound`), чтобы тут же сопоставить его со своим ответом.
**Почему.** Инверсия правила «трансляция у источника» из **ПОЧЕМУ.** Инверсия правила «трансляция у источника» из конвенции
`lang/go/errors.md`. Sentinel — сообщение от слоя, который знает факт: `errors`. Sentinel — сообщение от слоя, который знает факт:
строка не найдена, потому что store её искал. Сфабрикованный транспортом, строка не найдена, потому что store её искал. Сфабрикованный транспортом,
он утверждает непроверенное, и по типу ошибки перестаёт быть видно, был ли он утверждает непроверенное, и по типу ошибки перестаёт быть видно, был ли
вообще поход в хранилище — а на этом держится вся диагностика по ошибкам. вообще поход в хранилище — а на этом держится вся диагностика по ошибкам.
<!-- local:механизировано -->
<!-- /local -->
<!-- local:отступления -->
<!-- /local -->
@@ -1,7 +1,17 @@
---
topic: db-schema
prefix: MIGR
lang: go
---
# Схема и миграции (SQLite, Go) # Схема и миграции (SQLite, Go)
Как пишутся миграции и какие типы колонок выбираются для схемы SQLite в Как пишутся миграции и какие типы колонок выбираются для схемы SQLite в
Go-приложении. Форма записи — `common/language.md`. Go-приложении.
Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки
ПОЧЕМУ, ПРИМЕРЫ, МЕХАНИЗИРОВАНО и СНЯТО толкуются как описано в языке
конвенций версии 1 — тогда и только тогда, когда написаны заглавными.
## Область действия ## Область действия
@@ -12,83 +22,83 @@ Go-приложении. Форма записи — `common/language.md`.
## Миграции ## Миграции
### R1. Миграции ведёт goose ### MIGR-1. Миграции ведёт goose
**ДОЛЖЕН.** Набор миграций репозитория применяется одним инструментом — **ДОЛЖЕН.** Набор миграций репозитория применяется одним инструментом —
goose. goose.
**Почему.** Журнал применённых версий goose держит в самой базе **ПОЧЕМУ.** Журнал применённых версий goose держит в самой базе
(`goose_db_version`) и по нему решает, что ещё не накатывалось. Второй (`goose_db_version`) и по нему решает, что ещё не накатывалось. Второй
инструмент заводит второй журнал: миграция, применённая одним, для другого инструмент заводит второй журнал: миграция, применённая одним, для другого
выглядит неприменённой, и попытка накатить её повторно упирается в уже выглядит неприменённой, и попытка накатить её повторно упирается в уже
существующую таблицу. На сервере это означает ручной разбор состояния существующую таблицу. На сервере это означает ручной разбор состояния
схемы вместо автоматического деплоя. схемы вместо автоматического деплоя.
### R2. Файлы миграций лежат рядом со store-слоем ### MIGR-2. Файлы миграций лежат рядом со store-слоем
**СЛЕДУЕТ.** Миграции хранятся рядом с кодом, который работает с этой **СЛЕДУЕТ.** Миграции хранятся рядом с кодом, который работает с этой
схемой. схемой.
**Почему.** Миграция и код, читающий схему, — одно изменение: колонка **ПОЧЕМУ.** Миграция и код, читающий схему, — одно изменение: колонка
появляется вместе с полем структуры и запросом. Лежащие в другом конце появляется вместе с полем структуры и запросом. Лежащие в другом конце
дерева миграции выпадают из поля зрения при правке store, и уезжает либо дерева миграции выпадают из поля зрения при правке store, и уезжает либо
код без миграции, либо миграция без кода; расходятся они на сервере, где код без миграции, либо миграция без кода; расходятся они на сервере, где
схема ещё старая. схема ещё старая.
### R3. Форма миграции выбирается по тому, нужен ли код ### MIGR-3. Форма миграции выбирается по тому, нужен ли код
**ДОЛЖЕН.** Миграция пишется в той форме, которой требует её содержимое: **ДОЛЖЕН.** Миграция пишется в той форме, которой требует её содержимое:
| № | Что делает миграция | Форма | | № | Что делает миграция | Форма |
|---|---|---| |---|---|---|
| R3.1 | DDL: создание таблиц, индексы, изменение структуры | SQL-файл | | MIGR-3.1 | DDL: создание таблиц, индексы, изменение структуры | SQL-файл |
| R3.2 | требует кода: генерация идентификаторов, backfill, перенос данных между формами | Go-миграция (`goose.AddMigrationContext`) | | MIGR-3.2 | требует кода: генерация идентификаторов, backfill, перенос данных между формами | Go-миграция (`goose.AddMigrationContext`) |
**Почему.** DDL ничего не вычисляет, и SQL-файл показывает ровно тот текст, **ПОЧЕМУ.** DDL ничего не вычисляет, и SQL-файл показывает ровно тот текст,
который уедет в базу; обёртка на Go вокруг него добавляет место, где можно который уедет в базу; обёртка на Go вокруг него добавляет место, где можно
ошибиться, не добавляя ничего к результату. ошибиться, не добавляя ничего к результату.
Обратное направление дороже. Перенос данных и генерация идентификаторов Обратное направление дороже. Перенос данных и генерация идентификаторов
выражаются на SQL либо громоздко, либо неточно: идентификатор по выражаются на SQL либо громоздко, либо неточно: идентификатор по
`arch/db-identifiers.md` R2 порождает приложение, и SQL-миграция вынуждена `KEYS-2` порождает приложение, и SQL-миграция вынуждена
завести для него второй генератор — ровно то, что запрещает завести для него второй генератор — ровно то, что запрещает
`arch/db-identifiers.md` R3. Единообразие формы здесь покупается `KEYS-3`. Единообразие формы здесь покупается
дублированием логики, которая уже есть в коде. дублированием логики, которая уже есть в коде.
### R4. В деплое схема движется только вперёд ### MIGR-4. В деплое схема движется только вперёд
**НЕ ДОЛЖЕН.** Откат схемы на сервере не выполняется down-миграцией; **НЕ ДОЛЖЕН.** Откат схемы на сервере не выполняется down-миграцией;
ошибка исправляется новой миграцией вперёд. ошибка исправляется новой миграцией вперёд.
**Почему.** Down на сервере не возвращает прежнее состояние, а имитирует **ПОЧЕМУ.** Down на сервере не возвращает прежнее состояние, а имитирует
его: колонка, которую убрал up, восстанавливается пустой, а строки, его: колонка, которую убрал up, восстанавливается пустой, а строки,
записанные уже по новой схеме, в старую форму не ложатся. Потеря при этом записанные уже по новой схеме, в старую форму не ложатся. Потеря при этом
происходит молча — миграция отчитывается об успехе. Исправление, приехавшее происходит молча — миграция отчитывается об успехе. Исправление, приехавшее
следующей миграцией, оставляет целыми и данные, и журнал применённых следующей миграцией, оставляет целыми и данные, и журнал применённых
версий. версий.
### R5. Down пишется, когда он честно обращает up ### MIGR-5. Down пишется, когда он честно обращает up
**ДОЛЖЕН.** Наличие down-миграции определяется тем, обратим ли up: **ДОЛЖЕН.** Наличие down-миграции определяется тем, обратим ли up:
| № | Что делает up | Down | | № | Что делает up | Down |
|---|---|---| |---|---|---|
| R5.1 | добавляет структуру: таблицу, колонку, индекс | пишется, убирает добавленное | | MIGR-5.1 | добавляет структуру: таблицу, колонку, индекс | пишется, убирает добавленное |
| R5.2 | необратимо преобразует данные | не пишется | | MIGR-5.2 | необратимо преобразует данные | не пишется |
**Почему.** Down — инструмент разработки, где ветку переключают туда-сюда, **ПОЧЕМУ.** Down — инструмент разработки, где ветку переключают туда-сюда,
и именно там он обязан действительно обращать up. Имитация опаснее и именно там он обязан действительно обращать up. Имитация опаснее
отсутствия: разработчик применяет её, получает схему прежней формы и отсутствия: разработчик применяет её, получает схему прежней формы и
продолжает работу, не заметив, что колонка вернулась пустой. Отсутствующий продолжает работу, не заметив, что колонка вернулась пустой. Отсутствующий
down останавливает сразу и заставляет пересоздать базу — это дешевле, чем down останавливает сразу и заставляет пересоздать базу — это дешевле, чем
отладка по данным, которых уже нет. отладка по данным, которых уже нет.
### R6. ER-схема обновляется в том же изменении ### MIGR-6. ER-схема обновляется в том же изменении
**ДОЛЖЕН.** Изменение структуры и правка ER-схемы в спеках едут одним **ДОЛЖЕН.** Изменение структуры и правка ER-схемы в спеках едут одним
изменением. изменением.
**Почему.** Диаграмму читают вместо DDL — в этом весь её смысл. **ПОЧЕМУ.** Диаграмму читают вместо DDL — в этом весь её смысл.
Разошедшаяся с базой, она не бесполезна, а даёт неверный ответ, и заметить Разошедшаяся с базой, она не бесполезна, а даёт неверный ответ, и заметить
это можно, только сверив её с миграциями, то есть проделав работу, которую это можно, только сверив её с миграциями, то есть проделав работу, которую
диаграмма экономит. Отложенное обновление не делается: изменение уже диаграмма экономит. Отложенное обновление не делается: изменение уже
@@ -99,12 +109,12 @@ down останавливает сразу и заставляет пересо
Правила ниже описывают хранение в SQLite: выбор типа диктует движок базы, Правила ниже описывают хранение в SQLite: выбор типа диктует движок базы,
а не язык приложения. а не язык приложения.
### R7. Enum-поля — `TEXT`, допустимые значения держит код ### MIGR-7. Enum-поля — `TEXT`, допустимые значения держит код
**ДОЛЖЕН.** Поле-перечисление (`state`, `kind`, …) объявляется как `TEXT` **ДОЛЖЕН.** Поле-перечисление (`state`, `kind`, …) объявляется как `TEXT`
без `CHECK`-ограничения на список значений. без `CHECK`-ограничения на список значений.
**Почему.** `ALTER TABLE` в SQLite не умеет менять ограничения ни в одной **ПОЧЕМУ.** `ALTER TABLE` в SQLite не умеет менять ограничения ни в одной
версии. Поэтому каждое новое значение перечисления в `CHECK (... IN (...))` версии. Поэтому каждое новое значение перечисления в `CHECK (... IN (...))`
превращается из строки в коде в пересоздание таблицы по 12-шаговой превращается из строки в коде в пересоздание таблицы по 12-шаговой
процедуре, с копированием данных и восстановлением внешних ключей. процедуре, с копированием данных и восстановлением внешних ключей.
@@ -115,82 +125,75 @@ down останавливает сразу и заставляет пересо
таблицы соответствия, которую пришлось бы держать в голове для числового таблицы соответствия, которую пришлось бы держать в голове для числового
кода. кода.
### R8. Метки времени — `TEXT` в формате из `arch/time.md` ### MIGR-8. Метки времени — `TEXT` в каноническом формате
**ДОЛЖЕН.** Колонка с меткой времени объявляется как `TEXT`, значения **ДОЛЖЕН.** Колонка с меткой времени объявляется как `TEXT`, значения
пишутся в формате из `arch/time.md`. пишутся как RFC 3339 в UTC с суффиксом `Z` и фиксированной шириной.
**Почему.** Типа даты в SQLite нет, поэтому единственное, что делает **ПОЧЕМУ.** Типа даты в SQLite нет, поэтому единственное, что делает
значения сравнимыми, — договорённость о формате. Текст в формате из значения сравнимыми, — договорённость о формате; сам формат выбран не
`arch/time.md` сортируется лексикографически в том же порядке, что и здесь, а конвенцией `time` (`TIME-1`, `TIME-2`). Текст в нём сортируется
лексикографически в том же порядке, что и
хронологически: `ORDER BY` и диапазонные условия работают без функций хронологически: `ORDER BY` и диапазонные условия работают без функций
преобразования, а значит и без потери индекса. Соседство двух форматов в преобразования, а значит и без потери индекса. Соседство двух форматов в
одной колонке ломает и сравнение, и разбор на стороне Go. одной колонке ломает и сравнение, и разбор на стороне Go.
### R9. Умолчание `DEFAULT (datetime('now'))` не ставится ### MIGR-9. Умолчание `DEFAULT (datetime('now'))` не ставится
**НЕ ДОЛЖЕН.** Колонка с меткой времени не получает значение по умолчанию **НЕ ДОЛЖЕН.** Колонка с меткой времени не получает значение по умолчанию
на уровне схемы. на уровне схемы.
**Почему.** Время ставит приложение, и умолчание в схеме заводит второй **ПОЧЕМУ.** Время ставит приложение, и умолчание в схеме заводит второй
источник этого значения: пропущенное приложением поле не падает, а тихо источник этого значения: пропущенное приложением поле не падает, а тихо
получает время сервера базы — расхождение обнаруживается по данным, а не получает время сервера базы — расхождение обнаруживается по данным, а не
по ошибке. по ошибке.
Вдобавок `datetime('now')` даёт `YYYY-MM-DD HH:MM:SS` — без `T` и без `Z`, Вдобавок `datetime('now')` даёт `YYYY-MM-DD HH:MM:SS` — без `T` и без `Z`,
то есть не тот формат, которого требует R8. В колонке оказываются строки то есть не тот формат, которого требует MIGR-8. В колонке оказываются строки
двух видов, и ломается ровно то, ради чего формат выбран. двух видов, и ломается ровно то, ради чего формат выбран.
### R10. Булевы поля — `INTEGER` со значениями 0 и 1 ### MIGR-10. Булевы поля — `INTEGER` со значениями 0 и 1
**ДОЛЖЕН.** Булево значение хранится как `INTEGER` 0/1. **ДОЛЖЕН.** Булево значение хранится как `INTEGER` 0/1.
**Почему.** Отдельного булева типа в SQLite нет, поэтому от разнобоя **ПОЧЕМУ.** Отдельного булева типа в SQLite нет, поэтому от разнобоя
колонку удерживает только договорённость о представлении. Цена ошибки колонку удерживает только договорённость о представлении. Цена ошибки
здесь несимметрична: строка `'true'` в булевом контексте приводится к здесь несимметрична: строка `'true'` в булевом контексте приводится к
**0**, то есть даёт противоположный ответ, а не пустую выборку и не ошибку **0**, то есть даёт противоположный ответ, а не пустую выборку и не ошибку
типа. Такой дефект не падает, не виден в логе и переживает тесты, которые типа. Такой дефект не падает, не виден в логе и переживает тесты, которые
проверяют, что список не пуст. проверяют, что список не пуст.
### R11. Вид первичного ключа задаёт `arch/db-identifiers.md` ### MIGR-11. Первичный ключ новой таблицы — TEXT ULID
**ДОЛЖЕН.** В репозитории, подписанном на `arch/db-identifiers.md`, вид **ДОЛЖЕН.** Колонка ключа объявляется как `TEXT`, значение приходит из
ключа выбирается по её R1, и `AUTOINCREMENT` в миграции не пишется. приложения.
**Почему.** Вопрос о виде ключа решается один раз на репозиторий **ПОЧЕМУ.** Здесь конвенция схемы ничего не решает — она реализует решение,
(`arch/db-identifiers.md` R1). Повторив здесь его ветвление, мы завели бы принятое конвенцией `db-identifiers` (`KEYS-1`, `KEYS-2`). Повторить там
второй источник правды, и соседние таблицы разъехались бы по разным ветвление или условие значило бы завести второй источник правды, и соседние
ответам на один и тот же вопрос. таблицы разъехались бы по разным ответам на один вопрос.
`AUTOINCREMENT` не нужен ни в одной из веток R1. Строкового ключа он не `AUTOINCREMENT` в такой таблице невозможен: SQLite разрешает его только на
касается вовсе, а целочисленному даёт единственную гарантию — что значение `INTEGER PRIMARY KEY`. То есть для новых таблиц запрещать нечего.
rowid не будет переиспользовано после удаления строки, — ценой служебной
таблицы `sqlite_sequence` и записи в неё на каждой вставке. Гарантия эта
имеет смысл, только если старые идентификаторы живут где-то вне базы.
### R12. Вне `arch/db-identifiers.md` первичный ключ — автоинкремент ### MIGR-12. Целочисленный ключ идёт вместе с `AUTOINCREMENT`
**ДОПУСКАЕТСЯ.** Репозиторий, не подписанный на `arch/db-identifiers.md`, **ДОЛЖЕН.** Там, где первичный ключ всё-таки целочисленный — существующая
берёт целочисленный автоинкрементный ключ. схема, миграция легаси-таблицы, — он объявляется с `AUTOINCREMENT`.
**Почему.** Явное разрешение нужно, чтобы R11 не читался как требование **ПОЧЕМУ.** Без него SQLite выдаёт rowid как `max(rowid)+1`, поэтому после
подписаться на `arch/db-identifiers.md`. Выбор вида ключа — решение уровня удаления последней строки номер переиспользуется. Протухшая ссылка на
репозитория, и конвенция про типы колонок его за репозиторий не принимает; удалённую запись — из закладки, из чужой таблицы, из старого лога — молча
приложению, сущности которого не адресуют снаружи, целочисленный ключ наводится на другую сущность и возвращает правдоподобный, но чужой ответ.
ничего не стоит. Обнаружить это по данным нельзя: обе строки валидны.
<!-- local:механизировано --> Цена — служебная таблица `sqlite_sequence` и запись в неё на каждой
<!-- /local --> вставке — против этого пренебрежима. Для новых таблиц вопрос не возникает:
там ключ строковый (MIGR-11).
<!-- local:отступления -->
<!-- /local -->
## Связано ## Связано
- `arch/time.md` — формат меток времени. - конвенция `time` — формат меток времени.
- `arch/db-identifiers.md` — выбор первичных ключей. - конвенция `db-identifiers` — выбор первичных ключей.
- `lang/go/errors.md` — граничные ошибки `database/sql` транслируются в - конвенция `errors` — граничные ошибки `database/sql` транслируются в
доменные у источника, в слое store. доменные у источника, в слое store.
<!-- local:связано -->
<!-- /local -->
@@ -1,102 +1,123 @@
---
topic: errors
prefix: GERR
lang: go
---
# Ошибки # Ошибки
Как ошибки строятся, оборачиваются и проверяются. Форма записи — Как ошибки строятся, оборачиваются и проверяются. Где и когда ошибку
`common/language.md`. Где и когда ошибку **логировать** — в **логировать** — в конвенции `logging` (коротко: лог один раз на доменной
`lang/go/logging.md` (коротко: лог один раз на доменной границе). границе).
Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки
ПОЧЕМУ, ПРИМЕРЫ, МЕХАНИЗИРОВАНО и СНЯТО толкуются как описано в языке
конвенций версии 1 — тогда и только тогда, когда написаны заглавными.
Две границы, о которых говорят правила ниже:
- **доменная граница** — место, где определяется исход операции: use-case,
публичная команда воркера, стадия асинхронной обработки. Ниже неё ошибка
только накапливает контекст, выше — операция уже либо удалась, либо нет.
- **внешняя граница** — место, где ответ покидает процесс: обработчик HTTP,
рендер страницы, отправка сообщения ботом.
Одна операция проходит обе: сначала доменную (там её исход логируется),
потом внешнюю (там он превращается в ответ).
## Правила ## Правила
### R1. Ошибки строятся средствами стандартной библиотеки ### GERR-1. Ошибки строятся средствами стандартной библиотеки
**ДОЛЖЕН.** Ошибки создаются и оборачиваются через `errors` и **ДОЛЖЕН.** Ошибки создаются и оборачиваются через `errors` и
`fmt.Errorf`; библиотеки со стек-трейсами не подключаются. `fmt.Errorf`; библиотеки со стек-трейсами не подключаются.
**Почему.** Стек и цепочка обёрток решают одну задачу — локализацию места. **ПОЧЕМУ.** Стек и цепочка обёрток решают одну задачу — локализацию места.
При дисциплине «каждый слой добавляет свой контекст» (R3) цепочка сообщений При дисциплине «каждый слой добавляет свой контекст» (GERR-3) цепочка сообщений
локализует не хуже, а стоит ничего: остальной контекст ошибки уже несёт локализует не хуже, а стоит ничего: остальной контекст ошибки уже несёт
`slog`. Библиотека со стеками добавляет зависимость, собственный тип ошибки `slog`. Библиотека со стеками добавляет зависимость, собственный тип ошибки
и обычно инфраструктуру доставки стеков (Sentry) — для домашнего сервиса и обычно инфраструктуру доставки стеков (Sentry) — для домашнего сервиса
это цена без покупателя. это цена без покупателя.
Единственное место, где стек всё-таки нужен, — восстановленная паника: у Единственное место, где стек всё-таки нужен, — восстановленная паника: у
неё цепочки `%w` нет вовсе (R23). неё цепочки `%w` нет вовсе (GERR-23).
### R2. Дефолт не обходится точечно ### GERR-2. Дефолт не обходится точечно
**НЕ ДОЛЖЕН.** Пакет со стек-трейсами не заводится в отдельном месте **НЕ ДОЛЖЕН.** Пакет со стек-трейсами не заводится в отдельном месте
кодовой базы ради конкретной отладки. кодовой базы ради конкретной отладки.
**Почему.** В коде появляются два способа устроить ошибку, и вызывающий **ПОЧЕМУ.** В коде появляются два способа устроить ошибку, и вызывающий
перестаёт знать, какой перед ним: обёртки склеиваются по-разному, перестаёт знать, какой перед ним: обёртки склеиваются по-разному,
`errors.Is` работает не везде одинаково. Хуже второе: боль, снятая `errors.Is` работает не везде одинаково. Хуже второе: боль, снятая
локально, перестаёт накапливаться — а накопление и есть единственный локально, перестаёт накапливаться — а накопление и есть единственный
сигнал, что решение R1 пора пересматривать целиком. сигнал, что решение GERR-1 пора пересматривать целиком.
### R3. Каждый слой добавляет свой контекст ### GERR-3. Каждый слой добавляет свой контекст
**ДОЛЖЕН.** Ошибка, возвращаемая на уровень выше, оборачивается с **ДОЛЖЕН.** Ошибка, возвращаемая на уровень выше, оборачивается с
контекстом: `fmt.Errorf("parse magnet: %w", err)`. контекстом: `fmt.Errorf("parse magnet: %w", err)`.
**Почему.** На этом держится R1: цепочка заменяет стек ровно настолько, **ПОЧЕМУ.** На этом держится GERR-1: цепочка заменяет стек ровно настолько,
насколько слои в неё пишут. Слой, пробросивший ошибку без своего контекста, насколько слои в неё пишут. Слой, пробросивший ошибку без своего контекста,
стирает участок пути — по итоговому сообщению нельзя сказать, через какую стирает участок пути — по итоговому сообщению нельзя сказать, через какую
операцию ошибка прошла, и отладка «no such file» начинается с чтения всего операцию ошибка прошла, и отладка «no such file» начинается с чтения всего
кода. кода.
### R4. Обёртка по умолчанию — `%w` ### GERR-4. Обёртка по умолчанию — `%w`
**СЛЕДУЕТ.** Глагол выбирается по тому, раскрываем ли мы причину **СЛЕДУЕТ.** Глагол выбирается по тому, раскрываем ли мы причину
вызывающему: вызывающему:
| № | Ситуация | Глагол | | № | Ситуация | Глагол |
|---|---|---| |---|---|---|
| R4.1 | вызывающий может инспектировать причину (обычный случай) | `%w` | | GERR-4.1 | вызывающий может инспектировать причину (обычный случай) | `%w` |
| R4.2 | причину сознательно не раскрываем | `%v` | | GERR-4.2 | причину сознательно не раскрываем | `%v` |
**Почему.** Возражение против дефолтного `%w` — «обёрнутая ошибка **ПОЧЕМУ.** Возражение против дефолтного `%w` — «обёрнутая ошибка
становится частью API» — относится к библиотекам с внешними потребителями. становится частью API» — относится к библиотекам с внешними потребителями.
Сервис же — приложение: внешнего Go-API нет, весь код наш, контракт Сервис же — приложение: внешнего Go-API нет, весь код наш, контракт
меняется вместе с вызывающими. Зато `%v` в середине цепочки обрывает меняется вместе с вызывающими. Зато `%v` в середине цепочки обрывает
`errors.Is` и `errors.As` для всех слоёв выше, и ветвление по sentinel'у `errors.Is` и `errors.As` для всех слоёв выше, и ветвление по sentinel'у
(R10) молча перестаёт срабатывать — дефект проявляется как «код не заметил (GERR-10) молча перестаёт срабатывать — дефект проявляется как «код не заметил
`ErrNotFound`», далеко от места обрыва. R4.2 остаётся для случая, когда `ErrNotFound`», далеко от места обрыва. GERR-4.2 остаётся для случая, когда
завязывать вызывающего на чужой тип ошибки не хотят намеренно. завязывать вызывающего на чужой тип ошибки не хотят намеренно.
### R5. Утечка внутренних деталей лечится трансляцией, а не `%v` ### GERR-5. Утечка внутренних деталей лечится трансляцией, а не `%v`
**НЕ ДОЛЖЕН.** `%v` не используется как средство не пустить внутреннюю **НЕ ДОЛЖЕН.** `%v` не используется как средство не пустить внутреннюю
ошибку наружу. ошибку наружу.
**Почему.** Обрыв цепочки внутри кода не мешает тексту уехать наружу **ПОЧЕМУ.** Обрыв цепочки внутри кода не мешает тексту уехать наружу
целиком: наружу отдаёт внешняя граница, и если она отдаёт `err.Error()`, целиком: наружу отдаёт внешняя граница, и если она отдаёт `err.Error()`,
детали утекут при любом глаголе. Подмена не решает задачу, ради которой детали утекут при любом глаголе. Подмена не решает задачу, ради которой
сделана, а плату берёт сразу — `errors.Is` ломается у всех вызывающих. сделана, а плату берёт сразу — `errors.Is` ломается у всех вызывающих.
Настоящее место защиты — R13. Настоящее место защиты — GERR-13.
### R6. Текст обёртки — со строчной буквы и без служебных слов ### GERR-6. Текст обёртки — со строчной буквы и без служебных слов
**СЛЕДУЕТ.** Без точки в конце, без «failed to» и «error». **СЛЕДУЕТ.** Без точки в конце, без «failed to» и «error».
**Почему.** Цепочка склеивается в одну строку через `": "`, и обёртка **ПОЧЕМУ.** Цепочка склеивается в одну строку через `": "`, и обёртка
читается как «контекст: причина» — заглавные буквы и точки рвут эту строку читается как «контекст: причина» — заглавные буквы и точки рвут эту строку
на середине. Слова «failed» и «error» не несут информации: то, что перед на середине. Слова «failed» и «error» не несут информации: то, что перед
нами ошибка, известно из того, что это ошибка. Зато повторяются они на нами ошибка, известно из того, что это ошибка. Зато повторяются они на
каждом уровне и вытесняют из строки полезный контекст. каждом уровне и вытесняют из строки полезный контекст.
### R7. Контекст обёртки называет операцию или субъект ### GERR-7. Контекст обёртки называет операцию или субъект
**СЛЕДУЕТ.** В обёртку идёт то, что делал слой: `"link target: %w"`. **СЛЕДУЕТ.** В обёртку идёт то, что делал слой: `"link target: %w"`.
**Почему.** Обёртка ценна ровно тем, что сужает место (R3). «something **ПОЧЕМУ.** Обёртка ценна ровно тем, что сужает место (GERR-3). «something
failed» не сужает ничего и при этом занимает в сообщении место, которое мог failed» не сужает ничего и при этом занимает в сообщении место, которое мог
бы занять единственный полезный здесь факт — имя операции. бы занять единственный полезный здесь факт — имя операции.
### R8. Слой не повторяет смысл нижнего ### GERR-8. Слой не повторяет смысл нижнего
**НЕ СЛЕДУЕТ.** Обёртка не пересказывает то, что уже сказал уровень ниже: **НЕ СЛЕДУЕТ.** Обёртка не пересказывает то, что уже сказал уровень ниже:
`"add to qbt: %w"`, а не `"add download failed: add to qbt failed: …"`. `"add to qbt: %w"`, а не `"add download failed: add to qbt failed: …"`.
**Почему.** Повтор удлиняет сообщение, не добавляя локализации: одно и то **ПОЧЕМУ.** Повтор удлиняет сообщение, не добавляя локализации: одно и то
же событие названо дважды. Читателю приходится проверять, не два ли это же событие названо дважды. Читателю приходится проверять, не два ли это
разных места в коде, — то есть заикание не просто бесполезно, оно стоит разных места в коде, — то есть заикание не просто бесполезно, оно стоит
времени при каждом чтении лога. времени при каждом чтении лога.
@@ -104,46 +125,46 @@ failed» не сужает ничего и при этом занимает в
## Две трансляции ## Две трансляции
Ошибка меняет форму дважды, и это разные преобразования: инфраструктурная → Ошибка меняет форму дважды, и это разные преобразования: инфраструктурная →
доменная у источника (R9) и доменная → пользовательская на внешней границе доменная у источника (GERR-9) и доменная → пользовательская на внешней границе
(R13). Первую делает слой, работающий с зависимостью, вторую — транспорт. (GERR-13). Первую делает слой, работающий с зависимостью, вторую — транспорт.
### R9. Инфраструктурная ошибка транслируется в доменную у источника ### GERR-9. Инфраструктурная ошибка транслируется в доменную у источника
**ДОЛЖЕН.** Граничная ошибка зависимости превращается в доменную там, где **ДОЛЖЕН.** Граничная ошибка зависимости превращается в доменную там, где
возникла: `sql.ErrNoRows``store.ErrNotFound` в слое store; то же для возникла: `sql.ErrNoRows``store.ErrNotFound` в слое store; то же для
HTTP-клиентов, файловой системы, внешних SDK. HTTP-клиентов, файловой системы, внешних SDK.
**Почему.** Иначе тип зависимости становится частью контракта всех слоёв **ПОЧЕМУ.** Иначе тип зависимости становится частью контракта всех слоёв
выше: чтобы отличить «нет записи», доменный код импортирует `database/sql` выше: чтобы отличить «нет записи», доменный код импортирует `database/sql`
и сравнивает с его sentinel'ом. Замена хранилища или SDK правит тогда не и сравнивает с его sentinel'ом. Замена хранилища или SDK правит тогда не
адаптер, а все ветвления в приложении — притом что снаружи адаптера адаптер, а все ветвления в приложении — притом что снаружи адаптера
состояние «нет записи» одно и то же. Трансляция у источника оставляет состояние «нет записи» одно и то же. Трансляция у источника оставляет
знание о зависимости в единственном слое, который её и так знает. знание о зависимости в единственном слое, который её и так знает.
### R10. Форма доменной ошибки выбирается по тому, что нужно вызывающему ### GERR-10. Форма доменной ошибки выбирается по тому, что нужно вызывающему
**ДОЛЖЕН.** Между sentinel'ом и типом выбирают так: **ДОЛЖЕН.** Между sentinel'ом и типом выбирают так:
| № | Что нужно вызывающему | Форма | | № | Что нужно вызывающему | Форма |
|---|---|---| |---|---|---|
| R10.1 | ветвление по условию: нет записи, дубликат, неподдерживаемый источник | sentinel `var ErrNotFound = errors.New("not found")`, проверка `errors.Is` | | GERR-10.1 | ветвление по условию: нет записи, дубликат, неподдерживаемый источник | sentinel `var ErrNotFound = errors.New("not found")`, проверка `errors.Is` |
| R10.2 | данные ошибки: поле валидации, код, лимит | тип с полями и методом `Error()`, извлечение `errors.As` | | GERR-10.2 | данные ошибки: поле валидации, код, лимит | тип с полями и методом `Error()`, извлечение `errors.As` |
**Почему.** Sentinel — одно значение; сравнение с ним не зависит от **ПОЧЕМУ.** Sentinel — одно значение; сравнение с ним не зависит от
структуры ошибки и переживает добавление полей. Тип заводится ради данных, структуры ошибки и переживает добавление полей. Тип заводится ради данных,
и тип без данных отвечает вызывающему ровно то же, что sentinel, но ценой и тип без данных отвечает вызывающему ровно то же, что sentinel, но ценой
объявления, `errors.As` и вопроса «сравнивать по типу или по значению» на объявления, `errors.As` и вопроса «сравнивать по типу или по значению» на
каждой проверке. Две формы для одного условия — это два способа его каждой проверке. Две формы для одного условия — это два способа его
проверить, и про второй рано или поздно забудут. проверить, и про второй рано или поздно забудут.
### R11. Матчинг по тексту сообщения ### GERR-11. Матчинг по тексту сообщения
**НЕ ДОЛЖЕН.** Ветвление по содержимому `err.Error()` не используется. **НЕ ДОЛЖЕН.** Ветвление по содержимому `err.Error()` не используется.
**Почему.** Текст сообщения — не контракт: R6–R8 разрешают переписывать его **ПОЧЕМУ.** Текст сообщения — не контракт: GERR-6GERR-8 разрешают
свободно. Правка формулировки в нижнем слое молча ломает ветвление переписывать его свободно. Правка формулировки в нижнем слое молча ломает
наверху, и компилятор этого не видит. Это то же самое, что публичный API из ветвление наверху, и компилятор этого не видит. Это то же самое, что
строки лога. публичный API из строки лога.
## Граница: приватный канал и публичный ## Граница: приватный канал и публичный
@@ -151,77 +172,93 @@ HTTP-клиентов, файловой системы, внешних SDK.
того, кто канал видит: приватный канал — логи (их читает владелец сервиса), того, кто канал видит: приватный канал — логи (их читает владелец сервиса),
публичный — пользовательские поверхности (HTTP API, web-UI, бот). публичный — пользовательские поверхности (HTTP API, web-UI, бот).
### R12. Полная ошибка идёт в приватный канал ### GERR-12. Полная ошибка идёт в приватный канал
**ДОЛЖЕН.** В лог уходит вся цепочка `%w` с контекстом; где и когда именно **ДОЛЖЕН.** В лог уходит вся цепочка `%w` с контекстом; где и когда именно
`lang/go/logging.md`. конвенция `logging`.
**Почему.** Цепочка — единственный носитель диагностики (R1), и **ПОЧЕМУ.** Цепочка — единственный носитель диагностики (GERR-1), и
единственный канал, где её можно показать целиком, — тот, который видит единственный канал, где её можно показать целиком, — тот, который видит
владелец. Не записанная там, она не сохранится нигде: наружу идёт владелец. Не записанная там, она не сохранится нигде: наружу идёт
нейтральное сообщение (R13), и восстанавливать причину будет не из чего. нейтральное сообщение (GERR-13), и восстанавливать причину будет не из чего.
### R13. Публичная поверхность получает сообщение по доменной ошибке ### GERR-13. Публичная поверхность получает сообщение по доменной ошибке
**ДОЛЖЕН.** Наружу идёт человекочитаемый текст по доменной ошибке, а не **ДОЛЖЕН.** Наружу идёт человекочитаемый текст по доменной ошибке, а не
`err.Error()` и не детали реализации (`database/sql`, пути, стек). `err.Error()` и не детали реализации (`database/sql`, пути, стек).
**Почему.** Внутренние детали пользователю нечитаемы, а владельцу не нужны **ПОЧЕМУ.** Внутренние детали пользователю нечитаемы, а владельцу не нужны
у него есть лог (R12). Зато они раскрывают устройство системы — имена у него есть лог (GERR-12). Зато они раскрывают устройство системы — имена
таблиц, пути на диске, версии зависимостей — тому, кто их знать не должен, таблиц, пути на диске, версии зависимостей — тому, кто их знать не должен,
причём раскрывают именно в момент, когда что-то пошло не так. причём раскрывают именно в момент, когда что-то пошло не так.
### R14. Публичное сообщение несёт корреляционный ключ ### GERR-14. Публичное сообщение несёт корреляционный ключ
**ДОЛЖЕН.** Наружу вместе с сообщением идёт id сущности либо `request_id`: **ДОЛЖЕН.** Наружу вместе с сообщением идёт id сущности либо `request_id`:
«При обработке загрузки произошла ошибка, download_id=…» вместо «произошла «При обработке загрузки произошла ошибка, download_id=…» вместо «произошла
ошибка». ошибка».
**Почему.** R13 забирает у пользователя всю фактуру; без ключа его **ПОЧЕМУ.** GERR-13 забирает у пользователя всю фактуру; без ключа его
обращение звучит как «у меня что-то не работает», и владелец ищет запись в обращение звучит как «у меня что-то не работает», и владелец ищет запись в
логе по времени и догадкам. Ключ соединяет нейтральный ответ с полной логе по времени и догадкам. Ключ соединяет нейтральный ответ с полной
ошибкой в логе, не раскрывая наружу ничего сверх того, что пользователь уже ошибкой в логе, не раскрывая наружу ничего сверх того, что пользователь уже
видел. видел.
### R15. Маппинг доменных ошибок — в одной точке на все транспорты ### GERR-15. Маппинг доменных ошибок — в одной точке на все транспорты
**ДОЛЖЕН.** Соответствие «доменная ошибка → сообщение и, для HTTP, статус» **ДОЛЖЕН.** Соответствие «доменная ошибка → сообщение и, для HTTP, статус»
задаётся один раз; транспорт без статусов (бот) берёт из него только задаётся один раз; транспорт без статусов (бот) берёт из него только
сообщение. сообщение.
**Почему.** Иначе одна и та же ошибка отвечает по-разному в HTTP и в боте, **ПОЧЕМУ.** Иначе одна и та же ошибка отвечает по-разному в HTTP и в боте,
и расхождение обнаруживается не как дефект, а как жалоба. Вторая причина и расхождение обнаруживается не как дефект, а как жалоба. Вторая причина
важнее: единственная точка — это место, куда механически дописывается новая важнее: единственная точка — это место, куда механически дописывается новая
ветвь (R16). Маппинг, размазанный по хендлерам, требование «дописать везде» ветвь (GERR-16). Маппинг, размазанный по хендлерам, требование «дописать везде»
ничем не проверяет. ничем не проверяет.
### R16. Новая штатная ветвь отказа сразу попадает в маппинг ### GERR-16. Новая штатная ветвь отказа сразу попадает в маппинг
**ДОЛЖЕН.** Ожидаемый отказ (конфликт, валидация) заводится sentinel'ом и **ДОЛЖЕН.** Ожидаемый отказ (конфликт, валидация) заводится sentinel'ом и
добавляется в маппинг (R15) тем же изменением. добавляется в маппинг (GERR-15) тем же изменением.
**Почему.** Ветка `default` врёт в обе стороны: транспорт отдаёт 500 **ПОЧЕМУ.** Ветка `default` врёт в обе стороны: транспорт отдаёт 500
«внутренняя ошибка» на нормальный конфликт, а логирующая граница списывает «внутренняя ошибка» на нормальный конфликт, а логирующая граница списывает
его в `ERROR` вместо `DEBUG`. Второе хуже первого — штатные отказы начинают его в `ERROR` вместо `DEBUG`. Второе хуже первого — штатные отказы начинают
шуметь в логе ровно там, где по нему ищут настоящие поломки. шуметь в логе ровно там, где по нему ищут настоящие поломки.
<!-- local:маппинг --> ### GERR-25. Непокрытая маппингом ошибка — 500 и `ERROR` с признаком
<!-- /local -->
### R17. Форма текста определяется поверхностью **ДОЛЖЕН.** Доменная ошибка, для которой в маппинге (GERR-15) нет ветви, отдаёт
наружу 500 и нейтральное «внутренняя ошибка», а в лог идёт `ERROR` с
признаком того, что маппинг её не знает.
**ПОЧЕМУ.** Непокрытая ошибка — не класс отказа, а дефект: ветвь забыли
завести вопреки GERR-16. Адресат у неё владелец в смысле «надо чинить», отсюда
`ERROR` — уровень выбирается по адресату (конвенция `logging`). Статус
тоже не выбирается: известное пользовательское состояние лежало бы в
маппинге, а про неизвестное сказать пользователю нечего, поэтому 4xx
отпадает.
Признак нужен потому, что без него забытая ветвь неотличима от упавшей
базы: обе дают `ERROR` с текстом ошибки, и наткнуться на пропуск можно
только случайно. Отдельное поле или своя категория сообщения делают пропуск
находимым одним фильтром — и тогда громкость 500 и `ERROR` работает как
механизм обнаружения, а не как шум.
### GERR-17. Форма текста определяется поверхностью
**ДОЛЖЕН.** У публичной границы две разные поверхности, и правило сырого **ДОЛЖЕН.** У публичной границы две разные поверхности, и правило сырого
текста для них разное: текста для них разное:
| № | Поверхность | Текст ошибки | | № | Поверхность | Текст ошибки |
|---|---|---| |---|---|---|
| R17.1 | транзиентный ответ на действие: тело ответа, `?err=`, реплика бота по результату команды | строго нейтральный, из маппинга (R15); `err.Error()` наружу не идёт | | GERR-17.1 | транзиентный ответ на действие: тело ответа, `?err=`, реплика бота по результату команды | строго нейтральный, из маппинга (GERR-15); `err.Error()` наружу не идёт |
| R17.2 | персистентная диагностика состояния: причина ухода записи в ошибочное состояние, сохранённая в БД и показанная оператору | сырой текст ошибки (пути, фрагмент ответа внешнего сервиса) — пока поверхность видит исключительно владелец | | GERR-17.2 | персистентная диагностика состояния: причина ухода записи в ошибочное состояние, сохранённая в БД и показанная оператору | сырой текст ошибки (пути, фрагмент ответа внешнего сервиса) — пока поверхность видит исключительно владелец |
Появился второй зритель или публичный доступ к экрану состояния — Появился второй зритель или публичный доступ к экрану состояния —
поверхность стала публичным каналом, и на неё распространяется R17.1. поверхность стала публичным каналом, и на неё распространяется GERR-17.1.
**Почему.** Транзиентный ответ читает тот, кто нажал кнопку: сырой текст **ПОЧЕМУ.** Транзиентный ответ читает тот, кто нажал кнопку: сырой текст
ему ничего не объясняет, а владельцу не нужен — у него лог. Персистентную ему ничего не объясняет, а владельцу не нужен — у него лог. Персистентную
диагностику читает владелец, и она отвечает на вопрос «почему сломалась вот диагностику читает владелец, и она отвечает на вопрос «почему сломалась вот
эта запись» через месяц, когда лог уже ротировался; нейтральное «произошла эта запись» через месяц, когда лог уже ротировался; нейтральное «произошла
@@ -229,61 +266,61 @@ HTTP-клиентов, файловой системы, внешних SDK.
про единственного зрителя — ровно то, что делает вторую поверхность про единственного зрителя — ровно то, что делает вторую поверхность
приватным каналом; без него это обычная публичная поверхность. приватным каналом; без него это обычная публичная поверхность.
### R18. Секретов нет ни на одной из поверхностей ### GERR-18. Секретов нет ни на одной из поверхностей
**НЕ ДОЛЖЕН.** Токены, пароли и ключи не попадают ни в транзиентный ответ, **НЕ ДОЛЖЕН.** Токены, пароли и ключи не попадают ни в транзиентный ответ,
ни в персистентную диагностику; источник вычищается на границе клиента. ни в персистентную диагностику; источник вычищается на границе клиента.
**Почему.** Запрет абсолютен, потому что персистентная диагностика живёт в **ПОЧЕМУ.** Запрет абсолютен, потому что персистентная диагностика живёт в
БД: уезжает в бэкапы, попадает в скриншоты и выгрузки и переживает ротацию БД: уезжает в бэкапы, попадает в скриншоты и выгрузки и переживает ротацию
самого секрета. Вычистка на границе клиента — единственное место, где ещё самого секрета. Вычистка на границе клиента — единственное место, где ещё
известно, какие поля запроса секретны: дальше ошибка едет как текст, и известно, какие поля запроса секретны: дальше ошибка едет как текст, и
отличить в нём токен от идентификатора уже нельзя. отличить в нём токен от идентификатора уже нельзя.
### R19. Диагностика хранится в отдельном поле ### GERR-19. Диагностика хранится в отдельном поле
**ДОЛЖЕН.** Персистентная диагностика не кладётся в доменное поле, которое **ДОЛЖЕН.** Персистентная диагностика не кладётся в доменное поле, которое
показывают пользователю. показывают пользователю.
**Почему.** Различие R17.1 и R17.2 держится на том, что у поверхностей **ПОЧЕМУ.** Различие GERR-17.1 и GERR-17.2 держится на том, что у поверхностей
разные поля. Одно поле на оба назначения означает, что при первом же показе разные поля. Одно поле на оба назначения означает, что при первом же показе
записи наружу сырой текст уедет туда же — не по решению, а потому что поле записи наружу сырой текст уедет туда же — не по решению, а потому что поле
одно. одно.
## panic ## panic
### R20. `panic` — только для невосстановимого ### GERR-20. `panic` — только для невосстановимого
**ДОЛЖЕН.** Паникой отмечается нарушенный инвариант (баг программиста) и **ДОЛЖЕН.** Паникой отмечается нарушенный инвариант (баг программиста) и
ошибка инициализации, из которой нельзя стартовать. ошибка инициализации, из которой нельзя стартовать.
**Почему.** Паника не оставляет вызывающему выбора: обработать её на месте **ПОЧЕМУ.** Паника не оставляет вызывающему выбора: обработать её на месте
нельзя, можно только уронить единицу обработки. Это верный ответ, когда нельзя, можно только уронить единицу обработки. Это верный ответ, когда
состояние процесса перестало описываться кодом: работа с нарушенным состояние процесса перестало описываться кодом: работа с нарушенным
инвариантом опаснее падения, а сервис, стартовавший без обязательной инвариантом опаснее падения, а сервис, стартовавший без обязательной
зависимости, всё равно откажет позже и непонятнее. зависимости, всё равно откажет позже и непонятнее.
### R21. Ожидаемые ошибки — значения `error` ### GERR-21. Ожидаемые ошибки — значения `error`
**НЕ ДОЛЖЕН.** Паника не используется для управления потоком: нет сети, **НЕ ДОЛЖЕН.** Паника не используется для управления потоком: нет сети,
плохой ввод, отсутствующая запись возвращаются как `error`. плохой ввод, отсутствующая запись возвращаются как `error`.
**Почему.** Сигнатура — единственное, что сообщает вызывающему о возможном **ПОЧЕМУ.** Сигнатура — единственное, что сообщает вызывающему о возможном
отказе; отказ, брошенный паникой, из неё не виден, и компилятор не заставит отказе; отказ, брошенный паникой, из неё не виден, и компилятор не заставит
его обработать. Дальше такая паника долетает до recover-границы (R22), где его обработать. Дальше такая паника долетает до recover-границы (GERR-22), где
неотличима от бага: штатный отказ попадает в лог со стеком и с интонацией неотличима от бага: штатный отказ попадает в лог со стеком и с интонацией
«мы сломались». «мы сломались».
### R22. `recover` — на верхней границе каждой обрабатывающей единицы ### GERR-22. `recover` — на верхней границе каждой обрабатывающей единицы
**ДОЛЖЕН.** Своя граница ставится у каждой единицы, мотив у них разный: **ДОЛЖЕН.** Своя граница ставится у каждой единицы, мотив у них разный:
| № | Единица | Зачем `recover` | | № | Единица | Зачем `recover` |
|---|---|---| |---|---|---|
| R22.1 | HTTP-хендлер | `net/http` восстанавливает панику сам и процесс не роняет; свой `recover` нужен, чтобы отдать контролируемый 500 и записать событие в `slog`, а не в stdlib-логгер | | GERR-22.1 | HTTP-хендлер | `net/http` восстанавливает панику сам и процесс не роняет; свой `recover` нужен, чтобы отдать контролируемый 500 и записать событие в `slog`, а не в stdlib-логгер |
| R22.2 | цикл обработки апдейтов бота, фоновый воркер | паника в горутине роняет процесс; `recover` ставится в той же горутине | | GERR-22.2 | цикл обработки апдейтов бота, фоновый воркер | паника в горутине роняет процесс; `recover` ставится в той же горутине |
**Почему.** `recover` работает только в той горутине, где случилась паника, **ПОЧЕМУ.** `recover` работает только в той горутине, где случилась паника,
поэтому «у нас есть recover в HTTP» не защищает воркер — граница нужна у поэтому «у нас есть recover в HTTP» не защищает воркер — граница нужна у
каждой единицы отдельно. Без неё один плохой апдейт бота или одна запись с каждой единицы отдельно. Без неё один плохой апдейт бота или одна запись с
неожиданным полем гасят весь сервис, включая части, к этой ошибке неожиданным полем гасят весь сервис, включая части, к этой ошибке
@@ -291,32 +328,68 @@ HTTP-клиентов, файловой системы, внешних SDK.
без своего `recover` уходит мимо структурированного лога, а клиент получает без своего `recover` уходит мимо структурированного лога, а клиент получает
оборванное соединение вместо ответа. оборванное соединение вместо ответа.
### R23. Recover-граница пишет `debug.Stack()` ### GERR-23. Recover-граница пишет `debug.Stack()`
**ДОЛЖЕН.** Логирующий `recover` кладёт в запись стек. **ДОЛЖЕН.** Логирующий `recover` кладёт в запись стек.
**Почему.** Это единственное место, где стек нужен (R1): у восстановленной **ПОЧЕМУ.** Это единственное место, где стек нужен (GERR-1): у восстановленной
паники цепочки `%w` нет вовсе. «index out of range» без стека не паники цепочки `%w` нет вовсе. «index out of range» без стека не
диагностируется в принципе — сообщение не называет ни файла, ни операции, диагностируется в принципе — сообщение не называет ни файла, ни операции,
по нему нельзя сказать даже, в каком пакете упало. по нему нельзя сказать даже, в каком пакете упало.
## Несколько ошибок ## Несколько ошибок
### R24. Независимые ошибки собираются `errors.Join` ### GERR-26. После `recover` единица продолжает работу, исключив упавшее
**ДОЛЖЕН.** Что происходит после перехвата, зависит от того, где стоит
граница:
| № | Где перехвачена паника | Что дальше |
|---|---|---|
| GERR-26.1 | обработчик HTTP-запроса, паника любая, кроме сигнала намеренного прерывания | ответ 500, если он ещё не начат; процесс и прочие запросы не затрагиваются |
| GERR-26.2 | итерация цикла обработки — бот, воркер | цикл продолжается со следующего элемента, упавший элемент повторно не берётся |
| GERR-26.3 | обработчик HTTP-запроса, паника — сигнал намеренного прерывания (`http.ErrAbortHandler`) | значение пробрасывается дальше, ответ не подменяется |
**ПОЧЕМУ.** Паника внутри обработки одного элемента почти всегда говорит о
баге в работе с данными этого элемента, а не о порче общего состояния, —
останавливать всё остальное не за что. Довод «let it crash» здесь работает
не буквально: в OTP падает изолированный процесс под супервизором, а не узел
целиком, и в Go ближайшая замена такой изоляции — граница итерации, а не
граница процесса. Обратное при этом верно и делает `recover` в цикле
обязательным (GERR-22): неперехваченная паника в любой горутине завершает весь
процесс.
Исключение упавшего элемента в GERR-26.2 — не осторожность, а условие
прогресса: детерминированная паника даёт бесконечный цикл — тот же элемент,
тот же стек, залитый лог и нулевой прогресс. Это классический poison message,
и лекарство здесь то же, что принято в очередях: элемент выводится из
оборота, а не берётся снова. У
цикла, который и так подтверждает прогресс — сдвигает офсет, помечает
строку состоянием, — механизм для этого уже есть, заводить отдельный не
нужно.
Оговорка «если ответ ещё не начат» в GERR-26.1 не формальность: статус
отправляется один раз, и после первой записи в тело поменять его нечем —
клиент получит обрывок с кодом 200. Отсюда же общее предпочтение собирать
ответ целиком до записи там, где это возможно.
Отдельная строка GERR-26.3 нужна потому, что `http.ErrAbortHandler` — не
отказ, а сигнал «прервать обработку намеренно»: подмена его на 500 превратила
бы штатный разрыв в ложную ошибку в логе и в метриках. Так поступают и
стандартные обёртки вроде chi.
### GERR-24. Независимые ошибки собираются `errors.Join`
**СЛЕДУЕТ.** Валидация конфига и подобные проверки отдают все проблемы **СЛЕДУЕТ.** Валидация конфига и подобные проверки отдают все проблемы
разом; проверка собранного — по-прежнему через `errors.Is`. разом; проверка собранного — по-прежнему через `errors.Is`.
**Почему.** Возврат первой ошибки превращает починку конфига в серию **ПОЧЕМУ.** Возврат первой ошибки превращает починку конфига в серию
перезапусков, по одной проблеме за прогон. Склейка сообщений в строку даёт перезапусков, по одной проблеме за прогон. Склейка сообщений в строку даёт
тот же список, но убивает ветвление: `errors.Is` по такому результату не тот же список, но убивает ветвление: `errors.Is` по такому результату не
находит ничего, и вызывающий остаётся с текстом, матчить который запрещено находит ничего, и вызывающий остаётся с текстом, матчить который запрещено
(R11). (GERR-11).
## Связано ## Связано
- `lang/go/logging.md` — где и когда ошибка попадает в лог. - конвенция `logging` — где и когда ошибка попадает в лог.
- `arch/db-identifiers.md` R7 — формат корреляционного ключа из R14. - `KEYS-7` — формат корреляционного ключа из `GERR-14`.
<!-- local:механизировано -->
<!-- /local -->
@@ -1,12 +1,18 @@
--- ---
extends: arch/time.md topic: logging
prefix: SLOG
lang: go
--- ---
# Логирование # Логирование
Как и когда писать логи. Это правила оформления кода (How), а не Как и когда писать логи. Это правила оформления кода (How), а не
спецификация поведения: наблюдаемые требования к логам, входящие в контракт спецификация поведения: наблюдаемые требования к логам, входящие в контракт
функциональности, живут в спеках. Форма записи — `common/language.md`. функциональности, живут в спеках.
Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки
ПОЧЕМУ, ПРИМЕРЫ, МЕХАНИЗИРОВАНО и СНЯТО толкуются как описано в языке
конвенций версии 1 — тогда и только тогда, когда написаны заглавными.
Лог читают инструментами, а не глазами: повседневно — `jq` Лог читают инструментами, а не глазами: повседневно — `jq`
(`jq 'select(.download_id=="a1b2")' app.jsonl`), тяжёлое (агрегации, JOIN) — (`jq 'select(.download_id=="a1b2")' app.jsonl`), тяжёлое (агрегации, JOIN) —
@@ -19,33 +25,33 @@ DuckDB поверх JSONL прямо из файла. Отсюда почти в
## Формат записи ## Формат записи
### R1. Структурированный JSON, один формат для dev и prod ### SLOG-1. Структурированный JSON, один формат для dev и prod
**ДОЛЖЕН.** Хендлер — `slog.JSONHandler`, одинаково в разработке и в **ДОЛЖЕН.** Хендлер — `slog.JSONHandler`, одинаково в разработке и в
проде. проде.
**Почему.** Довод не в том, что текстовый вывод «расходит поля»: смена **ПОЧЕМУ.** Довод не в том, что текстовый вывод «расходит поля»: смена
хендлера структуру атрибутов не меняет. Довод в читателе — с текстовым хендлера структуру атрибутов не меняет. Довод в читателе — с текстовым
dev-выводом перестаёшь ежедневно гонять собственные `jq`-пайплайны, и dev-выводом перестаёшь ежедневно гонять собственные `jq`-пайплайны, и
поломки словаря (опечатка в имени поля, потерянный атрибут, склеенное поломки словаря (опечатка в имени поля, потерянный атрибут, склеенное
значение) обнаруживаются только в проде, где заметить их заранее уже значение) обнаруживаются только в проде, где заметить их заранее уже
некому. некому.
### R2. Данные — в типизированных полях, а не в тексте сообщения ### SLOG-2. Данные — в типизированных полях, а не в тексте сообщения
**ДОЛЖЕН.** Каждая величина — отдельный ключ со значением своего типа. **ДОЛЖЕН.** Каждая величина — отдельный ключ со значением своего типа.
**Почему.** Фильтрация и агрегация работают по ключам; величина, вклеенная **ПОЧЕМУ.** Фильтрация и агрегация работают по ключам; величина, вклеенная
в текст, достаётся только регуляркой, а регулярка ломается при первой же в текст, достаётся только регуляркой, а регулярка ломается при первой же
правке формулировки. Тип важен отдельно от ключа: число внутри строки не правке формулировки. Тип важен отдельно от ключа: число внутри строки не
сравнивается и не суммируется, то есть попадает в лог, но не в отчёт. сравнивается и не суммируется, то есть попадает в лог, но не в отчёт.
### R3. Время записи — UTC ### SLOG-3. Время записи — UTC
**ДОЛЖЕН.** `time` приводится к UTC через `ReplaceAttr` по `slog.TimeKey` **ДОЛЖЕН.** `time` приводится к UTC через `ReplaceAttr` по `slog.TimeKey`
(см. `lang/go/time.md`). (см. конвенцию `time`).
**Почему.** По умолчанию UTC не получится: встроенные хендлеры пишут время **ПОЧЕМУ.** По умолчанию UTC не получится: встроенные хендлеры пишут время
в зоне самого `time.Time`, то есть в локальной зоне процесса. Записи одного в зоне самого `time.Time`, то есть в локальной зоне процесса. Записи одного
процесса до и после смены TZ (или записи рядом с данными из БД) перестают процесса до и после смены TZ (или записи рядом с данными из БД) перестают
складываться в одну хронологию, причём сдвиг на целые часы глазом не виден складываться в одну хронологию, причём сдвиг на целые часы глазом не виден
@@ -53,209 +59,210 @@ dev-выводом перестаёшь ежедневно гонять собс
событий. событий.
Точность `JSONHandler` — миллисекунды фиксированной ширины; это другая Точность `JSONHandler` — миллисекунды фиксированной ширины; это другая
точность, чем в БД, и по `arch/time.md` так и должно быть: ширина точность, чем в БД, и по `TIME-2` так и должно быть: ширина фиксируется
фиксируется на носитель. на носитель.
## Сообщение ## Сообщение
### R4. `msg` — константа в нижнем регистре ### SLOG-4. `msg` — константа в нижнем регистре
**ДОЛЖЕН.** Текст сообщения не собирается из переменных: **ДОЛЖЕН.** Текст сообщения не собирается из переменных:
`log.Info("download accepted", "download_id", id)`. `log.Info("download accepted", "download_id", id)`.
**Почему.** `msg` — то, по чему записи группируют и считают. Интерполяция **ПОЧЕМУ.** `msg` — то, по чему записи группируют и считают. Интерполяция
превращает одну категорию в множество уникальных строк, и вопрос «сколько превращает одну категорию в множество уникальных строк, и вопрос «сколько
раз это случилось» перестаёт решаться группировкой. Нижний регистр — чтобы раз это случилось» перестаёт решаться группировкой. Нижний регистр — чтобы
одна категория не двоилась на варианты, различающиеся только заглавной одна категория не двоилась на варианты, различающиеся только заглавной
буквой. буквой.
### R5. `msg` не несёт префикса подсистемы ### SLOG-5. `msg` не несёт префикса подсистемы
**НЕ ДОЛЖЕН.** `recognition done`, а не `recognize: done`; подсистема — **НЕ ДОЛЖЕН.** `recognition done`, а не `recognize: done`; подсистема —
отдельное поле. отдельное поле.
**Почему.** Префикс кладёт в текст ровно то, по чему потом фильтруют, и **ПОЧЕМУ.** Префикс кладёт в текст ровно то, по чему потом фильтруют, и
фильтр по подсистеме становится сопоставлением с началом строки вместо фильтр по подсистеме становится сопоставлением с началом строки вместо
сравнения значения поля. Заодно это второй способ записать одно и то же: сравнения значения поля. Заодно это второй способ записать одно и то же:
категория дробится на варианты с префиксом и без, а совпадать они обязаны категория дробится на варианты с префиксом и без, а совпадать они обязаны
посимвольно. посимвольно.
### R6. Смена состояния сущности — единая категория ### SLOG-6. Смена состояния сущности — единая категория
**ДОЛЖЕН.** `state transition` с полями `from`/`to`/`code`; какое именно **ДОЛЖЕН.** `state transition` с полями `from`/`to`/`code`; какое именно
состояние и по какой причине — данные, а не текст. состояние и по какой причине — данные, а не текст.
**Почему.** С отдельной категорией на каждый переход жизненный цикл **ПОЧЕМУ.** С отдельной категорией на каждый переход жизненный цикл
сущности собирается перечислением всех известных `msg` — и переход, сущности собирается перечислением всех известных `msg` — и переход,
добавленный в код позже, в это перечисление не попадёт: выборка тихо добавленный в код позже, в это перечисление не попадёт: выборка тихо
останется неполной. Единая категория даёт весь цикл одним фильтром и не останется неполной. Единая категория даёт весь цикл одним фильтром и не
требует обновлять запрос вслед за кодом. требует обновлять запрос вслед за кодом.
### R7. Физический эффект — отдельная запись, а не вместо перехода ### SLOG-7. Физический эффект — отдельная запись, а не вместо перехода
**НЕ ДОЛЖЕН.** Запись о действии, сопровождающем переход, не подменяет **НЕ ДОЛЖЕН.** Запись о действии, сопровождающем переход, не подменяет
запись самого перехода. запись самого перехода.
**Почему.** Иначе из выборки по R6 выпадают именно те переходы, у которых **ПОЧЕМУ.** Иначе из выборки по SLOG-6 выпадают именно те переходы, у которых
был заметный эффект, — то есть самые интересные. Вторая запись стоит одной был заметный эффект, — то есть самые интересные. Вторая запись стоит одной
строки в логе; восстановление пропущенного перехода не стоит ничего, потому строки в логе; восстановление пропущенного перехода не стоит ничего, потому
что невозможно. что невозможно.
## Уровни ## Уровни
### R8. Уровень выбирается по адресату ### SLOG-8. Уровень выбирается по адресату
**ДОЛЖЕН.** Уровень отвечает на вопрос «кому сообщение», а не «насколько **ДОЛЖЕН.** Уровень отвечает на вопрос «кому сообщение», а не «насколько
громко сломалось». громко сломалось».
| № | Уровень | Кому и когда | | № | Уровень | Кому и когда |
|---|---|---| |---|---|---|
| R8.1 | `DEBUG` | разработчику при отладке; в проде выключен | | SLOG-8.1 | `DEBUG` | разработчику при отладке; в проде выключен |
| R8.2 | `INFO` | владельцу, аудит постфактум | | SLOG-8.2 | `INFO` | владельцу, аудит постфактум |
| R8.3 | `WARN` | владельцу, «может стать проблемой» | | SLOG-8.3 | `WARN` | владельцу, «может стать проблемой» |
| R8.4 | `ERROR` | владельцу, в разбор | | SLOG-8.4 | `ERROR` | владельцу, в разбор |
**Почему.** Адресат — единственный признак, по которому разные авторы в **ПОЧЕМУ.** Адресат — единственный признак, по которому разные авторы в
разных местах кода выберут уровень одинаково. «Насколько серьёзно» каждый разных местах кода выберут уровень одинаково. «Насколько серьёзно» каждый
оценивает по-своему, шкала расползается — и вместе с ней теряет смысл оценивает по-своему, шкала расползается — и вместе с ней теряет смысл
базовый порог в проде (R40), потому что он отсекает уже не то, что базовый порог в проде (SLOG-40), потому что он отсекает уже не то, что
задумано. задумано.
### R9. Уровень не зависит от подсистемы ### SLOG-9. Уровень не зависит от подсистемы
**НЕ ДОЛЖЕН.** Происхождение записи на выбор уровня не влияет: `ERROR` **НЕ ДОЛЖЕН.** Происхождение записи на выбор уровня не влияет: `ERROR`
везде одинаково серьёзен. везде одинаково серьёзен.
**Почему.** Фильтр по уровню собирает записи из всех подсистем сразу. Если **ПОЧЕМУ.** Фильтр по уровню собирает записи из всех подсистем сразу. Если
в шумной подсистеме `ERROR` «дешевле», читателю приходится помнить в шумной подсистеме `ERROR` «дешевле», читателю приходится помнить
происхождение каждой записи, чтобы понять, надо ли реагировать, — то есть происхождение каждой записи, чтобы понять, надо ли реагировать, — то есть
уровень перестаёт быть фильтром и становится подсказкой, требующей знания уровень перестаёт быть фильтром и становится подсказкой, требующей знания
кода. кода.
### R10. `WARN` — только когда «может стать проблемой» ### SLOG-10. `WARN` — только когда «может стать проблемой»
**ДОЛЖЕН.** Если «может» не про эту запись, уровень — `INFO`. **ДОЛЖЕН.** Если «может» не про эту запись, уровень — `INFO`.
**Почему.** `WARN` разбирают вручную и целиком. Как только в нём заводится **ПОЧЕМУ.** `WARN` разбирают вручную и целиком. Как только в нём заводится
«ничего страшного», его перестают читать — и вместе с шумом теряется то «ничего страшного», его перестают читать — и вместе с шумом теряется то
единственное, ради чего уровень существует: предупреждение, на которое ещё единственное, ради чего уровень существует: предупреждение, на которое ещё
есть время отреагировать. есть время отреагировать.
### R11. Событийное — `INFO`, рутинно-частое — `DEBUG` ### SLOG-11. Событийное — `INFO`, рутинно-частое — `DEBUG`
**ДОЛЖЕН.** Уровень зависит от того, стоит ли за операцией событие. **ДОЛЖЕН.** Уровень зависит от того, стоит ли за операцией событие.
| № | Операция | Уровень | | № | Операция | Уровень |
|---|---|---| |---|---|---|
| R11.1 | по реальному действию или изменению | `INFO` | | SLOG-11.1 | по реальному действию или изменению | `INFO` |
| R11.2 | повторяющаяся служебная, по таймеру или поллингу, сама по себе события не несущая (healthcheck, опрос статуса, авто-рефреш UI) | `DEBUG` | | SLOG-11.2 | повторяющаяся служебная, по таймеру или поллингу, сама по себе события не несущая (healthcheck, опрос статуса, авто-рефреш UI) | `DEBUG` |
**Почему.** `INFO` — аудит постфактум (R8.2), и его пригодность **ПОЧЕМУ.** `INFO` — аудит постфактум (SLOG-8.2), и его пригодность
определяется долей записей, за которыми что-то стоит. Периодическая определяется долей записей, за которыми что-то стоит. Периодическая
операция даёт ровный поток при нулевой информации, в котором настоящие операция даёт ровный поток при нулевой информации, в котором настоящие
события тонут количественно: их не отфильтровать, потому что фильтровать события тонут количественно: их не отфильтровать, потому что фильтровать
приходится по содержанию, а не по уровню. приходится по содержанию, а не по уровню.
### R12. Фатальный сбой на старте — `ERROR` и ненулевой код возврата ### SLOG-12. Фатальный сбой на старте — `ERROR` и ненулевой код возврата
**ДОЛЖЕН.** `slog` не разделяет CRITICAL/FATAL, поэтому недостающую **ДОЛЖЕН.** Фатальный сбой на старте пишется как `ERROR` и завершает процесс
степень даёт завершение процесса. ненулевым кодом.
**Почему.** Супервизор (docker, journald, systemd) отличает падение от **ПОЧЕМУ.** `slog` не разделяет CRITICAL и FATAL, поэтому недостающую степень
штатной остановки по коду возврата, а не по уровню последней записи. выражает не уровень записи, а сам факт завершения. Супервизор (docker,
Процесс, который написал `ERROR` и продолжил жить с неработающей journald, systemd) отличает падение от штатной остановки по коду возврата, а
конфигурацией, выглядит здоровым и будет получать трафик; изобретать же не по уровню последней записи. Процесс, который написал `ERROR` и продолжил
уровень выше `ERROR` не нужно — сам факт завершения информативнее. жить с неработающей конфигурацией, выглядит здоровым и будет получать трафик;
изобретать же уровень выше `ERROR` не нужно — сам факт завершения
информативнее.
## Поля: единый словарь ## Поля: единый словарь
### R13. Одно поле — одно имя по всему коду ### SLOG-13. Одно поле — одно имя по всему коду
**ДОЛЖЕН.** Не `mediaType`/`media`/`media_type` вперемешку. **ДОЛЖЕН.** Не `mediaType`/`media`/`media_type` вперемешку.
**Почему.** Имя поля — и есть интерфейс запроса к логам. Второе имя для той **ПОЧЕМУ.** Имя поля — и есть интерфейс запроса к логам. Второе имя для той
же величины делает любую выборку по ней молча неполной: фильтр отработает, же величины делает любую выборку по ней молча неполной: фильтр отработает,
часть записей в него не попадёт, и заметить это можно, только заранее зная, часть записей в него не попадёт, и заметить это можно, только заранее зная,
что они должны были быть. что они должны были быть.
### R14. Форма имени зависит от вида поля ### SLOG-14. Форма имени зависит от вида поля
**ДОЛЖЕН.** Две формы, третьей нет. **ДОЛЖЕН.** Две формы, третьей нет.
| № | Вид поля | Форма имени | | № | Вид поля | Форма имени |
|---|---|---| |---|---|---|
| R14.1 | бизнес-поле | плоский `snake_case`: `download_id`, `media_type` | | SLOG-14.1 | бизнес-поле | плоский `snake_case`: `download_id`, `media_type` |
| R14.2 | системный домен | точечная иерархия (адаптация OpenTelemetry): `http.*`, `ext.*` | | SLOG-14.2 | системный домен | точечная иерархия (адаптация OpenTelemetry): `http.*`, `ext.*` |
**Почему.** Точка отделяет поля, приходящие от инфраструктуры и одинаковые **ПОЧЕМУ.** Точка отделяет поля, приходящие от инфраструктуры и одинаковые
в любом проекте, от доменных, которые в каждом свои: по общему префиксу в любом проекте, от доменных, которые в каждом свои: по общему префиксу
запрос «все внешние вызовы» пишется без перечисления имён. Заимствование запрос «все внешние вызовы» пишется без перечисления имён. Заимствование
словаря OpenTelemetry снимает необходимость изобретать имена тому, что уже словаря OpenTelemetry снимает необходимость изобретать имена тому, что уже
названо, и спорить о них на каждом ревью. названо, и спорить о них на каждом ревью.
### R15. Запись плоская ### SLOG-15. Запись плоская
**НЕ ДОЛЖЕН.** Вложенных объектов в записи нет; точка в имени — часть **НЕ ДОЛЖЕН.** Вложенных объектов в записи нет; точка в имени — часть
имени, а не уровень вложенности. имени, а не уровень вложенности.
**Почему.** Плоский ключ адресуется одинаково в `jq`, в DuckDB и в любой **ПОЧЕМУ.** Плоский ключ адресуется одинаково в `jq`, в DuckDB и в любой
записи независимо от её категории. Вложенность требует знать глубину записи независимо от её категории. Вложенность требует знать глубину
заранее, а она у разных категорий разная — и один запрос перестаёт покрывать заранее, а она у разных категорий разная — и один запрос перестаёт покрывать
весь лог, распадаясь на запрос под каждую форму записи. весь лог, распадаясь на запрос под каждую форму записи.
### R16. Набор полей определяется ситуацией ### SLOG-16. Набор полей определяется ситуацией
**ДОЛЖЕН.** Записи каждой ситуации несут её набор целиком. **ДОЛЖЕН.** Записи каждой ситуации несут её набор целиком.
| № | Когда добавляем | Поля | | № | Когда добавляем | Поля |
|---|---|---| |---|---|---|
| R16.1 | входящий HTTP-запрос (middleware) | `http.method`, `http.route`, `http.status_code`, `duration_ms`, `transport`если транспортов больше одного | | SLOG-16.1 | входящий HTTP-запрос (middleware) | `http.method`, `http.route`, `http.status_code`, `duration_ms`, `transport`пока его значение различается между записями (SLOG-17) |
| R16.2 | работа с сущностью (scoped-логгер) | `<entity>_id` и доменные атрибуты | | SLOG-16.2 | работа с сущностью (scoped-логгер) | `<entity>_id` и доменные атрибуты |
| R16.3 | запись об ошибке | `error` | | SLOG-16.3 | запись об ошибке | `error` |
| R16.4 | вызов внешнего сервиса | `ext.service`, `ext.operation` (логическая операция, не URL), `ext.status_code`, `duration_ms`, `retry` | | SLOG-16.4 | вызов внешнего сервиса | `ext.service`, `ext.operation` (логическая операция, не URL), `ext.status_code`, `duration_ms`, `retry` |
**Почему.** Набор задан не «на всякий случай»: без него запись не отвечает **ПОЧЕМУ.** Набор задан не «на всякий случай»: без него запись не отвечает
на свой вопрос. HTTP-запись без `duration_ms` не показывает деградацию, на свой вопрос. HTTP-запись без `duration_ms` не показывает деградацию,
`ext`-запись без `ext.service` не отделяет «легла зависимость» от «у нас `ext`-запись без `ext.service` не отделяет «легла зависимость» от «у нас
баг», запись о сущности без идентификатора не корреллируется (R19). Полный баг», запись о сущности без идентификатора не корреллируется (SLOG-19). Полный
набор делает записи однородными — один запрос работает по всем вызовам, а набор делает записи однородными — один запрос работает по всем вызовам, а
не по тем, где автор вспомнил про поле. не по тем, где автор вспомнил про поле.
### R17. `service.*` и `host.*` не заводим ### SLOG-17. `service.*` и `host.*` не заводим
**НЕ СЛЕДУЕТ.** Пока это один бинарь на одном хосте. **НЕ СЛЕДУЕТ.** Поле, значение которого одинаково во всех записях, не
заводится — для одного бинаря на одном хосте это `service.*` и `host.*`.
**Почему.** Поле с одним и тем же значением во всех записях не несёт **ПОЧЕМУ.** Такое поле не несёт информации, но стоит места в каждой строке
информации, но стоит места в каждой строке и внимания при чтении. Условие и внимания при чтении. Критерий один на все поля словаря — им же решается,
нужен ли `transport` (SLOG-16.1): пока транспорт один, поле постоянно. Условие
названо явно, поэтому правило отпадёт вместе со своей причиной: с названо явно, поэтому правило отпадёт вместе со своей причиной: с
появлением нескольких инстансов различающее поле (`service.version`) появлением нескольких инстансов различающее поле (`service.version`)
добавляется одной строкой при старте. добавляется одной строкой при старте.
<!-- local:словарь -->
<!-- /local -->
## Корреляция ## Корреляция
### R18. Ключ корреляции — идентификатор сущности, а не `trace_id` ### SLOG-18. Ключ корреляции — идентификатор сущности, а не `trace_id`
**НЕ СЛЕДУЕТ.** Отдельный случайный `trace_id` не заводится, если у **НЕ СЛЕДУЕТ.** Отдельный случайный `trace_id` не заводится, если у
сущностей есть стабильные уникальные идентификаторы. (Как их выбирают — сущностей есть стабильные уникальные идентификаторы. (Как их выбирают —
`arch/db-identifiers.md`, если конвенция взята.) конвенция `db-identifiers`, если взята.)
**Почему.** Идентификатор сущности уже существует, стабилен между **ПОЧЕМУ.** Идентификатор сущности уже существует, стабилен между
процессами и во времени — по нему собираются записи не одного прохода, а процессами и во времени — по нему собираются записи не одного прохода, а
всей истории сущности, включая вчерашнюю. `trace_id` даёт то же самое всей истории сущности, включая вчерашнюю. `trace_id` даёт то же самое
только внутри одной операции, то есть дублирует ключ и добавляет второй только внутри одной операции, то есть дублирует ключ и добавляет второй
способ спросить об одном. Условие применимости названо: там, где сущности способ спросить об одном. Условие применимости названо: там, где сущности
со стабильным идентификатором нет, связывать записи больше нечем. со стабильным идентификатором нет, связывать записи больше нечем.
### R19. Запись о сущности несёт её идентификатор ### SLOG-19. Запись о сущности несёт её идентификатор
**ДОЛЖЕН.** Поле `<entity>_id` в каждой записи, относящейся к сущности. **ДОЛЖЕН.** Поле `<entity>_id` в каждой записи, относящейся к сущности.
**Почему.** Принадлежность записи восстанавливается только в момент **ПОЧЕМУ.** Принадлежность записи восстанавливается только в момент
записи; постфактум её не вывести — остаётся воспроизводить инцидент заново. записи; постфактум её не вывести — остаётся воспроизводить инцидент заново.
Это же условие, при котором работает R18: отказ от `trace_id` оплачен тем, Это же условие, при котором работает SLOG-18: отказ от `trace_id` оплачен тем,
что идентификатор стоит везде, а не в удобных местах. что идентификатор стоит везде, а не в удобных местах.
Все записи одной операции собираются одним фильтром: Все записи одной операции собираются одним фильтром:
@@ -263,7 +270,7 @@ dev-выводом перестаёшь ежедневно гонять собс
глобально уникален across сущностей, штатно работает и простой `grep` по глобально уникален across сущностей, штатно работает и простой `grep` по
голому значению — он находит все упоминания независимо от имени поля. голому значению — он находит все упоминания независимо от имени поля.
### R20. Долгая операция ведётся scoped-логгером через `context.Context` ### SLOG-20. Долгая операция ведётся scoped-логгером через `context.Context`
**СЛЕДУЕТ.** Логгер с дописанным ключом протаскивается сквозь асинхронные **СЛЕДУЕТ.** Логгер с дописанным ключом протаскивается сквозь асинхронные
стадии: стадии:
@@ -273,91 +280,100 @@ log := log.With("download_id", id)
ctx = logctx.With(ctx, log) // достаём логгер из ctx в каждой стадии ctx = logctx.With(ctx, log) // достаём логгер из ctx в каждой стадии
``` ```
**Почему.** Ручное дописывание ключа пропускают не в основном сценарии, а в **ПОЧЕМУ.** Ручное дописывание ключа пропускают не в основном сценарии, а в
редких ветках — обработке ошибок и ранних выходах, где корреляция нужнее редких ветках — обработке ошибок и ранних выходах, где корреляция нужнее
всего. Логгер из контекста дописывает ключ сам, и запись без всего. Логгер из контекста дописывает ключ сам, и запись без
идентификатора становится невозможной, а не маловероятной. идентификатора становится невозможной, а не маловероятной.
## Ошибки ## Ошибки
### R21. Ошибка логируется атрибутом `error` ### SLOG-21. Ошибка логируется атрибутом `error`
**ДОЛЖЕН.** `log.Error("layout failed", "error", err, "download_id", id)`. **ДОЛЖЕН.** `log.Error("layout failed", "error", err, "download_id", id)`.
**Почему.** Ошибка, вклеенная в текст сообщения, дробит категорию (R4) и **ПОЧЕМУ.** Ошибка, вклеенная в текст сообщения, дробит категорию (SLOG-4) и
уносит текст туда, где по нему нельзя отфильтровать. Ключ единый — так же, уносит текст туда, где по нему нельзя отфильтровать. Ключ единый — так же,
как по умолчанию в zap/zerolog: выборка «все записи с ошибкой» не должна как по умолчанию в zap/zerolog: выборка «все записи с ошибкой» не должна
зависеть от того, кто писал конкретный вызов, и ради этого единообразия зависеть от того, кто писал конкретный вызов, и ради этого единообразия
краткостью жертвуют. краткостью жертвуют.
### R22. Промежуточный слой либо логирует, либо возвращает ### SLOG-22. Промежуточный слой либо логирует, либо возвращает
**НЕ ДОЛЖЕН.** Слой, возвращающий ошибку выше, её не логирует — только **НЕ ДОЛЖЕН.** Слой, возвращающий ошибку выше, её не логирует — только
оборачивает (`%w`). оборачивает (`%w`).
**Почему.** Иначе один сбой даёт столько записей, сколько слоёв он прошёл, **ПОЧЕМУ.** Иначе один сбой даёт столько записей, сколько слоёв он прошёл,
и количество `ERROR` перестаёт соответствовать количеству отказов — а и количество `ERROR` перестаёт соответствовать количеству отказов — а
считают именно его. Контекст при этом не теряется: он накапливается в считают именно его. Контекст при этом не теряется: он накапливается в
цепочке обёрток и попадает в единственную запись на границе (R23). цепочке обёрток и попадает в единственную запись на границе (SLOG-23).
### R23. Ошибка логируется один раз — на границе доменного слоя ### SLOG-23. Ошибка логируется один раз — на границе доменного слоя
**ДОЛЖЕН.** Логирует единый чокпоинт, определяющий исход операции. **ДОЛЖЕН.** Логирует единый чокпоинт, определяющий исход операции.
**Почему.** У ошибки нужен ровно один логирующий, иначе неизбежны дубли; и **ПОЧЕМУ.** У ошибки нужен ровно один логирующий, иначе неизбежны дубли; и
этим местом выбрана доменная граница, а не транспорт, потому что там этим местом выбрана доменная граница, а не транспорт, потому что там
известен исход операции целиком и, значит, класс отказа (R25) — транспорт известен исход операции целиком и, значит, класс отказа (SLOG-25) — транспорт
знает лишь то, что ему вернули ошибку. Побочный эффект того же выбора: знает лишь то, что ему вернули ошибку. Побочный эффект того же выбора:
транспорты остаются тонкими. транспорты остаются тонкими.
<!-- local:границы --> ### SLOG-24. Транспорт не логирует ошибку повторно
<!-- /local -->
### R24. Транспорт не логирует ошибку повторно
**НЕ ДОЛЖЕН.** Транспорт переводит возвращённую ошибку в свой ответ **НЕ ДОЛЖЕН.** Транспорт переводит возвращённую ошибку в свой ответ
(статус, сообщение пользователю) и на этом останавливается. (статус, сообщение пользователю) и на этом останавливается.
**Почему.** Запись уже сделана на границе (R23); вторая отличается от неё **ПОЧЕМУ.** Запись уже сделана на границе (SLOG-23); вторая отличается от неё
только формулировкой и читается как второй сбой. Когда транспортов над только формулировкой и читается как второй сбой. Когда транспортов над
одним доменом несколько, дублирование ещё и множится, а расследование одним доменом несколько, дублирование ещё и множится, а расследование
начинается с вопроса, один это инцидент или два. начинается с вопроса, один это инцидент или два.
### R25. Уровень доменного отказа — по классу отказа ### SLOG-25. Уровень доменного отказа — по классу отказа
**ДОЛЖЕН.** Уровень выбирает единственный логирующий (R23), и выбирает по **ДОЛЖЕН.** Уровень выбирает единственный логирующий (SLOG-23), и выбирает по
классу, а не по месту в коде. классу, а не по месту в коде. Классификация покрывает **доменные** отказы —
те, что операция вернула значением `error`.
| № | Класс отказа | Кому | Уровень | | № | Класс отказа | Кому | Уровень |
|---|---|---|---| |---|---|---|---|
| R25.1 | штатный конфликт состояния или некорректный ввод | пользователю, он уже получил ответ | `DEBUG` | | SLOG-25.1 | штатный конфликт состояния или некорректный ввод | пользователю, он уже получил ответ | `DEBUG` |
| R25.2 | расхождение производного или учётного состояния, первичные данные целы | владельцу, «может стать проблемой» | `WARN` | | SLOG-25.2 | расхождение производного или учётного состояния, первичные данные целы | владельцу, «может стать проблемой» | `WARN` |
| R25.3 | сбой БД, ФС, недоступность зависимости | владельцу, в разбор | `ERROR` | | SLOG-25.3 | сбой БД, ФС, недоступность зависимости | владельцу, в разбор | `ERROR` |
| SLOG-25.4 | класса нет: отказ в классификацию не заведён | владельцу, как пропуск в классификации | `ERROR` с отметкой о непокрытом классе |
**Почему.** Это применение R8 к отказам: пользователь уже увидел причину на **ПОЧЕМУ.** Это применение SLOG-8 к отказам: пользователь уже увидел причину на
экране — владельцу разбирать нечего; целостность первичных данных отделяет экране — владельцу разбирать нечего; целостность первичных данных отделяет
«надо посмотреть» от «надо чинить сейчас». Привязка к месту дала бы разный «надо посмотреть» от «надо чинить сейчас». Привязка к месту дала бы разный
уровень для одного и того же отказа в зависимости от того, какой транспорт уровень для одного и того же отказа в зависимости от того, какой транспорт
его вызвал, — и невалидный ввод из формы копился бы в `ERROR` наравне с его вызвал, — и невалидный ввод из формы копился бы в `ERROR` наравне с
упавшей базой. упавшей базой.
### R26. Тот же отказ в асинхронной стадии — уровнем выше Нарушение инварианта в собственном коде — паника, недостижимая ветка — в
таблицу не входит: это не доменный отказ, и логирует его recover-граница
вместе со стеком (конвенция `errors`). Искать его класс здесь не нужно.
Строка SLOG-25.4 говорит не о классе отказа, а о пропуске в самой
классификации: ошибку забыли завести в маппинге. `ERROR` здесь — громкость,
по которой пропуск находят фильтром, а не оценка тяжести отказа; саму отметку
о непокрытом классе ставит трансляция ошибки (`GERR-25` в конвенции
`errors`).
### SLOG-26. Тот же отказ в асинхронной стадии — уровнем выше
**ДОЛЖЕН.** Когда пользователь не ждёт результата, отказ адресован **ДОЛЖЕН.** Когда пользователь не ждёт результата, отказ адресован
владельцу как деградация автоматики: коллизия в ручном действии — `DEBUG`, владельцу как деградация автоматики: коллизия в ручном действии — `DEBUG`,
она же в авто-обработке — `WARN`. она же в авто-обработке — `WARN`.
**Почему.** В ручном действии человек видит причину на экране и сам решает, **ПОЧЕМУ.** В ручном действии человек видит причину на экране и сам решает,
что делать дальше; запись нужна только для отладки. В автоматике не увидел что делать дальше; запись нужна только для отладки. В автоматике не увидел
никто, задача осталась недоведённой, и лог — единственное место, где это никто, задача осталась недоведённой, и лог — единственное место, где это
вообще проявится. вообще проявится.
### R27. Повторяющийся сбой фонового цикла — `WARN` ### SLOG-27. Повторяющийся сбой фонового цикла — `WARN`
**ДОЛЖЕН.** Тот же класс сбоя внутри синхронной операции — `ERROR`: **ДОЛЖЕН.** Тот же класс сбоя внутри синхронной операции — `ERROR`:
уровень задаёт наличие штатного повтора, а не текст ошибки. уровень задаёт наличие штатного повтора, а не текст ошибки.
**Почему.** Одиночный промах тика транзиентен — следующий тик повторит, и **ПОЧЕМУ.** Одиночный промах тика транзиентен — следующий тик повторит, и
вмешательство не требуется; `ERROR` на каждый такой промах обесценивает вмешательство не требуется; `ERROR` на каждый такой промах обесценивает
уровень, на который смотрят в первую очередь. Синхронная операция повтора уровень, на который смотрят в первую очередь. Синхронная операция повтора
не имеет: она провалилась целиком, результат никто не восстановит, и это не имеет: она провалилась целиком, результат никто не восстановит, и это
@@ -365,32 +381,32 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд
## Внешние сервисы ## Внешние сервисы
### R28. Каждый вызов внешнего сервиса логируется ### SLOG-28. Каждый вызов внешнего сервиса логируется
**ДОЛЖЕН.** Все вызовы, включая успешные; поля — по R16.4. **ДОЛЖЕН.** Все вызовы, включая успешные; поля — по SLOG-16.4.
**Почему.** Это единственный способ отличить «у нас баг» от «зависимость **ПОЧЕМУ.** Это единственный способ отличить «у нас баг» от «зависимость
легла»: на своей стороне видно лишь то, что операция не удалась. легла»: на своей стороне видно лишь то, что операция не удалась.
Выборочное логирование ломает и второе применение — доля неуспехов и Выборочное логирование ломает и второе применение — доля неуспехов и
распределение `duration_ms` считаются, только если знаменатель полный. распределение `duration_ms` считаются, только если знаменатель полный.
### R29. Уровень `ext`-записи — по исходу вызова ### SLOG-29. Уровень `ext`-записи — по исходу вызова
**ДОЛЖЕН.** Исход считается по одному вызову с его ретраями. **ДОЛЖЕН.** Исход считается по одному вызову с его ретраями.
| № | Исход | Уровень | | № | Исход | Уровень |
|---|---|---| |---|---|---|
| R29.1 | успешный событийный вызов | `INFO` | | SLOG-29.1 | успешный событийный вызов | `INFO` |
| R29.2 | успешный рутинно-частый вызов (поллинг, авто-рефреш) | `DEBUG` | | SLOG-29.2 | успешный рутинно-частый вызов (поллинг, авто-рефреш) | `DEBUG` |
| R29.3 | попытка не удалась, делается retry | `WARN` | | SLOG-29.3 | попытка не удалась, делается retry | `WARN` |
| R29.4 | ретраи исчерпаны, сервис недоступен | `ERROR` | | SLOG-29.4 | ретраи исчерпаны, сервис недоступен | `ERROR` |
**Почему.** Неудачная попытка, за которой следует повтор, — ещё не отказ: **ПОЧЕМУ.** Неудачная попытка, за которой следует повтор, — ещё не отказ:
операция может завершиться успешно, и `ERROR` на каждую попытку сделал бы операция может завершиться успешно, и `ERROR` на каждую попытку сделал бы
уровень непригодным для главного вопроса «зависимость доступна?». уровень непригодным для главного вопроса «зависимость доступна?».
Исчерпание ретраев и есть момент, когда транспорт сдался и дальше Исчерпание ретраев и есть момент, когда транспорт сдался и дальше
разбираться владельцу. Различение R29.1 и R29.2 — то же самое разделение разбираться владельцу. Различение SLOG-29.1 и SLOG-29.2 — то же самое разделение
событийного и рутинного, что в R11: поллинг внешнего сервиса зашумляет событийного и рутинного, что в SLOG-11: поллинг внешнего сервиса зашумляет
аудит так же, как любой другой. аудит так же, как любой другой.
## Два цикла повтора — не путать ## Два цикла повтора — не путать
@@ -401,8 +417,10 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд
уровень доменной записи об исходе тика. уровень доменной записи об исходе тика.
``` ```
WHEN зависимость недоступна и ретраи вызова исчерпаны → ext-запись `ERROR` (R29.4) КОГДА зависимость недоступна И ретраи вызова исчерпаны
AND тик фонового цикла упал по той же причине → доменная запись `WARN` (R27) ТОГДА ext-запись `ERROR` (SLOG-29.4)
И тик фонового цикла, упавший по той же причине,
даёт доменную запись `WARN` (SLOG-27)
``` ```
Из этого следует, что у лежащей зависимости `ext`-запись пишет `ERROR` Из этого следует, что у лежащей зависимости `ext`-запись пишет `ERROR`
@@ -411,13 +429,13 @@ AND тик фонового цикла упал по той же причине
`ERROR` от поллинга мешает — это лечится понижением частоты тика или `ERROR` от поллинга мешает — это лечится понижением частоты тика или
подавлением повторов в самом клиенте, а не переклассификацией уровня. подавлением повторов в самом клиенте, а не переклассификацией уровня.
### R30. Ответ 4xx — успех на транспортном уровне ### SLOG-30. Ответ 4xx — успех на транспортном уровне
**ДОЛЖЕН.** Завершённый HTTP-ответ с 4xx логируется как успешный вызов **ДОЛЖЕН.** Завершённый HTTP-ответ с 4xx логируется как успешный вызов
(`ext.status_code` записан); решение «это ошибка» принимает доменный (`ext.status_code` записан); решение «это ошибка» принимает доменный
вызывающий. вызывающий.
**Почему.** Транспорт своё дело сделал: запрос доставлен, ответ получен и **ПОЧЕМУ.** Транспорт своё дело сделал: запрос доставлен, ответ получен и
разобран. Классифицировать 4xx как сбой транспорта значит смешать «сервис разобран. Классифицировать 4xx как сбой транспорта значит смешать «сервис
недоступен» с «сервис ответил нам нет» — это разные инциденты с разной недоступен» с «сервис ответил нам нет» — это разные инциденты с разной
реакцией, и различает их как раз `ext`-уровень. Что 404 значит для реакцией, и различает их как раз `ext`-уровень. Что 404 значит для
@@ -426,74 +444,74 @@ AND тик фонового цикла упал по той же причине
## HTTP и healthcheck ## HTTP и healthcheck
### R31. Входящий запрос — `INFO` независимо от кода ответа ### SLOG-31. Входящий запрос — `INFO` независимо от кода ответа
**ДОЛЖЕН.** Поля по R16.1; 4xx остаётся `INFO`-записью доступа. **ДОЛЖЕН.** Поля по SLOG-16.1; 4xx остаётся `INFO`-записью доступа.
**Почему.** Это аудит обращений, а не отладка: запись отвечает на «кто и **ПОЧЕМУ.** Это аудит обращений, а не отладка: запись отвечает на «кто и
когда приходил», и ценность у неё одинаковая при любом коде ответа. когда приходил», и ценность у неё одинаковая при любом коде ответа.
Уровень, зависящий от кода, делает аудит неполным именно на тех запросах, Уровень, зависящий от кода, делает аудит неполным именно на тех запросах,
которые чаще всего разбирают. Доменную оценку исхода даёт отдельная запись которые чаще всего разбирают. Доменную оценку исхода даёт отдельная запись
(R25) — она и адресована по-другому. (SLOG-25) — она и адресована по-другому.
### R32. Для корреляции запроса допустим `request_id` ### SLOG-32. Для корреляции запроса допустим `request_id`
**ДОПУСКАЕТСЯ.** Это отдельный слой от корреляции по сущности. **ДОПУСКАЕТСЯ.** Это отдельный слой от корреляции по сущности.
**Почему.** Явное разрешение снимает вопрос, не запрещает ли `request_id` **ПОЧЕМУ.** Явное разрешение снимает вопрос, не запрещает ли `request_id`
правило R18. Не запрещает: R18 отказывается от случайного ключа там, где правило SLOG-18. Не запрещает: SLOG-18 отказывается от случайного ключа там, где
уже есть стабильный идентификатор сущности, а у HTTP-запроса собственной уже есть стабильный идентификатор сущности, а у HTTP-запроса собственной
сущности нет — связать его записи между собой больше нечем. сущности нет — связать его записи между собой больше нечем.
### R33. Healthcheck, liveness, readiness — `DEBUG` ### SLOG-33. Healthcheck, liveness, readiness — `DEBUG`
**ДОЛЖЕН.** Периодические проверки живости пишутся на отладочном уровне. **ДОЛЖЕН.** Периодические проверки живости пишутся на отладочном уровне.
**Почему.** Частный случай R11.2, названный отдельно, потому что нарушают **ПОЧЕМУ.** Частный случай SLOG-11.2, названный отдельно, потому что нарушают
его чаще всего: проверку дёргают по таймеру, и на `INFO` она вытесняет из его чаще всего: проверку дёргают по таймеру, и на `INFO` она вытесняет из
аудита всё остальное — в проде с базовым `INFO` (R40) лог превратился бы в аудита всё остальное — в проде с базовым `INFO` (SLOG-40) лог превратился бы в
опрос самого себя. На `DEBUG` она не пишется вовсе и при этом остаётся опрос самого себя. На `DEBUG` она не пишется вовсе и при этом остаётся
доступной при отладке. доступной при отладке.
## Безопасность: что не логируем ## Безопасность: что не логируем
### R34. Секреты не логируются ### SLOG-34. Секреты не логируются
**НЕ ДОЛЖЕН.** Ни в полях, ни в сообщениях: пароли и cookie сессий, **НЕ ДОЛЖЕН.** Ни в полях, ни в сообщениях: пароли и cookie сессий,
API-ключи и токены, `Authorization`-заголовки, аутентификационные параметры API-ключи и токены, `Authorization`-заголовки, аутентификационные параметры
в ссылках. в ссылках.
**Почему.** Лог уезжает целиком в чужое хранилище, читается шире, чем код, **ПОЧЕМУ.** Лог уезжает целиком в чужое хранилище, читается шире, чем код,
и переживает ротацию самого секрета. Попавший в него секрет скомпрометирован и переживает ротацию самого секрета. Попавший в него секрет скомпрометирован
с момента записи, а не с момента, когда это заметили, и вычистить его задним с момента записи, а не с момента, когда это заметили, и вычистить его задним
числом из уже собранных копий нельзя. числом из уже собранных копий нельзя.
### R35. Недоверенные и большие тела — только на `DEBUG`, после вычистки и обрезки ### SLOG-35. Недоверенные и большие тела — только на `DEBUG`, после вычистки и обрезки
**ДОЛЖЕН.** Тела запросов и ответов внешних API, сырой вывод LLM — **ДОЛЖЕН.** Тела запросов и ответов внешних API, сырой вывод LLM —
`DEBUG`, с вычисткой секретов и обрезкой по длине. `DEBUG`, с вычисткой секретов и обрезкой по длине.
**Почему.** Содержимое пришло снаружи: размер не ограничен, состав **ПОЧЕМУ.** Содержимое пришло снаружи: размер не ограничен, состав
неизвестен, а секрет в нём возможен по недосмотру той стороны. `DEBUG` неизвестен, а секрет в нём возможен по недосмотру той стороны. `DEBUG`
выключен в проде (R40), поэтому цена ошибки ограничена отладочной сессией; выключен в проде (SLOG-40), поэтому цена ошибки ограничена отладочной сессией;
обрезка не даёт одной записи вытеснить весь остальной лог за период. обрезка не даёт одной записи вытеснить весь остальной лог за период.
### R36. При сомнении логируется факт, а не значение ### SLOG-36. При сомнении логируется факт, а не значение
**СЛЕДУЕТ.** `"has_api_key", true` вместо самого значения. **СЛЕДУЕТ.** `"has_api_key", true` вместо самого значения.
**Почему.** Для отладки почти всегда достаточно ответа «значение было или **ПОЧЕМУ.** Для отладки почти всегда достаточно ответа «значение было или
не было» — потеря полезности близка к нулю, а риск снимается целиком. не было» — потеря полезности близка к нулю, а риск снимается целиком.
Правило нужно потому, что решение принимается в момент написания строки, Правило нужно потому, что решение принимается в момент написания строки,
когда чувствительность значения ещё неочевидна, а перечитывать этот выбор когда чувствительность значения ещё неочевидна, а перечитывать этот выбор
никто не придёт. никто не придёт.
### R37. `*url.Error` санитизируется на границе клиента ### SLOG-37. `*url.Error` санитизируется на границе клиента
**ДОЛЖЕН.** Ошибка разворачивается в первопричину **до** лога и до **ДОЛЖЕН.** Ошибка разворачивается в первопричину **до** лога и до
обёртки — раньше трансляции в доменную (`lang/go/errors.md`). обёртки — раньше трансляции в доменную (конвенция `errors`).
**Почему.** `*url.Error` встраивает полный URL запроса, а секрет живёт **ПОЧЕМУ.** `*url.Error` встраивает полный URL запроса, а секрет живёт
прямо в нём: токен в пути, `api_key` в query. Go редактирует только пароль прямо в нём: токен в пути, `api_key` в query. Go редактирует только пароль
из userinfo, остального не трогает, поэтому ошибка уносит секрет и в из userinfo, остального не трогает, поэтому ошибка уносит секрет и в
обёртку, и в лог целиком. Порядок — часть нормы: санитизация после обёртку, и в лог целиком. Порядок — часть нормы: санитизация после
@@ -502,49 +520,43 @@ API-ключи и токены, `Authorization`-заголовки, аутент
причину сохраняется); альтернатива с редактированием URL сохранила бы причину сохраняется); альтернатива с редактированием URL сохранила бы
структуру, но сложнее. структуру, но сложнее.
### R38. Секрет не кладётся в URL, если у API есть заголовок ### SLOG-38. Секрет не кладётся в URL, если у API есть заголовок
**НЕ ДОЛЖЕН.** Аутентификация параметром ссылки — только когда другого **НЕ ДОЛЖЕН.** Аутентификация параметром ссылки — только когда другого
способа нет. способа нет.
**Почему.** Секрет в URL попадает не только в ошибку транспорта (R37), но и **ПОЧЕМУ.** Секрет в URL попадает не только в ошибку транспорта (SLOG-37), но и
в любую запись, куда URL попал целиком, — то есть обязывает помнить про в любую запись, куда URL попал целиком, — то есть обязывает помнить про
санитизацию в каждой такой точке, и одна забытая сводит остальные на нет. санитизацию в каждой такой точке, и одна забытая сводит остальные на нет.
Заголовок снимает задачу в источнике: чего нет в URL, того нет и в ошибке. Заголовок снимает задачу в источнике: чего нет в URL, того нет и в ошибке.
<!-- local:секреты -->
<!-- /local -->
## Куда пишем ## Куда пишем
### R39. Логи идут в `stdout` одним потоком ### SLOG-39. Логи идут в `stdout` одним потоком
**ДОЛЖЕН.** Сбор и ротацию делает окружение (docker, journald); по файлам **ДОЛЖЕН.** Сбор и ротацию делает окружение (docker, journald); по файлам
не маршрутизируем. не маршрутизируем.
**Почему.** Приложение, которое само решает, что куда писать, дублирует **ПОЧЕМУ.** Приложение, которое само решает, что куда писать, дублирует
работу супервизора и расходится с ней при первой же смене окружения: срок работу супервизора и расходится с ней при первой же смене окружения: срок
хранения, сжатие и ротация оказываются настроены в двух местах и по-разному. хранения, сжатие и ротация оказываются настроены в двух местах и по-разному.
Один поток вдобавок сохраняет порядок записей — маршрутизация по файлам Один поток вдобавок сохраняет порядок записей — маршрутизация по файлам
теряет его ровно там, где важен ход событий. теряет его ровно там, где важен ход событий.
### R40. Базовый уровень — `INFO` в проде и `DEBUG` в dev ### SLOG-40. Базовый уровень — `INFO` в проде и `DEBUG` в dev
**ДОЛЖЕН.** `DEBUG` в проде включается конфигом. **ДОЛЖЕН.** `DEBUG` в проде включается конфигом.
**Почему.** Уровень — единственный регулятор объёма, доступный без **ПОЧЕМУ.** Уровень — единственный регулятор объёма, доступный без
пересборки; если `DEBUG` в проде включается только правкой кода, его не пересборки; если `DEBUG` в проде включается только правкой кода, его не
включают, и разбор инцидента идёт вслепую. `INFO` выбран базовым потому, включают, и разбор инцидента идёт вслепую. `INFO` выбран базовым потому,
что на нём аудит полон (R8.2), а рутинно-частое уже отсечено (R11.2). что на нём аудит полон (SLOG-8.2), а рутинно-частое уже отсечено (SLOG-11.2).
## Связано ## Связано
- `arch/time.md` — точность и зона меток времени фиксируются на носитель. - конвенция `time` — точность и зона меток времени фиксируются на носитель;
- `lang/go/time.md` как ставится UTC в `ReplaceAttr` (R3). как ставится UTC в `ReplaceAttr` (SLOG-3).
- `lang/go/errors.md` — трансляция ошибки в доменную, порядок относительно - конвенция `errors` — трансляция ошибки в доменную, порядок относительно
санитизации (R37). санитизации (SLOG-37).
- `arch/db-identifiers.md` — откуда берутся стабильные идентификаторы, - конвенция `db-identifiers` — откуда берутся стабильные идентификаторы,
на которых держится корреляция (R18). на которых держится корреляция (SLOG-18).
<!-- local:механизировано -->
<!-- /local -->
+70 -49
View File
@@ -1,21 +1,27 @@
--- ---
topic: time
prefix: GTIM
lang: go
extends: arch/time.md extends: arch/time.md
--- ---
# Время: реализация на Go # Время: реализация на Go
Как требования `arch/time.md` выполняются в Go-коде: откуда берётся Как требования базового слоя выполняются в Go-коде: откуда берётся «сейчас»,
«сейчас», в каком виде время попадает в базу и в логи, что делать с зонами. в каком виде время попадает в базу и в логи, что делать с зонами.
Форма записи — `common/language.md`.
Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки
ПОЧЕМУ, ПРИМЕРЫ, МЕХАНИЗИРОВАНО и СНЯТО толкуются как описано в языке
конвенций версии 1 — тогда и только тогда, когда написаны заглавными.
## Правила ## Правила
### R1. «Сейчас» берётся у слоя хранилища ### GTIM-1. «Сейчас» берётся у слоя хранилища
**ДОЛЖЕН.** Текущее время приходит из `store.Now()`, возвращающего **ДОЛЖЕН.** Текущее время приходит из `store.Now()`, возвращающего
`time.Now().UTC()`, а не из `time.Now()` по коду. `time.Now().UTC()`, а не из `time.Now()` по коду.
**Почему.** Единая точка даёт гарантированный UTC и один формат: ни одна **ПОЧЕМУ.** Единая точка даёт гарантированный UTC и один формат: ни одна
ветка кода не может о них забыть. Разъехавшиеся зоны чинятся только чтением ветка кода не может о них забыть. Разъехавшиеся зоны чинятся только чтением
всех записей — по метке `2026-06-28T11:23:45Z` уже не видно, была ли она всех записей — по метке `2026-06-28T11:23:45Z` уже не видно, была ли она
когда-то локальной, и восстановить смещение задним числом не по чему. когда-то локальной, и восстановить смещение задним числом не по чему.
@@ -24,28 +30,28 @@ extends: arch/time.md
придётся превратить в переменную или поле, если однажды понадобится придётся превратить в переменную или поле, если однажды понадобится
подменять часы, но само по себе оно подмены не даёт. подменять часы, но само по себе оно подмены не даёт.
### R2. Форматирование и разбор — через `store.FormatTime` / `store.ParseTime` ### GTIM-2. Форматирование и разбор — через `store.FormatTime` / `store.ParseTime`
**ДОЛЖЕН.** Пара функций поверх `time.RFC3339` — единственный способ **ДОЛЖЕН.** Пара функций поверх `time.RFC3339` — единственный способ
получить строку времени и прочитать её обратно. получить строку времени и прочитать её обратно.
**Почему.** Layout, набранный по месту вызова, превращает формат хранения в **ПОЧЕМУ.** Layout, набранный по месту вызова, превращает формат хранения в
свойство каждой отдельной строки кода. Фиксированная ширина (R4) и свойство каждой отдельной строки кода. Фиксированная ширина (GTIM-4) и
взаимная обратимость записи и чтения держатся ровно до первого второго взаимная обратимость записи и чтения держатся ровно до первого второго
layout — а расхождение проявится не на записи, а при сравнении значений, layout — а расхождение проявится не на записи, а при сравнении значений,
записанных разными местами. записанных разными местами.
### R3. Прямой `time.Now()` запрещён линтером, список исключений исчерпывающий ### GTIM-3. Прямой `time.Now()` запрещён линтером, список исключений исчерпывающий
**ДОЛЖЕН.** Запрет механизируется `forbidigo`; исключений ровно два, и оба **ДОЛЖЕН.** Запрет проверяется линтером (в Go — `forbidigo`); исключений
прописаны явно: ровно два, и оба прописаны явно:
| № | Исключение | Почему оно не покрывается R1 | | № | Исключение | Почему оно не покрывается GTIM-1 |
|---|---|---| |---|---|---|
| R3.1 | сама точка `store.Now()` | внутри неё вызов `time.Now()` и происходит | | GTIM-3.1 | сама точка `store.Now()` | внутри неё вызов `time.Now()` и происходит |
| R3.2 | обёртка измерения длительности | приведение к UTC срезает монотонную составляющую (R10) | | GTIM-3.2 | обёртка измерения длительности | приведение к UTC срезает монотонную составляющую (GTIM-10) |
**Почему.** R1 без механической проверки держится на внимании, а **ПОЧЕМУ.** GTIM-1 без механической проверки держится на внимании, а
`time.Now()` пишется рефлекторно — и в новом коде, и в мелкой правке; `time.Now()` пишется рефлекторно — и в новом коде, и в мелкой правке;
нарушение молчит до тех пор, пока процесс не окажется в не-UTC зоне. нарушение молчит до тех пор, пока процесс не окажется в не-UTC зоне.
Исключения перечисляются исчерпывающе, потому что каждое из них — само по Исключения перечисляются исчерпывающе, потому что каждое из них — само по
@@ -53,11 +59,27 @@ layout — а расхождение проявится не на записи,
«починены» тем, кто увидит в них дефект, и конвенция начнёт противоречить «починены» тем, кто увидит в них дефект, и конвенция начнёт противоречить
сама себе. сама себе.
### R4. В БД время хранится с секундной точностью, ширина 20 символов ### GTIM-13. Исключение регистрируется директивой на месте вызова, а не в конфиге линтера
**ДОЛЖЕН.** Исключение из GTIM-3 оформляется как
`//nolint:forbidigo // <причина>` на строке вызова; exclude-записи в
конфигурации линтера для него не заводятся.
**ПОЧЕМУ.** Запись в конфиге — второй реестр тех же двух мест: она адресует
исключение путём к файлу, отвязывается при переносе кода и продолжает
разрешать `time.Now()` там, где исключения уже нет, — молча. Директива
переезжает вместе с кодом, и grep по `nolint:forbidigo` даёт весь список —
та исчерпываемость, которой требует GTIM-3, проверяется одной командой. Голый
`//nolint` без имени правила глушит на строке все проверки сразу, а без
причины неотличим от заглушенного дефекта; обе деградации штатно ловит
`nolintlint` (`require-specific`, `require-explanation`) — стандартный
способ дисциплинировать директивы в golangci-lint.
### GTIM-4. В БД время хранится с секундной точностью, ширина 20 символов
**ДОЛЖЕН.** Каноническое значение в колонке — `2026-06-28T11:23:45Z`. **ДОЛЖЕН.** Каноническое значение в колонке — `2026-06-28T11:23:45Z`.
**Почему.** Строки в колонке `TEXT` сравниваются побайтово, поэтому **ПОЧЕМУ.** Строки в колонке `TEXT` сравниваются побайтово, поэтому
лексикографический порядок совпадает с хронологическим только при лексикографический порядок совпадает с хронологическим только при
одинаковых ширине и форме. Значение с долями секунды сортируется **раньше** одинаковых ширине и форме. Значение с долями секунды сортируется **раньше**
целой секунды того же момента (`.` меньше `Z`), то есть ломаются и целой секунды того же момента (`.` меньше `Z`), то есть ломаются и
@@ -67,37 +89,39 @@ layout — а расхождение проявится не на записи,
Ширина достаётся даром: layout `time.RFC3339` не содержит долей секунды, Ширина достаётся даром: layout `time.RFC3339` не содержит долей секунды,
поэтому `Format` их не выведет. поэтому `Format` их не выведет.
### R5. `time.RFC3339Nano` не используется ### GTIM-5. `time.RFC3339Nano` не используется
**НЕ ДОЛЖЕН.** Ни как layout вывода, ни как формат хранения. **НЕ ДОЛЖЕН.** Ни как layout вывода, ни как формат хранения.
**Почему.** Он отбрасывает хвостовые нули, из-за чего длина строки зависит **ПОЧЕМУ.** Он отбрасывает хвостовые нули, из-за чего длина строки зависит
от значения: соседние записи получают разную ширину, и свойство, на котором от значения: соседние записи получают разную ширину, и свойство, на котором
держится R4, исчезает незаметно. Проверка «формат корректен» при этом держится GTIM-4, исчезает незаметно. Проверка «формат корректен» при этом
проходит — отказывает только порядок. проходит — отказывает только порядок.
### R6. Чужой вход нормализуется явно ### GTIM-6. Чужой вход нормализуется явно
**ДОЛЖЕН.** Значение времени, пришедшее не от нашего писателя, приводится **ДОЛЖЕН.** Значение времени, пришедшее не от нашего писателя, приводится
к каноническому виду явно, а не считается каноническим по факту успешного к каноническому виду явно, а не считается каноническим по факту успешного
разбора. разбора.
**Почему.** `time.Parse(time.RFC3339, …)` принимает и доли секунды, и **ПОЧЕМУ.** `time.Parse(time.RFC3339, …)` принимает и доли секунды, и
офсеты, отличные от `Z`, — разбор шире вывода. Канонический вид гарантирует офсеты, отличные от `Z`, — разбор шире вывода. Канонический вид гарантирует
**писатель**, а не читатель; пока писатель один, этого достаточно, но **писатель**, а не читатель; пока писатель один, этого достаточно, но
значение из чужой системы, положенное в базу как пришло, нарушает R4 и значение из чужой системы, положенное в базу как пришло, нарушает GTIM-4 и
обнаруживается не на записи, а на первой сортировке. обнаруживается не на записи, а на первой сортировке. Само решение
«нормализовать, а не отклонять» — базовое (`TIME-13`); здесь —
Go-механика, из-за которой его легко нарушить незаметно.
### R7. В драйвер передаётся строка, а не `time.Time` ### GTIM-7. В драйвер передаётся строка, а не `time.Time`
**СЛЕДУЕТ.** В запрос идёт результат `FormatTime`. **СЛЕДУЕТ.** В запрос идёт результат `FormatTime`.
**Почему.** Колонка — `TEXT`, и передача `time.Time` отдаёт форматирование **ПОЧЕМУ.** Колонка — `TEXT`, и передача `time.Time` отдаёт форматирование
драйверу: появляется вторая точка формата вне `FormatTime` (R2), с драйверу: появляется вторая точка формата вне `FormatTime` (GTIM-2), с
собственным layout, который меняется вместе с версией драйвера, а не вместе собственным layout, который меняется вместе с версией драйвера, а не вместе
с конвенцией. с конвенцией.
### R8. Время в логах приводится к UTC через `ReplaceAttr` ### GTIM-8. Время в логах приводится к UTC через `ReplaceAttr`
**ДОЛЖЕН.** Хендлер `slog` переопределяет атрибут `slog.TimeKey`: **ДОЛЖЕН.** Хендлер `slog` переопределяет атрибут `slog.TimeKey`:
@@ -110,62 +134,59 @@ func utcTime(_ []string, a slog.Attr) slog.Attr {
} }
``` ```
**Почему.** `slog` по умолчанию UTC не даёт: встроенные хендлеры пишут **ПОЧЕМУ.** `slog` по умолчанию UTC не даёт: встроенные хендлеры пишут
время в зоне самого `time.Time`, то есть в локальной зоне процесса — на время в зоне самого `time.Time`, то есть в локальной зоне процесса — на
ноутбуке разработчика логи молча уезжают в `+03:00`. Умолчание тихое, ноутбуке разработчика логи молча уезжают в `+03:00`. Умолчание тихое,
неверная зона выглядит как совершенно валидное время, а записи из разных неверная зона выглядит как совершенно валидное время, а записи из разных
мест перестают складываться в одну хронологию с метками хранилища. мест перестают складываться в одну хронологию с метками хранилища.
### R9. Точность времени в логах отличается от точности в БД ### GTIM-9. Точность времени в логах отличается от точности в БД
**ДОПУСКАЕТСЯ.** `JSONHandler` пишет миллисекунды — три знака, и это не **ДОПУСКАЕТСЯ.** `JSONHandler` пишет миллисекунды — три знака, и это не
приводится к секундной точности R4. приводится к секундной точности GTIM-4.
**Почему.** Явное разрешение нужно, чтобы R4 не читался как требование **ПОЧЕМУ.** Явное разрешение нужно, чтобы GTIM-4 не читался как требование
одной точности везде. Ширина фиксируется на носитель: три знака в логе — одной точности везде. Ширина фиксируется на носитель: три знака в логе —
такая же фиксированная ширина, и свойство, ради которого R4 существует, не такая же фиксированная ширина, и свойство, ради которого GTIM-4 существует, не
нарушено. Общее у лога и базы одно — зона (R8). нарушено. Общее у лога и базы одно — зона (GTIM-8).
### R10. Обёртка измерения длительности берёт `time.Now()` напрямую ### GTIM-10. Обёртка измерения длительности берёт `time.Now()` напрямую
**ДОПУСКАЕТСЯ.** Обёртка вызывает `time.Now()` и считает `time.Since`с **ДОПУСКАЕТСЯ.** Обёртка вызывает `time.Now()` и считает `time.Since`с
локальным `//nolint`. локальным `//nolint`.
**Почему.** `store.Now()` приводит время к UTC через `.UTC()`, а это **ПОЧЕМУ.** `store.Now()` приводит время к UTC через `.UTC()`, а это
срезает монотонную составляющую `time.Time`. Интервал, посчитанный по таким срезает монотонную составляющую `time.Time`. Интервал, посчитанный по таким
меткам, зависит от подводки часов: перевод назад даёт отрицательную меткам, зависит от подводки часов: перевод назад даёт отрицательную
длительность, скачок вперёд — выброс в измерениях, и оба случая длительность, скачок вперёд — выброс в измерениях, и оба случая
невоспроизводимы. Разрешение записано явно, иначе исключение R3.2 читается невоспроизводимы. Разрешение записано явно, иначе исключение GTIM-3.2 читается
как недосмотр и его «чинят». как недосмотр и его «чинят».
### R11. `time/tzdata` импортируется в `main` ### GTIM-11. `time/tzdata` импортируется в `main`
**ДОЛЖЕН.** База зон вшивается в бинарь. **ДОЛЖЕН.** База зон вшивается в бинарь.
**Почему.** Без неё `LoadLocation` зависит от файлов зон в системе, которых **ПОЧЕМУ.** Без неё `LoadLocation` зависит от файлов зон в системе, которых
в минимальном образе нет: отказ происходит в рантайме, на первой же попытке в минимальном образе нет: отказ происходит в рантайме, на первой же попытке
применить зону, — то есть после выкладки, а не на сборке. Импорт именно в применить зону, — то есть после выкладки, а не на сборке. Импорт именно в
`main` держит это решение в одном видимом месте, а не в случайном пакете, `main` держит это решение в одном видимом месте, а не в случайном пакете,
откуда его удаляют при чистке зависимостей. откуда его удаляют при чистке зависимостей.
### R12. Зона отображения применяется только в UI ### GTIM-12. Зона отображения применяется только в UI
**ДОЛЖЕН.** Зона из конфига используется в шаблонах и форматтерах **ДОЛЖЕН.** Зона из конфига используется в шаблонах и форматтерах
представления, но не в хранимых значениях и не в вычислениях. представления, но не в хранимых значениях и не в вычислениях.
**Почему.** Зона отображения — настройка, и её меняют. Протекая в **ПОЧЕМУ.** Зона отображения — настройка, и её меняют. Протекая в
вычисления и хранение, она делает уже записанные данные зависимыми от вычисления и хранение, она делает уже записанные данные зависимыми от
текущего значения настройки: смена зоны задним числом сдвигает границы текущего значения настройки: смена зоны задним числом сдвигает границы
суток у того, что давно посчитано и сохранено. суток у того, что давно посчитано и сохранено.
Календарные вычисления бизнес-логики берут зону явно — как описано в Явную зону в календарных вычислениях требует TIME-12 — это его правило, а не
`arch/time.md`. второе такое же здесь.
<!-- local:механизировано -->
<!-- /local -->
## Связано ## Связано
- `arch/time.md` — базовая конвенция: UTC как формат хранения, явная зона в - базовый слой — UTC как формат хранения, нормализация чужого входа, явная
календарных вычислениях. зона в календарных вычислениях.
- `lang/go/config.md` — валидация зоны отображения загрузчиком конфига. - конвенция `config` — валидация зоны отображения загрузчиком конфига.
@@ -1,11 +1,17 @@
--- ---
topic: app-directories
prefix: ANSD
stack: ansible
extends: arch/app-directories.md extends: arch/app-directories.md
--- ---
# Категории директорий: реализация в Ansible # Категории директорий: реализация в Ansible
Как категории из `arch/app-directories.md` раскладываются на сервере Как категории из базового слоя раскладываются на сервере плейбуком.
плейбуком. Форма записи — `common/language.md`.
Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки
ПОЧЕМУ, ПРИМЕРЫ, МЕХАНИЗИРОВАНО и СНЯТО толкуются как описано в языке
конвенций версии 1 — тогда и только тогда, когда написаны заглавными.
## Область действия ## Область действия
@@ -15,7 +21,7 @@ extends: arch/app-directories.md
## Правила ## Правила
### R1. Каждая директория объявлена переменной `*_dir` ### ANSD-1. Каждая директория объявлена переменной `*_dir`
**ДОЛЖЕН.** Директория приложения объявляется переменной плейбука внутри **ДОЛЖЕН.** Директория приложения объявляется переменной плейбука внутри
`base_dir`, имя оканчивается на `_dir`. Для случая «одна директория на `base_dir`, имя оканчивается на `_dir`. Для случая «одна директория на
@@ -23,28 +29,28 @@ extends: arch/app-directories.md
состоит из нескольких директорий, имя даётся по содержимому (`media_dir`, состоит из нескольких директорий, имя даётся по содержимому (`media_dir`,
`uploads_dir`, `dumps_dir`). `uploads_dir`, `dumps_dir`).
**Почему.** Переменная — единственная ссылка, которую разделяют задача **ПОЧЕМУ.** Переменная — единственная ссылка, которую разделяют задача
создания директории и список бэкапа (R4). Литерал пути в одном из этих мест создания директории и список бэкапа (ANSD-4). Литерал пути в одном из этих мест
означает, что переименование директории молча разойдётся с бэкапом, и означает, что переименование директории молча разойдётся с бэкапом, и
обнаружится это при восстановлении. обнаружится это при восстановлении.
### R2. Директории создаются одной задачей циклом по списку ### ANSD-2. Директории создаются одной задачей циклом по списку
**СЛЕДУЕТ.** Список директорий в единственной задаче создания. **СЛЕДУЕТ.** Список директорий в единственной задаче создания.
**Почему.** Этот список — единственное место, где декларировано всё, что **ПОЧЕМУ.** Этот список — единственное место, где декларировано всё, что
приложение пишет на диск. Разнесённое по нескольким задачам создание приложение пишет на диск. Разнесённое по нескольким задачам создание
отвечает на вопрос «какие директории есть у приложения» только чтением отвечает на вопрос «какие директории есть у приложения» только чтением
всего плейбука, а именно этот вопрос задают при заведении бэкапа и при всего плейбука, а именно этот вопрос задают при заведении бэкапа и при
разборе места на диске. разборе места на диске.
### R3. Владелец директорий — пользователь, от имени которого работает приложение ### ANSD-3. Владелец директорий — пользователь, от имени которого работает приложение
**ДОЛЖЕН.** Конкретная модель — выделенный пользователь на приложение **ДОЛЖЕН.** Конкретная модель — выделенный пользователь на приложение
(`app_owner_uid == app_owner_gid`) или общий `primary_user` — выбирается на (`app_owner_uid == app_owner_gid`) или общий `primary_user` — выбирается на
репозиторий и фиксируется ниже. репозиторий и фиксируется ниже.
**Почему.** Правило про соответствие владельца рантайму, а не про **ПОЧЕМУ.** Правило про соответствие владельца рантайму, а не про
конкретную модель: приложение в контейнере пишет от определённого uid, и конкретную модель: приложение в контейнере пишет от определённого uid, и
если директория принадлежит другому, отказ произойдёт не при деплое, а при если директория принадлежит другому, отказ произойдёт не при деплое, а при
первой записи — то есть после того, как плейбук отчитался об успехе. Выбор первой записи — то есть после того, как плейбук отчитался об успехе. Выбор
@@ -52,71 +58,60 @@ extends: arch/app-directories.md
изоляцией по приложениям решают разные задачи, и навязывать одну модель изоляцией по приложениям решают разные задачи, и навязывать одну модель
обоим значит гарантировать вечное отступление. обоим значит гарантировать вечное отступление.
<!-- local:модель-владельца --> ### ANSD-4. Список бэкапа собирается из тех же переменных
<!-- /local -->
### R4. Список бэкапа собирается из тех же переменных
**ДОЛЖЕН.** Плейбук кладёт в `base_dir` файл `backup-targets`, строки **ДОЛЖЕН.** Плейбук кладёт в `base_dir` файл `backup-targets`, строки
которого ссылаются на переменные `*_dir` из R1, а не на литеральные пути. которого ссылаются на переменные `*_dir` из ANSD-1, а не на литеральные пути.
**Почему.** Правило вывода списка механическое (R5), но применяет его **ПОЧЕМУ.** Правило вывода списка механическое (ANSD-5), но применяет его
человек или шаблон — то есть ошибиться можно. Общая переменная делает целый человек или шаблон — то есть ошибиться можно. Общая переменная делает целый
класс ошибок невозможным: переименовал директорию — переименовалось в класс ошибок невозможным: переименовал директорию — переименовалось в
обоих местах. Независимо набранный список расходится тихо и проявляется в обоих местах. Независимо набранный список расходится тихо и проявляется в
единственный момент, когда это уже неисправимо. единственный момент, когда это уже неисправимо.
### R5. В список бэкапа идут только данные ### ANSD-5. В список бэкапа идут только данные
**ДОЛЖЕН.** Директории категории «данные», включая директорию дампов, — в **ДОЛЖЕН.** Директории категории «данные», включая директорию дампов, — в
списке; конфигурация и кеш — нет. списке; конфигурация и кеш — нет.
**Почему.** Реализация правила базовой конвенции. Кеш раздувает снапшот без **ПОЧЕМУ.** Реализация правила базовой конвенции. Кеш раздувает снапшот без
пользы, а конфигурация содержит отрендеренные секреты — бэкап уезжает в пользы, а конфигурация содержит отрендеренные секреты — бэкап уезжает в
облако, и источником истины для секретов остаётся vault, а не снапшот. облако, и источником истины для секретов остаётся vault, а не снапшот.
### R6. Конфигурация монтируется только на чтение ### ANSD-6. Конфигурация монтируется только на чтение
**СЛЕДУЕТ.** В compose конфигурация подключается с `:ro`. **СЛЕДУЕТ.** В compose конфигурация подключается с `:ro`.
**Почему.** Плейбук — источник истины для конфигурации, и `:ro` превращает **ПОЧЕМУ.** Плейбук — источник истины для конфигурации, и `:ro` превращает
это из договорённости в свойство системы: приложение, которое втихую это из договорённости в свойство системы: приложение, которое втихую
переписывает свой конфиг, падает сразу, а не расходится с репозиторием переписывает свой конфиг, падает сразу, а не расходится с репозиторием
незаметно. Приложение, которому запись в конфиг нужна по устройству, незаметно. Приложение, которому запись в конфиг нужна по устройству,
монтируется на запись — это отступление, и оно записывается. монтируется на запись — это отступление, и оно записывается.
### R7. `docker-compose.yml` лежит в корне `base_dir` ### ANSD-7. `docker-compose.yml` лежит в корне `base_dir`
**ДОЛЖЕН.** Файл не переносится во вложенную директорию. **ДОЛЖЕН.** Файл не переносится во вложенную директорию.
**Почему.** Туда смотрит `project_src` модуля `docker_compose_v2`. Правило **ПОЧЕМУ.** Туда смотрит `project_src` модуля `docker_compose_v2`. Правило
внешнее по происхождению, но нарушается легко — при попытке «навести внешнее по происхождению, но нарушается легко — при попытке «навести
порядок» и убрать compose в `config/`, где ему по смыслу категорий было бы порядок» и убрать compose в `config/`, где ему по смыслу категорий было бы
место. место.
### R8. Секреты рендерятся в файл конфигурации ### ANSD-8. Секреты рендерятся в файл конфигурации
**СЛЕДУЕТ.** Значения приходят из vault-переменных и попадают в файл, **СЛЕДУЕТ.** Значения приходят из vault-переменных и попадают в файл,
принадлежащий пользователю приложения. принадлежащий пользователю приложения.
**Почему.** Файл под `0600` не наследуется дочерними процессами, не виден в **ПОЧЕМУ.** Файл под `0600` не наследуется дочерними процессами, не виден в
`docker inspect` и не оседает в compose-файле на диске. Это те же три `docker inspect` и не оседает в compose-файле на диске. Это те же три
довода, по которым базовая конвенция конфигурации выбирает файл вместо довода, по которым базовая конвенция конфигурации выбирает файл вместо
окружения. окружения.
### R9. Когда приложение не умеет файловые секреты — `environment` под `no_log` ### ANSD-9. Когда приложение не умеет файловые секреты — `environment` под `no_log`
**ДОПУСКАЕТСЯ.** Задача рендера идёт с `no_log: true`. **ДОПУСКАЕТСЯ.** Задача рендера идёт с `no_log: true`.
**Почему.** Явное разрешение нужно, чтобы R8 не читался как запрет на **ПОЧЕМУ.** Явное разрешение нужно, чтобы ANSD-8 не читался как запрет на
деплой такого приложения. Способ вынужденный: секрет попадает в метаданные деплой такого приложения. Способ вынужденный: секрет попадает в метаданные
контейнера и в compose-файл на диске. Приложение, научившееся читать контейнера и в compose-файл на диске. Приложение, научившееся читать
секреты из файла, переводится на R8 при ближайшем касании. секреты из файла, переводится на ANSD-8 при ближайшем касании.
<!-- local:отступления -->
<!-- /local -->
## Связано
<!-- local:связано -->
<!-- /local -->
@@ -1,12 +1,21 @@
---
topic: web-ui
prefix: HTMX
stack: htmx
---
# Веб-UI на htmx # Веб-UI на htmx
Как пишется код веб-UI: частичный своп фрагментов, поллинг живых Как пишется код веб-UI: частичный своп фрагментов, поллинг живых обновлений,
обновлений, обработчики действий, деградация без JS, ошибки. Что именно UI обработчики действий, деградация без JS, ошибки. Что именно UI показывает и
показывает и какие действия поддерживает — в спеках, не здесь. Форма записи какие действия поддерживает — в спеках, не здесь.
`common/language.md`.
Логирование запросов — `lang/go/logging.md` (HTTP-поля, рутинно-частое на Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки
`DEBUG`). Трансляция доменных ошибок наружу — `lang/go/errors.md` ПОЧЕМУ, ПРИМЕРЫ, МЕХАНИЗИРОВАНО и СНЯТО толкуются как описано в языке
конвенций версии 1 — тогда и только тогда, когда написаны заглавными.
Логирование запросов — конвенция `logging` (HTTP-поля, рутинно-частое на
`DEBUG`). Трансляция доменных ошибок наружу — конвенция `errors`
(приватный канал = логи, публичный = сообщение плюс корреляционный ключ). (приватный канал = логи, публичный = сообщение плюс корреляционный ключ).
Здесь — только специфика htmx-транспорта, без дублирования. Здесь — только специфика htmx-транспорта, без дублирования.
@@ -18,80 +27,80 @@
## Стек и границы ## Стек и границы
### R1. Стек: роутер, серверные шаблоны, htmx ### HTMX-1. Стек: роутер, серверные шаблоны, htmx
**ДОЛЖЕН.** UI собирается из серверных шаблонов и htmx — без шага сборки, **ДОЛЖЕН.** UI собирается из серверных шаблонов и htmx — без шага сборки,
без Node и бандлера, без реактивного фреймворка. без Node и бандлера, без реактивного фреймворка.
**Почему.** Шаг сборки — это второй язык, второй менеджер зависимостей и **ПОЧЕМУ.** Шаг сборки — это второй язык, второй менеджер зависимостей и
артефакт, который расходится с исходником; приложению, где разметку целиком артефакт, который расходится с исходником; приложению, где разметку целиком
отдаёт сервер, он не покупает ничего. Реактивный фреймворк добавляет вторую отдаёт сервер, он не покупает ничего. Реактивный фреймворк добавляет вторую
модель состояния рядом с серверной (R2), и дальше на каждом экране модель состояния рядом с серверной (HTMX-2), и дальше на каждом экране
приходится решать, какая из них главная. Сам htmx — вендорный ассет и приходится решать, какая из них главная. Сам htmx — вендорный ассет и
живёт по правилам вендоринга (R32, R33): внешний CDN добавил бы к аптайму живёт по правилам вендоринга (HTMX-32, HTMX-33): внешний CDN добавил бы к
приложения аптайм чужого хоста. аптайму приложения аптайм чужого хоста.
### R2. Клиент не пересчитывает доменное состояние ### HTMX-2. Клиент не пересчитывает доменное состояние
**НЕ ДОЛЖЕН.** Свой JS делает только то, чего серверу знать не нужно **НЕ ДОЛЖЕН.** Свой JS делает только то, чего серверу знать не нужно
(копирование в буфер обмена и подобное); доменное состояние считает сервер, (копирование в буфер обмена и подобное); доменное состояние считает сервер,
клиент свопит присланную разметку. клиент свопит присланную разметку.
**Почему.** Пересчёт на клиенте — вторая реализация той же логики, которую **ПОЧЕМУ.** Пересчёт на клиенте — вторая реализация той же логики, которую
никто не сверяет: расходится она тихо, а проявляется как «на экране одно, в никто не сверяет: расходится она тихо, а проявляется как «на экране одно, в
базе другое». Вдобавок клиентский пересчёт по определению не работает в базе другое». Вдобавок клиентский пересчёт по определению не работает в
деградированном режиме (R11, R12) — значит, серверную версию того же деградированном режиме (HTMX-11, HTMX-12) — значит, серверную версию того же
вычисления всё равно придётся держать. вычисления всё равно придётся держать.
### R3. Реактивный слой вводится отдельным решением ### HTMX-3. Реактивный слой вводится отдельным решением
**НЕ ДОЛЖЕН.** Alpine.js и подобное не появляется попутно с задачей — **НЕ ДОЛЖЕН.** Alpine.js и подобное не появляется попутно с задачей —
только когда есть виджет, которому он действительно нужен, и отдельным только когда есть виджет, которому он действительно нужен, и отдельным
решением. решением.
**Почему.** Реактивный слой, попавший в проект ради одного выпадающего **ПОЧЕМУ.** Реактивный слой, попавший в проект ради одного выпадающего
списка, немедленно доступен всему остальному коду — и граница R1/R2 списка, немедленно доступен всему остальному коду — и граница HTMX-1/HTMX-2
перестаёт держаться сама собой. Отдельное решение — единственный момент, перестаёт держаться сама собой. Отдельное решение — единственный момент,
когда цену видно целиком: она не в килобайтах, а в том, что дальше на когда цену видно целиком: она не в килобайтах, а в том, что дальше на
каждом экране есть выбор между двумя моделями состояния. каждом экране есть выбор между двумя моделями состояния.
## Единый источник разметки ## Единый источник разметки
### R4. Партиал = страница = фрагмент ### HTMX-4. Партиал = страница = фрагмент
**ДОЛЖЕН.** Переиспользуемый кусок разметки — именованный шаблон в **ДОЛЖЕН.** Переиспользуемый кусок разметки — именованный шаблон в
`partials/`, и он же рендерится инлайн на странице и как ответ-фрагмент `partials/`, и он же рендерится инлайн на странице и как ответ-фрагмент
обработчика; отдельной разметки под фрагмент нет. обработчика; отдельной разметки под фрагмент нет.
**Почему.** Две копии одной разметки расходятся молча: правку вносят в ту, **ПОЧЕМУ.** Две копии одной разметки расходятся молча: правку вносят в ту,
что открыта, и страница начинает выглядеть иначе, чем результат свопа того что открыта, и страница начинает выглядеть иначе, чем результат свопа того
же региона. Заметно это становится только на глаз и только тому, кто открыл же региона. Заметно это становится только на глаз и только тому, кто открыл
оба пути подряд. оба пути подряд.
### R5. Корень партиала — элемент с целевым `id` ### HTMX-5. Корень партиала — элемент с целевым `id`
**ДОЛЖЕН.** Корневой узел шаблона несёт тот `id`, по которому адресуют **ДОЛЖЕН.** Корневой узел шаблона несёт тот `id`, по которому адресуют
регион, и ответный фрагмент несёт тот же `id`. регион, и ответный фрагмент несёт тот же `id`.
**Почему.** `hx-swap="outerHTML"` заменяет корневой узел целиком, вместе с **ПОЧЕМУ.** `hx-swap="outerHTML"` заменяет корневой узел целиком, вместе с
его атрибутами. Если пришедший фрагмент несёт другой `id` или не несёт его его атрибутами. Если пришедший фрагмент несёт другой `id` или не несёт его
вовсе, первый своп проходит успешно, а следующее действие и поллер уже не вовсе, первый своп проходит успешно, а следующее действие и поллер уже не
находят таргет: регион застывает без единой ошибки — ни в консоли, ни в находят таргет: регион застывает без единой ошибки — ни в консоли, ни в
логе. логе.
### R6. Сборку view делает общая функция ### HTMX-6. Сборку view делает общая функция
**СЛЕДУЕТ.** Один view-builder зовут и обработчик полной страницы, и **СЛЕДУЕТ.** Один view-builder зовут и обработчик полной страницы, и
htmx-ветка. htmx-ветка.
**Почему.** Общий шаблон (R4) гарантирует одинаковую разметку, но не **ПОЧЕМУ.** Общий шаблон (HTMX-4) гарантирует одинаковую разметку, но не
одинаковые данные: скопированная сборка view расходится по набору полей, и одинаковые данные: скопированная сборка view расходится по набору полей, и
фрагмент начинает показывать не то, что показала бы страница. Это ровно тот фрагмент начинает показывать не то, что показала бы страница. Это ровно тот
класс расхождений, который R4 закрывает для разметки. класс расхождений, который HTMX-4 закрывает для разметки.
## Обработчик действия ## Обработчик действия
### R7. Доменный вызов одинаков для htmx и обычного запроса ### HTMX-7. Доменный вызов одинаков для htmx и обычного запроса
**ДОЛЖЕН.** Обработчик определяет htmx-запрос по заголовку **ДОЛЖЕН.** Обработчик определяет htmx-запрос по заголовку
`HX-Request: true`, зовёт доменную операцию до ветвления и ветвится только `HX-Request: true`, зовёт доменную операцию до ветвления и ветвится только
@@ -99,8 +108,8 @@ htmx-ветка.
| № | Запрос | Ответ | | № | Запрос | Ответ |
|---|---|---| |---|---|---|
| R7.1 | `HX-Request: true` | фрагмент тем же партиалом (R4) по перечитанному состоянию | | HTMX-7.1 | `HX-Request: true` | фрагмент тем же партиалом (HTMX-4) по перечитанному состоянию |
| R7.2 | обычный | PRG-редирект (303) | | HTMX-7.2 | обычный | PRG-редирект (303) |
```go ```go
actionErr := fn(r.Context(), id) // доменный вызов идентичен для htmx и не-htmx actionErr := fn(r.Context(), id) // доменный вызов идентичен для htmx и не-htmx
@@ -117,79 +126,79 @@ if actionErr != nil {
s.render(w, "source_block", view) // фрагмент = тот же шаблон s.render(w, "source_block", view) // фрагмент = тот же шаблон
``` ```
**Почему.** Ветвление до вызова даёт две реализации одного действия, и **ПОЧЕМУ.** Ветвление до вызова даёт две реализации одного действия, и
дальше дефект воспроизводится только на одной поверхности — причём дальше дефект воспроизводится только на одной поверхности — причём
деградированный путь (R11) открывают реже, то есть чинить будут не тот. деградированный путь (HTMX-11) открывают реже, то есть чинить будут не тот.
Перечитанное состояние в htmx-ветке нужно потому, что своп заменяет регион Перечитанное состояние в htmx-ветке нужно потому, что своп заменяет регион
целиком: view, собранный из аргументов запроса, покажет намерение, а не целиком: view, собранный из аргументов запроса, покажет намерение, а не
результат. результат.
### R8. Шаблон рендерится в буфер, потом в ответ ### HTMX-8. Шаблон рендерится в буфер, потом в ответ
**ДОЛЖЕН.** Именованный шаблон собирается целиком в буфер, и только затем **ДОЛЖЕН.** Именованный шаблон собирается целиком в буфер, и только затем
буфер пишется в ответ. буфер пишется в ответ.
**Почему.** Прямая запись в ответ отправляет клиенту статус и часть **ПОЧЕМУ.** Прямая запись в ответ отправляет клиенту статус и часть
разметки раньше, чем шаблон дошёл до ошибки: сообщить об отказе уже нечем, разметки раньше, чем шаблон дошёл до ошибки: сообщить об отказе уже нечем,
а htmx свопит в DOM полученный обрывок. Внешне это «исчезла половина а htmx свопит в DOM полученный обрывок. Внешне это «исчезла половина
региона», и причина по такому симптому не читается. региона», и причина по такому симптому не читается.
## Одно действие — два региона ## Одно действие — два региона
### R9. Второй регион едет тем же ответом через `hx-swap-oob` ### HTMX-9. Второй регион едет тем же ответом через `hx-swap-oob`
**СЛЕДУЕТ.** Когда действие меняет не только свой регион, второй фрагмент **СЛЕДУЕТ.** Когда действие меняет не только свой регион, второй фрагмент
отдаётся в том же ответе с `hx-swap-oob="true"` — обычным именованным отдаётся в том же ответе с `hx-swap-oob="true"` — обычным именованным
партиалом с тем же `id`, что и на странице (R4, R5). партиалом с тем же `id`, что и на странице (HTMX-4, HTMX-5).
**Почему.** Второй запрос с клиента вводит гонку: два ответа считают **ПОЧЕМУ.** Второй запрос с клиента вводит гонку: два ответа считают
состояние в разные моменты и приезжают в произвольном порядке, поэтому состояние в разные моменты и приезжают в произвольном порядке, поэтому
панель действий может отразить состояние до действия. Плюс лишний панель действий может отразить состояние до действия. Плюс лишний
раунд-трип на каждое действие. раунд-трип на каждое действие.
### R10. Отдельный запрос за вторым регионом — когда он обновляется реже действия ### HTMX-10. Отдельный запрос за вторым регионом — когда он обновляется реже действия
**ДОПУСКАЕТСЯ.** `HX-Trigger` в ответе плюс отдельный `hx-get`, если второй **ДОПУСКАЕТСЯ.** `HX-Trigger` в ответе плюс отдельный `hx-get`, если второй
регион меняется не на каждое действие. регион меняется не на каждое действие.
**Почему.** Явное разрешение нужно, чтобы R9 не читался как запрет любого **ПОЧЕМУ.** Явное разрешение нужно, чтобы HTMX-9 не читался как запрет любого
второго запроса. Когда регион обновляется редко, oob-ветка гоняет второго запроса. Когда регион обновляется редко, oob-ветка гоняет
одинаковую разметку на каждое действие и связывает два шаблона там, где одинаковую разметку на каждое действие и связывает два шаблона там, где
связи нет; гонка же тем менее наблюдаема, чем реже обновление. связи нет; гонка же тем менее наблюдаема, чем реже обновление.
## Graceful degradation ## Graceful degradation
### R11. Форма действия работает без JS ### HTMX-11. Форма действия работает без JS
**ДОЛЖЕН.** Действие — обычная `<form method="post" action="…">`, на которую **ДОЛЖЕН.** Действие — обычная `<form method="post" action="…">`, на которую
`hx-post`/`hx-target`/`hx-swap` накладываются сверху; `action` ведёт на `hx-post`/`hx-target`/`hx-swap` накладываются сверху; `action` ведёт на
рабочий обработчик. рабочий обработчик.
**Почему.** htmx может не загрузиться — ошибка вендоринга, блокировщик, **ПОЧЕМУ.** htmx может не загрузиться — ошибка вендоринга, блокировщик,
медленная сеть, — и без рабочего `action` форма в этот момент не отправляет медленная сеть, — и без рабочего `action` форма в этот момент не отправляет
ничего, молча. Тот же `action` — единственное, что делает действие ничего, молча. Тот же `action` — единственное, что делает действие
проверяемым без браузера с JS. проверяемым без браузера с JS.
### R12. Фильтр, поиск и пагинация — серверные ### HTMX-12. Фильтр, поиск и пагинация — серверные
**ДОЛЖЕН.** Отбор списка задаётся GET-параметрами и выполняется на сервере; **ДОЛЖЕН.** Отбор списка задаётся GET-параметрами и выполняется на сервере;
клиентской фильтрации загруженной разметки нет. клиентской фильтрации загруженной разметки нет.
**Почему.** Клиент видит только текущую страницу списка, поэтому клиентский **ПОЧЕМУ.** Клиент видит только текущую страницу списка, поэтому клиентский
фильтр отвечает по неполным данным и делает это молча — результат выглядит фильтр отвечает по неполным данным и делает это молча — результат выглядит
валидным. Вдобавок состояние отбора в query переживает своп (R25) и валидным. Вдобавок состояние отбора в query переживает своп (HTMX-25) и
перезагрузку, его можно послать ссылкой и увидеть в логе. перезагрузку, его можно послать ссылкой и увидеть в логе.
### R13. Область обязательной деградации ### HTMX-13. Область обязательной деградации
**ДОЛЖЕН.** Требование работать без JS распространяется не на весь UI: **ДОЛЖЕН.** Требование работать без JS распространяется не на весь UI:
| № | Поверхность | Поведение без JS | | № | Поверхность | Поведение без JS |
|---|---|---| |---|---|---|
| R13.1 | действия и навигация | работают полностью (R11, R12) | | HTMX-13.1 | действия и навигация | работают полностью (HTMX-11, HTMX-12) |
| R13.2 | интерактивный виджет выбора, у которого нет осмысленного не-JS поведения | может не работать; записывается в отступления | | HTMX-13.2 | интерактивный виджет выбора, у которого нет осмысленного не-JS поведения | может не работать; записывается в отступления |
**Почему.** Без явной границы правило деградации читается как запрет на **ПОЧЕМУ.** Без явной границы правило деградации читается как запрет на
любой JS-виджет — и тогда его либо тихо нарушают, либо отказываются от любой JS-виджет — и тогда его либо тихо нарушают, либо отказываются от
виджета, который был нужен. Запись в отступления держит список честным: виджета, который был нужен. Запись в отступления держит список честным:
видно, какие именно места ломаются с выключенным JS, а не «где-то видно, какие именно места ломаются с выключенным JS, а не «где-то
@@ -197,49 +206,68 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
## Ошибки на htmx-пути ## Ошибки на htmx-пути
### R14. Ошибка действия на htmx-пути — 200 с фрагментом ### HTMX-14. Ошибка действия на htmx-пути — 200 с фрагментом
**ДОЛЖЕН.** Провалившееся действие отдаёт статус 200 и фрагмент с **ДОЛЖЕН.** Провалившееся действие отдаёт статус 200 и фрагмент с
сообщением; доменная ошибка на htmx-пути не транслируется в HTTP-статус. сообщением; доменная ошибка на htmx-пути не транслируется в HTTP-статус.
**Почему.** В htmx 2.x ответы 4xx/5xx по умолчанию не свопят DOM — то есть **ПОЧЕМУ.** В htmx 2.x ответы 4xx/5xx по умолчанию не свопят DOM — то есть
пользователь не увидит ничего. Настроить это можно пользователь не увидит ничего. Своп ошибочных ответов настраивается
(`htmx.config.responseHandling`, расширение `response-targets`, слушатель (`htmx.config.responseHandling`, расширение `response-targets`), но любая
`htmx:responseError`), но любая настройка — свой JS-конфиг на клиенте, и такая настройка — свой JS-конфиг на клиенте, и платится она из HTMX-1 и HTMX-2.
платится она из R1 и R2. Для REST API и не-JS редиректа с `?err=` статус Сообщить о сбое, для которого фрагмента нет вовсе, — отдельная задача, и
по-прежнему используется: там его кто-то читает. её решает глобальный слушатель (HTMX-34). Для REST API и не-JS редиректа с
`?err=` статус по-прежнему используется: там его кто-то читает.
Цена решения: в логе доступа провалившееся действие выглядит как `200`. Цена решения: в логе доступа провалившееся действие выглядит как `200`.
Искать его надо по доменной записи об исходе операции (`lang/go/logging.md`), Искать его надо по доменной записи об исходе операции (конвенция
а не по коду ответа. `logging`), а не по коду ответа.
### R15. Наружу идёт сообщение публичного канала ### HTMX-34. Сбой без ответа-фрагмента показывается глобальным слушателем
**ДОЛЖЕН.** Во фрагмент попадает нейтральный текст по правилам **ДОЛЖЕН.** Один глобальный слушатель `htmx:responseError` и
`lang/go/errors.md`; `err.Error()` в разметку не рендерится. `htmx:sendError` показывает нейтральное сообщение о неудаче запроса; своп
ошибочных ответов в целевые регионы (`htmx.config.responseHandling`,
`response-targets`) не настраивается.
**Почему.** htmx-фрагмент выглядит внутренней деталью приложения, и на нём **ПОЧЕМУ.** HTMX-14 закрывает доменный отказ, до которого обработчик дошёл.
Паника, сбой шаблона и обрыв сети отдают 5xx или ничего, htmx 2.x такое не
свопит — регион не меняется, интерфейс замирает без единого признака сбоя,
и пользователь повторяет действие, которое могло уже примениться. Слушатель
— несколько строк без доменного состояния, то есть внутри границы HTMX-2, и он
не спорит с HTMX-14: там настройки отвергнуты как замена фрагменту, который
обработчик в состоянии отдать, а здесь фрагмента нет по определению. Своп
тела ошибки в целевой регион стоил бы дороже: страница 500 не несёт
целевого `id`, и после первого же такого свопа регион перестаёт находиться
(HTMX-5).
### HTMX-15. Наружу идёт сообщение публичного канала
**ДОЛЖЕН.** Во фрагмент попадает нейтральный текст публичного канала;
`err.Error()` в разметку не рендерится.
**ПОЧЕМУ.** htmx-фрагмент выглядит внутренней деталью приложения, и на нём
легче всего забыть, что это тот же публичный канал, что и страница: легче всего забыть, что это тот же публичный канал, что и страница:
разметка уезжает в браузер пользователя целиком. Статус 200 (R14) разметка уезжает в браузер пользователя целиком. Статус 200 (HTMX-14)
дополнительно снимает ощущение «это ошибочный ответ, его никто не увидит». дополнительно снимает ощущение «это ошибочный ответ, его никто не увидит».
### R16. Сообщение об ошибке — в отдельном поле view ### HTMX-16. Сообщение об ошибке — в отдельном поле view
**ДОЛЖЕН.** У view есть поле под ошибку действия; доменные поля под **ДОЛЖЕН.** У view есть поле под ошибку действия; доменные поля под
сообщение не переиспользуются. сообщение не переиспользуются.
**Почему.** У доменного поля может быть своё непустое значение, и сообщение **ПОЧЕМУ.** У доменного поля может быть своё непустое значение, и сообщение
его перекроет: пользователь получит текст ошибки вместо данных, а шаблон — его перекроет: пользователь получит текст ошибки вместо данных, а шаблон —
необходимость угадывать, что сейчас лежит в поле. Отдельное поле делает оба необходимость угадывать, что сейчас лежит в поле. Отдельное поле делает оба
состояния — данные и ошибку — выразимыми одновременно, а это ровно то, чего состояния — данные и ошибку — выразимыми одновременно, а это ровно то, чего
требует R17. требует HTMX-17.
### R17. При ошибке активное состояние не меняется ### HTMX-17. При ошибке активное состояние не меняется
**НЕ ДОЛЖЕН.** Фрагмент, отданный после неудачного действия, показывает **НЕ ДОЛЖЕН.** Фрагмент, отданный после неудачного действия, показывает
прежний выбор плюс сообщение. прежний выбор плюс сообщение.
**Почему.** Своп заменяет регион целиком, поэтому фрагмент — единственное, **ПОЧЕМУ.** Своп заменяет регион целиком, поэтому фрагмент — единственное,
что пользователь узнает о состоянии. Показав намеренное состояние вместо что пользователь узнает о состоянии. Показав намеренное состояние вместо
фактического, интерфейс расходится с сервером, и следующее действие человек фактического, интерфейс расходится с сервером, и следующее действие человек
делает по ложной картине — на сервере оно применится к другому объекту. делает по ложной картине — на сервере оно применится к другому объекту.
@@ -257,70 +285,70 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
</div>{{end}} </div>{{end}}
``` ```
### R18. Поллер самозавершается ### HTMX-18. Поллер самозавершается
**ДОЛЖЕН.** Когда состояние вышло из «живого», фрагмент возвращается без **ДОЛЖЕН.** Когда состояние вышло из «живого», фрагмент возвращается без
`hx-*`-атрибутов. `hx-*`-атрибутов.
**Почему.** Иначе опрос не прекращается никогда: каждая открытая вкладка **ПОЧЕМУ.** Иначе опрос не прекращается никогда: каждая открытая вкладка
держит постоянный поток запросов за неизменными данными, и закрывает его держит постоянный поток запросов за неизменными данными, и закрывает его
только пользователь. Условие остановки живёт в разметке ответа, потому что только пользователь. Условие остановки живёт в разметке ответа, потому что
это единственный канал, которым сервер управляет поллером. это единственный канал, которым сервер управляет поллером.
Встроенная альтернатива — ответ со статусом 286 — не используется: она не Встроенная альтернатива — ответ со статусом 286 — не используется: она не
совместима с R4, ведь свежезагруженная страница рендерится тем же партиалом совместима с HTMX-4, ведь свежезагруженная страница рендерится тем же партиалом
и тоже без поллера. и тоже без поллера.
### R19. Условие живости ведёт собственное состояние приложения ### HTMX-19. Условие живости ведёт собственное состояние приложения
**ДОЛЖЕН.** Признак «живо ли ещё» вычисляется по состоянию, которым владеет **ДОЛЖЕН.** Признак «живо ли ещё» вычисляется по состоянию, которым владеет
приложение, а не по ответу внешнего сервиса. приложение, а не по ответу внешнего сервиса.
**Почему.** Внешний сервис отвечает не всегда и не одинаково: на его **ПОЧЕМУ.** Внешний сервис отвечает не всегда и не одинаково: на его
недоступности поллер либо останавливается, пока работа идёт, либо не недоступности поллер либо останавливается, пока работа идёт, либо не
останавливается никогда. Приложение — единственный участник, который знает останавливается никогда. Приложение — единственный участник, который знает
про операцию всё и может ответить на каждом тике. про операцию всё и может ответить на каждом тике.
### R20. Поллер свопит фрагмент целиком через `outerHTML` ### HTMX-20. Поллер свопит фрагмент целиком через `outerHTML`
**ДОЛЖЕН.** Тик заменяет весь фрагмент (`hx-swap="outerHTML"`), а не его **ДОЛЖЕН.** Тик заменяет весь фрагмент (`hx-swap="outerHTML"`), а не его
содержимое. содержимое.
**Почему.** `outerHTML` удаляет старый узел вместе с его `hx-trigger` и **ПОЧЕМУ.** `outerHTML` удаляет старый узел вместе с его `hx-trigger` и
инициализирует новый — так поллер живёт ровно в одном экземпляре и так же инициализирует новый — так поллер живёт ровно в одном экземпляре и так же
выключается (R18). Своп содержимого оставил бы старый узел с его таймером, выключается (HTMX-18). Своп содержимого оставил бы старый узел с его таймером,
и через несколько обновлений опрос шёл бы в несколько потоков. Работает это и через несколько обновлений опрос шёл бы в несколько потоков. Работает это
при совпадении корневого `id` (R5). при совпадении корневого `id` (HTMX-5).
### R21. Поллер не свопит контейнер с активными полями ввода ### HTMX-21. Поллер не свопит контейнер с активными полями ввода
**НЕ ДОЛЖЕН.** Живость включается только в тех состояниях фрагмента, где **НЕ ДОЛЖЕН.** Живость включается только в тех состояниях фрагмента, где
редактировать нечего. редактировать нечего.
**Почему.** Своп поддерева теряет фокус, выделение и незасабмиченный текст **ПОЧЕМУ.** Своп поддерева теряет фокус, выделение и незасабмиченный текст
внутри него. У поллера это происходит по таймеру, то есть в момент, который внутри него. У поллера это происходит по таймеру, то есть в момент, который
пользователь не выбирал: текст исчезает посреди набора и воспроизводится пользователь не выбирал: текст исчезает посреди набора и воспроизводится
как «приложение стирает мой ввод». как «приложение стирает мой ввод».
### R22. Браузер не ходит во внешний сервис напрямую ### HTMX-22. Браузер не ходит во внешний сервис напрямую
**НЕ ДОЛЖЕН.** Поллинг и прочие запросы страницы идут на свой сервер. **НЕ ДОЛЖЕН.** Поллинг и прочие запросы страницы идут на свой сервер.
**Почему.** Прямой запрос из браузера выносит наружу адрес и учётные данные **ПОЧЕМУ.** Прямой запрос из браузера выносит наружу адрес и учётные данные
внешнего сервиса и делает страницу заложником его CORS-политики. Вдобавок внешнего сервиса и делает страницу заложником его CORS-политики. Вдобавок
контракт внешнего сервиса протекает в разметку: его смена перестаёт быть контракт внешнего сервиса протекает в разметку: его смена перестаёт быть
серверным изменением. серверным изменением.
### R23. Источник данных для тика ### HTMX-23. Источник данных для тика
**ДОЛЖЕН.** Тик читает данные там, где они уже есть, не ходя в сеть: **ДОЛЖЕН.** Тик читает данные там, где они уже есть, не ходя в сеть:
| № | Что показывает тик | Откуда берёт | | № | Что показывает тик | Откуда берёт |
|---|---|---| |---|---|---|
| R23.1 | состояние внешнего сервиса | in-memory снимок, обновляемый воркером | | HTMX-23.1 | состояние внешнего сервиса | in-memory снимок, обновляемый воркером |
| R23.2 | собственное состояние приложения | своё хранилище; снимок не требуется | | HTMX-23.2 | собственное состояние приложения | своё хранилище; снимок не требуется |
**Почему.** Тик умножается на число открытых вкладок, поэтому сеть на **ПОЧЕМУ.** Тик умножается на число открытых вкладок, поэтому сеть на
каждом тике превращает интерфейс в генератор нагрузки на внешний сервис — и каждом тике превращает интерфейс в генератор нагрузки на внешний сервис — и
его недоступность становится недоступностью страницы. Снимок разрывает эту его недоступность становится недоступностью страницы. Снимок разрывает эту
связь: частоту обращений к внешнему сервису задаёт воркер, а не связь: частоту обращений к внешнему сервису задаёт воркер, а не
@@ -329,7 +357,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
собственного состояния той же цены нет: хранилище и так своё, а лишний слой собственного состояния той же цены нет: хранилище и так своё, а лишний слой
кеша добавил бы только рассинхрон. кеша добавил бы только рассинхрон.
### R24. Поллинг URL страницы вместо отдельного фрагмент-роута ### HTMX-24. Поллинг URL страницы вместо отдельного фрагмент-роута
**ДОПУСКАЕТСЯ.** Когда живой фрагмент — почти вся страница, `hx-get` идёт **ДОПУСКАЕТСЯ.** Когда живой фрагмент — почти вся страница, `hx-get` идёт
на URL самой страницы, а нужный узел вырезается `hx-select`: на URL самой страницы, а нужный узел вырезается `hx-select`:
@@ -339,121 +367,131 @@ hx-get="/item/{{.ID}}" hx-trigger="every 3s"
hx-select="#item-main" hx-swap="outerHTML" hx-select="#item-main" hx-swap="outerHTML"
``` ```
**Почему.** Отдельный `/fragments/…`-роут в этом случае дублирует **ПОЧЕМУ.** Отдельный `/fragments/…`-роут в этом случае дублирует
обработчик страницы целиком — вместе с перечитыванием состояния и сборкой обработчик страницы целиком — вместе с перечитыванием состояния и сборкой
view, — и дальше два обработчика расходятся по тому же сценарию, что и две view, — и дальше два обработчика расходятся по тому же сценарию, что и две
копии разметки (R4). копии разметки (HTMX-4).
Инвариант корневого `id` (R5) действует и здесь: `hx-select` выбирает тот Инвариант корневого `id` (HTMX-5) действует и здесь: `hx-select` выбирает тот
же узел, который свопится. же узел, который свопится.
## Своп и выход со страницы ## Своп и выход со страницы
### R25. Действие не уводит со страницы, если предмет остаётся на ней ### HTMX-25. Действие не уводит со страницы, если предмет остаётся на ней
**НЕ ДОЛЖЕН.** Такое действие свопит свой регион на месте. **НЕ ДОЛЖЕН.** Такое действие свопит свой регион на месте.
**Почему.** Своп сохраняет прокрутку и не трогает серверные фильтр, поиск и **ПОЧЕМУ.** Своп сохраняет прокрутку и не трогает серверные фильтр, поиск и
пагинацию — они в query (R12). Полная навигация ради изменения одного пагинацию — они в query (HTMX-12). Полная навигация ради изменения одного
региона возвращает пользователя в начало списка и стоит перерисовки всей региона возвращает пользователя в начало списка и стоит перерисовки всей
страницы. Не сохраняется при свопе только контекст внутри самого страницы. Не сохраняется при свопе только контекст внутри самого
заменяемого поддерева — фокус, выделение, введённый текст (R21). заменяемого поддерева — фокус, выделение, введённый текст (HTMX-21).
### R26. Выход со страницы — форма без `hx-*` ### HTMX-26. Выход со страницы — форма без `hx-*`
**ДОЛЖЕН.** Действие, после которого предмет покидает страницу, остаётся **ДОЛЖЕН.** Действие, после которого предмет покидает страницу, остаётся
обычной POST-формой без htmx-атрибутов, то есть полной навигацией. обычной POST-формой без htmx-атрибутов, то есть полной навигацией.
**Почему.** Своп для такого действия оставил бы на месте регион, **ПОЧЕМУ.** Своп для такого действия оставил бы на месте регион,
описывающий объект, которого на странице больше нет. Отсутствие описывающий объект, которого на странице больше нет. Отсутствие
htmx-атрибутов при этом само работает маркером «это выход»: намерение видно htmx-атрибутов при этом само работает маркером «это выход»: намерение видно
прямо в разметке, и не нужен `HX-Redirect` — то есть ещё один способ прямо в разметке, и не нужен `HX-Redirect` — то есть ещё один способ
сменить страницу, существующий только на htmx-пути. сменить страницу, существующий только на htmx-пути.
### R27. Асинхронное действие свопит промежуточное состояние ### HTMX-27. Асинхронное действие свопит промежуточное состояние
**ДОЛЖЕН.** Если работу доделывает воркер, ответ на действие показывает **ДОЛЖЕН.** Если работу доделывает воркер, ответ на действие показывает
промежуточное состояние, а итог догоняет самозавершающийся поллер (R18). промежуточное состояние, а итог догоняет самозавершающийся поллер (HTMX-18).
**Почему.** Мнимый результат расходится с сервером до следующего тика, и **ПОЧЕМУ.** Мнимый результат расходится с сервером до следующего тика, и
всё это время пользователь принимает решения по несуществующему исходу — всё это время пользователь принимает решения по несуществующему исходу —
включая повтор действия, которое на самом деле выполняется. Промежуточное включая повтор действия, которое на самом деле выполняется. Промежуточное
состояние вдобавок объясняет, почему регион продолжает обновляться сам. состояние вдобавок объясняет, почему регион продолжает обновляться сам.
## Различение поверхности одного действия ## Различение поверхности одного действия
### R28. Поверхность различается скрытым полем формы ### HTMX-28. Поверхность различается скрытым полем формы
**ДОЛЖЕН.** Когда один роут зовут с разных страниц и своп-ответ различается **ДОЛЖЕН.** Когда один роут зовут с разных страниц и своп-ответ различается
фрагментом, поверхность передаётся явным скрытым полем фрагментом, поверхность передаётся явным скрытым полем
(`surface=list|detail`), а не выводится из `HX-Target` или `Referer`. (`surface=list|detail`), а не выводится из `HX-Target` или `Referer`.
**Почему.** `HX-Target` — свойство разметки вызывающей страницы, `Referer` **ПОЧЕМУ.** `HX-Target` — свойство разметки вызывающей страницы, `Referer`
может не прийти вовсе; и то и другое меняется без участия обработчика, и может не прийти вовсе; и то и другое меняется без участия обработчика, и
ответ начинает приходить не тем фрагментом. Поле же видно в форме рядом с ответ начинает приходить не тем фрагментом. Поле же видно в форме рядом с
действием, поэтому связь «эта страница → этот фрагмент» читается там, где действием, поэтому связь «эта страница → этот фрагмент» читается там, где
её заводят. её заводят.
### HTMX-35. Запрос без поля поверхности получает 400
**ДОЛЖЕН.** Обработчик, различающий поверхности (HTMX-28), отвечает статусом
400, когда поля `surface` в запросе нет; поверхность по умолчанию не
выбирается.
**ПОЧЕМУ.** Поле кладёт в форму наш же шаблон, поэтому его отсутствие —
дефект формы, а не вход пользователя. Поверхность по умолчанию маскирует
такой дефект молча неверным фрагментом: своп с чужим `id` проходит, после
чего регион перестаёт находиться таргетом (HTMX-5), и ошибка воспроизводится
как «интерфейс иногда застывает». 400 не свопится и всплывает сообщением
глобального слушателя (HTMX-34) — сразу и на той странице, где форму сломали.
Вкладка, открытая до появления поля, получает тот же 400 и чинится
перезагрузкой; это дешевле, чем молча неверный фрагмент в актуальной
разметке.
## Статика, вендоринг, кэш ## Статика, вендоринг, кэш
Раздел не про htmx — это упаковка любого server-rendered приложения; Раздел не про htmx — это упаковка любого server-rendered приложения;
разъедется в языковой слой, когда понадобится там. разъедется в языковой слой, когда понадобится там.
### R29. Ассеты встроены в бинарь и отдаются иммутабельным кэшем ### HTMX-29. Ассеты встроены в бинарь и отдаются иммутабельным кэшем
**ДОЛЖЕН.** Статика подключается через `go:embed` и отдаётся с **ДОЛЖЕН.** Статика подключается через `go:embed` и отдаётся с
`Cache-Control: public, max-age=31536000, immutable`. `Cache-Control: public, max-age=31536000, immutable`.
**Почему.** Встроенные ассеты делают деплой одним артефактом: нет второго **ПОЧЕМУ.** Встроенные ассеты делают деплой одним артефактом: нет второго
шага раскладки файлов, который может отстать от бинаря и оставить новую шага раскладки файлов, который может отстать от бинаря и оставить новую
разметку со старым css. Иммутабельный кэш безопасен ровно потому, что URL разметку со старым css. Иммутабельный кэш безопасен ровно потому, что URL
меняется вместе с содержимым (R30, R31); без этого условия год кэша был бы меняется вместе с содержимым (HTMX-30, HTMX-31); без этого условия год кэша
способом навсегда закрепить у пользователя старый файл. был бы способом навсегда закрепить у пользователя старый файл.
### R30. Меняемые ассеты версионируются хешем содержимого ### HTMX-30. Меняемые ассеты версионируются хешем содержимого
**ДОЛЖЕН.** css и js адресуются с `?v=<короткий sha256 содержимого>`, и URL **ДОЛЖЕН.** css и js адресуются с `?v=<короткий sha256 содержимого>`, и URL
строит хелпер шаблона. строит хелпер шаблона.
**Почему.** Хеш содержимого — единственная версия, которую невозможно **ПОЧЕМУ.** Хеш содержимого — единственная версия, которую невозможно
забыть обновить: она меняется от самой правки. Ручной номер и дата сборки забыть обновить: она меняется от самой правки. Ручной номер и дата сборки
от этого не защищают, а цена промаха при иммутабельном кэше (R29) — от этого не защищают, а цена промаха при иммутабельном кэше (HTMX-29) —
устаревший файл у пользователя до ручной очистки кэша. Хелпер нужен, чтобы устаревший файл у пользователя до ручной очистки кэша. Хелпер нужен, чтобы
хеш не проставляли в каждом шаблоне руками. хеш не проставляли в каждом шаблоне руками.
### R31. Вендорный ассет в `?v=` не нуждается ### HTMX-31. Вендорный ассет в `?v=` не нуждается
**ДОПУСКАЕТСЯ.** Вендор адресуется по неизменному имени файла, без **ДОПУСКАЕТСЯ.** Вендор адресуется по неизменному имени файла, без
параметра версии. параметра версии.
**Почему.** Содержимое под этим именем не меняется: обновление вендора **ПОЧЕМУ.** Содержимое под этим именем не меняется: обновление вендора
приходит новым именем файла, то есть новым URL. Кэш-бастер защищает от приходит новым именем файла, то есть новым URL. Кэш-бастер защищает от
подмены содержимого под тем же адресом, а такой ситуации здесь нет — и подмены содержимого под тем же адресом, а такой ситуации здесь нет — и
явное разрешение снимает вопрос, не нарушает ли это R30. явное разрешение снимает вопрос, не нарушает ли это HTMX-30.
### R32. Вендор не коммитится, а добывается по манифесту ### HTMX-32. Вендор не коммитится, а добывается по манифесту
**ДОЛЖЕН.** Идемпотентная задача скачивает вендорные файлы по манифесту **ДОЛЖЕН.** Идемпотентная задача скачивает вендорные файлы по манифесту
(`путь url sha256`) с проверкой контрольной суммы; сборка зависит от этой (`путь url sha256`) с проверкой контрольной суммы; сборка зависит от этой
задачи. задачи.
**Почему.** Манифест делает версию и происхождение ассета видимыми в **ПОЧЕМУ.** Манифест делает версию и происхождение ассета видимыми в
diff'е — у закоммиченного минифицированного файла обновление выглядит diff'е — у закоммиченного минифицированного файла обновление выглядит
стеной непрозрачных изменений, и подмену в ней не разглядеть. Sha256 — стеной непрозрачных изменений, и подмену в ней не разглядеть. Sha256 —
единственная проверка, что скачали то же самое, что проверяли; зависимость единственная проверка, что скачали то же самое, что проверяли; зависимость
сборки от задачи не даёт собраться без ассета в свежем клоне. сборки от задачи не даёт собраться без ассета в свежем клоне.
### R33. Шрифты и скрипты — self-hosted ### HTMX-33. Шрифты и скрипты — self-hosted
**ДОЛЖЕН.** Внешних хостов во время выполнения нет. **ДОЛЖЕН.** Внешних хостов во время выполнения нет.
**Почему.** Каждый внешний хост — это чужой аптайм внутри своей страницы и **ПОЧЕМУ.** Каждый внешний хост — это чужой аптайм внутри своей страницы и
третья сторона, видящая каждый запрос пользователя. Самодостаточный бинарь третья сторона, видящая каждый запрос пользователя. Самодостаточный бинарь
вдобавок разворачивается в сети без выхода наружу, где CDN просто не вдобавок разворачивается в сети без выхода наружу, где CDN просто не
отвечает. отвечает.
<!-- local:эталоны -->
<!-- /local -->
<!-- local:отступления -->
<!-- /local -->