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.
| Getting started | Bringing your people in: from an empty Kanidm to everyone at their own desk, from what the content says. |
| Architecture | What the engine is made of and where each piece lives in the source. |
| Content reference | Every key a content repo may write, and what it does. |
| State machines | aggregates.yaml: stages, who moves a case, mail, deadlines, memberships. |
| Access | How the Cedar policy is compiled from content, and who may ask it what. |
| Storylines | The same site told several ways: lists of texts, one storyline per visitor, counted per storyline. |
| Memberships | Groups kept by portal itself: a membership as a case, a person as a phone number first, and the provider only saying who. |
| Operations | Releasing, deploying, adding a site, environment variables. |
| Training a site | portal plan and portal build: a business in plain words becomes a content repo, held to the lint. |
| The lint | portal lint: what a content repo’s CI runs before anything reaches a site. |
| Filesystem routes | Why the questions/ tree is the router. |
One repo in the same family carries its own docs:
- uhhm/gdo — the mailer. Delivers what content asked to be sent, and carries replies back.
The lint (once uhhm/iris) and the trainer (once uhhm/portal-trainer)
are commands of the portal binary since 0.6; their chapters are
above, and the trainer’s runs stay in the old repo.
Getting started: bringing your people in
A site is run by a handful of people: whoever answers for each question it asks, and whoever works each desk. This is how they get in - from an empty Kanidm to everyone signed in at their own desk - without anyone handing out passwords or running an admin tool per person.
The idea in one line: the content says who is involved, and portal makes their accounts. A page names who is responsible for it; a directory is gated on a group; a desk may invite into groups. Portal reads that and does the rest at start.
What you need
- A content repo, and a portal instance running it (Operations has adding a site).
- A Kanidm of the site’s own. Group names below are exactly the ones
the content uses, with no prefix; that is only safe when the Kanidm
serves this site alone. (Or no Kanidm at all: with
PEOPLE_BACKEND=membershipsportal keeps the groups itself and any OIDC provider that can say who someone is will do; see Memberships. The rest of this page is the Kanidm way.) - Mail:
mail: { from }insite.yaml, and gdo on the host. Without it accounts are still made, but nobody hears about them.
1. Say who is involved, in the content
Three things in the content decide who needs an account:
| content | who | what they get |
|---|---|---|
responsible: { name, contact } on a page | the person answering for that question | an account, and a mail saying they are listed as responsible for asking it |
responsible: { name, contact, group } | a group answering for it | nothing: the inbox is not a person; the group’s members have the desk, and each of them may suggest changes to the page |
qualifies: board on a page or _section.yaml | members of that group | the pages and desks under it |
invite: { group: treasurer } on a desk page | the desk’s own group | the right to bring people into treasurer |
The desk whose group may invite into the most groups is the site’s most privileged one; for an association that is the board. Portal works that out from the content (see step 4).
2. Set up Kanidm for portal
Portal needs four things from Kanidm. The
tomtervel/infrastructure
repo’s kanidm-setup.sh does all of them and is a good starting point;
by hand, as idm_admin:
K="kanidm -D idm_admin -H https://id.example.no"
# An onboarding account: may create people and issue a first password
# link, nothing more. pii_read lets an invite find someone who already
# has an account by their email instead of making a second one.
$K service-account create portal-onboarding "Portal onboarding" idm_admin
$K group add-members idm_people_on_boarding portal-onboarding
$K group add-members idm_people_pii_read portal-onboarding
# One group per `qualifies:` in the content, managed by that account:
# adding a member to a group takes the right to manage it.
for g in board treasurer traffic; do
$K group create "$g" portal-onboarding
done
# The sign-in client. Everyone in this Kanidm may sign in; what they
# see comes from their groups, in the `groups` claim.
$K system oauth2 create portal "My site" https://site.example.no
$K system oauth2 add-redirect-url portal https://site.example.no/auth/callback
$K system oauth2 update-scope-map portal idm_all_persons openid profile email
for g in board treasurer traffic; do
$K system oauth2 update-claim-map portal groups "$g" "$g"
done
$K system oauth2 update-claim-map-join portal groups array
# The onboarding token, read-write, shown once: into portal's env.
$K service-account api-token generate portal-onboarding portal --readwrite
Then portal’s environment:
| variable | value |
|---|---|
KANIDM_URL | https://id.example.no |
OAUTH2_CLIENT_ID, OAUTH2_CLIENT_SECRET | the client above (show-basic-secret) |
KANIDM_API_TOKEN | the onboarding token |
3. Name the first person
Someone has to be first: every later person is invited from a desk, and on an empty Kanidm there is no desk anyone can open yet.
| variable | value |
|---|---|
SEED_ADMIN_EMAIL | the first person’s address |
SEED_ADMIN_NAME | their name, as it should appear (optional) |
SEED_GROUP | a group to put them in, if not the most privileged one (optional) |
RESPONSIBLE_GROUP | a group every responsible person joins, e.g. editors (optional) |
4. Start portal
At start, portal:
- Invites the first person into
SEED_GROUP, or the most privileged group, and mails them the one-time link. Once: it is remembered in theportal_seedbucket, so restarts and upgrades never send a second link. - Makes an account for everyone named responsible who has none,
and mails them that they are listed as responsible for asking those
questions, with their link. With
RESPONSIBLE_GROUPthey all join it; someone who already had an account is added without a mail.
The two run side by side. Kanidm may still be starting beside portal, so each is tried again a minute apart, up to twenty times. The log says what happened, per person.
The second happens again whenever the content changes, so a new
page naming a new person sets them up without a restart. And it works
both ways: someone the content has stopped naming leaves
RESPONSIBLE_GROUP in the same pass, and with it whatever else that
group opens. Only if portal is who put them there - a person who was in
the group before is left in it - and their account stays, since it is
theirs.
5. The rest come in from the desks
From here nobody touches Kanidm:
- At a desk, whoever may invite fills in a name and an address. Portal makes the account, adds them to the group, and mails the link.
- A decision can be the invitation. A state with
grants: groupinaggregates.yamlputs the person a case is about into that group when the case arrives there - an approved committee application that is the membership. - A new page with a new responsible person gets them an account when the page goes live.
An invite that nobody used
A link lives a day. Every invite - a desk’s, and the ones portal made
at start - is a case in portal_invites, and keeps itself current:
| state | means | how it gets there |
|---|---|---|
open | the link is waiting | the invite was sent, or sent again |
done | the person is in | they signed in to the site; or a desk says so |
expired | the link ran out unused | a sweep, every ten minutes |
A desk that lists the bucket offers the way back with a transition from
expired to open. Pressing it makes a fresh link and mails it:
resource:
source: { kind: kv, bucket: portal_invites }
requires_group: board
transitions:
- { from: open, to: done, label: Is in }
- { from: expired, to: open, label: Send again }
- { from: expired, to: done, label: Is in }
6. What the people who ask the questions can do
Signed in, a person a page names as responsible sees a card at the foot of their own pages to suggest a change. The server checks it is really them, and the suggestion is mailed to the site’s inbox with them as reply-to, so answering it answers them.
It is also a case, in the built-in portal_edit_suggestions bucket, so
it does not depend on somebody reading that inbox. Give a desk the
list, and whoever sits there takes it up and settles it:
resource:
source: { kind: kv, bucket: portal_edit_suggestions }
requires_group: board
transitions:
- { from: open, to: discussed, label: Looking at it }
- { from: open, to: done, label: Changed }
- { from: open, to: declined, label: Will not change }
- { from: discussed, to: done, label: Changed }
- { from: discussed, to: declined, label: Will not change }
The person who suggested it is mailed at each move, in the site’s
language, and the card on their page lists what they have sent and
where each one stands. A content repo that declares the bucket itself
in aggregates.yaml, with a mail: on a state, sends that instead.
Their group can also be the way into other tools. The vel’s
editors group signs in to the project Gitea with the same account:
a second OAuth2 client scoped to that group alone.
When it does not work
| what you see | why |
|---|---|
an invite fails with 403 setting mail | a portal before 0.5.4 set the address in a second call; the onboarding account may create but not modify |
an invite fails with 404 adding to a group | the group is not managed by portal-onboarding: group set-entry-manager <group> portal-onboarding |
Item not found setting the login page’s name or logo | those are system settings only admin may change, not idm_admin - or the CLI and server versions differ |
| accounts are made but nobody is mailed | site.yaml has no mail.from, or gdo is not running on the host |
| someone responsible never got a mail | they already had an account (they are only added to RESPONSIBLE_GROUP), or their contact is not an email address |
an invite stays open though the person has set a passkey | Kanidm does not show a passkey to the onboarding account; it becomes done when they sign in to the site |
| “send again” is not on the desk | the list has no { from: expired, to: open } transition, or the content declares portal_invites itself without an expired state |
someone left a page and is still in RESPONSIBLE_GROUP | they were in the group before portal added them, so it is not portal’s to undo |
| the first person was invited twice | it cannot be, per address and group; changing SEED_ADMIN_EMAIL or SEED_GROUP invites the new pair |
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
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.
Any text a person reads (name, description, label,
placeholder, a mail’s subject and body, consequence,
encouragements) may be a list instead of one string: one entry per
storyline, all lists the same length.
A question
One YAML file is one page.
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, contact: post@vestmark.example }
# or a group's: no account is made for the inbox, and any signed-in
# member of the group may suggest a change to the page
responsible: { name: The board, contact: board@vestmark.example, group: board }
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: The town hall
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.
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:
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.
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)
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
value: "{user.email}" or "{user.name}" is the signed-in person’s
own, and a field it fills is carried rather than asked: someone the
site knows is not asked who they are. A visitor gets the field, empty.
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:
- 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.
- 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:
resource:
source: { kind: kv, bucket: objections }
requires_group: case_officers
where: { field: case, equals: "{key}" }
And the way there from a list: link puts a link on every listed
record to a page about it, with {id} for the record’s id and
{field} for any of its fields. A notice on a public board links to
/notices/{id}, the dynamic page /notices/[key] whose form carries
the notice’s id back in with value: "{key}":
resource:
source: { kind: kv, bucket: notices }
public: true
link: { to: "/notices/{id}", label: I can lend or borrow this }
A resource
Live data, declared by content and read by the server. What a resource reads is never a client-supplied parameter.
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.
link: { to: "/notices/{id}", label: Answer this } # a page per record
transitions: # the desk's buttons
- { from: complete, to: on_hearing, label: Send it out }
source.kind is one of kv, gitea_starred,
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.
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.
A record that belongs to another (a type: record field, say
notice) can write to that record’s people: to: "{notice.email}"
goes to whoever posted the notice, and {notice.hva} in the body is
what they posted. The lint requires every form filling the bucket to
carry that field. This is how an answer to a notice on a board reaches
the neighbour who put it there, without anyone in between.
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. With memberships as the people
backend a phone number does as well as an address, and the grant is a
record in portal_memberships rather than a Kanidm call (see
Memberships). An instance with neither 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.
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.
Where the groups come from
The policy names groups; it does not hold them. A User entity’s
parents are the person’s groups, and those come from the sign-in: the
groups claim the provider sent, joined with the person’s active
memberships when portal keeps them itself. Cedar
sees one list either way.
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
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.
A principal may also name a person by sub, phone or email and
list no groups; portal then looks their memberships
up before deciding. That is what makes the policy usable for someone
who has never opened the site.
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.
Storylines
The same site, told two ways, and counted.
Anywhere content says something to a person, it may say it in several ways by giving a list instead of one string:
name: Want to help out in the neighbourhood?
description:
- The work day, the spring fair and the committees need more hands. Say what you can do.
- The neighbours do most things themselves. Sign up here for whatever you fancy.
alternatives:
- name: [Join in, Sign me up]
consequence: [[Send], [Count me in]]
Each index is a storyline. A visitor is given one at their first
request, kept in a cookie (portal_storyline) for a year, and reads
that storyline on every page, in every button and in every mail the
site sends them about their case. A single string is every storyline.
The rules
- Same length everywhere. Every list of texts in the content has the site’s arity, so a storyline never has a hole in it. The lint refuses a list of another length and names it.
- What varies is what a person reads:
name,description,label,placeholder,title, a mail’ssubjectandbody, a list’semptyline,consequenceandencouragements(those two as lists of lists). - What the machine reads never varies: a field’s
name(the stored key), a select’soptions(the stored values), states, groups, paths, buckets. - One storyline per site, not per page. That is what makes it a storyline rather than a scatter of variants: a warm front door leads to a warm form and a warm mail.
- A signed-in person keeps the storyline they had as a visitor.
- A site that tells one storyline sets no cookie.
What is counted
Every record and every answer event carries the storyline it came
from (storyline on Answer and on portal.answers.submitted). The
mail a case gets tells the same storyline, so a person who read the
warm front door is not thanked in the plain voice.
GET /report gives the counts, per bucket, per storyline, per state:
{ "storylines": 2,
"buckets": { "requests": { "0": { "open": 3, "solved": 9 },
"1": { "open": 5, "solved": 14 } } } }
Storylines are assigned evenly, so those numbers are the comparison:
which words brought more people to send, and how their cases went. No
record, field, address or time leaves the site. The trainer reads this
(portal report) next to what its personas scored offline, and
the plan’s tones are where the storylines are written.
Memberships
Who belongs to which group, kept by portal itself.
A site’s groups are named in its content: qualifies on a page,
requires_group on a list, invite: { group } on a desk, grants on
a state. Until now the groups themselves lived in Kanidm, and portal
read them from the sign-in token. With memberships they live in portal,
as records in the built-in portal_memberships bucket, and the
identity provider only has to say who someone is. Cedar sees no
difference: a person’s active memberships are the parents of their
User entity, exactly as a groups claim was.
A membership is a case
One record per group and person:
| state | means | how it gets there |
|---|---|---|
invited | a place is held for them | a desk invite, a state that grants, the first person, someone the content names as responsible |
active | they are in | they signed in, and the provider’s subject, phone number or address matched the record |
removed | a desk took them out | a button on the desk that lists the bucket; inviting them again reopens the record |
The record carries name, group, by (who or what put them there:
a username, seed, responsible, or the case as bucket:id), and
what is known of the person: phone, email, sub. At sign-in
everything the provider verified is written onto every record that
matched, so a record made from an address learns the phone number,
and the next sign-in matches on the subject before anything else.
Because it is a case, a desk lists it like any other bucket:
resource:
source: { kind: kv, bucket: portal_memberships }
requires_group: board
transitions:
- { from: invited, to: removed, label: Withdraw }
- { from: active, to: removed, label: Take out }
- { from: removed, to: invited, label: Invite again }
A person is their phone number first
A provider that verifies phone numbers (Vipps) makes the number the
surest thing a record can hold. So a person is known by, in order:
their phone number, their email address, the provider’s subject. A
form that collects phone, telefon, tel or mobil gives the
first; email or epost the second. Numbers are kept as +4791234567
whatever way they were typed; a bare national number gets the site’s
country (PHONE_COUNTRY, +47 unless set). Addresses are lowercased.
A record matches a person on any one of the three, so a desk that invited by address and a decision that granted by number are talking about one person, and there is one record.
Switching a site to memberships
PEOPLE_BACKEND=memberships
Unset, a KANIDM_API_TOKEN means Kanidm and no token means
memberships. With memberships:
- A desk invite writes an
invitedrecord and mails “log in at the site, and your place is there”. The provider makes people, portal makes members: with Vipps Login everyone can already sign in. A Kanidm still configured beside memberships (KANIDM_API_TOKEN) makes the account too, with no group and the one-time link in the mail, so a site can switch to memberships before its provider does. grantswrites a record for the person the case is about, from the form’s name and phone number or address.SEED_ADMIN_PHONEorSEED_ADMIN_EMAILwrites the first person’s.- The people the content names as responsible get theirs in
RESPONSIBLE_GROUP, and lose it when the content stops naming them. - The
groupsclaim, if the provider sends one, still counts: a person’s groups are the union.
With OIDC_EXTRA_SCOPES=phoneNumber the sign-in asks the provider for
the number and reads phone_number from userinfo. Set it for Vipps;
not for Kanidm, which denies a sign-in that asks for a scope the
person does not hold. Without it, matching falls back to the address.
Asking who belongs
Other applications ask portal the same access question they always
could, over NATS on portal.access.<site>.may. A principal that names
a person and lists no groups is looked up:
{ "principal": { "kind": "user", "sub": "vipps:abc", "phone": "912 34 567" },
"action": "transition",
"resource": { "kind": "bucket", "name": "hazards" },
"context": { "from": "open", "to": "fixed" } }
The answer is Cedar’s, with the rule ids that granted it, for a person who may never have opened the site. That is the whole authorization engine: the provider says who, memberships say what they belong to, the policy compiled from content says what they may do.
What Kanidm still does
For a site on Kanidm nothing changes: invites make accounts and add to
Kanidm groups, and portal_invites tracks the one-time links. The two
backends can be swapped by the one variable; the content and the
policy are the same either way.
Operations
Portal does not deploy itself. It publishes versions; each site decides when to take one.
Cut a release
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 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:
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, sessions, content reload, access questions |
CONTENT_REPO, CONTENT_BRANCH | the repo that is the site |
KANIDM_URL, OAUTH2_CLIENT_ID, OAUTH2_CLIENT_SECRET | sign-in |
PEOPLE_BACKEND | kanidm or memberships (see Memberships); unset, Kanidm when KANIDM_API_TOKEN is set, memberships otherwise |
KANIDM_API_TOKEN | invites and grants: through Kanidm |
PHONE_COUNTRY | the calling code a bare phone number gets, +47 unless set |
OIDC_EXTRA_SCOPES | scopes to ask the provider for besides openid profile email: phoneNumber for Vipps. Not for Kanidm, which denies a sign-in that asks for a scope the person does not hold |
SEED_ADMIN_EMAIL or SEED_ADMIN_PHONE, SEED_ADMIN_NAME, SEED_GROUP | the first person in, invited once at start into the most privileged group (or SEED_GROUP) |
RESPONSIBLE_GROUP | the group every person a page names as responsible joins, at start and whenever the content changes, and leaves when no page names them any more; they get an account either way |
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 |
GARAGE_* (4) | attachments; see below. Unset, upload fields fail closed |
PORTAL_RATE_BURST, PORTAL_RATE_SECONDS | the rate limit; see below |
Mail is delivered by gdo, a separate unit on the same host reading the same NATS.
Rate limiting
Every request passes a per-caller-IP limit before a session is loaded or a handler is entered: a burst of 30, refilled one every two seconds. A visitor filling in a form never reaches it (a page load fetches several resources at once); a script does.
PORTAL_RATE_BURST | how many requests at once, default 30 |
PORTAL_RATE_SECONDS | seconds per refilled request, default 2 |
Either set to 0 turns it off, for an instance behind something that
already does this. The caller is read from X-Forwarded-For or
X-Real-IP, falling back to the socket — all of these instances are
behind Caddy.
This is a fence, not an accounting system. What keeps uploads honest is the policy check on the route itself.
Attachments
A type: file field stores an object key in the record. The file
comes back out at:
GET /attachment/{bucket}/{record}/{field}
which reads the key out of that record rather than taking one from the caller, so a file can only be fetched through a record somebody is allowed to see, and a leaked key is a link to nothing. Reading it needs the same policy permission as reading the bucket on a desk.
Uploads need a bucket and a key in the Garage on the host:
garage bucket create <site>-attachments
garage key create <site>-portal-uploads
garage bucket allow --read --write <site>-attachments --key <site>-portal-uploads
then GARAGE_S3_ENDPOINT, GARAGE_ACCESS_KEY, GARAGE_SECRET_KEY and
GARAGE_UPLOADS_BUCKET in that instance’s env. Unset, an upload field
fails closed rather than appearing to work and storing nowhere.
The report
GET /report is public and answers with counts only: per bucket, per
storyline, per state. It is what the trainer reads to
compare the storylines on what people did.
The docs site
These pages are portal.uhhm.no: docs/ built
with mdBook (book.toml, docs/SUMMARY.md) by .gitea/workflows/docs.yml
on every push to main that touches them, and put to git-pages the way
every static site on uhhm.no is (uhhm/infrastructure PAGES.md). The
changelog is the book’s last page. mdbook serve previews it locally.
Who deploys what
Both hosts deploy from CI now, and a rollout is the same commit either
way: bump PORTAL_RELEASE in the site’s own content repo.
| runner | how it is allowed to | |
|---|---|---|
| ergo | runs-on: bare | the runner is privileged and writes /srv/app and /etc/app itself |
| kasse | runs-on: fish | the runner is not privileged. It may run one root script, /usr/local/bin/deploy-portal-instance, which checks the instance against a fixed list and the tag against a release-tag shape before touching anything |
The kasse grant is a single sudoers line
(/etc/sudoers.d/30-portal-deploy). It exists so a workflow can pick
which of three known instances gets which published portal tag, and
nothing else.
Both paths health-check the instance afterwards without a forwarded header. A check that sends the header Caddy would send cannot tell you the site is broken for everything that is not Caddy, which is how v0.5.1 reached a host.
IRIS_RELEASE in a repo’s lint workflow should equal its
PORTAL_RELEASE. Bump it only once iris’s own publish job has attached
the binary for that tag - portal’s release tags iris, but iris builds
and uploads on its own run, and a pin bumped in between fails the lint
on a download that is not there yet.
Training a site
A business describes itself in plain words: what it does, who comes to
it, what it keeps track of and through which stages, and who handles
what. The trainer, portal plan and portal build, turns that into a
working content repo: a question series for every role, the state
machines behind them, and a desk per handling role. No software is
written for the business; the only thing produced is content, and
every line of it is held to the lint (portal lint), which is the
same code as the site, at the same version.
The trainer was its own repo, uhhm/portal-trainer, until portal 0.6;
its runs (runs/, the lab notebook of every site trained so far)
stay there.
Build: structure from the plan, words from the model
portal build --plan plans/contractor.yaml --out out/nordvik \
--lint "portal lint" --llm "MODEL=sonnet engines/claude.sh"
A plan.yaml is a needs spec that also says what each form collects
and, per need, max_fields: how many required fields that person
will tolerate, taken from what the business said about them, never
from the form. build refuses a form over its budget, and hands the
budget to the verifier, so the check still means something after
handover. Everything structural is derived from the plan with no
model involved: the state machines (read off stages: walk the line,
be turned down at any point, succeed only from the end, pass over
skippable stages), the front door, a followup page per need, a gated
directory per group with its desk and the pages only that group may
open, and every button the machine allows. The verifier accepts the
result by construction; what it still checks is the budget.
The model writes words only, one page at a time, into a JSON object
whose shape is fixed. Its strings are merged over built-in fallbacks
key by key, so a bad reply costs wording, never a working site. --llm-for <path prefix> points the model at some pages only
(aggregates.yaml for the mails alone); a reply in the wrong language
for the plan is refused, and a mail that lost the submitter’s link
gets it back.
Onboarding from the plan
invites_by: [board] on a plan, or invited_by: [...] on a group,
puts an invite form on the inviting group’s desk for each group it may
fill, and a list of the invites sent. Portal does the rest (an account
in Kanidm, the group, a one-time credential link) through invite: on
the alternative. Every group with a desk invites into itself by
default.
Rebuilding a live site
--words-from <repo> lifts the words a repo already has back into the
words map by what each card is (its bucket, the page it leads to, the
group it invites into), across the whole repo, so a rebuild keeps
hand-edited wording even when a card moves page (a door taken away, a
need brought onto the front door) and
adds only the new slots. Cards are matched by what they are (the
bucket they record into, the page they lead to, a field’s name, the
group they invite into), not by position, so a rebuild that reorders
or reshapes a page still hands each card its own words; on uhhm.no
the live front door’s three headings follow their buckets onto the
new page. --words <json> overrides slots by page and may repeat, each file layered over
the last (the words a build used, then a round of hand changes);
words.json in the output is the words as used. See
runs/tomtervel-invites-2026-09-23 and runs/uhhm-2026-09-23.
| Same contractor, same local model (nemotron 30B-A3B on ergo) | Free-form generate | build |
|---|---|---|
| A site the verifier accepts | never, in 6 rounds and 16 min | first try, 130 s |
| Held-out personas, with descriptions | - | 8 of 8 |
| Held-out personas, headings only | - | 7 of 8 |
The wording numbers are from the re-score of 2026-09-22, one task per
call (runs/contractor-build-2026-09-21/rescore-2026-09-22/). The
first score, all tasks in one prompt, said 8 of 8 on headings only;
the apprentice picking the building survey over “Join our carpentry
team” only showed once no other visitor was in the prompt.
For comparison, a Sonnet-class model writing everything free-form
scored 8 of 10 on headings only and silently dropped a stage the owner
had described. build cannot drop a stage: it never decides one. The
denominators differ: the build site gives the two crew personas a
one-option page, so they generate no task, and 8 of 8 is eight public
personas where the generate number is ten. Held out means held out
from the model: the plan’s who and wants lines and the personas
were written by the same person about the same needs, and the fallback
heading is the need’s wants, so the two are cousins.
The front door opens modestly
A front door with more buttons than text leaves a visitor paralysed.
So the built front door opens with what the business does, in one or
two features and no button at all (does: on the plan, at most two,
each for: a need so it speaks to one role and is headed by what
that role gets); then a look-around card, things a visitor may
inspect before asking for anything (shows:, each a portal resource
block passed through verbatim, plus every public view the plan
opens); and only then the offers, as more alternatives on the same
page. Nothing is layered: doors still exist for a plan that wants
them, but a second tier is what this shape is there to avoid, and on
uhhm.no a flat front door with an opener routed better than the
doored one.
The house and its players: a role the opener invites in has to see
its win, and the machine has to give it something to do after it
sits down. build warns when a featured need gives the submitter
neither a move (moves_by: {<stage>: [submitter, ...]}) nor a public
view; a player with no move is a spectator.
Doors: a first question before the forms
Five or six needs on one page is more than a visitor sorts through
(the full contractor plan had six). A plan can put public needs
behind doors: each door is one alternative on the front door
leading to a page of its own, where the needs that name it sit.
doors:
- id: patients
who: someone with pain or an injury, or a patient who already has a time here
needs:
- id: new-patient
door: patients
...
The door’s page is a nav page, public, holding the forms. The spec
gives such a need max_steps: 2 and pins via to the door’s heading,
so the verifier walks both pages. A door nobody is behind, a door on
an internal need, or a door named like a group is refused, and a
front door over five alternatives is warned about. The visitor is
scored on both pages: a persona who picks the wrong door is a
misroute on /, one who picks the wrong form is a misroute on the
door’s page.
Cases with doors: plans/physio.yaml (a physiotherapy clinic,
patients and referrers), plans/fotballklubb.yaml (a football club,
in Norwegian, parents and helpers), plans/hire.yaml (a machine hire
yard, hiring and firms), and plans/contractor-full.yaml (three
doors over six public needs). Each has an intake under intakes/ in
the business’s own words and held-out personas.
What a plan can say about a form and its readers
multiple: trueon aselect: pick any number (portal renders toggles). What a volunteer can do, which events, which committees.groups:at the top, a list of{ id, label }: the groups with a name the public knows, such as a vel’s committees. A select withoptions_from: groupsoffers exactly those labels, so the application form and the desks cannot drift apart.watched_by: [public]: an ungated read-only page of the bucket at/status/<bucket>, linked from the followup page. Everyone sees what was sent in and where it stands.emailandtelfields, and any field markedprivate: true, are projected away before it is shown.events: { open: applied, active: onboarded }on a need: the event name a stage is entered by, where the builder’s default (the stage’s own name,receivedfor the first) is not what a live site already has. Portal replays every record by event name, so a rebuild of a site that holds records keeps the names its aggregates.yaml has, or every record vanishes from its desk.plans/uhhm.yamlrebuilds uhhm.no this way.mails: { <stage>: submitter | <address> }on a need: who hears when a case enters that stage, sent by portal the moment it moves (portal 0.3.43, delivered by uhhm/gdo on the host).submittergoes to the email the form collected, so the form must collect a requiredemail; an address is the treasurer, the board. The plan then needsmail: { from, reply_to }at the top, the sender every mail carries intosite.yaml. The words model writes each mail’s subject and body (aggregates.yamlin the words map); the fallback says what happened and, where the submitter has a move from that stage, the link to make it.stages_text: { <stage>: <words> }on a need: each stage in the plan’s language, where the ASCII id will not do (ikke_lostreads as English). Used by the fallback mails, the “what happens next” line, the desk buttons’ fallback labels, and written into needs.yaml asstage_wordsso the desk score describes an outcome in the site’s own words. With them, the vel’s desks score 26 of 26.turned_down_from: [<stage>, ...]on a need: the unfinished stages a case can still be turned down from; omitted, every one. An applicant is declined before they start and departs after, which is what uhhm.no’s live machine says and what two near-synonymous desk buttons had been standing in for.reached_from: { <stage>: [<stage>, ...] }on a need: where a stage is entered from, when the line a case walks does not say it. Two shapes need it and nothing else does - a case SENT BACK to a stage it was already past (a building application returned for the drawing it was missing, after the hearing turned something up), and a GOOD ENDING PARTWAY ALONG (an authority that changes its own decision while it is looking at the appeal, rather than passing it up to the board). A stage named there is entered from exactly those stages and nowhere else, so saying where a refusal may come from also says where it may not: an applicant may withdraw from anywhere, and be refused only once the papers are in, whichturned_down_fromcould not express because it was one list for every bad ending at once. Found by replaying a building authority’s real cases through portal’s engine, which refused three legal paths and accepted two illegal ones until the plan could say this; see runs/permits-2026-09-25/README.md.attended_by:on a need: who moves cases that no desk button moves, an automation listening on the bucket’s events, written into aggregates.yaml as portal’s own annotation for the reader.
Plan: the intake, read by a model, checked by a person
portal plan --intake intakes/contractor.yaml --out out/nordvik \
--personas sectors/construction/needs.yaml \
--llm "MODEL=nemotron-3.5-lightning:30b-a3b engines/ollama.sh"
This is the one step where a model decides structure: which needs the
intake implies, the stages, who handles what, the form, the budget.
Its YAML is parsed and validated exactly as build will, the errors go
back to it, and what passes is written verbatim for a person to read
before building from it. Personas are never asked of this model; they
are attached from a file, and where their need: names an id the plan
does not have, plan says so and leaves the mapping to the reader.
generate (below) is the older path, a model writing pages free-form
against the lint. It is kept for the record of what it measured.
How it works
intake (plain words)
│
▼ phase 1 frozen after this phase: the generator
needs.yaml ─────────────────────── may not weaken the spec to pass
│
▼ phase 2 ◄── iris findings, until it exits 0
aggregates.yaml + questions/** + desks
│
▼ phase 3 ◄── misroutes from a blind visitor model
wording
- Needs. The intake becomes
needs.yaml: one need per kind of person and thing they want, including the roles inside the business. Each names the bucket the answer lands in, the group that handles it, the states that count as finished, and the effort the visitor will tolerate. - Structure. Pages, state machines and desks are generated, then
revised against the lint,
iris check, until every need is met: a path within budget, a desk held by the right group, every finished state reachable, no record stranding. The page-writing model sees the needs and never the personas. - Wording. A separate model plays each persona, blind, first with headings only and then with descriptions. A misroute is a wording defect and goes back to the generator. A rewrite that breaks structure is reverted.
Model output is only ever written to aggregates.yaml, site.yaml
and questions/**.yaml inside the output directory.
portal generate --intake intakes/contractor.yaml --out out/nordvik \
--lint "portal lint" --llm engines/ollama.sh
portal answer --tasks tasks.jsonl --llm engines/ollama.sh > answers.jsonl
answer puts one task per call to the visitor model, with the options
shuffled per task and the call seeded from the task id: no visitor
sees another visitor’s situation, a heading’s place on the page cannot
carry the answer, and a rerun on the same site is the same experiment.
The earlier runs under runs/ answered all tasks in one prompt.
The verifier is uhhm/iris, not code in this repo: it reads portal’s own content types and ships in every portal release, so a generated repo’s CI keeps enforcing its needs after handover.
LLM backends
A backend is any command that takes a prompt on stdin and prints text.
| Backend | Use |
|---|---|
engines/ollama.sh | Local Ollama. MODEL=qwen3:8b answers a task in about two seconds on ergo’s GPU and is the default visitor. Generation wants a larger model (MODEL=qwen3.6:27b), which runs mostly on CPU there. |
engines/claude.sh | Claude through Claude Code’s own login (a Max or Pro subscription), no API key. MODEL=sonnet or opus, EFFORT=low by default. It drops any ANTHROPIC_API_KEY from the environment, because a key outranks the login and a stale one fails every call. Log in once with claude auth login. |
engines/anthropic.sh | The Anthropic API, given a working ANTHROPIC_API_KEY from the Console. That is separate, prepaid billing; a subscription does not cover it. |
engines/mailbox.sh | Writes each prompt to a directory and waits for a reply file: drive the generator from an agent session, or by hand. |
The visitor and the generator should be different models. A model grading its own wording agrees with itself.
The onboarding site
portal-portal/ is the front end of this: a portal content repo whose
front door is the intake interview. A submission lands in
onboardings (open → drafted ⇄ revising → live | declined) and is
worked from an owners’ desk. Today the desk step is a person running
generate and sending the link; the intake record already carries
every field the generator reads, so the step that remains is a
listener on portal.answers.submitted that runs it and pushes the
draft to a new repo. It passes its own needs.yaml.
The whole card, not the heading
A visitor never sees a bare heading and description. Portal renders a card: a button, an encouragement, one or more sections each with an icon, a title and a line of guidance, and the fields with their labels and placeholders. Since 2026-09-23 the builder fills all of it and the scorers read all of it.
- Slots. Beside the heading and description, each form alternative gets a button, an encouragement, an icon, a form title and guidance line, and a placeholder per field. A door gets an icon and a “for whom” line. Icons are picked from a fixed list of Iconify lucide names; anything else is dropped.
- Context from the plan. Every form alternative also carries two sections built from what the plan already knows, before the form: what happens next (the stages, in order, as one sentence) and who reads it (the handling group and the contact). Their fallbacks are true before a model has written a word; the model rewords them.
- Scoring.
answer --repo <content dir>shows the visitor each option as its whole card (headings-only tasks stay headings only).rate --repodoes the same and asks a fourth question, “where”: do you know where you are, who this is for, and what the button does. All four are controlled against the gutted twin.
Scoring a built site
portal score --site out/nordvik --out out/scored \
--iris /var/local/cargo-target/release/iris \
--llm "MODEL=qwen3:8b engines/ollama.sh"
One command for the whole grading, which every run under runs/
before 2026-09-23 carried as its own score.sh: iris writes the
visitor tasks and the simulation model, the blind visitor answers
them with descriptions and with headings only, iris scores the
answers (the misroutes are printed), and rate reads the cards. The
two routing numbers land in summary.json, the rating in
rated/rate.json. The pieces still run alone: answer --tasks for
one visitor model against tasks iris wrote, and:
iris check --path out/nordvik/questions --needs-sim model.json
portal rate --model model.json --out out/rated --repo out/nordvik \
--llm "MODEL=qwen3:8b engines/ollama.sh"
Four yes-or-no questions per persona (which option is mine, where am
I and what does the button do, what will I be asked, what happens
after I send it), each also put to a gutted
twin of the page with headings, descriptions, the form and the
followup removed. A question the twin still gets a yes on is not
measuring the page, and rate says which. The followup page is read
from the repo (--repo); without it “what happens next” is reported
as not measured rather than as zero. LIX is over the prose only
(question, descriptions, followup): headings and labels are not
sentences and would pull every page down to “very easy”. The
rate.json in runs/contractor-build-2026-09-21/ predates this and
its asked and next columns are the old, pinned ones.
rate --repo also scores the desks, the inside of the business,
which reads button labels and nothing else: for every desk and bucket
with two or more buttons, a worker is told what has just happened to
a case (in the state’s own words, never the label’s) and picks the
button among the ones a row in that state shows, in a rotated order.
Scored per desk as right of total, with what each miss was mistaken
for. Measured on 2026-09-24 on the live vel: 24 of 26, the misses
one button each on the neighbour-help desk and the notice board. Two
limits: the outcome is put from the state id, and a Norwegian id with
its accents stripped (ikke_lost) reads as English even to a strong
model; and a first version that offered every button of the bucket,
not the row’s, scored the same desks 17 of 26 and sent a rewrite
chasing an instrument fault. A desk that scores full marks has labels
a stranger to the business could press right.
prefer has a positive control on record
(runs/tomtervel-opener-2026-09-23, “The friendliness judge”): the
live vel front door against a bureaucratic twin, friendlier 9 of 10
to the live page and 0 to the twin. A pair that comes back “no
preference” throughout is alike in tone, not an instrument that
cannot see; and the rejected door rewrite that was judged clearer on
5 of 10 needs routed worse across three seeds, so clarity as judged
and routing as measured are different things, and routing decides.
Measured so far
Generated: a building contractor (runs/contractor-2026-09-21/),
from intakes/contractor.yaml, a Sonnet-class model generating.
- Phase 1 derived six needs from the owner’s prose, including one nobody asked for: a project manager logging a phone enquiry, taken from “enquiries arrive by phone, email and text and get lost”.
- Phase 2 was green on the first round: 14 files, four state machines
(
enquiries: open → quoted → won | lost,subcontractorswith anawaiting_papersloop,applicants,site_issues), two desks. - Phase 3, its own personas: 10 of 10 in both modes. That number is flattering, because in that run the pages were written with the personas in view. Against ten held-out personas, with qwen3:8b as the visitor:
| Visitor could read | Routed right |
|---|---|
| Headings and descriptions | 10 of 10 |
| Headings only | 8 of 10 |
A scaffolding firm asking “how do I get to do jobs for you?” chose “Ask about working with us” (the hiring door). An attic conversion chose “Get someone to look at a building”. The generator has since been changed to hide personas from the page-writing phase.
Built: the same contractor from a plan (runs/contractor-build-2026-09-21/),
nemotron writing words only, and re-scored one task per call the day
after (rescore-2026-09-22/ in it): 8 of 8 with descriptions, 7 of 8
on headings alone, the miss being an apprentice bricklayer reading
“Join our carpentry team” as not for them. rate on the same site,
with the followup shown and every question controlled, passes its
controls and puts the pages at LIX 18 to 35.
Built behind doors, 2026-09-22. Five plans with a first question before the forms, each built and then scored by qwen3:8b one task per call, against held-out personas. Behind a door a persona is asked twice, on the front door and on the door’s page.
| Case | Words by | With descriptions | Headings only | Notes |
|---|---|---|---|---|
Machine hire (hire) | nemotron | 10 of 10 | 10 of 10 | doors that ask a question the visitor already knows the answer to |
Physio clinic (physio) | nemotron | 8 of 10 | 9 of 10 | “Book your first appointment” fails a daughter booking for her mother |
Football club, Norwegian (fotballklubb) | nemotron | 14 of 16 | 11 of 16 | three parents walk past “Er det på tide å melde inn?” |
| Football club, Norwegian | Claude Sonnet | 13 of 16 | 12 of 16 | better sentences, same driver/sponsor blur |
Whole contractor (contractor-full) | nemotron | 18 of 22 | 15 of 22 | “Apply to work with us” and “Find a job with us” cross over for all four |
Tomter Vel, Norwegian (tomtervel), first plan | Claude Sonnet | 25 of 30 | 22 of 30 | “utstyr” for a swing, hearing input behind the wrong door |
| Tomter Vel, plan revised 2026-09-23, whole card | Claude Sonnet | 31 of 38 | 30 of 38 | committees as groups, public lists, hearing input on the front door |
Each run’s README names the misroutes and what in the plan or the heading caused them. A stronger word model did not route better on the one plan built with both: routing is decided by which distinctions sit on one page, and the visitor grades that the same whoever wrote the words.
Planned: the contractor intake read by a local model
(runs/contractor-plan-2026-09-22/), nemotron, one round, 41 s. The
plan passed every check build makes and a reader finds three
structural misses in it the checks cannot: the crew’s report made
public, the “papers missing” stage dropped, and no need for the
project manager logging a call. The person reading the plan is not
optional.
Existing site: uhhm.no (runs/uhhm-2026-09-21/), eight tasks.
| Visitor model | With descriptions | Headings only |
|---|---|---|
| small LLM, blind | 7 of 8 | 5 of 8 |
| qwen3:8b (Ollama) | 7 of 8 | 7 of 8 |
| granite4.1:8b (Ollama) | 7 of 8 | 6 of 8 |
All three send a paying client through “Work happens through dialogue, not tickets”, the collaborator door. Different models, same defect: that is what a wording problem looks like.
The noise floor, 2026-09-23 (runs/spread.sh, answer --seed).
The same tasks answered by qwen3:8b with four seeds (0 is the run
every score above used):
| Run | With descriptions | Headings only |
|---|---|---|
| uhhm.no round 6, 15 tasks | 11, 10, 10, 10 | 12, 12, 7, 7 |
| Tomter Vel with the opener, 38 tasks | 30, 29, 29, 31 | 26, 24, 26, 23 |
With descriptions the visitor is steady: one task in fifteen, two in thirty-eight. On headings alone it is not: five of fifteen on uhhm.no between seeds, three of thirty-eight on the vel. A headings-only difference smaller than that is the visitor’s mood. Compare descriptions-mode scores; read headings-only as a sanity check, and run several seeds before believing it. Small numbers, one run each. Leads, not measurements.
What else is here
skill/SKILL.md- the same procedure for an agent or a person working by hand.sectors/- needs specs to start from.construction/doubles as the held-out persona set above.generator/reference.md- everything the generating model is told about portal content. When the lint rejects something the reference did not warn about, the fix belongs there.
Toward a trained model
Nothing here trains weights yet. What would make it possible is accumulating: every run leaves the intake, the needs, the pages at each revision and the scores. With a fast local visitor as the scorer, generating many variants per need and keeping the winners is cheap, and those winners are the fine-tuning set for a small generator. The verifier has to be trusted before any of that: a generator trained against a judge that is often wrong learns the judge.
The lint
The iris over a site. A content push is an incoming wormhole; nothing comes through until it checks out.
portal lint --path questions # a local checkout
portal lint --repo https://project.uhhm.no/uhhm/questions
portal lint replay --path questions --nats nats://127.0.0.1:14222
It was its own binary, iris, until portal 0.6: the same code, released
as portal’s twin. Now it is the portal binary’s lint command, so a
content repo’s CI downloads the release and runs it:
- run: |
curl -sfL "https://project.uhhm.no/uhhm/portal/releases/download/$PORTAL_RELEASE/portal" -o portal
chmod +x portal && ./portal lint --path questions
check loads a content repo exactly the way the running site does,
validates it (every action lands on a page, every transition is one
the state machine allows, every recorded bucket is read back
somewhere), and then holds it to the business needs the repo declares
in needs.yaml, when it has one:
- a path of submissions gets each kind of visitor’s answer into the bucket it names, within a submission and required-field budget;
- the handling group holds a desk over that bucket, and every
finished state is reachable through the buttons it (and any
also_moved_bygroup) offers; no record strands on the way; - every stage the business named is a state of the machine.
A need marked planned warns instead of failing. Exit status is the
verdict, so a content repo’s CI is one line.
Beyond the verdict, check exports what a trainer needs to grade the
wording: --needs-tasks writes the personas as typed choice tasks
(--skim for headings only), --needs-score grades an engine’s
answers, and --needs-sim writes a resolved model of the whole site.
portal lint replay runs simulated cases through the site’s real aggregate
engine on a throwaway JetStream and reports every bucket by state;
--report-only takes the same scorecard from a live site’s buckets.
See Training a site for what sits around those.
Matching the site
The lint is the site: portal lint and portal serve are one binary
built from one source, so a content shape the site reads is a shape
the lint knows, at the same version. A content repo pins
PORTAL_RELEASE to what its site runs and lints with that release’s
portal. The IRIS_RELEASE pins from before portal 0.6 keep working
against the iris releases that exist; nothing new is published there.
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.
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, 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.
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.
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 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.
Sessions (src/sessions.rs) — in the same JetStream KV as
everything else, so a restart does not sign anybody out. The bucket
carries a max_age so NATS collects what nobody comes back for, and
each record’s own expiry_date is what decides on the way out.
Filesystem routes, sections, and where chains fit
Status: implemented in v0.2.0 (2026-08-24) — all three phases, with
one deviation: dynamic-page params substitute into resource keys via
server-side resolve_question at lookup time (get_question,
find_feature, submit_answer all resolve concrete paths), so no param
threading exists client-side. The user-facing routing contract is
documented in uhhm/questions’ README (“Routing: the tree is the
router”); this file stays as the design rationale.
What exists today, precisely
- A question’s
iddoubles as its URL. Filenames are meaningless:questions/is loaded as a flat directory (both the Gitea loader andquestion_lint --path), every*.yamlbecomes aQuestionkeyed by its own declaredid. - Hierarchy exists only as strings:
action: /proposededges, plus a manualfollowup: trueflag that hides post-submission pages from the nav. The graph is invisible in a repo listing — you open every file to learn the flow. (uhhm’s actual graph:/fans out to three followups;/develop→/develop-proposal→/proposedis a two-step flow flattened into three top-level files.) - Criteria are per-file:
qualifies: <kanidm-group>on a question,requires_groupon a resource. Gating a whole area means repeating the flag in every file of that area. - Chains are query-string lineage: submitting hashes
(question, parents, responses, ts)intochain_hash, the next page isaction + ?chain=<hash>, and holding a chain is itself a capability (self_transition+ email second factor).chain.rsreserves DAG shape (multiple parents) but nothing produces it yet. Question.routeis a declared-but-never-read field — a fossil of this exact idea.
The proposal, in three phases
Phase 1 — the tree is the router
Directory structure becomes the URL structure, next-js style:
questions/
index.yaml → /
applied.yaml → /applied
develop/
index.yaml → /develop
proposal.yaml → /develop/proposal
proposed.yaml → /develop/proposed
review/
index.yaml → /review
id:becomes optional and derived from the path (index.yamlnames its directory). An explicitid:still wins so both content repos keep working unchanged; lint warns when it disagrees with the path, and the field can retire later along withroute.action:accepts relative references:action: proposedresolves against the file’s directory,action: /subscribedstays absolute. Resolution happens at load time, so validation and the runtime see absolute ids exactly as today. A flow directory becomes self-contained: renamedevelop/and every internal edge moves with it.followup:is inferred:index.yamlfiles are nav pages, non-index files are followups unless they saynav: true. This matches the real content exactly (uhhm’s fourfollowup: truefiles are precisely its non-index leaf pages) and deletes a flag people must remember.- Loader: switch from the per-directory contents API to Gitea’s git
trees API (
/git/trees/{branch}?recursive=true) — one request for the whole tree instead of one per directory, which the flat loader should be using anyway.
Phase 2 — sections: criteria scoped by URL prefix
A _section.yaml in any directory applies to everything beneath it
(underscore = not a page, like next’s private folders):
questions/review/_section.yaml:
qualifies: portal_owners
responsible: { name: Bendik, contact: … }
This is the URL/criteria interplay actually worth having: a URL
prefix becomes a trust boundary. “Everything under /review is
owner-only” is one line in one place, instead of a flag per file that
drifts. Per-question qualifies still overrides (tighter or looser —
lint should warn on looser). Sections are also the natural home for
shared responsible contacts and, later, per-section branding
accents.
Phase 3 — dynamic segments, and chains stay out of the path
Two criteria axes exist: who you are (Kanidm group) and what you’ve done (holding a chain). Phase 2 scopes the first by prefix; phase 3 does the same for the second, plus gives records addresses:
[record].yaml— a dynamic segment, one YAML file rendered per record:questions/review/[record].yamlserves/review/<key>, with the param available to the page’sResourceSpec.key. Today a single record is only reachable through a desk row or a?chain=link; this gives every record a real, gated URL — linkable from review desks, automations, and n8n notifications.requires_chain: <question-ref>(page- or section-level): the page only renders for a visitor whose chain tip answers the referenced question. Today’s followup pages are soft-hidden (out of nav) but fully reachable; this makes “you must have come from X” an actual criterion, declared with a relative ref like actions are.
Deliberately not proposed: encoding the chain in the path. The
page tree is static structure; the chain is runtime lineage and a
bearer capability. Putting it in the path makes it look canonical and
shareable — exactly what a capability URL shouldn’t invite — and a
DAG (the reserved multi-parent shape) doesn’t linearize into a path
anyway. ?chain= stays a query parameter: pages keep one canonical
URL, lineage rides along only when it’s actually held.
Migration
Phase 1 is fully backward compatible (explicit id wins, flat repos
are just trees of depth one). The content repos migrate by git mv:
uhhm’s develop flow nests under develop/, its followups either stay
top-level (/applied keeps its URL) or move with their flows if URL
churn is acceptable; redoal’s three files are already the tree. Lint
learns the same resolution rules in the same commit, so a bad
reference stays a caught push, never a 404.
Order of value
Phase 1 is cheap and pays immediately (the repo listing becomes the
sitemap). Phase 2 is small and unlocks gated areas properly. Phase 3
is the real feature work — [record] pages change what desks and
automations can link to — and can wait until something concrete needs
it.
Changelog
One line per change, grouped by the release that shipped it. Newest
first. A release is a Cargo.toml bump plus a v<version> tag; the
content repos pin the tag they run (see the end of this file).
0.6.0 (2026-09-30)
- One binary. The lint (uhhm/iris) and the trainer (uhhm/portal-trainer) are commands of
portal:portal serve(the default),portal lint, andportal plan,build,answer,rate,prefer,score,simulate,compare,report,generate. Same source, same version, so the site, the lint and the trainer never disagree about what content means; both histories are merged in. The release attaches the bareportalbinary beside the tarball, so a content repo’s CI downloads it and runsportal lint. No more iris releases; the pins that exist keep working. The trainer’splans/,intakes/,engines/,sectors/and its reference prompt live here now; itsruns/stay in uhhm/portal-trainer. Docs gained “Training a site” and “The lint”.
0.5.11 (2026-09-30, tagged, never published)
The publish job failed at its lint step: iris, built against this tag, did not compile against the storylines change (the same two fixes that went into src/lint/ when the lint moved in). Nothing was attached to the tag; 0.6.0 is the first release that ships storylines.
- Storylines (
src/content/variants.rs,docs/storylines.md): any text a person reads may be a list, one entry per storyline, every list the same length; a visitor is given one storyline for the whole site (theportal_storylinecookie, a year) and reads it on every page and in every mail about their case. What the machine reads - a field’sname,options, states, groups, paths - never varies. The lint refuses a list of another length. Records and answer events carrystoryline, andGET /reportgives counts per bucket, per storyline, per state, and nothing else.
0.5.10 (2026-09-30)
- The lint lets an invite form collect a required
phonein place of a requiredemail: memberships know a person by number first.
0.5.9 (2026-09-30)
- With memberships as the people backend and a Kanidm token still set, an invite makes the Kanidm account as well (no group, the one-time link in the mail): Kanidm makes people, portal makes members, and a site can switch to memberships before its sign-in provider is one everyone already has.
0.5.8 (2026-09-30)
- Fixed: 0.5.7 asked every provider for the
phoneNumberscope, and Kanidm denies a sign-in that asks for a scope the person does not hold, so nobody could sign in. The scope is asked for only withOIDC_EXTRA_SCOPES=phoneNumber(Vipps). 0.5.7 must not be deployed on a Kanidm site.
0.5.7 (2026-09-30)
- Memberships: groups kept by portal itself (
src/memberships.rs,docs/memberships.md). A membership is a case in the built-inportal_membershipsbucket -invitedby a desk, agrants, the seed or the responsible pass;activefrom the person’s first sign-in, when the provider’s subject, phone number or address matches;removedby a desk. A person is their phone number first (+4791234567,PHONE_COUNTRYfor bare numbers), their address second, the provider’s subject third, and one record per group and person however it was keyed. At sign-in the active memberships join thegroupsclaim, so Cedar sees the same parents as before.PEOPLE_BACKEND=membershipsswitches a site; unset, aKANIDM_API_TOKENmeans Kanidm and no token means memberships.SEED_ADMIN_PHONEseeds by number. The sign-in asks for thephoneNumberscope and readsphone_numberfrom userinfo. - The NATS access service answers for a person named by
sub,phoneoremailwith no groups listed: their memberships are looked up first. who_for(the person agrantsis about) takesnavnas well asname, and a phone number in place of an address.- The invite mail for someone who needs no link says where to sign in.
0.5.6 (2026-09-30)
- A group may hold a page’s responsibility:
responsible: { name, contact, group: styret }. No account or mail for the inbox, and any signed-in member of the group gets the suggest-a-change card. Before this, a board’s shared address was onboarded as a person.
0.5.5 (2026-09-30)
- One record answers another, from a list.
linkon a resource puts a link on every listed record to a page about it ({id},{field}), a state mail’stomay be a field of the record this one belongs to ({notice.email}, and{notice.field}in the text), andvalue: "{user.email}"fills a field from the session and hides it, so a signed-in person is not asked who they are. Together they make a board: a notice is listed with “answer this”, the answer page’s form carries the notice’s id, and the mail goes to whoever posted it. The lint requires the record field the mail reaches through. - The way down the tree is the line at the foot of the page and nothing else. The arrow fixed at the left edge, which 0.5.3 added beside it, is gone: it followed the reader down every page to offer something the foot already does.
0.5.4 (2026-09-29)
- Accounts the content mandates, made at start (
src/seed.rs). Every person a page names asresponsiblegets a Kanidm account if they have none, and a mail in the site’s language saying they are listed as responsible for asking that question, with the link to sign in;RESPONSIBLE_GROUPputs them all in one group (people who already had an account are added without a mail).SEED_ADMIN_EMAIL(andSEED_ADMIN_NAME,SEED_GROUP) invites the first person into the site’s most privileged group - the group whose desk may invite into the most groups - so a site with its own Kanidm has someone to invite everyone else. Both happen once per address and group (portal_seed), side by side, and each tries again a minute apart, twenty times, while Kanidm comes up. The responsible are gone over again whenever the content changes (portal.content.reloaded, published once new content is in place), so a new page’s person is set up without a restart - and someone the content has stopped naming leavesRESPONSIBLE_GROUPin the same pass, if portal is who put them there. - An invite is a case that keeps itself current (
portal_invites:open,expired,done). Signing in closes a person’s invites; a sweep every ten minutes moves one whose link ran out unused toexpired, and takes the link off the record. Fromexpireda desk may send it again: the move back toopenmakes a fresh link and mails it. Content offers that with{ from: expired, to: open }on the list of invites. The invites portal makes itself at start are on file with the rest. - Fixed: an invite mail said the link worked “until” and then nothing. Kanidm 1.11 gives a link’s end as seconds since the epoch, and portal read only the older RFC 3339 string.
- Fixed: a search by mail that Kanidm failed to answer (a 5xx) was read as “not permitted”, and the invite went on to match by username - which could give someone with an account under another name a second one. Only a refusal is read that way now.
- A page’s responsible person, signed in, may suggest a change to it: a card at the foot of their own pages, checked on the server against the page’s
responsible.contact, published onportal.edits.suggested, and mailed to the site’s inbox with them as reply-to. It is a case in the built-inportal_edit_suggestionsbucket -open,discussed,doneordeclined- so a desk that lists the bucket takes it up and settles it, the person who made it is mailed at each move, and the card shows them what became of what they sent before. The session now carries the signed-in person’s email. - The mail to someone named responsible names the first three questions and counts the rest; on a site where one address answers for every page it used to list them all.
- Fixed: an invite of someone new failed on a Kanidm set up with the onboarding account alone. Portal created the person, then set their address in a second call, and
idm_people_on_boardingmay create but not modify: 403. The address now goes in with the person.
0.5.3 (2026-09-29)
- A way down the tree from every page: an arrow fixed at the left edge, half-way up, and the same link in words at the foot (“Tilbake til starten”, “Back to “What came in?””). It goes to the nearest page below the current one that the visitor may open, taking the path apart a segment at a time, and to the front door when none is; the front door has none. Content no longer needs a “back” alternative, and a visitor who went up a branch that turned out not to apply is never stranded.
0.5.2 (2026-09-28)
- The rate limiter answered 500 to any request that arrived without
X-Forwarded-For. It reads the caller from that header orX-Real-IPand falls back to the socket, and 0.5.0 served the app without connect info, so there was no socket to fall back to and no key to rate-limit by. Through Caddy every request carries the header and the site was fine; a health check on localhost got a 500, which is how the first deploy of 0.5.1 failed.
0.5.1 (2026-09-28)
- A
self_transitionthat names nofrommeans the bucket’s own initial state, not the wordopen. 0.5.0 read the literal, which is only right for a bucket whose records start there: redoal’s watchers arrive atpendingand confirm themselves intoopen, so the rule compiled asopen -> openand the confirm link in the mail could never match. Wrong the same way before 0.5.0, and now visible because the lint checks the edge exists.
0.5.0 (2026-09-28)
- Sessions live in NATS KV (
src/sessions.rs), not in memory. Every restart used to sign out every desk on the site being deployed; they now survive it, in the JetStream that is already under everything else - no new service and no new store to run. The bucket’smax_agecollects what nobody returns for, and a record’s own expiry is what answers a load. - Fixed: the JetStream integration test asserted a record’s projected state was one a desk move had never written to it, so it failed whenever it actually ran.
test.ymlhas been red since 0.3.47 whilepublish.ymlstayed green, which is how 0.4.0 shipped past it. - A file can come back out.
GET /attachment/{bucket}/{record}/{field}streams what atype: filefield stored, reading the object key out of the record rather than taking one from the caller - so a file is only reachable through a record somebody may see, and a key that leaks off a desk is a link to nothing. Before this, uploads were one-way: a desk showed the key as a string and there was no way to open it. - Rate limiting, per caller IP, outside everything else: a burst of 30 refilled one every two seconds (
PORTAL_RATE_BURST,PORTAL_RATE_SECONDS; either 0 turns it off). A form-filling visitor never reaches it; a script does. There was none at all before. /uploadasks the policy, like every other way in. It asked nothing: any caller who could reach the host could put 20 MB in the bucket, as often as they liked, and a site whose forms are public is a site whose upload route is public. It now takes thequestionandalternativethe file is being attached to, refuses a page that declares notype: filefield or an alternative that is closed, and asksSubmiton that page - the same question the submission itself will ask - before a byte is written. Latent until an instance had storage configured; the first one now does.- A submitter stays in dialogue with their case for as long as it runs.
self_transitiontakesfrom: [state, ...], and the access policy compiles one rule per state named; it defaulted to the initial state alone and was written that way in the compiler, so an applicant written to for a missing drawing could not send it, and nobody could withdraw once anything had happened. Omitted, it still means the initial state alone. - A record can belong to another record.
type: recordwithof: <bucket>stores the parent’s id, which arrives on the link rather than being typed; a submission naming a record that is not there is refused. The other half iswhere: { field, equals }on a Kv resource, so an application’s page can list the objections that name it. - A public view need not publish the whole bucket.
states: [...]on a Kv resource shows only those, for one record as well as a listing - a register that is public by law is public about what was decided, not about what was withdrawn. The desk over the same bucket is a different spec and still sees all of it. - A state can grant a group:
grants: <group>beside itsevent, so an approval that means membership does not also need somebody to remember to invite them afterwards. The lint requires the forms that fill such a bucket to collect a required name and email, because by the time a desk presses approve there is nobody left to ask. - A state can be moved by a clock:
after: { days, to }, swept hourly, measured from the record’s last move (src/deadlines.rs). A deadline may only do what a person could have done -after.tois held to the same declared edge as a desk button - and the move fires the same mail and publishes the same event, stampeddeadline. - Documentation moved out of the README into
docs/: architecture, content reference, state machines, access, operations. - A page says nothing until it has something to say. The Suspense fallback on a question was the whole served page - no header, no wordmark - so the first and only thing a visitor or a crawler read was the word “loading…”, which is true and is not about them. It now holds the space and stays quiet, with
aria-busyfor a reader that would otherwise find an empty document. - Gone: the
gitea_org_reposresource kind, which no site ever named.gitea_starredandgitea_releasesstay.
0.4.0 (2026-09-24)
- A field can be a place:
type: locationputs a pin on a map and stores{lat, lon, zoom}; a record holding one renders as a still map on its desk and in any resource card. - A question can be about a place:
place: { lat, lon, zoom, name }is the counterpart toevent:- event makes a page an occasion with a time, place makes it somewhere, draws the map under the question, and opens any location field on that page there instead of on the globe. - Maps are served from this origin. Portal fetches every tile and every still itself (
src/maps.rs, MapLibre vendored), so a page with a map on it makes no third-party request, needs no consent banner, and theMAPBOX_TOKENnever leaves the server. Unset, the feature is simply off. - A reply to a case mail becomes a note on its record: whatever reads the mailbox publishes on
portal.mail.receivedand portal appends the note, after checking the record exists and the sender is the address the record itself holds. A note never moves a case.
0.3.47 (2026-09-24)
- iris: needs.yaml takes
stage_words, each state in the site’s own words, carried into the simulation model for the trainer’s desk score. No change to portal itself.
0.3.46 (2026-09-23)
- An invite first looks the email up in Kanidm (
/v1/raw/searchonmail, when the service account may read people:idm_people_pii_read) and only adds an existing account, under whatever username, to the group; no second account, no reset link.
0.3.45 (2026-09-23)
- A mail stream that cannot be created at boot (subjects owned by an older stream) is a warning, not a failed start; 0.3.44 took the kasse sites down for two minutes on the rename.
0.3.44 (2026-09-23)
- The mail stream is named
mail(wasWORMHOLE); gdo 0.1.2 matches. Delete the old stream on each NATS after upgrading both.
0.3.43 (2026-09-23)
- Mail is content:
mail: { to, subject, body }on a state in aggregates.yaml sends when a record enters that state (to: submitteror one address;{field},{site},{chain},{email}in the templates), and an invite mails its one-time link built in;site.yamlgainsmail: { from, reply_to, invite }. Portal renders and publishes onportal.mail.sendin theWORMHOLEstream; uhhm/gdo delivers. The lint requires a requiredemailfield on every form recording into a bucket that mails the submitter.
0.3.42 (2026-09-23)
- people: the Kanidm person lookup reads the body, since Kanidm 1.11 answers a missing person with 200 and
nullrather than 404; before this every invite skipped account creation and failed on the group step
0.3.41 (2026-09-23)
- publish: the iris release step retries on a fresh clone when its push loses a race against a push to iris main (the v0.3.40 tag never published)
- iris:
check --accessprints the access matrix, and every check validates the compiled policy against the schema
0.3.40 (2026-09-23)
- One access policy, compiled from content into Cedar on every load (
src/access.rs): pages, resource reads, desk transitions, self transitions and the automation endpoint all decide through it; decisions carry rule ids, refusals and mutations publish onportal.access.decided, andportal.access.<site>.mayanswers the same question for other apps over NATS invite: {group}on an alternative onboards a person into Kanidm from a desk (src/people.rs,KANIDM_API_TOKEN), recording the one-time credential link inportal_invitesand onportal.people.invited
0.3.39 (2026-09-23)
- publish: releases the matching uhhm/iris by proxy - re-pins, builds, tests and tags it as this version (needs the
IRIS_PUSH_USERvariable andIRIS_PUSH_TOKENsecret); iris carries portal’s version number from here on
0.3.38 (2026-09-22)
- publish: the iris build step gets a writable cargo home; the v0.3.37 tag never published
0.3.37 (2026-09-22)
- The lint moves to its own repo, uhhm/iris:
question_lintbecomesiris check,needs_replaybecomesiris replay; the tarball shipsiris(pinned in publish.yml) instead ofquestion_lint; the needs module and local loaders leave portal
0.3.36 (2026-09-22)
- Question nav: a followup is offered only once earned - the verified chain answered the alternative leading there, or a link that records nothing leads on from an earned page. Standing on a page no longer unlocks the pages behind its forms; the chain index now records which alternative was answered
- One bounded HTTP client for every server-side fetch: 20 s budget, size caps per kind of fetch (
src/http.rs) - Uploads are refused past 20 MB while streaming, and the route body limit that had capped them at axum’s 2 MB default is raised to match
tests/jetstream.rs: the store, the event log and the projection tested against a real JetStream (CI starts a throwaway one)contentsplit intogitea,validate,markdown,handlers; every path stillcontent::x- This changelog, and the table of what each site runs
- Business needs as a checkable spec: needs.yaml + question_lint pass
- needs:
successstates and a resolved simulation model - needs:
stages- every stage the business named must be a state - needs_replay: make simulated cases real aggregates, report buckets by state
- needs: also_moved_by - a need carried by more than one group’s desk
0.3.35 (2026-09-12)
- Item cards: no paragraph margin under the description
0.3.34 (2026-09-11)
- Question nav: offer followups by reach, not by carrying any chain
0.3.33 (2026-09-02)
- Head preloads: latin font, wordmark, content-host preconnect
0.3.32 (2026-09-01)
- Per-site favicon via site.yaml
0.3.31 (2026-09-01)
- Markdown links allow tel:
0.3.30 (2026-09-01)
- Single alt-image shows natural height
0.3.29 (2026-09-01)
- Markdown image layout hints + AVIF assets
0.3.28 (2026-09-01)
- Hero description renders inline markdown
0.3.27 (2026-09-01)
- wordmark_invert: explicit, not inferred from .svg
0.3.26 (2026-09-01)
- Select fields: inline static options
0.3.25 (2026-09-01)
- Content-shipped stylesheet override (site.yaml stylesheet)
0.3.24 (2026-09-01)
- Gateway submit-as-link loses the underline
0.3.23 (2026-09-01)
- Chrome speaks the site’s language: site.yaml lang + i18n table
0.3.22 (2026-09-01)
- site.yaml wordmark_height; raster logos keep their colors
0.3.21 (2026-09-01)
- Hero paragraph centers its measure box
0.3.20 (2026-09-01)
- Sign-in becomes optional: KANIDM_URL unset disables auth cleanly
0.3.19 (2026-09-01)
- Markdown images in descriptions, gated and framed
0.3.18 (2026-09-01)
- Item cards render inline markdown descriptions
- Convergence: ink converges onto the decoded key (WebGL glow)
- Gesture results overlay the canvas - the page never jumps
0.3.16 (2026-08-30)
- Announce sweeper: refresh open records from content
0.3.15 (2026-08-30)
- Hidden fields: carrier value without a label row
0.3.14 (2026-08-30)
- Feature cards: icon column, one text edge
0.3.13 (2026-08-30)
- Voice field: nothing leaves the browser until Keep it here
0.3.12 (2026-08-30)
- Voice preview stays hidden until there is something to play
0.3.11 (2026-08-30)
- README: gesture and voice fields
- Validate templated actions against the page pattern
0.3.10 (2026-08-30)
- type: voice, Requirement.value, playable resource cards
0.3.9 (2026-08-30)
- Templated actions; places are pickable in the gesture input
0.3.8 (2026-08-30)
- Descriptions are inline markdown; Feature.link withdrawn
0.3.7 (2026-08-30)
- Feature.link: a card’s name may point elsewhere
0.3.6 (2026-08-30)
- Announced pages: event windows, header announcements, summary tasks
0.3.5 (2026-08-30)
- Gesture widget: stop drawing the decoded ghost path
- Gesture widget: ghost path back, for v2 keys only
0.3.4 (2026-08-25)
- Alternative.disabled: announced but not yet takeable
0.3.3 (2026-08-25)
- Empty list says so before it can parse as zero answers
0.3.2 (2026-08-25)
- README: hashed pkg assets in the release flow
- Resource empty text: content-declared, and null counts as empty
0.3.1 (2026-08-25)
- Content-hashed pkg assets (hash-files) so stale bundles can’t pair with new wasm
0.3.0 (2026-08-25)
- Hero becomes a content-owned module; site asset proxy; gesture growth fix
0.2.4 (2026-08-25)
- Gates keep the question nav
0.2.3 (2026-08-25)
- Attended-bucket lint rule; app-lifetime resources fix first-nav corruption
0.2.2 (2026-08-24)
- One app-level Title fed by a shared site resource
0.2.1 (2026-08-24)
- Docs: routing bullet + design doc marked implemented
- Revalidating cache on app assets; site title survives SPA navigation
0.2.0 (2026-08-24)
- Initial commit: content-driven onboarding portal
- Load content from Gitea directly, drop the local clone; add deploy workflow
- Share sccache between manual dev builds and CI
- Give CI its own SCCACHE_SERVER_PORT
- Erase view types at every list/component boundary, not just leaves
- Redirect cargo-leptos’s tool cache out of act-runner-bare’s StateDirectory
- Fix Ship release: site-root is project-relative, not CARGO_TARGET_DIR
- Drop sudo from the deploy workflow - NoNewPrivileges blocks it outright
- Caddy snippet: use multi-line log block
- Fix static asset 404s and build-time SITE_NAME
- Hot-reload content on a NATS trigger instead of requiring a restart
- Add prosekit rich-text field, Gitea repo embeds, automation KV read endpoint; fix apex/www session-cookie mismatch on /auth/callback
- Fix prosekit-editor.js: remove bare CSS imports that aborted the whole module
- Redirect back to the originating page after sign-in, not always /
- Fix hero-canvas navigation race; style the prosekit editor and add a toolbar
- yes.js: guard against the canvas not being in the DOM yet
- Fix get_resource/transition_answer: scope feature lookup to its own alternative
- Add self-service transitions, authorized by item possession not group membership
- Event-sourced applicant/subscriber/project aggregates, generalized resources
- deploy.yml: write GITEA_API_TOKEN for the new GiteaStarred/OrgRepos resource sources
- Cargo.toml: disambiguate bin-target for cargo-leptos
- Add manually-triggered backfill workflow, no terminal/sudo needed
- Trigger redeploy to pick up PORTAL_GITEA_API_TOKEN secret
- Render generic resource lists as cards, not raw JSON dump
- Fix recursion-limit build failure in the new item-card renderer
- Resource-backed multi/single-select requirement
- Add Organization aggregate (client-as-status), wired but inert
- Fix favicon (real icon, not an unrelated orange circle) + stack encouragements with the submit button
- Fix bucket-404 on empty resources, redesign review actions as select-then-confirm, CSS polish
- Inline encouragements with the submit button, vertically centered
- Use the real institutional logo for the header wordmark, not plain text
- Use the actual UHHM-letters wordmark, not the institutional mark
- Responsive base font size via svmin, not fixed 17px
- Add plain-unit fallbacks for svmin/svh in case of unsupported engines
- Size the YES canvas from its container, not window.innerHeight
- Debounce the YES canvas resize handler and skip no-op resizes
- Revert canvas sizing to window.innerWidth/innerHeight, gate resize on width change
- Stop blocking native scroll on the YES canvas’s touch handlers
- Quicksand: font-display optional, not swap, to stop the text-jump on scroll
- Use lvh/lvmin, not svh/svmin - svh was the wrong end of the viewport
- Update style/main.css
- Update style/main.css
- YES canvas: size off its own container rect, not window.innerHeight
- yes.js: freeze .hero-yes’s height via inline style, don’t trust svh alone
- add responsive container width
- deploy.yml: build and publish question_lint alongside the app binary
- Make aggregate state graphs content-driven, not compiled Rust
- One shared, content-labeled Confirm button per alternative instead of one per row
- get_resource/get_requirement_options: #[server(default)] on params
- Add optional Alternative.image and Feature.color/icon for richer layouts
- Drop the feature list’s left-padding
- Drop question-report entirely, obfuscate the mailto link instead
- Transition.from: state graphs deeper than one decision
- Alternative.images: array of urls, rendered as a Swiper card deck
- Reseed lost event history from the KV projection on transition
- Lighten the comment load
- Validate action targets; format timestamps client-side; clippy cleanup
- Question nav + gateway alternatives
- Responsible note under the title; followup pages out of the nav until a chain exists
- README: what the engine provides
- Feature icons: masked span painted by currentColor, not
- Responsible note: one line, same hue and size as the hero description
- Responsible note as
<small>: meta information, dimmer than the description - Responsible note back to the footer, above the question nav
- Bound inputs: a field that loads its value from a sibling-parameterized resource
- add some more width to our alternatives
- Accent to a dusty press-cyan, a muted echo of the canvas’s cyan ink
- Editor type in rem: prosekit’s px typography ignored the responsive root
- Ban px from stylesheets: every length rem/em off the responsive root
- No translucent input chrome: solid ink-dim placeholders and embed meta
- Editor type scales for real: drop prosekit typography.css, fix selectors
- Stable server fn endpoints: open tabs survive deploys
- Drop stray local tool temp file, ignore .claude/
- Noise-driven YES, sticky hero with frosted cards, full light theme
- increase translucency of laternative cards
- Showcase jq test tracks Gitea repo shape and website-first links
- Gesture input type, redoal-relay client, gitea_releases, content-driven branding
- Echo thumbnails fade in via timeout, not rAF
- Trigger deploy: redoal OAuth2 credentials now configured
- Deploy becomes publish: portal ships as a versioned release artifact
- Semver releases cut with cargo-release; publish on v* tags only
- Docs: release/rollout flow after the instance-ownership refactor
- README: frame portal as a generic multi-site question engine
- Design doc: filesystem routes, sections, dynamic segments
- Filesystem routes, sections, dynamic segments; instant YES hero
What each site runs
Read from the hosts on 2026-09-28; a rollout is a pin bump in the
site’s content repo (PORTAL_RELEASE in its deploy.yml) - on both
hosts now, since kasse’s runner deploys its own instances.
| Site | Host | Instance | Version |
|---|---|---|---|
| uhhm.no | ergo | app@uhhm-portal | v0.5.2 |
| redoal.com | ergo | app@redoal-portal | v0.5.2 |
| westra.klingenbergbygg.no | kasse | app@westra-portal | v0.5.2 |
| portal.klingenbergbygg.no | kasse | app@klingenberg-portal | v0.5.2 |
| vel.klingenbergbygg.no | kasse | tomtervel-portal-1 (podman, tomtervel/infrastructure) | v0.6.0, memberships |
Changelog
One line per change, grouped by the release that shipped it. Newest first. Each release names the portal tag it is built against.
Unreleased
needs.yamlmay name the site’stones, one storyline each; the checks read the first storyline, which is whatbuild_questionsgives.
0.5.10 (2026-09-30) - portal v0.5.10
- Matches portal v0.5.10 (released by portal’s publish job).
0.5.9 (2026-09-30) - portal v0.5.9
- Matches portal v0.5.9 (released by portal’s publish job).
0.5.8 (2026-09-30) - portal v0.5.8
- Matches portal v0.5.8 (released by portal’s publish job).
0.5.7 (2026-09-30) - portal v0.5.7
- Matches portal v0.5.7 (released by portal’s publish job).
Unreleased
- The effort budget counts what a person fills in: a
type: recordfield (the case an answer is about, carried on the link) and ahiddenpreset are not asked and no longer count. An objection page whose form carried the application’s id used to be one field over.
0.5.6 (2026-09-30) - portal v0.5.6
- Matches portal v0.5.6 (released by portal’s publish job).
0.5.5 (2026-09-30) - portal v0.5.5
- Matches portal v0.5.5 (released by portal’s publish job).
0.5.4 (2026-09-29) - portal v0.5.4
- Matches portal v0.5.4 (released by portal’s publish job).
0.5.3 (2026-09-29) - portal v0.5.3
- Matches portal v0.5.3 (released by portal’s publish job).
0.5.2 (2026-09-28) - portal v0.5.2
- Matches portal v0.5.2 (released by portal’s publish job).
0.5.1 (2026-09-28) - portal v0.5.1
- Matches portal v0.5.1 (released by portal’s publish job).
0.5.0 (2026-09-28) - portal v0.5.0
- Matches portal v0.5.0 (released by portal’s publish job).
0.4.0 (2026-09-24) - portal v0.4.0
- Matches portal v0.4.0 (released by portal’s publish job).
0.3.47 (2026-09-24) - portal v0.3.47
- Matches portal v0.3.47 (released by portal’s publish job).
0.3.46 (2026-09-23) - portal v0.3.46
- Matches portal v0.3.46 (released by portal’s publish job).
0.3.45 (2026-09-23) - portal v0.3.45
- Matches portal v0.3.45 (released by portal’s publish job).
0.3.44 (2026-09-23) - portal v0.3.44
- Matches portal v0.3.44 (released by portal’s publish job).
0.3.43 (2026-09-23) - portal v0.3.43
- Matches portal v0.3.43 (released by portal’s publish job).
0.3.42 (2026-09-23) - portal v0.3.42
- Matches portal v0.3.42 (released by portal’s publish job).
0.3.41 (2026-09-23) - portal v0.3.41
- Matches portal v0.3.41 (released by portal’s publish job).
0.3.39 (2026-09-23) - portal v0.3.39
- Matches portal v0.3.39 (released by portal’s publish job).
0.1.1 (2026-09-22) - portal v0.3.36
check --repofetches needs.yaml itself; the v0.1.0 tag never published (a cache permission on the runner, since fixed).
0.1.0 (2026-09-22) - portal v0.3.36
- First release: the lint moved out of portal (
question_lintbecomesiris check,needs_replaybecomesiris replay), with the business-needs pass, the wording task export and scoring, and the simulation model export. Portal is a library dependency pinned to the release iris matches.