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
+95
View File
@@ -216,10 +216,20 @@ impl ResourceSpec {
#[derive(Clone, Debug, Serialize, Deserialize)]
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 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<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"));
}
}