0015. Encode each country's rules as dated, sourced data behind one contract
- Status: accepted
- Date: 2026-10-26
Context
Section titled “Context”Chapter 6 of the analysis designs the country packs: the core knows what every country shares, and a pack brings the rules of one country. That includes taxes, fiscal documents, guest reporting, identity, data retention and the presentation of prices. The research report (i18n-research.md, section D) confirms that design and adds precisions.
What shapes the code:
- Rules change, and the date that decides is not always the same. Italy rounds cash totals to 5 cents only from 1 January 2018 (DL 50/2017, art. 13-quater). A tax may follow the night served, a document the day it is issued.
- Jurisdictions overlap. A region, a province and a municipality each have their own codes and authorities.
- Some answers are not yet known with certainty. For example, whether the Italian tourist tax belongs in the total shown to consumers, or the exact boundary of the 24-hour rule of Alloggiati Web.
- Validation. Passing tests does not make a pack legally correct; a professional of the country has to validate it.
Decision
Section titled “Decision”Cash rounding (payments.cash_rounding) and consumer prices (prices.consumer)
are planned: not yet consumed by payment, folio or pricing workflows. Their
dated data, pure calculations and startup validation are implemented; these
contracts do not yet provide an operator workflow.
The contract lives in core/countries, pure like the rest of the core.
-
Kinds of rules.
RuleKindsmaps each capability to its kind of rule: the value of each version, and the date that decides which version applies.payments.cash_roundingis aCashRoundingRule, decided by the day of the payment;prices.consumeris aConsumerPriceRule, decided by the day the price is offered.
A capability joins the contract with its kind of rule. The function that applies the rule takes that date (
roundCashTotal(policy, total, paidOn),consumerPriceRule(policy, offeredOn)), and the types refuse a value of another kind. -
Dated rules. A
DatedRule<T>has:- a first day, included, and optionally a last day, excluded;
- its sources;
- its value, or
nullwhen the sources say that no such rule applies in the period, as after a repeal.
checkRulesrefuses overlaps, empty periods, unsorted versions and versions without a source.ruleOnreturns the version in force on a date. -
Known absence and gaps. Between versions there may be gaps, where nothing was checked. A use case tells them apart: the functions that apply rules answer
nonefor a known absence andnot_coveredfor a gap, and the preflight reports what is not covered. A gap is never read as “the law requires nothing”. -
Dates come from the version history of the source. A version starts on the day its provision took effect, as the source’s version history shows: Normattiva lists every version of an article. Each source:
- links to the version read, such as Normattiva’s
!vig=links; - quotes the words relied on;
- records the day it was read.
Where the history cannot be verified, the version starts at the earliest version verified, and the earlier period stays a gap: a date is never guessed.
- links to the version read, such as Normattiva’s
-
One date per rule. A transition that depends on two dates, such as stays from one day booked before another, is not a dated rule. When one arrives, the pack expresses it as a function, with its sources and tests.
-
Jurisdictions. Rules for a part of a country, such as a municipal tourist tax, will name it by a code under a named scheme, such as
ISO-3166-2IT-52orISTAT048017. Jurisdictions overlap, so they are references, not one hierarchy. The type arrives with the first such rule. -
Manifest. A
CountryPackManifestgives:- the pack’s identifier and the countries it covers;
- the contract version, and the ruleset version (
YYYY.MM.N), separate from the code’s; - the status:
experimental, orvalidatedwith who validated it and when; - its maintainers.
-
Policy and capabilities. A
CountryPolicyis a manifest and the versions of each capability’s rule. Its capabilities are the ones it has versions for (capabilitiesOf): a pack cannot declare what it does not provide.- Cash rounding and consumer prices are the capabilities now.
- Taxes, fiscal documents, guest reporting, identity and data policy join as their layers arrive, in the shape of chapter 6. Those implemented by adapters will join the same way.
-
Registry. The composition root builds it once (
countryPackRegistry) from the packs it includes. The registry:- takes at most one pack per country;
- refuses a pack whose versions do not pass
checkRules, or whose values the core could not apply (checkRuleValues: a positive whole increment, a known rounding mode, no part listed twice); - loads nothing dynamically.
A country without a pack supports no capability, and the preflight says so.
The packs live in @guestcraft/countries (AGPL), one subpath per country.
- Rules and adapters. The rules are pure data and functions, under the core’s gates: no clock, randomness, locale or I/O, which Biome and dependency-cruiser check. Adapters (protocols, portals, devices, credentials per legal entity) come later, in
adapters/subpaths outside those gates. it. The Italian pack is the reference. It is experimental until an Italian professional validates it, and holds only rules checked against primary sources:- cash rounding to 5 cents for totals paid wholly in cash, from 1 January 2018 (DL 50/2017, art. 13-quater, commi 1, 2 and 6);
- consumer prices with taxes included, from 21 September 2007, when the decreto legislativo 146/2007 rewrote articles 18 to 27 of the Codice del consumo (the Italian Consumer Code). The words of art. 22, comma 4, lettera c are the same in every version since.
fictitious. A country that does not exist, for tests. It has consumer prices without taxes, a rule that changes over time, a year when the rule was repealed, and nothing checked before 2020. It covers no country, so no installation can choose it.- Conformance kit. CI runs
checkPackon every pack, and the composition root runs it at startup with the date of its clock, refusing to start on a problem. It checks:- a well-formed manifest: the identifier, a ruleset version of a past or current month, who validated a validated pack and when, maintainers with names;
- at least one capability, and no empty list of versions;
- valid dated rules, with values that the core’s functions can apply, as the registry checks too;
- sources with a title, an https link with a host, the words relied on if given, and a reading no later than today.
Rules for the code.
- Use cases never test a country code: they ask the property’s pack.
- A language or a locale never chooses a pack: the property’s country does.
- A pack never formats text, numbers or dates (ADR 0012).
Consequences
Section titled “Consequences”- Adding a country means adding a subpath with a manifest, sourced rules and their tests, and registering it in the composition root. The kit says when the pack is incomplete.
- Adding a capability means a member of
RuleKinds, its value checks in the kit, and the function that applies it. The compiler finds every place to change. - The history of a rule stays in the code, with its sources and their words. A document can always be explained by the version in force at its date.
- Open questions stay visible as missing rules rather than hidden in code, until a professional answers them:
- whether the tourist tax and other mandatory charges belong in Italian consumer totals. Art. 49, comma 1, lettera e of the Codice del consumo (“ogni altro costo”, any other cost) is the candidate source for distance contracts, such as a booking engine makes;
- whether a cash refund is rounded like a payment;
- whether the 6-hour deadline of Alloggiati Web applies to stays of up to 24 hours or of less than 24 hours.
- Updating a rule is a pull request with its source, reviewed like code. Installations choose when to update.
Alternatives considered
Section titled “Alternatives considered”- Rules in a database edited at runtime. Rejected: rules are code that must be reviewed, tested and versioned with the release, and an installation should not invent its own tax law.
- A universal rule engine with expressions. Rejected in chapter 6: each country’s algorithm is clearer and safer as code, with data as its parameters.
- One package per country. Possible later. One package with subpaths keeps the boundaries simple while there are few packs.
- Guessing a rule’s history back to its origin. Rejected: an unverified date is worse than a gap, which the use case can handle explicitly.
- Starting every rule at the version that was read. Rejected after the first review: it denies a rule that the version history shows in force. The Italian consumer price rule would have started in 2026 instead of 2007.
- Declaring the capabilities in the manifest. Rejected: the list repeated the rules provided, and only a runtime check kept the two in step.