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?
}
};
// 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 =