Every field of the two manifest formats, with types and whether it's required. Manifests are TOML. For what the words mean, see Core concepts.
- Node manifest — one node per
.bloccsfile - Network manifest — topology
Node manifest
[node] — required
| key | type | required | meaning |
|---|---|---|---|
id | string | ✓ | node identifier |
version | string | ✓ | semver of this node |
kind | "source" | "transform" | "router" | "sink" | ✓ | role in the graph |
[node]
id = "enrich"
version = "0.1.0"
kind = "transform"[doc] — optional
Human-facing documentation. Both keys optional.
| key | type | meaning |
|---|---|---|
intent | string | what the node is for |
owner | string | team/person responsible |
[ports.in] / [ports.out]
A table of named ports. Each value is an inline table:
| key | type | required | meaning |
|---|---|---|---|
schema | string "Name@N" | ✓ | the message schema on this port |
buffer | positive integer | — | in-ports only: producer mailbox bound (back-pressure). Ignored on out-ports. |
[ports.in]
event = { schema = "RawEvent@1", buffer = 1000 }
[ports.out]
enriched = { schema = "EnrichedEvent@1" }
failed = { schema = "FailedEvent@1" }The schema strings must be registered via
Bloccs.Schema.register/2at app start. Edges between ports are checked for matching schemas at validation time.
[effects] — optional
Declared capabilities. Omit the block (or an axis) to deny that axis entirely. An undeclared axis is refused at runtime; a declared one is allowlist-enforced.
| axis | type | meaning |
|---|---|---|
http | { allow = [host, …], methods = [method, …] } | outbound HTTP, restricted to those hosts + methods |
db | { allow = [scope, …] } | DB ops, each scope a "table:action" string |
time | "wall_clock" | "none" | clock access |
random | "none" | "pseudo" | "crypto" | randomness source |
[effects]
http = { allow = ["enrichment.local"], methods = ["GET"] }
db = { allow = ["events:insert"] }
time = "wall_clock"[contract] — required
| key | type | required | meaning |
|---|---|---|---|
pure_core | string "Mod.fun/arity" | ✓ | pure-core function ref; must exist with that arity |
effect_shell | string "Mod.fun/arity" | ✓ | effect-shell function ref; must exist with that arity |
timeout_ms | positive integer | — | bounds each attempt; overrun fails the message with :timeout (retriable) |
idempotency | { key = "field" } | — | dedup repeat deliveries by that payload field (atomic in-flight reservation) |
retry | inline table (below) | — | retry policy |
retry table:
| key | type | required | meaning |
|---|---|---|---|
strategy | "constant" | "linear" | "exponential" | ✓ | back-off curve |
max | non-negative integer | ✓ | max attempts |
on | list of strings | ✓ | failure reasons that are retriable (e.g. ["timeout"]) |
base_ms | positive integer | — | base delay for the back-off |
Permanent failures (schema mismatch, capability denial) are never retried,
regardless of on.
[contract]
pure_core = "MyApp.Nodes.Enrich.transform/2"
effect_shell = "MyApp.Nodes.Enrich.execute/2"
timeout_ms = 3000
idempotency = { key = "id" }
retry = { strategy = "exponential", max = 2, on = ["timeout"], base_ms = 50 }[batch] — optional
Present only on aggregate nodes. When set, the node processes messages in
batches via Broadway batchers instead of one at a time: its pure_core receives
the list of payloads in the batch (not a single payload) and reduces them,
and its effect_shell emits the aggregate result (one emit, a split, or a drop —
like any node). The batch flushes when it reaches size messages or after
timeout_ms of idle, whichever is first, so a partial batch is never stranded.
| key | type | default | meaning |
|---|---|---|---|
size | positive integer | 100 | count window — flush after this many messages |
timeout_ms | positive integer | 1000 | time window — flush a partial batch after this idle period |
At least one of size / timeout_ms must be set. A [batch] node may not
also declare [contract].retry, idempotency, or timeout_ms — the batch path
is at-least-once and does not run those per-message policies yet (the validator
rejects the combination).
[batch]
size = 100
timeout_ms = 5000[join] — optional
Present only on join nodes. A join node has two or more in-ports
carrying distinct schemas; arrivals are correlated by the value of the on
field (present in every input payload). When all in-ports have produced a
payload for the same key, the node's pure_core receives the map
%{port => payload} and emits the joined result. (This is the one case where a
node may declare more than one in-port — each compiles to its own pipeline.)
| key | type | default | meaning |
|---|---|---|---|
on | string (field name) | — (required) | correlation key — a field name present in every input payload (not a predicate) |
timeout_ms | positive integer | — | how long to hold a partial match before giving up |
deadletter | string (out-port name) | — | a partial match that times out is emitted there as an %{key, present, payloads} envelope; without it, a timeout drops with a [:bloccs, :join, :timeout] event |
A [join] node may not also be a [batch] node, nor declare
[contract].retry / idempotency / timeout_ms (the join path is
at-least-once and does not run those per-message policies yet).
[ports.in]
left = { schema = "Order@1" }
right = { schema = "Payment@1" }
[ports.out]
joined = { schema = "Reconciled@1" }
deadletter = { schema = "Unmatched@1" }
[join]
on = "order_id"
timeout_ms = 30000
deadletter = "deadletter"[rate] — optional
Throttle: cap how fast the node's producer delivers messages downstream, using Broadway's built-in producer rate limiting.
| key | type | meaning |
|---|---|---|
allowed | positive integer | messages allowed per interval |
interval_ms | positive integer | the interval window, in ms |
[rate]
allowed = 100
interval_ms = 1000[delay] — optional
Delay: hold every message for ms before it enters the node (the producer
time-shifts delivery — the push is acked immediately, it does not back-pressure).
[delay]
ms = 5000[rate] and [delay] are not supported on a [join] node. Debounce (collapse
a burst, keep the last) is not a separate block — model it as a time-windowed
[batch] whose pure_core keeps the last payload.
[observability] — optional
A free-form table captured onto the node (metrics, traces, …); telemetry is
emitted on every span regardless. Stored as-is for downstream tooling.
[observability]
metrics = ["duration_ms"]
traces = "auto"Network manifest
A file is parsed as a network (not a node) when it has a top-level [network]
table.
[network] — required
| key | type | required | meaning |
|---|---|---|---|
id | string | ✓ | network identifier |
version | string | ✓ | semver |
runtime | string | — | target runtime; defaults to "beam" |
[nodes] — required
A table mapping a local id to an instantiation. Each value is an inline table
with a use path pointing at a node manifest — or another network manifest, in
which case it is flattened in as a subgraph (namespaced ids like pipe.up).
| key | type | required | meaning |
|---|---|---|---|
use | string (path) | ✓ | node or network manifest to instantiate |
config | inline table | — | per-instance config overlay for parameterized nodes |
[nodes]
ingest = { use = "../nodes/ingest.bloccs" }
enrich = { use = "../nodes/enrich.bloccs" }[[edges]] — required
An array of tables. Each edge wires one out-port to one or more in-ports.
Endpoints are "node.port" strings.
| key | type | required | meaning |
|---|---|---|---|
from | string "node.port" | ✓ | source out-port |
to | string | list of strings | ✓ | one or more destination in-ports (a list fans out) |
[[edges]]
from = "ingest.event"
to = "enrich.event"
[[edges]]
from = "route.known"
to = ["persist.event", "notify.event"] # fan-out
[[edges]]
from = "left.out"
to = "collect.in"
[[edges]]
from = "right.out"
to = "collect.in" # fan-in (merge): both feed collect.inThe validator checks both endpoints exist, schemas match end-to-end, and the graph is acyclic. Several edges may target one in-port (fan-in / merge) — that port's producer receives all of them, interleaved and unordered.
[expose] — optional
Promotes internal ports to the network's public ports (and makes it usable as a
subgraph). Two tables, in and out, mapping a public name to an internal
"node.port".
[expose]
in = { webhook = "ingest.received" }
out = { stored = "persist.stored",
dead = "deadletter.recorded" }mix bloccs.run --port <name> and the default message intake use these names.
[supervision] — optional
Supervisor config for the generated tree. Defaults: one_for_one, 3, 5.
| key | type | meaning |
|---|---|---|
strategy | "one_for_one" | "one_for_all" | "rest_for_one" | restart strategy |
max_restarts | non-negative integer | restart intensity |
max_seconds | positive integer | restart window |
[deploy] — optional
| key | type | meaning |
|---|---|---|
concurrency | table { node = N, … } | per-node Broadway processor concurrency |
placement | string | placement hint (captured; "default" today) |
[deploy]
concurrency = { enrich = 4, persist = 1 }
placement = "default"