Until now nothing outside a live site exercised the NATS side: the aggregate engine was tested by in-memory replay only. This test submits an answer, moves it from a desk through the engine, checks that an undeclared move and an unknown record are refused, and reads the record back from its own event log. It runs when PORTAL_TEST_NATS_URL is set; CI starts a throwaway nats:2.14.6-alpine with JetStream for it and tears it down. Without the variable the test passes as skipped, so `cargo test` stays green offline. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
portal
A content-driven question engine: one Rust binary that turns a Git repo of YAML into a live site — pages, forms, review desks, and state machines, with a durable event-sourced record of every answer underneath. The engine special-cases nothing: everything a site serves is declared in its content repo, and the same build serves any number of sites.
Point an instance at a content repo and that repo is the site:
pages, copy, state graphs, branding, and even the landing hero
(site.yaml: title, wordmark, and hero: {kind: module, module: hero.js} — a JavaScript module the content repo ships, served
same-origin at /site/<path>, that portal mounts at parse time and
stops on navigation; the YES canvas and redoal's sine swings are both
content, not engine). Content
pushes hot-reload every running instance; instances differ only by
env vars. uhhm.no
(uhhm/questions) and
redoal.com
(redoal/questions) are
two faces of the same binary, deployed from the same release
artifact.
It composes with the surrounding stack rather than bundling it: Gitea hosts and serves the content, NATS JetStream stores events and projections, Kanidm provides identity, and anything downstream (automations, newsletters, onboarding) subscribes to the answer stream.
What the engine provides
- Content-driven pages (
src/content.rs,src/app.rs): YAML loaded from Gitea at boot and hot-swapped on a NATS reload signal; a bad push keeps the last-good content serving. Thequestions/tree IS the router — file paths become URLs,_section.yamlapplies criteria to a whole directory,[name].yamlpages serve any/dir/<value>with the segment fed into resource keys, andrequires_chaingates a page on verifiable answer provenance next toqualifies' Kanidm-group identity gate (seedocs/design/filesystem-routes.md). - Gesture and voice fields (
gesture.js,voice.js): a stroke becomes a redoal gesture key through a relay (ADR-0013/0015 in the redoal repo) - the input shows the key's own decode under the stroke, who is at a similar shape now, and places with recordings the visitor can pick to make that key theirs; a voice field records in the browser and leaves the audio at a key. Actions may be templated from the answer (/shape/{curve.key}) onto dynamic pages, whose URL segment also fillsvalue: "{key}"presets andurlresource sources; resource items with an httpsaudiofield render a player. - Announced pages (
src/announce.rs): a question with anevent: {starts, duration, place}window is announced in the header (not the footer nav) while the window is open, becomes a followup when it closes, and its record in the runtime-ownedportal_eventsbucket moves toawaiting_summary- a "post what happened" task any review desk can read, published on NATS like a decision. A one-minute sweeper keeps it all idempotent. - State machines as content (
src/aggregates/):aggregates.yamldeclares each bucket's states and legal transitions; the engine replays a record's event history and refuses undeclared moves, with optimistic concurrency (CAS on the event log's sequence) against racing decisions. An empty history reseeds from the KV projection, so wiping the event stream strands nothing. - Durable events + projections (
src/events/,src/answers.rs): every submission and decision appends to a JetStream event log and projects into a NATS KV bucket pages read back; every one also publishes onportal.answers.submittedfor automations (n8n) to react to. - Answer chains (
src/chain.rs): each submission hashes its parent(s), so a visitor's path through the questions is a verifiable lineage;?chain=links carry it, and self-service transitions (unsubscribe) authorize by holding one. The question nav reads it too: afollowuppage is offered only when it is on that path (the page the chain verifiably answered) or further down from the current page overactionedges - one followup path never unlocks another. - Business needs as a checkable spec (
src/needs.rs,question_lint): a content repo may ship aneeds.yamlsaying who wants what, which bucket the answer must land in, and which group has to carry it to a finished state. The lint proves the structure (a path of submissions within an effort budget, a desk held by that group, every finished state reachable, no record stranding; aplannedneed warns instead of failing). Its personas - visitors in their own words - become typedchoicetasks for any external answering engine (--needs-tasks,--skimfor headings only) whose picks are graded with--needs-score: a misroute is a wording defect. The runtime never reads the file. - Live resources (
src/resource.rs): content can pull a KV bucket, Gitea starred/org repos/releases, or any public JSON URL (SSRF fail-closed), reshaped by a content-declaredjqfilter. - Input kinds as content: a requirement's
type:picks the widget — plain fields, a rich-text editor, or agesturedrawing canvas that can connect to a redoal relay so similar strokes echo between visitors live. Submitted values are arbitrary JSON; the engine records what the widget produced. - Review desks: any Kv resource with
transitionsrenders rows with per-state action buttons and one shared confirm per alternative — owners walk records through their graphs without bespoke UI per bucket.
Binaries
portal— the server (Leptos SSR + hydrate, Axum underneath).question_lint— headless content validation, shipped inside every release artifact so each content repo's CI lints with the exact portal version its instance runs; also works offline:question_lint --path <dir>.
Development
cargo leptos build # full app (server + wasm)
cargo test --features ssr # engine tests
cargo build --features ssr --bin question_lint
Runtime configuration is env vars (see src/main.rs, and each
content repo's .gitea/workflows/deploy.yml for the values in
production): NATS_URL, CONTENT_REPO/CONTENT_BRANCH, Kanidm OIDC
(KANIDM_URL, OAUTH2_CLIENT_*), optional GARAGE_* for uploads,
GITEA_API_TOKEN for authenticated resource pulls,
AUTOMATION_READ_TOKEN for the automation KV read endpoint.
Release and deploy
Portal doesn't deploy itself — it publishes versions, and each site decides when to take one. The whole day-to-day surface is two commands:
Cut a release (here):
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 + question_lint +
hash.txt + site/) to the Gitea release. Pkg files are
content-hashed (hash-files = true; instances run with
LEPTOS_HASH_FILES=true), so a freshly served page can never pair a
stale cached bundle with new wasm. Plain pushes to main only run the
tests (test.yml) — nothing reaches production from this repo.
Roll it out (in a content repo): edit one line in that repo's
.gitea/workflows/deploy.yml —
env:
PORTAL_RELEASE: v0.1.1 # <- bump, commit, push
Its CI downloads the pinned artifact, ships
/srv/app/<instance>/releases/<tag>, flips current, rewrites the
instance env from that repo's own Actions variables/secrets, restarts
app@<instance>, and refreshes the Caddy route. Every rollout is a
commit, so reverting a bad version is git revert + push. Sites
upgrade independently: uhhm.no (uhhm/questions → app@uhhm-portal,
:3010) and redoal.com (redoal/questions → app@redoal-portal,
:3020) can pin different versions.
Content changes never come through any of this — they hot-reload live from the content repos over NATS.
Add a site: new content repo with a copy of an existing
deploy.yml (change INSTANCE, port, domains), Actions variables
(KANIDM_URL, OAUTH2_CLIENT_ID, PUBLIC_URL, SITE_NAME) and
secrets (NATS_URL, OAUTH2_CLIENT_SECRET,
PORTAL_GITEA_API_TOKEN — Gitea reserves the GITEA_ prefix for
secret names), a Kanidm OAuth2 client, and DNS. site.yaml in the
content repo handles all branding; no portal changes needed.