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>
This commit is contained in:
Bendik Aagaard Lynghaug
2026-09-25 21:35:25 +02:00
co-authored by Claude Opus 5
parent 26c1762dfa
commit f18d1a5181
19 changed files with 1417 additions and 184 deletions
+87
View File
@@ -0,0 +1,87 @@
# Architecture
One Rust binary (Leptos SSR + hydrate, Axum underneath) serving any
number of sites. The engine special-cases nothing: every page, form,
desk and state graph a site serves is declared in its content repo,
and instances differ only by environment variables.
It composes with the surrounding stack rather than bundling it. Gitea
hosts the content, NATS JetStream stores the events and projections,
Kanidm provides identity, and anything downstream subscribes to the
answer stream.
## The pieces
**Content** (`src/content/`) — YAML loaded from Gitea at boot and
hot-swapped on a NATS reload signal. A bad push keeps the last-good
content serving. The `questions/` tree is the router: file paths
become URLs, `_section.yaml` applies to a whole directory, and
`[name].yaml` serves any `/dir/<value>` with the segment fed into the
page's resource keys. See [filesystem routes](design/filesystem-routes.md).
**The lint** (`src/content/validate.rs`) — every rule a content repo
is held to. It runs at boot, on every hot reload, and in a content
repo's CI through [iris](https://project.uhhm.no/uhhm/iris), which
depends on this crate and so applies exactly the version the instance
runs.
**State machines** (`src/aggregates/`) — `aggregates.yaml` declares
each bucket's states and legal moves. A record's state is replayed
from its own event history, undeclared moves are refused, and racing
decisions are settled by CAS on the log's sequence. An empty history
reseeds from the KV projection, so wiping the event stream strands
nothing. See [state machines](state-machines.md).
**Events and projections** (`src/events/`, `src/answers.rs`) — every
submission and decision appends to a JetStream log and projects into
a NATS KV bucket that pages read back, and publishes on
`portal.answers.submitted` for automations to react to.
**Answer chains** (`src/chain.rs`) — each submission hashes its
parents, so a visitor's path through the questions is a verifiable
lineage. `?chain=` links carry it; a `followup` page is offered only
once the chain has earned it, and a page behind an answer somebody has
not given is never offered.
**Access** (`src/access.rs`) — who may do what to what, compiled from
content on every load into one Cedar policy set that every server
entry point asks. See [access](access.md).
**People** (`src/people.rs`) — onboarding as a form. An alternative
with `invite: {group}` creates the person in Kanidm, records their
email, adds them to the group and mints a one-time credential link. A
state may also grant a group on its own (`grants:`), so an approval
that means membership needs no second step.
**Mail** (`src/mail.rs`) — a state may declare the mail its arrival
sends. Portal renders it and publishes on `portal.mail.send`;
[gdo](https://project.uhhm.no/uhhm/gdo) delivers it and carries
replies back on `portal.mail.received`, where they become notes beside
the record. A note never moves a case.
**Sweepers** (`src/announce.rs`, `src/deadlines.rs`) — the two things
that move without a person. An announced page's window closes and its
record becomes a "post what happened" task; a state with a deadline
waits out its days and then makes the move itself. Both go through the
same transition path as a desk button and publish the same event.
**Resources** (`src/resource.rs`) — content can read a KV bucket,
Gitea starred repos, org repos or releases, or any public JSON URL
(SSRF fail-closed), reshaped by a content-declared `jq` filter. What a
resource reads is never a client-supplied parameter: the client names
a page and a feature, never a bucket or URL.
**Maps** (`src/maps.rs`) — MapLibre is vendored and every tile, style
and still image is proxied through portal, so a page with a map makes
no third-party request and needs no consent banner.
**Desks** — any Kv resource with `transitions` renders rows with
per-state buttons and one shared confirm. Owners walk records through
their graphs with no bespoke UI per bucket.
## Binaries
- `portal` — the server.
- `iris` — the lint, from its own repo, shipped in every release
tarball so a content repo's CI can lint with the exact version its
instance runs.