FTS and time: rule versions, snapshots, and retroactive audit
Rules live through time, while specifications do not. “The discount is active from March 1,” “the tariff was revised in July,” “the client disputes the charges for April” — these are three forms of one question: according to which rules should an event be calculated if the rules have since changed. An FTS model is a snapshot of one rule revision. Everything related to effective dates revolves around it: in the file system, in the revisions table, and in the application code.
Minimal example
The rule to be changed is — bonus accrual:
категория «Лояльность 2025»
объект Покупка
сумма является деньгами
«постоянный клиент» является признаком
утилита «Начислить бонусы»
принимает Покупка
возвращает деньги
начинает с 0
// Верхняя граница названа в условиях правил, а не только в свойстве:
// свойство проверяется на всей области входа, поэтому потолок, заданный
// процентом от «суммы», ломался бы на отрицательной сумме (см. главу 18).
правило «Базовое начисление»
если сумма не меньше 1000
и сумма не больше 100000
то добавить 3 процента от поля сумма
правило «Постоянный клиент»
если «постоянный клиент» равен да
и сумма больше 0
и сумма не больше 100000
то добавить 2 процента от поля сумма
// Выше потолка начисление перестаёт зависеть от суммы. Правило добавляет,
// а не переписывает результат, поэтому порядок правил ни на что не влияет.
правило «Очень крупная покупка»
если сумма больше 100000
то добавить 5000
свойство «Бонус ограничен»
результат не больше 5000
пример «Мелкая покупка»
дано сумма равна 500
дано «постоянный клиент» равен нет
ожидается результат равен 0
пример «Тысяча у обычного клиента»
дано сумма равна 1000
дано «постоянный клиент» равен нет
ожидается результат равен 30
пример «Две тысячи у постоянного клиента»
дано сумма равна 2000
дано «постоянный клиент» равен да
ожидается результат равен 100
пример «Покупка ровно на потолок бонуса»
дано сумма равна 100000
дано «постоянный клиент» равен да
ожидается результат равен 5000
пример «Очень крупная покупка»
дано сумма равна 200000
дано «постоянный клиент» равен да
ожидается результат равен 5000
This model lies in static/fts/models/loyalty-tier.fts. As of March 1, 2026,
the thresholds were revised: base — 5 percent of 5000, a surcharge for a regular customer —
1 percent, the ceiling — 6000 instead of 5000. The new version lies next to it, in
loyalty-tier.v2.fts. No file is rewritten over another.
The ceiling in both editions is recorded as a number, not a percentage of the сумма field.
This is not a style issue: the property is checked over the entire input domain, and
результат не больше 10 процентов от поля сумма with a negative sum gives
a negative limit, which a zero result does not satisfy. Walkthrough
of this error — in the chapter on antipatterns.
Why the model has no “today”
This is not an omission, but a language boundary. The language reference has no current date function, no scheduler, no calendar branching. является датой declares the field type; in the utility execution Дата is a regular string, and order comparisons for it are prohibited. Trying to write “the rule is active from March” directly in the model is rejected by the check:
правило «После марта»
если «дата события» не меньше «2026-03-01»
то добавить 10 процентов от поля сумма
validate returns valid: false with diagnostics
FTS_UTILITY_COMPARE_TYPE: field 'дата события' is not numeric, and execution
fails with «order comparisons are only valid for numbers». The keyword
версия is also absent: line версия 2 gives a parsing error «expected object,
structure, morphism, theorem, or utility».
Bypassing the restriction is technically possible: if the event day is submitted as the number 20260410,
the order comparison becomes legal and the rule will work.
категория «Лояльность с датой в числе»
объект Начисление
«день события» является числом
сумма является деньгами
утилита «Начислить»
принимает Начисление
возвращает деньги
начинает с 0
правило «После первого марта»
если «день события» не меньше 20260301
то добавить 10 процентов от поля сумма
пример «Апрель»
дано «день события» равен 20260410
дано сумма равна 1000
ожидается результат равен 100
Note: «день события» is an entry field that brings the application. The model still does not know what today’s date is — it knows only what was passed to it. This technique works when there are two periods and they are never revised retroactively. In all other cases, it is bad: the file accumulates dead branches from all past years, each rule must be read together with the calendar, and examples stop answering the question “what counts as the current version”. Further on, we keep versions separately.
Version as a File
Versioning unit — model file, and revisions should be stored nearby, not in git history. History is needed for resolving “who and when changed”, while the April calculation should work on the deployed service without accessing the repository.
policies/loyalty/
loyalty-tier.fts // редакция 1, события до 2026-03-01
loyalty-tier.v2.fts // редакция 2, события с 2026-03-01
editions.json // таблица периодов
The number of files grows over time, and this is normal: an edition that
was ever applied to a real event is deleted only together with the retention
period of the corresponding operations. Branch git, release tag, and edition
directory are three different things; there is no need to mix them.
Duplicating the edition mark in the category name: «Лояльность 2025»
against «Лояльность 2026». The category name ends up in the canonical JSON and in
document_digest, so editions are guaranteed to be distinguishable by the certificate.
A comment does not work this way: // редакция 1.1 at the beginning of the file does not change
document_digest at all — comments do not enter the canonical model. A version
number recorded only as a comment does not exist for auditing.
Who chooses the version
Selection of the edition is the application’s responsibility. The rule is simple: the selection key is
the event date, not the calculation date. Half-open intervals [since, until) exclude
double coverage of the boundary; ISO-dates are compared lexicographically, so
a regular < suffices.
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),
};
}
The same snapshot { сумма: 2000, «постоянный клиент»: true } gives 100
on date 2026-02-20 and 20 on date 2026-04-11. A date outside all periods gives
not a “default zero”, but an exception “no rules edition on date 1899-05-05” —
a gap in the table should be loud, otherwise it will turn into a silent incorrect
calculation. Compilation is cached by edition identifier: model parsing for
each request is not needed.
Keep the revisions table in the same place where the rest of the domain configuration is, and version it together with the models: changing the period is as much a change in business rules as changing the percentage. A special case is retrospective editing, when the business decides to recalculate an already closed period. This is not editing an old file, but a third revision with its own period and an explicit recalculation procedure: old operations remain in the log as they were, new ones are written next to them with a reference to the basis.
Shadow run before switching
Before changing the revision table, run both versions on the same data and look at the delta. This is cheaper than any meeting: the numbers show exactly who the new revision will affect.
const было = executeUtility(current, 'Начислить бонусы', snapshot);
const стало = executeUtility(candidate, 'Начислить бонусы', snapshot);
if (было !== стало) report.push({ snapshot, было, стало, дельта: стало - было });
On six snapshots of our editions, five diverge: 1000/обычный gives 30 versus 0, 2000/постоянный — 100 versus 20, 10000/постоянный — 500 versus 600, and purchases at the ceiling and above (100000/постоянный and 200000/постоянный) — 5000 versus 6000. Such a report is a workbench artifact for business, not a technical detail. In production, shadow mode is placed next to the live calculation: the new edition calculates but does not affect the result, and discrepancies are written to the log until the switching day.
Snapshot, Result and Certificate
The client disputes the charge for April. Recalculation “now” does not answer the question: during this time, the rules and the data — the client’s status, the return flag, the amount after adjustment — could have changed. Therefore, the journal records three things: input, editing, and result.
{
"event_date": "2026-04-11",
"edition": "loyalty/2026",
"category": "Лояльность 2026",
"document_digest": "sha256:485b483efeaa87788b837877aa035d2d3b583c1ac9a41de7e2540692f680b774",
"snapshot": { "сумма": 2000, "постоянный клиент": true },
"result": 20
}
A snapshot consists of exactly those fields that are declared by the model object. A reference to a client record is not suitable: the record will change, but the dispute will remain.
fts certify fixes this binding cryptographically: canonizes the document and
the context and calculates 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
Checking on a different edition of the same rule fails with
FTS_CERTIFICATE_MISMATCH: document_digest editions differ
(sha256:d44ade94… versus sha256:485b483e…). Changing the snapshot — the sum from
2000 to 2500 — also breaks the check. Both teams use node:crypto and
are available in CLI and on the server, but not in the browser-based sandbox of the site.
What the certificate does not provide. A model without a theorem provides a trivial certificate: "steps": [] and "conclusion": {"type": "⊤", "term": "tt"}. The status verified here means only “the document is valid, and the document and snapshot are canonized”, but not “the charge of 20 rubles is justified”. A certificate is a seal on the pair “edition + input”, but not a proof of the utility’s arithmetic, and certainly not a proof that the edition was applicable to this date. The correspondence between the event date and the edition remains an application assertion, and it must be written into the journal as a separate field.
Breaking changes and CI
Breaking changes are considered to be those after which the previous calling code stops
working or begins to receive a different meaning: renaming an object’s field, changing
its type, removing a field, renaming or removing a utility, changing the result type.
Do not break the contract: new thresholds and percentages, adding
an optional field (иногда является), new examples, changing
comments.
Dangerous trap: fts test does not catch this. If you rename
«постоянный клиент» to «статус клиента» and fix the examples accordingly, all
examples will remain green and the command will finish with code 0. But a call with the old
payload will crash: FTS_UTILITY_INPUT: во входных данных отсутствует поле «статус клиента». Examples check behavior, not the contract.
The contract is verified by comparing the canonical JSON of two versions — it is sufficient to project onto the input and output forms:
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);
Our editions pass the check: the input forms match, only the thresholds and percentages have changed. For the variant with the renamed field — it fails. A red result does not prohibit changes, it requires a new major edition and migration of consumers.
Old examples are not moved to the new edition and are not “updated for new
figures.” They remain in the old file and serve as a regression set: if
fts test loyalty-tier.fts suddenly turns red, it means someone edited the history. What
happens during a real regression is immediately visible: removing the rule
“Regular customer” from the first edition breaks the example “Two thousand for a regular
customer” — expected 100, received 60, return code 1.
Practice in the sandbox
Change the threshold не меньше 10000 to не меньше 5000 and see which
examples turned red. This is a shadow run in miniature: a set of examples
shows the boundary that the new revision moves. Then switch the view to
model and compare the canonical JSON before and after the edit — it shows exactly what
will end up in document_digest.
Common Fallacies
- Apply the rules “as of today”, not the rules as of the event date.
- Store only the latest version, relying on git: the deployed service cannot read the repository history.
- Record the version number as a comment in
.fts— it does not go into the canonical model or the digest. - Write a reference to the client in the journal instead of a snapshot of fields.
- Consider a green
fts testas proof of compatibility. - Rewrite old examples with new digits, losing the regression set.
- Leave a gap in the periods table and silently fall back to the default version.
- Issue
status: "verified"on the model without a theorem as confirmation of the calculation.
Checklist
- Each edition is a separate file; old ones are not rewritten.
- Edition marking is in the file name and in the category name.
- Period table — application data, half-open intervals, no gaps.
- Edition selection is by event date; absence of edition — exception.
- Before switching, a shadow run is made and the delta is calculated.
- The log writes the snapshot, edition, document digest, and result.
- CI compares the input and output forms of two editions and fails on breaking changes.
- Old examples remain green on their edition.
Related: proofs and certificates and generation and CI.