diff --git a/docs/architecture/rfcs/harness-selection-dsh-pi-v0.md b/docs/architecture/rfcs/harness-selection-dsh-pi-v0.md index dde20aef8d..1873cfd42a 100644 --- a/docs/architecture/rfcs/harness-selection-dsh-pi-v0.md +++ b/docs/architecture/rfcs/harness-selection-dsh-pi-v0.md @@ -496,6 +496,8 @@ A proposal of kind `steward_team_plan_preview` (`steward_team_plan_preview_v0`) is validated before anything may be applied, and a validated preview names, and may not invent: +- the exact Goal the plan staffs, so the admission that validates its lanes and + the settlement that materializes them describe one Goal rather than two; - each lane and the Agent that runs it, resolved from the Agents Core already registers for the Goal, at most 8 lanes; - that lane's first bounded Todo, with its declared priority (P0..P3), task @@ -529,7 +531,9 @@ Shipped enforcement, in delivery order: kind at `PRE_SETTLEMENT`. The apply re-validates the proposal against the Goal's registered Agents and the shipped advancement action kinds, creates the first bounded Todo of each *ready* lane through the canonical Todo owner, - creates nothing for a gap lane, and refuses an unknown Goal before any write. + creates nothing for a gap lane, refuses an unknown Goal before any write, and + refuses a plan whose named Goal differs from its settlement, so a plan + admitted against one Goal's Agents cannot be retargeted into another's. The receipt records the proposal digest, so a replayed settlement reuses the same lane Todo instead of adding a second row, and the receipt names every lane Todo the settlement ensured. diff --git a/docs/architecture/rfcs/harness-selection-dsh-pi-v0.zh-CN.md b/docs/architecture/rfcs/harness-selection-dsh-pi-v0.zh-CN.md index 0fce316d8f..2d10e0c738 100644 --- a/docs/architecture/rfcs/harness-selection-dsh-pi-v0.zh-CN.md +++ b/docs/architecture/rfcs/harness-selection-dsh-pi-v0.zh-CN.md @@ -389,6 +389,8 @@ Chat Turn 也早已把 `response.proposals` 投影成 `proposal.ready` 事件。 kind 为 `steward_team_plan_preview`(`steward_team_plan_preview_v0`)的提案,在任何落地 之前先被校验;校验通过的预览必须点名、且不得编造: +- 这次配人的**确切 Goal**,使"验证 lanes 的准入"与"建成 lanes 的结算"描述的是同一个 Goal + 而不是两个; - 每条 lane 及其运行的 Agent,且只能来自 Core 已为该 Goal 注册的 Agent,最多 8 条 lane; - 该 lane 的首个有界 Todo,含其声明优先级(P0..P3)、task class 与 action kind; - 约束这些 lane 的 quota 包络; @@ -413,8 +415,10 @@ Todo 创建、quota 或 goal policy——复用预览点名的身份,不得扩 3. **落地**(`#4524`,`c159a15b3`):受治理提案所有者在 `PRE_SETTLEMENT` 相位分派该 kind, 落地时重新按本 Goal 已注册 Agent 与本机 shipment 的 advancement action kind 校验,经 canonical Todo owner 为每条 **ready** lane 创建首个有界 Todo,gap lane 不创建任何东西, - 未知 Goal 在任何写入前就被拒绝,回执记录 proposal digest,因此重放结算复用同一条 lane - Todo 而不会新增第二行,并且回执点名这次确保的每一条 lane Todo。 + 未知 Goal 在任何写入前就被拒绝,且**点名 Goal 与结算 Goal 不一致的计划会被拒绝**,因此 + 按某个 Goal 的 Agent 通过准入的计划无法被改投到另一个 Goal;回执记录 proposal digest, + 因此重放结算复用同一条 lane Todo 而不会新增第二行,并且回执点名这次确保的每一条 lane + Todo。 这条入端口径目前在线上仍是**惰性**的,本节不作相反声明:还没有任何生产调用方传入 `team_plan_context`,因此模型产出的预览会在准入处被丢弃,而不会浮现给业主确认;提供准入事实 diff --git a/loopx/control_plane/work_items/governed_transition_proposal.py b/loopx/control_plane/work_items/governed_transition_proposal.py index 5c3f16a830..ad67f03133 100644 --- a/loopx/control_plane/work_items/governed_transition_proposal.py +++ b/loopx/control_plane/work_items/governed_transition_proposal.py @@ -263,19 +263,26 @@ def _apply_team_plan( ) -> dict[str, Any]: """Create the confirmed lanes' first bounded Todos through the Todo owner. - The plan is re-validated here against this Goal's registered Agents and the - shipped advancement action kinds, so a proposal cannot become work by - bypassing admission. Only lanes the preview already marked ready are - materialized; a lane the preview reported as a gap stays a gap and creates - nothing, and the canonical Todo owner decides whether a row is added or - reused, which makes a replayed settlement idempotent. - """ + The plan is re-validated here against this Goal's registered Agents and the + shipped advancement action kinds, so a proposal cannot become work by + bypassing admission. Only lanes the preview already marked ready are + materialized; a lane the preview reported as a gap stays a gap and creates + nothing, and the canonical Todo owner decides whether a row is added or + reused, which makes a replayed settlement idempotent. + """ from ...agent_registry import registered_agent_ids_for_goal from ...history import load_registry from ...registry import registry_goals from ..todos.contract import TODO_ACTION_KIND_ADVANCEMENT_VALUES + # The plan names the Goal it staffs, and it may not be retargeted by the + # settlement it arrives in: admitting a plan against one Goal's agents and + # then creating its lanes under another would be a silent widening. + if str(proposal.get("goal_id") or "") != goal_id: + raise ValueError( + "steward team plan proposal names a different Goal than its settlement" + ) registry = load_registry(registry_path) goal = next( ( @@ -461,6 +468,7 @@ def settle_governed_transition_proposals( STEWARD_TEAM_PLAN_PREVIEW_SCHEMA_VERSION = "steward_team_plan_preview_v0" STEWARD_TEAM_PLAN_LANE_LIMIT = 8 STEWARD_TEAM_PLAN_PRIORITIES = ("P0", "P1", "P2", "P3") +_GOAL_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,159}$") STEWARD_TEAM_PLAN_GAP_REASONS = ( "agent_not_registered", "capability_not_granted", @@ -501,6 +509,13 @@ def validate_steward_team_plan_preview( raise ValueError("steward team plan preview schema_version is invalid") if plan.get("kind") != STEWARD_TEAM_PLAN_PREVIEW_KIND: raise ValueError("steward team plan preview kind is invalid") + # The plan names the Goal it staffs. Without that, the admission that + # validates its lanes and the settlement that materializes them would each + # have to guess which Goal's agents the host should describe, and a plan + # could be admitted against one Goal's facts and applied under another's. + goal_id = _plan_text(plan.get("goal_id"), "goal_id") + if not _GOAL_ID.fullmatch(goal_id): + raise ValueError("steward team plan preview requires an exact Goal id") registered = {str(value) for value in registered_agent_ids} action_kinds = {str(value) for value in supported_action_kinds} lanes_value = plan.get("lanes") @@ -585,6 +600,7 @@ def validate_steward_team_plan_preview( preview = { "schema_version": STEWARD_TEAM_PLAN_PREVIEW_SCHEMA_VERSION, "kind": STEWARD_TEAM_PLAN_PREVIEW_KIND, + "goal_id": goal_id, "objective": _plan_text(plan.get("objective"), "objective"), "lanes": lanes, "gaps": gaps, diff --git a/tests/test_steward_team_plan_apply.py b/tests/test_steward_team_plan_apply.py index 915a6fdf9f..4cab5f9e0d 100644 --- a/tests/test_steward_team_plan_apply.py +++ b/tests/test_steward_team_plan_apply.py @@ -89,6 +89,7 @@ def _proposal(*, agent_id: str = AGENT_ID, extra_lane: dict | None = None) -> di return { "schema_version": "steward_team_plan_preview_v0", "kind": "steward_team_plan_preview", + "goal_id": GOAL_ID, "proposal_id": "proposal-team-plan", "objective": "Stand up the intake lane", "quota_envelope": {"slots_per_day": 4}, @@ -169,6 +170,8 @@ def test_a_gap_lane_creates_nothing_and_a_replay_adds_no_second_row( def test_an_unknown_goal_is_refused_before_any_todo(tmp_path: Path) -> None: project, registry_path = _fixture(tmp_path) + unknown = _proposal() + unknown["goal_id"] = "goal-that-does-not-exist" with pytest.raises(ValueError, match="unknown Goal"): settle_governed_transition_proposals( @@ -176,7 +179,7 @@ def test_an_unknown_goal_is_refused_before_any_todo(tmp_path: Path) -> None: goal_id="goal-that-does-not-exist", agent_id=AGENT_ID, effect_id="effect-team-plan", - proposals=[_proposal()], + proposals=[unknown], existing_receipts=[], checkpoint=lambda _receipts: None, phase=GovernedTransitionSettlementPhase.PRE_SETTLEMENT, @@ -185,6 +188,21 @@ def test_an_unknown_goal_is_refused_before_any_todo(tmp_path: Path) -> None: assert "loopx:todo " not in _todos(project) +def test_a_plan_cannot_be_retargeted_to_another_goal(tmp_path: Path) -> None: + """Admission facts and the applied Goal have to be the same Goal.""" + + project, registry_path = _fixture(tmp_path) + retargeted = _proposal() + retargeted["goal_id"] = "some-other-goal" + + with pytest.raises(ValueError, match="different Goal than its settlement"): + _settle(registry_path, retargeted) + + # Nothing was created, and the named Goal's own plan still applies. + assert "loopx:todo " not in _todos(project) + assert _settle(registry_path, _proposal())[0]["action"] == "created" + + def _second_lane() -> dict: return { "lane_id": "lane-beta", diff --git a/tests/test_steward_team_plan_preview.py b/tests/test_steward_team_plan_preview.py index 9496873d49..ad37721cbc 100644 --- a/tests/test_steward_team_plan_preview.py +++ b/tests/test_steward_team_plan_preview.py @@ -17,6 +17,7 @@ def _plan(**overrides: object) -> dict[str, object]: plan: dict[str, object] = { "schema_version": STEWARD_TEAM_PLAN_PREVIEW_SCHEMA_VERSION, "kind": STEWARD_TEAM_PLAN_PREVIEW_KIND, + "goal_id": "team-plan-fixture", "objective": "Ship the intake lane", "quota_envelope": {"slots_per_day": 4}, "stop_condition": "Stop when the owner withdraws the request", @@ -50,12 +51,30 @@ def test_a_staffed_lane_becomes_a_preview_that_cannot_apply() -> None: preview = _validate(_plan()) assert preview["applies"] is False + assert preview["goal_id"] == "team-plan-fixture" assert preview["gaps"] == [] assert preview["lanes"][0]["staffing"] == "ready" assert preview["lanes"][0]["first_todo"]["priority"] == "P1" assert preview["quota_envelope"] == {"slots_per_day": 4} +def test_the_preview_must_name_the_goal_it_staffs() -> None: + """Admission and settlement both need one named Goal's facts.""" + + without_goal = _plan() + del without_goal["goal_id"] + with pytest.raises(ValueError, match="goal_id must be a non-empty string"): + _validate(without_goal) + + # A Goal id is an exact registry id, not free text: a path, a sentence or an + # unbounded string cannot become the Goal a settlement materializes into. + for invalid in ("", " ", "../escape", "goal with spaces", "x" * 161): + with pytest.raises(ValueError): + _validate(_plan(goal_id=invalid)) + with pytest.raises(ValueError, match="requires an exact Goal id"): + _validate(_plan(goal_id="../escape")) + + def test_an_unregistered_agent_becomes_a_gap_instead_of_being_invented() -> None: plan = _plan() plan["lanes"][0]["agent_id"] = "agent-not-registered" # type: ignore[index]