docs← Back to article

Markdown for LLMs

Exceptions and conflicting rules

The source Markdown for this article. Copy it into your assistant or download it as a text file.

Download this articlePlain text ↗
# Exceptions and conflicting rules

## Situation

Ann is a Northbridge resident with a registered car, so the general rule
says she is eligible for a parking permit. Then the office learns more:
her permit was suspended; or she has outstanding fines; or a badge case
carries a fraud flag.

Those are four situations where a plain grant is not the answer,
and the engine gives four observably different answers. This article
shows which mechanism to use for each, and what goes wrong when they
are mixed up.

Northbridge is a synthetic training town: every act, office and
calendar here is fictional, and nothing in this article is legal advice
or a claim about real legislation.

## Prerequisites

- [nb-01: First permit: facts, a rule and a question](/tutorials/northbridge/nb-01-first-permit/) —
  writing a fact and a rule, running the first test.
- [nb-02: Why a missing fact is not a refusal](/tutorials/northbridge/nb-02-missing-fact/) —
  the four truth statuses (`TRUE_ONLY`, `FALSE_ONLY`, `BOTH`,
  `NEITHER`) and explicit negation.

This article assumes only those two: facts, one rule and `law test`
from the first; the four-valued outcome of a question from the second.
No advanced constructs are required.

## Minimal example

Base rule: a resident with a registered car is eligible. The rule is
`defeasible`, meaning its conclusion can be withdrawn when an
exception applies — unlike a `strict` rule, which admits none.

Excerpt (package `demo.northbridge.permits`,
`packs/examples/language-demo/permits/package.law`; imports, types and
unrelated rules cut):

```law
rule PermitEligibility defeasible {
    for a: Applicant;
    when demo.northbridge.vocabulary::resident(a) and demo.northbridge.vocabulary::vehicle_registered(a);
    then permit_eligible(a);
    unless permit_suspended(a);
}
```

Read the block top to bottom: `for` names the applicant, `when` states
the two qualifying facts, `then` draws the eligibility conclusion, and
`unless` names the one fact that switches the whole rule off. `unless`
is an exception written inside the rule: when `permit_suspended(a)`
holds, this rule silently produces nothing — it does not argue against
eligibility, it just stays out.

A competing rule argues the opposite — fines mean refusal — and a
`priority` names the winner. A third route, the disability badge, has
no priority against the fines rule, so that conflict stays visible.

Excerpt (same package and file; only these three blocks shown):

```law
rule FinesRefusal defeasible {
    for a: Applicant;
    when outstanding_fines(a);
    then not permit_eligible(a);
}

rule BadgeEligibility defeasible {
    for a: Applicant;
    when disability_badge(a);
    then permit_eligible(a);
}

priority RefusalOverEligibility {
    prefer FinesRefusal over PermitEligibility;
    reason lex_specialis;
}
```

Note what each block contributes. `FinesRefusal` concludes
`not permit_eligible(a)` — the explicit negation, not silence.
`BadgeEligibility` offers a second, independent route to eligibility
from `disability_badge(a)`. The `priority` block ranks only the first
pair: `prefer FinesRefusal over PermitEligibility`, with the reason
`lex_specialis`. No line ranks `FinesRefusal` against
`BadgeEligibility`, and that gap is deliberate — the suite shows the
resulting conflict.

A fourth mechanism, the `defeater`, blocks evidence without asserting
the negation. Fraud does not prove Bob ineligible; it only stops his
badge from counting in his favour.

Excerpt (same package and file; only this block shown):

```law
rule FraudBlocksBadge defeater {
    for a: Applicant;
    when fraud_flag(a);
    defeat permit_eligible(a);
}
```

Note the verb: `defeat permit_eligible(a)` names the conclusion it
blocks, but the block has no `then` line — there is no conclusion at
all. Contrast with `FinesRefusal`, whose `then not
permit_eligible(a)` records a refusal.

## Command and result

The suite `packs/examples/language-demo/permits` contains one test per
mechanism. Command (run from the repository root):

```sh
law test packs/examples/language-demo/permits
```

Observed result (exception and conflict lines; full run: 28 checked,
28 passed, 0 failed):

```text
  ok   [demo.northbridge.permits] tests/permits.lawtest / unless removes the conclusion
  ok   [demo.northbridge.permits] tests/permits.lawtest / denial wins by priority
  ok   [demo.northbridge.permits] tests/permits.lawtest / without priority the conflict persists
  ok   [demo.northbridge.permits] tests/permits.lawtest / defeater removes support without negation
итого: 28 проверено, 28 прошли, 0 не прошли, 0 не исполнены; код 0
```

Each test asks the same shape of question —
`truth(permit_eligible(...))` — with the facts and outcomes below
(`tests/permits.lawtest`, `expect truth_status == ...`):

| Test | Facts on file | Result |
|---|---|---|
| `unless removes the conclusion` | resident + vehicle + suspended (Ann) | `NEITHER` |
| `denial wins by priority` | resident + vehicle + fines (Ann) | `FALSE_ONLY` |
| `without priority the conflict persists` | badge + fines (Bob) | `BOTH` |
| `defeater removes support without negation` | badge + fraud flag (Bob) | `NEITHER` |

All four tests pass: each answer matched its expectation. A passing
test says the mechanism behaved as declared; it does not say the office
made the right call on any applicant.

The summary line is in Russian: 28 tests checked, 28 passed, 0 failed,
0 skipped, exit code 0.

The badge-route scenarios use Bob (`urn:demo:northbridge:bob`)
because the badge rule, not the resident rule, is the positive side of
that conflict; identifiers are as written in the suite. The package
also passes a structural check: `law engine check
packs/examples/language-demo/permits` prints `check OK`.

## Why this construct

The task each mechanism solves:

- `defeasible` marks a rule as overridable. It states the general case
  — residents with cars qualify — while leaving room for exceptions.
  Without it, every exception would have to be written as a negated
  precondition, and the rule would rot with each new one.
- `unless` handles an exception owned by the rule itself: suspension
  is part of what "eligible under this rule" means. The rule withdraws
  its own conclusion; nothing else changes.
- `priority` resolves a genuine disagreement between two rules that
  both fired. The office has two applicable norms — residents qualify,
  debtors are refused — and must record a refusal, not a shrug. The
  priority flips the outcome from `BOTH` to `FALSE_ONLY`.
- `defeater` blocks a piece of support without taking the other side.
  A fraud flag taints the badge evidence, but the office has said
  nothing about Bob's eligibility as such. Support is removed, so the
  answer is `NEITHER` — and, crucially, no negation is asserted.

Why these and not something simpler? A `strict` rule cannot be excepted
at all, so it cannot express any of the four rows. A negated
precondition (`and not permit_suspended(a)`) behaves like `unless` here
but spreads the exception logic across every rule body instead of
naming it. An explicit `not permit_eligible(a)` conclusion (like
`FinesRefusal`) is right for the fines case — the office does refuse —
but wrong for fraud, where no refusal was decided.

What proves each action: the priority pair is the proof. The same kind
of conflict — one rule for, one rule against `permit_eligible` — yields
`BOTH` where no priority applies (`without priority the conflict
persists`) and `FALSE_ONLY` where `RefusalOverEligibility` applies
(`denial wins by priority`). The defeater proof is the contrast
between its `NEITHER` and a refusal's `FALSE_ONLY`: the fraud test
expects `NEITHER` — support gone, negation absent.

What the example does NOT prove: it does not prove that `lex_specialis`
is the correct legal ground for preferring refusals — the reason is a
label in a teaching package, not a holding. It does not prove anything
about real parking law.

## Changed condition

Remove the priority and the conflict returns. The suite demonstrates
exactly this with the badge rule pair: `BadgeEligibility` (for) and
`FinesRefusal` (against) have no priority between them, so when Bob has
both a badge and outstanding fines, both rules fire and neither yields:

```text
  ok   [demo.northbridge.permits] tests/permits.lawtest / without priority the conflict persists
```

with `expect truth_status == BOTH`. Compare with Ann's fines case —
the same `FinesRefusal` against a different positive rule
(`PermitEligibility`), but *with* `RefusalOverEligibility` in force:
`FALSE_ONLY`. The priority is what turns this shape of conflict into
a refusal: where a ranking applies, the denial wins; where none
applies, the conflict persists as `BOTH`.

A `BOTH` answer is the engine telling the office that two applicable
norms disagree and no ranking was given — work for the drafter, not a
decision.

## Typical mistake

Writing the fines refusal as an `unless` clause on the eligibility
rule instead of a separate rule with priority:

Sketch — the wrong shape on purpose (the extra `unless` line is the
mistake); contrast with the real rule in `permits/package.law`.

```law
rule PermitEligibility defeasible {
    for a: Applicant;
    when demo.northbridge.vocabulary::resident(a) and demo.northbridge.vocabulary::vehicle_registered(a);
    then permit_eligible(a);
    unless permit_suspended(a);
    unless outstanding_fines(a);   // mistake: refusal modelled as exception
}
```

Observable consequence: with outstanding fines, the question returns
`NEITHER` (no rule speaks) instead of `FALSE_ONLY` (the office
refuses). Downstream, everything built on a refusal breaks: duties and
positions that trigger on `not permit_eligible(a)` never activate, and
the file records no decision, only silence.

The fix: if the office must be able to say "refused", the refusal needs
its own rule concluding the negation, plus a priority. `unless` can
only ever produce silence.

## Limits

- A `priority` needs explicit grounds: `prefer X over Y` with a
  `reason`. Without the block, conflicting defeasible rules yield
  `BOTH` — the engine never invents a ranking.
- A `defeater` is not a negation. `defeat permit_eligible(a)` removes
  support; it never produces `FALSE_ONLY` by itself. Do not use it
  where a refusal must be recorded.
- `unless` belongs to one rule only: it blocks that rule's conclusion
  and nothing else. It cannot resolve two rules that both fired.
- Verified profile: engine `law 0.1.0`, semantics `law.core/0.2`, std
  `0.2.0`. Refusal surfaces (e.g. what the engine rejects) are facts
  about this implementation, not claims about the language in general.

## Exercise

Bob holds a disability badge and has outstanding fines recorded. Two
questions, answered from the suite output and the test sources — no new
runs needed:

1. What is the status of `truth(permit_eligible(bob))`, and which test
   name and `expect` line say so?
2. Which single package element explains why Ann's fines case on the
   resident route answers `FALSE_ONLY` instead, and what would Ann's
   answer become if that element were deleted?

The solution lives only in
[the nb-03 solution](/tutorials/northbridge/solutions/nb-03-solutions/).

## Sources

- Teaching package: `packs/examples/language-demo/permits/package.law`
  (rules `PermitEligibility`, `FinesRefusal`, `BadgeEligibility`,
  priority `RefusalOverEligibility`, defeater `FraudBlocksBadge`).
- Scenarios: `packs/examples/language-demo/permits/tests/permits.lawtest`
  (`unless removes the conclusion`, `denial wins by priority`,
  `without priority the conflict persists`,
  `defeater removes support without negation`).
- Level 1 — Northbridge use: this article (fictional permit office).
- Level 2 — domain template: when a general entitlement meets an
  internal exception, use `unless`; when two applicable norms disagree
  and the denial must stand, use a separate negating rule plus
  `priority` with a stated reason; when evidence is tainted but nothing
  is decided against the person, use a `defeater`.
- Level 3 — confirmed external formalization: scope and exclusions
  (ILO Convention No. 138, minimum age, art. 4) — package
  `intl.labour.c138`,
  `corpus/laws/intl/ilo-c138/04-scope-and-exclusions.law:118-125`,
  construct `rule ... defeasible` with a contrary proviso
  (`WorkIsExcludable`: excludable `unless hazardous_work(w) then not
  excludable_work(w)`). The exception lives inside the one rule it
  limits, and only the contrary clause establishes the negation — the
  same `unless`-vs-priority-vs-defeater separation this article teaches.

<details>
<summary>Evidence and scope of the external example</summary>

Documented in
`docs/research/constructs/02-rule-defeasible-unless/corpus-forms.en.md`,
section 1 (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.

</details>