From 40694a8f3f4de99f85a7a78dca155b25af94fe44 Mon Sep 17 00:00:00 2001 From: Bendik Aagaard Lynghaug Date: Wed, 12 Aug 2026 22:03:43 +0200 Subject: [PATCH] Transition.from: state graphs deeper than one decision 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 --- src/answers.rs | 35 +++++++++++++------ src/app.rs | 31 ++++++++++++---- src/content.rs | 95 ++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 144 insertions(+), 17 deletions(-) diff --git a/src/answers.rs b/src/answers.rs index 3c9e315..37f4419 100644 --- a/src/answers.rs +++ b/src/answers.rs @@ -63,12 +63,19 @@ pub async fn store_answer( .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 { id: id.clone(), question_id: question_id.to_string(), alternative: alternative.to_string(), responses: responses.clone(), - state: OPEN_STATE.to_string(), + state: initial_state, submitted_ms, decided_ms: None, decided_by: None, @@ -170,12 +177,6 @@ async fn apply_transition( .resource .as_ref() .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 .requires_group .as_deref() @@ -199,9 +200,23 @@ async fn apply_transition( .ok_or_else(|| ServerFnError::new("unknown answer"))?; let mut answer: Answer = serde_json::from_slice(&bytes).map_err(|e| ServerFnError::new(e.to_string()))?; - if answer.state != OPEN_STATE { - return Err(ServerFnError::new("already decided")); - } + // Matched on (from, to), not to alone - two declared transitions + // 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 decision_payload_for_event = diff --git a/src/app.rs b/src/app.rs index 7c44e77..a3f7610 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1183,7 +1183,24 @@ fn AnswerRow( pending_transitions: RwSignal>, transition_batch: ServerAction, ) -> 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 = 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 .responses .as_object() @@ -1195,7 +1212,7 @@ fn AnswerRow( let key = (feature_name.clone(), item_id.clone()); view! { -
+
{format_ms(submitted_ms)}
{ let key = key.clone(); - let transitions = transitions.clone(); + let available = available.clone(); view! {
active, etc.). + #[serde(default = "default_transition_from")] + pub from: String, pub to: String, pub label: String, } +fn default_transition_from() -> String { + crate::answers::OPEN_STATE.to_string() +} + #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Requirement { pub name: String, @@ -473,12 +483,24 @@ pub fn validate_questions( continue; }; 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) { anyhow::bail!( "question {:?} alternative {:?} feature {:?}: transition.to {:?} is not a declared state for 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))) } + +#[cfg(all(test, feature = "ssr"))] +mod tests { + use super::*; + + fn schema_fixture() -> std::collections::HashMap { + 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 { + 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")); + } +}