Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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.

mail

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