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:
co-authored by
Claude Opus 5
parent
26c1762dfa
commit
f18d1a5181
@@ -0,0 +1,22 @@
|
||||
# portal documentation
|
||||
|
||||
Portal turns a Git repo of YAML into a running site. Almost everything
|
||||
here is about that YAML: what a content repo may say, and what the
|
||||
engine does with it.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| [Architecture](architecture.md) | What the engine is made of and where each piece lives in the source. |
|
||||
| [Content reference](content-reference.md) | Every key a content repo may write, and what it does. |
|
||||
| [State machines](state-machines.md) | `aggregates.yaml`: stages, who moves a case, mail, deadlines, memberships. |
|
||||
| [Access](access.md) | How the Cedar policy is compiled from content, and who may ask it what. |
|
||||
| [Operations](operations.md) | Releasing, deploying, adding a site, environment variables. |
|
||||
| [Filesystem routes](design/filesystem-routes.md) | Why the `questions/` tree is the router. |
|
||||
|
||||
Two repos in the same family carry their own docs:
|
||||
|
||||
- [uhhm/iris](https://project.uhhm.no/uhhm/iris) — the lint. Versioned
|
||||
as portal and released by portal's publish job; it is the thing a
|
||||
content repo's CI runs before anything reaches a site.
|
||||
- [uhhm/gdo](https://project.uhhm.no/uhhm/gdo) — the mailer. Delivers
|
||||
what content asked to be sent, and carries replies back.
|
||||
@@ -0,0 +1,55 @@
|
||||
# Access
|
||||
|
||||
Who may do what to what is compiled from content on every load into
|
||||
one Cedar policy set, and every server entry point asks that one
|
||||
policy. Identity stays Kanidm's; portal only decides.
|
||||
|
||||
## What content compiles to
|
||||
|
||||
| content | rule |
|
||||
|---|---|
|
||||
| a page with no `qualifies` | anyone may open and submit |
|
||||
| `qualifies: case_officers` | that group may open and submit |
|
||||
| `requires_chain: /start` | the visitor's verified answer lineage must reach it |
|
||||
| a resource with `public: true` | anyone may read it |
|
||||
| a resource with `requires_group: g` | that group may read it |
|
||||
| a desk resource's `transitions` | that group may make those moves |
|
||||
| an alternative's `self_transition` | the submitter may make it, from each state in `from`, with their link and their matching email |
|
||||
| an alternative's `invite: {group}` | the page's own group may invite into that group |
|
||||
|
||||
Mutations always need a group, whatever `public` says. A resource with
|
||||
neither `public` nor `requires_group` is unreachable rather than open:
|
||||
the policy fails closed.
|
||||
|
||||
## Reading the policy
|
||||
|
||||
```sh
|
||||
iris check --path questions --access
|
||||
```
|
||||
|
||||
prints every rule the content compiled to, as a table of who may do
|
||||
what from where to where. It is the fastest way to find a rule a
|
||||
content repo did not mean to write — a refusal available on the day a
|
||||
case arrives, a board that can decide a case it never received.
|
||||
|
||||
## Asking it
|
||||
|
||||
Every decision carries the rule ids that granted it. Refusals and
|
||||
every mutation are published on `portal.access.decided`.
|
||||
|
||||
Other applications ask the same question over NATS request-reply on
|
||||
`portal.access.<site>.may`, with the same principal, action, resource
|
||||
and context the server uses internally — so an automation acting as a
|
||||
group gets exactly the answer a person in that group would.
|
||||
|
||||
## The submitter
|
||||
|
||||
A person who sent a form has no account. Their credential is the link:
|
||||
an unguessable chain hash, plus the email they gave, which the policy
|
||||
compares against the one stored on the record.
|
||||
|
||||
What they may do, and from where, is `self_transition.from`. Its
|
||||
default is the initial state alone. A refusal reads "not found" while
|
||||
the record is somewhere the move could have been made from — so the
|
||||
endpoint cannot be used to discover which records or addresses exist —
|
||||
and "already processed" once the case has moved past it.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,232 @@
|
||||
# Content reference
|
||||
|
||||
A content repo is the site. This is everything it may say.
|
||||
|
||||
```
|
||||
site.yaml branding, language, mail
|
||||
aggregates.yaml the state machine of every bucket
|
||||
needs.yaml what the site is for (read by iris, never by portal)
|
||||
questions/
|
||||
index.yaml the front page, served at /
|
||||
about.yaml served at /about
|
||||
review/
|
||||
_section.yaml applies to everything under /review
|
||||
index.yaml served at /review
|
||||
[record].yaml serves /review/<anything>
|
||||
```
|
||||
|
||||
Nothing here is required except a `questions/` tree with something in
|
||||
it. Portal's lint refuses content it cannot serve honestly, at boot,
|
||||
on every hot reload, and in CI through `iris check`.
|
||||
|
||||
## site.yaml
|
||||
|
||||
```yaml
|
||||
title: Vestmark kommune # the browser title and the header
|
||||
wordmark: /site/logo.svg # an image instead of the title
|
||||
wordmark_invert: true # flip it in dark mode
|
||||
wordmark_height: 2.4 # rem
|
||||
lang: no # the interface language: en or no
|
||||
stylesheet: /site/theme.css # served same-origin from the repo
|
||||
favicon: /site/icon.svg
|
||||
hero: { kind: module, module: hero.js } # or { kind: plain }
|
||||
mail:
|
||||
from: Vestmark kommune <post@vestmark.example>
|
||||
reply_to: post@vestmark.example
|
||||
invite: { subject: "...", body: "..." } # rewords the built-in invite mail
|
||||
```
|
||||
|
||||
`mail.from` is required as soon as any state declares a mail.
|
||||
|
||||
## A question
|
||||
|
||||
One YAML file is one page.
|
||||
|
||||
```yaml
|
||||
id: /apply # defaults to the file's path
|
||||
name: Apply to build
|
||||
description: What we need from you.
|
||||
qualifies: case_officers # a Kanidm group may open this page
|
||||
requires_chain: /start # or: an answer they must already have given
|
||||
followup: true # keep it out of the nav until earned
|
||||
responsible: { name: Solveig, email: post@vestmark.example }
|
||||
event: # this page announces an occasion
|
||||
starts: 2026-10-14T18:00
|
||||
duration: 2h
|
||||
place: The town hall
|
||||
place: # this page is about somewhere
|
||||
lat: 59.8938
|
||||
lon: 11.0332
|
||||
zoom: 15
|
||||
name: Tomter
|
||||
alternatives: [ ... ]
|
||||
```
|
||||
|
||||
`qualifies`, `requires_chain` and `responsible` are inherited from the
|
||||
nearest `_section.yaml`. `event` makes a page an occasion with a time;
|
||||
`place` makes it a place, drawing a still map under the question and
|
||||
opening any `type: location` field on the page there rather than on
|
||||
the whole world.
|
||||
|
||||
## An alternative
|
||||
|
||||
One choice. Never two actions behind one heading: if two things can be
|
||||
done, they are two alternatives.
|
||||
|
||||
```yaml
|
||||
alternatives:
|
||||
- name: Send the drawings
|
||||
description: We answer within twelve weeks.
|
||||
action: /sent # the page to go to; /shape/{field} templates
|
||||
record_as: applications # the bucket the answer lands in
|
||||
disabled: false
|
||||
consequence: [What happens next]
|
||||
encouragements: [It takes two minutes]
|
||||
images: [/site/drawing.png]
|
||||
features: [ ... ]
|
||||
```
|
||||
|
||||
Three things an alternative may do instead of, or beside, recording an
|
||||
answer:
|
||||
|
||||
```yaml
|
||||
self_transition: # the person moves their own record
|
||||
bucket: applications
|
||||
to: complete
|
||||
from: [open, incomplete] # defaults to the initial state alone
|
||||
label: Send them
|
||||
invite: { group: case_officers } # onboard someone into Kanidm
|
||||
```
|
||||
|
||||
`self_transition.from` is what keeps a person in dialogue with their
|
||||
case: an applicant written to for a missing drawing answers from
|
||||
`incomplete`, and may withdraw from wherever the case has got to.
|
||||
Omitted, it is the initial state alone, which is what a one-shot
|
||||
unsubscribe link means. Each state listed becomes its own access rule,
|
||||
and the lint refuses a `from -> to` the state machine does not declare.
|
||||
|
||||
An `invite` needs a required `name` and `email` on the same
|
||||
alternative, and the inviting group must be allowed to invite that
|
||||
group by the policy.
|
||||
|
||||
## A feature
|
||||
|
||||
What an alternative offers, explained. A feature that is only layout
|
||||
is a feature wasted: each one says something true about the choice.
|
||||
|
||||
```yaml
|
||||
features:
|
||||
- name: What you need
|
||||
description: The case number from the letter.
|
||||
icon: document
|
||||
color: "#8ec2c0"
|
||||
requirements: [ ... ] # form fields
|
||||
resource: { ... } # live data
|
||||
```
|
||||
|
||||
## A requirement (a form field)
|
||||
|
||||
```yaml
|
||||
requirements:
|
||||
- name: email # snake_case; this is the stored key
|
||||
label: Your email
|
||||
placeholder: you@example.no
|
||||
type: email
|
||||
optional: false
|
||||
value: "{key}" # a preset; a dynamic page's segment fills it
|
||||
```
|
||||
|
||||
`type` is one of:
|
||||
|
||||
| type | what it is |
|
||||
|---|---|
|
||||
| `text` | the default |
|
||||
| `email`, `tel`, `date`, `number`, … | anything else is passed straight through as the HTML input type |
|
||||
| `textarea` | several lines |
|
||||
| `prosekit` | a rich-text editor |
|
||||
| `select` | one choice, or `multiple: true` for any number |
|
||||
| `file` | an upload, with `accept:` and `multiple:` |
|
||||
| `location` | a pin on a map, stored as `{lat, lon, zoom}` |
|
||||
| `record` | the record this one belongs to |
|
||||
| `gesture`, `voice` | a drawn stroke or a recording, through a redoal relay |
|
||||
| `hidden` | a carrier value, no label row |
|
||||
| `module` | a widget the content repo ships |
|
||||
|
||||
A `select` takes either inline `options: [...]` or a `resource:` to
|
||||
draw them from, with `id_field:` naming each item's stable id.
|
||||
|
||||
A field may load its value from a resource when a sibling changes:
|
||||
|
||||
```yaml
|
||||
- name: contents
|
||||
type: textarea
|
||||
bind:
|
||||
field: path # the sibling to watch
|
||||
resource: { source: { kind: url, url: "https://git.example/raw/{path}" } }
|
||||
```
|
||||
|
||||
### type: record
|
||||
|
||||
How one case belongs to another — an objection to the application it
|
||||
objects to, an appeal to the decision it appeals.
|
||||
|
||||
```yaml
|
||||
- name: case
|
||||
type: record
|
||||
of: applications # the bucket the other record is in
|
||||
value: "{key}" # arrives on the link, on a dynamic page
|
||||
```
|
||||
|
||||
Nobody types one of these: the stored value is the parent's record id,
|
||||
which is a chain hash. It arrives the way it was given out, so the
|
||||
field carries rather than asks, and renders hidden. A submission
|
||||
naming a record that is not there is refused, so a reference in a
|
||||
stored record is one that resolved at least once.
|
||||
|
||||
The other half is a listing that asks for the children:
|
||||
|
||||
```yaml
|
||||
resource:
|
||||
source: { kind: kv, bucket: objections }
|
||||
requires_group: case_officers
|
||||
where: { field: case, equals: "{key}" }
|
||||
```
|
||||
|
||||
## A resource
|
||||
|
||||
Live data, declared by content and read by the server. What a resource
|
||||
reads is never a client-supplied parameter.
|
||||
|
||||
```yaml
|
||||
resource:
|
||||
source: { kind: kv, bucket: applications }
|
||||
key: abc123 # one record instead of the list
|
||||
public: true # or requires_group: case_officers
|
||||
states: [approved, refused] # only these; empty means all
|
||||
where: { field: case, equals: "{key}" }
|
||||
jq: ".[] | {name, url: .html_url}"
|
||||
empty: Nothing decided yet.
|
||||
transitions: # the desk's buttons
|
||||
- { from: complete, to: on_hearing, label: Send it out }
|
||||
```
|
||||
|
||||
`source.kind` is one of `kv`, `gitea_starred`, `gitea_org_repos`,
|
||||
`gitea_releases`, `url`.
|
||||
|
||||
A spec with neither `public: true` nor `requires_group` is
|
||||
unreachable: reads fail closed, and mutations always need a group
|
||||
whatever `public` says.
|
||||
|
||||
`states` and `where` narrow a listing. They matter most for a register
|
||||
that is public by law — public about what was decided, not about what
|
||||
was withdrawn — while the desk working the same bucket is a different
|
||||
spec and still sees all of it. A record that has no such field never
|
||||
matches: "everything that did not say" is not something a page can ask
|
||||
for by accident.
|
||||
|
||||
## needs.yaml
|
||||
|
||||
What the site is for, as a checkable spec: who wants what, which
|
||||
bucket the answer must land in, which group carries it to a finished
|
||||
state. The runtime never reads it. `iris check` proves the site meets
|
||||
it, and `iris replay` runs cases through the real engine.
|
||||
@@ -0,0 +1,80 @@
|
||||
# Operations
|
||||
|
||||
Portal does not deploy itself. It publishes versions; each site
|
||||
decides when to take one.
|
||||
|
||||
## Cut a release
|
||||
|
||||
```sh
|
||||
cargo release patch # or minor / major
|
||||
```
|
||||
|
||||
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. Plain pushes to `main` only run the
|
||||
tests: nothing reaches production from this repo.
|
||||
|
||||
The same job re-pins, tests and tags [iris](https://project.uhhm.no/uhhm/iris)
|
||||
to match, so a portal change to anything the lint reads fails the
|
||||
release rather than the next content push.
|
||||
|
||||
Before cutting, move the `Unreleased` lines in `CHANGELOG.md` under a
|
||||
heading for the new version. That section becomes the release notes,
|
||||
and the table at the end of the file records which version each site
|
||||
runs.
|
||||
|
||||
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.
|
||||
|
||||
## Roll it out
|
||||
|
||||
In the content repo, one line of `.gitea/workflows/deploy.yml`:
|
||||
|
||||
```yaml
|
||||
env:
|
||||
PORTAL_RELEASE: v0.4.0
|
||||
```
|
||||
|
||||
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 and secrets,
|
||||
restarts `app@<instance>` and refreshes the Caddy route. Every rollout
|
||||
is a commit, so reverting a bad version is `git revert` and a push.
|
||||
|
||||
Sites upgrade independently and may pin different versions.
|
||||
|
||||
Content changes never come through any of this: they hot-reload live
|
||||
from the content repos over NATS.
|
||||
|
||||
## Add a site
|
||||
|
||||
A new content repo with a copy of an existing `deploy.yml` (change
|
||||
`INSTANCE`, the port and the 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` handles all branding; no portal
|
||||
change is needed.
|
||||
|
||||
## Environment
|
||||
|
||||
Runtime configuration is environment variables; `src/main.rs` is the
|
||||
authority and each content repo's `deploy.yml` holds the production
|
||||
values.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `NATS_URL` | events, projections, content reload, access questions |
|
||||
| `CONTENT_REPO`, `CONTENT_BRANCH` | the repo that is the site |
|
||||
| `KANIDM_URL`, `OAUTH2_CLIENT_ID`, `OAUTH2_CLIENT_SECRET` | sign-in |
|
||||
| `KANIDM_API_TOKEN` | invites and `grants:`; unset, neither happens |
|
||||
| `PUBLIC_URL`, `SITE_NAME` | what the site calls itself in links and mail |
|
||||
| `GITEA_API_TOKEN` | authenticated resource pulls |
|
||||
| `AUTOMATION_READ_TOKEN` | the automation KV read endpoint |
|
||||
| `GARAGE_*` | uploads |
|
||||
| `MAPBOX_TOKEN` | map tiles, proxied; unset, maps are off |
|
||||
|
||||
Mail is delivered by [gdo](https://project.uhhm.no/uhhm/gdo), a
|
||||
separate unit on the same host reading the same NATS.
|
||||
@@ -0,0 +1,151 @@
|
||||
# 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.
|
||||
|
||||
```yaml
|
||||
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
|
||||
|
||||
```yaml
|
||||
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
|
||||
|
||||
```yaml
|
||||
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
|
||||
|
||||
```yaml
|
||||
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
|
||||
|
||||
```yaml
|
||||
- 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](access.md), 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.
|
||||
|
||||
```sh
|
||||
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.
|
||||
Reference in New Issue
Block a user