diff --git a/control_plane/authoring/book_state.py b/control_plane/authoring/book_state.py index 1df937b..ac47806 100644 --- a/control_plane/authoring/book_state.py +++ b/control_plane/authoring/book_state.py @@ -648,9 +648,9 @@ class BookStateService: ) if not validation["valid"]: raise ValueError("book state is not approval-valid: " + "; ".join(validation["errors"])) - parent_id = _value(locked, "parent_id") current_id = _value(work, "current_book_state_id") - if parent_id != current_id: + approved_base_id = self._approved_base_id(locked) + if approved_base_id != current_id: raise ValueError("book state parent is stale relative to the work's approved state") if force and not notes.strip(): raise ValueError("forced approval requires notes") @@ -684,6 +684,18 @@ class BookStateService: self._write_artifacts(locked) return locked + @staticmethod + def _approved_base_id(state: BookStateVersion) -> Any: + ancestor_id = state.parent_id + while ancestor_id: + ancestor = BookStateVersion.objects.select_for_update().only( + "id", "parent_id", "status" + ).get(pk=ancestor_id) + if ancestor.status == BookStateStatus.APPROVED: + return ancestor.id + ancestor_id = ancestor.parent_id + return None + @transaction.atomic def reject( self, state: BookStateVersion, *, actor: str = "", notes: str = "" diff --git a/tests/test_book_authoring_state.py b/tests/test_book_authoring_state.py index 098ae75..10f720c 100644 --- a/tests/test_book_authoring_state.py +++ b/tests/test_book_authoring_state.py @@ -230,6 +230,24 @@ def test_approval_requires_all_reviews_and_rejects_stale_parent(work: Work) -> N service.approve(child) +def test_approval_allows_review_revisions_before_first_approved_state(work: Work) -> None: + service = BookStateService(FakeReviewRouter()) + first = service.create(work=work, content=book_content(1)) + second_content = deepcopy(first.content) + second_content["open_questions"] = ["What must the pilot verify?"] + second = service.revise(first, content=second_content) + third_content = deepcopy(second.content) + third_content["open_questions"] = ["What evidence resolves the review finding?"] + third = service.revise(second, content=third_content) + make_review_ready(third) + + approved = service.approve(third, actor="editor") + + work.refresh_from_db() + assert approved.status == BookStateStatus.APPROVED + assert work.current_book_state == approved + + def test_review_routes_structure_and_continuity_to_distinct_capabilities(work: Work) -> None: router = FakeReviewRouter() service = BookStateService(router)