Skip to content

POST /approvals/{approval_id}/resume returns 500 for both approved and rejected decisions: assignment to read-only ProposalHandlingFlow.state #67

Description

@jaanijuk

Summary

POST /approvals/{approval_id}/resume is the documented mechanism for completing a proposal-handling flow after a human decision. It returned an unstructured HTTP 500 for both decision types tested, because _resume_proposal_flow assigns to ProposalHandlingFlow.state, which is a read-only property in the lock-resolved CrewAI 1.15.2.

Approval decisions themselves are unaffected: they persist correctly, stamp the verified principal, and emit the expected events. What fails is the step that applies the decision to the flow.

Observed

Against v2.4.2 (e5b367d), with APPROVAL_GATE_ENABLED=true and APPROVAL_REQUIRED_FLOWS=proposal_decision, two gated proposals were created and decided — one approved, one rejected. Both decisions succeeded. Both resume calls returned:

HTTP 500
Internal Server Error

Server traceback, identical for both:

File "src/ad_seller/interfaces/api/routers/approvals.py", line 78, in resume_flow
  return await approval_service.resume_flow(approval_id)
File "src/ad_seller/services/approval_service.py", line 108, in resume_flow
  return await _resume_proposal_flow(request, response)
File "src/ad_seller/services/approval_service.py", line 129, in _resume_proposal_flow
  flow.state = ProposalState(**snapshot)
File ".venv/lib/python3.12/site-packages/crewai/flow/runtime/__init__.py", line 747, in __setattr__
  object.__setattr__(self, name, value)
AttributeError: property 'state' of 'ProposalHandlingFlow' object has no setter

The line in question, approval_service.py:128-129:

flow = ProposalHandlingFlow()
flow.state = ProposalState(**snapshot)

This is the only assignment to .state on a flow object anywhere in src/.

Observed — the endpoint is the documented completion path

The router docstring states its purpose plainly (approvals.py:72-77):

Loads the flow state snapshot, applies the decision, and returns the final result without re-running expensive crew evaluations.

_resume_proposal_flow is where the decision is actually applied — appending to accepted_proposals or rejected_proposals and setting ExecutionStatus. None of that is reached.

Observed — existing test coverage cannot detect this

tests/unit/test_approval_gates.py contains one test that calls the resume endpoint:

async def test_unauthenticated_list_and_resume_rejected(self, client, mock_storage):
    """The list and resume endpoints also require authentication."""
    ...
    assert resume_resp.status_code == 401

The request is rejected at the auth dependency and never reaches resume_flow. So resume has coverage proving it is protected, and none proving it works — which is consistent with the failure shipping unnoticed.

Reproduction

# 1. enable the gate and restart
#    APPROVAL_GATE_ENABLED=true
#    APPROVAL_REQUIRED_FLOWS=proposal_decision

# 2. create a gated proposal
curl -s -X POST localhost:8001/proposals -H "Content-Type: application/json" \
  -d '{"product_id":"inv-ctv-apex-series","deal_type":"preferreddeal","price":40.0,
       "impressions":1000000,"start_date":"2026-09-01","end_date":"2026-09-30",
       "buyer_id":"lab-buyer-1"}'
# -> status: pending_approval, approval_id: <AID>

# 3. decide it (works)
curl -s -X POST "localhost:8001/approvals/<AID>/decide" \
  -H "Authorization: Bearer <OPERATOR_KEY>" -H "Content-Type: application/json" \
  -d '{"decision":"approve","decided_by":"lab","reason":"repro"}'
# -> 200, decision recorded, approval.granted emitted

# 4. resume it (fails)
curl -s -X POST "localhost:8001/approvals/<AID>/resume" \
  -H "Authorization: Bearer <OPERATOR_KEY>" -w "\nHTTP %{http_code}\n"
# -> Internal Server Error / HTTP 500

Repeating step 3 with "decision":"reject" on a second approval produces the same 500 at step 4.

Environment

seller-agent v2.4.2, commit e5b367d2b780aa4d0e03b6363d744ba3adf2b221, installed with uv sync --locked. CrewAI 1.15.2, resolved from the >=1.14.4,<2.0.0 constraint. Python 3.12.3, WSL2 Ubuntu 24.04.

Inferred

Probable mechanism: CrewAI version drift. flow.state appears to have been assignable in the CrewAI generation this code was written against, and is a read-only property in the lock-resolved 1.15.2. I have not verified which release introduced the change, so this is offered as a likely explanation rather than an established one.

Not established: what happens to a decided-but-unresumed flow. The decision persists and the event is emitted, so the audit record is intact. Whether anything downstream treats the proposal as accepted or rejected without the resume step, I have not tested.

Suggested remedy

Not prescribed — reconstructing or initialising ProposalHandlingFlow through CrewAI's supported state mechanism is a maintainer call, and the right approach depends on what that runtime offers in the pinned version.

Worth noting alongside any fix: a test that exercises resume with a valid credential would have caught this. The current coverage asserts only that the endpoint rejects unauthenticated callers.

Acceptance criteria

  • POST /approvals/{approval_id}/resume completes for an approved decision and for a rejected decision, returning a structured response rather than an unhandled 500.
  • A test exercises the resume path with a valid credential, so that a failure inside _resume_proposal_flow is detectable.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions