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.