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