Language

Patterns

v1 leaves out negation, arithmetic, functions and lists, so that every decision is a short, predictable sequence of comparisons over facts. These patterns cover what people usually reach for those features to do. Each one is checked by ulang-lite.

Saying "not"

NOT is refused before parsingpat-not-bad
CONTRACT Publish {
  ENFORCEMENT_MODE: enforced
  CONTEXT {
    ENTITIES Article {
      reviewed: bool
      word_count: int
      status: string
    }
  }
  TRIGGER {
    publish()
  }
  PROHIBITION {
    FORBID publish WHEN NOT Article.reviewed == true [SOURCE:needs_review]
  }
}

rejected unsupported[not-operator] at 14:25 NOT is refused before recursive parsing

Write the opposite comparison instead:

Instead ofWrite
not a == ba != b
not a < ba >= b
not a <= ba > b
not flag == trueflag == false
not (A AND B)(not A) OR (not B)
not (A OR B)(not A) AND (not B)
Say it with the opposite comparisonpat-not
CONTRACT Publish {
  ENFORCEMENT_MODE: enforced
  CONTEXT {
    ENTITIES Article {
      reviewed: bool
      word_count: int
      status: string
    }
  }
  TRIGGER {
    publish()
  }
  PROHIBITION {
    FORBID publish WHEN Article.reviewed == false [SOURCE:needs_review]
    FORBID publish WHEN Article.word_count < 300 [SOURCE:too_short]
    FORBID publish WHEN Article.status != "ready" AND Article.status != "scheduled" [SOURCE:wrong_status]
  }
}

valid Publish 3 clauses · sha256 e21f874658d8a743229e186939fa8b3baf72548147242642ca341549fc6c8987

Facts of the first request; each other request changes only the fields shown.

{
  "Article": {
    "reviewed": true,
    "word_count": 800,
    "status": "ready"
  }
}
RequestActionChangedVerdictClause
reviewed, 800 words, readypublishas abovePERMITPublish.publish.permit
not reviewedpublishreviewed: falseDENYPublish.needs_review
a draftpublishstatus: "draft"DENYPublish.wrong_status

The three letters NOT are refused anywhere outside strings, comments and source tags, even inside a name such as CANNOT_MERGE. Use lower-case names.

Allow-lists

A chain of != joined with AND denies everything except the listed values:

Allow-list of stringspat-allowlist
CONTRACT Shipping {
  ENFORCEMENT_MODE: enforced
  CONTEXT {
    ENTITIES Shipment {
      country: string
      weight_kg: float
      hazardous: bool
    }
    CONSTANTS {
      max_weight_kg: float == 30.0 [SOURCE:CARRIER-TERMS-2]
    }
  }
  TRIGGER {
    ship()
  }
  PROHIBITION {
    FORBID ship WHEN Shipment.country != "US" AND Shipment.country != "CA" AND Shipment.country != "MX" [SOURCE:ship_regions]
    FORBID ship WHEN Shipment.hazardous == true OR Shipment.weight_kg > max_weight_kg [SOURCE:carrier_limits]
  }
}

valid Shipping 2 clauses · sha256 849104d027b84a94dfdbc5ee0739dd33fa5c7f66143d5761e05f8ee9e07132f4

Facts of the first request; each other request changes only the fields shown.

{
  "Shipment": {
    "country": "US",
    "weight_kg": 2,
    "hazardous": false
  }
}
RequestActionChangedVerdictClause
US, 2 kgshipas abovePERMITShipping.ship.permit
Franceshipcountry: "FR"DENYShipping.ship_regions
"us" in lower caseshipcountry: "us"DENYShipping.ship_regions
Canada, 31 kgshipcountry: "CA" weight_kg: 31DENYShipping.carrier_limits

Matching is exact. If case should not matter, normalise it in the application.

Ranges

A range: inside with ENSURE, outside with FORBIDpat-range
CONTRACT Setpoint {
  ENFORCEMENT_MODE: enforced
  CONTEXT {
    ENTITIES Change {
      celsius: float
      operator_certified: bool
    }
    CONSTANTS {
      min_c: float == 5.0 [SOURCE:SAFETY-1]
      max_c: float == 28.0 [SOURCE:SAFETY-1]
    }
  }
  TRIGGER {
    set_temperature()
  }
  GUARANTEE {
    ENSURE set_temperature() WHEN Change.celsius >= min_c AND Change.celsius <= max_c [SOURCE:in_range]
  }
  PROHIBITION {
    FORBID set_temperature WHEN Change.celsius > 24.0 AND Change.operator_certified == false [SOURCE:certified_above_24]
  }
}

valid Setpoint 2 clauses · sha256 a55e62765e7467b7cd7309c8219c9af2f7dd80d6ee9a7e6b53a9958ab2c75cc4

Facts of the first request; each other request changes only the fields shown.

{
  "Change": {
    "celsius": 21,
    "operator_certified": false
  }
}
RequestActionChangedVerdictClause
21 °Cset_temperatureas abovePERMITSetpoint.set_temperature.permit
3 °Cset_temperaturecelsius: 3DENYSetpoint.in_range
26 °C, not certifiedset_temperaturecelsius: 26DENYSetpoint.certified_above_24
26 °C, certifiedset_temperaturecelsius: 26 operator_certified: truePERMITSetpoint.set_temperature.permit

ENSURE x >= min AND x <= max names one clause for the whole range. Separate FORBID clauses for each side name the side that was crossed.

Comparing two facts

Two numeric facts can be compared directly. Use integer minor units, such as cents, for money, so that equality is exact.

Compare two factspat-two-fields
CONTRACT Purchasing {
  ENFORCEMENT_MODE: enforced
  CONTEXT {
    ENTITIES Purchase {
      amount_cents: int
      remaining_budget_cents: int
      vendor_approved: bool
    }
  }
  TRIGGER {
    purchase()
  }
  GUARANTEE {
    ENSURE purchase() WHEN Purchase.vendor_approved == true [SOURCE:approved_vendor]
  }
  PROHIBITION {
    FORBID purchase WHEN Purchase.amount_cents > Purchase.remaining_budget_cents [SOURCE:over_budget]
  }
}

valid Purchasing 2 clauses · sha256 80232a79bb4efcf53f35bbfb38f7695e895ebc466f44c79190a4c0c6576a6bd2

Facts of the first request; each other request changes only the fields shown.

{
  "Purchase": {
    "amount_cents": 5000,
    "remaining_budget_cents": 20000,
    "vendor_approved": true
  }
}
RequestActionChangedVerdictClause
$50 of $200 leftpurchaseas abovePERMITPurchasing.purchase.permit
$250 of $200 leftpurchaseamount_cents: 25000DENYPurchasing.over_budget

Computed values

Arithmetic is refusedpat-derived-bad
CONTRACT Quota {
  ENFORCEMENT_MODE: enforced
  CONTEXT {
    ENTITIES Usage {
      used: int
      requested: int
      limit: int
    }
  }
  TRIGGER {
    reserve()
  }
  PROHIBITION {
    FORBID reserve WHEN Usage.used + Usage.requested > Usage.limit [SOURCE:quota]
  }
}

rejected unsupported[arithmetic] at 14:36 arithmetic is refused before recursive parsing

Contracts compare; applications compute. Pass the computed value as a fact, named for what it means:

Compute it in the application, decide it in the contractpat-derived
CONTRACT Quota {
  ENFORCEMENT_MODE: enforced
  CONTEXT {
    ENTITIES Usage {
      used: int
      requested: int
      projected: int
      limit: int
    }
  }
  TRIGGER {
    reserve()
  }
  PROHIBITION {
    FORBID reserve WHEN Usage.projected > Usage.limit [SOURCE:quota]
  }
}

valid Quota 1 clause · sha256 04b0f1188e1e0b8f95d4e1039faa609a6e8df7b9afb4751d95d7f8071f4d3427

Facts of the first request; each other request changes only the fields shown.

{
  "Usage": {
    "used": 70,
    "requested": 20,
    "projected": 90,
    "limit": 100
  }
}
RequestActionChangedVerdictClause
70 used + 20 requestedreserveas abovePERMITQuota.reserve.permit
70 used + 40 requestedreserverequested: 40 projected: 110DENYQuota.quota

Keep computed facts few and obvious, and compute each in one place: a contract can only be as right as the facts it is given.

Relations between facts

Two string facts cannot be compared with each other:

Two string facts cannot be comparedpat-relation-bad
CONTRACT Approvals {
  ENFORCEMENT_MODE: enforced
  CONTEXT {
    ENTITIES Approval {
      approver_id: string
      requester_id: string
      approver_is_requester: bool
    }
  }
  TRIGGER {
    approve()
  }
  PROHIBITION {
    FORBID approve WHEN Approval.approver_id == Approval.requester_id [SOURCE:four_eyes]
  }
}

rejected unsupported[nonnumeric-comparison] at 14:25 nonnumeric comparisons require a field and literal

Compute the relation in the application and pass it as a boolean:

Pass the relation as a boolean factpat-relation
CONTRACT Approvals {
  ENFORCEMENT_MODE: enforced
  CONTEXT {
    ENTITIES Approval {
      approver_id: string
      requester_id: string
      approver_is_requester: bool
    }
  }
  TRIGGER {
    approve()
  }
  PROHIBITION {
    FORBID approve WHEN Approval.approver_is_requester == true [SOURCE:four_eyes]
  }
}

valid Approvals 1 clause · sha256 f6a9fe155f7924641bbe29b6427d5bd4681ae4656de70524db30ca0343940905

Facts of the first request; each other request changes only the fields shown.

{
  "Approval": {
    "approver_id": "u-17",
    "requester_id": "u-42",
    "approver_is_requester": false
  }
}
RequestActionChangedVerdictClause
someone else approvesapproveas abovePERMITApprovals.approve.permit
the requester approvesapproveapprover_id: "u-42" approver_is_requester: trueDENYApprovals.four_eyes

Time

A contract has no clock. The application supplies time as facts, in whatever form the rule needs:

Time windows: the application supplies the clockpat-time
CONTRACT ChangeWindow {
  ENFORCEMENT_MODE: enforced
  CONTEXT {
    ENTITIES Window {
      weekday: int
      hour_utc: int
    }
  }
  TRIGGER {
    deploy()
  }
  PROHIBITION {
    FORBID deploy WHEN Window.weekday >= 5 OR Window.hour_utc < 9 OR Window.hour_utc >= 17 [SOURCE:business_hours]
  }
}

valid ChangeWindow 1 clause · sha256 d218eaff9511e87499bbb84b769fb22e58eeca02d08cbe750b785fe7194aa438

Facts of the first request; each other request changes only the fields shown.

{
  "Window": {
    "weekday": 1,
    "hour_utc": 14
  }
}
RequestActionChangedVerdictClause
Tuesday 14:00deployas abovePERMITChangeWindow.deploy.permit
Saturday 14:00deployweekday: 5DENYChangeWindow.business_hours
Tuesday 18:00deployhour_utc: 18DENYChangeWindow.business_hours

Here weekday is 0 for Monday, by the application's convention. Write such conventions down next to the code that builds the facts.

Ordering prohibitions

When several prohibitions apply, the first in source order is named. These two contracts have the same rules in a different order:

Most useful rule firstpat-order-a
CONTRACT Payouts {
  ENFORCEMENT_MODE: enforced
  CONTEXT {
    ENTITIES Account {
      frozen: bool
      amount_cents: int
      balance_cents: int
    }
  }
  TRIGGER {
    withdraw()
  }
  PROHIBITION {
    FORBID withdraw WHEN Account.frozen == true [SOURCE:account_frozen]
    FORBID withdraw WHEN Account.amount_cents > Account.balance_cents [SOURCE:insufficient_funds]
  }
}

valid Payouts 2 clauses · sha256 7425e5b3fb960f154ecacc95d45edcd68073f269bb9180f80b14cbf5a2fbdf18

Facts:

{
  "Account": {
    "frozen": true,
    "amount_cents": 9000,
    "balance_cents": 5000
  }
}
RequestActionVerdictClause
frozen and overdrawnwithdrawDENYPayouts.account_frozen
The same rules, reversedpat-order-b
CONTRACT Payouts {
  ENFORCEMENT_MODE: enforced
  CONTEXT {
    ENTITIES Account {
      frozen: bool
      amount_cents: int
      balance_cents: int
    }
  }
  TRIGGER {
    withdraw()
  }
  PROHIBITION {
    FORBID withdraw WHEN Account.amount_cents > Account.balance_cents [SOURCE:insufficient_funds]
    FORBID withdraw WHEN Account.frozen == true [SOURCE:account_frozen]
  }
}

valid Payouts 2 clauses · sha256 9ae8ce9470978d9eac4216cec9c931cbe47d616e382cff79b9f84326d254e1b0

Facts:

{
  "Account": {
    "frozen": true,
    "amount_cents": 9000,
    "balance_cents": 5000
  }
}
RequestActionVerdictClause
frozen and overdrawnwithdrawDENYPayouts.insufficient_funds

Both deny. For a frozen account, only the first gives the reason that matters. Order prohibitions from the most useful explanation to the least.