FTS Anti-Patterns: What Should Not Be Expressed in the Specification
A language that can do less makes fewer mistakes. But this economy works only when
the boundary is known in advance: otherwise, the first two weeks are spent trying
to write things that cannot exist in the specification, and concluding that “the tool is raw.”
The errors below are not a result of carelessness. These are exactly the constructs
written by someone with fifteen years of imperative code behind them: there, a condition
means branching, a function means an action, and if / else if means
mutual exclusion. In FTS, each of these words means something else.
How to read this catalog
Each item is analyzed according to the scheme: what the actual task is, how they try to record it, what happens, how the working division looks. The third item is more important than the others. Some attempts are rejected by the compiler immediately — this is a good outcome. Some compile, and the model that passes the check, but does not mean what the author intended, is more expensive than any diagnostics.
Messages received on the vendor compiler build from this repository
(static/js/vendor/fts/browser.js). Obviously broken blocks are marked as
fragments; blocks without a mark compile and pass their examples.
Effects in Specification
1. Action in utility rule
Task. Send a client an email with a promo code upon a large purchase.
правило «Большая покупка»
если сумма не меньше 10000
то отправить письмо на поле почта
What will happen. Compilation fails:
FTS_UTILITY_RULE | в правиле ожидаются если, и или то
The message looks strange — the line starts with то. But grammar knows
exactly two forms of action: то добавить ... and то результат равен .... Everything
else after то is not parsed, and the rule is considered incomplete. The same
will get то списать сумму со счёта.
How to do it right. The utility answers the question “how many,” not “what to do.” The application calls it, looks at the result, and decides on its own whether to send an email: sending is a template, retry, deduplication, and delivery report—none of this is checked by the compiler.
2. Action Hidden in the Morphism Name
More dangerous is the variant where the effect moves into the morphism, and то means not an action,
but the name of a consequence-type.
категория «Уведомления»
объект Заказ
номер является строкой
«оплачен» является состоянием «Оплачен»
морфизм «Оплаченный заказ подтверждаем письмом»
если «Оплачен»
то «Письмо клиенту отправлено»
What will happen. Nothing: the model is valid, no diagnostics. The trap is in the formulation of the codomain — it is named as an accomplished fact, and in a month someone will read the certificate as proof that the letter was sent. Another thing is proven: if the order is paid, then by the declared rule the state «letter sent» is considered reachable. No one checked the sending.
How to do it right. The codomain is formulated as a resolution or obligation:
«Уведомление клиента требуется». The fact of sending is returned from the mailing system into the data and becomes a field of the object, which the theorem will already check by context.
3. Retries, timeouts, queues
Task. After a payment failure, retry after five seconds, then after thirty, then — do not retry.
правило «Повтор после сбоя»
если «номер попытки» меньше 3
то повторить через 5 секунд
What will happen. FTS_UTILITY_RULE | в правиле ожидаются если, и или то. The
language has no waiting, no schedule, and no queue.
How correctly. Separate «how long to wait» and «who is waiting». Retry policy — a clean table, it should be fixed; delay execution, idempotent key and deduplication remain in the application.
категория «Повторы платежа»
объект «Неудачная попытка»
«номер попытки» является числом
«ошибка окончательная» является признаком
утилита «Задержка до следующей попытки»
принимает «Неудачная попытка»
возвращает число
начинает с 0
правило «Первый повтор»
если «ошибка окончательная» равна нет
и «номер попытки» равен 1
то результат равен 5
правило «Второй повтор»
если «ошибка окончательная» равна нет
и «номер попытки» равен 2
то результат равен 30
пример «Окончательная ошибка не повторяется»
дано «номер попытки» равен 1
дано «ошибка окончательная» равна да
ожидается результат равен 0
пример «Третья попытка не назначается»
дано «номер попытки» равен 3
дано «ошибка окончательная» равна нет
ожидается результат равен 0
Zero here means “no repetition” — also a model decision, not an application default.
The Outside World: Time, Randomness, Network
4. External Source as a Field
Task. Recalculate the tariff according to the current currency exchange rate from an external API.
объект Отправление
«курс валюты» является запросом к «API ЦБ»
What will happen. FTS_NATURAL_NAME | лишний текст после имени: 'к «API ЦБ»'.
But remove the tail — and the interesting part begins:
категория «Тарифы»
объект Отправление
вес является числом
«курс валюты» является запросом
This compiles, validate returns valid: true, the field gets type
запросом: any unfamiliar word after является becomes a nominal
type. The model looks meaningful and means nothing. This is discovered when
trying to do something with the field:
FTS_UTILITY_COMPARE_TYPE | field 'курс валюты' is not numeric
If a field is not declared in the rule at all, the diagnostics is more honest and earlier:
FTS_UTILITY_FIELD | unknown utility input field 'курс валюты'.
How to do it right. The course receives an application — with a timeout, a cache, and a fallback value. In the model, it arrives as the «курс валюты» является числом field.
5. «Today» inside the rule
Task. Calculate penalties if the invoice is overdue relative to today.
правило «Просрочка»
если «срок оплаты» меньше сегодня
то добавить 1 процент от поля сумма
What will happen. The string will be parsed, but validate will return
FTS_UTILITY_COMPARE_TYPE | field 'срок оплаты' is not numeric. The word сегодня
does not cause an error — it silently becomes the string "сегодня". Replacing it with
«текущая дата» gives the same diagnostics; if the field is made numeric, the message
will change to comparison for 'дней просрочки' uses incompatible values.
It’s not about dates. It’s about the fact that “today” is a hidden input: the model ceases to be a total function, the same count will give a different answer tomorrow, and today’s green example will turn red in a month.
How to do it right. Time is calculated by the application and passed as data. Delinquency, age, ownership duration — numbers as of the calculation date.
категория «Счета»
объект Счёт
сумма является деньгами
«дней просрочки» является числом
утилита «Начислить пени»
принимает Счёт
возвращает деньги
начинает с 0
правило «Просрочка более 30 дней»
если «дней просрочки» больше 30
то добавить 1 процент от поля сумма
пример «Просрочка 45 дней»
дано сумма равна 100000
дано «дней просрочки» равно 45
ожидается результат равен 1000
A nice side effect: “days of delinquency” is visible in the logs and in the examples, and the dispute over whether a payment day is counted is resolved at the level of one field.
6. Randomness: A/B, sampling, random discount
Task. Give a discount to half of the customers and compare the conversion.
правило «Половине покупателей»
если случайное меньше 0.5
то добавить 10 процентов от поля сумма
What will happen. FTS_UTILITY_FIELD | unknown utility input field 'случайное'. Attempt to make the action itself random
(то добавить случайное число процентов от поля сумма) is cut off earlier:
FTS_NATURAL_NAME | лишний текст после имени: 'число процентов от поля сумма'.
How to do it right. The application throws the dice — once, deterministically based on the client’s identifier, saving the branch. The model receives the branch as input and remains reproducible: by the order number, it is always possible to recalculate what discount it should have received.
категория «Продажи»
объект Покупка
сумма является деньгами
«ветка эксперимента» является строкой
утилита «Скидка эксперимента»
принимает Покупка
возвращает деньги
начинает с 0
правило «Ветка B получает скидку»
если «ветка эксперимента» равна «B»
то добавить 10 процентов от поля сумма
пример «Контрольная ветка»
дано сумма равна 20000
дано «ветка эксперимента» равна «A»
ожидается результат равен 0
Logic of Rules
7. Rules as if / else
The main catalog antipattern: it compiles, passes validate, and silently
counts the wrong thing.
Task. Five percent discount from one thousand rubles, ten — from ten thousand.
категория «Продажи»
объект Покупка
сумма является деньгами
утилита «Рассчитать скидку»
принимает Покупка
возвращает деньги
начинает с 0
правило «Базовая скидка»
если сумма не меньше 1000
то добавить 5 процентов от поля сумма
правило «Скидка за объём»
если сумма не меньше 10000
то добавить 10 процентов от поля сумма
What will happen. The model is valid. executeUtility at a total of 20000 returns
3000, not 2000: all rules whose conditions are true are executed, and five
percent are added to ten. At a total of 5000 the result is 250 — here expectation
and reality coincide, so the error often makes it to production.
Reordering the rules does not help: добавить is commutative, the order affects only
the order of the addends. “Otherwise” is not present in the language.
How to do it right. Mutual exclusion is expressed by conditions, not by order: each branch receives an explicit lower and upper bound, closed by an example.
категория «Продажи»
объект Покупка
сумма является деньгами
утилита «Рассчитать скидку»
принимает Покупка
возвращает деньги
начинает с 0
правило «Средний чек»
если сумма не меньше 1000
и сумма меньше 10000
то добавить 5 процентов от поля сумма
правило «Крупный чек»
если сумма не меньше 10000
то добавить 10 процентов от поля сумма
пример «Средний чек»
дано сумма равна 5000
ожидается результат равен 250
пример «Граница десяти тысяч»
дано сумма равна 10000
ожидается результат равен 1000
The “not less / less” pair — the only place where it is visible which branch
the boundary itself belongs to. In the if / else if chain this choice is not recorded anywhere.
8. Rule order and то результат равен
Action то результат равен does not accumulate but overwrites, and the rules
are still executed one after another:
категория «Продажи»
объект Покупка
сумма является деньгами
утилита «Ставка скидки»
принимает Покупка
возвращает число
начинает с 0
правило «Скидка за объём»
если сумма не меньше 10000
то результат равен 10
правило «Базовая скидка»
если сумма не меньше 1000
то результат равен 5
At a total of 20000 the result is 5. The habit of placing a special case above the general one works in the opposite way: the last rule that triggered wins. The treatment is the same — non-overlapping conditions, after which the order stops meaning anything.
Along the way: comparing a field to a field is not possible. если сумма больше поля лимит gives
FTS_NATURAL_NAME | лишний текст после имени: 'лимит', and the variant without the word
“field” silently converts лимит to a string and fails on
FTS_UTILITY_COMPARE_TYPE | comparison for 'сумма' uses incompatible values.
The difference calculates and passes as a field.
9. Property as a Result Correction
Task. Limit the total discount to twenty percent.
правило «Большая покупка»
если сумма не меньше 10000
то добавить 15 процентов от поля сумма
правило «Постоянный клиент»
если «постоянный клиент» равен да
то добавить 10 процентов от поля сумма
свойство «Скидка не больше 20 процентов»
результат не больше 20 процентов от поля сумма
What will happen. The model is valid, but with a sum of 20000 and a regular customer execution fails:
FTS_UTILITY_PROPERTY | нарушено свойство «Скидка не больше 20 процентов»
утилиты «Рассчитать скидку»
Property — postcondition, not clamp. It won’t clip 25 % to 20 %, it will drop
the calculation: in production this is a service denial to the most valuable customer. A property with
two lines is cut off even during the walkthrough — свойство «...» должно содержать одно сравнение результата, one invariant per one name.
How to do it right. The ceiling is defined in the rules, and the property remains a safeguard that must never trigger.
категория «Продажи»
объект Покупка
сумма является деньгами
«постоянный клиент» является признаком
утилита «Рассчитать скидку»
принимает Покупка
возвращает деньги
начинает с 0
правило «Крупная покупка постоянного клиента»
если сумма не меньше 10000
и сумма не больше 100000
и «постоянный клиент» равен да
то добавить 20 процентов от поля сумма
правило «Крупная покупка нового клиента»
если сумма не меньше 10000
и сумма не больше 100000
и «постоянный клиент» равен нет
то добавить 15 процентов от поля сумма
свойство «Скидка не превышает потолка»
результат не больше 20000
пример «Крупная покупка постоянного клиента»
дано сумма равна 20000
дано «постоянный клиент» равен да
ожидается результат равен 4000
Related case: percentage of a field that can be negative.
It is tempting to record the ceiling in the same way as the rule — as a percentage of the same field: результат не больше 20 процентов от поля сумма. On positive amounts this looks flawless. But the “percentage of a field” operand changes sign together with the field: at сумма = −100 the limit equals −20, while the result remains the initial zero, because no rule triggered. 0 не больше −20 is false — and the utility crashes with FTS_UTILITY_PROPERTY as input, which the rules didn’t even touch.
Exactly this error lived in the model order-discount.fts from the first chapter for two years: examples were green, check was silent, and the 20% ceiling at a maximum rule of 15% didn’t check anything at all — it could have been doubled, and no example would have changed. It was found not by example checking, but by the coverage map (ftsmap), which iterates over input regions, not points. Therefore, in the course model the ceiling is now defined as a number (результат не больше 15000), rules explicitly require сумма больше 0, and the upper bound is closed by a separate rule — the property became simultaneously achievable and impossible to violate.
The same correction was applied to the remaining course models: loyalty-tier.fts and loyalty-tier.v2.fts from the chapter on versioning, pricing-rules.fts from the chapter on lodash, the capstone model and the educational model from the utilities chapter. Otherwise it would be inconvenient: chapter 18 declares the practice an antipattern, while chapter 15 demonstrates it. Two exceptions were left intentionally and named in place — the transfer models in the chapter on lodash and the chapter on migration: there the limit must literally repeat the legacy formula, otherwise the discrepancies in the equivalence test will stop being discoveries in the old code.
Mirror case: lower bound coinciding with “starts with”. Property
результат не меньше 0 at начинает с 0 looks like a safeguard against
a negative result, but its equality to the limit is achieved exactly where
none of the rules applied—that is, the limit does not separate any calculation from
another. ftsmap calls this FTSMAP_PROPERTY_UNATTAINABLE: “the limit is unreachable
in the area where rules change the result.” This is treated not by editing
the property, but by moving the boundary into the rules: in delivery-price.fts the base tariff
previously lived in line начинает с 300, and property результат не меньше 300
repeated the declaration; now the base tariff is covered by two mutually exclusive rules
(расстояние меньше 500 and расстояние не меньше 500), the utility starts from zero,
and the same limit became reachable—while at the same time the gaps in the partition disappeared. Where
the lower bound is zero (bonus deductions in pricing-rules.fts), the property
was removed entirely, and the boundary is held by the condition of rule если «баланс бонусов» не меньше 0: an invariant equal to the declaration is better not to write than to write.
Examples and Proofs
10. Model without examples
A model that passes fts check without a single пример is a public offer.
validate will return valid: true; an attempt to run tests will return
FTS_NO_UTILITY_EXAMPLES | утилиты не содержат примеров, and for a document without utilities —
FTS_NO_UTILITIES | документ не содержит утилит.
Type checking says that the model is consistent, and says nothing about whether it computes correctly. A specification becomes text when it contains numbers agreed upon by the business: a minimum per example per branch and an example per boundary.
11. Example, fitted to the implementation
The quietest way to devalue a construct. Take the model from item 7 and add a fair expectation: the business promised a 2000 discount at 20000.
{"valid": false, "total": 1, "passed": 0, "failed": 1,
"results": [{"example": "Крупная покупка", "expected": 2000, "actual": 3000}]}
Ahead is a fork. The correct move is to fix the rules: the discrepancy is the discovery.
The tempting one is to fix ожидается результат равен 3000: CI is green, the commit
is small. After this, the example ceases to be a requirement and becomes a snapshot
of behavior. The model calculates not what is promised to the client, but now no one
will find out about it.
Code review rule: changing line ожидается in the same commit as changing the rules is considered a change to the business agreement and requires a reference to the source — a ticket, an order, a tariff. Without a reference — a refund.
12. A theorem without data, passed off as proof of a fact
Task. Show the audit that the order ZK-7781 can indeed be shipped.
категория «Исполнение заказа»
объект Заказ
номер является строкой
«готов к отгрузке» является состоянием «Готов к отгрузке»
морфизм «Готовый заказ можно отгрузить»
если «Готов к отгрузке»
то «Отгрузить заказ разрешено»
теорема «Заказ ЗК-7781 можно отгрузить»
дано Заказ имеет «готов к отгрузке» равное да
в данных заказы найти где номер равен «ЗК-7781»
по морфизму «Готовый заказ можно отгрузить»
следовательно «Отгрузить заказ разрешено»
What will happen. Without the data prove will output with
Готовый заказ можно отгрузить ∘ π_готов к отгрузке. Exactly the same output
will be obtained with the data — visually they are indistinguishable. The difference is visible in the certificate:
certify without context gives status: "symbolic", and in assumptions it is recorded
symbolic witness Заказ.готов к отгрузке. A strict check does not
accept such a certificate:
FTS_CERTIFICATE_SYMBOLIC | proof is symbolic; provide complete witness
context for strict verification
With the context the status becomes verified, and the fake data is caught immediately:
FTS_WITNESS_MISMATCH | witness does not match context at заказы[номер="ЗК-7781"].готов к отгрузке: expected true, got false.
How to do it right. Symbolic derivation shows that the reasoning is correct.
To assert a fact about a specific order, a certificate with status
verified and a matching context_digest is required. The auditor gets the status, not the
arrows’ picture.
Model Boundaries
13. Giant Category
Task. “Describe a company with one model, so that everything is in one place.”
The first thing such a model will stumble upon is homonyms. An order in sales and an order in
logistics are different, and a similarly named declaration is rejected:
FTS_DUPLICATE_STRUCTURE | duplicate structure 'Заказ'. Attempting to merge them into one
object results in FTS_DUPLICATE_FIELD | duplicate field 'Заказ.номер', as soon as the
two departments diverge in the type of one field.
Further, a less obvious mechanics comes into play: the utility accepts exactly one object, and the example must specify all its fields. An object with six fields, of which the rule uses one, gives five diagnostics for each example:
FTS_UTILITY_EXAMPLE_FIELD | example 'Большая покупка' misses 'номер'
FTS_UTILITY_EXAMPLE_FIELD | example 'Большая покупка' misses 'вес отправления'
FTS_UTILITY_EXAMPLE_FIELD | example 'Большая покупка' misses 'регион доставки'
FTS_UTILITY_EXAMPLE_FIELD | example 'Большая покупка' misses 'кредитный рейтинг'
FTS_UTILITY_EXAMPLE_FIELD | example 'Большая покупка' misses 'канал продаж'
This is not a quibble, but a coherence meter: the cost of an example grows linearly with the object size, and a god-object makes examples unportable long before it becomes uncomfortable to read the model.
How to do it right. A category is a bounded context: one language, one owner, one reason for change. A utility object contains only what is involved in its solution, while intersections are represented by separate objects with their own names.
14. String Statuses Instead of States and Morphisms
Task. «An order has a status, compare it with a string — why states».
объект Заказ
статус является строкой
правило «Готовый заказ»
если статус равен «готов к отрузке»
то результат равен 1
What will happen. Compiles. A typo in «готов к отрузке» — an ordinary
string literal, the compiler has nothing to compare it to. The rule never
triggers; with a live example this is visible as expected 1, actual 0, without an example
— not at all.
Named states behave differently, although not immediately. The same typo in
the morphism also compiles: domain Готов к отрузке — a legal, but unreachable
type. As soon as a theorem appears that relies on the actual state of the object,
the inconsistency becomes a type error:
FTS_PROOF_TYPE_MISMATCH | морфизм «Готовый заказ можно отгрузить»
ожидает «Готов к отрузке»,
получено «Готов к отгрузке»
How to do it right. Everything to which transitions and permissions are bound is declared as a state, transitions — as morphisms, and at least one transition is closed by a theorem. Then a typo is caught by the compiler, not the warehouse operator. A string remains a string where it is really data: number, region, experiment branch.
Practice in the sandbox
Add the fourth rule “Average Check”: if the amount is no less than 1000, then add 7 percent of the amount field. Perform the calculation for the amount 100000 and a regular customer. Three out of the four rules will trigger, accumulating 22 percent of the amount — 22 000 against the ceiling of 15 000 — and you will receive
FTS_UTILITY_PROPERTY | нарушено свойство «Скидка ограничена» — items 7 and 9
of this catalog in one action.
Add the field «курс валюты» является запросом to the object and ensure that check
passes. Then use the field in the rule condition and find in the output
FTS_UTILITY_COMPARE_TYPE.
Adjust the threshold in any rule so that one example stops matching. Then
adjust the string ожидается to match the new result and formulate in one
sentence what the model stops guaranteeing.
Find the section assumptions and determine which assumptions the compiler checked
based on the data, and which it accepted as declared business laws.
Checklist for FTS-model code review
- Are there action verbs in the rules — “send”, “charge”, “repeat”?
- Is the codomain of the morphism named as a fait accompli instead of a resolution?
- Are there fields with unfamiliar types after
является? Make sure this is an intentional state, not a silently acceptedзапросомorсписком. - Are there
сегодня,сейчас,случайноеin the conditions? The external comes as a field. - Can two rules trigger simultaneously? Then it is addition, not a choice.
- Are there
то результат равенwith overlapping conditions? The last one wins. - Is each boundary closed with an example — the value exactly at the threshold, not nearby?
- Does
свойствоnot act as a limiter? It drops the calculation, not corrects it. And is its limit reachable on at least one input — otherwise it is a dead line. Is the limit not recorded as a percentage of a field that can be negative? Does the limit not coincide withначинает с— then the property repeats the declaration, not checks the calculation. Both checks are done byftsmap <модель>.fts --text. - Are there examples at all and do they converge?
valid: truewithout them is a declaration. - Has the line
ожидаетсяchanged together with the rules without referring to a business decision? - Are all object fields needed by the utility? Extra ones are paid for in each example.
- Are there objects from a foreign context in the category — by name, by owner, by reason for change?
- Does the report about a proven fact rely on a certificate
verified, rather than symbolic derivation?