Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 122 additions & 6 deletions crates/traverse-registry/src/application_manifest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,32 @@ pub struct ApplicationStateInvoke {
pub struct ApplicationStateTransition {
pub on: String,
pub to: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub condition: Option<ApplicationStateTransitionCondition>,
pub with_last_payload: bool,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ApplicationStateTransitionCondition {
pub field: String,
pub op: ApplicationStateTransitionConditionOp,
#[serde(skip_serializing_if = "Option::is_none")]
pub value: Option<Value>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ApplicationStateTransitionConditionOp {
Eq,
Neq,
Gt,
Gte,
Lt,
Lte,
In,
Exists,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ApplicationRegistryRecord {
pub scope: RegistryScope,
Expand Down Expand Up @@ -496,9 +519,19 @@ struct ApplicationStateTransitionSerde {
on: String,
to: String,
#[serde(default)]
condition: Option<ApplicationStateTransitionConditionSerde>,
#[serde(default)]
with_last_payload: bool,
}

#[derive(Debug, Deserialize)]
struct ApplicationStateTransitionConditionSerde {
field: String,
op: String,
#[serde(default)]
value: Option<Value>,
}

#[derive(Debug, Deserialize)]
struct WorkspaceLocalConfigSerde {
#[serde(default)]
Expand Down Expand Up @@ -1101,15 +1134,98 @@ fn materialize_state_machine_state(
transitions: state
.transitions
.iter()
.map(|transition| ApplicationStateTransition {
on: transition.on.clone(),
to: transition.to.clone(),
with_last_payload: transition.with_last_payload,
})
.collect(),
.map(|transition| materialize_state_machine_transition(&state.id, transition))
.collect::<Result<Vec<_>, _>>()?,
})
}

fn materialize_state_machine_transition(
state_id: &str,
transition: &ApplicationStateTransitionSerde,
) -> Result<ApplicationStateTransition, ApplicationManifestFailure> {
Ok(ApplicationStateTransition {
on: transition.on.clone(),
to: transition.to.clone(),
condition: transition
.condition
.as_ref()
.map(|condition| materialize_transition_condition(state_id, transition, condition))
.transpose()?,
with_last_payload: transition.with_last_payload,
})
}

fn materialize_transition_condition(
state_id: &str,
transition: &ApplicationStateTransitionSerde,
condition: &ApplicationStateTransitionConditionSerde,
) -> Result<ApplicationStateTransitionCondition, ApplicationManifestFailure> {
let field = condition.field.trim();
if !valid_condition_field_path(field) {
return Err(single_error(
ApplicationManifestErrorCode::AppStateMachineInvalid,
format!(
"state_machine.states.{state_id}.transitions.{}.condition.field",
transition.on
),
"transition condition field must be a non-empty output.* path".to_string(),
));
}

let op = parse_transition_condition_op(&condition.op).ok_or_else(|| {
single_error(
ApplicationManifestErrorCode::AppStateMachineInvalid,
format!(
"state_machine.states.{state_id}.transitions.{}.condition.op",
transition.on
),
format!(
"unsupported transition condition operator '{}'",
condition.op
),
)
})?;
if op != ApplicationStateTransitionConditionOp::Exists && condition.value.is_none() {
return Err(single_error(
ApplicationManifestErrorCode::AppStateMachineInvalid,
format!(
"state_machine.states.{state_id}.transitions.{}.condition.value",
transition.on
),
format!(
"transition condition operator '{}' requires value",
condition.op
),
));
}

Ok(ApplicationStateTransitionCondition {
field: field.to_string(),
op,
value: condition.value.clone(),
})
}

fn parse_transition_condition_op(raw: &str) -> Option<ApplicationStateTransitionConditionOp> {
match raw {
"eq" => Some(ApplicationStateTransitionConditionOp::Eq),
"neq" => Some(ApplicationStateTransitionConditionOp::Neq),
"gt" => Some(ApplicationStateTransitionConditionOp::Gt),
"gte" => Some(ApplicationStateTransitionConditionOp::Gte),
"lt" => Some(ApplicationStateTransitionConditionOp::Lt),
"lte" => Some(ApplicationStateTransitionConditionOp::Lte),
"in" => Some(ApplicationStateTransitionConditionOp::In),
"exists" => Some(ApplicationStateTransitionConditionOp::Exists),
_ => None,
}
}

fn valid_condition_field_path(field: &str) -> bool {
field
.strip_prefix("output.")
.is_some_and(|suffix| suffix.split('.').all(has_text))
}

fn load_effective_config(
manifest_dir: &Path,
manifest: &ApplicationManifestSerde,
Expand Down
162 changes: 158 additions & 4 deletions crates/traverse-registry/tests/application_manifest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ use std::time::{SystemTime, UNIX_EPOCH};
use traverse_contracts::{ExecutionTarget, parse_event_contract};
use traverse_registry::{
ApplicationManifestErrorCode, ApplicationRegistrationErrorCode, ApplicationRegistrationRequest,
ApplicationRegistrationStatus, ApplicationRegistry, CapabilityRegistry, EventRegistration,
EventRegistry, LookupScope, ModelCandidateRejectionCode, ModelResolutionEvidence,
ModelResolutionPhase, RegistryScope, SelectedModelCandidate, WorkflowRegistry,
load_application_bundle_manifest,
ApplicationRegistrationStatus, ApplicationRegistry, ApplicationStateTransitionConditionOp,
CapabilityRegistry, EventRegistration, EventRegistry, LookupScope, ModelCandidateRejectionCode,
ModelResolutionEvidence, ModelResolutionPhase, RegistryScope, SelectedModelCandidate,
WorkflowRegistry, load_application_bundle_manifest,
};

#[test]
Expand Down Expand Up @@ -190,6 +190,160 @@ fn loads_valid_application_state_machine() {
assert!(state_machine.states[0].transitions[0].with_last_payload);
}

#[test]
fn loads_conditional_application_state_machine_transitions() {
let fixture = AppFixture::new("conditional-state-machine");
let wasm_digest = fixture.write_wasm("component bytes");
fixture.write_component_manifest(&json!({
"wasm_digest": format!("sha256:{wasm_digest}"),
"dependencies": []
}));
fixture.write_app_manifest_with_state_machine(
&json!([component_ref(
"expedition.readiness.validate-team-readiness-component",
"1.0.0",
&format!("sha256:{wasm_digest}"),
"components/validate-team-readiness/component.manifest.json",
)]),
&json!({
"initial_state": "extracting",
"states": [
{
"id": "extracting",
"invoke": {
"capability_id": "expedition.planning.validate-team-readiness",
"input_from": "command.payload"
},
"transitions": [
{
"on": "capability_succeeded",
"condition": {
"field": "output.confidence_score",
"op": "gte",
"value": 0.85
},
"to": "auto_approved"
},
{
"on": "capability_succeeded",
"condition": {
"field": "output.confidence_score",
"op": "lt",
"value": 0.85
},
"to": "pending_review"
},
{
"on": "capability_failed",
"condition": {
"field": "output.error_code",
"op": "exists"
},
"to": "extraction_error"
}
]
},
{"id": "auto_approved", "transitions": []},
{"id": "pending_review", "transitions": []},
{"id": "extraction_error", "transitions": []}
]
}),
);

let bundle = load_application_bundle_manifest(&fixture.app_manifest_path())
.expect("conditional state machine should validate");
let transitions = &bundle
.state_machine
.expect("state machine should be materialized")
.states[0]
.transitions;

assert_eq!(transitions[0].to, "auto_approved");
let condition = transitions[0]
.condition
.as_ref()
.expect("success transition should be conditional");
assert_eq!(condition.field, "output.confidence_score");
assert_eq!(condition.op, ApplicationStateTransitionConditionOp::Gte);
assert_eq!(condition.value, Some(json!(0.85)));
assert_eq!(
transitions[2]
.condition
.as_ref()
.expect("error transition should check existence")
.op,
ApplicationStateTransitionConditionOp::Exists
);
}

#[test]
fn rejects_invalid_conditional_transition_shapes() {
let cases = [
(
"condition-private-field",
json!({
"field": "session.context.confidence_score",
"op": "gte",
"value": 0.85
}),
),
(
"condition-empty-path-segment",
json!({
"field": "output..confidence_score",
"op": "gte",
"value": 0.85
}),
),
(
"condition-unknown-op",
json!({
"field": "output.confidence_score",
"op": "between",
"value": [0.5, 0.85]
}),
),
(
"condition-missing-value",
json!({
"field": "output.confidence_score",
"op": "gte"
}),
),
];

for (name, condition) in cases {
let fixture = AppFixture::new(name);
fixture.write_app_manifest_with_state_machine(
&json!([]),
&json!({
"initial_state": "extracting",
"states": [
{
"id": "extracting",
"transitions": [
{
"on": "capability_succeeded",
"condition": condition,
"to": "done"
}
]
},
{"id": "done", "transitions": []}
]
}),
);

let failure = load_application_bundle_manifest(&fixture.app_manifest_path())
.expect_err("invalid transition condition should fail");

assert_eq!(
failure.errors[0].code,
ApplicationManifestErrorCode::AppStateMachineInvalid
);
}
}

#[test]
fn rejects_invalid_application_state_machine_shapes() {
let cases = [
Expand Down
56 changes: 56 additions & 0 deletions specs/053-conditional-state-transitions/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Feature Specification: Conditional State Transitions

**Feature Branch**: `053-conditional-state-transitions`
**Created**: 2026-07-05
**Status**: Approved
**Input**: GitHub issue #536, extending the state-machine manifest slice approved in `052-app-state-machine`.

## Purpose

Traverse application manifests MAY route `capability_succeeded` transitions by evaluating deterministic predicates against the capability output. This lets a runtime-owned app state machine branch without duplicating transition logic in clients.

## Requirements

- **FR-001**: A transition MAY include a `condition` object.
- **FR-002**: `condition.field` MUST be a non-empty dot path rooted at `output`.
- **FR-003**: `condition.op` MUST be one of `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `in`, or `exists`.
- **FR-004**: Operators other than `exists` MUST include `condition.value`.
- **FR-005**: Transitions with the same `on` event MUST be evaluated in manifest order; the first matching condition wins.
- **FR-006**: A transition without `condition` is an unconditional fallback and MUST be evaluated after conditional transitions for the same `on` event.
- **FR-007**: If no condition matches a `capability_succeeded` event, runtime execution MUST emit a structured `no_matching_transition` error event and leave the session state unchanged.
- **FR-008**: Type mismatch during condition evaluation MUST emit a structured `condition_type_error` event and leave the session state unchanged.
- **FR-009**: `traverse-cli app validate` MUST reject malformed condition schemas before app registration.

## Schema Shape

```json
{
"on": "capability_succeeded",
"condition": {
"field": "output.confidence_score",
"op": "gte",
"value": 0.85
},
"to": "auto_approved"
}
```

## Operator Semantics

| Operator | Meaning |
| --- | --- |
| `eq` | field value equals `value` |
| `neq` | field value does not equal `value` |
| `gt` | field value is greater than `value` |
| `gte` | field value is greater than or equal to `value` |
| `lt` | field value is less than `value` |
| `lte` | field value is less than or equal to `value` |
| `in` | field value is contained in array `value` |
| `exists` | field exists and is not null |

## Out of Scope

- HTTP command dispatch endpoints.
- HTTP state subscription endpoints.
- Durable execution of state-machine sessions.
- Capability output contract introspection warnings for condition paths.
Loading
Loading