Test / test (pull_request) Successful in 1m20s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
178 lines
6.2 KiB
Markdown
178 lines
6.2 KiB
Markdown
# 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`.
|
|
|
|
A record that belongs to another (a `type: record` field, say
|
|
`notice`) can write to that record's people: `to: "{notice.email}"`
|
|
goes to whoever posted the notice, and `{notice.hva}` in the body is
|
|
what they posted. The lint requires every form filling the bucket to
|
|
carry that field. This is how an answer to a notice on a board reaches
|
|
the neighbour who put it there, without anyone in between.
|
|
|
|
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. With memberships as the people
|
|
backend a phone number does as well as an address, and the grant is a
|
|
record in `portal_memberships` rather than a Kanidm call (see
|
|
[Memberships](memberships.md)). An instance with neither 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.
|
|
|
|
### leaves_after
|
|
|
|
```yaml
|
|
taken_down:
|
|
event: taken_down
|
|
leaves_after: { minutes: 10 }
|
|
```
|
|
|
|
How long a record stays in its bucket's lists once it has entered the
|
|
state, when that is not for ever. A card taken off the front page
|
|
leaves the desk's list ten minutes later instead of standing there
|
|
greyed out, and the ten minutes are the time to see what was just
|
|
done. Days, hours and minutes add up, and a sweep takes records out
|
|
once a minute. Nothing is decided and no mail goes. The record leaves
|
|
the lists, and the event log keeps it.
|
|
|
|
## 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.
|