Files
portal/docs/state-machines.md
T
Bendik Aagaard LynghaugandClaude Opus 5 f18d1a5181 A person stays in dialogue with their case
Five things a building authority needed and portal could not say,
found by building one and replaying its cases through the engine.

The one that mattered: a submitter could only ever act on a record
nobody had touched. `access.rs` pinned a self_transition's from-state
to `open`, though the runtime had always passed the real state and
asked. So the applicant written to for a missing drawing could not
send it, and nobody could withdraw once anything had happened.
`self_transition` now takes `from: [state, ...]` and compiles one rule
per state; omitted it still means the initial state alone, which is
what every unsubscribe link already meant.

Then the four beside it. A record can belong to another record
(`type: record`, `of:`), with `where: { field, equals }` on a listing
as the other half, so an application's page lists the objections that
name it. A public view can be narrowed to the states worth showing,
so a register that is public by law is public about what was decided
and not about what was withdrawn. A state can grant a group, so an
approval that means membership needs no second step. And a state can
be moved by a clock (`after: { days, to }`, src/deadlines.rs) - held
to the same declared edge as a desk button, firing the same mail,
publishing the same event.

Also: the README is a README again, and everything that was crowding
it is in docs/ - architecture, content reference, state machines,
access, operations.

95 tests, up from 82.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 21:35:25 +02:00

5.1 KiB

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.

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. An instance with no Kanidm 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.