Rule Cascade
LearnIntroduction

How a ruleset works

The parts of a ruleset, and the three things that happen to it — compile, publish, evaluate.

A ruleset is one YAML or JSON document of kind: RuleSet. It says who owns it, which data its rules talk about, the rules themselves, the messages users read and the tests that prove it works. A compiler checks the document and turns it into a bundle. An engine evaluates a request against that bundle and returns a decision. Evaluation is a pure function: the same request always gives the same answer, in every language.

Syntax

the sections of a ruleset, top to bottom
ruleCascade: 1.0.0          # envelope: the version of the specification
kind: RuleSet
metadata: { id, version, title, owner, status }
scope: [ { level, id } ]    # hierarchy: where the ruleset sits
entities:                   # vocabulary: the data the rules check
  <Entity>:
    description: <text>
    schema: { $ref: "<schema file>#/<pointer>" }
rules: [ ... ]              # the logic
messages: { <locale>: { <key>: <template> } }   # presentation
bindings:                   # which API operations the rules guard
  openapi: [ { document, entity, operations: { <operation>: <operationId> } } ]
tests: [ ... ]              # golden tests
SectionWhat it holds
ruleCascade, kind, metadataThe envelope: specification version, document kind, the ruleset's id, version, title, owner and status.
scopeWhere the ruleset sits: a list of {level, id}, least specific first.
entitiesThe data rules may mention. Each entity points at a JSON Schema, usually inside an OpenAPI file, with schema.$ref. description is for people.
rulesThe logic. Each rule has an id, a kind, a target and operations.
messagesMessage templates, per locale.
bindings.openapiWhich OpenAPI operation each rule operation guards, for tools that wire rules into an API.
testsGolden tests every engine must pass.

Three things happen to a ruleset, often in three different programs:

  1. Compile. The compiler validates the document, checks every path against the entity schema, computes a checksum and writes a bundle.
  2. Publish. You hand the bundle, or one manifest of it, to whoever evaluates.
  3. Evaluate. An engine takes the bundle and a request and returns a decision, findings, effects and commands.

Example

This ruleset has one of every main section. The request has no e-mail address.

how-it-works.ruleset.yaml
# Envelope: the spec version, the kind of document and who owns it.
ruleCascade: 1.0.0
kind: RuleSet
metadata:
  id: learn.how-it-works
  version: 1.0.0
  title: Orders
  owner: shop-team
  status: active

# Hierarchy: where the ruleset sits in the enterprise.
scope:
  - { level: organization, id: learn }

# Vocabulary: the data the rules may talk about.
entities:
  Order:
    description: An order placed in the web shop.
    schema: { $ref: "./learn.openapi.yaml#/components/schemas/Order" }

# Rules: the logic.
rules:
  - id: order.email.required
    kind: validation
    target: { entity: Order, field: /email }
    operations: [create]
    assert: { op: exists, args: [{ var: data.email }] }
    severity: error
    finding: { code: LRN-HOW-001, message: order.emailMissing }

# Presentation: what users read.
messages:
  en:
    order.emailMissing: "Enter an e-mail address for the receipt."

# Bindings: which API operations the rules guard.
bindings:
  openapi:
    - document: ./learn.openapi.yaml
      entity: Order
      operations: { create: createOrder }

# Conformance: golden tests every engine must pass.
tests:
  - name: an order without an e-mail is denied
    entity: Order
    operation: create
    given:
      data: { quantity: 2 }
    expect:
      decision: deny
      findings:
        - { rule: order.email.required, fields: [/email] }
  - name: an order with an e-mail is allowed
    entity: Order
    operation: create
    given:
      data: { quantity: 2, email: ana@example.com }
    expect: { decision: allow, findings: [] }
The entity schema, learn.openapi.yaml (the same for every lesson)
learn.openapi.yaml
openapi: 3.1.0
info: { title: Learn Rule Cascade, version: 1.0.0 }
paths: {}
components:
  schemas:
    Order:
      type: object
      properties:
        id: { type: string }
        quantity: { type: integer }
        price: { type: number }
        total: { type: number }
        discount: { type: number }
        shipping: { type: number }
        coupon: { type: string }
        email: { type: string }
        country: { type: string }
        status: { type: string }
        express: { type: boolean }
        giftWrap: { type: boolean }
        giftMessage: { type: string }
        notes: { type: string }
        reference: { type: string }
        orderDate: { type: string }
        deliveryDate: { type: string }
        paymentMethod: { type: string }
        cardNumber: { type: string }
        currency: { type: string }
        weight: { type: number }
        priority: { type: integer }
        approved: { type: boolean }
        cancelReason: { type: string }
        promoCodes: { type: array, items: { type: string } }
        scores: { type: array, items: { type: number } }
        tags: { type: array, items: { type: string } }
        items:
          type: array
          items:
            type: object
            properties:
              sku: { type: string }
              qty: { type: integer }
              price: { type: number }
    Customer:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        nickname: { type: string }
        email: { type: string }
        backupEmail: { type: string }
        phone: { type: string }
        country: { type: string }
        dateOfBirth: { type: string }
        vatId: { type: string }
        company: { type: string }
        accountType: { type: string }
        newsletter: { type: boolean }
        tier: { type: string }
        age: { type: integer }
        website: { type: string }
        postcode: { type: string }
        username: { type: string }
        iban: { type: string }
        bio: { type: string }
        signupDate: { type: string }
        lastLogin: { type: string }
        creditLimit: { type: number }
        balance: { type: number }
        verified: { type: boolean }
        roles: { type: array, items: { type: string } }
        address:
          type: object
          properties:
            street: { type: string }
            city: { type: string }
            postcode: { type: string }
            country: { type: string }
        contacts:
          type: array
          items:
            type: object
            properties:
              name: { type: string }
              email: { type: string }
              phone: { type: string }
    Ticket:
      type: object
      properties:
        id: { type: string }
        title: { type: string }
        description: { type: string }
        status: { type: string }
        priority: { type: string }
        assignee: { type: string }
        reporter: { type: string }
        dueDate: { type: string }
        createdAt: { type: string }
        closedAt: { type: string }
        resolution: { type: string }
        estimate: { type: number }
        labels: { type: array, items: { type: string } }
request.json
{
  "entity": "Order",
  "operation": "create",
  "data": {
    "quantity": 2
  }
}

Result, from the engine

Decisiondeny1 finding, server channel

  • LRN-HOW-001errorblockingEnter an e-mail address for the receipt./email
Try it YourselfOpens this ruleset and request in the playground. Nothing to install.

Common mistakes

  • Writing the schema inline. schema holds only a $ref to a schema document.
  • Forgetting the message. Every finding's message key must exist in the default locale.
  • Expecting the engine to read files or the clock while it evaluates. It never does: pass facts in the request.

Exercise

Add a second rule: an order must have a /country. Give it the code LRN-HOW-002 and the message "Choose the country to deliver to." Test it with an order that has an e-mail but no country.

Hint

Copy the e-mail rule, change its id, field, code and message key, and add the message under messages.en.

Show answer
how-it-works.ruleset.yaml
ruleCascade: 1.0.0
kind: RuleSet
metadata:
  id: learn.how-it-works
  version: 1.0.0
  title: Orders
  owner: shop-team
  status: active

scope:
  - { level: organization, id: learn }

entities:
  Order:
    description: An order placed in the web shop.
    schema: { $ref: "./learn.openapi.yaml#/components/schemas/Order" }

rules:
  - id: order.email.required
    kind: validation
    target: { entity: Order, field: /email }
    operations: [create]
    assert: { op: exists, args: [{ var: data.email }] }
    severity: error
    finding: { code: LRN-HOW-001, message: order.emailMissing }
  - id: order.country.required
    kind: validation
    target: { entity: Order, field: /country }
    operations: [create]
    assert: { op: exists, args: [{ var: data.country }] }
    severity: error
    finding: { code: LRN-HOW-002, message: order.countryMissing }

messages:
  en:
    order.emailMissing: "Enter an e-mail address for the receipt."
    order.countryMissing: "Choose the country to deliver to."

bindings:
  openapi:
    - document: ./learn.openapi.yaml
      entity: Order
      operations: { create: createOrder }

tests:
  - name: an order without a country is denied
    entity: Order
    operation: create
    given:
      data: { quantity: 2, email: ana@example.com }
    expect:
      decision: deny
      findings:
        - { rule: order.country.required, fields: [/country] }
  - name: a complete order is allowed
    entity: Order
    operation: create
    given:
      data: { quantity: 2, email: ana@example.com, country: ES }
    expect: { decision: allow, findings: [] }
request.json
{
  "entity": "Order",
  "operation": "create",
  "data": {
    "quantity": 2,
    "email": "ana@example.com"
  }
}

Result, from the engine

Decisiondeny1 finding, server channel

  • LRN-HOW-002errorblockingChoose the country to deliver to./country
Course overview

On this page