Время: версии правил, снапшоты и аудит задним числом
Правило живёт во времени, а модель — нет. «Скидка действует с 1 марта», «тариф пересмотрели в июле», «клиент спорит о начислении за апрель» — три формы одного вопроса: по каким правилам считать событие, если правила с тех пор изменились. Модель на flang — снимок одной редакции правила. Всё, что связано с датами действия, живёт вокруг неё: в файлах, в таблице редакций и в коде приложения.
Минимальный пример
Правило, которое предстоит изменить, — начисление бонусов:
модуль «Лояльность 2025»
объект «Покупка»
«сумма»: неотрицательное
«постоянный клиент»: признак
тотальная функция «Базовое начисление»
принимает покупка: «Покупка»
возвращает число
если покупка.«сумма» не меньше 1000 и притом покупка.«сумма» не больше 100000
то (покупка.«сумма» умножить на 3) делить на 100
иначе 0
тотальная функция «Надбавка постоянному»
принимает покупка: «Покупка»
возвращает число
если покупка.«постоянный клиент» и притом покупка.«сумма» не больше 100000
то (покупка.«сумма» умножить на 2) делить на 100
иначе 0
тотальная функция «Потолок крупной покупки»
принимает покупка: «Покупка»
возвращает число
обеспечивает «за крупную покупку ровно потолок» результат не больше 5000
если покупка.«сумма» больше 100000
то 5000
иначе 0
тотальная функция «Начислить бонусы»
принимает покупка: «Покупка»
возвращает число
обеспечивает «бонус ограничен» результат не больше 5000
пример «мелкая покупка»
дано покупка равно запись «Покупка» с «сумма» равным 500 и «постоянный клиент» равным нет
ожидается 0
пример «тысяча у обычного клиента»
дано покупка равно запись «Покупка» с «сумма» равным 1000 и «постоянный клиент» равным нет
ожидается 30
пример «две тысячи у постоянного клиента»
дано покупка равно запись «Покупка» с «сумма» равным 2000 и «постоянный клиент» равным да
ожидается 100
пример «покупка ровно на потолок бонуса»
дано покупка равно запись «Покупка» с «сумма» равным 100000 и «постоянный клиент» равным да
ожидается 5000
пример «очень крупная покупка»
дано покупка равно запись «Покупка» с «сумма» равным 200000 и «постоянный клиент» равным да
ожидается 5000
(«Базовое начисление» от покупка) плюс («Надбавка постоянному» от покупка) плюс («Потолок крупной покупки» от покупка)
Эта модель лежит в static/spec/models/loyalty-tier.flang. С 1 марта 2026 года
пороги пересмотрели: база — 5 процентов от 5000, надбавка постоянному клиенту —
1 процент, потолок — 6000 вместо 5000. Новая редакция лежит рядом, в
loyalty-tier.v2.flang, с модулем «Лояльность 2026». Ни один файл не
переписывается поверх другого.
Потолок в обеих редакциях записан числом, и обещание у «Потолка крупной
покупки» ядро доказывает для всех входов. Обещание «бонус ограничен» у итоговой
функции держится только на примерах — сумму процентов ядро не ограничивает, —
поэтому flang check --proof отвечает кодом 3, а напечатанный код проверяет
это обещание при каждом вызове.
Почему в модели нет «сегодня»
Это не упущение, а граница языка. Функция flang — чистая: её ответ зависит
только от входа. Функции текущей даты в языке нет. Часы читает только план
(flang io) — это действие с миром, как чтение файла, и у команды есть ключ
--no-clock, который это действие отнимает.
Встроенного типа даты тоже нет. Поле «дата события»: дата компилятор
принимает, но хранит строкой, и сравнение порядка на строках отвергается:
модуль «Лояльность по дате»
объект «Начисление»
«дата события»: дата
«сумма»: неотрицательное
тотальная функция «Начислить»
принимает начисление: «Начисление»
возвращает число
если начисление.«дата события» не меньше "2026-03-01"
то (начисление.«сумма» умножить на 10) делить на 100
иначе 0
FLANG_TYPE в файле date-err.flang, строка 10, столбец 18: левый операнд сравнения «gte» имеет тип строка: сравнения порядка допустимы только для чисел
FLANG_TYPE в файле date-err.flang, строка 10, столбец 44: правый операнд сравнения «gte» имеет тип строка: сравнения порядка допустимы только для чисел
В стандартной библиотеке есть модуль «Date and time»: календарный день
вариантом, «Дней от эпохи», разбор отметки времени. Но подключать его в модель
правила дорого. Подключённый модуль проверяется вместе с моделью — со всеми 134
примерами самой библиотеки. На машине, где готовился курс, двоичный flang
проверял такую модель 2 минуты 8 секунд, а в WebAssembly песочницы проверка
упёрлась в предел шагов (FLANG_RECURSION_LIMIT).
Обойти это просто: пусть приложение подаёт день события числом, например
20260410. Сравнение чисел законно, и правило проверяется за доли секунды:
модуль «Лояльность с днём в числе»
объект «Начисление»
«день события»: неотрицательное
«сумма»: неотрицательное
тотальная функция «Начислить»
принимает начисление: «Начисление»
возвращает число
пример «апрель»
дано начисление равно запись «Начисление» с «день события» равным 20260410 и «сумма» равным 1000
ожидается 100
пример «февраль»
дано начисление равно запись «Начисление» с «день события» равным 20260220 и «сумма» равным 1000
ожидается 0
если начисление.«день события» не меньше 20260301
то (начисление.«сумма» умножить на 10) делить на 100
иначе 0
Обратите внимание: «день события» — поле входа, которое приносит приложение.
Модель по-прежнему не знает, какое сегодня число, — она знает только то, что ей
передали. Такой приём годится, когда периодов два и их никогда не пересматривают
задним числом. В остальных случаях он плох: файл копит мёртвые ветки за все
прошлые годы, каждое правило приходится читать вместе с календарём, а примеры
перестают отвечать на вопрос «что считает текущая редакция». Дальше редакции
держатся раздельно.
Версия как файл
Единица версионирования — файл модели, и хранить редакции нужно рядом, а не в истории git. История нужна для разбирательства «кто и когда изменил», а расчёт за апрель должен работать на развёрнутом сервисе без обращения к репозиторию.
policies/loyalty/
loyalty-tier.flang // редакция 1, события до 2026-03-01
loyalty-tier.v2.flang // редакция 2, события с 2026-03-01
editions.json // таблица периодов
Файлов со временем становится много, и это нормально: редакция, которая когда-либо применялась к реальному событию, удаляется только вместе со сроком хранения соответствующих операций. Ветка git, тег релиза и каталог редакций — три разные вещи; смешивать их не нужно.
Отметку редакции полезно продублировать в имени модуля: «Лояльность 2025»
против «Лояльность 2026». Имя модуля видно в отчёте компилятора, в flang ast
и в напечатанном коде. Комментарий так не работает: компилятор комментарии
выбрасывает, и // редакция 1.1 в начале файла не видно ни в одной функции, ни
в разобранной программе.
Различить сами файлы помогает отпечаток. flang check --proof --record пишет
запись доказательства, и в ней стоит отпечаток256 — sha256 исходника:
запись доказательства 1
исходник loyalty-tier.flang
строк 49
знаков 1815
отпечаток 354337829 725376771
ядро 2
утверждений 2
отпечаток256 beac695a764f4dac1da8b0a491547b367c6a30869d473eae05444b2c52632b69
У второй редакции он другой — 34f496a3…. Это тот же sha256, что печатает
sha256sum, поэтому в журнал операций можно писать его, не запуская
компилятор. Любая правка файла, даже комментария, меняет отпечаток.
Кто выбирает версию
Выбор редакции — обязанность приложения. Правило простое: ключом выбора служит
дата события, а не дата расчёта. Полуинтервалы [since, until) исключают
двойное покрытие границы; ISO-даты сравниваются лексикографически, поэтому
хватает обычного <. Код ниже запускается из корня репозитория курса; каждая
редакция печатается в JavaScript один раз (importModel из
examples/spec/lib/flang.mjs):
import { importModel } from './examples/spec/lib/flang.mjs';
const editions = [
{ id: 'loyalty/2025', since: '2025-01-01', until: '2026-03-01', file: 'static/spec/models/loyalty-tier.flang' },
{ id: 'loyalty/2026', since: '2026-03-01', until: null, file: 'static/spec/models/loyalty-tier.v2.flang' },
];
const cache = new Map();
async function editionFor(eventDate) {
const edition = editions.find((item) => item.since <= eventDate && (item.until === null || eventDate < item.until));
if (edition === undefined) throw new Error(`нет редакции правил на дату ${eventDate}`);
if (!cache.has(edition.id)) cache.set(edition.id, (await importModel(edition.file)).functions.get('Начислить бонусы'));
return { ...edition, accrue: cache.get(edition.id) };
}
export async function accrue(purchase, eventDate) {
const edition = await editionFor(eventDate);
return { edition: edition.id, result: edition.accrue(purchase) };
}
Один и тот же снапшот { сумма: 2000, «постоянный клиент»: true } даёт 100
на дате 2026-02-20 и 20 на дате 2026-04-11. Дата вне всех периодов даёт не
«ноль по умолчанию», а исключение «нет редакции правил на дату 1899-05-05» —
пробел в таблице должен быть громким, иначе он превратится в тихое неверное
начисление. Печать кешируется по редакции: компилятор на каждый запрос не нужен.
В продакшене модели лучше печатать при сборке (flang emit --target js) и
класть рядом с сервисом, как в
главе о генерации; importModel
делает то же самое на лету.
Саму таблицу редакций держите там же, где остальная конфигурация домена, и версионируйте вместе с моделями: изменение периода — такое же изменение бизнес-правила, как изменение процента. Отдельный случай — ретроспективная правка, когда бизнес решает пересчитать уже закрытый период. Это не редактирование старого файла, а третья редакция с собственным периодом и явной процедурой пересчёта: старые операции остаются в журнале как были, новые пишутся рядом со ссылкой на основание.
Shadow-прогон перед переключением
Прежде чем менять таблицу редакций, прогоните обе версии на одних данных и посмотрите на дельту. Это дешевле любого совещания: цифры показывают, кого именно новая редакция затронет.
const было = current(snapshot);
const стало = candidate(snapshot);
if (было !== стало) report.push({ snapshot, было, стало, дельта: стало - было });
Здесь current и candidate — функция «Начислить бонусы» двух напечатанных
редакций. На шести снапшотах расходятся пять: 1000/обычный даёт 30 против
0, 2000/постоянный — 100 против 20, 10000/постоянный — 500 против
600, а покупки на потолке и выше (100000/постоянный и 200000/постоянный) —
5000 против 6000. Такой отчёт — рабочий артефакт для бизнеса, а не
техническая деталь. В продакшене shadow-режим ставят рядом с боевым расчётом:
новая редакция считает, но на результат не влияет, а расхождения пишутся в лог
до дня переключения.
В репозитории языка flang есть и готовая «таблица поведения правила» — исходы функции на переборе её входов и дифф таких таблиц между версиями. В установленный пакет 0.7.29 она не входит: это скрипт репозитория, и вне клона команда отвечает отказом:
flang run-script: файла «.flangrc» нет ни в рабочем каталоге, ни выше до корня проекта
Поэтому shadow-прогон на своих данных остаётся вашим инструментом.
Снапшот, результат и запись решения
Клиент спорит о начислении за апрель. Пересчёт «сейчас» на вопрос не отвечает: за это время могли измениться и правила, и данные — статус клиента, признак возврата, сумма после корректировки. Поэтому в журнал операции пишут вход, редакцию и результат:
{
"event_date": "2026-04-11",
"edition": "loyalty/2026",
"module": "Лояльность 2026",
"source_sha256": "34f496a3c9d0e975e28660fb4607df997236895ac5bf34763f343db0db4994d1",
"snapshot": { "сумма": 2000, "постоянный клиент": true },
"result": 20
}
Снапшот — это те и только те поля, которые объявлены объектом модели. Ссылка на запись клиента не годится: запись изменится, а спор останется.
Через полгода эту запись можно перепроверить командой flang facts. Она
вычисляет утверждение на фактах и отвечает вердиктом с шагами:
flang facts loyalty-tier.v2.flang --facts snapshot.json --claims '["«Начислить бонусы» от покупка равно 20"]' --pretty
Здесь snapshot.json — {"покупка": {"сумма": 2000, "постоянный клиент": true}}.
Ответ — "holds": true и шаги: какая функция, на каких фактах, какое значение,
какое сравнение. Та же команда на первой редакции выходит кодом 1 и называет
расхождение:
«Начислить бонусы» от факта «покупка» = 100, а утверждение требует «равно 20». Исходные факты — «покупка»: сумма = 2000, постоянный клиент = да
Чего такая проверка не даёт. Вердикт flang facts — это вычисление, а не
доказательство: он говорит, что эта редакция на этом снапшоте даёт 20, и не
больше. Что редакция была применима к этой дате, остаётся утверждением
приложения, и его нужно писать в журнал отдельным полем, вместе с отпечатком
файла, по которому считали.
Ломающие изменения и CI
Ломающим считается изменение, после которого прежний вызывающий код перестаёт
работать или получает другой смысл: переименование поля объекта, смена его
типа, удаление поля, переименование или удаление функции, смена типа
результата. Не ломают контракт: новые пороги и проценты, добавление
необязательного поля (иногда является), новые примеры.
Опасная ловушка: flang test этого не ловит. Если переименовать
«постоянный клиент» в «статус клиента» и заодно поправить примеры, все
примеры останутся зелёными и команда выйдет кодом 0. Но вызов со старой
полезной нагрузкой упадёт: прогонщик напечатанного модуля ответит
FLANG_TYPE … не задано поле «статус клиента» записи «Покупка», а прямой вызов
функции — FLANG_UNKNOWN_NAME: запись не содержит поле «статус клиента».
Примеры проверяют поведение, а не контракт.
Контракт проверяется сравнением разобранной программы двух версий — достаточно
проекции на типы и сигнатуры функций из flang ast:
import { flang } from './examples/spec/lib/flang.mjs';
const compiler = await flang();
const shape = async (file) => {
const ast = await compiler.ast(file);
return JSON.stringify({
types: ast.types.map((type) => ({
name: type.name,
fields: (type.fields ?? []).map((field) => `${field.name}: ${JSON.stringify(field.type)}${field.optional ? '?' : ''}`).sort(),
})),
functions: ast.functions.map((fn) => ({
name: fn.name,
params: fn.params.map((param) => JSON.stringify(param.type)),
returns: JSON.stringify(fn.returns),
})),
});
};
if ((await shape(previous)) !== (await shape(candidate))) process.exit(1);
Для наших редакций проверка проходит: формы входа совпадают, изменились только пороги и проценты. Для варианта с переименованным полем — падает. Красный результат не запрещает изменение, он требует новой мажорной редакции и миграции потребителей.
Старые примеры не переносятся в новую редакцию и не «обновляются под новые
цифры». Они остаются в старом файле и служат регрессионным набором: если
flang test loyalty-tier.flang вдруг покраснел, значит, кто-то правил историю.
Что происходит при настоящей регрессии, видно сразу: если убрать из первой
редакции надбавку постоянному клиенту, падают два примера, и команда выходит
кодом 1:
пример «две тысячи у постоянного клиента» функции «Начислить бонусы» не прошёл: значение не совпало с ожидаемым: ожидалось 100, получено 60
пример «покупка ровно на потолок бонуса» функции «Начислить бонусы» не прошёл: значение не совпало с ожидаемым: ожидалось 5000, получено 3000
Практика в песочнице
Измените порог не меньше 5000 в «Базовом начислении» на не меньше 1000 и
посмотрите, какие примеры покраснели. Это и есть shadow-прогон в миниатюре:
набор примеров показывает границу, которую двигает новая редакция. Затем
откройте первую редакцию — loyalty-tier.flang — и сравните, какие примеры
у неё другие.
Типичные ошибки
- Считать по правилам «на сегодня», а не по правилам на дату события.
- Хранить только последнюю редакцию, полагаясь на git: развёрнутый сервис не умеет читать историю репозитория.
- Записывать номер версии комментарием: компилятор его не видит.
- Писать в журнал ссылку на клиента вместо снапшота полей.
- Считать зелёный
flang testдоказательством совместимости. - Переписывать старые примеры под новые цифры, теряя регрессионный набор.
- Оставлять дыру в таблице периодов и молча падать в редакцию по умолчанию.
- Выдавать вердикт
flang factsза доказательство того, что редакция применима к дате.
Чек-лист
- Каждая редакция — отдельный файл, старые не переписываются.
- Отметка редакции есть в имени файла и в имени модуля.
- Таблица периодов — данные приложения, интервалы полуоткрытые, без дыр.
- Выбор редакции идёт по дате события; отсутствие редакции — исключение.
- Перед переключением сделан shadow-прогон и посчитана дельта.
- В журнал пишутся снапшот, редакция, sha256 файла модели и результат.
- CI сравнивает типы и сигнатуры двух редакций и падает на ломающих изменениях.
- Старые примеры остаются зелёными на своей редакции.
Смежное: доказательства и сертификаты и generation и CI.