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:
co-authored by
Claude Sonnet 5
parent
35663a28c1
commit
40694a8f3f
+25
-10
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user