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>
This commit is contained in:
Bendik Aagaard Lynghaug
2026-08-12 22:03:43 +02:00
co-authored by Claude Sonnet 5
parent 35663a28c1
commit 40694a8f3f
3 changed files with 144 additions and 17 deletions
+25 -10
View File
@@ -63,12 +63,19 @@ pub async fn store_answer(
.await? .await?
} }
}; };
// A schema-backed bucket's projection must agree with its event
// log about where a record starts - organizations begin at
// "prospect", not "open". Only schemaless buckets default to open.
let initial_state = aggregates
.get(bucket)
.map(|s| s.initial.clone())
.unwrap_or_else(|| OPEN_STATE.to_string());
let answer = Answer { let answer = Answer {
id: id.clone(), id: id.clone(),
question_id: question_id.to_string(), question_id: question_id.to_string(),
alternative: alternative.to_string(), alternative: alternative.to_string(),
responses: responses.clone(), responses: responses.clone(),
state: OPEN_STATE.to_string(), state: initial_state,
submitted_ms, submitted_ms,
decided_ms: None, decided_ms: None,
decided_by: None, decided_by: None,
@@ -170,12 +177,6 @@ async fn apply_transition(
.resource .resource
.as_ref() .as_ref()
.ok_or_else(|| ServerFnError::new("feature has no resource"))?; .ok_or_else(|| ServerFnError::new("feature has no resource"))?;
let transition = resource
.transitions
.iter()
.find(|t| t.to == item.to)
.ok_or_else(|| ServerFnError::new("no such transition"))?
.clone();
let group = resource let group = resource
.requires_group .requires_group
.as_deref() .as_deref()
@@ -199,9 +200,23 @@ async fn apply_transition(
.ok_or_else(|| ServerFnError::new("unknown answer"))?; .ok_or_else(|| ServerFnError::new("unknown answer"))?;
let mut answer: Answer = let mut answer: Answer =
serde_json::from_slice(&bytes).map_err(|e| ServerFnError::new(e.to_string()))?; serde_json::from_slice(&bytes).map_err(|e| ServerFnError::new(e.to_string()))?;
if answer.state != OPEN_STATE { // Matched on (from, to), not to alone - two declared transitions
return Err(ServerFnError::new("already decided")); // may share a target (open -> declined and in_dialogue -> declined),
} // and only the one whose `from` is the row's actual current state
// is valid to fire. This also subsumes the old blanket
// "already decided" check: a row in a state no declared transition
// starts from simply has no legal move here.
let transition = resource
.transitions
.iter()
.find(|t| t.to == item.to && t.from == answer.state)
.ok_or_else(|| {
ServerFnError::new(format!(
"no transition to {:?} from this item's current state {:?}",
item.to, answer.state
))
})?
.clone();
let decided_ms = chrono::Utc::now().timestamp_millis(); let decided_ms = chrono::Utc::now().timestamp_millis();
let decision_payload_for_event = let decision_payload_for_event =
+24 -7
View File
@@ -1183,7 +1183,24 @@ fn AnswerRow(
pending_transitions: RwSignal<std::collections::HashMap<(String, String), String>>, pending_transitions: RwSignal<std::collections::HashMap<(String, String), String>>,
transition_batch: ServerAction<TransitionAnswers>, transition_batch: ServerAction<TransitionAnswers>,
) -> impl IntoView { ) -> impl IntoView {
let is_open = answer.state == crate::answers::OPEN_STATE; // Only the transitions legal from this row's current state - two
// declared transitions may share a target (open -> declined and
// in_dialogue -> declined), each rendered only on rows actually in
// its `from` state.
let available: Vec<Transition> = transitions
.iter()
.filter(|t| t.from == answer.state)
.cloned()
.collect();
// "Decided" (dimmed) means this row has no legal move here. A
// read-only listing (no transitions declared at all) keeps the old
// rule - dim anything past `open` - since "no legal move" would
// dim every row indiscriminately.
let decided = if transitions.is_empty() {
answer.state != crate::answers::OPEN_STATE
} else {
available.is_empty()
};
let responses = answer let responses = answer
.responses .responses
.as_object() .as_object()
@@ -1195,7 +1212,7 @@ fn AnswerRow(
let key = (feature_name.clone(), item_id.clone()); let key = (feature_name.clone(), item_id.clone());
view! { view! {
<article class="answer-row" class:decided=move || !is_open> <article class="answer-row" class:decided=move || decided>
<div class="answer-fields"> <div class="answer-fields">
<For <For
each={ each={
@@ -1220,19 +1237,19 @@ fn AnswerRow(
<span class="answer-time">{format_ms(submitted_ms)}</span> <span class="answer-time">{format_ms(submitted_ms)}</span>
</div> </div>
<Show when={ <Show when={
let transitions = transitions.clone(); let available = available.clone();
move || is_open && !transitions.is_empty() move || !available.is_empty()
}> }>
{ {
let key = key.clone(); let key = key.clone();
let transitions = transitions.clone(); let available = available.clone();
view! { view! {
<div class="answer-actions"> <div class="answer-actions">
<div class="select-options"> <div class="select-options">
<For <For
each={ each={
let transitions = transitions.clone(); let available = available.clone();
move || transitions.clone() move || available.clone()
} }
key=|t| t.to.clone() key=|t| t.to.clone()
children=move |t: Transition| { children=move |t: Transition| {
+95
View File
@@ -216,10 +216,20 @@ impl ResourceSpec {
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Transition { pub struct Transition {
/// The state a row must currently be in for this transition's
/// button to render and its server call to be accepted - defaults
/// to `"open"`, which is what every transition meant before graphs
/// grew deeper than one decision (invited -> active, etc.).
#[serde(default = "default_transition_from")]
pub from: String,
pub to: String, pub to: String,
pub label: String, pub label: String,
} }
fn default_transition_from() -> String {
crate::answers::OPEN_STATE.to_string()
}
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Requirement { pub struct Requirement {
pub name: String, pub name: String,
@@ -473,12 +483,24 @@ pub fn validate_questions(
continue; continue;
}; };
for transition in &resource.transitions { for transition in &resource.transitions {
if !schema.has_state(&transition.from) {
anyhow::bail!(
"question {:?} alternative {:?} feature {:?}: transition.from {:?} is not a declared state for bucket {:?}",
question.id, alternative.name, feature.name, transition.from, bucket
);
}
if !schema.has_state(&transition.to) { if !schema.has_state(&transition.to) {
anyhow::bail!( anyhow::bail!(
"question {:?} alternative {:?} feature {:?}: transition.to {:?} is not a declared state for bucket {:?}", "question {:?} alternative {:?} feature {:?}: transition.to {:?} is not a declared state for bucket {:?}",
question.id, alternative.name, feature.name, transition.to, bucket question.id, alternative.name, feature.name, transition.to, bucket
); );
} }
if !schema.allowed(&transition.from).iter().any(|s| s == &transition.to) {
anyhow::bail!(
"question {:?} alternative {:?} feature {:?}: transition {:?} -> {:?} is not a declared edge for bucket {:?}",
question.id, alternative.name, feature.name, transition.from, transition.to, bucket
);
}
} }
} }
} }
@@ -680,3 +702,76 @@ pub async fn automation_kv_handler(
} }
Ok(axum::Json(serde_json::Value::Array(items))) Ok(axum::Json(serde_json::Value::Array(items)))
} }
#[cfg(all(test, feature = "ssr"))]
mod tests {
use super::*;
fn schema_fixture() -> std::collections::HashMap<String, crate::aggregates::AggregateSchema> {
crate::aggregates::parse_aggregates_yaml(
r#"
aggregates:
- bucket: things
initial: open
states:
open: { event: opened }
middle: { event: advanced }
done: { event: finished }
transitions:
open: [middle]
middle: [done]
"#,
)
.unwrap()
}
fn question_with_transitions(transitions_yaml: &str) -> std::collections::HashMap<String, Question> {
let question: Question = serde_yaml::from_str(&format!(
r#"
id: /t
name: T
alternatives:
- name: A
features:
- name: ""
resource:
source: {{ kind: kv, bucket: things }}
requires_group: owners
transitions:
{transitions_yaml}
"#
))
.unwrap();
std::collections::HashMap::from([(question.id.clone(), question)])
}
#[test]
fn declared_edge_passes() {
let questions = question_with_transitions(
" - { from: open, to: middle, label: Advance }\n - { from: middle, to: done, label: Finish }",
);
assert!(validate_questions(&questions, &schema_fixture()).is_ok());
}
#[test]
fn from_defaults_to_open() {
let questions = question_with_transitions(" - { to: middle, label: Advance }");
assert!(validate_questions(&questions, &schema_fixture()).is_ok());
}
#[test]
fn undeclared_from_state_is_rejected() {
let questions = question_with_transitions(" - { from: bogus, to: middle, label: X }");
let err = validate_questions(&questions, &schema_fixture()).unwrap_err();
assert!(err.to_string().contains("transition.from"));
}
#[test]
fn undeclared_edge_is_rejected() {
// Both states exist, but open -> done skips a step the graph
// never declared.
let questions = question_with_transitions(" - { from: open, to: done, label: Skip }");
let err = validate_questions(&questions, &schema_fixture()).unwrap_err();
assert!(err.to_string().contains("not a declared edge"));
}
}