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.
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.
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
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). An instance with neither configured
grants nothing and says so in the log; the transition itself always
stands.
after
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
- 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
transitionsgives it to that resource’srequires_group; - an alternative’s
self_transitiongives it to the person who sent the record, by their link and their email, from the states itsfromnames; - a state’s
aftergives it to the clock.
All three compile into the same access policy, 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.
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.