diff --git a/loopx/event_sourced_state.py b/loopx/event_sourced_state.py index e4ef42ba31..82f97dffc0 100644 --- a/loopx/event_sourced_state.py +++ b/loopx/event_sourced_state.py @@ -946,13 +946,11 @@ def build_state_projection( kind = event["event_type"] edits = kind in (TODO_ADDED, TODO_UPDATED) order = payload.get("planner_order") if kind == TODO_ADDED else None - if order is not None: - if isinstance(order, bool): - raise StateEventError("planner_order must be an integer") - try: - order = int(order) - except (ValueError, TypeError, OverflowError) as exc: - raise StateEventError("planner_order must be an integer") from exc + # The typed fold rejects non-integer orders, so the adapter must not + # coerce them first: truncating 1.5 to 1 would sort a Todo by one value + # and report another. Every producer of this payload writes an integer. + if order is not None and (isinstance(order, bool) or not isinstance(order, int)): + raise StateEventError("planner_order must be an integer") sequence = event.get("append_sequence") for value in (order, sequence): if value is not None and abs(value) > 2**53 - 1: diff --git a/tests/control_plane/test_event_replay_integrity.py b/tests/control_plane/test_event_replay_integrity.py index e3b1b92bb3..f113f4a296 100644 --- a/tests/control_plane/test_event_replay_integrity.py +++ b/tests/control_plane/test_event_replay_integrity.py @@ -119,3 +119,29 @@ def measured(method, params, **kwargs): # Duplicate identity protection must survive a batch boundary as well. with pytest.raises(StateEventError, match="already exists"): build_state_projection([*source, event("todo_added", 4101, title="Cannot reset history")]) + + +def test_fractional_planner_order_is_rejected_before_typing(): + # The adapter used to truncate 1.5 to 1, so the fold sorted by one value + # while the projection still reported the original fraction. + with pytest.raises(StateEventError, match="planner_order must be an integer"): + build_state_projection([ + event("todo_added", 1, title="Fractional", planner_order=1.5), + event("todo_added", 2, title="Integer", planner_order=1, todo_id="todo_beta"), + ]) + + +@pytest.mark.parametrize("order", [True, "1", [1], {"value": 1}]) +def test_non_integer_planner_order_forms_are_rejected(order): + with pytest.raises(StateEventError, match="planner_order must be an integer"): + build_state_projection([event("todo_added", 1, title="Bad order", planner_order=order)]) + + +def test_integer_and_absent_planner_order_still_project(): + result = build_state_projection([ + event("todo_added", 1, title="First", planner_order=3), + event("todo_added", 2, title="Second", todo_id="todo_beta"), + ]) + items = result["agent_todos"]["items"] + assert [item["todo_id"] for item in items] == ["todo_alpha", "todo_beta"] + assert items[0]["planner_order"] == 3