Commit Graph
11 Commits
Author SHA1 Message Date
Bendik Aagaard LynghaugandClaude Sonnet 5 6932816c42 Lighten the comment load
Deploy / deploy (push) Successful in 1m1s
Drops porting-history narratives (dodrenett), superseded-behavior
explanations, and restatements of what the next line does. Constraint
notes (fail-closed policies, CAS semantics, cascade behavior, id
uniqueness) stay, just shorter. No code changes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-12 22:55:36 +02:00
Bendik Aagaard LynghaugandClaude Sonnet 5 20b24f08c2 Reseed lost event history from the KV projection on transition
Deploy / deploy (push) Successful in 1m0s
A record whose event log is empty (the EVENTS stream was purged, or
the record predates event-sourcing) but whose projection exists used
to be stranded - replay found nothing, every transition failed with
"unknown item". transition_or_reseed reconstructs a minimal one-event
history from the projection's own current state and retries, making
"zap the whole event stream" a recoverable operation by design instead
of a slow-motion data loss. Reseed races are guarded by the same
expected-sequence-0 CAS create uses.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-12 22:27:57 +02:00
Bendik Aagaard LynghaugandClaude Sonnet 5 40694a8f3f Transition.from: state graphs deeper than one decision
Deploy / deploy (push) Successful in 1m0s
Transitions now declare which state they fire from (default "open",
today's implicit behavior - existing content needs no edits). AnswerRow
renders only the buttons legal from a row's current state; two declared
transitions may share a target (open -> declined, in_dialogue ->
declined), so apply_transition matches on (from, to) against the row's
actual state instead of the old blanket "already decided" check.
validate_questions/question_lint reject a from naming an undeclared
state or a from -> to pair the bucket's graph never declared.

store_answer's KV projection now starts a record at its schema's
declared initial state, not a hardcoded "open" - organizations begin
at "prospect", and the read model has to agree with the event log
about that.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-12 22:03:43 +02:00
Bendik Aagaard LynghaugandClaude Sonnet 5 95ad459b24 One shared, content-labeled Confirm button per alternative instead of one per row
Deploy / deploy (push) Successful in 1m5s
Each AnswerRow used to carry its own ServerAction<TransitionAnswer> and
its own "Confirm" button - a page with N open rows showed N buttons.
Selecting a transition now just toggles an entry in a shared
pending_transitions map (keyed by feature_name+item_id) owned by
AlternativeCard; one button, labeled from the alternative's own
consequence field (same mechanism Subscribers' "Send" already used),
confirms every selection at once via the new batch transition_answers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-12 14:32:10 +02:00
Bendik Aagaard LynghaugandClaude Sonnet 5 84c3fad339 Make aggregate state graphs content-driven, not compiled Rust
Deploy / deploy (push) Successful in 59s
Six hand-maintained copies of the bucket->aggregate-type table
(4 per-type State enums, content::aggregate_type_for_bucket +
is_valid_transition_target, answers.rs's two dispatch matches,
backfill_events.rs's own migration table) collapse into one:
questions/aggregates.yaml, loaded and hot-swapped in AppState.aggregates
the same way AppState.questions already is. aggregates/mod.rs's
replay/create/transition now run off a runtime-loaded AggregateSchema
instead of a compile-time AggregateKind trait impl per type - the
CAS/JetStream mechanics underneath are unchanged. A new aggregate type
is now a content-only change, no portal deploy required.

backfill_events.rs is deleted outright rather than ported - its
migration job was already done and production data here is minimal
and expendable (the EVENTS stream gets purged by hand post-deploy).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 21:43:32 +02:00
Bendik Aagaard LynghaugandClaude Sonnet 5 aa18c69be4 Add Organization aggregate (client-as-status), wired but inert
Deploy / deploy (push) Successful in 34s
Prospect -> Client -> PastClient (Prospect -> PastClient allowed
directly too - a prospect that never converted). "Client" is a status
on Organization, not a separate aggregate, per the event-sourcing
plan's design decision. Wired into the same dispatch points as the
other three aggregates (content.rs's bucket map, answers.rs's
create/transition dispatch) so it's ready the moment content
references an "organizations" bucket - nothing does yet, so this lands
compiled and tested but with zero production surface.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 12:01:17 +02:00
Bendik Aagaard LynghaugandClaude Sonnet 5 ebf4bf91b3 Event-sourced applicant/subscriber/project aggregates, generalized resources
Deploy / deploy (push) Failing after 3s
Replaces the free-string, direct-KV-mutate state model in answers.rs
with a proper event log (events/store.rs, JetStream-backed, CAS via
expected_last_subject_sequence) and three pure state machines
(aggregates/{applicant,subscriber,project}.rs). Closes a real
lost-update race in the old transition_answer (concurrent decisions on
the same item could both win, publishing contradictory events). KV
buckets become best-effort read-model projections, not the source of
truth. Content-declared transition targets are now validated at
load/reload time against the real compiled transition tables, not
accepted as arbitrary strings.

Buckets renamed to describe their content, not their relation to the
app (portal_applicants -> applicants, etc); "inquiry" folded into a
richer "project" concept.

ResourceSpec generalized beyond a single KV bucket: Kv | GiteaStarred |
GiteaOrgRepos | Url sources, with an optional jq filter (via the jaq
crate) to shape live data for the frontend. Url source is SSRF-guarded
(https-only, rejects loopback/private/link-local, real DNS resolve).

New headless question_lint binary (validates content against compiled
transition tables with no NATS/OIDC/server involved) and a one-time
backfill_events binary (dry-run by default) for migrating existing KV
data onto the new event log.

Questions get an optional `responsible` contact plus a lightweight
"report this question" action.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 08:52:29 +02:00
Bendik Aagaard Lynghaug 213b1130bb Add self-service transitions, authorized by item possession not group membership
Deploy / deploy (push) Successful in 33s
New Alternative.self_transition: like ResourceSpec.transitions, but for
an anonymous visitor holding one specific item's own chain hash (from
a ?chain= link, the same reference /subscribed?chain=... already
carries) rather than a signed-in owner browsing a whole bucket. email
is a second factor checked against the stored item, not the lookup
key - defense in depth against a leaked/guessed chain hash alone.

AlternativeCard renders it as a single confirm button when both
?chain= and ?email= are present; QuestionView hides the alternative
entirely otherwise, rather than showing a dead card with nothing to
click.

Powers /subscribed's new "Unsubscribe" alternative, which the
newsletter's own recipient links now carry - no email lookup needed,
just the same chain_hash the subscription itself already produced.
2026-08-05 18:44:28 +02:00
Bendik Aagaard Lynghaug 6df895b1d3 Fix get_resource/transition_answer: scope feature lookup to its own alternative
Deploy / deploy (push) Successful in 28s
Both resolved a feature by name flattened across every alternative on
the question, not scoped to the one the caller actually meant - fine
when every question had exactly one alternative with a resource
feature, silently wrong now that /review has three (Applicants,
Inquiries, Subscribers all use the same empty feature name). Every
lookup always resolved to the first alternative's feature - Subscribers
was reading the (empty) Applicants bucket instead of its own, and
Invite/Decline/Mark-handled buttons would have had the same problem
had two of those transitions ever been clicked side by side.

Threaded a new `alternative` parameter through get_resource,
transition_answer, and their client-side callers
(ResourceFeature/ResourceValue/AnswerRow) - the fix lives entirely in
the already-generic resource-fetching machinery, no per-alternative
special-casing.
2026-08-05 15:04:02 +02:00
Bendik Aagaard Lynghaug 18025bf870 Hot-reload content on a NATS trigger instead of requiring a restart
Deploy / deploy (push) Successful in 29s
questions is now Arc<ArcSwap<HashMap<...>>> - readers do a lock-free
atomic load (state.questions.load().get(&id).cloned()), never blocking
on or blocked by a reload. content::watch_for_reload subscribes to
portal.content.reload (published by the questions repo's own CI after
it lints a push - see that repo's lint-and-reload.yml) and swaps in a
freshly re-fetched HashMap on each message. A fetch/parse failure logs
and keeps serving the last-good content rather than clearing it.
2026-08-05 07:20:40 +02:00
Bendik Aagaard Lynghaug aa1a7fa572 Initial commit: content-driven onboarding portal
Leptos/Axum app that renders a Question/Alternative/Feature schema
loaded from a sibling content repo (portal-content). Kanidm OIDC login,
content-driven authorization (Question.qualifies), a generic NATS
KV-backed resource + state-transition mechanism (no bespoke "applicant"
concept baked into the runtime - it's all content), a SHA-256 DAG chain
tying submissions and decisions together, and the "YES - Rasterized
Lines" piece (ported from the live uhhm.no site) as the landing hero.
2026-07-29 19:38:40 +02:00