FTS и время: версии правил, снапшоты и аудит задним числом
Правило живёт во времени, а спецификация — нет. «Скидка действует с 1 марта», «тариф пересмотрели в июле», «клиент спорит о начислении за апрель» — это три формы одного вопроса: по каким правилам считать событие, если правила с тех пор изменились. FTS-модель — это моментальный снимок одной редакции правила. Всё, что связано с датами действия, живёт вокруг неё: в файловой системе, в таблице редакций и в коде приложения.
Минимальный пример
Правило, которое предстоит изменить, — начисление бонусов:
категория «Лояльность 2025»
объект Покупка
сумма является деньгами
«постоянный клиент» является признаком
утилита «Начислить бонусы»
принимает Покупка
возвращает деньги
начинает с 0
// Верхняя граница названа в условиях правил, а не только в свойстве:
// свойство проверяется на всей области входа, поэтому потолок, заданный
// процентом от «суммы», ломался бы на отрицательной сумме (см. главу 18).
правило «Базовое начисление»
если сумма не меньше 1000
и сумма не больше 100000
то добавить 3 процента от поля сумма
правило «Постоянный клиент»
если «постоянный клиент» равен да
и сумма больше 0
и сумма не больше 100000
то добавить 2 процента от поля сумма
// Выше потолка начисление перестаёт зависеть от суммы. Правило добавляет,
// а не переписывает результат, поэтому порядок правил ни на что не влияет.
правило «Очень крупная покупка»
если сумма больше 100000
то добавить 5000
свойство «Бонус ограничен»
результат не больше 5000
пример «Мелкая покупка»
дано сумма равна 500
дано «постоянный клиент» равен нет
ожидается результат равен 0
пример «Тысяча у обычного клиента»
дано сумма равна 1000
дано «постоянный клиент» равен нет
ожидается результат равен 30
пример «Две тысячи у постоянного клиента»
дано сумма равна 2000
дано «постоянный клиент» равен да
ожидается результат равен 100
пример «Покупка ровно на потолок бонуса»
дано сумма равна 100000
дано «постоянный клиент» равен да
ожидается результат равен 5000
пример «Очень крупная покупка»
дано сумма равна 200000
дано «постоянный клиент» равен да
ожидается результат равен 5000
Эта модель лежит в static/fts/models/loyalty-tier.fts. С 1 марта 2026 года
пороги пересмотрели: база — 5 процентов от 5000, надбавка постоянному клиенту —
1 процент, потолок — 6000 вместо 5000. Новая редакция лежит рядом, в
loyalty-tier.v2.fts. Ни один файл не переписывается поверх другого.
Потолок в обеих редакциях записан числом, а не процентом от поля сумма.
Это не стилистика: свойство проверяется на всей области входа, а
результат не больше 10 процентов от поля сумма при отрицательной сумме даёт
отрицательный предел, которому нулевой результат не удовлетворяет. Разбор
этой ошибки — в главе об антипаттернах.
Почему в модели нет «сегодня»
Это не упущение, а граница языка. В справочнике языка нет ни функции текущей
даты, ни планировщика, ни ветвления по календарю. является датой объявляет
тип поля; в исполнении утилиты Дата — обычная строка, и сравнения порядка для
неё запрещены. Попытка написать «правило действует с марта» прямо в модели
отклоняется проверкой:
правило «После марта»
если «дата события» не меньше «2026-03-01»
то добавить 10 процентов от поля сумма
validate возвращает valid: false с диагностикой
FTS_UTILITY_COMPARE_TYPE: field 'дата события' is not numeric, а исполнение
падает с «сравнения порядка допустимы только для чисел». Ключевого слова
версия тоже нет: строка версия 2 даёт ошибку разбора «ожидались объект,
структура, морфизм, теорема или утилита».
Обойти запрет технически можно: если подать день события числом 20260410,
сравнение порядка станет законным и правило заработает.
категория «Лояльность с датой в числе»
объект Начисление
«день события» является числом
сумма является деньгами
утилита «Начислить»
принимает Начисление
возвращает деньги
начинает с 0
правило «После первого марта»
если «день события» не меньше 20260301
то добавить 10 процентов от поля сумма
пример «Апрель»
дано «день события» равен 20260410
дано сумма равна 1000
ожидается результат равен 100
Обратите внимание: «день события» — это поле входа, которое приносит
приложение. Модель по-прежнему не знает, какое сегодня число, — она знает
только то, что ей передали. Такой приём годится, когда периодов два и они
никогда не пересматриваются задним числом. В остальных случаях он плох: файл
копит мёртвые ветки за все прошлые годы, каждое правило приходится читать
вместе с календарём, а примеры перестают отвечать на вопрос «что считает
текущая редакция». Дальше мы держим редакции раздельно.
Версия как файл
Единица версионирования — файл модели, и хранить редакции нужно рядом, а не в истории git. История нужна для разбирательства «кто и когда изменил», а расчёт за апрель должен работать на развёрнутом сервисе без обращения к репозиторию.
policies/loyalty/
loyalty-tier.fts // редакция 1, события до 2026-03-01
loyalty-tier.v2.fts // редакция 2, события с 2026-03-01
editions.json // таблица периодов
Файлов со временем становится много, и это нормально: редакция, которая
когда-либо применялась к реальному событию, удаляется только вместе со сроком
хранения соответствующих операций. Ветка git, тег релиза и каталог редакций —
три разные вещи; смешивать их не нужно.
Отметку редакции полезно продублировать в имени категории: «Лояльность 2025»
против «Лояльность 2026». Имя категории попадает в канонический JSON и в
document_digest, поэтому редакции гарантированно различимы по сертификату.
Комментарий так не работает: // редакция 1.1 в начале файла не меняет
document_digest вообще — комментарии не попадают в каноническую модель. Номер
версии, записанный только комментарием, для аудита не существует.
Кто выбирает версию
Выбор редакции — обязанность приложения. Правило простое: ключом выбора служит
дата события, а не дата расчёта. Полуинтервалы [since, until) исключают
двойное покрытие границы; ISO-даты сравниваются лексикографически, поэтому
хватает обычного <.
import { readFileSync } from 'node:fs';
import { assertValid, compile, executeUtility } from '../../../static/js/vendor/fts/browser.js';
const editions = [
{ id: 'loyalty/2025', since: '2025-01-01', until: '2026-03-01', file: 'policies/loyalty/loyalty-tier.fts' },
{ id: 'loyalty/2026', since: '2026-03-01', until: null, file: 'policies/loyalty/loyalty-tier.v2.fts' },
];
const cache = new Map();
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, assertValid(compile(readFileSync(edition.file, 'utf8'))));
return { ...edition, document: cache.get(edition.id) };
}
export function accrue(purchase, eventDate) {
const edition = editionFor(eventDate);
return {
edition: edition.id,
category: edition.document.category,
result: executeUtility(edition.document, 'Начислить бонусы', purchase),
};
}
Один и тот же снапшот { сумма: 2000, «постоянный клиент»: true } даёт 100
на дате 2026-02-20 и 20 на дате 2026-04-11. Дата вне всех периодов даёт
не «ноль по умолчанию», а исключение «нет редакции правил на дату 1899-05-05» —
пробел в таблице должен быть громким, иначе он превратится в тихое неверное
начисление. Компиляция кэшируется по идентификатору редакции: разбор модели на
каждый запрос не нужен.
Саму таблицу редакций держите там же, где остальная конфигурация домена, и версионируйте вместе с моделями: изменение периода — такое же изменение бизнес-правила, как изменение процента. Отдельный случай — ретроспективная правка, когда бизнес решает пересчитать уже закрытый период. Это не редактирование старого файла, а третья редакция с собственным периодом и явной процедурой пересчёта: старые операции остаются в журнале как были, новые пишутся рядом со ссылкой на основание.
Shadow-прогон перед переключением
Прежде чем менять таблицу редакций, прогоните обе версии на одних данных и посмотрите на дельту. Это дешевле любого совещания: цифры показывают, кого именно новая редакция затронет.
const было = executeUtility(current, 'Начислить бонусы', snapshot);
const стало = executeUtility(candidate, 'Начислить бонусы', snapshot);
if (было !== стало) report.push({ snapshot, было, стало, дельта: стало - было });
На шести снапшотах наших редакций расходятся пять: 1000/обычный даёт 30
против 0, 2000/постоянный — 100 против 20, 10000/постоянный — 500
против 600, а покупки на потолке и выше (100000/постоянный и
200000/постоянный) — 5000 против 6000. Такой отчёт — рабочий артефакт для
бизнеса, а не техническая деталь. В проде shadow-режим ставят рядом с боевым расчётом: новая редакция
считает, но не влияет на результат, а расхождения пишутся в лог до дня
переключения.
Снапшот, результат и сертификат
Клиент спорит о начислении за апрель. Пересчёт «сейчас» на вопрос не отвечает: за это время могли измениться и правила, и данные — статус клиента, признак возврата, сумма после корректировки. Поэтому в журнал операции пишут три вещи: вход, редакцию и результат.
{
"event_date": "2026-04-11",
"edition": "loyalty/2026",
"category": "Лояльность 2026",
"document_digest": "sha256:485b483efeaa87788b837877aa035d2d3b583c1ac9a41de7e2540692f680b774",
"snapshot": { "сумма": 2000, "постоянный клиент": true },
"result": 20
}
Снапшот — это те и только те поля, которые объявлены объектом модели. Ссылка на запись клиента не годится: запись изменится, а спор останется.
fts certify фиксирует эту связку криптографически: канонизирует документ и
контекст и считает sha256.
fts certify policies/loyalty/loyalty-tier.fts --context snapshot.json > certificate.json
fts verify policies/loyalty/loyalty-tier.fts --context snapshot.json --certificate certificate.json
Проверка на другой редакции того же правила падает с
FTS_CERTIFICATE_MISMATCH: document_digest редакций различается
(sha256:d44ade94… против sha256:485b483e…). Изменение снапшота — суммы с
2000 на 2500 — тоже ломает проверку. Обе команды используют node:crypto и
доступны в CLI и на сервере, но не в браузерной песочнице сайта.
Чего сертификат не даёт. Модель без теоремы даёт тривиальный сертификат:
"steps": [] и "conclusion": {"type": "⊤", "term": "tt"}. Статус verified
здесь означает только «документ валиден, а документ и снапшот канонизированы», а
не «начисление в 20 рублей обосновано». Сертификат — это пломба на паре
«редакция + вход», а не доказательство арифметики утилиты и тем более не
доказательство того, что редакция была применима к этой дате. Соответствие даты
события и редакции остаётся утверждением приложения, и его нужно писать в
журнал отдельным полем.
Ломающие изменения и CI
Ломающим считается изменение, после которого прежний вызывающий код перестаёт
работать или начинает получать другой смысл: переименование поля объекта, смена
его типа, удаление поля, переименование или удаление утилиты, смена типа
результата. Не ломают контракт: новые пороги и проценты, добавление
необязательного поля (иногда является), новые примеры, изменение
комментариев.
Опасная ловушка: fts test этого не ловит. Если переименовать
«постоянный клиент» в «статус клиента» и заодно поправить примеры, все
примеры останутся зелёными и команда завершится с кодом 0. Но вызов со старой
полезной нагрузкой упадёт: FTS_UTILITY_INPUT: во входных данных отсутствует поле «статус клиента». Примеры проверяют поведение, а не контракт.
Контракт проверяется сравнением канонического JSON двух версий — достаточно проекции на форму входа и выхода:
const shape = (file) => {
const document = compile(readFileSync(file, 'utf8'));
return JSON.stringify({
structures: document.structures.map((s) => ({
name: s.name,
fields: s.fields.map((f) => `${f.name}: ${f.type}`).sort(),
})),
utilities: (document.utilities ?? []).map((u) => ({ name: u.name, input: u.input, output: u.output })),
});
};
if (shape(previous) !== shape(candidate)) process.exit(1);
Для наших редакций проверка проходит: формы входа совпадают, изменились только пороги и проценты. Для варианта с переименованным полем — падает. Красный результат не запрещает изменение, он требует новой мажорной редакции и миграции потребителей.
Старые примеры не переносятся в новую редакцию и не «обновляются под новые
цифры». Они остаются в старом файле и служат регрессионным набором: если
fts test loyalty-tier.fts вдруг покраснел, значит, кто-то правил историю. Что
происходит при настоящей регрессии, видно сразу: удаление правила
«Постоянный клиент» из первой редакции роняет пример «Две тысячи у постоянного
клиента» — ожидалось 100, получено 60, код возврата 1.
Практика в песочнице
Измените порог не меньше 10000 на не меньше 5000 и посмотрите, какие
примеры покраснели. Это и есть shadow-прогон в миниатюре: набор примеров
показывает границу, которую двигает новая редакция. Затем переключите вид на
model и сравните канонический JSON до и после правки — в нём видно ровно то,
что попадёт в document_digest.
Типичные ошибки
- Считать по правилам «на сегодня», а не по правилам на дату события.
- Хранить только последнюю редакцию, полагаясь на git: развёрнутый сервис не умеет читать историю репозитория.
- Записывать номер версии комментарием в
.fts— в каноническую модель и в digest он не попадает. - Писать в журнал ссылку на клиента вместо снапшота полей.
- Считать зелёный
fts testдоказательством совместимости. - Переписывать старые примеры под новые цифры, теряя регрессионный набор.
- Оставлять дыру в таблице периодов и молча падать в редакцию по умолчанию.
- Выдавать
status: "verified"на модели без теоремы за подтверждение расчёта.
Чек-лист
- Каждая редакция — отдельный файл, старые не переписываются.
- Отметка редакции есть в имени файла и в имени категории.
- Таблица периодов — данные приложения, интервалы полуоткрытые, без дыр.
- Выбор редакции идёт по дате события; отсутствие редакции — исключение.
- Перед переключением сделан shadow-прогон и посчитана дельта.
- В журнал пишутся снапшот, редакция, digest документа и результат.
- CI сравнивает форму входа и выхода двух редакций и падает на ломающих изменениях.
- Старые примеры остаются зелёными на своей редакции.
Смежное: доказательства и сертификаты и generation и CI.