Материал FTS and time: rule versions, snapshots, and retroactive audit
0%

FTS and time: rule versions, snapshots, and retroactive audit

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 test as 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

  1. Each edition is a separate file; old ones are not rewritten.
  2. Edition marking is in the file name and in the category name.
  3. Period table — application data, half-open intervals, no gaps.
  4. Edition selection is by event date; absence of edition — exception.
  5. Before switching, a shadow run is made and the delta is calculated.
  6. The log writes the snapshot, edition, document digest, and result.
  7. CI compares the input and output forms of two editions and fails on breaking changes.
  8. Old examples remain green on their edition.

Related: proofs and certificates and generation and CI.

Cases in the catalog on this topic

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов
Дальше