From 91e43e64338787ba84fc534245110d3a5efb8c99 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:30:56 +0800 Subject: [PATCH] fix(coordination): preserve JSON types in prose write fencing Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../local-authority-provider-selection.md | 6 +++ .../runtime_shadow_writer_adapter.py | 6 ++- .../test_shadow_writer_boundaries.py | 45 +++++++++++++++++++ 3 files changed, 55 insertions(+), 2 deletions(-) diff --git a/docs/reference/local-authority-provider-selection.md b/docs/reference/local-authority-provider-selection.md index 2fb8bd79a4..882ce1ea14 100644 --- a/docs/reference/local-authority-provider-selection.md +++ b/docs/reference/local-authority-provider-selection.md @@ -188,3 +188,9 @@ CLI 使用上面的完整 JSON 和 preview/apply/inspect/bootstrap 命令;App 删除 fence 或重新开放旧 writer。既有 Goal 升级仍需备份、停止写入、结算租约和 审核计划;反向迁移须保留新增写入。受支持的旧备份、格式和原回执恢复能力保留。 此路径不代表完整升级、D2 长期资格或发布默认已通过。 + +Prose-only source maintenance compares the exact canonical JSON bytes of the +existing stable partition view. Only the resume evaluation clock is excluded; +boolean and integer facts remain distinct even where Python object equality +would equate them. This uses the same encoding boundary as capture identity, +without granting a prose writer any Todo mutation or provider fallback. diff --git a/loopx/control_plane/coordination/runtime_shadow_writer_adapter.py b/loopx/control_plane/coordination/runtime_shadow_writer_adapter.py index aa36a46f95..df629d8986 100644 --- a/loopx/control_plane/coordination/runtime_shadow_writer_adapter.py +++ b/loopx/control_plane/coordination/runtime_shadow_writer_adapter.py @@ -37,7 +37,7 @@ def require_prose_state_write_allowed( from ...rollout_event_log import load_rollout_events, rollout_event_log_path from ..todos.todo_index import MAX_TODO_INDEX_ROLLOUT_EVENTS_PER_GOAL from .local_authority_shadow_adapter import todo_partition_projector - from .local_authority_shadow_projection import partition_comparison_view + from .local_authority_shadow_projection import canonical_bytes, partition_comparison_view try: goal = find_registry_goal(load_registry(registry_path), goal_id) @@ -54,7 +54,9 @@ def require_prose_state_write_allowed( # two projections were evaluated milliseconds apart. original_projection = partition_comparison_view(projector(original_text)) planned_projection = partition_comparison_view(projector(planned_text)) - if original_projection != planned_projection: + # Python structural equality aliases JSON booleans with integers. + # Use the existing exact-byte identity contract before authorizing IO. + if canonical_bytes(original_projection) != canonical_bytes(planned_projection): raise ActiveStateAuthorityMutationError( "prose update would change canonical Todo or handoff state" ) diff --git a/tests/control_plane/test_shadow_writer_boundaries.py b/tests/control_plane/test_shadow_writer_boundaries.py index 95e3a59528..ece31b7899 100644 --- a/tests/control_plane/test_shadow_writer_boundaries.py +++ b/tests/control_plane/test_shadow_writer_boundaries.py @@ -492,6 +492,51 @@ def project_at_distinct_time(text): ) + +@pytest.mark.parametrize("original_value,planned_value", [(True, 1), (False, 0)]) +def test_prose_guard_preserves_json_scalar_identity( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + original_value: bool, planned_value: int, +) -> None: + """A full source projection must retain JSON types even if Python equates them.""" + from loopx.control_plane.coordination import local_authority_shadow_adapter as adapter + from loopx.control_plane.coordination.local_authority_shadow_projection import ( + todo_partition_projection, + ) + from loopx.control_plane.coordination.runtime_shadow_writer_adapter import ( + ActiveStateAuthorityMutationError, require_prose_state_write_allowed, + ) + + registry, state, root = fixture(tmp_path) + add_goal_todo(registry_path=registry, goal_id=GOAL, role="agent", text="Retain result facts.") + original = state.read_text(encoding="utf-8") + planned = original + "\nA narrative observation.\n" + real_projector = adapter.todo_partition_projector + + def source_projector(*args, **kwargs): + project = real_projector(*args, **kwargs) + + def project_result(text): + projection = project(text) + # Exercise the complete-record adapter contract through the real TS + # assembler. This injected result fact models an adapter output, + # not an assertion that the current Markdown codec emits this shape. + projection["todos"][0]["completion_result"] = { + "facts": [{"value": original_value if text == original else planned_value}], + } + return todo_partition_projection(**projection) + + return project_result + + monkeypatch.setattr(adapter, "todo_partition_projector", source_projector) + with pytest.raises(ActiveStateAuthorityMutationError, match="would change canonical"): + require_prose_state_write_allowed( + registry_path=registry, runtime_root=root, goal_id=GOAL, + state_path=state, original_text=original, planned_text=planned, + ) + assert state.read_text(encoding="utf-8") == original + + def test_prose_only_reward_holds_before_index_append_during_maintenance(tmp_path: Path) -> None: from loopx.feedback import append_human_reward from loopx.control_plane.coordination.shadow_management import ShadowManagementError, shadow_management_state_path