`mail: { to, subject, body }` on a state in aggregates.yaml sends when a
record enters that state, `to: submitter` or one address, templates
over the record's fields plus {site}, {chain}, {email}. An invite mails
its one-time link built in, reworded by `mail.invite` in site.yaml,
which also carries the sender (`mail.from`, `mail.reply_to`); without
it a site sends nothing and says so in the log. Portal renders the
text and an HTML alternative and publishes one message per state
change on portal.mail.send in the WORMHOLE stream, id <bucket>:<record>
:<state> so a retried transition sends once; uhhm/gdo holds the
durable consumer and talks SMTP. The lint requires a required email
field on every form recording into a bucket that mails the submitter.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
people: the Kanidm person lookup reads the body (200 + null is a
missing person in Kanidm 1.11).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The existence check went by status alone, so every invitee looked like
an existing account: creation was skipped and the group step failed.
Read the body; `null` means nobody there.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Three releases went out with no section because the edit that moves
Unreleased under a version heading was a no-op when no Unreleased
section existed, and nothing checked. Now the publish job fails when
the tag has no section.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
Who may do what to what was five checks in five files: `qualifies` on
a page, `requires_group`/`public` on a resource, a desk's transition
allow-list, a self_transition's email check, the automation token.
They are now compiled from content on every load into one Cedar
policy set (src/access.rs), and every server entry point asks it: open
and submit a page, read a resource, move a record, fire a self
transition, read a bucket as the automation service, extend an
invite. Each decision names the rules that granted it; refusals and
every mutation are published on portal.access.decided, and
portal.access.<site>.may answers the same question for other apps
over request-reply (proven live against uhhm's content). The policy
validates against a fixed schema, so a content shape the compiler
cannot express fails instead of granting nothing. Identity is still
Kanidm's; the browser keeps is_qualified as a rendering hint only.
`invite: {group}` on an alternative of a gated page onboards a person:
src/people.rs creates the account, records the email, adds the group
and mints a one-time credential link through Kanidm's REST API with a
service-account token (KANIDM_API_TOKEN, in idm_people_on_boarding).
The result is recorded with the answer into the runtime's
portal_invites bucket and announced on portal.people.invited. Validated:
an invite needs a gated page and required name and email fields.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
iris is versioned as portal. On a tag push this job clones uhhm/iris,
re-pins its portal dependency to the tag, sets its version to match,
builds and tests it, writes the changelog line, and pushes the commit
and tag - which iris's own CI publishes. The tarball ships the binary
just built. A portal change to something the lint reads fails the
release at this step. Needs the IRIS_PUSH_USER variable and
IRIS_PUSH_TOKEN secret on this repo.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
question_lint, needs_replay, the needs module and the local-checkout
loaders now live in uhhm/iris, which depends on this crate as a
library pinned to a release tag. The tarball ships the matching iris
binary (IRIS_RELEASE in publish.yml) in place of question_lint, so a
site that pins nothing extra keeps a lint at current/iris. Portal
loses about 1,100 lines and keeps only what runs at request time.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
The 0.3.34 rule listed every followup "further down" from the current
page, so the front door offered the thank-you pages before anything
was submitted, and a followup one typed in offered the ones beyond
it. The chain half was weaker than it looked too: it matched the
question the chain answered, which is a front door anyway.
Now a followup is offered only when the visitor has earned it: their
verified chain answered the alternative that leads there (the chain
index records the alternative from here on; older nodes fall back to
every recording alternative of the answered question, and nothing
beyond it), or it hangs off an earned page by a link that records
nothing. A page behind an answer they have not given is never offered.
The walk ends at any front door, so "back to the start" never fans out.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
Forty releases carried only a version bump in their commit, so what
changed lived in the commits between them and in the content repos'
pin bumps. The changelog is reconstructed from that history, newest
first, with an Unreleased section on top and a table of which version
each of the four sites runs. The publish workflow now posts the tag's
section as the Gitea release notes, and the README says to move the
Unreleased lines under a version heading before cutting.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
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
content.rs was 2,373 lines doing five jobs. It is now a directory:
mod.rs keeps the types and the resolution rules (router, refs,
reachability, build_questions) and the tests; gitea.rs loads from
Gitea and watches for reload, through crate::http; validate.rs holds
the checks a repo must pass; markdown.rs the inline markdown;
handlers.rs the Axum routes. Each submodule is re-exported from
content, so no caller changes. The loader module is server-only and
gated as such. No behaviour change; 65 unit tests unchanged and green.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
Every outbound fetch - Gitea content at boot and on reload, the raw
asset proxy, the repo-info endpoint, the content-declared url resource
- built its own reqwest client with no timeout and read whole bodies
with .text(). `crate::http` is now the one client (5 s connect, 20 s
per request, 5 redirects) with capped body readers: 2 MB for a url
resource, 8 MB for a Gitea reply, 32 MB for a proxied asset; one byte
over is an error, never a truncated document.
The upload handler read a whole field into memory before comparing it
to the 20 MB cap, and axum's 2 MB default body limit had made that cap
unreachable anyway. It now reads chunk by chunk and refuses the moment
the cap is crossed; the /upload route raises the body limit to match.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
Roles overlap on one aggregate: a project manager prices a change, a
site manager completes it. A need may list the other groups whose
desks hold some of its moves; their buttons count toward reaching
`done_when` and toward the strand check exactly as `handled_by`'s do.
`handled_by` must still hold a desk of its own, so that someone is
answerable for the record.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
A simulation that lives in JSON never meets the engine. needs_replay
takes a simulation's cases and runs them through the code the site
runs: answers::store_answer for the submission, aggregates::transition
for each desk move, against a real JetStream with its event log and
its refusal of undeclared moves. Buckets are then read back by
replaying each record's events and reported per bucket against
`done_when` and `success`. `--report-only` takes the same scorecard
from any JetStream, a live site's included.
First run, 30 cases from a generated contractor site on a throwaway
in-memory NATS: 30 aggregates, 56 transitions accepted, none refused.
The same routes against the earlier machine that skipped a stage: 8
refused, 8 of 12 enquiries left in `open`.
The local-checkout loaders move from the lint binary to `portal::local`
so both tools share them.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
Found by simulating a generated site: the owner wrote "they come in,
we go and look at the building, we send a quote", and the generated
machine went open -> quoted. Nothing compared the machine to what the
business said, so it passed. A need may now list its stages as the
business describes them; each must be a state of the bucket and
reachable through the handling group's desk.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
A need may name which of its `done_when` states are a good outcome
for the business ("lost" is finished, "won" is success), checked to be
a subset of `done_when`.
`question_lint --needs-sim <model.json>` writes everything a
simulation of the business needs, resolved the way the runtime
resolves content: each need's path with the choice on every page and
the form that gets filled, each bucket's state machine with every
desk's labelled buttons, and the personas. Portal exports it because
only portal loads content correctly; the simulator (uhhm/portal-trainer)
reads plain JSON.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
A content repo may now state what the business needs the site to do,
apart from how the pages word it. question_lint checks each need
against the pages and aggregates.yaml:
- a path of submissions gets the answer into `lands_in`, within
`max_steps` and a required-field budget (`max_fields`);
- `handled_by` holds a desk over that bucket;
- every `done_when` state is reachable through the moves that desk
(or a self_transition) offers, and no record strands on the way;
- a disabled alternative is named as the reason when it is one;
- `planned: true` reports the gap as a warning instead of failing.
Personas - visitors in their own words - become typed `choice` tasks
(the page's question, its alternatives as the options) written as
JSONL by `--needs-tasks` (`--skim`: headings only). Any external
answering engine returns `{id, choice, confidence}` lines, graded by
`--needs-score [--min-accuracy]`: a misroute is a wording defect. The
engine stays outside this crate on purpose so classifiers and LLMs
can be compared on the same tasks. The runtime never reads the file.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
`list_qualifying_questions` reduced the visitor's `?chain=` to a
bool, so one followup path unlocked every followup on the site -
redoal's `/watching` (an email-link page whose header says "never in
the nav") showed up for anyone who had just subscribed.
The nav now resolves the chain hash to the one question it verifiably
answered (the same `lookup_node` a `requires_chain` gate uses) and
offers a followup only when it is on that path or further down from
the current page over `action` edges. `content::outcomes_reachable_from`
walks those edges through followup pages only, so a "back to the
start" action ends the walk at the front door instead of fanning out.
Templated actions (`/shape/{curve.key}`) resolve to their dynamic
page's pattern. No schema change: the `followup` flag keeps meaning
"not a front door", and the edges were already in the content.
Also: `main.loading`/`main.not-found` gets the same `svh` ladder as
`.hero-piece`, the last bare `vh` in the stylesheet, so wrapper
browsers that resize the webview as their toolbar animates don't
reflow the placeholder.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
Fonts hide behind the stylesheet - the browser parses CSS before it
finds them - so the latin Quicksand subset (every page's critical
path) is announced in the shell head; latin-ext/vietnamese stay
unicode-range-gated. The wordmark preloads from the resolved site
config, with a preconnect to its origin when it lives on the content
host (redoal.com's does), opening that TLS session early.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
A content repo can ship a favicon (svg/png/ico) named in site.yaml;
portal serves it at /site/<path> and injects a <link rel=icon> after
the static defaults so it wins. Also serves .ico assets.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
Was cropped to a 14rem band via object-fit: cover; now shows the whole
image at natural aspect, bounded at 32rem so a tall one can't tower.
The image deck keeps its fixed-height cards.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
Content-only image control: a markdown image title carries layout —
left/right to float text around it, a number for max-width in rem
(validated, capped) — translated to md-float-* classes and a numeric
inline max-width. No arbitrary CSS reaches the page. Portal also now
serves .avif site assets.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
A content-shipped colour logo (Klingenberg's red tile) was being
inverted to teal in light theme by the .svg heuristic. Invert is now
an explicit site.yaml flag defaulting to house-mark-only; content
logos keep their colours unless they opt in.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ