From fe3196c6ecdcdb750948a47fd0250410ac4644df Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:25:00 +0800 Subject: [PATCH] fix(refresh): reject malformed vision before outcome refinement Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../project_lifecycle_refresh_state.py | 13 ++++++ .../test_goal_outcome_refinement.py | 42 +++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/loopx/cli_commands/project_lifecycle_refresh_state.py b/loopx/cli_commands/project_lifecycle_refresh_state.py index 56f586914c..224a0b76e1 100644 --- a/loopx/cli_commands/project_lifecycle_refresh_state.py +++ b/loopx/cli_commands/project_lifecycle_refresh_state.py @@ -29,6 +29,7 @@ capture_first_party_host_goal_ref, ) from ..control_plane.goals.source_session_registry_state import exact_goal_ref +from ..control_plane.goals.vision_checkpoint import prepare_vision_refresh from ..control_plane.quota.settlement import ( attach_settlement_progress, read_heartbeat_settlement, @@ -515,6 +516,18 @@ def handle_refresh_state_command( turn_instance_id=args.turn_instance_id, ), ) + if outcome_update is not None and agent_vision_packet is not None: + # Reject malformed input before committing the independent outcome + # refinement. Reuse the typed owner; do not authorize a transition + # or replace the original packet with this context-free result. + # Refresh still checks merged history and policy against current + # state, including whether a terminal stride is actually enabled. + prepare_vision_refresh( + agent_vision_packet, goal_id=args.goal_id, agent_id=args.agent_id, + existing_agent_vision=None, merge_patch=False, + require_path_delta_for_durable_change=False, + allow_terminal_stride_path=True, + ) progress_observation = inline_progress_observation(args) reward_memory_reflection_json = str( getattr(args, "reward_memory_reflection_json", None) or "" diff --git a/tests/control_plane/test_goal_outcome_refinement.py b/tests/control_plane/test_goal_outcome_refinement.py index 1552e49e29..7ab1efb57b 100644 --- a/tests/control_plane/test_goal_outcome_refinement.py +++ b/tests/control_plane/test_goal_outcome_refinement.py @@ -316,6 +316,48 @@ def admission(): assert declaration["execution_authority_granted"] is False +@pytest.mark.parametrize("invalid_field", ["evidence_refs", "acceptance_summary", "agent_id"]) +def test_batched_refinement_rejects_invalid_vision_before_mutation(acceptance_goal, invalid_field): + project, _, cli, run = acceptance_goal + before = cli("inspect")[1] + update = project / "update.json" + update.write_text(json.dumps({ + "operation_id": "validated-writeback", + "expected_provider_revision": before["provider_revision"], + "refinement": refinement([{"id": "opening", "description": "Keep the introduction"}], "message:first"), + })) + packet = { + "state": "vision_patch_proposed", + "vision_patch": {"acceptance_summary": "The introduction still needs validation."}, + "path_delta": {"outcome": "continue", "prior_assumption": "Retain the introduction.", + "observed_reality": "Independent validation remains open.", + "retained": ["Original content"], "evidence_refs": ["artifact:tour"]}, + } + invalid = json.loads(json.dumps(packet)) + if invalid_field == "evidence_refs": + invalid["path_delta"]["evidence_refs"] = [f"artifact:part-{i}" for i in range(5)] + elif invalid_field == "acceptance_summary": + invalid["vision_patch"]["acceptance_summary"] = "x" * 421 + else: + invalid["agent_id"] = "other-agent" + vision = project / "vision.json" + vision.write_text(json.dumps(invalid)) + args = ("refresh-state", "--goal-id", "goal-acceptance", "--agent-id", "agent-a", + "--no-global-sync", "--goal-outcome-update-json", str(update), "--agent-vision-json", str(vision)) + for suffix in [(), ("--dry-run",)]: + code, rejected = run(*args, *suffix) + assert code == 1 and not rejected["appended"], rejected + assert invalid_field in rejected["error"], rejected + assert cli("inspect")[1] == before, "bad input must not advance the outcome or provider revision" + assert "goal_outcome_update" not in rejected + # Repair only the malformed packet: no new basis or operation identity. + vision.write_text(json.dumps(packet)) + for status in ["applied", "replayed"]: + code, result = run(*args) + assert code == 0 and result["goal_outcome_update"]["status"] == status, result + assert cli("inspect")[1]["goal_acceptance_contract"]["outcome_requirements"]["revision"] == 1 + + def test_batched_refinement_receipt_survives_later_writeback_failure(acceptance_goal, monkeypatch, capsys): from loopx.cli import main import loopx.cli_commands.project_lifecycle_refresh_state as refresh