Half of what a real process waits for happens somewhere else: a file lands on an upload platform, a nightly load finishes, a ticket closes, a lab returns a result. None of that is a click in your app.
The event log is the API. Current state is a fold over an append-only list of events, so causing an event is the only thing an outside system ever needs to do.
The inbox
One message per file, dropped into
<store>/inbox/:
{"instance": "2026Q1", "task": "delivery", "element": "Northgate",
"field": "status", "value": "done", "actor": "delivery-platform"}Only task is required. instance defaults to
"latest", field to "status",
value to "done", actor to
"external", so the smallest useful message is
{"task": "delivery"}.
Nothing but a directory is involved: no server, no port, no token store, no dependency. Every language can write a file, and it works over any shared or synced mount. What it costs is that the filesystem is the authentication: whoever may write into the inbox may move a process forward. That is the same trust as “whoever may INSERT into the events table”; enforce it with directory permissions.
End to end
store <- tempfile()
process <- data.frame(
task = c("each_unit", "delivery", "review", "approve"),
name = c("for each unit", "Data delivery", "Review", "Approve"),
role = c("", "system", "analyst", "management"),
depends_on = c("", "", "delivery", "each_unit"),
collection = c("unit", "", "", ""),
parent = c("", "each_unit", "each_unit", "")
)
start_instance(process, c("north", "south"), "2026Q1", store)delivery is system with no
script: the worker will not run it (no script) and the task
list will not let anyone tick it (role system), so the only
thing that can complete it is a message from outside. That is the
convention for a task that waits on the outside world: BPMN’s receive
task.
The platform delivers north:
write_inbox_message(
store, task = "delivery", element = "north", instance = "2026Q1",
actor = "delivery-platform", id = "delivery-2026Q1-north"
)
ingest_inbox(store)
#> inbox: delivery-2026Q1-north.json -> delivery@north status=done
tab <- instance_view(store, "2026Q1")
tab[, c("task", "element", "status")]
#> task element status
#> 1 delivery north done
#> 2 delivery south open
#> 3 review north open
#> 4 review south blocked
#> 5 approve <NA> blockednorth may be reviewed; south is still
blocked, waiting for its own delivery. The event says who caused it and
which message it was:
instance_log(store, "2026Q1")[1, c("task", "element", "value", "actor", "ref")]
#> task element value actor ref
#> 5 delivery north done delivery-platform delivery-2026Q1-north.jsonref is what makes an event traceable back
out of the process: actor says which
system spoke, ref says which of its messages this was.
In production nobody calls ingest_inbox() by hand;
run_worker() does it on every tick. A process with no
scripts at all can run ingest_inbox() from cron instead; it
is an ordinary function.
Resending is free
Networks time out and senders retry. A delivery counted twice is a wrong process, so the file name is the idempotency key:
write_inbox_message(
store, task = "delivery", element = "north", instance = "2026Q1",
actor = "delivery-platform", id = "delivery-2026Q1-north"
)
ingest_inbox(store)
#> inbox: delivery-2026Q1-north.json already applied, ignored
sum(instance_events(store, "2026Q1")$task == "delivery")
#> [1] 1One event, not two. Give a message a stable id that says what it is
about (delivery-2026Q1-north), and a resend costs nothing.
The duplicate is kept in processed/ rather than deleted,
because a file someone sent is evidence.
Three outcomes
| outcome | what happens | where the file goes |
|---|---|---|
| applied | one event is appended | inbox/processed/ |
| impossible | nothing is written |
inbox/failed/, with a .error beside
it |
| early | nothing happens yet | stays in inbox/
|
“Impossible” means it can never apply, however often it is retried: no such task in this instance, an element that is not in the collection, an element on a task that does not repeat, unreadable JSON. The reason is written next to the file:
write_inbox_message(store, "delivery", element = "westside",
instance = "2026Q1", id = "wrong")
ingest_inbox(store, quiet = TRUE)
readLines(file.path(inbox_dir(store), "failed", "wrong.json.error"))
#> [1] "'westside' is not an element of instance '2026Q1'"“Early” means the message is right but the instance has not been started yet, a race rather than a mistake. The file stays where it is and the next tick picks it up.
Validation is against the instance, not a schema: the definition an instance was started with is stamped into its log, so the check knows which tasks exist and which elements are real.
Sending from somewhere else
Anything that can write a file can send a message. Write to a temporary name in the same directory and rename, so a half-written file is never read:
# shell
cat > /srv/store/inbox/.tmp <<JSON
{"instance":"2026Q1","task":"delivery","element":"north","actor":"platform"}
JSON
mv /srv/store/inbox/.tmp /srv/store/inbox/delivery-2026Q1-north.jsonWhen the sender cannot reach the filesystem
Two variants, both built on the same message, so the process side does not change.
HTTP: put a plumber route in front of the inbox. This is the whole server:
# plumber.R
library(blockr.process)
store <- Sys.getenv("PROC_STORE")
#* @post /events
function(req, res, task, element = NULL, value = "done",
field = "status", instance = "latest", actor = "api", id = NULL) {
if (!identical(req$HTTP_AUTHORIZATION, paste("Bearer", Sys.getenv("PROC_TOKEN")))) {
res$status <- 401
return(list(error = "unauthorized"))
}
write_inbox_message(store, task, value, field, element, instance, actor, id)
list(ok = TRUE)
}curl -X POST https://.../events -H "Authorization: Bearer $TOKEN" \
-d task=delivery -d element=north -d id=delivery-2026Q1-northThe endpoint never writes an event; it writes a message, and the worker does the rest. Idempotency, validation and the audit trail come along unchanged.
Pull: a scheduled script on the process side asks the other system what is true and turns the difference into messages:
delivered <- DBI::dbGetQuery(con, "
SELECT unit, uploaded_at FROM deliveries WHERE period = '2026Q1'
")
for (i in seq_len(nrow(delivered))) {
write_inbox_message(
store, task = "delivery", element = delivered$unit[i],
instance = "2026Q1", actor = "upload-db",
id = paste0("delivery-2026Q1-", delivered$unit[i]) # <- resends are no-ops
)
}
ingest_inbox(store)The id means the script does not need to remember what it already sent, and it does not need to query for “new” rows. Sending everything every time is correct, because the second send of a message is ignored. That is simpler than a watermark, and a missed run is repaired by the next one.
Assignments, not just completions
A message sets any field, so a roster system can assign work:
write_inbox_message(store, "review", field = "assignee", value = "ana",
element = "north", instance = "2026Q1",
actor = "hr-system", id = "assign-north")
ingest_inbox(store, quiet = TRUE)
tab <- instance_view(store, "2026Q1")
tab[tab$task == "review", c("element", "assignee", "status")]
#> element assignee status
#> 3 north ana open
#> 4 south blockedWhat is not here yet
Everything above is inbound. A process that wants to tell another system something (a webhook when an instance completes, a row written back when a delivery is approved) has no support yet; you would write it as a script task. First-class outbound messages are the most likely next addition.