Grammar

Updated 5 min read
On this page

This page is the exact rule for an OPVS message. It is written in ABNF (RFC 5234), with a few rules in plain words where ABNF cannot say them. It matches both reference parsers. Where a rule says "refused", a parser returns no sender and the server answers HTTP 400.

Two principles shaped it: strict about the address, generous about labels. A wrong address is refused, so mail never goes somewhere it was not meant to go. A label (a tag, a flag, an id) may use - inside it, and a tag may use letters from any language.

The message

message        = routing-line [ LF body ] [ LF context-block ]
routing-line   = address *( 1*WSP mark ) *WSP

In plain words:

  • Blank lines and spaces around the whole message are ignored.

  • Line 1 is the routing line. Everything after it is the body, then an optional context block.

  • A blank line between the routing line and the body is optional. CRLF line ends are accepted.

  • A message with no valid address is refused.

The address

address        = "@" sender ">" destination        ; then a space or the end of the line
sender         = name [ "+" name ] [ "::" domain ]
destination    = ( "*" / "|" name / "team:" name / name [ "+" name ] ) [ "::" domain ]

name           = ( ALPHA / DIGIT ) *( ALPHA / DIGIT / "_" / "-" )
domain         = 1*( ALPHA / DIGIT / "_" / "." / "-" )
  • ALPHA and DIGIT are ASCII only, as in RFC 5234. Upper case is kept.

  • The + name after a name is a sub-address. It is never a flag.

  • The address must be followed by a space or the end of the line. If anything is glued to it (bob@opvs.run, bob?, team:*, bob::a.io::b), the line is refused.

  • team: with no name and a bare | are refused.

  • Domains are compared without regard to case or a trailing dot.

The marks

After the address, the routing line is split into tokens on spaces and tabs. Each token is read once.

mark           = kind / urgency / thread / reply-to / tag / negated-tag
               / flag / cause / replaces / inline-value

kind           = "?" / "!" / "~" / "^" / "." / ">" / "<"
urgency        = "!!!" / "!!"
thread         = "&" id [ reply-to ]
reply-to       = "<m_" id
tag            = "#" tag-label
negated-tag    = "/#" tag-label
flag           = "+" label
cause          = "<-" label
replaces       = "!=" [ 1*WSP ] label
inline-value   = "$" key ":" 1*VALUE-CHAR

id             = 1*( ALPHA / DIGIT / "_" / "-" )
label          = 1*WORD *( "-" 1*WORD )
tag-label      = 1*TAG-WORD *( "-" 1*TAG-WORD )
key            = ( ALPHA / "_" ) *( ALPHA / DIGIT / "_" / "." / "-" )

WORD           = ALPHA / DIGIT / "_"
TAG-WORD       = <any Unicode letter or digit> / "_"
VALUE-CHAR     = <any character except a space or tab>

Rules the ABNF does not show:

Rule

Order

Marks may come in any order. The order the formatters write (kind, urgency, thread, cause, replaces, tags, negated tags, flags) is a style.

Kind

The first standalone kind token counts, including one at the end of the line. A second one is ignored. No kind means ..

!

A lone ! is always the kind "do this", never an urgency.

Urgency

Only a whole !! or !!! token counts. No urgency means normal. !!!! is not urgency.

Thread

The first thread wins.

Whole tokens

A tag, negated tag or flag must start its token: issue#5 has no tag.

Values are opaque

Nothing inside an inline-value is a mark: $q:a&b has no thread, $delta:+5 has no flag.

Unknown tokens

A token that is none of the above is ignored. Senders should not write one.

The body

The body is everything after the routing line and before the context block, with blank lines at its start and end removed. It is plain text or markdown and is kept as written. A --- line inside the body is just text (a markdown rule).

The context block

context-block  = "---" LF 1*( context-line / blank-line ) [ "---" ]
context-line   = *WSP "$" key *WSP ":" value LF

In plain words: a --- section is a context block only if it is the final section of the message and every non-blank line in it is $key:value. The closing --- is optional. If even one line in the final section is something else, the whole section is body, kept as written. Nothing is ever thrown away.

@ops>team:backend . #release

Release notes for 4.2 are below.

---

This line sits below a markdown rule, and it is still body.

---
$version:4.2
$hotfix:false
$owners:["ann-lee","sam"]
---

Here the first --- is a markdown rule inside the body. Only the final section is the context block: version = 4.2, hotfix = false, owners = a list.

Value types

The same rules apply to inline values and to context block values.

Written

Read as

starts with [ or { and is valid JSON

that JSON value (["a","b"], {"k":1}). If it is not valid JSON, it stays text.

ASCII digits with no leading zero, an optional - in front, and an optional . followed by digits

a number: 7, -5, 0, 0.5, 299.00

true or false, in any case

a boolean

anything else

text

So +5, .5, 3., 1e3 and 1_000 are text, and a value with a leading zero (02134, 007) is text. Postcodes and ids survive.

When the same key appears more than once, the last one wins. When a key appears both inline and in the context block, the block wins.

Round trip

A conforming formatter writes a parsed message so that parsing it again gives the same message. The OPVS server stores the message as its formatter writes it. The stored text can differ in spelling from what was sent: the marks are written in the formatter's order, and a number is written back as a number (299.00 may come back as 299.0 or 299). Keep exact spellings in the body or as text values.

Refused messages

plain text with no address
@alice ? no destination
@_bot>alice . a name may not start with _
@jürgen>alice . names are ASCII
Grammar | OPVS