Rule Cascade
LearnSafety

Portable patterns

The small regular-expression language that means the same in every engine, and the one rule that keeps it fast.

matches tests a string against a pattern. Regular-expression engines disagree in small ways, so Rule Cascade accepts only a portable subset and gives it one meaning in every language. The search is unanchored: use ^ and $ to match the whole string. Matching is case-sensitive.

The pattern must be a string literal in the ruleset. A pattern outside the subset fails to load with PATTERN_NOT_PORTABLE.

Syntax

SyntaxMeaning
abcThe characters themselves
.Any one character, line breaks included
^, $The very start and the very end of the string
\d \D \w \WASCII digits [0-9], word characters [A-Za-z0-9_], and their opposites
\t \n \rTab, line feed, carriage return
\. \( \$ ...An escaped syntax character
[a-z], [^0-9]A character class, or its opposite
(...), (?:...)A group
a|bEither side
* + ? {n} {n,} {n,m}Repetition, at most 1000; add ? to make it lazy
matches in a rule (single quotes keep the backslashes)
assert: { op: matches, args: [{ var: data.postcode }, '^\d{5}$'] }

Example

A postcode is five digits. 75OO1 has two letter Os, so it is denied.

patterns.ruleset.yaml
ruleCascade: 1.0.0
kind: RuleSet
metadata: { id: learn.patterns, version: 1.0.0, title: Portable patterns }
scope:
  - { level: organization, id: learn }
entities:
  Customer:
    schema: { $ref: "./learn.openapi.yaml#/components/schemas/Customer" }
rules:
  - id: customer.postcode.format
    kind: validation
    target: { entity: Customer, field: /postcode }
    operations: [create]
    when: { op: exists, args: [{ var: data.postcode }] }
    assert: { op: matches, args: [{ var: data.postcode }, '^\d{5}$'] }
    severity: error
    finding: { code: LRN-PAT-001, message: customer.badPostcode }
  - id: customer.username.format
    kind: validation
    target: { entity: Customer, field: /username }
    operations: [create]
    when: { op: exists, args: [{ var: data.username }] }
    assert: { op: matches, args: [{ var: data.username }, '^[a-z][a-z0-9_]{2,15}$'] }
    severity: error
    finding: { code: LRN-PAT-002, message: customer.badUsername }
messages:
  en:
    customer.badPostcode: "Enter a postcode of 5 digits."
    customer.badUsername: "A username is 3 to 16 lower-case letters, digits or _, starting with a letter."
tests:
  - name: a postcode with letters is denied
    entity: Customer
    operation: create
    given:
      data: { postcode: "75OO1", username: "ana_23" }
    expect:
      decision: deny
      findings:
        - { rule: customer.postcode.format, fields: [/postcode] }
  - name: matching is case-sensitive
    entity: Customer
    operation: create
    given:
      data: { postcode: "75001", username: "Ana" }
    expect:
      decision: deny
      findings:
        - { rule: customer.username.format }
  - name: good values are allowed
    entity: Customer
    operation: create
    given:
      data: { postcode: "75001", username: "ana_23" }
    expect: { decision: allow, findings: [] }
request.json
{
  "entity": "Customer",
  "operation": "create",
  "data": {
    "postcode": "75OO1",
    "username": "ana_23"
  }
}

Result, from the engine

Decisiondeny1 finding, server channel

  • LRN-PAT-001errorblockingEnter a postcode of 5 digits./postcode
Try it YourselfOpens this ruleset and request in the playground. Nothing to install.

Common mistakes

  • Using \s, \b, \p{...}, lookarounds or back-references. They are not portable. Write the characters out, for example [ \t] instead of \s.
  • Forgetting ^ and $. \d{5} also matches abc123456xyz.
  • Nesting repetitions without a bound. A group that repeats must not contain *, + or {n,} at any depth. (a+)+ can make a backtracking engine take exponential time to fail, so the compiler refuses it. The usual rewrite separates the repeated part by its first character: ^[a-z]+(\.[a-z]+)*$ becomes ^[a-z](?:[a-z]|\.[a-z])*$.

The subject of matches may have at most 10000 characters. A longer one is an evaluation error.

This ruleset uses the nested form, so it does not load:

patterns-nested.ruleset.yaml
ruleCascade: 1.0.0
kind: RuleSet
metadata: { id: learn.patterns-nested, version: 1.0.0, title: A nested quantifier }
scope:
  - { level: organization, id: learn }
entities:
  Customer:
    schema: { $ref: "./learn.openapi.yaml#/components/schemas/Customer" }
rules:
  - id: customer.username.dotted
    kind: validation
    target: { entity: Customer, field: /username }
    operations: [create]
    when: { op: exists, args: [{ var: data.username }] }
    assert: { op: matches, args: [{ var: data.username }, '^[a-z]+(\.[a-z]+)*$'] }
    severity: error
    finding: { code: LRN-PAT-010, message: customer.badUsername }
messages:
  en:
    customer.badUsername: "A username is lower-case words joined by dots, like ana.maria."
tests:
  - name: a dotted username is allowed
    entity: Customer
    operation: create
    given:
      data: { username: "ana.maria" }
    expect: { decision: allow, findings: [] }
  - name: two dots in a row are denied
    entity: Customer
    operation: create
    given:
      data: { username: "ana..maria" }
    expect:
      decision: deny
      findings:
        - { rule: customer.username.dotted }
request.json
{
  "entity": "Customer",
  "operation": "create",
  "data": {
    "username": "ana.maria"
  }
}

Result, from the engine

does not loadThe engine refuses the ruleset before it evaluates anything.

  • PATTERN_NOT_PORTABLE rule: pattern "^[a-z]+(\\.[a-z]+)*$": a group that repeats must not contain an unbounded quantifier (*, + or {n,}) (line 15)
Try it YourselfOpens this ruleset and request in the playground. Nothing to install.

Exercise

Fix the username pattern so that it loads. ana.maria must stay allowed and ana..maria denied.

Hint

Use the rewrite above: one letter first, then letters or a dot followed by a letter.

Show answer
patterns-nested.ruleset.yaml
ruleCascade: 1.0.0
kind: RuleSet
metadata: { id: learn.patterns-nested, version: 1.0.0, title: A nested quantifier }
scope:
  - { level: organization, id: learn }
entities:
  Customer:
    schema: { $ref: "./learn.openapi.yaml#/components/schemas/Customer" }
rules:
  - id: customer.username.dotted
    kind: validation
    target: { entity: Customer, field: /username }
    operations: [create]
    when: { op: exists, args: [{ var: data.username }] }
    assert: { op: matches, args: [{ var: data.username }, '^[a-z](?:[a-z]|\.[a-z])*$'] }
    severity: error
    finding: { code: LRN-PAT-010, message: customer.badUsername }
messages:
  en:
    customer.badUsername: "A username is lower-case words joined by dots, like ana.maria."
tests:
  - name: a dotted username is allowed
    entity: Customer
    operation: create
    given:
      data: { username: "ana.maria" }
    expect: { decision: allow, findings: [] }
  - name: two dots in a row are denied
    entity: Customer
    operation: create
    given:
      data: { username: "ana..maria" }
    expect:
      decision: deny
      findings:
        - { rule: customer.username.dotted }
request.json
{
  "entity": "Customer",
  "operation": "create",
  "data": {
    "username": "ana.maria"
  }
}

Result, from the engine

Decisionallow0 findings, server channel

Exercise

Change the postcode rule so that it also accepts US ZIP+4 codes like 12345-6789, but not 12345-67.

Hint

An optional group is (...)? and is allowed: it repeats at most once.

Show answer
patterns.ruleset.yaml
ruleCascade: 1.0.0
kind: RuleSet
metadata: { id: learn.patterns, version: 1.0.0, title: Portable patterns }
scope:
  - { level: organization, id: learn }
entities:
  Customer:
    schema: { $ref: "./learn.openapi.yaml#/components/schemas/Customer" }
rules:
  - id: customer.postcode.format
    kind: validation
    target: { entity: Customer, field: /postcode }
    operations: [create]
    when: { op: exists, args: [{ var: data.postcode }] }
    assert: { op: matches, args: [{ var: data.postcode }, '^\d{5}(-\d{4})?$'] }
    severity: error
    finding: { code: LRN-PAT-001, message: customer.badPostcode }
  - id: customer.username.format
    kind: validation
    target: { entity: Customer, field: /username }
    operations: [create]
    when: { op: exists, args: [{ var: data.username }] }
    assert: { op: matches, args: [{ var: data.username }, '^[a-z][a-z0-9_]{2,15}$'] }
    severity: error
    finding: { code: LRN-PAT-002, message: customer.badUsername }
messages:
  en:
    customer.badPostcode: "Enter a postcode of 5 digits, or 5+4 digits like 12345-6789."
    customer.badUsername: "A username is 3 to 16 lower-case letters, digits or _, starting with a letter."
tests:
  - name: a ZIP+4 postcode is allowed
    entity: Customer
    operation: create
    given:
      data: { postcode: "12345-6789" }
    expect: { decision: allow, findings: [] }
  - name: a half suffix is denied
    entity: Customer
    operation: create
    given:
      data: { postcode: "12345-67" }
    expect:
      decision: deny
      findings:
        - { rule: customer.postcode.format }
request.json
{
  "entity": "Customer",
  "operation": "create",
  "data": {
    "postcode": "12345-6789"
  }
}

Result, from the engine

Decisionallow0 findings, server channel

Course overview

On this page