kind: Process — react to your data¶
The second rung: a declarative watcher on ingested rows. When a row lands that matches your
conditions, the process emits a decision.* event — and the effect IS the event: delivery rides
the hub's existing webhook subscriptions (n8n, Make, Zapier, your endpoint). Zero new code paths.
The real manifest (from the WOW demo — proven live)¶
# The dragon's first rung, PROVEN LIVE: when a new customer from Lyon lands in the CSV,
# emit a decision event. The effect IS the event — delivery rides the existing webhook
# subscriptions (n8n/Make/Zapier), zero new code.
#
# Why `ville` and scope `demo` (not `segment`/`clients`): a process triggers on
# data.row.changed, born at the RAW write path — so on.table is the raw ingest table
# `connector.t_cust_csv`, and its columns are the CSV's own (code, cname, email, ville).
# `segment` is a Siebel field, absent from the CSV. The raw table lives in the `demo` scope
# card (the csv connector is tagged `demo`); the fused `clients` card names only v_customer.
apiVersion: apps.lumnik.io/v1
kind: Process
metadata:
name: lyon-client-arrive
spec:
scope: demo
on:
event: data.row.changed
table: connector.t_cust_csv
when:
- ville=Lyon
emit:
type: decision.lyon-client-arrive
message: "Nouveau client {cname} à {ville}"
Fields¶
| Field | Required | Meaning |
|---|---|---|
metadata.name |
yes | kebab-case ([a-z0-9-]+) |
spec.scope |
yes | the scope whose card declares on.table |
spec.on.event |
yes | must be data.row.changed — the only v1 trigger |
spec.on.table |
yes | the raw ingest table (connector.t_<name>) — see the note below |
spec.when[] |
yes (≥1) | conditions in the view filter grammar, ANDed |
spec.emit.type |
yes | must start with decision. |
spec.emit.message |
yes | human message, {col} templated |
That table is the whole surface: apply refuses a key it does not honor and names it, so a
misspelled mesage: costs you a 400 rather than a decision that fires with no text. The
apiVersion: envelope is accepted and ignored.
lm process apply -f lyon-process.yaml
lm process list
In the TUI, :processes lists the declared rules; e re-opens the stored manifest through
the :new authoring loop (edit → field-by-field validation → apply). The stored manifest is
also retrievable raw: GET /api/processes/{name}/manifest.
The whole /api/processes surface (apply, list, manifest) is the admin door — the
lm_admin role: declaring an attention rule is tenant administration, not a métier gesture.
Unlike views, entities, and chat, it is not métier-scope-bound — there is no
403 {"error":"scope not granted"} here; the admin sees the whole tenant.
Every stored process carries an enabled flag (default true; the ENABLED column in
lm process list), and only enabled processes fire. There is no API or CLI gesture to
toggle it yet — disabling is a database-level edit.
Trigger at the raw write path
data.row.changed is born where rows are written — so on.table is the raw table the
connector fills, not a fused view, and when columns are that table's own columns. The
scope must be the one whose card carries that raw table (in the demo: the CSV connector's
demo tag, not the fused clients scope).
Message templating¶
{col} interpolates the row's value ("Nouveau client {cname} à {ville}"). A null value renders
as an empty string; there is no escape syntax — a placeholder is exactly {letters_digits_},
anything else ({}, {a-b}, a lone brace) passes through literal. Every {col} is validated
at apply time against the trigger table: an unknown placeholder is refused
(message references {x} — not a column of …), never discovered silently at fire time.
Every referenced column also rides in the event payload as fields, so the receiving
automation gets the data, not just the sentence.
Honesty & failure contract¶
- Refusable at apply: structural errors, an unknown
whencolumn, or an out-of-domain value are refused with the card's hint — a process that could never fire is never accepted silently. - Fail-closed per process at runtime: a process that breaks (e.g. its table vanished) logs and skips — it never blocks the event cursor or its sibling processes.
- Declarative only, by doctrine: there is no Kotlin escape hatch for processes. If a real métier process can't be expressed declaratively, that limitation should surface — not be hidden inside a script (the substrate breathes, it does not mutate).
See also¶
- Inbox — where a human sees and disposes of the emitted decisions.
- Workflows — declares what a status column's values mean, so a
when: statut=TERMINEcondition matches a state someone actually declared; a process fires on a row landing, but silence — nothing landing — is the workflow's dormancy sweep to see. - Kafka connector — events into the hub.