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>
This commit is contained in:
Bendik Aagaard Lynghaug
2026-08-06 08:52:29 +02:00
co-authored by Claude Sonnet 5
parent 213b1130bb
commit ebf4bf91b3
16 changed files with 1811 additions and 67 deletions
+157 -20
View File
@@ -1,15 +1,15 @@
//! Durable, queryable storage for submitted answers - the KV-backed
//! counterpart to the ephemeral `events::AnswerSubmitted` every
//! submission already publishes. The runtime here has no notion of
//! "applicant" or any other specific workflow: an `Alternative.record_as`
//! names a NATS KV bucket (nothing more); on submit, the response gets
//! wrapped in the same envelope (`Answer`) regardless of what content
//! produced it. `resource::get_resource` reads it back generically, and
//! `transition_answer` below moves an answer between states named in
//! content (`ResourceSpec.transitions`), never states hardcoded here.
//! Whatever workflow this ends up powering - applicant review, order
//! triage, comment moderation - is entirely a property of some
//! `portal-content` YAML, not of this file.
//! Read-model storage for submitted answers - the KV-backed projection
//! of the durable event log now underneath it (`events::store`,
//! `aggregates`) for the three buckets migrated onto that machinery
//! this pass (`content::aggregate_type_for_bucket`). An
//! `Alternative.record_as`/`ResourceSpec`'s bucket is still just a
//! bucket name as far as `resource::get_resource` is concerned - this
//! file is where that name additionally gets checked against a real,
//! Rust-defined transition table, for the buckets that have one.
//! A bucket outside that three-entry map still works exactly as
//! before (direct KV mutate-in-place, no event log, no CAS) - this is
//! deliberately not a hard cutover, so content isn't forced to migrate
//! before it needs to.
use leptos::prelude::*;
use serde::{Deserialize, Serialize};
@@ -71,7 +71,30 @@ pub async fn store_answer(
decided_by: None,
};
let data = serde_json::to_vec(&answer)?;
store.put(id, data.into()).await?;
store.put(id.clone(), data.into()).await?;
// Also seed the event log for the three aggregate types migrated
// onto it this pass - best-effort, same "a hiccup here shouldn't
// fail a submission the NATS notification event has already
// recorded" policy as the KV write above.
if let Some(agg_type) = crate::content::aggregate_type_for_bucket(bucket) {
use crate::aggregates::{applicant, create, project, subscriber};
let seed = match agg_type {
"applicant" => create::<applicant::State>(js, &id, responses.clone(), submitted_ms)
.await
.map(|_| ()),
"subscriber" => create::<subscriber::State>(js, &id, responses.clone(), submitted_ms)
.await
.map(|_| ()),
"project" => create::<project::State>(js, &id, responses.clone(), submitted_ms)
.await
.map(|_| ()),
_ => Ok(()),
};
if let Err(e) = seed {
tracing::error!(bucket, id, error = %e, "failed to seed event log for submission");
}
}
Ok(())
}
@@ -138,9 +161,12 @@ pub async fn transition_answer(
return Err(ServerFnError::new("not authorized"));
}
let bucket = resource
.bucket()
.ok_or_else(|| ServerFnError::new("resource is not kv-backed, has no transitions"))?;
let store = state
.jetstream
.get_key_value(&resource.bucket)
.get_key_value(bucket)
.await
.map_err(|e| ServerFnError::new(format!("resource bucket unavailable: {e}")))?;
let bytes = store
@@ -155,6 +181,22 @@ pub async fn transition_answer(
}
let decided_ms = chrono::Utc::now().timestamp_millis();
let decision_payload_for_event =
serde_json::json!({ "to": to, "item": item_id, "by": user.username });
// For the three buckets migrated onto the event log this pass, the
// real fix for the lost-update race two concurrent decisions on the
// same item used to hit: append with CAS on the aggregate's
// just-replayed sequence, so a second racing caller's write is
// rejected instead of silently overwriting the first. A bucket
// outside that map falls back to the direct KV mutate this always
// did - not every resource has to be migrated to keep working.
if let Some(agg_type) = crate::content::aggregate_type_for_bucket(bucket) {
transition_by_aggregate(&state.jetstream, agg_type, &item_id, &to, decision_payload_for_event.clone(), decided_ms)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
}
answer.state = to.clone();
answer.decided_ms = Some(decided_ms);
answer.decided_by = Some(user.username.clone());
@@ -169,15 +211,14 @@ pub async fn transition_answer(
// submission hash, published the same way any other answer is -
// question_id/alternative come from this call's own arguments and
// the content-declared transition label, never a hardcoded value.
let decision_payload = serde_json::json!({ "to": to, "item": item_id, "by": user.username });
let parent_hashes = vec![item_id.clone()];
let chain_hash = hash_node(&question_id, &parent_hashes, &decision_payload, decided_ms);
let chain_hash = hash_node(&question_id, &parent_hashes, &decision_payload_for_event, decided_ms);
let event = AnswerSubmitted {
chain_hash,
parent_hashes,
question_id,
alternative: transition.label,
responses: decision_payload,
responses: decision_payload_for_event,
timestamp_ms: decided_ms,
};
emit_answer_submitted(&state.nats, &event)
@@ -187,6 +228,43 @@ pub async fn transition_answer(
Ok(())
}
/// Dispatches to the right aggregate type's `transition()` by name -
/// the one place `answers.rs` has to know the fixed, three-entry set of
/// migrated aggregate types (`content::aggregate_type_for_bucket`'s
/// counterpart), since `crate::aggregates` itself stays generic over
/// any `AggregateKind` and has no list of its own to dispatch through.
#[cfg(feature = "ssr")]
async fn transition_by_aggregate(
js: &async_nats::jetstream::Context,
aggregate_type: &str,
item_id: &str,
to: &str,
payload: serde_json::Value,
occurred_at_ms: i64,
) -> Result<(), crate::aggregates::TransitionError> {
use crate::aggregates::{applicant, project, subscriber, transition, AggregateKind, TransitionError};
macro_rules! dispatch {
($state:ty) => {{
let target = <$state>::from_event_type(to).ok_or_else(|| {
TransitionError::DisallowedTransition {
from: "?".to_string(),
to: to.to_string(),
}
})?;
transition::<$state>(js, item_id, target, payload, occurred_at_ms).await?;
}};
}
match aggregate_type {
"applicant" => dispatch!(applicant::State),
"subscriber" => dispatch!(subscriber::State),
"project" => dispatch!(project::State),
_ => return Err(TransitionError::UnknownAggregate),
}
Ok(())
}
/// The self-service counterpart to `transition_answer`: no signed-in
/// session, no group check - authorized instead by already holding
/// `item_id` (a chain hash, opaque and unguessable) plus a matching
@@ -244,6 +322,22 @@ pub async fn self_transition_answer(
}
let decided_ms = chrono::Utc::now().timestamp_millis();
let decision_payload_for_event =
serde_json::json!({ "to": self_transition.to, "item": item_id, "by": email });
if let Some(agg_type) = crate::content::aggregate_type_for_bucket(&self_transition.bucket) {
transition_by_aggregate(
&state.jetstream,
agg_type,
&item_id,
&self_transition.to,
decision_payload_for_event.clone(),
decided_ms,
)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
}
answer.state = self_transition.to.clone();
answer.decided_ms = Some(decided_ms);
answer.decided_by = Some(email.clone());
@@ -254,15 +348,14 @@ pub async fn self_transition_answer(
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
let decision_payload = serde_json::json!({ "to": self_transition.to, "item": item_id, "by": email });
let parent_hashes = vec![item_id.clone()];
let chain_hash = hash_node(&question_id, &parent_hashes, &decision_payload, decided_ms);
let chain_hash = hash_node(&question_id, &parent_hashes, &decision_payload_for_event, decided_ms);
let event = AnswerSubmitted {
chain_hash,
parent_hashes,
question_id,
alternative: self_transition.label,
responses: decision_payload,
responses: decision_payload_for_event,
timestamp_ms: decided_ms,
};
emit_answer_submitted(&state.nats, &event)
@@ -271,3 +364,47 @@ pub async fn self_transition_answer(
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
/// `app.rs`'s list-rendering path only shows the nice
/// button-per-transition UI if a resource's raw JSON value
/// deserializes as `Vec<Answer>` - otherwise it silently falls
/// back to a plain read-only dump with no error. Guards that shape
/// contract directly, since nothing else would catch a drift here.
#[test]
fn answer_list_round_trips_through_the_shape_app_rs_expects() {
let answers = vec![
Answer {
id: "abc123".to_string(),
question_id: "/review".to_string(),
alternative: "Applicants".to_string(),
responses: serde_json::json!({"name": "Ada"}),
state: OPEN_STATE.to_string(),
submitted_ms: 1_700_000_000_000,
decided_ms: None,
decided_by: None,
},
Answer {
id: "def456".to_string(),
question_id: "/review".to_string(),
alternative: "Applicants".to_string(),
responses: serde_json::json!({"name": "Grace"}),
state: "invited".to_string(),
submitted_ms: 1_700_000_001_000,
decided_ms: Some(1_700_000_002_000),
decided_by: Some("bl".to_string()),
},
];
let value = serde_json::Value::Array(
answers.iter().map(|a| serde_json::to_value(a).unwrap()).collect(),
);
let round_tripped: Vec<Answer> =
serde_json::from_value(value).expect("Answer list round-trips");
assert_eq!(round_tripped.len(), 2);
assert_eq!(round_tripped[1].state, "invited");
assert_eq!(round_tripped[1].decided_by.as_deref(), Some("bl"));
}
}