конвенции отделены от обвязки

- сами конвенции переехали в conventions/, описательное — в корень:
  LANGUAGE.md (язык записи) и GUIDE.md (как ведут конвенции)
- conv синхронизирует только conventions/, пути в origin даются
  относительно неё — раскладка копий в репозиториях не меняется
This commit is contained in:
av
2026-07-25 19:23:32 +03:00
parent 62c5645dd4
commit 0842850fae
16 changed files with 42 additions and 23 deletions
+154
View File
@@ -0,0 +1,154 @@
# Категории директорий приложения
Всё, что приложение пишет на диск, делится на три категории по принципу
создания и ценности содержимого: конфигурация, данные, кеш. Категория сразу
отвечает на два вопроса, которые иначе выясняются чтением кода приложения:
**кто создаёт** содержимое и **что будет, если его потерять**. Из категорий
механически выводится состав бэкапа. Форма записи — `LANGUAGE.md`.
## Область действия
Раскладка меняется вместе с миграцией данных, поэтому правила
распространяются на **новые приложения**; существующие переезжают по мере
касания, отдельной кампанией не переписываются. Разделять данные и кеш
задним числом имеет смысл тогда, когда кеш заметен по объёму в бэкапе, а не
ради самой схемы.
## Правила
### R1. Записываемые пути разложены по трём категориям
**ДОЛЖЕН.** Каждая директория, в которую пишет приложение или деплой,
относится к одной из трёх категорий:
| № | Категория | Директория | Создаёт | Потеря содержимого | В бэкапе |
|---|---|---|---|---|---|
| R1.1 | конфигурация | `config/` | деплой | восстанавливается прогоном деплоя | нет |
| R1.2 | данные | `data/` | приложение | невосполнима | да |
| R1.3 | кеш | `cache/` | приложение | приложение перегенерирует | нет |
Имена в таблице — умолчание для случая «одна директория на категорию».
**Почему.** Все дальнейшие решения — что попадает в бэкап (R4), что можно
снести при нехватке места, что переживает переезд на другой диск —
читаются из категории, а не выясняются по коду приложения. Без единой
классификации каждое такое решение принимается заново и каждый раз чуть
по-другому, а цена ошибки несимметрична: лишний кеш в снапшоте стоит места,
потерянные данные не стоят ничего, потому что их больше нет.
### R2. Категория может состоять из нескольких директорий
**ДОПУСКАЕТСЯ.** Директорий в категории столько, сколько нужно раскладке;
принадлежность к категории задаётся не именем, а участием в списке бэкапа.
**Почему.** Явное разрешение снимает вопрос, не читается ли R1 как «ровно
три директории». Крупные файлы отделяют от базы, чтобы двигать их между
дисками независимо (`media/`, `uploads/` — та же категория «данные», что и
`data/`); запрет на такое деление вынуждал бы либо держать всё на одном
диске, либо выводить директорию из-под категорий вовсе. Категорию нельзя
задавать именем ровно поэтому: имён в категории несколько, и выбираются они
по содержимому.
### R3. Данные и кеш разделяются по тесту на пересоздание
**ДОЛЖЕН.** Записываемый путь относят к данным или к кешу по содержимому:
| № | Что лежит | Категория |
|---|---|---|
| R3.1 | база, загруженные файлы, сгенерированные артефакты | данные |
| R3.2 | миниатюры, распакованные ассеты, кеш внешних ответов, перестраиваемые индексы | кеш |
| R3.3 | спорный случай | `rm -rf` и запуск приложения заново: поднялось и наверстало само — кеш; не поднялось или поднялось пустым — данные |
**Почему.** Без внешнего теста граница проводится по ощущению «жалко
потерять», а оно смещено в одну сторону: дорогой в пересборке кеш
переезжает в данные и раздувает каждый снапшот. Дорого ≠ невосполнимо, и
разделяет эти два свойства именно способность приложения пересоздать
содержимое. Обратная ошибка — данные, названные кешем, — тестом
обнаруживается в тот же момент, но дёшево: при попытке пересоздать, а не
при попытке восстановить.
### R4. В бэкап идут данные, и только они
**ДОЛЖЕН.** Директории категории «данные» — в списке бэкапа; конфигурация и
кеш — нет.
**Почему.** Кеш раздувает снапшот содержимым, которое приложение
восстановит само. Конфигурацию бэкапить не только незачем, но и вредно: там
лежат секреты, а бэкапы уезжают в облако — источник истины для
конфигурации остаётся в репозитории и хранилище секретов, а не в снапшоте.
Ошибка в другую сторону дороже: директория данных, не попавшая в список,
обнаруживается в единственный момент, когда исправить её уже нечем.
### R5. Список бэкапа ссылается на те же пути, что и создание директорий
**ДОЛЖЕН.** Список выводится из категорий по R4 и ссылается на те же
объявления путей, по которым директории создаются, а не набирается
независимо.
**Почему.** Правило вывода механическое, но применяет его человек или
шаблон — то есть ошибиться можно. Общая ссылка делает целый класс ошибок
невозможным: переименование директории отражается в обоих местах сразу.
Независимо набранный список расходится тихо — ни деплой, ни прогон бэкапа
на это не жалуются, — и расхождение между тем, что бэкапится, и тем, что
нужно, проявляется при восстановлении.
### R6. Способ попадания данных в бэкап зависит от того, кто отвечает за консистентность
**ДОЛЖЕН.** Способ выбирается по тому, самодостаточны ли файлы на диске:
| № | Данные | В бэкап |
|---|---|---|
| R6.1 | файлы самодостаточны на любой момент времени | копированием |
| R6.2 | консистентность обеспечивает только сама СУБД | дампом: бэкапится директория дампов, сырой каталог базы — нет |
**Почему.** Файловый снапшот работающей СУБД не гарантирует
консистентности: скопированный каталог может не восстановиться, и узнают
об этом при восстановлении. Директория дампов — тоже данные, просто
производные, поэтому R4 покрывает её без оговорок. Сырой каталог базы из
списка при этом исключается: он удваивает объём снапшота и добавляет к
надёжной копии заведомо ненадёжную.
### R7. Способ выбирается при заведении приложения
**ДОЛЖЕН.** Решение «копировать или дампить» (R6) принимается, когда
приложение заводят.
**Почему.** Неверный выбор ничем себя не проявляет, пока бэкап не
понадобился: прогоны копирования проходят успешно и накапливают снапшоты, из
которых база не поднимется. Отложить решение — значит принять его по факту
первой неудачной попытки восстановления, то есть тогда, когда данных уже
нет.
### R8. Приложение разводит записываемые пути по категориям
**ДОЛЖЕН.** Конфигурация приложения задаёт отдельные пути для данных и для
кеша, а не один каталог на всё.
**Почему.** Снаружи категория определяется только тогда, когда разным
категориям соответствуют разные директории. Всё, сложенное в один каталог,
заставляет составлять список бэкапа вручную, читая код приложения, — и
пересматривать его при каждом обновлении, потому что новый подкаталог
появляется молча. Приложение, которое не умеет разделять, тем самым
дефектно; раскладка под этот дефект не подстраивается.
### R9. Приложение не пишет в директорию конфигурации
**НЕ ДОЛЖЕН.** Записываемые пути приложения не указывают внутрь
конфигурации.
**Почему.** Конфигурация восстанавливается прогоном деплоя (R1.1), поэтому
всё, что приложение туда записало, следующий деплой затирает без
предупреждения. Вдобавок директория конфигурации может быть подключена
только на чтение — тогда запись отказывает в рантайме. Ни то ни другое не
видно в момент, когда приложение настраивают.
<!-- local:отступления -->
<!-- /local -->
## Связано
<!-- local:эталон -->
<!-- /local -->
<!-- local:связано -->
<!-- /local -->
+258
View File
@@ -0,0 +1,258 @@
# Конфигурация приложения
Как устроена конфигурация: где лежит, как попадает в процесс, что с
секретами и когда падает. Форма записи — `LANGUAGE.md`.
## Область действия
Правила написаны для приложений, которые мы пишем сами: только там мы
управляем тем, как конфигурация читается. Сторонний образ, живущий на
переменных окружения, вне области действия — это не повод отказываться от
конвенции для своих приложений.
## Правила
### R1. Конфигурация — файл, а не окружение
**ДОЛЖЕН.** Приложение читает параметры из файла конфигурации; переменные
окружения источником конфигурации не служат.
**Почему.** Три довода, по убыванию веса:
- **Один типизированный источник.** Файл несёт секции, комментарии,
единицы измерения и валидируется целиком. Окружение — плоский набор
нетипизированных строк, который приходится документировать отдельно;
появление второго канала конфигурации гарантирует расхождение между ними.
- **Окружение наследуется дочерними процессами.** Всё, что приложение
запускает — конвертер, `git`, шелл-хук, — по умолчанию получает копию
секретов, хотя они ему не нужны.
- **В контейнере окружение расползается по лишним поверхностям.**
`docker inspect` показывает его любому, у кого есть доступ к сокету
докера; переменные оседают в compose-файле и `.env` на диске — то есть
файл всё равно появляется, только без структуры и валидации.
Обратите внимание, чего в этих доводах **нет**: `/proc/<pid>/environ` не
является аргументом — он имеет права `0400` и защищён проверкой
`PTRACE_MODE_READ`, то есть доступен ровно тому же кругу, что и файл под
`0600`.
### R2. Формат конфигурации — текстовый, с секциями и комментариями
**СЛЕДУЕТ.** Конкретный формат (TOML, YAML) выбирается по стеку.
**Почему.** Комментарий у каждого поля (R9) — часть того, ради чего конфиг
вообще читают; формат, в котором комментарий негде разместить, делает R9
невыполнимым. Секции дают структуру, которую валидатор проверяет целиком, —
плоский список пар такой возможности не даёт и возвращает нас к тем же
свойствам, из-за которых отвергнуто окружение (R1).
### R3. Имя файла фиксировано, путь переопределяется опцией
**СЛЕДУЕТ.** Имя по умолчанию ищется в рабочей директории процесса, а путь
задаётся опцией командной строки.
**Почему.** Запуск без аргументов работает одинаково в разработке, в
контейнере и на сервере, и способ запуска не приходится помнить отдельно
для каждой среды. Опция нужна ровно для случаев, когда конфигов несколько
(тесты, второй инстанс): без неё их разводят переменной окружения — тем
самым каналом, который закрывает R1.
### R4. В репозитории лежит образец, а не рабочий конфиг
**НЕ ДОЛЖЕН.** Реальный конфиг не коммитится; в репозитории — образец.
**Почему.** Рабочий конфиг содержит отрендеренные секреты (R12), а секрет,
попавший в историю, чинится ротацией, а не удалением файла. Кроме того,
закоммиченный конфиг конкретной среды становится вторым источником истины:
он расходится с тем, что реально развёрнуто, и расходится молча.
### R5. Конфиг разбирается один раз при старте
**ДОЛЖЕН.** Разбор — при старте, в одну типизированную структуру; чтения
файла конфигурации в бизнес-коде нет.
**Почему.** Второе место чтения — это второй момент времени: две части кода
начинают видеть разные значения одного параметра, и расхождение не
воспроизводится, потому что зависит от того, когда файл потрогали.
Типизированная структура вдобавок переносит ошибку формата в старт (R17),
где она видна сразу, а не в первый вызов ветки, которая это поле читает.
### R6. Конфиг неизменяем после старта
**ДОЛЖЕН.** Смена параметров — рестарт процесса.
**Почему.** Изменяемый конфиг делает поведение функцией момента: один
запрос обслуживается наполовину старыми, наполовину новыми значениями, а
разбор инцидента требует знать хронологию правок файла, а не его текущее
содержимое.
Горячая перезагрузка — отдельное решение с отдельным обоснованием, а не
умолчание.
### R7. Умолчания живут в коде
**ДОЛЖЕН.** Значение по умолчанию задаётся в коде, файл его перекрывает.
**Почему.** Умолчание, живущее в образце, действует только для тех, кто
образец скопировал: конфиг без этого поля даёт другое поведение, и ни одно
из двух значений не выглядит ошибкой. Умолчание в коде — одно определённое
поведение для неполного конфига и одно место, где это значение меняется.
### R8. Образец перечисляет все поля
**ДОЛЖЕН.** В образце присутствуют все секции и все поля, включая те, у
которых есть умолчание (R7).
**Почему.** Поле, живущее только в коде, для читателя конфига не
существует: он не знает, что параметр вообще можно менять, и добивается
нужного поведения обходным путём. Полнота образца — цена, которой R7
покупает себе видимость.
### R9. У каждого поля образца есть комментарий
**ДОЛЖЕН.** Каждое поле сопровождается комментарием, из которого ясно:
- **зачем** поле — что оно меняет в поведении;
- **диапазон или допустимые значения** — перечисление либо границы;
- **единицы измерения**, если применимо: секунды/миллисекунды, байты, доля
`01`.
**Почему.** Так конфиг читается без открывания кода — этим он и полезен;
без комментария читатель всё равно идёт в код, и образец перестаёт быть
справочником. Единицы стоят отдельно: перепутанные секунды и миллисекунды
дают валидное значение и работающий процесс, а ошибка обнаруживается по
последствиям — таймаут в тысячу раз не тот.
### R10. Обязательность полей определяется дискриминатором `type`
**ДОЛЖЕН.** Когда набор полей секции зависит от поля-дискриминатора (выбор
бекенда или внешнего сервиса), валидация идёт по его значению:
| № | Значение `type` | Валидация |
|---|---|---|
| R10.1 | поддерживаемое | обязательны поля этого варианта; поля прочих вариантов не требуются |
| R10.2 | неизвестное | ошибка на старте с перечислением поддерживаемых значений |
**Почему.** Фиксированный на секцию набор обязательных полей оставляет
выбор из двух плохих: заполнять поля бекенда, который не используется, или
не проверять обязательность вовсе — то есть выключить валидацию ровно там,
где вариантов много и ошибиться легче всего. Перечисление поддерживаемых
значений (R10.2) нужно потому, что опечатка в `type` иначе неотличима от
неподдерживаемого варианта, и за списком приходится идти в код.
### R11. Образец показывает все варианты `type`
**СЛЕДУЕТ.** Основной вариант предзаполнен рабочими значениями,
альтернативные — блоками-комментариями ниже, каждый со своим описанием
полей.
**Почему.** Иначе набор вариантов виден только из кода валидации, и образец
теряет свойство справочника (R8, R9) ровно на той секции, где выбор
действительно есть. Закомментированный блок вдобавок переключается правкой
на месте, а не сборкой секции с нуля по документации.
### R12. Секреты в конфиг приносит деплой
**ДОЛЖЕН.** Деплой рендерит значения секретов прямо в файл конфигурации;
отдельного слоя секретов в приложении нет.
**Почему.** Источник истины секрета — внешнее хранилище деплоя, не
репозиторий и не окружение. Любой второй канал — переменная окружения рядом
с файлом, собственный клиент к хранилищу внутри приложения — возвращает
вопрос «откуда взялось значение, которое сейчас в процессе». Приложение при
этом остаётся тривиальным: оно читает файл и про секреты не знает ничего
особенного.
### R13. Рендеренный конфиг — `0600` и владелец-рантайм
**ДОЛЖЕН.** Права `0600`, владелец — пользователь, от имени которого
работает процесс.
**Почему.** После R1 и R12 файл конфигурации — единственная поверхность, на
которой секреты лежат, и весь довод «файл вместо окружения» держится на его
правах: конфиг, читаемый всеми на машине, раздаёт секреты шире, чем раздало
бы окружение, — и тогда R1 меняет одну утечку на другую.
### R14. В образце секретные поля — пустые строки
**ДОЛЖЕН.** Значение секретного поля в образце — пустая строка, а не
пример.
**Почему.** Правдоподобная заглушка доезжает до продакшена как настоящее
значение: шаблон отрендерился криво, поле осталось от образца, и проверка
непустоты (R15) его пропускает. Пустая строка делает недорендеренный конфиг
механически отличимым от заполненного.
### R15. Загрузчик проверяет, что обязательные секреты не пусты
**ДОЛЖЕН.** Непустота обязательных секретов проверяется на старте.
**Почему.** Это ловит криво отрендеренный шаблон до того, как он превратится
в 401 от внешнего API через час работы, — то есть в момент, когда причина
ещё очевидна и связана с деплоем.
### R16. Секреты не попадают в логи
**НЕ ДОЛЖЕН.** Значение секретного поля не появляется в записи лога ни на
одном уровне.
**Почему.** У логов круг доступа шире, чем у файла под `0600` (R13): они
собираются, пересылаются и попадают в бэкапы, где права исходного файла уже
ничего не значат. Попавший в лог секрет чинится ротацией, а не удалением
записи. Типичный источник утечки — отладочный дамп разобранного конфига при
старте.
<!-- local:секретные-поля -->
<!-- /local -->
### R17. Конфиг валидируется на старте, до приёма трафика
**ДОЛЖЕН.** Невалидный конфиг — запись уровня `ERROR` и выход с ненулевым
кодом; процесс не стартует «наполовину».
**Почему.** Наполовину стартовавший процесс проходит проверку живости и
падает позже — на первом запросе, который трогает испорченный параметр, — и
падение выглядит дефектом кода, а не ошибкой деплоя. Ненулевой код нужен,
чтобы неудачный старт увидел супервизор: без него он неотличим от штатного
завершения, и приложение считается развёрнутым.
### R18. Минимальный набор проверок
**ДОЛЖЕН.** Валидация покрывает как минимум:
| № | Что проверяется | Когда всплывёт без проверки |
|---|---|---|
| R18.1 | обязательные поля заданы (непустота секретов — R15) | в ветке, которая это поле читает |
| R18.2 | пути существуют и доступны на запись/чтение по назначению | при первой записи, уже после отчёта об успешном старте |
| R18.3 | числовые диапазоны и единицы: доли, таймауты, счётчики попыток | искажённым поведением без единого сообщения об ошибке |
| R18.4 | строки, которые парсятся во что-то (длительности, зоны, URL), реально парсятся | при первом обращении к тому, что за ними стоит |
| R18.5 | включённые секции консистентны: у включённой интеграции заданы все обязательные поля | при первом вызове интеграции, часто по расписанию |
**Почему.** Список минимальный и собран по одному признаку — правый
столбец: каждая из этих ошибок иначе всплывает там, где связь с деплоем уже
потеряна, и диагностируется как дефект приложения. Проверка на старте
сводит их все к одному моменту и одному сообщению.
<!-- local:проверки -->
<!-- /local -->
### R19. Проблемы конфига показываются разом
**ДОЛЖЕН.** Валидация собирает все найденные проблемы и выводит их одним
списком, а не падает на первой.
**Почему.** Иначе цикл «запуск — одна ошибка — правка» повторяется столько
раз, сколько в конфиге ошибок, а на сервере каждая итерация — это ещё и
деплой. Криво отрендеренный шаблон обычно ломает не одно поле, а все поля
одного источника: разом они читаются как одна причина, по одной — как
череда несвязанных мелочей.
## Связано
- `arch/time.md` — формат времени; зона отображения — единственный
конфигурируемый параметр времени, семантика описана там.
- `arch/app-directories.md` — конфиг лежит в категории «конфигурация» и
доступен приложению только на чтение.
<!-- local:связано -->
<!-- /local -->
+137
View File
@@ -0,0 +1,137 @@
# Идентификаторы сущностей
Как выбираются и как выглядят первичные ключи сущностей. Форма записи —
`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 -->
+171
View File
@@ -0,0 +1,171 @@
# Время
Как приложение записывает моменты и длительности: в каком формате, откуда
берётся значение и где появляется не-UTC. Форма записи —
`LANGUAGE.md`.
## Область действия
Конвенция описывает фиксацию **свершившихся моментов** — того, что уже
произошло и попало в базу, лог или ответ API. Планирование будущих событий —
отдельный случай: там хранят локальное время плюс имя зоны, потому что
правила зон меняются в промежутке между планированием и наступлением. Пока
такой сущности нет, правил для неё в файле нет.
## Правила
### R1. Единый формат — RFC 3339, UTC, суффикс `Z`
**ДОЛЖЕН.** Момент времени записывается как `2026-06-28T11:23:45Z`
одинаково в хранении, логах, API и обмене с внешними системами.
**Почему.** Разные форматы в разных слоях требуют преобразования на каждой
границе, а ошибка в таком преобразовании не видна сразу: она всплывает через
полгода, на переходе на летнее время, когда реальное смещение перестаёт
совпадать с тем, которое подразумевалось при написании кода. UTC с явным `Z`
убирает из данных и смещение, и сам вопрос «в какой зоне это записано».
### R2. Ширина строки фиксируется на каждый носитель
**ДОЛЖЕН.** Внутри одной колонки БД и внутри одного потока логов длина
строки времени одна и от записи к записи не плавает.
**Почему.** Лексикографическая сортировка совпадает с хронологией только
среди строк одинаковой длины: `…00.123Z` сортируется раньше `…00.12Z`, хотя
произошло позже. Ради этого ширина и фиксируется — `ORDER BY created_at` по
текстовому полю обязан давать порядок событий. Плавающая ширина (типичный
источник — форматирование, отбрасывающее незначащие нули) ломает порядок не
везде, а только на тех парах записей, где дробная часть оказалась короче, —
то есть редко, выборочно и невоспроизводимо.
### R3. Точность разных носителей может различаться
**ДОПУСКАЕТСЯ.** У колонки БД и у потока логов каждая своя точность.
**Почему.** Квантор в R2 — на носитель, а не на приложение, потому что
строки разных носителей между собой не сравниваются: сортировка идёт внутри
колонки, чтение — внутри потока. Явное разрешение нужно, чтобы R2 не читался
как «одна точность на всё приложение»: от подгонки формата логов под формат
колонки ни одна пара строк не становится сравнимой, зато точность режется до
худшего из носителей.
### R4. Локальное время не хранится и не передаётся
**НЕ ДОЛЖЕН.** Ни в базе, ни в логах, ни в JSON API нет меток в локальной
зоне.
**Почему.** Метка без зоны неинтерпретируема вне процесса, который её
записал: чтобы понять, какому моменту она соответствует, читателю нужно
знать настройки чужой машины на момент записи. И даже зная их, он не
разберёт час перехода на зимнее время: этот час идёт дважды, две записи
получают одинаковую метку, и порядок между ними не восстанавливается ничем.
### R5. Единая точка получения «сейчас», форматирования и разбора
**ДОЛЖЕН.** Один модуль отдаёт текущий момент, он же форматирует и разбирает
метки; прямые вызовы часов по коду не разбросаны.
**Почему.** Формат, зона (R1) и ширина (R2) обязаны выполняться для всех
меток без исключения, а каждый прямой вызов часов заводит ещё одно место,
где их можно не соблюсти. Промах такого вызова проявляется не в коде, а в
данных, и обнаруживается, когда испорченных записей уже накопилось.
Соображение то же, что для идентификаторов (`arch/db-identifiers.md R3`).
### R6. Дефолтов времени в схеме БД нет
**НЕ ДОЛЖЕН.** Колонки времени не имеют `DEFAULT` с текущим моментом.
**Почему.** Дефолт превращает забытую вставку `created_at` в тихо работающий
код: значение появляется, но приходит от сервера БД — то есть с других часов
и в формате, который выбирала не единая точка (R5). Без дефолта та же ошибка
падает громко и чинится в момент написания, а не при разборе расхождения
между временем в записи и временем в логе. Правило то же, что для
идентификаторов (`arch/db-identifiers.md R2`).
### R7. Длительность — отдельная величина, а не пара меток
**ДОЛЖЕН.** Длительность операции записывается числом (обычно
миллисекундами) в поле вида `duration_ms`.
**Почему.** Метка отвечает на вопрос «когда», длительность — на «сколько».
Пара меток заставляет каждого потребителя знать, какие именно две из них
образуют операцию, и вычитать их самому — в запросе, в дашборде и глазами в
логе; число сравнивается, агрегируется и попадает в перцентили без этого
шага. Кроме того, разность сохранённых меток считается по стенным часам и
наследует их дефект (R9).
### R8. Длительность засекает слой, который делает вызов
**СЛЕДУЕТ.** Замер живёт там же, где вызов, границы которого он измеряет.
**Почему.** Обе границы операции видит только этот слой: замер уровнем выше
приписывает операции чужие накладные расходы, уровнем ниже — теряет часть
вызова. В обоих случаях число остаётся правдоподобным и потому не
оспаривается, хотя отвечает не на тот вопрос, который к нему задают.
### R9. Момент и интервал берутся с разных часов
**ДОЛЖЕН.** Источник зависит от того, что записывается:
| № | Величина | Источник |
|---|---|---|
| R9.1 | момент события | стенные часы через единую точку (R5) |
| R9.2 | длительность операции | монотонные часы процесса |
**Почему.** Стенные часы подводит NTP: они могут шагнуть назад, и тогда
интервал, посчитанный вычитанием, выйдет отрицательным, а при шаге вперёд —
правдоподобным, но выдуманным. Монотонные часы, наоборот, не годятся для
меток: их ноль произволен и не переживает перезапуск процесса, так что вне
процесса такое значение ничего не означает. Отсюда следствие, которое легко
упустить: источник меток времени и источник интервалов — разные, даже если
оба называются «часы».
### R10. Не-UTC существует только на слое отображения
**ДОЛЖЕН.** Преобразование в зону пользователя происходит при выводе и не
проникает в хранение, сортировку и логи.
**Почему.** Как только конвертация уходит вглубь, результат вычислений
начинает зависеть от настроек конкретного зрителя: одна и та же выборка даёт
разные группировки, а порядок записей перестаёт быть общим для всех. Ещё
хуже, что при конвертации в нескольких слоях её легко выполнить дважды —
смещение удваивается, результат остаётся похожим на правду, а найти
виновный слой можно только перечитав их все.
### R11. Зона отображения берётся из конфигурации, по умолчанию `UTC`
**ДОЛЖЕН.** Значение приходит из конфигурации (`arch/config.md`), значение
по умолчанию — `UTC`.
**Почему.** Зашитая в код зона превращает переезд или второго пользователя в
другом поясе в правку кода и релиз. Значение по умолчанию `UTC` выбрано
потому, что оно не притворяется настроенным: показанное время совпадает с
тем, что лежит в базе и в логах, поэтому несовпадение с ожиданиями читается
как «зону не задали», а не как «где-то потерялось смещение».
### R12. В календарных вычислениях зона указывается явно
**ДОЛЖЕН.** «Сегодня», «за месяц» и прочие календарные границы считаются с
явно переданной зоной, а не с системной зоной процесса.
**Почему.** Системная зона разная на ноутбуке разработчика и в контейнере на
сервере, поэтому граница суток уезжает, а вместе с ней — содержимое отчёта:
расхождение не воспроизводится там, где его заметили, и объясняется средой,
а не кодом. Явно переданная зона делает результат функцией от аргументов.
Зона по умолчанию здесь та же, что и для отображения (R11); календарная
логика, которой нужна другая, получает её тем же явным аргументом.
<!-- local:механизировано -->
<!-- /local -->
<!-- local:отступления -->
<!-- /local -->
## Связано
- `arch/config.md` — где задаётся зона отображения.
- `arch/db-identifiers.md` — то же правило «генерирует приложение» для id.
<!-- local:связано -->
<!-- /local -->