Bind and answer deliveries
For: plugin authors.
Bind event identities, carry forms and choose delivery responses.
Bind events to the delivery
Your function runs on a node, and what it answers is its reading of the delivery. The edge holds that reading to the delivery it verified: bind gives, for each field of an event that something acts on, a CEL expression over the delivery's body, headers (lowercase names), verified (the facts the edge checked, such as a token's claims) and sealed (the facts of a seal the delivery carried back) that gives the value the field has, or a list of the values of the delivery's events, one each.
A delivery whose answer has an event that differs in a bound field is refused (422, audited as event.failed), and the fields that match are the event's verified ones.
bind:
type: "headers[?'x-github-event'].orValue('')"
action: "body.?action.orValue('')"
subject: "body.repository.full_name + '#' + string(body.number)"
attributes.sender: "body.?sender.?login.orValue('')"
You may bind type, action, subject, text, attributes.<name>, and a chat message's message.kind, workspace, room, thread, id, author.id, action, agent, private and direct. A text binding gives a string, compared without the whitespace around it, or for a form an object of its values, which the event's text holds as one JSON object with nothing after it (text after the object would be words the delivery never held, so such an event is refused).
CEL's optional fields (body.?a.?b.orValue('')) read a field a delivery may lack, cel.bind(name, value, expr) names a value within one, json(s) reads JSON text, such as a form field that holds a JSON document, unescapeHTML() reads HTML's character references, and the string, regex, list and two-variable comprehension extensions are there (regex.replace, transformMapEntry). A binding is at most 4096 bytes. A binding that fails on a delivery's shape verifies nothing for it.
A bound value is compared with its type: false, 0, "" and null are four values, and a number is not its digits, so 12 and "12" differ. Two numbers are the same only when they are the same number, whichever way they are written (12 and 12.0, 1.50 and 15e-1).
A binding that gives null says the delivery holds no such attribute, so the event must leave it out: a function that adds one, with any value, is refused, and that is how a source binds an attribute that only some of its deliveries have (k == 'push' ? body.?after.orValue('') : null). An event's own fields leave out their empty value, so there null, "" and, for message.private and message.direct, false are what leaving one out means.
A sender that gives a value in two forms is named in the binding: string(body.number) when your function answers its digits, and cel.bind(v, body.?conclusion.orValue(''), v == null ? '' : v) for a field a sender may send as null when your function answers "". Before this rule a binding's '', false or 0 also matched an attribute the function left out: a source's bindings written that way give a delivery its events' failures (event.failed) until they say null.
A value that cannot be compared refuses the delivery: a number no double holds (1e400), in a text that is a JSON object or read by json(), and a value that is not finite. A text is words or one JSON object, never both: empty text is no object, and an object that gives a key twice has no one reading, so it stays words and equals only itself. json(s) reads one value, and text after it fails as a shape that does not fit.
One delivery gives no more events than it holds. The bindings read a delivery as rows, one per event: a binding that gives one value gives it to every event, and the bindings that give a list give the values of the delivery's events in the same order, so all of a source's lists have one length for a delivery (a delivery whose lists differ is refused).
Each event of the answer must be a row of its own in every bound field, so a node can neither give more events than the rows, nor pair one event's subject with another's number. An empty list means the delivery holds no event: a source whose function takes nothing from some deliveries (another app's button, say) gives [] for them, and any event answered for such a delivery is refused. A list is never a choice: to say "the first of these that is set", give that one value (([a, b].filter(x, x != '') + [''])[0]).
A source that binds no list gives one event a delivery.
A chat channel's delivery is one message. The edge refuses a delivery to a channel's source whose bindings read it as more than one row, whatever the answer picks, so each binding of a chat source gives the one value its function reads, never the values it might read.
What acts on an event acts only on what the edge verified:
- Routines and flows fire on an event's bound
type,action,subject,textand attributes: a trigger withtypes, afilteror asenderthat reads a field the source does not bind does not fire, and its line says why. Asendergate reads only a bound field, soallow_sendersholds against a node that answers for the source. - Waits take an event whose bound type and subject they name, and their filter reads only bound fields.
- A chat channel acts on a message only when its source binds its
kind,workspace,room,thread,id,author.id,action,text,agent,privateanddirect: lint refuses a channel whose source does not, and the edge refuses such a message (channel.refused). What a person wrote, a form's values among it, is then the delivery's, never your function's.
Bind only what the signature covers. GitHub's HMAC covers the body and not X-GitHub-Event, so a type bound to that header alone verifies a word anyone who holds one signed body could change by sending it again under another event's header: the GitHub plugin binds the header's type only when the signed body has the keys that event's bodies have and lacks the ones of the events like it, and gives [] (no event) otherwise. Do the same for any header a scheme does not sign.
Only what the edge checked is verified: with signature.scheme: plugin your function checks the signature, so the edge verifies none of the source's fields, whatever bind says, seals nothing, and lint refuses a channel on such a source. The fields no source can bind (mentions, a command's name, a person's display name) are always your function's reading: they decide nothing about who sent the event or what it is.
The first-party sources bind every field their routines, waits and channels read; a source that binds nothing still records its events and wakes the waits and fires the triggers that name no type, subject or filter, and lint warns.
Carry a click through a form
Some chat systems give a form's answer back without the click that opened it: a Slack modal, which your function opens with the click's short-lived trigger, comes back with only what your function put in it. What your function put there is the node's word, so the source asks the edge to vouch for the click:
seal:
when: "<CEL over body, headers and verified: the deliveries whose facts the edge seals>"
facts:
room: "<CEL: a string>"
message: "<CEL: a string>"
author: "<CEL: a string>"
from: "<CEL over body and headers: the seal a later delivery carries back, or ''>"
For a delivery the edge checked that when holds for, the edge evaluates facts (at most 16, each one line of at most 200 bytes) and hands your function a seal of them as delivery.seal: a MAC under a key of the tenant's that only edges hold, which opens for one hour and for this source only. Put it where the other system sends it back, such as a modal's private metadata.
When a later delivery carries one (from), the edge opens it and your bindings read its facts as sealed; a seal that does not open, or none, gives sealed no facts, so the bindings that read it verify nothing and a channel refuses the form, telling the person to open it again. Bind the form's room, message and button to sealed, and check that sealed names the person who sent the form, as the Slack plugin does.
How deliveries are answered
| Answer | When |
|---|---|
202 with {"event", "events", "routed", "ignored"} | The delivery was taken. routed lists where its events went, such as routine pr-review (trigger pr-opened): job rt.pr-review.pr-opened.<delivery> or wait w1 of job <job>. |
202 with {"state": "accepted"} | Taken, and routed later: no node with the plugin answered within 6 seconds (GitHub gives up after 10). The edge keeps trying for an hour. |
401 | A wrong or missing signature, or your function refused it. A source whose secret is not set yet answers the same, so the answer tells a sender nothing about the tenant's secrets; the edge's log says which it was |
404 | No such source in the tenant |
409 | Two plugins declare a source of that name |
413 | The body is larger than max_body_kb |
422 with {"event", "error"} | Your function failed on the delivery, or answered what the edge does not take (a bad type, too many events). Fix the plugin and send the delivery again: a delivery that failed runs again. |
503 | The source's secret could not be read now, a kill switch stops its tool or the tenant (oz0_killed), or the edge could not take it now: send it again |
A delivery is named by its body's SHA-256, which its signature covers, so a delivery sent again is the same delivery whatever its headers say: its events are recorded once, and what they started is not started again. Only a delivery your function failed on runs again. A source whose sender signs a timestamp also refuses a delivery sent again outside its window; one that signs the body alone takes the same body again only once the edge has forgotten its first routing (the run is kept for the namespace's retention, the events for a day).
plugin inspect and the plugin hub list each event source under outside_input, with the endpoint it opens and the function that reads it, and its secret under secrets, so a reviewer sees what a plugin takes from outside.
oz0 event list # the latest events, and where each went
oz0 event list --json # with their text and attributes, for callers who may see content
The edge keeps events for a day.
Answer at once
Some senders want a particular answer, and soon: Slack checks a new URL by sending a challenge it wants back, and gives three seconds; Discord sends a PING that wants a PONG, and wants a command answered within three seconds, often with "I will answer later".
Your function runs on a node, a round trip through Temporal away, so the edge answers these itself from rules you declare under replies. Each rule has a CEL condition over body (as your function gets it) and headers (lowercase), and an expression that builds the answer; the first rule whose condition holds answers, after the signature check:
events:
- name: discord
# ...
replies:
- when: "body.type == 1" # PING
reply: "{'type': 1}" # PONG
route: false # nothing more to do: no node sees it
- when: "body.type == 2" # a slash command
reply: "{'type': 5}" # deferred: the answer comes later, as an edit
- when: "true" # anything else: 200, no body, and on to your function
| Field | Meaning | Default |
|---|---|---|
when | A CEL condition. One that fails, such as on a field the body lacks, does not hold | required |
reply | A CEL expression: a map or a list is sent as JSON, a string as text; empty sends no body. CEL's string functions (replace, substring, startsWith) are there | none |
status | The answer's status, a 2xx | 200 |
route | false stops the delivery at the edge | true |
A delivery a rule answered with route left on still goes to your function and is routed as usual; the sender just does not wait for it. Without a rule that holds, the edge answers as above, after the routing or within six seconds.