Specification · v1

Syntax

1. Source text

A v1 source is Unicode text. Its size is measured in UTF-8 bytes (65,536 by default, see Limits). A source holds zero or more contracts. An empty source is rejected (limit[source-size]).

2. Reading in context

v1 has no separate tokenizer pass. The parser asks for the kind of token it can accept at the current position: a keyword, a name, a word, a number, a string or a source tag. Three consequences follow.

Keywords are matched as prefixes, and only where the grammar expects them. At a position where CONTRACT may appear, any text that begins with CONTRACT is read as the keyword: CONTRACTX { is CONTRACT followed by the name X. Elsewhere a keyword is not reserved. A field may be called WHEN, and an entity may be called CONTEXT.

Names and words are read greedily. x == 1 ANDROID == 2 reads as x == 1 AND ROID == 2, and integer reads as the type int followed by the name eger. trueAND is a single word.

Write a space after every keyword.

3. White space and comments

White spacespace, tab, CR, LF. Nothing else: a no-break space, a form feed or a BOM is not white space.
Comment// or #, up to (not including) the next LF

There are no block comments.

3.1 A comment can become part of a word (sharp edge)

An operand word or a type extends over any trivia that follows it, including a comment. In

excerpt of a checked contractcomment-absorbed
    FORBID grant WHEN Request.count <= zero // must be non-empty
      [SOURCE:claim_nonempty]

the constant is named zero // must be non-empty, which does not resolve (unsupported[unknown-name]). Put comments on their own line, or after the source tag.

A comment after an operand becomes part of itcomment-absorbed
CONTRACT Claim {
  ENFORCEMENT_MODE: enforced
  CONTEXT {
    ENTITIES Request {
      count: int
      region: string
      ready: bool
      ratio: float
    }
    CONSTANTS {
      zero: int == 0 [SOURCE:POLICY]
      cap: int == 25 [SOURCE:POLICY]
    }
  }
  TRIGGER {
    grant()
  }
  PROHIBITION {
    FORBID grant WHEN Request.count <= zero // must be non-empty
      [SOURCE:claim_nonempty]
  }
}

rejected unsupported[unknown-name] at 19:40 unknown name zero // must be non-empty

A comment after a field's type does the same to the type, which is then no longer primitive:

A comment after a type becomes part of ittype-comment
CONTRACT Claim {
  ENFORCEMENT_MODE: enforced
  CONTEXT {
    ENTITIES Request {
      count: int // paths requested
      region: string
      ready: bool
      ratio: float
    }
    CONSTANTS {
      zero: int == 0 [SOURCE:POLICY]
      cap: int == 25 [SOURCE:POLICY]
    }
  }
  TRIGGER {
    grant()
  }
  GUARANTEE {
    ENSURE grant() WHEN Request.ready == true [SOURCE:must_be_ready]
  }
  PROHIBITION {
    FORBID grant WHEN Request.count > cap [SOURCE:over_cap]
  }
}

rejected unsupported[type-unsupported] at 5:14 only primitive fact types supported

4. Lexical elements

ElementForm
Name[A-Za-z_][A-Za-z0-9_]*
Worda name followed by any number of .segment parts, segment = [A-Za-z0-9_]*, e.g. Request.count
Integer-?[0-9]+. Leading zeros are allowed. There is no +, no _ and no hex.
Float-?[0-9]+(\.[0-9]+)?([eE]-?[0-9]+)? with a fraction or an exponent: 0.5, 1e3, 2.5E-1. A number with an exponent is always a float. There is no 1., no .5 and no 1e+3.
String"…": any characters except ", newlines included. No escapes: the text between the quotes is the value.
Booleantrue, TRUE, false, FALSE
Source tag[SOURCE:id]. White space may follow SOURCE:. The id is the trimmed text up to ].

A minus sign is part of a number only when a digit follows it directly. -1 is a negative literal. - 1 is arithmetic, and arithmetic is refused (§7).

5. Grammar

The grammar in EBNF. It is also available as a file: ulang-v1.ebnf.

file          = { contract } ;
contract      = "CONTRACT" name "{"
                  [ mode ] [ context ] [ trigger ]
                  [ guarantee ] [ prohibition ] [ observability ]
                "}" ;
mode          = "ENFORCEMENT_MODE" ":"
                ( "enforced" | "ENFORCED" | "advisory" | "ADVISORY" | "audit" | "AUDIT" ) ;

context       = "CONTEXT" "{" { entities } [ constants ] "}" ;
entities      = "ENTITIES" [ name ] "{" field { field } "}" ;
field         = name ":" type ;
type          = "int" | "float" | "bool" | "string"
              | name [ "<" type { "," type } ">" ] ;
constants     = "CONSTANTS" [ name ] "{" { constant } "}" ;
constant      = name ":" type ( "==" | "=" ) literal [ source ] ;

trigger       = "TRIGGER" "{" event { event } "}" ;
event         = name [ "(" [ args ] ")" ] [ "WHEN" condition ] ;

guarantee     = "GUARANTEE" "{" { "ENSURE" clause } "}" ;
prohibition   = "PROHIBITION" "{" { "FORBID" clause } "}" ;
clause        = name [ "(" [ args ] ")" ] [ "WHEN" condition ] [ source ] ;

observability = "OBSERVABILITY" "{" { "LOG" string "ON" word } "}" ;

source        = "[" "SOURCE:" text "]" ;

condition     = conjunction { "OR" conjunction } ;
conjunction   = primary { "AND" primary } ;
primary       = "(" condition ")"
              | value [ op value ]
              | word "=" value ;                  (* assignment: outside the subset *)
op            = "==" | "!=" | "<=" | ">=" | "<" | ">" ;
value         = term { "-" integer } ;            (* subtraction: outside the subset *)
term          = integer | float | string | boolean
              | word [ "(" [ args ] ")" ]         (* a call: outside the subset *)
              | "(" value ")" ;                   (* right-hand side only *)
args          = value { "," value } ;
literal       = integer | float | string | boolean ;

5.1 Reading the grammar

6. Comparisons

FormExampleIn the subset
path op literalRequest.count > 25yes, typed (see subset)
path op pathRequest.used >= Request.limitnumbers only
path op constantRequest.count <= max_pathsnumbers only
literal op path25 < Request.countnumbers only
constant op literalmax_paths == 25no: literal-comparison
bare operandRequest.readyno: condition-form
assignmentRequest.ready = trueno: condition-form

7. Refused before parsing

A linear bounds scan runs before the parser. It skips strings, comments and source tags, and refuses:

NOT inside a name is refused before parsingnot-inside-name
CONTRACT Claim {
  ENFORCEMENT_MODE: enforced
  CONTEXT {
    ENTITIES Request {
      CANNOT_MERGE: bool
    }
    CONSTANTS {
      zero: int == 0 [SOURCE:POLICY]
      cap: int == 25 [SOURCE:POLICY]
    }
  }
  TRIGGER {
    grant()
  }
  GUARANTEE {
    ENSURE grant() WHEN Request.CANNOT_MERGE == false [SOURCE:mergeable]
  }
}

rejected unsupported[not-operator] at 5:10 NOT is refused before recursive parsing

Use lower-case names for fields that would otherwise contain NOT, and compare with == false instead of negating.

8. Multiple contracts

A source may hold several contracts. Each is validated independently, in order, and two contracts in one source may not share a name (semantic[duplicate-contract]). Anything between contracts other than white space and comments is a syntax error.