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
+31 -170
View File
@@ -1,190 +1,51 @@
# portal
A content-driven question engine: one Rust binary that turns a Git
repo of YAML into a live site — pages, forms, review desks, and state
repo of YAML into a live site — pages, forms, review desks and state
machines, with a durable event-sourced record of every answer
underneath. The engine special-cases nothing: everything a site
serves is declared in its content repo, and the same build serves any
number of sites.
underneath.
Point an instance at a content repo and that repo *is* the site:
pages, copy, state graphs, branding, and even the landing hero
(`site.yaml`: title, wordmark, and `hero: {kind: module, module:
hero.js}` — a JavaScript module the content repo ships, served
same-origin at `/site/<path>`, that portal mounts at parse time and
stops on navigation; the YES canvas and redoal's sine swings are both
content, not engine). Content
pushes hot-reload every running instance; instances differ only by
env vars. [uhhm.no](https://uhhm.no)
([uhhm/questions](https://project.uhhm.no/uhhm/questions)) and
[redoal.com](https://redoal.com)
([redoal/questions](https://project.uhhm.no/redoal/questions)) are
two faces of the same binary, deployed from the same release
artifact.
The engine special-cases nothing. Everything a site serves is declared
in its content repo, content pushes hot-reload every running instance,
and the same build serves any number of sites: instances differ only
by environment variables. [uhhm.no](https://uhhm.no) and
[redoal.com](https://redoal.com) are two faces of the same binary from
the same release artifact.
It composes with the surrounding stack rather than bundling it: Gitea
hosts and serves the content, NATS JetStream stores events and
projections, Kanidm provides identity, and anything downstream
(automations, newsletters, onboarding) subscribes to the answer
stream.
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 — automations,
newsletters, onboarding — subscribes to the answer stream.
## What the engine provides
## Documentation
- **Content-driven pages** (`src/content/`, `src/app.rs`): 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 criteria to a whole directory, `[name].yaml` pages serve
any `/dir/<value>` with the segment fed into resource keys, and
`requires_chain` gates a page on verifiable answer provenance next
to `qualifies`' Kanidm-group identity gate (see
`docs/design/filesystem-routes.md`).
- **Gesture and voice fields** (`gesture.js`, `voice.js`): a stroke
becomes a redoal gesture key through a relay (ADR-0013/0015 in the
redoal repo) - the input shows the key's own decode under the
stroke, who is at a similar shape now, and *places* with recordings
the visitor can pick to make that key theirs; a voice field records
in the browser and leaves the audio at a key. Actions may be
templated from the answer (`/shape/{curve.key}`) onto dynamic
pages, whose URL segment also fills `value: "{key}"` presets and
`url` resource sources; resource items with an https `audio` field
render a player.
- **Announced pages** (`src/announce.rs`): a question with an
`event: {starts, duration, place}` window is announced in the
header (not the footer nav) while the window is open, becomes a
followup when it closes, and its record in the runtime-owned
`portal_events` bucket moves to `awaiting_summary` - a "post what
happened" task any review desk can read, published on NATS like a
decision. A one-minute sweeper keeps it all idempotent.
- **State machines as content** (`src/aggregates/`): `aggregates.yaml`
declares each bucket's states and legal transitions; the engine
replays a record's event history and refuses undeclared moves, with
optimistic concurrency (CAS on the event log's sequence) against
racing decisions. An empty history reseeds from the KV projection,
so wiping the event stream strands nothing.
- **Durable events + projections** (`src/events/`, `src/answers.rs`):
every submission and decision appends to a JetStream event log and
projects into a NATS KV bucket pages read back; every one also
publishes on `portal.answers.submitted` for automations (n8n) to
react to.
- **Answer chains** (`src/chain.rs`): each submission hashes its
parent(s), so a visitor's path through the questions is a verifiable
lineage; `?chain=` links carry it, and self-service transitions
(unsubscribe) authorize by holding one. The question nav reads it
too: a `followup` page is offered only once the visitor has earned
it - the chain verifiably answered the alternative that leads there,
or it hangs off an earned page by a link that records nothing. A
page behind an answer they have not given is never offered.
- **One access policy** (`src/access.rs`): who may do what to what is
compiled from content on every load into a Cedar policy set - `qualifies`
on a page, `requires_group`/`public` on a resource, a desk's transitions,
a `self_transition` by link and email, an `invite` - and every server
entry point asks that one policy. Decisions carry the rule ids that
granted them; refusals and every mutation are published on
`portal.access.decided`, and other apps ask the same question over
request-reply on `portal.access.<site>.may`. Identity stays Kanidm's.
- **Onboarding as a form** (`src/people.rs`): an alternative with
`invite: {group}` on a gated page creates the person in Kanidm, records
their email, adds them to the group and mints a one-time link to set
their own credentials, through a service account in
`idm_people_on_boarding` (`KANIDM_API_TOKEN`). The result lands in the
`portal_invites` bucket for the inviter's desk and on
`portal.people.invited` for whatever sends the mail.
- **Business needs as a checkable spec** (in
[uhhm/iris](https://project.uhhm.no/uhhm/iris)): a content repo may
ship a `needs.yaml` saying who wants what, which bucket the answer
must land in, and which group has to carry it to a finished state.
`iris check` proves the site meets it. The runtime never reads the
file; portal only provides the content types iris reads.
- **Live resources** (`src/resource.rs`): content can pull a KV
bucket, Gitea starred/org repos/releases, or any public JSON URL
(SSRF fail-closed), reshaped by a content-declared `jq` filter.
- **Input kinds as content**: a requirement's `type:` picks the
widget — plain fields, a rich-text editor, or a `gesture` drawing
canvas that can connect to a redoal relay so similar strokes echo
between visitors live. Submitted values are arbitrary JSON; the
engine records what the widget produced.
- **Review desks**: any Kv resource with `transitions` renders rows
with per-state action buttons and one shared confirm per
alternative — owners walk records through their graphs without
bespoke UI per bucket.
Everything is under [`docs/`](docs/README.md):
## Binaries
- `portal` — the server (Leptos SSR + hydrate, Axum underneath).
- `iris` — the lint, from [uhhm/iris](https://project.uhhm.no/uhhm/iris),
which depends on this crate as a library pinned to a release tag and
carries the same version: the publish job here re-pins, tests and tags
iris for every portal release.
Every release tarball ships that `iris` binary
too, so each content repo's CI can
lint with the exact portal version its instance runs:
`iris check --path <dir>`.
- [Architecture](docs/architecture.md) — what the engine is made of.
- [Content reference](docs/content-reference.md) — every key a content
repo may write.
- [State machines](docs/state-machines.md) — `aggregates.yaml`.
- [Access](docs/access.md) — the policy compiled from content.
- [Operations](docs/operations.md) — releasing, deploying, adding a site.
## Development
```sh
cargo leptos build # full app (server + wasm)
cargo test --features ssr # engine tests; tests/jetstream.rs needs PORTAL_TEST_NATS_URL
cargo leptos build # server + wasm
cargo test --features ssr # tests/jetstream.rs needs PORTAL_TEST_NATS_URL
```
Runtime configuration is env vars (see `src/main.rs`, and each
content repo's `.gitea/workflows/deploy.yml` for the values in
production): `NATS_URL`, `CONTENT_REPO`/`CONTENT_BRANCH`, Kanidm OIDC
(`KANIDM_URL`, `OAUTH2_CLIENT_*`), optional `GARAGE_*` for uploads,
`GITEA_API_TOKEN` for authenticated resource pulls,
`AUTOMATION_READ_TOKEN` for the automation KV read endpoint.
## Release and deploy
Portal doesn't deploy itself — it publishes versions, and each site
decides when to take one. The whole day-to-day surface is two
commands:
**Cut a release** (here):
Content is linted by [iris](https://project.uhhm.no/uhhm/iris), which
depends on this crate and carries the same version, so a content repo
can check itself against the exact portal its instance runs:
```sh
cargo release patch # or minor / major
iris check --path questions
```
That bumps `Cargo.toml`, tags `v<version>`, and pushes; CI
(`.gitea/workflows/publish.yml`) reacts to the tag, builds once, and
attaches `portal-v<version>.tar.gz` (`portal` + `iris` +
`hash.txt` + `site/`) to the Gitea release. Pkg files are
content-hashed (`hash-files = true`; instances run with
`LEPTOS_HASH_FILES=true`), so a freshly served page can never pair a
stale cached bundle with new wasm. Plain pushes to `main` only run the
tests (`test.yml`) — nothing reaches production from this repo.
## Releasing
Before cutting, move the `Unreleased` lines in `CHANGELOG.md` under a
heading for the new version. That section becomes the Gitea release
notes, and the table at the end of the file records which version each
site runs; update it when a pin moves.
**Roll it out** (in a content repo): edit one line in that repo's
`.gitea/workflows/deploy.yml` —
```yaml
env:
PORTAL_RELEASE: v0.1.1 # <- bump, commit, push
```
Its CI downloads the pinned artifact, ships
`/srv/app/<instance>/releases/<tag>`, flips `current`, rewrites the
instance env from that repo's own Actions variables/secrets, restarts
`app@<instance>`, and refreshes the Caddy route. Every rollout is a
commit, so reverting a bad version is `git revert` + push. Sites
upgrade independently: uhhm.no (uhhm/questions → `app@uhhm-portal`,
:3010) and redoal.com (redoal/questions → `app@redoal-portal`,
:3020) can pin different versions.
Content changes never come through any of this — they hot-reload
live from the content repos over NATS.
**Add a site**: new content repo with a copy of an existing
`deploy.yml` (change `INSTANCE`, port, domains), Actions variables
(`KANIDM_URL`, `OAUTH2_CLIENT_ID`, `PUBLIC_URL`, `SITE_NAME`) and
secrets (`NATS_URL`, `OAUTH2_CLIENT_SECRET`,
`PORTAL_GITEA_API_TOKEN` — Gitea reserves the `GITEA_` prefix for
secret names), a Kanidm OAuth2 client, and DNS. `site.yaml` in the
content repo handles all branding; no portal changes needed.
`cargo release patch` tags and pushes; CI builds the tarball and
attaches it to the Gitea release. A site takes it by bumping
`PORTAL_RELEASE` in its own content repo. See
[operations](docs/operations.md).