# nb-04 — The permit fee *Northbridge course, beginner ([nb-01](/tutorials/northbridge/nb-01-first-permit/) → [nb-02](/tutorials/northbridge/nb-02-missing-fact/) → [nb-03](/tutorials/northbridge/nb-03-exceptions/) → [nb-04](/tutorials/northbridge/nb-04-permit-fee/)). All law is fictional; every act, rate and discount is synthetic and unofficial. Engine `law 0.1.0`, semantics `law.core/0.2`, std `0.2.0`.* ## Situation Northbridge charges for permits. The clerks apply a simple desk rule: every month costs 10 EUR, so three months cost 30 EUR — but anyone who commits to twelve months or more gets a 20 percent discount. A short application pays full price; a long one pays less per month. This article turns that desk rule into three Law DSL constructs: a typed constant for the rate, a pure function for the fee, and a decision table for the discount. You will run the fee, discount and otherwise tests, watch which table row fires, then change one input and watch a different row win. ## Prerequisites [nb-01: First permit: facts, a rule and a question](/tutorials/northbridge/nb-01-first-permit/): facts, one rule, one question, and `law test` as the way to check a claim. This article adds nothing to support or truth statuses — everything here computes a *value* (a sum of money, a discount rate), not a proposition. ## Example Excerpt from `packs/examples/language-demo/calculations/package.law` (lines 8–18, identifiers as written): ```law pub const MONTHLY_RATE: Money = 10 EUR; pub const LARGE_HOUSEHOLD: Integer = 4; pub function permit_fee(months: Integer) -> Money = months * MONTHLY_RATE; pub decision LongTermDiscount(months: Integer) -> Decimal { table hit unique { when months >= 12 => 20 percent; otherwise => 0 percent; } } ``` Three ideas in eleven lines: - **Value types travel with their meaning.** `MONTHLY_RATE` is not a bare number, it is `Money` in EUR; `months` is an `Integer`. The types are written next to the names in the declarations, and they stay attached to the values: `permit_fee(3)` is `30 EUR`, not `30`. A fee in a different currency would not add up with it. - **`const` names one value once.** The rate appears in exactly one place. If the council ever changes the price, one line changes, and every computation that reads the constant follows. - **`function` is pure.** `permit_fee` returns a data value and nothing else: no assertions, no norms, no reading of ambient state. Given the same `months` it always returns the same `Money`. The discount is a **`decision`**: a named table mapping inputs to an output, one row at a time. `hit unique` means at most one row may match; `otherwise` is the row for everything else. The package holds six more tables over the same input, each with a different hit policy: Excerpt — `TieredRate` from `packs/examples/language-demo/calculations/package.law` lines 20–26 (nothing cut inside the block; surrounding decisions cut). ```law pub decision TieredRate(months: Integer) -> Decimal { table hit first { when months >= 24 => 0.5; when months >= 12 => 0.2; otherwise => 0.0; } } ``` `hit first` takes the first matching row in table order. Look at the guards: for 30 months both `months >= 24` and `months >= 12` hold, and the table answers with the first row's value, `0.5`. The remaining policies each handle overlap differently. `hit any` requires all matching rows to agree. `hit multiple` keeps every match as a list. `hit collect aggregate count` (or `sum`) folds all matches into one number. `hit priority order 0.5, 0.2, 0.0` ignores row order and picks the matched value with the highest listed priority: Excerpt — `PriorityRate` from `packs/examples/language-demo/calculations/package.law` lines 59–65 (nothing cut inside the block; surrounding decisions cut). ```law pub decision PriorityRate(months: Integer) -> Decimal { table hit priority order 0.5, 0.2, 0.0 { when months >= 12 => 0.2; when months >= 24 => 0.5; otherwise => 0.0; } } ``` Note the trap built into that table on purpose: the `0.2` row is listed *first*. A reader in a hurry expects row order to decide. It does not — the priority list decides, and the tests below prove it. ## Command and result The calculations package is self-contained (its test world is "own only"), so one command checks everything: ```sh law test packs/examples/language-demo/calculations ``` Observed result (engine `law 0.1.0`): ```text law test demo.northbridge.calculations: мир demo.northbridge.calculations ok [demo.northbridge.calculations] tests/calculations.lawtest / fee for three months ok [demo.northbridge.calculations] tests/calculations.lawtest / discount for twelve months ok [demo.northbridge.calculations] tests/calculations.lawtest / short term — otherwise row ok [demo.northbridge.calculations] tests/calculations.lawtest / first takes the first matching row ok [demo.northbridge.calculations] tests/calculations.lawtest / any lets matching rows agree ok [demo.northbridge.calculations] tests/calculations.lawtest / multiple keeps all matches ok [demo.northbridge.calculations] tests/calculations.lawtest / count of matching rows ok [demo.northbridge.calculations] tests/calculations.lawtest / sum of matching rows ok [demo.northbridge.calculations] tests/calculations.lawtest / priority selects by list, not by rows итого: 9 проверено, 9 прошли, 0 не прошли, 0 не исполнены; код 0 ``` The package itself also passes the static check: ```sh law engine check packs/examples/language-demo/calculations/package.law ``` ```text check OK: packs/examples/language-demo/calculations/package.law ``` Three of those nine tests carry this article: | Test | Evaluates | Expects | |---|---|---| | `fee for three months` | `permit_fee(3)` | `30 EUR` | | `discount for twelve months` | `LongTermDiscount(12)` | `0.2` | | `short term — otherwise row` | `LongTermDiscount(3)` | `0.0` | The fee test is pure arithmetic with the currency attached: `3 * 10 EUR = 30 EUR`. The two discount tests show the table's two rows: twelve months match the `months >= 12` row (`20 percent`, i.e. `0.2`); three months match nothing, so the `otherwise` row fires (`0 percent`, i.e. `0.0`). Which band applied is read off from the value together with the hit-policy tests below — the table's observable behavior is the selected value, not a row label. The remaining six tests pin the hit policies. `TieredRate(30)` is `0.5`: both percentage rows match, the first one in table order wins. `AnyMatch(18)` is `0.2`: two rows match and agree, so the answer stands. `MatchedCount(18)` is `2`: two rows match, and the count says so explicitly. `SumBonus(30)` is `0.7`: the collect-sum folds `0.5 + 0.2`. `PriorityRate(30)` is `0.5` — even though the `0.2` row stands first in the table, the priority list `0.5, 0.2, 0.0` outranks row order. All nine tests pass: each computed value matched its expectation. The summary is in Russian: 9 tests checked, 9 passed, 0 failed, 0 skipped, exit code 0. The first line names the test world (`мир` is "world"): the "own only" world from the package header. ## Why this shape The task is to turn "three months cost 30 EUR, twelve months earn a discount" into something the machine computes the same way every time. A constant fixes the rate in one place, a pure function fixes the arithmetic, and a table fixes the discount bands — each with an exhaustive row (`otherwise`), so no input falls through silently. Writing the discount as nested `if` expressions inside the function would compute the same numbers but hide which band applied. Writing each band as a separate rule would need conflict machinery ([nb-03: Exceptions and conflicting rules](/tutorials/northbridge/nb-03-exceptions/)) for inputs that match two bands. The table keeps overlap handling inside one declared policy. The proof is the nine tests above — each input/output pair plus the row-selection behavior (`first`, `priority`, `count`, `sum`) is executed by `law test`, not asserted in prose. What they do not prove: that 10 EUR or 20 percent is the *right* price. The tests prove the machine computes the declared rates; no test can prove the council chose wisely. Rates are fictional data, not legal advice. ## Changed condition Change one input: months `3` → `12` in the discount question. `LongTermDiscount(3)` matches no guarded row, so `otherwise` fires and the answer is `0.0`. `LongTermDiscount(12)` satisfies `months >= 12`, so the first row fires and the answer is `0.2` — the `20 percent` discount. Same table, same policy, different row wins, and the answer says which one. The same input change moves through the other tables too: `TieredRate(12)` is `0.2` (only the second row matches), while `TieredRate(30)` is `0.5` (both match, first wins). One input, two outcomes, and the policy — not the reader's intuition — picks between them. ## Typical mistake Expecting the table to *add up* rows that merely overlap. A newcomer sees `TieredRate` match two rows for 30 months and guesses the answer combines them — `0.5 + 0.2 = 0.7`. It does not: `hit first` returns one row's value, `0.5`. The `0.7` exists, but only where it is declared: `SumBonus` uses `hit collect aggregate sum`, and *that* table returns `0.7` for 30 months. Likewise `hit multiple` does not return a number at all — `AllMatches(18)` keeps all matches as a list, which is why its test expects `COMPUTED` status rather than a scalar value. Overlapping rows are normal; what happens with the overlap is the policy's job. `first` picks one, `any` demands agreement, `multiple` keeps all, `collect` folds them, `priority` ranks them. If you want a sum, write `sum` — never assume the table sums by itself. ## Limits - **Pure functions only.** `function` and `decision` compute values; they assert nothing, create no duties, and read no register. Anything with support effects — evidence, closure, positions — lives elsewhere ([nb-06: When the register may stay silent](/tutorials/northbridge/nb-06-register-silence/) through [nb-09: From permit to duties and powers](/tutorials/northbridge/nb-09-duties-powers/)) and cannot appear in these bodies. - **Money is exact.** `3 * 10 EUR` is exactly `30 EUR`. There is no silent rounding anywhere in this package — and deliberately so: rounding, when a computation needs it, is an explicit call, `round(value, precision, mode)` or `div_round(dividend, divisor, precision, mode)`, with a named mode such as `"HALF_UP"`. This package needs no rounding, so it calls none; the confirmed rounding example lives one level up (see below). - **Tables are total only with `otherwise`.** A table without an `otherwise` row (like `AllMatches` or `MatchedCount`) simply has no answer for inputs that match nothing. Totality is a property you declare, row by row — the engine does not invent a default. ## Three levels 1. **Northbridge use** (this article): rate constant, fee function, discount and tier tables, verified by the nine tests above. 2. **Domain template:** whenever a fee, tariff or quota depends on bands of one input, use one decision table with one hit policy and an `otherwise` row; expose "which row fired" in the answer; fold (`count`/`sum`) only where the domain asks for a fold. 3. **Confirmed example elsewhere:** explicit rounding in `demo.northbridge.math02` — `half_rounded(x) = round(x, 0, "HALF_UP")` and `ratio_seventh() = div_round(7.0, 2.0, 1, "HALF_UP")` — verified by `law test packs/examples/language-demo/math02` (42 checked, 42 passed, 0 failed). Rounding exists in the suite, it just does not live in the fee package — exactly as the limits say. Confirmed external formalization: high-36 average retired-pay base (10 U.S.C. section 1407(c)(1)) — package `us.code.military_retirement`, `corpus/laws/us/military-retirement/01-retired-pay.law:271-281`, construct `div_round(Money, Decimal, scale, policy)` single-rounding share (`BaseHigh36`: `div_round(t, 36.0, 2, "HALF_UP")`). Money divided by a Decimal divisor with one rounding over the exact quotient under an explicit policy — the explicit, named-mode rounding the Limits section requires.
Evidence and scope of the external example Documented in `docs/research/constructs/21-expressions-quantities/corpus-forms.en.md`, section 3 (rated exemplary there). Verification confirms the named construct at the cited lines only, by direct source read; it claims nothing about deployment, runtime behavior, or legal correctness.
## Exercise Without running the engine, predict, then check with `law test`: 1. `TieredRate(12)` — value and which row fires? 2. `PriorityRate(12)` — value and which row fires? 3. `PriorityRate(30)` — value? What would the answer be if selection followed row order instead of the priority list? 4. `MatchedCount(18)` — value, and what does it tell you about how many rows matched? Write down each prediction first; run the suite; explain any miss in one sentence. Checkable solution: [the nb-04 solution](/tutorials/northbridge/solutions/nb-04-solutions/). ## Links - Source: `packs/examples/language-demo/calculations/package.law` - Tests: `packs/examples/language-demo/calculations/tests/calculations.lawtest` - Suite tour: `packs/examples/language-demo/README.md` - Language reference: `docs/language/02-facts-and-questions.law.md` (constants, pure functions, Money), `docs/language/08-cheat-sheet.law.md` (values), `docs/language/09-advanced-cheat-sheet.law.md` (`decision`, hit policies, `round`/`div_round`/`sum` signatures) - Rounding source: `packs/examples/language-demo/math02/package.law` (`half_rounded`, `ratio_seventh`) - Prerequisite: [nb-01: First permit: facts, a rule and a question](/tutorials/northbridge/nb-01-first-permit/); next: [nb-05: Who counts as a suitable applicant](/tutorials/northbridge/nb-05-suitable-applicant/)