kind: Workflow — declare the lifecycle¶
The fourth rung, and the first that adds no surface: a declarative document lifecycle over a column already landed by ingestion. Views let you see, processes react, the inbox decides — a workflow adds none of those. It adds knowledge, and grounds the other three.
Twenty years of an ERP's Excel exports don't carry clean statuses — a legacy source is dirty input, not error, and the lifecycle it implies usually lives in a head, not a schema. Declaring it turns that head-knowledge into ground truth the chat can cite.
Why it exists (the honesty rationale)¶
Ask a model "pourquoi je ne peux pas clôturer cette commande ?" against a corpus with no declared lifecycle, and it answers anyway — inventing a plausible one, because a lifecycle sounds like the kind of thing a document has. A declared graph removes the option to invent: the chat is grounded in the states and transitions the integrator actually declared, or it abstains. See Act 3 of the WOW demo for the traced example this page draws from.
The real manifest (from the WOW demo — Act 3)¶
# Declare the document lifecycle of a column instead of teaching the chat its values by
# hand: states + labelled aliases (Excel reality is dirty: "en cours"/"encours" both mean
# the same thing) + a transition graph. provenance MUST be `declared` — v1 accepts no
# other value. `on.table`/`on.column` name the raw ingest column whose values this
# taxonomy explains; apply reports the data-truth confrontation: undeclared VALUES found
# in the data (reality, never refused — 'EN LITIGE' is one) and unobserved CODES declared
# but never seen.
apiVersion: apps.lumnik.io/v1
kind: Workflow
metadata:
name: commande-lifecycle
spec:
scope: demo
provenance: declared
# the sweep alerts a non-terminal commande observed unchanged past 30 days —
# observation-relative, see docs/apps/workflows.md
dormancy:
after: 30d
on:
table: connector.t_commandes_csv
column: statut
key: num
states:
- code: BROUILLON
initial: true
- code: EN_COURS
label: "En préparation"
aliases: [en cours, encours]
- code: TERMINE
aliases: [fini]
terminal: true
transitions:
- { from: [BROUILLON], to: EN_COURS }
- { from: [EN_COURS], to: TERMINE }
commandes.csv, the source behind connector.t_commandes_csv, carries En cours, fini,
TERMINE (trailing space), BROUILLON, encours — and twice, EN LITIGE, a value nobody
ever declared. This manifest is the lifecycle an integrator (standing in for the person who
used to keep it in her head) declares over that mess.
Fields¶
| Field | Required | Meaning |
|---|---|---|
metadata.name |
yes | kebab-case identifier ([a-z0-9-]+) |
spec.scope |
yes | the métier scope this lifecycle belongs to; must be a discovered scope (see below) |
spec.provenance |
yes | must be declared — v1 accepts no other value (see below) |
spec.on.table |
yes | the raw ingest table, connector.[a-z_][a-z0-9_]* |
spec.on.column |
yes | the column whose values this taxonomy explains, [a-z_][a-z0-9_]* |
spec.on.key |
no (required by dormancy) |
the business key linking a document's successive versions, [a-z_][a-z0-9_]* — see Dormancy |
spec.dormancy.after |
no (required by dormancy) |
declares the sweep threshold, [0-9]+[mhd] (e.g. 30d) — requires spec.on.key — see below |
spec.states[] |
yes (≥1) | the declared states — see below |
spec.transitions[] |
no | the allowed edges of the graph — see below |
states[]
| Field | Required | Meaning |
|---|---|---|
code |
yes | the canonical state code; unique, case/trim-insensitive |
label |
no | a human label |
aliases[] |
no | dirty-data spellings that resolve to this code |
initial |
no | exactly one state across the manifest must set this true |
terminal |
no | a terminal state may not carry an outgoing transition |
transitions[]
| Field | Required | Meaning |
|---|---|---|
from[] |
yes (≥1) | source state code(s) — one edge can fan in from several states |
to |
yes | destination state code |
That table is the whole surface: apply refuses a key it does not honor and names it, so
a misspelled provenence: costs you a 400 rather than a lifecycle that's silently incomplete.
The apiVersion: envelope is accepted and ignored.
The scope must exist, the table need not¶
spec.scope is checked against the tenant's discovered scopes (its SchemaCards). An unknown
scope is a 404 — no schema for scope 'demoo' — run discovery first, the same code and the
same wording kind:Process answers with, because it is the same condition — for
the same reason a misspelled key is refused: the chunk is tagged with spec.scope, so a typo
would index the lifecycle
under a tag nobody holds. Nothing would fail, and the chat would quietly go back to inventing.
The applied scope comes back in the response ("scope") so you can see the tag that was written.
Only the scope is checked. The trigger table is deliberately not required to be in the
scope's card — declaring a lifecycle before ingesting the data is a legitimate order of
operations, and that is exactly what the tableMissing report below is for. (kind:Process does
require its trigger table, because a process fires on rows; a workflow only describes them.)
One column, one lifecycle¶
Two workflows on the same on.table + on.column would put two contradictory declared graphs in
the corpus for the same column — both retrieved, the chat forced to choose. So a different name
declaring an already-declared column is a 400 naming the workflow that holds it. Renaming a
workflow therefore means re-applying it under the existing name (apply on the same name is the
update path); the boundary is per tenant.
That check is also backstopped at the database — UNIQUE (tenant_id, on_table, on_column) — so
two concurrent apply calls declaring different names on the same column can never both succeed:
the loser still gets a 400 ("a column has exactly one lifecycle"), just without the winner's
name, since the race is caught after the pre-check already found nothing.
Alias semantics¶
Alias and code matching is trim + case-insensitive ("TERMINE ", "fini", "En cours"
all resolve). That normalization is the whole point: it is what lets twenty years of dirty
Excel input collapse onto a handful of declared states without a cleanup pass first. Two
declaration-time contradictions are refused, not silently resolved:
- an alias that collides with a state code (declaring
aliases: [en_cours]on a state other thanEN_COURSwould make the same string mean two things); - an alias declared on two different states — ambiguous by construction.
Structural rules on the graph itself: exactly one state must be initial: true, and a
terminal: true state must have no outgoing transitions[].from entry — a "closed" state
that could still transition somewhere is a contradiction in the declaration, and apply
refuses it.
Provenance, and why it exists for a single accepted value¶
spec.provenance is required, and v1 accepts only declared. That looks redundant — why
require a field with one legal value? — but the field is there for what it prevents: if
provenance is ever going to mean "observed from data" or "inferred by the model" (neither
exists yet), citing how a lifecycle is known must never be a retrofit bolted on after the
fact. The chunk indexed into the corpus spells the value out verbatim
("declared by the integrator"), so the chat can already distinguish a declared graph from an
observed fact — even though, today, every graph in the system is declared.
Dormancy — the memory of the breathing¶
Legacy systems destroy time: a status is overwritten in place, so nothing in the raw table
says a commande has sat in EN_COURS for six weeks — only that it currently is EN_COURS.
lumnik reconstructs that missing time axis because every ingestion is a dated observation: a
scheduled sweep (every 15 minutes by default, lumnik.workflow.sweep.every — see
Observability) watches each
document's declared state and alerts when it has sat, unmoved, in a non-terminal state past a
declared threshold.
Two more fields turn a lifecycle declaration into a watched one:
# Declare the document lifecycle of a column instead of teaching the chat its values by
# hand: states + labelled aliases (Excel reality is dirty: "en cours"/"encours" both mean
# the same thing) + a transition graph. provenance MUST be `declared` — v1 accepts no
# other value. `on.table`/`on.column` name the raw ingest column whose values this
# taxonomy explains; apply reports the data-truth confrontation: undeclared VALUES found
# in the data (reality, never refused — 'EN LITIGE' is one) and unobserved CODES declared
# but never seen.
apiVersion: apps.lumnik.io/v1
kind: Workflow
metadata:
name: commande-lifecycle
spec:
scope: demo
provenance: declared
# the sweep alerts a non-terminal commande observed unchanged past 30 days —
# observation-relative, see docs/apps/workflows.md
dormancy:
after: 30d
on:
table: connector.t_commandes_csv
column: statut
key: num
states:
- code: BROUILLON
initial: true
- code: EN_COURS
label: "En préparation"
aliases: [en cours, encours]
- code: TERMINE
aliases: [fini]
terminal: true
transitions:
- { from: [BROUILLON], to: EN_COURS }
- { from: [EN_COURS], to: TERMINE }
spec.on.key (num in the fixture) names the business key that links a document's successive
raw rows — the sweep needs it to tell "the same commande, later" from "a different commande".
spec.dormancy.after (30d, or any [0-9]+[mhd]: minutes/hours/days) declares the threshold;
spec.dormancy requires spec.on.key — dormancy is inherently per-document, so a workflow
with no key to follow documents by cannot declare one (refused at apply: "dormancy is per-document;
the key links a document's versions").
The key must also exist in the table for the sweep to have anything to read. A typo (key: nu
for num) passes the identifier grammar and applies with a clean 200 — so apply's data-truth
report names it as keyColumnMissing. That is a report,
not a refusal: declaring a lifecycle before ingesting the data is legitimate, and the column may
simply not be there yet. But if you declared dormancy and the alerts never come, this field is the
first place to look.
What the sweep does not see¶
The sweep reads one row per business key, and it skips a document outright when either half of the
pair is empty: a NULL or blank spec.on.key value, or a NULL or blank status value. A commande
whose num is NULL is therefore invisible to dormancy — permanently, not "until the next tick". It
is not that lumnik decides it is fine; it is that there is no key to follow the document by, so
"the same commande, later" cannot be established at all. Same for a NULL status: there is no state
to observe sitting still.
Those rows are not hidden from you — apply's report counts blank statuses in
nullOrBlank, so a column that is NULL on most of its rows
says so at declaration time. A NULL key has no equivalent counter in v1; if your source leaves
business keys empty, dormancy silently covers only the rows that have one.
The threshold is declared, not learned. This is the same honesty posture as provenance:
declared above: the sweep does not (yet) infer "commandes usually close in 4 days" from
observed history and alert on a deviation — that is the next rung on lumnik's time-defrost
staircase, out of scope here. What ships today alerts on the number an integrator wrote down.
Cold start is honest¶
The very first sweep to see a document records "since now" for its current state — it does
not, and cannot, know how long that document sat there before lumnik ever looked. So nothing
can be dormant before after elapses from lumnik's own first observation, never from the
source's history: a commande that has silently sat in EN_COURS for a year in the ERP does not
alert on ingestion day one; it alerts 30d after the hub first saw it. Every claim the sweep
ever makes is observation-relative, and it says so in the alert itself.
The alert, verbatim¶
Captured from a live replay against the WOW fixture (docs/demo/wow-customer/commandes.csv),
with dormancy.after shortened to 1m so the sweep could fire without a real 30-day wait —
the committed manifest still declares after: 30d; only the replay's clock ran fast, and the
line below is quoted exactly as the outbox produced it, threshold included:
CMD-005 : observé immobile en « EN LITIGE » depuis le 2026-08-04 (première observation 2026-08-04) — seuil déclaré 1m dans commande-lifecycle
That shape — <key> : observé immobile en « <state> » depuis le <date> (première observation
<date>) — seuil déclaré <after> dans <workflow> — never asserts a source-absolute duration.
It names the date lumnik started watching this state and the date it first ever saw the
document; the reader draws the "how long" from those two dates, not from a number lumnik
invented about the ERP's past.
Five of the fixture's seven commandes alert this way: CMD-001 (EN_COURS), CMD-004
(BROUILLON), CMD-005 (EN LITIGE), CMD-006 (EN_COURS), CMD-007 (EN LITIGE).
CMD-002 and CMD-003 never do — both resolve to the terminal TERMINE (fini and
TERMINE are declared aliases of it), and the sweep excludes terminal states by
construction. CMD-005 and CMD-007 are the pair worth a second look: they sleep in
EN LITIGE, a value this manifest never declares anywhere — and they alert anyway, because
an undeclared value can never be terminal, so
it is exactly as watchable as a declared non-terminal state.
The alert is emitted as decision.dormant.<workflow-name> (decision.dormant.commande-lifecycle
for the fixture), source=workflow:commande-lifecycle — the same outbox → inbox → webhook
pipeline every other decision rides. See Inbox.
Re-alert semantics¶
Once a document alerts, it does not alert again on every subsequent sweep tick — an
alerted_at marker gates re-emission. It clears only when the document is observed to have
moved: a state change resets the "since" clock and clears the marker in the same write.
So a document alerts again only after it left the stale state and then sat unmoved, past the
threshold, in a (possibly different) non-terminal state once more — not every 15 minutes
forever.
Known limitation — a dormant document alerts once, and the alert ages out. alerted_at is
permanent until the document moves, but the alert it produced is an ordinary outbox event and is
therefore subject to the tenant's event retention
(lumnik.events.retention-days, 30 by default — the inbox is a work surface, not an archive). So a document that is stuck permanently — one
that never moves again — alerts exactly once, and roughly a month later that alert is purged and
disappears from GET /api/inbox. The most stuck dossier in the building eventually becomes the
most invisible one. v1 ships this way deliberately: a re-alert policy (re-emit every N days while
still dormant? escalate? never expire a dormancy alert?) is a design decision, not a bug fix, and
guessing at it would trade one silence for a different noise. Until it is decided, treat the inbox
as a stream to triage, not a standing register of everything asleep — ack the alert into your own
tracker, or set lumnik.events.retention-days long enough that it outlives your review cadence.
Scale: the sweep materialises every document, every tick¶
Each tick, for each workflow, the sweep loads the latest state of every business key and the
whole observation ledger for that workflow into memory — there is no LIMIT, no cursor and no
batching in v1. The supporting indexes are in place, so this is a memory-footprint bound, not a
query-plan one: free at demo and departmental scale, hundreds of megabytes on a table with millions
of distinct documents. If you point dormancy at a table of that size, expect the hub's heap to feel
it. Batching is deferred until a real deployment needs it — the same posture as the transition
journal's retention policy, which v1 also leaves unbounded on purpose (the tape is the asset the
next rung of the time staircase computes on).
An undeclared value can never be terminal¶
EN LITIGE — the value this fixture's own data-truth report names as undeclared — has no
terminal: true anywhere in this manifest, because the manifest never declared it at all. An
undeclared value can therefore never be terminal, so a document sitting in EN LITIGE is
exactly as watchable as one sitting in a declared non-terminal state — and arguably the
sickest dossier in the building: it isn't just stalled, it's stalled and off the map the
integrator drew. apply's data-truth report already names that value the moment the manifest
is applied ("undeclared":[{"value":"EN LITIGE","rows":2}]); the dormancy sweep is what turns
"this value exists and nobody declared it" into "and here is the specific document that has
been asleep in it since \<date>".
Applying it¶
lm workflow apply -f commande-workflow.yaml
lm workflow list
lm workflow get commande-lifecycle
lm workflow apply prints the hub's response verbatim. Expected shape for the fixture above:
{"name":"commande-lifecycle","scope":"demo","table":"connector.t_commandes_csv","column":"statut",
"dataTruth":{"undeclared":[{"value":"EN LITIGE","rows":2}],"unobserved":[],"nullOrBlank":0,
"tableMissing":false,"keyColumnMissing":false,
"checkUnavailable":false,"unavailableReason":null}}
lm workflow list shows NAME | TABLE | COLUMN | SCOPE | ENABLED; lm workflow get NAME
fetches the stored manifest YAML verbatim (the edit round-trip: get, edit, apply again).
The whole /api/workflows surface (POST /apply, GET list, GET /{name}/manifest) requires
the lm_integrator role — declaring a lifecycle is integrator work, the same posture as
saving a view, not the tenant-wide admin door kind:Process sits behind. It is not
métier-scope-bound the way reads are: an integrator applying a workflow is not required to
hold a scope:<tag> grant for it.
The data-truth report — never blocks¶
apply parses and stores the manifest first, then confronts it with what the trigger
column actually contains — and that confrontation never turns into a refusal:
undeclared— values seen in the data with no matching state or alias (EN LITIGEabove), each with its row count. This is reality; reality doesn't ask the manifest's permission.unobserved— declared codes never once seen in the column. Had the fixture never carried aTERMINE/finirow,TERMINEwould show up here instead: a declared code that's never actually reached.nullOrBlank— how many rows carry no status at all (NULL, or blank/whitespace). Its own bucket, never folded into silence: a column that is NULL on 90% of its rows would otherwise reportundeclared: [], which reads as "the data agrees with the manifest" when in truth there was almost nothing to agree with.tableMissing— the trigger table doesn't exist (yet, or anymore). Still200.keyColumnMissing—spec.on.keynames a column the table does not have. Same "declared but not yet real" family astableMissing, and the only signal a typo'd key ever produces:key: nufornumsatisfies the identifier grammar, applies with a clean200, and then makes the dormancy sweep fail on every tick with nothing but a server-side log line to show for it. Still200— declaring before ingesting is legitimate and the column may simply not be there yet — but if you declared dormancy and no alert ever arrives, check this field first.checkUnavailable(+unavailableReason) — the confrontation could not be run at all: a statement timeout on a very large table, a dropped column, a revoked grant. It means "we could not confront the data" — not "the data agrees". The other fields of such a report are empty out of ignorance, not out of agreement; re-run the apply once the cause is gone if you want a real confrontation. The manifest is stored either way (see below), and the underlying error is logged server-side at WARN with its SQLSTATE.
All of them land in the same 200 response alongside the stored manifest — 200, not
blocked, always. That holds even when the probe itself fails: the declaration is already
stored, and a report that cannot be produced must not cost the integrator the lifecycle.
Refusal is reserved for a genuine contradiction in the declaration itself (a bad alias, two
initial states, an unknown key, an unknown scope, a column already declared); a fact about the
data — or an accident of the database — is never one.
Coverage bound: the confrontation groups the column's values and looks at only the
top 200 distinct values by row count (WorkflowDataTruth.LIMIT). On a low-cardinality
status column this is exhaustive in practice; on a high-cardinality one, a rare undeclared
value can rank below the cut and never surface in undeclared. A clean report is honest
about what it checked — the top 200 — not a claim that the whole column agrees with the
manifest.
Applying a workflow also (re)indexes it as one chunk in the manifest's scope, so:
lm ask --scope demo "pourquoi je ne peux pas clôturer la commande CMD-001 ?"
resolves against the declared graph — CMD-001's En cours is the state EN_COURS, and the
only transition out of EN_COURS goes to TERMINE; nothing in the declared lifecycle blocks
it. What's fixed run to run is the ground truth the answer must agree with — the states,
aliases and transitions in the manifest — never an invented lifecycle.
See also¶
- Processes — react to a row landing; a workflow explains the values a
process's
whenconditions match against. - Inbox — where a
decision.dormant.<workflow>alert surfaces for a human to see. - Ask & honesty — the abstention contract a grounded answer serves.