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.
Summary
POST /approvals/{approval_id}/resumeis 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_flowassigns toProposalHandlingFlow.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), withAPPROVAL_GATE_ENABLED=trueandAPPROVAL_REQUIRED_FLOWS=proposal_decision, two gated proposals were created and decided — one approved, one rejected. Both decisions succeeded. Both resume calls returned:Server traceback, identical for both:
The line in question,
approval_service.py:128-129:This is the only assignment to
.stateon a flow object anywhere insrc/.Observed — the endpoint is the documented completion path
The router docstring states its purpose plainly (
approvals.py:72-77):_resume_proposal_flowis where the decision is actually applied — appending toaccepted_proposalsorrejected_proposalsand settingExecutionStatus. None of that is reached.Observed — existing test coverage cannot detect this
tests/unit/test_approval_gates.pycontains one test that calls the resume endpoint: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
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 withuv sync --locked. CrewAI 1.15.2, resolved from the>=1.14.4,<2.0.0constraint. Python 3.12.3, WSL2 Ubuntu 24.04.Inferred
Probable mechanism: CrewAI version drift.
flow.stateappears 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
ProposalHandlingFlowthrough 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}/resumecompletes for an approved decision and for a rejected decision, returning a structured response rather than an unhandled 500._resume_proposal_flowis detectable.