Files
portal/docs/state-machines.md
T

152 lines
5.1 KiB
Markdown
Raw Permalink Normal View History

2026-09-25 21:35:25 +02:00
# State machines
`aggregates.yaml` declares what a bucket's records may be and how they
move. It is the one file that decides what is possible; the pages only
decide who presses what.
```yaml
aggregates:
- bucket: applications
initial: open
states:
open: { event: received }
incomplete: { event: incomplete }
complete: { event: complete }
on_hearing: { event: on_hearing }
recommended: { event: recommended }
approved: { event: approved }
refused: { event: refused }
withdrawn: { event: withdrawn }
transitions:
open: [incomplete, complete, withdrawn]
incomplete: [complete, withdrawn]
complete: [incomplete, on_hearing, refused, withdrawn]
on_hearing: [incomplete, recommended, refused, withdrawn]
recommended: [approved, refused, withdrawn]
```
`transitions` is an arbitrary directed graph. Cycles are allowed and
are often the honest shape: a case sent back for the drawing it was
missing returns to a stage it was already past. A good ending may sit
partway along — an authority that changes its own decision finishes
the case without walking to the end of the line.
Each state's `event` string is the name written into the log, and must
be unique within the bucket. A record's state is replayed from its own
events, so renaming an event orphans the records that carry the old
name: when rebuilding a live site, keep the names it already has.
## What a state may carry
Beside its `event`, a state may declare three things that happen when
a record arrives in it.
### mail
```yaml
approved:
event: approved
mail:
to: submitter # or one fixed address
subject: "Your application"
body: |
Hello,
{property_address} is approved. The decision is at
{site}/status/{chain}.
```
`{field}` is any field of the record, plus `{site}`, `{chain}`,
`{email}`, `{bucket}` and `{state}`. The body is markdown; it is sent
as text with an HTML alternative. `to: submitter` means the record's
`email` field, and the lint then requires every form filling that
bucket to collect a required `email`.
Portal renders it and publishes on `portal.mail.send`. gdo delivers
it, and a reply comes back on `portal.mail.received` as a note beside
the record. A note never moves a case.
### grants
```yaml
approved:
event: approved
grants: approved_firms
```
The person the record is about becomes a member of that group when the
record arrives here. An approval that *means* membership should not
also need somebody to remember to invite them afterwards.
The lint requires every form filling that bucket to collect a required
`name` and a required `email`, because by the time a desk presses
approve there is nobody left to ask. An instance with no Kanidm
configured grants nothing and says so in the log; the transition
itself always stands.
### after
```yaml
on_hearing:
event: on_hearing
after: { days: 42, to: recommended }
```
The move a clock makes rather than a person. A hearing that gets no
answer in six weeks has to go on without one, and nobody in the
building is in a position to press that button.
A deadline may only do what a person could have done: `after.to` is
held to the same declared edge as a desk button, and content that says
otherwise is refused when it loads, not when the hour comes round. The
wait is measured from the record's last move. The move goes through
the same transition path, fires the same mail, and lands on
`portal.answers.submitted` like any decision, stamped `deadline`.
The unit is days and the sweep is hourly. This is a deadline, not a
job queue.
## attended_by
```yaml
- bucket: newsletter
initial: open
states: { open: { event: received } }
attended_by: n8n newsletter compose
```
Names what consumes a bucket no page reads back. The lint refuses a
bucket that a form fills and nothing attends — a question was once
dropped and its answers silently fell out of every reader's view, and
this is the contract that replaced the convention.
## Who moves what
Nothing in this file says who. A move becomes possible for somebody
when a page offers it:
- a desk resource's `transitions` gives it to that resource's
`requires_group`;
- an alternative's `self_transition` gives it to the person who sent
the record, by their link and their email, from the states its
`from` names;
- a state's `after` gives it to the clock.
All three compile into the same [access policy](access.md), and the
engine refuses anything the graph above does not declare, whoever asks.
## Testing one
`iris replay` runs cases through this exact machinery — the same
`store_answer` and `transition` the running site uses, against a
throwaway JetStream, reporting what the engine accepted and refused.
```sh
docker run -d --rm -p 127.0.0.1:14222:4222 nats:2.14.6-alpine -js
iris replay --path questions --nats nats://127.0.0.1:14222 --cases cases.jsonl
```
Each line of `cases.jsonl` is one case: a need, a record, and the
states it walks. Point it at a throwaway NATS, never a site's own: it
writes.