# 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. ## 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.