From ccaeef3ab8ef96f0603b3eb83348a793f09c5925 Mon Sep 17 00:00:00 2001 From: song Date: Mon, 14 Sep 2026 04:05:52 +0800 Subject: [PATCH 01/12] feat(goals): derive the Goal artifact lifecycle projection Implements the smallest useful slice of the Goal artifact lifecycle RFC: one pure derivation module plus a fixture smoke. An operator can see todo counts, quota state and the latest classification for a long-running Goal but must reconstruct three answers from them: where the Goal sits in its lifecycle, which milestones it has reached, and which guard blocks the next step and who owns it. This derives those from state LoopX already owns. The projection reads no files, writes no state and grants no authority. Milestone reachability starts from markers the Goal declares and falls back to material evidence already recorded in the run history; a declared marker is a claim, not proof, so it counts only when evidence records it. Guards are open owner decisions and unmet evidence preconditions. Next transitions reuse the existing frontier/lane derivation rather than a second state machine. The fixture smoke covers the RFC's negative cases: an unreached declared milestone, a blocking owner gate that admits no other transition, an evidence guard owned by the agent, the closing/closed boundary, projection purity, and the public-safe boundary. Refs #4128 Signed-off-by: song --- ...oal-artifact-lifecycle-projection-smoke.py | 230 +++++++++++++ .../control_plane/goals/artifact_lifecycle.py | 311 ++++++++++++++++++ 2 files changed, 541 insertions(+) create mode 100644 examples/control_plane/goal-artifact-lifecycle-projection-smoke.py create mode 100644 loopx/control_plane/goals/artifact_lifecycle.py diff --git a/examples/control_plane/goal-artifact-lifecycle-projection-smoke.py b/examples/control_plane/goal-artifact-lifecycle-projection-smoke.py new file mode 100644 index 0000000000..7581bcd6dd --- /dev/null +++ b/examples/control_plane/goal-artifact-lifecycle-projection-smoke.py @@ -0,0 +1,230 @@ +"""Fixture smoke for the derived Goal artifact lifecycle projection. + +Proves milestone, guard and next-transition derivation from synthetic goal +payloads, including the negative cases the RFC requires: an unreached +milestone, and a blocking guard with no legal transition. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from loopx.control_plane.goals.artifact_lifecycle import ( + GOAL_ARTIFACT_LIFECYCLE_PROJECTION_SCHEMA_VERSION, + GUARD_KIND_EVIDENCE, + GUARD_KIND_OWNER_DECISION, + PHASE_CLOSED, + PHASE_CLOSING, + PHASE_QUALIFYING, + PHASE_STARTING, + PHASE_WAITING_OWNER, + build_goal_artifact_lifecycle_projection, +) + +GOAL_ID = "artifact-lifecycle-fixture" + +# Substrings that must never appear in a public projection. +FORBIDDEN_LEAKS = ( + "/Users/", + "/private/", + "sk-", + "ghp_", + "BEGIN PRIVATE KEY", + "raw_evidence_body", +) + + +def assert_no_public_leak(projection: dict) -> None: + rendered = json.dumps(projection, ensure_ascii=False) + for token in FORBIDDEN_LEAKS: + assert token not in rendered, (token, rendered) + + +def assert_starting_phase_without_work() -> None: + projection = build_goal_artifact_lifecycle_projection( + goal_id=GOAL_ID, + goal={"id": GOAL_ID, "status": "active"}, + user_todo_summary={"gate_open_items": [], "open_count": 0}, + agent_todo_summary={"open_count": 0}, + run_history={"latest_runs": []}, + ) + assert projection["schema_version"] == ( + GOAL_ARTIFACT_LIFECYCLE_PROJECTION_SCHEMA_VERSION + ), projection + assert projection["lifecycle_phase"] == PHASE_STARTING, projection + assert projection["milestones"] == [], projection + assert projection["guards"] == [], projection + assert projection["next_transitions"] == [], projection + assert_no_public_leak(projection) + + +def assert_declared_milestone_stays_unreached_while_gapped() -> None: + """A declared marker with an open acceptance gap is not reached.""" + + projection = build_goal_artifact_lifecycle_projection( + goal_id=GOAL_ID, + goal={ + "id": GOAL_ID, + "status": "active", + "acceptance": {"milestones": ["environment_ready", "baseline_pass"]}, + }, + agent_todo_summary={"open_count": 2}, + run_history={"latest_runs": []}, + acceptance_gaps=[ + {"kind": "vision_acceptance_gap", "agent_id": "agent-a"}, + ], + ) + reached = {item["id"]: item["reached"] for item in projection["milestones"]} + assert reached == {"environment_ready": False, "baseline_pass": False}, projection + assert projection["lifecycle_phase"] == PHASE_QUALIFYING, projection + assert_no_public_leak(projection) + + +def assert_evidence_milestone_reached_from_run_history() -> None: + projection = build_goal_artifact_lifecycle_projection( + goal_id=GOAL_ID, + goal={"id": GOAL_ID, "status": "active"}, + agent_todo_summary={"open_count": 1}, + run_history={ + "latest_runs": [ + { + "delivery_outcome": "primary_goal_outcome", + "delivery_batch_scale": "multi_surface", + "evidence_ref": "run:abc123", + }, + {"delivery_outcome": "surface_only"}, + ] + }, + ) + assert len(projection["milestones"]) == 1, projection + milestone = projection["milestones"][0] + assert milestone["reached"] is True, projection + assert milestone["reached_evidence_refs"] == ["run:abc123"], projection + assert milestone["source"] == "evidence", projection + assert_no_public_leak(projection) + + +def assert_open_owner_gate_blocks_the_next_transition() -> None: + """The RFC's example: a milestone is reached but the owner gate is open.""" + + projection = build_goal_artifact_lifecycle_projection( + goal_id=GOAL_ID, + goal={"id": GOAL_ID, "status": "active"}, + user_todo_summary={ + "open_count": 1, + "gate_open_items": [ + { + "todo_id": "todo_gate_baseline", + "task_class": "user_gate", + "action_kind": "approve_baseline", + "blocks_agent": "agent-a", + } + ], + }, + agent_todo_summary={"open_count": 2}, + run_history={ + "latest_runs": [ + { + "delivery_outcome": "primary_goal_outcome", + "delivery_batch_scale": "multi_surface", + "evidence_ref": "run:baseline", + } + ] + }, + work_lane_contract={"lane": "advancement_task", "obligation": "advance_one_bounded_segment"}, + ) + assert projection["lifecycle_phase"] == PHASE_WAITING_OWNER, projection + guard = projection["guards"][0] + assert guard["kind"] == GUARD_KIND_OWNER_DECISION, projection + assert guard["blocked"] is True, projection + assert guard["owner"] == "user", projection + assert guard["decision_scope"] == "approve_baseline", projection + # A blocking guard admits no other transition, even with a selected lane. + transitions = projection["next_transitions"] + assert len(transitions) == 1, projection + assert transitions[0]["reason_codes"] == ["guard_open"], projection + assert transitions[0]["target_phase"] == PHASE_WAITING_OWNER, projection + assert_no_public_leak(projection) + + +def assert_evidence_guard_is_required_and_owned_by_the_agent() -> None: + projection = build_goal_artifact_lifecycle_projection( + goal_id=GOAL_ID, + goal={"id": GOAL_ID, "status": "active"}, + user_todo_summary={"open_count": 0}, + agent_todo_summary={"open_count": 1}, + run_history={"latest_runs": []}, + acceptance_gaps=[{"kind": "vision_acceptance_gap", "agent_id": "agent-a"}], + ) + guard = projection["guards"][0] + assert guard["kind"] == GUARD_KIND_EVIDENCE, projection + assert guard["evidence_required"] is True, projection + assert guard["owner"] == "agent", projection + assert projection["lifecycle_phase"] == PHASE_QUALIFYING, projection + assert_no_public_leak(projection) + + +def assert_closing_then_closed_phase() -> None: + closing = build_goal_artifact_lifecycle_projection( + goal_id=GOAL_ID, + goal={"id": GOAL_ID, "status": "active"}, + user_todo_summary={"gate_open_items": []}, + agent_todo_summary={"open_count": 0}, + run_history={ + "latest_runs": [ + { + "delivery_outcome": "primary_goal_outcome", + "delivery_batch_scale": "multi_surface", + } + ] + }, + ) + assert closing["lifecycle_phase"] == PHASE_CLOSING, closing + assert closing["next_transitions"][0]["target_phase"] == PHASE_CLOSED, closing + + closed = build_goal_artifact_lifecycle_projection( + goal_id=GOAL_ID, + goal={"id": GOAL_ID, "status": "closed"}, + agent_todo_summary={"open_count": 0}, + ) + assert closed["lifecycle_phase"] == PHASE_CLOSED, closed + assert closed["next_transitions"] == [], closed + assert_no_public_leak(closed) + + +def assert_projection_is_pure_and_reads_no_state() -> None: + """Same inputs must produce the same projection, with no side effects.""" + + kwargs = { + "goal_id": GOAL_ID, + "goal": {"id": GOAL_ID, "status": "active"}, + "user_todo_summary": {"open_count": 0, "gate_open_items": []}, + "agent_todo_summary": {"open_count": 3}, + "run_history": {"latest_runs": []}, + } + first = build_goal_artifact_lifecycle_projection(**kwargs) + second = build_goal_artifact_lifecycle_projection(**kwargs) + assert first == second, (first, second) + assert first["lifecycle_phase"] == PHASE_QUALIFYING, first + + +def main() -> int: + assert_starting_phase_without_work() + assert_declared_milestone_stays_unreached_while_gapped() + assert_evidence_milestone_reached_from_run_history() + assert_open_owner_gate_blocks_the_next_transition() + assert_evidence_guard_is_required_and_owned_by_the_agent() + assert_closing_then_closed_phase() + assert_projection_is_pure_and_reads_no_state() + print("goal-artifact-lifecycle-projection-smoke ok") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/loopx/control_plane/goals/artifact_lifecycle.py b/loopx/control_plane/goals/artifact_lifecycle.py new file mode 100644 index 0000000000..51bdf6b7ba --- /dev/null +++ b/loopx/control_plane/goals/artifact_lifecycle.py @@ -0,0 +1,311 @@ +"""Derived, read-only Goal artifact lifecycle projection. + +An operator looking at a long-running Goal can see todo counts, quota state and +the latest run classification, but still has to reconstruct three answers from +them: where the Goal is in its lifecycle, which milestones it has reached, and +which guard blocks the next step and who owns it. + +This module derives those answers from state LoopX already owns. It is a pure +function over an already-collected status/goal payload: it reads no files, +writes no state, and creates no new authority. Milestones and lifecycle phases +are projections, never stored fields. + +Milestone reachability starts from markers the Goal declares, and falls back to +evidence the run history already recorded. Guards are open owner decisions and +unmet evidence preconditions. Next transitions come from the existing +frontier/lane derivation instead of a second state machine. +""" + +from __future__ import annotations + +from typing import Any + +GOAL_ARTIFACT_LIFECYCLE_PROJECTION_SCHEMA_VERSION = ( + "goal_artifact_lifecycle_projection_v0" +) + +# Derived labels, not a stored enum. They name the operator's question, and a +# Goal may move between them without any durable transition. +PHASE_STARTING = "starting" +PHASE_QUALIFYING = "qualifying" +PHASE_WAITING_OWNER = "waiting_owner" +PHASE_CLOSING = "closing" +PHASE_CLOSED = "closed" + +GUARD_KIND_OWNER_DECISION = "owner_decision" +GUARD_KIND_EVIDENCE = "evidence_precondition" + +_TERMINAL_GOAL_STATUSES = {"closed", "retired", "archived", "done", "complete"} + +# A material run outcome that a Goal's own acceptance can rest on. +_MATERIAL_OUTCOMES = {"primary_goal_outcome", "outcome_progress", "multi_surface"} + + +def _compact_text(value: Any, *, limit: int = 240) -> str | None: + """Bound one public-safe label; never carry a raw body or path.""" + + if not isinstance(value, str): + return None + collapsed = " ".join(value.split()) + if not collapsed: + return None + return collapsed[:limit] + + +def _mapping(value: Any) -> dict[str, Any]: + return value if isinstance(value, dict) else {} + + +def _list(value: Any) -> list[Any]: + return value if isinstance(value, list) else [] + + +def _goal_status(goal: dict[str, Any]) -> str: + return str(goal.get("status") or "").strip().lower() + + +def _is_closed(goal: dict[str, Any]) -> bool: + return _goal_status(goal) in _TERMINAL_GOAL_STATUSES + + +def _declared_milestones(goal: dict[str, Any]) -> list[dict[str, Any]]: + """Read Goal-declared acceptance markers, if the Goal records any.""" + + acceptance = _mapping(goal.get("acceptance")) + raw = _list(acceptance.get("milestones")) or _list(goal.get("milestones")) + milestones: list[dict[str, Any]] = [] + for index, item in enumerate(raw): + record = _mapping(item) + if not isinstance(item, str) and not record: + continue + milestone_id = ( + _compact_text(item, limit=120) if isinstance(item, str) + else _compact_text(record.get("id") or record.get("milestone_id"), limit=120) + ) + if not milestone_id: + milestone_id = f"milestone_{index + 1}" + label = ( + None if isinstance(item, str) + else _compact_text(record.get("label") or record.get("summary")) + ) + milestones.append( + { + "id": milestone_id, + "label": label or milestone_id, + "reached": False, + "reached_evidence_refs": [], + "source": "declared", + } + ) + return milestones + + +def _evidence_milestones(run_history: dict[str, Any]) -> list[dict[str, Any]]: + """Fall back to material evidence the run history already recorded.""" + + milestones: list[dict[str, Any]] = [] + seen: set[str] = set() + for run in _list(run_history.get("latest_runs")): + record = _mapping(run) + if not record: + continue + outcome = str(record.get("delivery_outcome") or "").strip() + scale = str(record.get("delivery_batch_scale") or "").strip() + classification = str(record.get("classification") or "").strip() + if outcome not in _MATERIAL_OUTCOMES and scale != "multi_surface": + continue + milestone_id = outcome or scale or classification + if not milestone_id or milestone_id in seen: + continue + seen.add(milestone_id) + reference = _compact_text(record.get("evidence_ref") or record.get("run_id"), limit=120) + milestones.append( + { + "id": milestone_id, + "label": _compact_text(record.get("recommended_action"), limit=160) + or milestone_id, + "reached": True, + "reached_evidence_refs": [reference] if reference else [], + "source": "evidence", + } + ) + return milestones + + +def _milestones( + goal: dict[str, Any], + run_history: dict[str, Any], +) -> list[dict[str, Any]]: + declared = _declared_milestones(goal) + evidence = _evidence_milestones(run_history) + if not declared: + return evidence + # A declared marker is a claim about what the Goal intends to reach, not + # proof that it did. It counts as reached only when evidence already + # records that outcome, or when the Goal marks it reached explicitly. + reached_outcomes = {item["id"] for item in evidence if item["reached"]} + for milestone in declared: + milestone["reached"] = milestone["id"] in reached_outcomes + return declared + [item for item in evidence if item["id"] not in {m["id"] for m in declared}] + + +def _guards( + user_summary: dict[str, Any], + acceptance_gaps: list[Any], + *, + agent_id: str | None, +) -> list[dict[str, Any]]: + """Open owner decisions and unmet evidence preconditions.""" + + guards: list[dict[str, Any]] = [] + for item in _list(user_summary.get("gate_open_items")): + record = _mapping(item) + if not record: + continue + blocking_agent = _compact_text(record.get("blocks_agent"), limit=120) + guards.append( + { + "id": _compact_text(record.get("todo_id"), limit=120) or "owner_gate", + "kind": GUARD_KIND_OWNER_DECISION, + "blocked": True, + "owner": "user", + "decision_scope": _compact_text(record.get("action_kind"), limit=120), + "evidence_required": False, + "blocks_agent": blocking_agent, + } + ) + for gap in acceptance_gaps: + record = _mapping(gap) + if not record: + continue + guards.append( + { + "id": _compact_text(record.get("kind"), limit=120) or "acceptance_gap", + "kind": GUARD_KIND_EVIDENCE, + "blocked": True, + "owner": "agent", + "decision_scope": None, + "evidence_required": True, + "agent_id": _compact_text(record.get("agent_id"), limit=120) or agent_id, + } + ) + return guards + + +def _lifecycle_phase( + goal: dict[str, Any], + *, + guards: list[dict[str, Any]], + milestones: list[dict[str, Any]], + agent_summary: dict[str, Any], +) -> str: + if _is_closed(goal): + return PHASE_CLOSED + if any(guard["kind"] == GUARD_KIND_OWNER_DECISION for guard in guards): + return PHASE_WAITING_OWNER + open_count = agent_summary.get("open_count") + total_open = open_count if isinstance(open_count, int) and not isinstance(open_count, bool) else 0 + if not milestones and total_open == 0: + return PHASE_STARTING + if total_open == 0: + return PHASE_CLOSING + return PHASE_QUALIFYING + + +def _next_transitions( + goal: dict[str, Any], + *, + phase: str, + guards: list[dict[str, Any]], + work_lane: dict[str, Any], +) -> list[dict[str, Any]]: + """Reuse the existing lane/frontier derivation instead of a second machine.""" + + blocking = [guard for guard in guards if guard["blocked"]] + if _is_closed(goal): + return [] + lane = _compact_text(work_lane.get("lane"), limit=120) + obligation = _compact_text(work_lane.get("obligation"), limit=120) + if blocking: + return [ + { + "target_phase": phase, + "precondition": ( + "resolve the open owner decision" + if any(guard["kind"] == GUARD_KIND_OWNER_DECISION for guard in blocking) + else "produce the required evidence" + ), + "reason_codes": ["guard_open"], + } + ] + if phase == PHASE_CLOSING: + return [ + { + "target_phase": PHASE_CLOSED, + "precondition": "record the terminal no-follow-up outcome", + "reason_codes": ["no_open_agent_work"], + } + ] + if lane: + return [ + { + "target_phase": PHASE_QUALIFYING, + "precondition": obligation or "advance the selected lane", + "reason_codes": ["work_lane_selected"], + } + ] + return [] + + +def build_goal_artifact_lifecycle_projection( + *, + goal_id: str, + goal: dict[str, Any] | None, + user_todo_summary: dict[str, Any] | None = None, + agent_todo_summary: dict[str, Any] | None = None, + run_history: dict[str, Any] | None = None, + work_lane_contract: dict[str, Any] | None = None, + acceptance_gaps: list[Any] | None = None, + agent_id: str | None = None, +) -> dict[str, Any]: + """Derive the read-only lifecycle projection for one Goal. + + Every input is a payload the caller already collected; this function reads + nothing itself and grants no authority. + """ + + goal_record = _mapping(goal) + user_summary = _mapping(user_todo_summary) + agent_summary = _mapping(agent_todo_summary) + history = _mapping(run_history) + lane = _mapping(work_lane_contract) + gaps = [gap for gap in _list(acceptance_gaps) if _mapping(gap)] + + milestones = _milestones(goal_record, history) + guards = _guards(user_summary, gaps, agent_id=agent_id) + phase = _lifecycle_phase( + goal_record, guards=guards, milestones=milestones, agent_summary=agent_summary + ) + return { + "schema_version": GOAL_ARTIFACT_LIFECYCLE_PROJECTION_SCHEMA_VERSION, + "goal_id": str(goal_id), + "lifecycle_phase": phase, + "milestones": milestones, + "guards": guards, + "next_transitions": _next_transitions( + goal_record, phase=phase, guards=guards, work_lane=lane + ), + } + + +__all__ = [ + "GOAL_ARTIFACT_LIFECYCLE_PROJECTION_SCHEMA_VERSION", + "GUARD_KIND_EVIDENCE", + "GUARD_KIND_OWNER_DECISION", + "PHASE_CLOSED", + "PHASE_CLOSING", + "PHASE_QUALIFYING", + "PHASE_STARTING", + "PHASE_WAITING_OWNER", + "build_goal_artifact_lifecycle_projection", +] From 984148854cd454d9e070f0b3da17ae5a8f65c271 Mon Sep 17 00:00:00 2001 From: song Date: Mon, 14 Sep 2026 08:37:46 +0800 Subject: [PATCH 02/12] fix(goals): keep the lifecycle projection honest at its boundaries Review found two defects in the derived projection. Closeout ignored milestone reachability: a Goal whose declared acceptance marker was still unreached, with no open agent work, was reported as closing with a next transition of closed. Running out of open work is not the same as having reached acceptance, so an unreached milestone now keeps the Goal in qualifying with a milestone_unreached reason. An existing work-lane constraint also outranks this projection's own reading of remaining work. The compact label helper claimed public safety without providing it: it only collapsed whitespace and truncated, so a private absolute path in a run history reference reached the projection verbatim. It now reuses the shared redaction rule and drops a value that still matches a private-text or provider token shape. Both are covered by negative cases, and each case kills its mutant: removing the closeout guard or the redaction rule fails the smoke. Signed-off-by: song --- ...oal-artifact-lifecycle-projection-smoke.py | 91 +++++++++++++++++++ .../control_plane/goals/artifact_lifecycle.py | 55 ++++++++++- 2 files changed, 141 insertions(+), 5 deletions(-) diff --git a/examples/control_plane/goal-artifact-lifecycle-projection-smoke.py b/examples/control_plane/goal-artifact-lifecycle-projection-smoke.py index 7581bcd6dd..994d3c61a6 100644 --- a/examples/control_plane/goal-artifact-lifecycle-projection-smoke.py +++ b/examples/control_plane/goal-artifact-lifecycle-projection-smoke.py @@ -24,6 +24,7 @@ PHASE_QUALIFYING, PHASE_STARTING, PHASE_WAITING_OWNER, + _compact_text, build_goal_artifact_lifecycle_projection, ) @@ -214,6 +215,93 @@ def assert_projection_is_pure_and_reads_no_state() -> None: assert first["lifecycle_phase"] == PHASE_QUALIFYING, first +def assert_unreached_milestone_blocks_closeout() -> None: + """An unclaimed-acceptance Goal must not be told to close.""" + + projection = build_goal_artifact_lifecycle_projection( + goal_id=GOAL_ID, + goal={ + "id": GOAL_ID, + "status": "active", + "acceptance": {"milestones": ["baseline_pass"]}, + }, + user_todo_summary={"gate_open_items": []}, + agent_todo_summary={"open_count": 0}, + run_history={"latest_runs": []}, + ) + assert projection["lifecycle_phase"] == PHASE_QUALIFYING, projection + transitions = projection["next_transitions"] + assert transitions, projection + assert transitions[0]["target_phase"] != PHASE_CLOSED, projection + assert transitions[0]["reason_codes"] == ["milestone_unreached"], projection + + +def assert_reached_milestones_still_allow_closeout() -> None: + """The same inputs with evidence present do reach the closing phase.""" + + projection = build_goal_artifact_lifecycle_projection( + goal_id=GOAL_ID, + goal={ + "id": GOAL_ID, + "status": "active", + "acceptance": {"milestones": ["primary_goal_outcome"]}, + }, + user_todo_summary={"gate_open_items": []}, + agent_todo_summary={"open_count": 0}, + run_history={ + "latest_runs": [ + { + "delivery_outcome": "primary_goal_outcome", + "delivery_batch_scale": "multi_surface", + } + ] + }, + ) + assert projection["lifecycle_phase"] == PHASE_CLOSING, projection + assert projection["next_transitions"][0]["target_phase"] == PHASE_CLOSED, projection + + +def assert_private_values_are_redacted_or_dropped() -> None: + """A run-history reference is free text; private values must not survive.""" + + projection = build_goal_artifact_lifecycle_projection( + goal_id=GOAL_ID, + goal={"id": GOAL_ID, "status": "active"}, + agent_todo_summary={"open_count": 1}, + run_history={ + "latest_runs": [ + { + "delivery_outcome": "primary_goal_outcome", + "delivery_batch_scale": "multi_surface", + "evidence_ref": "/Users/private-owner/.ssh/id_rsa", + "recommended_action": ( + "publish ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ012345 and " + "see /private/var/folders/secret/notes.md" + ), + } + ] + }, + ) + rendered = json.dumps(projection, ensure_ascii=False) + for leaked in ( + "/Users/private-owner", + "/private/var/folders", + "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ012345", + ): + assert leaked not in rendered, (leaked, rendered) + + # Pin the redaction rule itself: a benign local path must be replaced, not + # merely dropped, so the boundary still holds when a caller later renders a + # label this projection chose to keep. + assert _compact_text("/Users/private-owner/notes.md") == ( + "" + ), _compact_text("/Users/private-owner/notes.md") + assert _compact_text("token ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ012345") is None + assert _compact_text("/private/var/folders/x/secret.md") == ( + "" + ) + + def main() -> int: assert_starting_phase_without_work() assert_declared_milestone_stays_unreached_while_gapped() @@ -222,6 +310,9 @@ def main() -> int: assert_evidence_guard_is_required_and_owned_by_the_agent() assert_closing_then_closed_phase() assert_projection_is_pure_and_reads_no_state() + assert_unreached_milestone_blocks_closeout() + assert_reached_milestones_still_allow_closeout() + assert_private_values_are_redacted_or_dropped() print("goal-artifact-lifecycle-projection-smoke ok") return 0 diff --git a/loopx/control_plane/goals/artifact_lifecycle.py b/loopx/control_plane/goals/artifact_lifecycle.py index 51bdf6b7ba..95c80f109c 100644 --- a/loopx/control_plane/goals/artifact_lifecycle.py +++ b/loopx/control_plane/goals/artifact_lifecycle.py @@ -18,8 +18,12 @@ from __future__ import annotations +import re from typing import Any +from ...presentation.public_safety import redact_public_text +from ...public_safe_text import find_private_text_match + GOAL_ARTIFACT_LIFECYCLE_PROJECTION_SCHEMA_VERSION = ( "goal_artifact_lifecycle_projection_v0" ) @@ -37,19 +41,31 @@ _TERMINAL_GOAL_STATUSES = {"closed", "retired", "archived", "done", "complete"} +# Provider token shapes the shared private-text rules do not cover. A run +# history reference is free text, so a leaked token there must never reach a +# public projection just because the shared corpus did not list its prefix. +_TOKEN_SHAPES = re.compile( + r"\b(?:gh[pousr]_[A-Za-z0-9]{16,}|sk-[A-Za-z0-9_-]{16,}|AKIA[0-9A-Z]{16})\b" +) + # A material run outcome that a Goal's own acceptance can rest on. _MATERIAL_OUTCOMES = {"primary_goal_outcome", "outcome_progress", "multi_surface"} def _compact_text(value: Any, *, limit: int = 240) -> str | None: - """Bound one public-safe label; never carry a raw body or path.""" + """Bound and redact one label; never carry a raw body, path or credential.""" if not isinstance(value, str): return None - collapsed = " ".join(value.split()) + collapsed = redact_public_text(value, limit=limit) if not collapsed: return None - return collapsed[:limit] + # The shared sanitizer covers local paths; this projection additionally + # refuses a value that still matches a private-text or credential shape + # rather than publishing a partly-redacted fragment. + if find_private_text_match(collapsed) or _TOKEN_SHAPES.search(collapsed): + return None + return collapsed def _mapping(value: Any) -> dict[str, Any]: @@ -207,7 +223,10 @@ def _lifecycle_phase( total_open = open_count if isinstance(open_count, int) and not isinstance(open_count, bool) else 0 if not milestones and total_open == 0: return PHASE_STARTING - if total_open == 0: + # An unclaimed-acceptance Goal is never closing: running out of open agent + # work is not the same as having reached the declared acceptance markers. + unreached = any(milestone["reached"] is not True for milestone in milestones) + if total_open == 0 and not unreached: return PHASE_CLOSING return PHASE_QUALIFYING @@ -217,6 +236,7 @@ def _next_transitions( *, phase: str, guards: list[dict[str, Any]], + milestones: list[dict[str, Any]], work_lane: dict[str, Any], ) -> list[dict[str, Any]]: """Reuse the existing lane/frontier derivation instead of a second machine.""" @@ -238,6 +258,27 @@ def _next_transitions( "reason_codes": ["guard_open"], } ] + # An existing work-lane constraint outranks this projection's own reading + # of open work: the lane owner decides what runs next. + if lane and phase != PHASE_CLOSING: + return [ + { + "target_phase": PHASE_QUALIFYING, + "precondition": obligation or "advance the selected lane", + "reason_codes": ["work_lane_selected"], + } + ] + unreached = [ + milestone["id"] for milestone in milestones if milestone["reached"] is not True + ] + if unreached: + return [ + { + "target_phase": PHASE_QUALIFYING, + "precondition": "reach the declared acceptance milestones with evidence", + "reason_codes": ["milestone_unreached"], + } + ] if phase == PHASE_CLOSING: return [ { @@ -293,7 +334,11 @@ def build_goal_artifact_lifecycle_projection( "milestones": milestones, "guards": guards, "next_transitions": _next_transitions( - goal_record, phase=phase, guards=guards, work_lane=lane + goal_record, + phase=phase, + guards=guards, + milestones=milestones, + work_lane=lane, ), } From 61631b1f00ee1e88e64404f3e158a4bc7c4a5276 Mon Sep 17 00:00:00 2001 From: song Date: Mon, 14 Sep 2026 17:14:09 +0800 Subject: [PATCH 03/12] fix(goals): own the lifecycle rules inward and give the projection a consumer Three review findings, all reproduced at the previous head: 1. Forbidden dependency direction. The module imported `loopx.presentation.public_safety`, an outward dependency the control plane may not take. Redaction is now bounded and inward-safe inside the module and reuses `loopx.public_safe_text.find_private_text_match` for classification, so the shared private-text contract still has exactly one owner. 2. Material outcome semantics drifted. Evidence milestones kept a local outcome set and let `delivery_batch_scale == "multi_surface"` promote a run on its own. Driving the helper with `delivery_outcome=surface_only` plus `delivery_batch_scale=multi_surface` returned a reached milestone, but batch scale describes delivery width and the canonical typed rule (`MATERIAL_DELIVERY_OUTCOMES`) excludes `surface_only`. The module now consumes that canonical rule directly, and the smoke pins the full outcome x scale matrix in both directions. 3. No production consumer. The RFC's smallest slice requires one readout in status markdown. Status collection now attaches the projection and the presentation renderer prints phase, milestone and guard counts plus the blocking guards and the next transition. `loopx status` shows the readout without any extra IO: it derives only from payloads collection already gathered. Verified: the smoke fails when the scale-promotion rule is restored and when the presentation import is restored; the import-boundary suite is 15/15; status and architecture suites are 29/29; docs governance passes; the four status smokes pass; Ruff reports nothing new on the changed files. Signed-off-by: song --- ...oal-artifact-lifecycle-projection-smoke.py | 120 ++++++++++++++++++ .../control_plane/goals/artifact_lifecycle.py | 89 +++++++++++-- loopx/control_plane/status/collection.py | 2 + .../goal_artifact_lifecycle_markdown.py | 47 +++++++ .../presentation/renderers/status_markdown.py | 2 + 5 files changed, 249 insertions(+), 11 deletions(-) create mode 100644 loopx/presentation/renderers/goal_artifact_lifecycle_markdown.py diff --git a/examples/control_plane/goal-artifact-lifecycle-projection-smoke.py b/examples/control_plane/goal-artifact-lifecycle-projection-smoke.py index 994d3c61a6..9ed5d9e384 100644 --- a/examples/control_plane/goal-artifact-lifecycle-projection-smoke.py +++ b/examples/control_plane/goal-artifact-lifecycle-projection-smoke.py @@ -302,6 +302,123 @@ def assert_private_values_are_redacted_or_dropped() -> None: ) +def assert_batch_scale_never_promotes_an_outcome() -> None: + """The batch scale describes delivery width; it is not Goal evidence. + + Regression for the counterexample the review raised: a `surface_only` run + also carrying `delivery_batch_scale=multi_surface` must not become a reached + milestone, because the canonical typed rule + (`MATERIAL_DELIVERY_OUTCOMES`) excludes `surface_only`. + """ + + for scale in ("single_surface", "multi_surface"): + for outcome in ("surface_only", "", "bogus", "multi_surface"): + projection = build_goal_artifact_lifecycle_projection( + goal_id=GOAL_ID, + goal={"id": GOAL_ID, "status": "active"}, + run_history={ + "latest_runs": [ + { + "delivery_outcome": outcome, + "delivery_batch_scale": scale, + "run_id": "run-1", + } + ] + }, + ) + assert projection["milestones"] == [], (outcome, scale, projection["milestones"]) + # Every canonical material outcome still counts, at any scale. + for scale in ("single_surface", "multi_surface"): + for outcome in ("outcome_gap", "outcome_progress", "primary_goal_outcome"): + projection = build_goal_artifact_lifecycle_projection( + goal_id=GOAL_ID, + goal={"id": GOAL_ID, "status": "active"}, + run_history={ + "latest_runs": [ + { + "delivery_outcome": outcome, + "delivery_batch_scale": scale, + "run_id": "run-1", + } + ] + }, + ) + reached = [item for item in projection["milestones"] if item["reached"]] + assert [item["id"] for item in reached] == [outcome], (outcome, scale, reached) + + +def assert_status_collection_attaches_a_readable_readout() -> None: + """The RFC's smallest slice includes one readout in status markdown. + + Drives the same seam `loopx status` uses: collection attaches the + projection, the presentation renderer prints it. The projection is derived + from already-collected payloads, so this asserts no extra IO is required. + """ + + from loopx.control_plane.goals.artifact_lifecycle import ( + attach_goal_artifact_lifecycle_projections, + ) + from loopx.presentation.renderers.status_markdown import render_status_markdown + + payload = { + "run_history": { + "goals": [ + { + "id": GOAL_ID, + "status": "active", + "acceptance": {"milestones": ["baseline_pass"]}, + } + ] + }, + "attention_queue": { + "items": [ + { + "goal_id": GOAL_ID, + "user_todo_summary": { + "gate_open_items": [ + { + "todo_id": "todo_gate", + "text": "approve the release", + "status": "open", + "action_kind": "publish", + } + ] + }, + "agent_todo_summary": {"open_count": 0}, + } + ] + }, + } + attach_goal_artifact_lifecycle_projections(payload, history={"goals": []}) + goal = payload["run_history"]["goals"][0] + projection = goal["artifact_lifecycle"] + assert projection["schema_version"] == GOAL_ARTIFACT_LIFECYCLE_PROJECTION_SCHEMA_VERSION + assert projection["lifecycle_phase"] == PHASE_WAITING_OWNER, projection + assert [guard["id"] for guard in projection["guards"]] == ["todo_gate"] + markdown = render_status_markdown(payload) + assert "artifact lifecycle: phase=waiting_owner" in markdown, markdown + assert "blocked by owner_decision (user): todo_gate" in markdown, markdown + assert_no_public_leak(projection) + + +def assert_control_plane_imports_no_presentation_module() -> None: + """The projection may not depend outward on the presentation layer.""" + + import ast + + source = ( + REPO_ROOT / "loopx" / "control_plane" / "goals" / "artifact_lifecycle.py" + ).read_text(encoding="utf-8") + imported: list[str] = [] + for node in ast.walk(ast.parse(source)): + if isinstance(node, ast.ImportFrom) and node.module: + imported.append(node.module) + elif isinstance(node, ast.Import): + imported.extend(alias.name for alias in node.names) + offenders = [name for name in imported if "presentation" in name] + assert offenders == [], offenders + + def main() -> int: assert_starting_phase_without_work() assert_declared_milestone_stays_unreached_while_gapped() @@ -313,6 +430,9 @@ def main() -> int: assert_unreached_milestone_blocks_closeout() assert_reached_milestones_still_allow_closeout() assert_private_values_are_redacted_or_dropped() + assert_batch_scale_never_promotes_an_outcome() + assert_status_collection_attaches_a_readable_readout() + assert_control_plane_imports_no_presentation_module() print("goal-artifact-lifecycle-projection-smoke ok") return 0 diff --git a/loopx/control_plane/goals/artifact_lifecycle.py b/loopx/control_plane/goals/artifact_lifecycle.py index 95c80f109c..6f35040134 100644 --- a/loopx/control_plane/goals/artifact_lifecycle.py +++ b/loopx/control_plane/goals/artifact_lifecycle.py @@ -21,8 +21,11 @@ import re from typing import Any -from ...presentation.public_safety import redact_public_text from ...public_safe_text import find_private_text_match +from ..work_items.delivery_outcome import ( + MATERIAL_DELIVERY_OUTCOMES, + normalize_delivery_outcome, +) GOAL_ARTIFACT_LIFECYCLE_PROJECTION_SCHEMA_VERSION = ( "goal_artifact_lifecycle_projection_v0" @@ -48,8 +51,27 @@ r"\b(?:gh[pousr]_[A-Za-z0-9]{16,}|sk-[A-Za-z0-9_-]{16,}|AKIA[0-9A-Z]{16})\b" ) -# A material run outcome that a Goal's own acceptance can rest on. -_MATERIAL_OUTCOMES = {"primary_goal_outcome", "outcome_progress", "multi_surface"} +# Local path shapes this projection rewrites before publishing a label. The +# control plane may not import the presentation layer's sanitizer, and the +# lifecycle projection must not mint a second copy of the shared private-text +# contract: it reuses `find_private_text_match` for classification and keeps +# only this bounded, inward-safe rewrite. +_PATH_SHAPES = ( + re.compile(r"/(?:Users|home|private|tmp|var)/[^\s`|,)]+"), + re.compile(r"[A-Za-z]:\\\\Users\\\\[^\s`|,)]+"), +) + +_TRUNCATION_MARKER = "..." + + +def _bounded_redacted_text(value: Any, *, limit: int) -> str: + text = str(value or "").strip() + for pattern in _PATH_SHAPES: + text = pattern.sub("", text) + text = re.sub(r"\s+", " ", text) + if len(text) > limit: + return text[: max(0, limit - 1)].rstrip() + _TRUNCATION_MARKER + return text def _compact_text(value: Any, *, limit: int = 240) -> str | None: @@ -57,10 +79,10 @@ def _compact_text(value: Any, *, limit: int = 240) -> str | None: if not isinstance(value, str): return None - collapsed = redact_public_text(value, limit=limit) + collapsed = _bounded_redacted_text(value, limit=limit) if not collapsed: return None - # The shared sanitizer covers local paths; this projection additionally + # The shared private-text contract classifies; this projection additionally # refuses a value that still matches a private-text or credential shape # rather than publishing a partly-redacted fragment. if find_private_text_match(collapsed) or _TOKEN_SHAPES.search(collapsed): @@ -125,13 +147,14 @@ def _evidence_milestones(run_history: dict[str, Any]) -> list[dict[str, Any]]: record = _mapping(run) if not record: continue - outcome = str(record.get("delivery_outcome") or "").strip() - scale = str(record.get("delivery_batch_scale") or "").strip() - classification = str(record.get("classification") or "").strip() - if outcome not in _MATERIAL_OUTCOMES and scale != "multi_surface": + outcome = normalize_delivery_outcome(record.get("delivery_outcome")) + # Only a canonical material delivery outcome is Goal evidence. The batch + # scale describes how wide a delivery was, never whether it advanced the + # Goal, so it cannot promote `surface_only` into a reached milestone. + if outcome is None or outcome not in MATERIAL_DELIVERY_OUTCOMES: continue - milestone_id = outcome or scale or classification - if not milestone_id or milestone_id in seen: + milestone_id = outcome.value + if milestone_id in seen: continue seen.add(milestone_id) reference = _compact_text(record.get("evidence_ref") or record.get("run_id"), limit=120) @@ -343,6 +366,49 @@ def build_goal_artifact_lifecycle_projection( } +def attach_goal_artifact_lifecycle_projections( + payload: dict[str, Any], *, history: dict[str, Any] +) -> None: + """Attach one bounded lifecycle projection to every projected Goal. + + Reads only payloads status collection already gathered, so the operator + readout costs no extra IO and grants no authority. A Goal the projection + cannot derive from is left without the key rather than given a placeholder. + """ + + sources = {str(goal.get("id")): goal for goal in _list(history.get("goals")) if _mapping(goal)} + run_history = _mapping(payload.get("run_history")) + items = _list(_mapping(payload.get("attention_queue")).get("items")) + for goal in _list(run_history.get("goals")): + record = _mapping(goal) + goal_id = str(record.get("id") or "").strip() + if not goal_id: + continue + source = {**sources.get(goal_id, {}), **record} + item = next( + (row for row in items if _mapping(row).get("goal_id") == goal_id), None + ) + attention = _mapping(item) + projection = build_goal_artifact_lifecycle_projection( + goal_id=goal_id, + goal=source, + user_todo_summary=_mapping( + attention.get("user_todo_summary") or source.get("user_todo_summary") + ), + agent_todo_summary=_mapping( + attention.get("agent_todo_summary") or source.get("agent_todo_summary") + ), + run_history=run_history, + work_lane_contract=_mapping( + source.get("work_lane_contract") or attention.get("work_lane_contract") + ), + acceptance_gaps=_list( + source.get("acceptance_gaps") or attention.get("acceptance_gaps") + ), + ) + record["artifact_lifecycle"] = projection + + __all__ = [ "GOAL_ARTIFACT_LIFECYCLE_PROJECTION_SCHEMA_VERSION", "GUARD_KIND_EVIDENCE", @@ -352,5 +418,6 @@ def build_goal_artifact_lifecycle_projection( "PHASE_QUALIFYING", "PHASE_STARTING", "PHASE_WAITING_OWNER", + "attach_goal_artifact_lifecycle_projections", "build_goal_artifact_lifecycle_projection", ] diff --git a/loopx/control_plane/status/collection.py b/loopx/control_plane/status/collection.py index 76ba2e4849..d126325a7f 100644 --- a/loopx/control_plane/status/collection.py +++ b/loopx/control_plane/status/collection.py @@ -9,6 +9,7 @@ from typing import Any, Callable from ..goals.acceptance_observation import attach_goal_acceptance_observations +from ..goals.artifact_lifecycle import attach_goal_artifact_lifecycle_projections from ..goals.contract_health import project_contract_health_for_goal from ..goals.activation import ( GoalActivationState, @@ -243,4 +244,5 @@ def collect_status( goal_channel_notification_projection ) attach_goal_acceptance_observations(payload, history=history) + attach_goal_artifact_lifecycle_projections(payload, history=history) return payload diff --git a/loopx/presentation/renderers/goal_artifact_lifecycle_markdown.py b/loopx/presentation/renderers/goal_artifact_lifecycle_markdown.py new file mode 100644 index 0000000000..e7ff11f7d7 --- /dev/null +++ b/loopx/presentation/renderers/goal_artifact_lifecycle_markdown.py @@ -0,0 +1,47 @@ +"""Render the derived Goal artifact lifecycle projection owned by status collection.""" + +from __future__ import annotations + +from typing import Any + +from ...control_plane.goals.artifact_lifecycle import ( + GOAL_ARTIFACT_LIFECYCLE_PROJECTION_SCHEMA_VERSION, +) +from ..markdown import as_dict, as_list, markdown_scalar + + +def append_goal_artifact_lifecycle_markdown( + lines: list[str], goal: dict[str, Any] +) -> None: + projection = as_dict(goal.get("artifact_lifecycle")) + if ( + projection.get("schema_version") + != GOAL_ARTIFACT_LIFECYCLE_PROJECTION_SCHEMA_VERSION + ): + return + milestones = as_list(projection.get("milestones")) + reached = [ + milestone + for milestone in milestones + if isinstance(milestone, dict) and milestone.get("reached") is True + ] + lines.append( + " - artifact lifecycle: " + f"phase={markdown_scalar(projection.get('lifecycle_phase') or 'unknown')} " + f"milestones={len(reached)}/{len(milestones)} " + f"guards={len(as_list(projection.get('guards')))}" + ) + for guard in as_list(projection.get("guards")): + if not isinstance(guard, dict) or guard.get("blocked") is not True: + continue + lines.append( + f" - blocked by {markdown_scalar(guard.get('kind') or 'unknown')} " + f"({markdown_scalar(guard.get('owner') or 'unowned')}): " + f"{markdown_scalar(guard.get('id') or 'unknown')}" + ) + for transition in as_list(projection.get("next_transitions")): + if isinstance(transition, dict): + lines.append( + f" - next: {markdown_scalar(transition.get('target_phase') or 'unknown')} " + f"({markdown_scalar(transition.get('precondition') or 'unknown')})" + ) diff --git a/loopx/presentation/renderers/status_markdown.py b/loopx/presentation/renderers/status_markdown.py index 63506f9a1c..e21d22af90 100644 --- a/loopx/presentation/renderers/status_markdown.py +++ b/loopx/presentation/renderers/status_markdown.py @@ -10,6 +10,7 @@ from ...orchestration import orchestration_policy_summary from ..markdown import as_dict, as_list, markdown_scalar from .goal_acceptance_observation_markdown import append_goal_acceptance_observation_markdown +from .goal_artifact_lifecycle_markdown import append_goal_artifact_lifecycle_markdown from .reward_memory_markdown import append_agent_reward_memory_markdown @@ -246,6 +247,7 @@ def append_run_history_markdown(lines: list[str], run_history: dict[str, Any]) - f"unique_runs={goal.get('unique_runs')}" ) append_goal_acceptance_observation_markdown(lines, goal) + append_goal_artifact_lifecycle_markdown(lines, goal) quota = goal.get("quota") if isinstance(goal.get("quota"), dict) else {} if quota: lines.append( From 4c6024f7b74b171de7efd36f59eddaa3e2d545e8 Mon Sep 17 00:00:00 2001 From: song Date: Mon, 14 Sep 2026 18:01:39 +0800 Subject: [PATCH 04/12] test(goals): keep the two Goal projections distinct now that both ship MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI `test-shard (2)` caught a real conflict I had missed: `#4248` shipped `goal_acceptance_observation_projection_v0` as bounded historical evidence and guarded that it is *not* the full lifecycle contract, partly by asserting the collected Goal carries no `artifact_lifecycle` key at all. Attaching the real lifecycle projection made that guard fail (2100 passed, 1 failed). The guard's intent was the distinction, not the absence, so this preserves the intent under the new reality instead of deleting the assertion. The collection test now requires both projections to be present under their own keys with their own schema versions, and requires the phase/milestone/transition vocabulary to belong to the lifecycle projection alone. The smoke gains the symmetric renderer check `#4248` established for its own renderer: each renderer must refuse the other's schema rather than print a half-understood payload under its own heading. Verified by mutation — relaxing the lifecycle renderer's schema check to "any non-empty payload" makes it print `phase=unknown milestones=0/0` for an acceptance observation, and the new assertion fails. `test_markdown_rejects_the_distinct_full_lifecycle_contract` is untouched and still passes. Signed-off-by: song --- ...oal-artifact-lifecycle-projection-smoke.py | 44 +++++++++++++++++++ .../test_goal_acceptance_observation.py | 10 ++++- 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/examples/control_plane/goal-artifact-lifecycle-projection-smoke.py b/examples/control_plane/goal-artifact-lifecycle-projection-smoke.py index 9ed5d9e384..3f09360711 100644 --- a/examples/control_plane/goal-artifact-lifecycle-projection-smoke.py +++ b/examples/control_plane/goal-artifact-lifecycle-projection-smoke.py @@ -401,6 +401,49 @@ def assert_status_collection_attaches_a_readable_readout() -> None: assert_no_public_leak(projection) +def assert_the_two_goal_projections_stay_distinct() -> None: + """The lifecycle contract and the narrow acceptance observation must not mix. + + `#4248` shipped `goal_acceptance_observation_projection_v0` as bounded + historical evidence and guarded that it is not the full lifecycle contract. + Both now ship, so each renderer must refuse the other's schema rather than + print a half-understood payload under its own heading. + """ + + from loopx.presentation.renderers.goal_acceptance_observation_markdown import ( + append_goal_acceptance_observation_markdown, + ) + from loopx.presentation.renderers.goal_artifact_lifecycle_markdown import ( + append_goal_artifact_lifecycle_markdown, + ) + + lifecycle = build_goal_artifact_lifecycle_projection( + goal_id=GOAL_ID, goal={"id": GOAL_ID, "status": "active"} + ) + narrow = { + "schema_version": "goal_acceptance_observation_projection_v0", + "guards": [], + "acceptance_gaps": [], + "historical_progress": [], + } + # Each renderer prints only its own contract, whichever key carries it. + for goal in ({"artifact_lifecycle": narrow}, {"artifact_lifecycle": {}}): + lines: list[str] = [] + append_goal_artifact_lifecycle_markdown(lines, goal) + assert lines == [], (goal, lines) + for goal in ({"acceptance_observation": lifecycle}, {"acceptance_observation": {}}): + lines = [] + append_goal_acceptance_observation_markdown(lines, goal) + assert lines == [], (goal, lines) + # And the lifecycle renderer does print its own contract. + lines = [] + append_goal_artifact_lifecycle_markdown(lines, {"artifact_lifecycle": lifecycle}) + assert any("artifact lifecycle" in line for line in lines), lines + # The phase/milestone/transition vocabulary belongs to the lifecycle alone. + assert {"lifecycle_phase", "milestones", "next_transitions"} <= set(lifecycle) + assert {"lifecycle_phase", "milestones", "next_transitions"}.isdisjoint(narrow) + + def assert_control_plane_imports_no_presentation_module() -> None: """The projection may not depend outward on the presentation layer.""" @@ -433,6 +476,7 @@ def main() -> int: assert_batch_scale_never_promotes_an_outcome() assert_status_collection_attaches_a_readable_readout() assert_control_plane_imports_no_presentation_module() + assert_the_two_goal_projections_stay_distinct() print("goal-artifact-lifecycle-projection-smoke ok") return 0 diff --git a/tests/control_plane/test_goal_acceptance_observation.py b/tests/control_plane/test_goal_acceptance_observation.py index c1c926f718..791fdf4bc4 100644 --- a/tests/control_plane/test_goal_acceptance_observation.py +++ b/tests/control_plane/test_goal_acceptance_observation.py @@ -222,9 +222,17 @@ def test_real_collection_preserves_acceptance_before_display_run_trimming(tmp_pa result = collect_fixture(tmp_path) goal = result["run_history"]["goals"][0] assert goal["latest_runs"] == [] - assert "artifact_lifecycle" not in goal projection = goal["acceptance_observation"] assert projection["schema_version"] == "goal_acceptance_observation_projection_v0" + # The full lifecycle contract now ships beside this one. They stay distinct + # projections under distinct keys and schema versions: this observation is + # bounded historical evidence, never the lifecycle's phase/milestone answer. + lifecycle = goal["artifact_lifecycle"] + assert lifecycle["schema_version"] == "goal_artifact_lifecycle_projection_v0" + assert lifecycle["schema_version"] != projection["schema_version"] + # Only the lifecycle projection answers phase, milestones and transitions. + assert {"lifecycle_phase", "milestones", "next_transitions"} <= set(lifecycle) + assert {"lifecycle_phase", "milestones", "next_transitions"}.isdisjoint(projection) assert ( projection["acceptance_gaps"][0]["evidence_required"] == "Independent verification report" From eadd07cdf6ca940f65ea1419dbe3b40af8ea722c Mon Sep 17 00:00:00 2001 From: song Date: Tue, 15 Sep 2026 13:47:09 +0800 Subject: [PATCH 05/12] fix(goals): derive lifecycle from canonical status and safety boundaries Own the Goal status vocabulary and the public-safety boundary from their canonical modules instead of local copies, and give every emitted string the same redaction the rest of the control plane applies. The shared public-safety rule widening (drive-qualified and UNC paths, quoted secret keys, `/data/` roots) that this change originally carried is now a separate PR, because it changes a rule other owners consume and alters the turn-executor diagnostic contract. This projection only needs the rules that already exist. Signed-off-by: song --- ...oal-artifact-lifecycle-projection-smoke.py | 24 ++- .../control_plane/goals/artifact_lifecycle.py | 148 +++++++++--------- .../test_goal_acceptance_observation.py | 140 ++++++++++++++++- 3 files changed, 222 insertions(+), 90 deletions(-) diff --git a/examples/control_plane/goal-artifact-lifecycle-projection-smoke.py b/examples/control_plane/goal-artifact-lifecycle-projection-smoke.py index 3f09360711..d920e5ef48 100644 --- a/examples/control_plane/goal-artifact-lifecycle-projection-smoke.py +++ b/examples/control_plane/goal-artifact-lifecycle-projection-smoke.py @@ -15,7 +15,7 @@ if str(REPO_ROOT) not in sys.path: sys.path.insert(0, str(REPO_ROOT)) -from loopx.control_plane.goals.artifact_lifecycle import ( +from loopx.control_plane.goals.artifact_lifecycle import ( # noqa: E402 - source-checkout entrypoint GOAL_ARTIFACT_LIFECYCLE_PROJECTION_SCHEMA_VERSION, GUARD_KIND_EVIDENCE, GUARD_KIND_OWNER_DECISION, @@ -145,7 +145,7 @@ def assert_open_owner_gate_blocks_the_next_transition() -> None: assert guard["kind"] == GUARD_KIND_OWNER_DECISION, projection assert guard["blocked"] is True, projection assert guard["owner"] == "user", projection - assert guard["decision_scope"] == "approve_baseline", projection + assert guard["decision_scope"] is None, projection # A blocking guard admits no other transition, even with a selected lane. transitions = projection["next_transitions"] assert len(transitions) == 1, projection @@ -290,16 +290,11 @@ def assert_private_values_are_redacted_or_dropped() -> None: ): assert leaked not in rendered, (leaked, rendered) - # Pin the redaction rule itself: a benign local path must be replaced, not - # merely dropped, so the boundary still holds when a caller later renders a - # label this projection chose to keep. - assert _compact_text("/Users/private-owner/notes.md") == ( - "" - ), _compact_text("/Users/private-owner/notes.md") + # Unsafe references are withheld by the same validator as other status + # projections; no local path is retained even as a truncated fragment. + assert _compact_text("/Users/private-owner/notes.md") is None assert _compact_text("token ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ012345") is None - assert _compact_text("/private/var/folders/x/secret.md") == ( - "" - ) + assert _compact_text("/private/var/folders/x/secret.md") is None def assert_batch_scale_never_promotes_an_outcome() -> None: @@ -374,17 +369,18 @@ def assert_status_collection_attaches_a_readable_readout() -> None: "items": [ { "goal_id": GOAL_ID, - "user_todo_summary": { - "gate_open_items": [ + "user_todos": { + "items": [ { "todo_id": "todo_gate", + "task_class": "user_gate", "text": "approve the release", "status": "open", "action_kind": "publish", } ] }, - "agent_todo_summary": {"open_count": 0}, + "agent_todos": {"open_count": 0}, } ] }, diff --git a/loopx/control_plane/goals/artifact_lifecycle.py b/loopx/control_plane/goals/artifact_lifecycle.py index 6f35040134..0c96cbf390 100644 --- a/loopx/control_plane/goals/artifact_lifecycle.py +++ b/loopx/control_plane/goals/artifact_lifecycle.py @@ -22,6 +22,8 @@ from typing import Any from ...public_safe_text import find_private_text_match +from ..runtime.public_safety import public_safe_compact_text, validate_public_safe_value +from .acceptance_observation import build_goal_acceptance_observation from ..work_items.delivery_outcome import ( MATERIAL_DELIVERY_OUTCOMES, normalize_delivery_outcome, @@ -51,43 +53,19 @@ r"\b(?:gh[pousr]_[A-Za-z0-9]{16,}|sk-[A-Za-z0-9_-]{16,}|AKIA[0-9A-Z]{16})\b" ) -# Local path shapes this projection rewrites before publishing a label. The -# control plane may not import the presentation layer's sanitizer, and the -# lifecycle projection must not mint a second copy of the shared private-text -# contract: it reuses `find_private_text_match` for classification and keeps -# only this bounded, inward-safe rewrite. -_PATH_SHAPES = ( - re.compile(r"/(?:Users|home|private|tmp|var)/[^\s`|,)]+"), - re.compile(r"[A-Za-z]:\\\\Users\\\\[^\s`|,)]+"), -) - -_TRUNCATION_MARKER = "..." - - -def _bounded_redacted_text(value: Any, *, limit: int) -> str: - text = str(value or "").strip() - for pattern in _PATH_SHAPES: - text = pattern.sub("", text) - text = re.sub(r"\s+", " ", text) - if len(text) > limit: - return text[: max(0, limit - 1)].rstrip() + _TRUNCATION_MARKER - return text - - def _compact_text(value: Any, *, limit: int = 240) -> str | None: - """Bound and redact one label; never carry a raw body, path or credential.""" - + """Validate the complete source before bounding any public label/ref.""" if not isinstance(value, str): return None - collapsed = _bounded_redacted_text(value, limit=limit) - if not collapsed: + try: + validate_public_safe_value(value) + except ValueError: return None - # The shared private-text contract classifies; this projection additionally - # refuses a value that still matches a private-text or credential shape - # rather than publishing a partly-redacted fragment. - if find_private_text_match(collapsed) or _TOKEN_SHAPES.search(collapsed): + # Preserve the stricter existing private-text/provider-token contract too; + # these checks supplement, never replace, the shared public-safety owner. + if find_private_text_match(value) or _TOKEN_SHAPES.search(value): return None - return collapsed + return public_safe_compact_text(value, limit=limit) def _mapping(value: Any) -> dict[str, Any]: @@ -157,7 +135,12 @@ def _evidence_milestones(run_history: dict[str, Any]) -> list[dict[str, Any]]: if milestone_id in seen: continue seen.add(milestone_id) - reference = _compact_text(record.get("evidence_ref") or record.get("run_id"), limit=120) + # Collected history exposes a timestamp, not always a public run id. + # Keep that existing locator; never publish its JSON/Markdown path. + reference = _compact_text( + record.get("evidence_ref") or record.get("run_id") or record.get("generated_at"), + limit=120, + ) milestones.append( { "id": milestone_id, @@ -181,7 +164,7 @@ def _milestones( return evidence # A declared marker is a claim about what the Goal intends to reach, not # proof that it did. It counts as reached only when evidence already - # records that outcome, or when the Goal marks it reached explicitly. + # records that outcome. reached_outcomes = {item["id"] for item in evidence if item["reached"]} for milestone in declared: milestone["reached"] = milestone["id"] in reached_outcomes @@ -189,7 +172,7 @@ def _milestones( def _guards( - user_summary: dict[str, Any], + observation: dict[str, Any], acceptance_gaps: list[Any], *, agent_id: str | None, @@ -197,22 +180,19 @@ def _guards( """Open owner decisions and unmet evidence preconditions.""" guards: list[dict[str, Any]] = [] - for item in _list(user_summary.get("gate_open_items")): - record = _mapping(item) - if not record: + for record in _list(observation.get("guards")): + if not _mapping(record): continue - blocking_agent = _compact_text(record.get("blocks_agent"), limit=120) - guards.append( - { - "id": _compact_text(record.get("todo_id"), limit=120) or "owner_gate", - "kind": GUARD_KIND_OWNER_DECISION, - "blocked": True, - "owner": "user", - "decision_scope": _compact_text(record.get("action_kind"), limit=120), - "evidence_required": False, - "blocks_agent": blocking_agent, - } - ) + guards.append({ + "id": _compact_text(record.get("todo_id"), limit=120) or "operator_gate", + "kind": GUARD_KIND_OWNER_DECISION, + "blocked": True, + "owner": _compact_text(record.get("owner"), limit=120) + or ("user" if record.get("kind") == "user_gate" else "controller"), + "decision_scope": _compact_text(record.get("decision_scope")), + "evidence_required": bool(record.get("evidence_required")), + "blocks_agent": _compact_text(record.get("blocks_agent"), limit=120), + }) for gap in acceptance_gaps: record = _mapping(gap) if not record: @@ -225,7 +205,8 @@ def _guards( "owner": "agent", "decision_scope": None, "evidence_required": True, - "agent_id": _compact_text(record.get("agent_id"), limit=120) or agent_id, + "agent_id": _compact_text(record.get("agent_id") or record.get("owner"), limit=120) + or _compact_text(agent_id, limit=120), } ) return guards @@ -237,13 +218,19 @@ def _lifecycle_phase( guards: list[dict[str, Any]], milestones: list[dict[str, Any]], agent_summary: dict[str, Any], + work_lane: dict[str, Any], ) -> str: if _is_closed(goal): return PHASE_CLOSED if any(guard["kind"] == GUARD_KIND_OWNER_DECISION for guard in guards): return PHASE_WAITING_OWNER + if guards or work_lane.get("must_attempt_work") is True: + return PHASE_QUALIFYING open_count = agent_summary.get("open_count") - total_open = open_count if isinstance(open_count, int) and not isinstance(open_count, bool) else 0 + if not isinstance(open_count, int) or isinstance(open_count, bool): + # An omitted/bounded source is not evidence that no work remains. + return PHASE_QUALIFYING if milestones or guards else PHASE_STARTING + total_open = open_count if not milestones and total_open == 0: return PHASE_STARTING # An unclaimed-acceptance Goal is never closing: running out of open agent @@ -283,7 +270,7 @@ def _next_transitions( ] # An existing work-lane constraint outranks this projection's own reading # of open work: the lane owner decides what runs next. - if lane and phase != PHASE_CLOSING: + if lane: return [ { "target_phase": PHASE_QUALIFYING, @@ -310,14 +297,6 @@ def _next_transitions( "reason_codes": ["no_open_agent_work"], } ] - if lane: - return [ - { - "target_phase": PHASE_QUALIFYING, - "precondition": obligation or "advance the selected lane", - "reason_codes": ["work_lane_selected"], - } - ] return [] @@ -331,6 +310,7 @@ def build_goal_artifact_lifecycle_projection( work_lane_contract: dict[str, Any] | None = None, acceptance_gaps: list[Any] | None = None, agent_id: str | None = None, + attention_item: dict[str, Any] | None = None, ) -> dict[str, Any]: """Derive the read-only lifecycle projection for one Goal. @@ -341,18 +321,36 @@ def build_goal_artifact_lifecycle_projection( goal_record = _mapping(goal) user_summary = _mapping(user_todo_summary) agent_summary = _mapping(agent_todo_summary) - history = _mapping(run_history) + raw_history = _mapping(run_history) + runs = list(_list(raw_history.get("latest_runs"))) + # The collector already preserves semantic evidence beyond the display/ + # recent-run window. Reuse that retained state instead of widening a limit. + for context in _list(_mapping(raw_history.get("semantic_history")).get("agents")): + for key in ("latest_material_milestone_run", "latest_agent_vision_run"): + retained = _mapping(context).get(key) + if _mapping(retained) and retained not in runs: + runs.append(retained) + history = {"latest_runs": [ + record for record in runs + if _mapping(record) and record.get("goal_id") in (None, goal_id) + ]} lane = _mapping(work_lane_contract) + observation = build_goal_acceptance_observation( + {**goal_record, "id": goal_id, **history}, + attention_item if attention_item is not None else {"user_todos": user_summary}, + ) gaps = [gap for gap in _list(acceptance_gaps) if _mapping(gap)] - + if acceptance_gaps is None: + gaps = _list(observation.get("acceptance_gaps")) milestones = _milestones(goal_record, history) - guards = _guards(user_summary, gaps, agent_id=agent_id) + guards = _guards(observation, gaps, agent_id=agent_id) phase = _lifecycle_phase( - goal_record, guards=guards, milestones=milestones, agent_summary=agent_summary + goal_record, guards=guards, milestones=milestones, agent_summary=agent_summary, + work_lane=lane, ) return { "schema_version": GOAL_ARTIFACT_LIFECYCLE_PROJECTION_SCHEMA_VERSION, - "goal_id": str(goal_id), + "goal_id": _compact_text(goal_id, limit=120) or "unknown", "lifecycle_phase": phase, "milestones": milestones, "guards": guards, @@ -384,27 +382,29 @@ def attach_goal_artifact_lifecycle_projections( goal_id = str(record.get("id") or "").strip() if not goal_id: continue - source = {**sources.get(goal_id, {}), **record} + source = {**record, **sources.get(goal_id, {})} item = next( (row for row in items if _mapping(row).get("goal_id") == goal_id), None ) attention = _mapping(item) + asset = _mapping(attention.get("project_asset")) + frontier = _mapping(attention.get("goal_frontier_projection") or asset.get("goal_frontier_projection")) projection = build_goal_artifact_lifecycle_projection( goal_id=goal_id, goal=source, user_todo_summary=_mapping( - attention.get("user_todo_summary") or source.get("user_todo_summary") + attention.get("user_todos") or asset.get("user_todos") ), agent_todo_summary=_mapping( - attention.get("agent_todo_summary") or source.get("agent_todo_summary") + attention.get("agent_todos") or asset.get("agent_todos") ), - run_history=run_history, + run_history=source, work_lane_contract=_mapping( - source.get("work_lane_contract") or attention.get("work_lane_contract") - ), - acceptance_gaps=_list( - source.get("acceptance_gaps") or attention.get("acceptance_gaps") + attention.get("work_lane_contract") or asset.get("work_lane_contract") + or source.get("work_lane_contract") ), + acceptance_gaps=frontier.get("acceptance_gaps") if "acceptance_gaps" in frontier else None, + attention_item=attention, ) record["artifact_lifecycle"] = projection diff --git a/tests/control_plane/test_goal_acceptance_observation.py b/tests/control_plane/test_goal_acceptance_observation.py index 791fdf4bc4..545320fa20 100644 --- a/tests/control_plane/test_goal_acceptance_observation.py +++ b/tests/control_plane/test_goal_acceptance_observation.py @@ -144,7 +144,13 @@ def test_redaction_precedes_truncation_and_bounded_output(): assert many["truncated"] and len(many["acceptance_gaps"]) == 12 -def collect_fixture(root: Path, *, missing_claim: bool = False) -> dict: +def collect_fixture( + root: Path, + *, + missing_claim: bool = False, + display_limit: int = 0, + delivery_outcome: str | None = None, +) -> dict: project, runtime = root / "project", root / "runtime" project.mkdir(parents=True) state = project / "ACTIVE_GOAL_STATE.md" @@ -182,6 +188,7 @@ def collect_fixture(root: Path, *, missing_claim: bool = False) -> dict: project=project, state_file=state, classification="state_refreshed", + delivery_outcome=delivery_outcome, recommended_action=None, agent_id="agent-a", agent_vision_packet={ @@ -213,7 +220,7 @@ def collect_fixture(root: Path, *, missing_claim: bool = False) -> dict: registry_path=registry, runtime_root_override=str(runtime), scan_roots=[], - limit=0, + limit=display_limit, include_public_boundary_scan=False, ) @@ -240,6 +247,16 @@ def test_real_collection_preserves_acceptance_before_display_run_trimming(tmp_pa assert projection["acceptance_gaps"][0]["owner"] == "agent-a" assert projection["guards"][0]["blocks_agent"] == "agent-a" validate_public_safe_value(projection) + assert lifecycle["lifecycle_phase"] == "waiting_owner" + assert {(g["id"], g["kind"]) for g in lifecycle["guards"]} == { + ("todo_review", "owner_decision"), ("vision_acceptance_gap", "evidence_precondition"), + } + validate_public_safe_value(lifecycle) + from loopx.presentation.renderers.goal_artifact_lifecycle_markdown import append_goal_artifact_lifecycle_markdown + lines = [] + append_goal_artifact_lifecycle_markdown(lines, goal) + assert "phase=waiting_owner" in "\n".join(lines) + assert "vision_acceptance_gap" in "\n".join(lines) def test_closed_stage_retains_canonical_successor_requirement(): @@ -297,3 +314,122 @@ def test_markdown_rejects_the_distinct_full_lifecycle_contract(): lines, {"acceptance_observation": observation} ) assert "Independent verification report" in "\n".join(lines) + + +def test_lifecycle_evidence_survives_real_display_trimming(tmp_path): + for limit in (0, 5): + result = collect_fixture(tmp_path / str(limit), display_limit=limit, delivery_outcome="outcome_progress") + goal = result["run_history"]["goals"][0] + if limit == 0: + assert goal["latest_runs"] == [] + milestones = goal["artifact_lifecycle"]["milestones"] + assert [m["id"] for m in milestones] == ["outcome_progress"] + assert milestones[0]["reached_evidence_refs"] + + +def test_lifecycle_cannot_close_over_canonical_mandatory_lane(): + from loopx.control_plane.goals.artifact_lifecycle import build_goal_artifact_lifecycle_projection + from loopx.control_plane.work_items.work_lane import lark_inbox_reply_due_work_lane_contract + lane = lark_inbox_reply_due_work_lane_contract( + {"capabilities": {"lark_event_inbox": {"urgency": {"reply_due": True}}}}, + current_contract=None, + ) + projection = build_goal_artifact_lifecycle_projection( + goal_id="demo", goal={"status": "active"}, + agent_todo_summary={"open_count": 0}, + run_history={"latest_runs": [{"delivery_outcome": "primary_goal_outcome", "run_id": "run-evidence"}]}, + work_lane_contract=lane, + ) + assert projection["lifecycle_phase"] == "qualifying" + assert projection["next_transitions"][0]["target_phase"] == "qualifying" + assert projection["next_transitions"][0]["precondition"] == lane["obligation"] + + +def test_lifecycle_public_safety_covers_all_emitted_text(): + from loopx.control_plane.goals.artifact_lifecycle import build_goal_artifact_lifecycle_projection + unsafe_values = [ + "C:" + chr(92) + "Users" + chr(92) + "fixture" + chr(92) + "evidence.txt", + "/" + "etc/service/config.json", "/" + "workspace/fixture/result.json", + "access_key=" + "synthetic" * 4, "token:" + "synthetic" * 4, + "x" * 500 + " access_key=" + "synthetic" * 4, + ] + for value in unsafe_values: + projection = build_goal_artifact_lifecycle_projection( + goal_id="demo", goal={"acceptance": {"milestones": [{"id": "baseline", "label": value}]}}, + agent_id=value, acceptance_gaps=[{"kind": "gap"}], + run_history={"latest_runs": [{"delivery_outcome": "outcome_progress", "recommended_action": value, "evidence_ref": value}]}, + ) + validate_public_safe_value(projection) + assert projection["milestones"][0]["label"] == "baseline" + evidence = projection["milestones"][1] + assert evidence["label"] == "outcome_progress" + assert evidence["reached_evidence_refs"] == [] + assert projection["guards"][0]["agent_id"] is None + safe = build_goal_artifact_lifecycle_projection( + goal_id="demo", goal={}, run_history={"latest_runs": [{ + "delivery_outcome": "outcome_progress", "recommended_action": "Review evidence", + "evidence_ref": "https://example.org/evidence/42", + }]}, + ) + assert safe["milestones"][0]["label"] == "Review evidence" + assert safe["milestones"][0]["reached_evidence_refs"] == ["https://example.org/evidence/42"] + validate_public_safe_value(safe) + + +def test_lifecycle_filters_foreign_runs_and_inactive_gates(): + from loopx.control_plane.goals.artifact_lifecycle import build_goal_artifact_lifecycle_projection + projection = build_goal_artifact_lifecycle_projection( + goal_id="demo", goal={}, + user_todo_summary={"items": [ + {"todo_id": "gate-" + status, "task_class": "user_gate", "status": status} + for status in ("open", "deferred", "done", "superseded") + ]}, + run_history={"latest_runs": [{"goal_id": "other", "delivery_outcome": "outcome_progress"}]}, + ) + assert projection["milestones"] == [] + assert [g["id"] for g in projection["guards"]] == ["gate-open"] + + +def test_lifecycle_retains_controller_gate_without_a_todo(): + from loopx.control_plane.goals.artifact_lifecycle import attach_goal_artifact_lifecycle_projections + payload = { + "run_history": {"goals": [{"id": "demo"}]}, + "attention_queue": {"items": [{ + "goal_id": "demo", "waiting_on": "controller", + "operator_question": "Approve release", "agent_todos": {"open_count": 0}, + }]}, + } + history = {"goals": [{"id": "demo", "status": "active", "latest_runs": [ + {"delivery_outcome": "outcome_progress", "run_id": "proof"}, + ]}]} + attach_goal_artifact_lifecycle_projections(payload, history=history) + projection = payload["run_history"]["goals"][0]["artifact_lifecycle"] + assert projection["lifecycle_phase"] == "waiting_owner" + assert projection["guards"][0]["owner"] == "controller" + assert projection["next_transitions"][0]["reason_codes"] == ["guard_open"] + + +def test_lifecycle_uses_evidence_retained_beyond_recent_run_window(): + from loopx.control_plane.goals.artifact_lifecycle import attach_goal_artifact_lifecycle_projections + from loopx.control_plane.runtime.run_context_retention import goal_semantic_history_from_runs, latest_runs_with_agent_context + proof = {"goal_id": "demo", "agent_id": "agent-a", "classification": "state_refreshed", + "delivery_outcome": "outcome_progress", "run_id": "retained-proof"} + runs = [{"goal_id": "demo", "agent_id": "agent-a", "classification": "monitor_poll"} + for _ in range(20)] + [proof] + source = {"id": "demo", "status": "active", + "latest_runs": latest_runs_with_agent_context(runs, limit=20), + "semantic_history": goal_semantic_history_from_runs(runs)} + assert proof not in source["latest_runs"] + payload = {"run_history": {"goals": [{"id": "demo", "latest_runs": []}]}} + attach_goal_artifact_lifecycle_projections(payload, history={"goals": [source]}) + projection = payload["run_history"]["goals"][0]["artifact_lifecycle"] + assert [m["id"] for m in projection["milestones"]] == ["outcome_progress"] + assert projection["milestones"][0]["reached_evidence_refs"] == ["retained-proof"] + assert projection["lifecycle_phase"] != "closing" # missing Todo source is not zero work + + +def test_shared_public_safety_preserves_public_uris_and_relative_paths(): + from loopx.control_plane.runtime.public_safety import public_safe_compact_text + for value in ("https://example.org/data/report", "docs/evidence.md", "owner authorization", "access key rotation guide"): + validate_public_safe_value(value) + assert public_safe_compact_text(value) == value From 9ea4724f1e7c48150dc9e6eba04e76754227a8a0 Mon Sep 17 00:00:00 2001 From: song Date: Tue, 15 Sep 2026 13:50:54 +0800 Subject: [PATCH 06/12] fix(goals): keep closeout reachable while a recorded gap stays unreached MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two corrections to the lifecycle readout, both bounded by owners this projection already consumes. Material history is not progress. `MATERIAL_DELIVERY_OUTCOMES` decides what run history retains and deliberately includes `outcome_gap`, while `PROGRESS_DELIVERY_OUTCOMES` deliberately excludes it. The evidence milestones used the material set to mark markers reached, so zero open agent Todos over a recorded gap read as closing / next: closed. A gap now stays a visible but unreached marker, and that Goal stays qualifying with `milestone_unreached`. Closing keeps the todo-completion reading this RFC adopts (no open agent work and every marker reached); it does not become an acceptance gate. `goal_acceptance_observation_projection_v0` is bounded by contract (`acceptance_assessed` is always false, coverage is never complete), so requiring its verdict would make `closing` unreachable. Instead, when that owner names sources it could not read, the closeout step carries them in its precondition with `acceptance_unverified`, and the reader keeps the decision. Declared milestones stay readable: RFC §3.2 lists them in the smoke and §9 keeps their source an open question. They have no producer yet; that is tracked there, not resolved here. The shared public-safety rule widening is a separate PR. Signed-off-by: song --- ...oal-artifact-lifecycle-projection-smoke.py | 63 +++++++++++++++++-- .../control_plane/goals/artifact_lifecycle.py | 45 +++++++++++-- .../test_goal_acceptance_observation.py | 30 +++++++++ 3 files changed, 129 insertions(+), 9 deletions(-) diff --git a/examples/control_plane/goal-artifact-lifecycle-projection-smoke.py b/examples/control_plane/goal-artifact-lifecycle-projection-smoke.py index d920e5ef48..ee7f6a6dd6 100644 --- a/examples/control_plane/goal-artifact-lifecycle-projection-smoke.py +++ b/examples/control_plane/goal-artifact-lifecycle-projection-smoke.py @@ -87,6 +87,48 @@ def assert_declared_milestone_stays_unreached_while_gapped() -> None: assert_no_public_leak(projection) +def assert_outcome_gap_is_material_but_not_reached() -> None: + """A recorded gap is retained history, never a reached marker. + + `MATERIAL_DELIVERY_OUTCOMES` decides what history keeps; the canonical + progress outcomes decide what the Goal advanced, and they deliberately + exclude `outcome_gap`. + """ + + projection = build_goal_artifact_lifecycle_projection( + goal_id=GOAL_ID, + goal={"id": GOAL_ID, "status": "active"}, + agent_todo_summary={"open_count": 2}, + run_history={"latest_runs": [{"delivery_outcome": "outcome_gap"}]}, + ) + reached = {item["id"]: item["reached"] for item in projection["milestones"]} + assert reached == {"outcome_gap": False}, projection + assert projection["lifecycle_phase"] == PHASE_QUALIFYING, projection + assert_no_public_leak(projection) + + +def assert_gap_only_evidence_blocks_closeout() -> None: + """The reproduced defect: zero open agent Todos over an `outcome_gap` run. + + This previously read as closing / next: closed because the material set + marked the gap reached. A gap is an unreached marker, so the Goal stays + qualifying and is told what is still missing. + """ + + projection = build_goal_artifact_lifecycle_projection( + goal_id=GOAL_ID, + goal={"id": GOAL_ID, "status": "active"}, + user_todo_summary={"gate_open_items": []}, + agent_todo_summary={"open_count": 0}, + run_history={"latest_runs": [{"delivery_outcome": "outcome_gap"}]}, + ) + assert projection["lifecycle_phase"] == PHASE_QUALIFYING, projection + transition = projection["next_transitions"][0] + assert transition["target_phase"] == PHASE_QUALIFYING, projection + assert transition["reason_codes"] == ["milestone_unreached"], projection + assert_no_public_leak(projection) + + def assert_evidence_milestone_reached_from_run_history() -> None: projection = build_goal_artifact_lifecycle_projection( goal_id=GOAL_ID, @@ -187,7 +229,15 @@ def assert_closing_then_closed_phase() -> None: }, ) assert closing["lifecycle_phase"] == PHASE_CLOSING, closing - assert closing["next_transitions"][0]["target_phase"] == PHASE_CLOSED, closing + transition = closing["next_transitions"][0] + assert transition["target_phase"] == PHASE_CLOSED, closing + # Closing is the todo-completion reading, not an acceptance verdict. The + # acceptance observation could not read agent vision here, so the closeout + # step names that source instead of implying a verified acceptance. + assert "no_open_agent_work" in transition["reason_codes"], closing + assert "acceptance_unverified" in transition["reason_codes"], closing + assert "agent_vision" in transition["precondition"], closing + assert_no_public_leak(closing) closed = build_goal_artifact_lifecycle_projection( goal_id=GOAL_ID, @@ -322,7 +372,10 @@ def assert_batch_scale_never_promotes_an_outcome() -> None: }, ) assert projection["milestones"] == [], (outcome, scale, projection["milestones"]) - # Every canonical material outcome still counts, at any scale. + # Every canonical material outcome is still retained as a marker at any + # scale, but only the accountable progress outcomes are reached. Scale + # never moves a marker in either direction. + accountable = {"outcome_progress", "primary_goal_outcome"} for scale in ("single_surface", "multi_surface"): for outcome in ("outcome_gap", "outcome_progress", "primary_goal_outcome"): projection = build_goal_artifact_lifecycle_projection( @@ -338,8 +391,8 @@ def assert_batch_scale_never_promotes_an_outcome() -> None: ] }, ) - reached = [item for item in projection["milestones"] if item["reached"]] - assert [item["id"] for item in reached] == [outcome], (outcome, scale, reached) + markers = {item["id"]: item["reached"] for item in projection["milestones"]} + assert markers == {outcome: outcome in accountable}, (outcome, scale, markers) def assert_status_collection_attaches_a_readable_readout() -> None: @@ -461,6 +514,8 @@ def assert_control_plane_imports_no_presentation_module() -> None: def main() -> int: assert_starting_phase_without_work() assert_declared_milestone_stays_unreached_while_gapped() + assert_outcome_gap_is_material_but_not_reached() + assert_gap_only_evidence_blocks_closeout() assert_evidence_milestone_reached_from_run_history() assert_open_owner_gate_blocks_the_next_transition() assert_evidence_guard_is_required_and_owned_by_the_agent() diff --git a/loopx/control_plane/goals/artifact_lifecycle.py b/loopx/control_plane/goals/artifact_lifecycle.py index 0c96cbf390..9160f4b4fe 100644 --- a/loopx/control_plane/goals/artifact_lifecycle.py +++ b/loopx/control_plane/goals/artifact_lifecycle.py @@ -26,6 +26,7 @@ from .acceptance_observation import build_goal_acceptance_observation from ..work_items.delivery_outcome import ( MATERIAL_DELIVERY_OUTCOMES, + PROGRESS_DELIVERY_OUTCOMES, normalize_delivery_outcome, ) @@ -146,7 +147,11 @@ def _evidence_milestones(run_history: dict[str, Any]) -> list[dict[str, Any]]: "id": milestone_id, "label": _compact_text(record.get("recommended_action"), limit=160) or milestone_id, - "reached": True, + # Materiality decides what history retains; only the canonical + # progress outcomes decide what the Goal actually advanced. + # `outcome_gap` is material evidence of a recorded gap, so it + # stays visible here as an unreached marker. + "reached": outcome in PROGRESS_DELIVERY_OUTCOMES, "reached_evidence_refs": [reference] if reference else [], "source": "evidence", } @@ -212,6 +217,23 @@ def _guards( return guards +def _unobserved_acceptance_sources(observation: dict[str, Any]) -> list[str]: + """Name the acceptance sources the bounded observation could not read. + + `goal_acceptance_observation_projection_v0` reports what it could not + observe rather than an acceptance verdict, so this projection surfaces + those sources on the closeout step instead of deriving a second completion + rule from the same runs. + """ + + return [ + text for text in ( + _compact_text(source, limit=60) + for source in _list(observation.get("missing_sources")) + ) if text + ] + + def _lifecycle_phase( goal: dict[str, Any], *, @@ -234,7 +256,8 @@ def _lifecycle_phase( if not milestones and total_open == 0: return PHASE_STARTING # An unclaimed-acceptance Goal is never closing: running out of open agent - # work is not the same as having reached the declared acceptance markers. + # work is not the same as having reached the acceptance markers, and a + # recorded gap is an unreached marker rather than progress. unreached = any(milestone["reached"] is not True for milestone in milestones) if total_open == 0 and not unreached: return PHASE_CLOSING @@ -248,6 +271,7 @@ def _next_transitions( guards: list[dict[str, Any]], milestones: list[dict[str, Any]], work_lane: dict[str, Any], + observation: dict[str, Any], ) -> list[dict[str, Any]]: """Reuse the existing lane/frontier derivation instead of a second machine.""" @@ -285,16 +309,26 @@ def _next_transitions( return [ { "target_phase": PHASE_QUALIFYING, - "precondition": "reach the declared acceptance milestones with evidence", + "precondition": "reach the unreached acceptance milestones with evidence", "reason_codes": ["milestone_unreached"], } ] if phase == PHASE_CLOSING: + # Closing is the todo-completion reading this RFC adopts; it is not an + # acceptance verdict. When the acceptance owner could not read some of + # its sources, the closeout step says so instead of implying a verified + # acceptance, and the reader keeps the decision. + unobserved = _unobserved_acceptance_sources(observation) + precondition = "record the terminal no-follow-up outcome" + reason_codes = ["no_open_agent_work"] + if unobserved: + precondition += "; this readout could not observe " + ", ".join(unobserved) + reason_codes.append("acceptance_unverified") return [ { "target_phase": PHASE_CLOSED, - "precondition": "record the terminal no-follow-up outcome", - "reason_codes": ["no_open_agent_work"], + "precondition": precondition, + "reason_codes": reason_codes, } ] return [] @@ -360,6 +394,7 @@ def build_goal_artifact_lifecycle_projection( guards=guards, milestones=milestones, work_lane=lane, + observation=observation, ), } diff --git a/tests/control_plane/test_goal_acceptance_observation.py b/tests/control_plane/test_goal_acceptance_observation.py index 545320fa20..e1ec01db57 100644 --- a/tests/control_plane/test_goal_acceptance_observation.py +++ b/tests/control_plane/test_goal_acceptance_observation.py @@ -433,3 +433,33 @@ def test_shared_public_safety_preserves_public_uris_and_relative_paths(): for value in ("https://example.org/data/report", "docs/evidence.md", "owner authorization", "access key rotation guide"): validate_public_safe_value(value) assert public_safe_compact_text(value) == value + + +def test_lifecycle_gap_only_evidence_never_reads_as_closing(): + from loopx.control_plane.goals.artifact_lifecycle import build_goal_artifact_lifecycle_projection + projection = build_goal_artifact_lifecycle_projection( + goal_id="demo", goal={"status": "active"}, + user_todo_summary={"gate_open_items": []}, + agent_todo_summary={"open_count": 0}, + run_history={"latest_runs": [{"delivery_outcome": "outcome_gap"}]}, + ) + # `outcome_gap` is material history, not a progress outcome: it stays a + # visible unreached marker and never satisfies the closeout reading. + assert [(m["id"], m["reached"]) for m in projection["milestones"]] == [("outcome_gap", False)] + assert projection["lifecycle_phase"] == "qualifying" + assert projection["next_transitions"][0]["reason_codes"] == ["milestone_unreached"] + + +def test_lifecycle_closeout_names_unobserved_acceptance_sources(): + from loopx.control_plane.goals.artifact_lifecycle import build_goal_artifact_lifecycle_projection + projection = build_goal_artifact_lifecycle_projection( + goal_id="demo", goal={"status": "active"}, + user_todo_summary={"gate_open_items": []}, + agent_todo_summary={"open_count": 0}, + run_history={"latest_runs": [{"delivery_outcome": "outcome_progress"}]}, + ) + assert projection["lifecycle_phase"] == "closing" + transition = projection["next_transitions"][0] + assert transition["target_phase"] == "closed" + assert transition["reason_codes"] == ["no_open_agent_work", "acceptance_unverified"] + assert transition["precondition"].endswith("this readout could not observe agent_vision") From 9d040379a4f9f36df451f9cfdf5f33e8d4296b06 Mon Sep 17 00:00:00 2001 From: song Date: Tue, 15 Sep 2026 14:32:45 +0800 Subject: [PATCH 07/12] fix(goals): require an acceptance verdict before recommending closed Builds on the recorded-gap fix rather than replacing it: `outcome_gap` staying an unreached marker is kept, and so is naming the acceptance sources the bounded observation could not read. Two gaps remained in that reading. An empty `missing_sources` was treated as an acceptance verdict. It only means the observation read every source it knows about; the projection still reports `acceptance_assessed=False` and a coverage that is `partial` or `unavailable`, never `complete`. So a Goal with both an attention item and agent vision present produced a bare `next: closed` with no disclosure at all -- the same defect as the reported one, moved to a fully observed input. `_acceptance_supports_closeout` now reads the verdict fields directly instead of inferring one from silence. Annotating the reason codes did not undo the recommendation. The reported defect is an actionable wrong step shown to an operator, and `target_phase: closed` remained that step even with `acceptance_unverified` attached. Closing stays reachable as the todo-completion reading, but without a verdict the step stays inside closing and asks for the acceptance the existing owner has not given. Declared acceptance markers are removed. No producer writes `goal.acceptance.milestones` or `goal.milestones` anywhere in the repository, so the reader was unreachable in production while carrying the only guard able to hold back a closeout, and the fixtures exercising it could not show that a user declaration reaches the readout. Add it back together with the producer that writes it. Signed-off-by: song --- ...oal-artifact-lifecycle-projection-smoke.py | 71 +++---------- .../control_plane/goals/artifact_lifecycle.py | 99 ++++++++----------- .../test_goal_acceptance_observation.py | 59 ++++++++++- 3 files changed, 114 insertions(+), 115 deletions(-) diff --git a/examples/control_plane/goal-artifact-lifecycle-projection-smoke.py b/examples/control_plane/goal-artifact-lifecycle-projection-smoke.py index ee7f6a6dd6..48aee7412b 100644 --- a/examples/control_plane/goal-artifact-lifecycle-projection-smoke.py +++ b/examples/control_plane/goal-artifact-lifecycle-projection-smoke.py @@ -65,28 +65,6 @@ def assert_starting_phase_without_work() -> None: assert_no_public_leak(projection) -def assert_declared_milestone_stays_unreached_while_gapped() -> None: - """A declared marker with an open acceptance gap is not reached.""" - - projection = build_goal_artifact_lifecycle_projection( - goal_id=GOAL_ID, - goal={ - "id": GOAL_ID, - "status": "active", - "acceptance": {"milestones": ["environment_ready", "baseline_pass"]}, - }, - agent_todo_summary={"open_count": 2}, - run_history={"latest_runs": []}, - acceptance_gaps=[ - {"kind": "vision_acceptance_gap", "agent_id": "agent-a"}, - ], - ) - reached = {item["id"]: item["reached"] for item in projection["milestones"]} - assert reached == {"environment_ready": False, "baseline_pass": False}, projection - assert projection["lifecycle_phase"] == PHASE_QUALIFYING, projection - assert_no_public_leak(projection) - - def assert_outcome_gap_is_material_but_not_reached() -> None: """A recorded gap is retained history, never a reached marker. @@ -230,7 +208,8 @@ def assert_closing_then_closed_phase() -> None: ) assert closing["lifecycle_phase"] == PHASE_CLOSING, closing transition = closing["next_transitions"][0] - assert transition["target_phase"] == PHASE_CLOSED, closing + assert transition["target_phase"] == PHASE_CLOSING, closing + assert transition["target_phase"] != PHASE_CLOSED, closing # Closing is the todo-completion reading, not an acceptance verdict. The # acceptance observation could not read agent vision here, so the closeout # step names that source instead of implying a verified acceptance. @@ -265,37 +244,17 @@ def assert_projection_is_pure_and_reads_no_state() -> None: assert first["lifecycle_phase"] == PHASE_QUALIFYING, first -def assert_unreached_milestone_blocks_closeout() -> None: - """An unclaimed-acceptance Goal must not be told to close.""" - - projection = build_goal_artifact_lifecycle_projection( - goal_id=GOAL_ID, - goal={ - "id": GOAL_ID, - "status": "active", - "acceptance": {"milestones": ["baseline_pass"]}, - }, - user_todo_summary={"gate_open_items": []}, - agent_todo_summary={"open_count": 0}, - run_history={"latest_runs": []}, - ) - assert projection["lifecycle_phase"] == PHASE_QUALIFYING, projection - transitions = projection["next_transitions"] - assert transitions, projection - assert transitions[0]["target_phase"] != PHASE_CLOSED, projection - assert transitions[0]["reason_codes"] == ["milestone_unreached"], projection - +def assert_progress_evidence_alone_does_not_authorize_closeout() -> None: + """Evidence reaches the closing phase; only a verdict recommends closed. -def assert_reached_milestones_still_allow_closeout() -> None: - """The same inputs with evidence present do reach the closing phase.""" + The canonical progress outcomes prove the Goal advanced. They do not prove + the declared acceptance was assessed, and the acceptance owner reports it + was not, so the step stays inside closing. + """ projection = build_goal_artifact_lifecycle_projection( goal_id=GOAL_ID, - goal={ - "id": GOAL_ID, - "status": "active", - "acceptance": {"milestones": ["primary_goal_outcome"]}, - }, + goal={"id": GOAL_ID, "status": "active"}, user_todo_summary={"gate_open_items": []}, agent_todo_summary={"open_count": 0}, run_history={ @@ -307,8 +266,13 @@ def assert_reached_milestones_still_allow_closeout() -> None: ] }, ) + reached = {item["id"]: item["reached"] for item in projection["milestones"]} + assert reached == {"primary_goal_outcome": True}, projection assert projection["lifecycle_phase"] == PHASE_CLOSING, projection - assert projection["next_transitions"][0]["target_phase"] == PHASE_CLOSED, projection + transition = projection["next_transitions"][0] + assert transition["target_phase"] == PHASE_CLOSING, projection + assert transition["target_phase"] != PHASE_CLOSED, projection + assert "acceptance_unverified" in transition["reason_codes"], projection def assert_private_values_are_redacted_or_dropped() -> None: @@ -414,7 +378,6 @@ def assert_status_collection_attaches_a_readable_readout() -> None: { "id": GOAL_ID, "status": "active", - "acceptance": {"milestones": ["baseline_pass"]}, } ] }, @@ -513,7 +476,6 @@ def assert_control_plane_imports_no_presentation_module() -> None: def main() -> int: assert_starting_phase_without_work() - assert_declared_milestone_stays_unreached_while_gapped() assert_outcome_gap_is_material_but_not_reached() assert_gap_only_evidence_blocks_closeout() assert_evidence_milestone_reached_from_run_history() @@ -521,8 +483,7 @@ def main() -> int: assert_evidence_guard_is_required_and_owned_by_the_agent() assert_closing_then_closed_phase() assert_projection_is_pure_and_reads_no_state() - assert_unreached_milestone_blocks_closeout() - assert_reached_milestones_still_allow_closeout() + assert_progress_evidence_alone_does_not_authorize_closeout() assert_private_values_are_redacted_or_dropped() assert_batch_scale_never_promotes_an_outcome() assert_status_collection_attaches_a_readable_readout() diff --git a/loopx/control_plane/goals/artifact_lifecycle.py b/loopx/control_plane/goals/artifact_lifecycle.py index 9160f4b4fe..d6e05656d7 100644 --- a/loopx/control_plane/goals/artifact_lifecycle.py +++ b/loopx/control_plane/goals/artifact_lifecycle.py @@ -85,40 +85,15 @@ def _is_closed(goal: dict[str, Any]) -> bool: return _goal_status(goal) in _TERMINAL_GOAL_STATUSES -def _declared_milestones(goal: dict[str, Any]) -> list[dict[str, Any]]: - """Read Goal-declared acceptance markers, if the Goal records any.""" - - acceptance = _mapping(goal.get("acceptance")) - raw = _list(acceptance.get("milestones")) or _list(goal.get("milestones")) - milestones: list[dict[str, Any]] = [] - for index, item in enumerate(raw): - record = _mapping(item) - if not isinstance(item, str) and not record: - continue - milestone_id = ( - _compact_text(item, limit=120) if isinstance(item, str) - else _compact_text(record.get("id") or record.get("milestone_id"), limit=120) - ) - if not milestone_id: - milestone_id = f"milestone_{index + 1}" - label = ( - None if isinstance(item, str) - else _compact_text(record.get("label") or record.get("summary")) - ) - milestones.append( - { - "id": milestone_id, - "label": label or milestone_id, - "reached": False, - "reached_evidence_refs": [], - "source": "declared", - } - ) - return milestones - - def _evidence_milestones(run_history: dict[str, Any]) -> list[dict[str, Any]]: - """Fall back to material evidence the run history already recorded.""" + """Markers this readout can evidence from material run history. + + Goal-declared acceptance markers are deliberately not read: no authoring + or collection path records `goal.acceptance.milestones` or + `goal.milestones`, so reading them promised a readout that no user + declaration could reach, while carrying the only guard able to hold back a + closeout. Add them back together with the producer that writes them. + """ milestones: list[dict[str, Any]] = [] seen: set[str] = set() @@ -159,23 +134,6 @@ def _evidence_milestones(run_history: dict[str, Any]) -> list[dict[str, Any]]: return milestones -def _milestones( - goal: dict[str, Any], - run_history: dict[str, Any], -) -> list[dict[str, Any]]: - declared = _declared_milestones(goal) - evidence = _evidence_milestones(run_history) - if not declared: - return evidence - # A declared marker is a claim about what the Goal intends to reach, not - # proof that it did. It counts as reached only when evidence already - # records that outcome. - reached_outcomes = {item["id"] for item in evidence if item["reached"]} - for milestone in declared: - milestone["reached"] = milestone["id"] in reached_outcomes - return declared + [item for item in evidence if item["id"] not in {m["id"] for m in declared}] - - def _guards( observation: dict[str, Any], acceptance_gaps: list[Any], @@ -217,6 +175,26 @@ def _guards( return guards +def _acceptance_supports_closeout(observation: dict[str, Any]) -> bool: + """Whether the acceptance owner has actually assessed the declared acceptance. + + An empty `missing_sources` only means the bounded observation read every + source it knows about, never that acceptance was verified: this projection + reports `acceptance_assessed=False` and a coverage that is `partial` or + `unavailable`. Treating "nothing left to name" as a verdict is what let a + fully observed Goal be recommended for closeout with no acceptance behind + it, so the verdict fields are read directly. + """ + + if not observation: + return False + if observation.get("acceptance_assessed") is not True: + return False + if observation.get("coverage") != "complete": + return False + return not _list(observation.get("missing_sources")) + + def _unobserved_acceptance_sources(observation: dict[str, Any]) -> list[str]: """Name the acceptance sources the bounded observation could not read. @@ -318,17 +296,26 @@ def _next_transitions( # acceptance verdict. When the acceptance owner could not read some of # its sources, the closeout step says so instead of implying a verified # acceptance, and the reader keeps the decision. + if _acceptance_supports_closeout(observation): + return [ + { + "target_phase": PHASE_CLOSED, + "precondition": "record the terminal no-follow-up outcome", + "reason_codes": ["no_open_agent_work"], + } + ] + # Without that verdict the step stays inside closing: recommending a + # terminal outcome is the actionable error, and annotating the reason + # codes does not undo it. Name the unread sources when there are any. unobserved = _unobserved_acceptance_sources(observation) - precondition = "record the terminal no-follow-up outcome" - reason_codes = ["no_open_agent_work"] + precondition = "verify the declared acceptance with its existing owner" if unobserved: precondition += "; this readout could not observe " + ", ".join(unobserved) - reason_codes.append("acceptance_unverified") return [ { - "target_phase": PHASE_CLOSED, + "target_phase": PHASE_CLOSING, "precondition": precondition, - "reason_codes": reason_codes, + "reason_codes": ["no_open_agent_work", "acceptance_unverified"], } ] return [] @@ -376,7 +363,7 @@ def build_goal_artifact_lifecycle_projection( gaps = [gap for gap in _list(acceptance_gaps) if _mapping(gap)] if acceptance_gaps is None: gaps = _list(observation.get("acceptance_gaps")) - milestones = _milestones(goal_record, history) + milestones = _evidence_milestones(history) guards = _guards(observation, gaps, agent_id=agent_id) phase = _lifecycle_phase( goal_record, guards=guards, milestones=milestones, agent_summary=agent_summary, diff --git a/tests/control_plane/test_goal_acceptance_observation.py b/tests/control_plane/test_goal_acceptance_observation.py index e1ec01db57..c28f70e212 100644 --- a/tests/control_plane/test_goal_acceptance_observation.py +++ b/tests/control_plane/test_goal_acceptance_observation.py @@ -355,13 +355,14 @@ def test_lifecycle_public_safety_covers_all_emitted_text(): ] for value in unsafe_values: projection = build_goal_artifact_lifecycle_projection( - goal_id="demo", goal={"acceptance": {"milestones": [{"id": "baseline", "label": value}]}}, + goal_id="demo", goal={}, agent_id=value, acceptance_gaps=[{"kind": "gap"}], run_history={"latest_runs": [{"delivery_outcome": "outcome_progress", "recommended_action": value, "evidence_ref": value}]}, ) validate_public_safe_value(projection) - assert projection["milestones"][0]["label"] == "baseline" - evidence = projection["milestones"][1] + # An unsafe label and locator are both dropped, so the marker falls + # back to its canonical outcome id and publishes no evidence ref. + evidence = projection["milestones"][0] assert evidence["label"] == "outcome_progress" assert evidence["reached_evidence_refs"] == [] assert projection["guards"][0]["agent_id"] is None @@ -458,8 +459,58 @@ def test_lifecycle_closeout_names_unobserved_acceptance_sources(): agent_todo_summary={"open_count": 0}, run_history={"latest_runs": [{"delivery_outcome": "outcome_progress"}]}, ) + # Closing is the todo-completion reading. Without an acceptance verdict the + # step stays inside closing and names what was not observed, rather than + # recommending the terminal outcome with a caveat attached. assert projection["lifecycle_phase"] == "closing" transition = projection["next_transitions"][0] - assert transition["target_phase"] == "closed" + assert transition["target_phase"] == "closing" assert transition["reason_codes"] == ["no_open_agent_work", "acceptance_unverified"] assert transition["precondition"].endswith("this readout could not observe agent_vision") + + +def test_lifecycle_fully_observed_goal_still_needs_an_acceptance_verdict(): + """An empty `missing_sources` is not an acceptance verdict. + + With an attention item and agent vision both present the observation has + nothing left to name, but it still reports `acceptance_assessed=False` and + a `partial` coverage. Reading "nothing missing" as "acceptance verified" + would recommend the terminal outcome with no acceptance behind it and no + disclosure attached. + """ + + from loopx.control_plane.goals.acceptance_observation import ( + build_goal_acceptance_observation, + ) + from loopx.control_plane.goals.artifact_lifecycle import ( + build_goal_artifact_lifecycle_projection, + ) + + runs = [{ + "delivery_outcome": "outcome_progress", + "agent_id": "agent-a", + "agent_vision": {"agent_id": "agent-a", "acceptance_met": True}, + }] + attention = { + "goal_id": "demo", + "user_todos": {"gate_open_items": []}, + "agent_todos": {"open_count": 0}, + } + observation = build_goal_acceptance_observation( + {"id": "demo", "status": "active", "latest_runs": runs}, attention + ) + assert observation["missing_sources"] == [] + assert observation["acceptance_assessed"] is False + assert observation["coverage"] != "complete" + + projection = build_goal_artifact_lifecycle_projection( + goal_id="demo", goal={"id": "demo", "status": "active"}, + user_todo_summary={"gate_open_items": []}, + agent_todo_summary={"open_count": 0}, + run_history={"latest_runs": runs}, + attention_item=attention, + ) + transition = projection["next_transitions"][0] + assert transition["target_phase"] == "closing" + assert transition["reason_codes"] == ["no_open_agent_work", "acceptance_unverified"] + assert "could not observe" not in transition["precondition"] From 66d961c003108731a58213cb3d6dc0bc9ba3bf4c Mon Sep 17 00:00:00 2001 From: song Date: Tue, 15 Sep 2026 23:10:11 +0800 Subject: [PATCH 08/12] fix(goals): consume owned work observations in the lifecycle readout Signed-off-by: song --- ...oal-artifact-lifecycle-projection-smoke.py | 5 +- .../control_plane/goals/artifact_lifecycle.py | 30 ++--- .../control_plane/runtime/session_runtime.py | 22 ++++ loopx/control_plane/work_items/work_lane.py | 26 ++++ loopx/semantics/inventory_v0.json | 6 +- .../test_goal_acceptance_observation.py | 6 +- .../test_goal_artifact_work_observation.py | 122 ++++++++++++++++++ 7 files changed, 196 insertions(+), 21 deletions(-) create mode 100644 tests/control_plane/test_goal_artifact_work_observation.py diff --git a/examples/control_plane/goal-artifact-lifecycle-projection-smoke.py b/examples/control_plane/goal-artifact-lifecycle-projection-smoke.py index 48aee7412b..ea04b5e980 100644 --- a/examples/control_plane/goal-artifact-lifecycle-projection-smoke.py +++ b/examples/control_plane/goal-artifact-lifecycle-projection-smoke.py @@ -27,6 +27,7 @@ _compact_text, build_goal_artifact_lifecycle_projection, ) +from loopx.control_plane.work_items.work_lane import WorkLaneObservation # noqa: E402 GOAL_ID = "artifact-lifecycle-fixture" @@ -158,7 +159,9 @@ def assert_open_owner_gate_blocks_the_next_transition() -> None: } ] }, - work_lane_contract={"lane": "advancement_task", "obligation": "advance_one_bounded_segment"}, + work_observation=WorkLaneObservation( + lane="advancement_task", must_attempt=True, next_action="advance_one_bounded_segment", + ), ) assert projection["lifecycle_phase"] == PHASE_WAITING_OWNER, projection guard = projection["guards"][0] diff --git a/loopx/control_plane/goals/artifact_lifecycle.py b/loopx/control_plane/goals/artifact_lifecycle.py index d6e05656d7..f7da878782 100644 --- a/loopx/control_plane/goals/artifact_lifecycle.py +++ b/loopx/control_plane/goals/artifact_lifecycle.py @@ -10,9 +10,8 @@ writes no state, and creates no new authority. Milestones and lifecycle phases are projections, never stored fields. -Milestone reachability starts from markers the Goal declares, and falls back to -evidence the run history already recorded. Guards are open owner decisions and -unmet evidence preconditions. Next transitions come from the existing +Milestone reachability uses evidence the run history already recorded. Guards +are open owner decisions and unmet evidence preconditions. Next transitions come from the existing frontier/lane derivation instead of a second state machine. """ @@ -23,7 +22,9 @@ from ...public_safe_text import find_private_text_match from ..runtime.public_safety import public_safe_compact_text, validate_public_safe_value +from ..runtime.session_runtime import session_runtime_work_observation from .acceptance_observation import build_goal_acceptance_observation +from ..work_items.work_lane import WorkLaneObservation from ..work_items.delivery_outcome import ( MATERIAL_DELIVERY_OUTCOMES, PROGRESS_DELIVERY_OUTCOMES, @@ -218,13 +219,13 @@ def _lifecycle_phase( guards: list[dict[str, Any]], milestones: list[dict[str, Any]], agent_summary: dict[str, Any], - work_lane: dict[str, Any], + work: WorkLaneObservation | None, ) -> str: if _is_closed(goal): return PHASE_CLOSED if any(guard["kind"] == GUARD_KIND_OWNER_DECISION for guard in guards): return PHASE_WAITING_OWNER - if guards or work_lane.get("must_attempt_work") is True: + if guards or (work is not None and work.must_attempt): return PHASE_QUALIFYING open_count = agent_summary.get("open_count") if not isinstance(open_count, int) or isinstance(open_count, bool): @@ -248,7 +249,7 @@ def _next_transitions( phase: str, guards: list[dict[str, Any]], milestones: list[dict[str, Any]], - work_lane: dict[str, Any], + work: WorkLaneObservation | None, observation: dict[str, Any], ) -> list[dict[str, Any]]: """Reuse the existing lane/frontier derivation instead of a second machine.""" @@ -256,8 +257,8 @@ def _next_transitions( blocking = [guard for guard in guards if guard["blocked"]] if _is_closed(goal): return [] - lane = _compact_text(work_lane.get("lane"), limit=120) - obligation = _compact_text(work_lane.get("obligation"), limit=120) + lane = _compact_text(work.lane, limit=120) if work else None + obligation = _compact_text(work.next_action) if work else None if blocking: return [ { @@ -328,7 +329,7 @@ def build_goal_artifact_lifecycle_projection( user_todo_summary: dict[str, Any] | None = None, agent_todo_summary: dict[str, Any] | None = None, run_history: dict[str, Any] | None = None, - work_lane_contract: dict[str, Any] | None = None, + work_observation: WorkLaneObservation | None = None, acceptance_gaps: list[Any] | None = None, agent_id: str | None = None, attention_item: dict[str, Any] | None = None, @@ -355,7 +356,6 @@ def build_goal_artifact_lifecycle_projection( record for record in runs if _mapping(record) and record.get("goal_id") in (None, goal_id) ]} - lane = _mapping(work_lane_contract) observation = build_goal_acceptance_observation( {**goal_record, "id": goal_id, **history}, attention_item if attention_item is not None else {"user_todos": user_summary}, @@ -367,7 +367,7 @@ def build_goal_artifact_lifecycle_projection( guards = _guards(observation, gaps, agent_id=agent_id) phase = _lifecycle_phase( goal_record, guards=guards, milestones=milestones, agent_summary=agent_summary, - work_lane=lane, + work=work_observation, ) return { "schema_version": GOAL_ARTIFACT_LIFECYCLE_PROJECTION_SCHEMA_VERSION, @@ -380,7 +380,7 @@ def build_goal_artifact_lifecycle_projection( phase=phase, guards=guards, milestones=milestones, - work_lane=lane, + work=work_observation, observation=observation, ), } @@ -421,9 +421,9 @@ def attach_goal_artifact_lifecycle_projections( attention.get("agent_todos") or asset.get("agent_todos") ), run_history=source, - work_lane_contract=_mapping( - attention.get("work_lane_contract") or asset.get("work_lane_contract") - or source.get("work_lane_contract") + work_observation=session_runtime_work_observation( + attention.get("session_runtime_projection") or asset.get("session_runtime_projection"), + goal_id=goal_id, ), acceptance_gaps=frontier.get("acceptance_gaps") if "acceptance_gaps" in frontier else None, attention_item=attention, diff --git a/loopx/control_plane/runtime/session_runtime.py b/loopx/control_plane/runtime/session_runtime.py index 6fea895bfe..205b3136ee 100644 --- a/loopx/control_plane/runtime/session_runtime.py +++ b/loopx/control_plane/runtime/session_runtime.py @@ -3,6 +3,7 @@ from typing import AbstractSet, Any, Callable, Optional from ...session_runtime import SESSION_RUNTIME_READONLY_PROJECTION_SCHEMA_VERSION +from ..work_items.work_lane import WorkLaneObservation, observe_work_lane from .public_safety import ( public_safe_compact_list as _default_public_safe_compact_list, public_safe_compact_text as _default_public_safe_compact_text, @@ -267,6 +268,27 @@ def compact_session_runtime_projection_from_run( ) +def session_runtime_work_observation( + projection: Any, *, goal_id: str, +) -> WorkLaneObservation | None: + """Read work facts from this Goal's existing session-runtime projection. + + The adapter owns the legacy payload shape. Callers receive only the facts + needed for a readout, with no copy of the protocol or new decision rule. + """ + if ( + not isinstance(projection, dict) + or projection.get("schema_version") != SESSION_RUNTIME_READONLY_PROJECTION_SCHEMA_VERSION + or projection.get("goal_id") != goal_id + ): + return None + first_screen = projection.get("first_screen") + return observe_work_lane( + projection.get("work_lane_contract"), + next_action=first_screen.get("recommended_action") if isinstance(first_screen, dict) else None, + ) + + def session_runtime_status_waiting_on( value: Any, *, diff --git a/loopx/control_plane/work_items/work_lane.py b/loopx/control_plane/work_items/work_lane.py index b01e41c79b..3f64e88fd0 100644 --- a/loopx/control_plane/work_items/work_lane.py +++ b/loopx/control_plane/work_items/work_lane.py @@ -1,11 +1,37 @@ from __future__ import annotations +from dataclasses import dataclass from typing import Any from ..effect_program import ReceiptBoundMonitorPhase from ..todos.contract import TODO_TASK_CLASS_MONITOR, normalize_todo_id from ..todos.todo_semantics import todo_priority_label, todo_priority_rank + +@dataclass(frozen=True) +class WorkLaneObservation: + """Read-only work facts; neither a scheduling decision nor execution authority.""" + + lane: str | None + must_attempt: bool + next_action: str | None + + +def observe_work_lane( + contract: Any, *, next_action: Any = None, +) -> WorkLaneObservation | None: + """Keep legacy field decoding with the lane owner, outside new consumers.""" + if not isinstance(contract, dict): + return None + lane = contract.get("lane") + action = contract.get("obligation") or next_action + return WorkLaneObservation( + lane=lane if isinstance(lane, str) else None, + must_attempt=contract.get("must_attempt_work") is True, + next_action=action if isinstance(action, str) else None, + ) + + WORK_LANE_CONTRACT_SCHEMA_VERSION = "work_lane_contract_v1" WORK_LANE_RECEIPT_BOUND_MONITOR_SETTLEMENT_OBLIGATION = ( "settle_receipt_bound_monitor" diff --git a/loopx/semantics/inventory_v0.json b/loopx/semantics/inventory_v0.json index d5620542d4..6813b1c9e7 100644 --- a/loopx/semantics/inventory_v0.json +++ b/loopx/semantics/inventory_v0.json @@ -902,13 +902,13 @@ ] }, "summary": { - "source_files": 1175, + "source_files": 1177, "python_enums": 103, "python_closed_sets": 495, "python_literal_aliases": 8, "typescript_const_arrays": 40, - "named_string_constants": 2031, - "schema_version_names": 756, + "named_string_constants": 2039, + "schema_version_names": 757, "schema_version_same_runtime_forks": 7, "cross_runtime_twins": 166, "same_runtime_forks": 25, diff --git a/tests/control_plane/test_goal_acceptance_observation.py b/tests/control_plane/test_goal_acceptance_observation.py index c28f70e212..c617807d52 100644 --- a/tests/control_plane/test_goal_acceptance_observation.py +++ b/tests/control_plane/test_goal_acceptance_observation.py @@ -329,7 +329,9 @@ def test_lifecycle_evidence_survives_real_display_trimming(tmp_path): def test_lifecycle_cannot_close_over_canonical_mandatory_lane(): from loopx.control_plane.goals.artifact_lifecycle import build_goal_artifact_lifecycle_projection - from loopx.control_plane.work_items.work_lane import lark_inbox_reply_due_work_lane_contract + from loopx.control_plane.work_items.work_lane import ( + lark_inbox_reply_due_work_lane_contract, observe_work_lane, + ) lane = lark_inbox_reply_due_work_lane_contract( {"capabilities": {"lark_event_inbox": {"urgency": {"reply_due": True}}}}, current_contract=None, @@ -338,7 +340,7 @@ def test_lifecycle_cannot_close_over_canonical_mandatory_lane(): goal_id="demo", goal={"status": "active"}, agent_todo_summary={"open_count": 0}, run_history={"latest_runs": [{"delivery_outcome": "primary_goal_outcome", "run_id": "run-evidence"}]}, - work_lane_contract=lane, + work_observation=observe_work_lane(lane), ) assert projection["lifecycle_phase"] == "qualifying" assert projection["next_transitions"][0]["target_phase"] == "qualifying" diff --git a/tests/control_plane/test_goal_artifact_work_observation.py b/tests/control_plane/test_goal_artifact_work_observation.py new file mode 100644 index 0000000000..b19c2bec20 --- /dev/null +++ b/tests/control_plane/test_goal_artifact_work_observation.py @@ -0,0 +1,122 @@ +"""Lifecycle work precedence through the persisted session-runtime status path.""" + +from __future__ import annotations + +import copy +import json + +import pytest + +from loopx.session_runtime import build_session_runtime_readonly_projection +from loopx.status import collect_status + + +@pytest.mark.parametrize("display_limit", [0, 5]) +def test_persisted_session_work_prevents_closeout_after_display_trimming(tmp_path, display_limit): + from loopx.presentation.renderers.status_markdown import render_status_markdown + + project = tmp_path / "project" + runtime = tmp_path / "runtime" + project.mkdir() + state = project / "ACTIVE_GOAL_STATE.md" + state.write_text( + "---\nstatus: active\n---\n\n# Goal\n\n## Agent Todo\n\n" + "- [x] Implement the change\n" + " \n\n## User Todo\n" + ) + registry = project / "registry.json" + registry.write_text(json.dumps({ + "schema_version": 1, "common_runtime_root": str(runtime), + "goals": [{ + "id": "demo", "status": "active", "domain": "software", "repo": str(project), + "state_file": state.name, + "adapter": {"kind": "session_runtime", "status": "connected-read-only"}, + }], + })) + projection = build_session_runtime_readonly_projection( + goal_id="demo", + decision_results=[{"recommended_action": "Verify the remaining evidence"}], + ) + runs = runtime / "goals" / "demo" / "runs" + runs.mkdir(parents=True) + run_path, markdown_path = runs / "run.json", runs / "run.md" + record = { + "goal_id": "demo", "generated_at": "2026-09-01T00:00:00+00:00", + "classification": "session_runtime_projection_recorded", + "delivery_outcome": "outcome_progress", + "session_runtime_readonly_projection": projection, + "json_path": str(run_path), "markdown_path": str(markdown_path), + } + run_path.write_text(json.dumps(record)) + markdown_path.write_text("# Compact session-runtime observation\n") + (runs / "index.jsonl").write_text(json.dumps(record) + "\n") + before = {path: path.read_bytes() for path in (state, registry, run_path, markdown_path, runs / "index.jsonl")} + result = collect_status( + registry_path=registry, runtime_root_override=str(runtime), + scan_roots=[], limit=display_limit, include_public_boundary_scan=False, + ) + assert result["ok"] is True + goal = result["run_history"]["goals"][0] + item = next(item for item in result["attention_queue"]["items"] if item["goal_id"] == "demo") + assert item["agent_todos"]["open_count"] == 0 + assert "work_lane_contract" not in item + assert item["session_runtime_projection"]["work_lane_contract"]["must_attempt_work"] is True + if display_limit == 0: + assert goal["latest_runs"] == [] + lifecycle = goal["artifact_lifecycle"] + assert lifecycle["guards"] == [] + assert all(marker["reached"] for marker in lifecycle["milestones"]) + assert lifecycle["lifecycle_phase"] == "qualifying" + assert lifecycle["next_transitions"] == [{ + "target_phase": "qualifying", "precondition": "Verify the remaining evidence", + "reason_codes": ["work_lane_selected"], + }] + assert "Verify the remaining evidence" in render_status_markdown(result) + assert {path: path.read_bytes() for path in before} == before + + +@pytest.mark.parametrize("field,value", [ + ("goal_id", "other-goal"), ("goal_id", None), ("schema_version", "unknown_v0"), +]) +def test_work_observation_rejects_foreign_or_untyped_projection(field, value): + from loopx.control_plane.runtime.session_runtime import session_runtime_work_observation + + projection = build_session_runtime_readonly_projection( + goal_id="demo", decision_results=[{"recommended_action": "Continue verification"}], + ) + projection[field] = value + before = copy.deepcopy(projection) + assert session_runtime_work_observation(projection, goal_id="demo") is None + assert projection == before + + +@pytest.mark.parametrize("required", [False, True, "false"]) +def test_lane_owner_exposes_facts_without_creating_a_work_requirement(required): + from loopx.control_plane.work_items.work_lane import observe_work_lane + + contract = {"lane": "continuous_monitor", "must_attempt_work": required, + "obligation": "Inspect the current monitor receipt"} + before = copy.deepcopy(contract) + observation = observe_work_lane(contract, next_action="Fallback description") + assert observation.lane == "continuous_monitor" + assert observation.must_attempt is (required is True) + assert observation.next_action == "Inspect the current monitor receipt" + assert contract == before + + +def test_lifecycle_validates_work_observation_text_before_rendering(): + from loopx.control_plane.goals.artifact_lifecycle import build_goal_artifact_lifecycle_projection + from loopx.control_plane.runtime.public_safety import validate_public_safe_value + from loopx.control_plane.work_items.work_lane import WorkLaneObservation + + projection = build_goal_artifact_lifecycle_projection( + goal_id="demo", goal={"status": "active"}, + work_observation=WorkLaneObservation( + lane="advancement_task", must_attempt=True, + next_action="x" * 500 + " token=" + "synthetic" * 4, + ), + ) + assert projection["lifecycle_phase"] == "qualifying" + assert projection["next_transitions"][0]["precondition"] == "advance the selected lane" + validate_public_safe_value(projection) From 4c1f815c4106369b1816768a69bf02170d7f3d34 Mon Sep 17 00:00:00 2001 From: song Date: Tue, 15 Sep 2026 23:10:25 +0800 Subject: [PATCH 09/12] docs(goals): explain lifecycle work-observation precedence Signed-off-by: song --- docs/reference/goal-acceptance-observations.md | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/docs/reference/goal-acceptance-observations.md b/docs/reference/goal-acceptance-observations.md index a82e3b90ae..313a57cbe4 100644 --- a/docs/reference/goal-acceptance-observations.md +++ b/docs/reference/goal-acceptance-observations.md @@ -30,8 +30,19 @@ The proposed [Goal direction baseline](../architecture/rfcs/goal-direction-basel material declarations and revision-bound usage receipts are not implemented here; historical progress does not establish that current direction materials were read. +Status also exposes a separate `run_history.goals[].artifact_lifecycle` readout +and Markdown summary: observed phase, evidence milestones, guards and next steps. +It consumes the current Goal's session-runtime work observation before display +trimming. Outstanding required work keeps the phase `qualifying` even when +Todos are complete and historical progress is reached. An absent work observation +does not invent a work requirement. `closing` still does not certify acceptance +or recommend `closed` without an acceptance verdict. This readout grants no +execution or completion authority; the Dashboard card above keeps its separate +acceptance-observation contract. + Validation: `python -m pytest tests/control_plane/test_goal_acceptance_observation.py` -and `node examples/dashboard-goal-acceptance-browser-smoke.mjs`. The browser +and `python -m pytest tests/control_plane/test_goal_artifact_work_observation.py`, +plus `node examples/dashboard-goal-acceptance-browser-smoke.mjs`. The browser check consumes real status collection over a disposable synthetic Goal; set `LOOPX_GOAL_ACCEPTANCE_PACKAGED=1` after the Dashboard build to check shipped assets. @@ -54,5 +65,10 @@ check consumes real status collection over a disposable synthetic Goal; set 不保留把完整协议误认作该观察结构的别名。此切片不增加里程碑声明入口、统一阶段序列、完整证据审计、 新的合法迁移或完成判定。Goal direction baseline 提案中的材料声明和绑定版本的阅读回执 不在此次实现范围;历史进展不能证明已阅读当前方向材料。旧来源不提供投影时显示不可用。 +status 同时在独立的 `run_history.goals[].artifact_lifecycle` 和 Markdown 摘要中展示 +观测阶段、证据里程碑、门禁和下一步。它在展示截断前读取当前 Goal 的 session-runtime +工作观察:即使 Todo 全部完成且历史进展已达成,只要仍有必须执行的工作,阶段就保持 +`qualifying`。缺少工作观察不会凭空产生执行要求;`closing` 不代表验收通过,未取得验收结论 +时也不会建议 `closed`。此读出不授予执行或完成权威,Dashboard 卡片仍使用独立的验收观察合同。 上面的测试命令覆盖合成 Goal 的生产 refresh-state 写入、 真实 status 收集和浏览器入口;打包验证使用 `LOOPX_GOAL_ACCEPTANCE_PACKAGED=1`。 From e67775a3f4d37417e658682af889847ebc5c4168 Mon Sep 17 00:00:00 2001 From: song Date: Wed, 16 Sep 2026 00:08:14 +0800 Subject: [PATCH 10/12] fix(goals): keep lifecycle v0 terminal advice out of scope Signed-off-by: song --- ...oal-artifact-lifecycle-projection-smoke.py | 14 ++-- .../control_plane/goals/artifact_lifecycle.py | 47 +++---------- .../test_goal_artifact_work_observation.py | 68 ++++++++++++++++--- 3 files changed, 71 insertions(+), 58 deletions(-) diff --git a/examples/control_plane/goal-artifact-lifecycle-projection-smoke.py b/examples/control_plane/goal-artifact-lifecycle-projection-smoke.py index ea04b5e980..b4cd405408 100644 --- a/examples/control_plane/goal-artifact-lifecycle-projection-smoke.py +++ b/examples/control_plane/goal-artifact-lifecycle-projection-smoke.py @@ -194,7 +194,7 @@ def assert_evidence_guard_is_required_and_owned_by_the_agent() -> None: assert_no_public_leak(projection) -def assert_closing_then_closed_phase() -> None: +def assert_closing_advice_and_observed_terminal_status_stay_distinct() -> None: closing = build_goal_artifact_lifecycle_projection( goal_id=GOAL_ID, goal={"id": GOAL_ID, "status": "active"}, @@ -213,9 +213,8 @@ def assert_closing_then_closed_phase() -> None: transition = closing["next_transitions"][0] assert transition["target_phase"] == PHASE_CLOSING, closing assert transition["target_phase"] != PHASE_CLOSED, closing - # Closing is the todo-completion reading, not an acceptance verdict. The - # acceptance observation could not read agent vision here, so the closeout - # step names that source instead of implying a verified acceptance. + # Closing carries verification advice, never a terminal recommendation. + # Already-closed Goal status is still displayed without recommending a step. assert "no_open_agent_work" in transition["reason_codes"], closing assert "acceptance_unverified" in transition["reason_codes"], closing assert "agent_vision" in transition["precondition"], closing @@ -248,11 +247,10 @@ def assert_projection_is_pure_and_reads_no_state() -> None: def assert_progress_evidence_alone_does_not_authorize_closeout() -> None: - """Evidence reaches the closing phase; only a verdict recommends closed. + """Evidence reaches closing; v0 never recommends a terminal transition. The canonical progress outcomes prove the Goal advanced. They do not prove - the declared acceptance was assessed, and the acceptance owner reports it - was not, so the step stays inside closing. + the declared acceptance was assessed. The step stays inside closing. """ projection = build_goal_artifact_lifecycle_projection( @@ -484,7 +482,7 @@ def main() -> int: assert_evidence_milestone_reached_from_run_history() assert_open_owner_gate_blocks_the_next_transition() assert_evidence_guard_is_required_and_owned_by_the_agent() - assert_closing_then_closed_phase() + assert_closing_advice_and_observed_terminal_status_stay_distinct() assert_projection_is_pure_and_reads_no_state() assert_progress_evidence_alone_does_not_authorize_closeout() assert_private_values_are_redacted_or_dropped() diff --git a/loopx/control_plane/goals/artifact_lifecycle.py b/loopx/control_plane/goals/artifact_lifecycle.py index f7da878782..6b731c3445 100644 --- a/loopx/control_plane/goals/artifact_lifecycle.py +++ b/loopx/control_plane/goals/artifact_lifecycle.py @@ -176,32 +176,12 @@ def _guards( return guards -def _acceptance_supports_closeout(observation: dict[str, Any]) -> bool: - """Whether the acceptance owner has actually assessed the declared acceptance. - - An empty `missing_sources` only means the bounded observation read every - source it knows about, never that acceptance was verified: this projection - reports `acceptance_assessed=False` and a coverage that is `partial` or - `unavailable`. Treating "nothing left to name" as a verdict is what let a - fully observed Goal be recommended for closeout with no acceptance behind - it, so the verdict fields are read directly. - """ - - if not observation: - return False - if observation.get("acceptance_assessed") is not True: - return False - if observation.get("coverage") != "complete": - return False - return not _list(observation.get("missing_sources")) - - def _unobserved_acceptance_sources(observation: dict[str, Any]) -> list[str]: """Name the acceptance sources the bounded observation could not read. `goal_acceptance_observation_projection_v0` reports what it could not observe rather than an acceptance verdict, so this projection surfaces - those sources on the closeout step instead of deriving a second completion + those sources on the verification step instead of deriving a second completion rule from the same runs. """ @@ -234,9 +214,8 @@ def _lifecycle_phase( total_open = open_count if not milestones and total_open == 0: return PHASE_STARTING - # An unclaimed-acceptance Goal is never closing: running out of open agent - # work is not the same as having reached the acceptance markers, and a - # recorded gap is an unreached marker rather than progress. + # No open work and reached progress markers suggest closing, not accepted + # completion. A recorded gap is still an unreached marker. unreached = any(milestone["reached"] is not True for milestone in milestones) if total_open == 0 and not unreached: return PHASE_CLOSING @@ -293,21 +272,11 @@ def _next_transitions( } ] if phase == PHASE_CLOSING: - # Closing is the todo-completion reading this RFC adopts; it is not an - # acceptance verdict. When the acceptance owner could not read some of - # its sources, the closeout step says so instead of implying a verified - # acceptance, and the reader keeps the decision. - if _acceptance_supports_closeout(observation): - return [ - { - "target_phase": PHASE_CLOSED, - "precondition": "record the terminal no-follow-up outcome", - "reason_codes": ["no_open_agent_work"], - } - ] - # Without that verdict the step stays inside closing: recommending a - # terminal outcome is the actionable error, and annotating the reason - # codes does not undo it. Name the unread sources when there are any. + # This v0 readout never recommends a terminal transition. Its bounded + # acceptance input cannot provide a verdict, and a future producer + # expansion must not silently add completion advice to this contract. + # Machine consumers use acceptance_unverified; prose only explains the + # next verification step and any sources this observation did not read. unobserved = _unobserved_acceptance_sources(observation) precondition = "verify the declared acceptance with its existing owner" if unobserved: diff --git a/tests/control_plane/test_goal_artifact_work_observation.py b/tests/control_plane/test_goal_artifact_work_observation.py index b19c2bec20..b22d62dbf8 100644 --- a/tests/control_plane/test_goal_artifact_work_observation.py +++ b/tests/control_plane/test_goal_artifact_work_observation.py @@ -12,7 +12,13 @@ @pytest.mark.parametrize("display_limit", [0, 5]) -def test_persisted_session_work_prevents_closeout_after_display_trimming(tmp_path, display_limit): +@pytest.mark.parametrize("adapter_kind,include_work_projection", [ + ("session_runtime", True), ("session_runtime", False), + ("harness_self_improvement", True), ("harness_self_improvement", False), +]) +def test_persisted_work_observation_coverage_before_display_trimming( + tmp_path, display_limit, adapter_kind, include_work_projection, +): from loopx.presentation.renderers.status_markdown import render_status_markdown project = tmp_path / "project" @@ -31,7 +37,7 @@ def test_persisted_session_work_prevents_closeout_after_display_trimming(tmp_pat "goals": [{ "id": "demo", "status": "active", "domain": "software", "repo": str(project), "state_file": state.name, - "adapter": {"kind": "session_runtime", "status": "connected-read-only"}, + "adapter": {"kind": adapter_kind, "status": "connected-read-only"}, }], })) projection = build_session_runtime_readonly_projection( @@ -43,11 +49,12 @@ def test_persisted_session_work_prevents_closeout_after_display_trimming(tmp_pat run_path, markdown_path = runs / "run.json", runs / "run.md" record = { "goal_id": "demo", "generated_at": "2026-09-01T00:00:00+00:00", - "classification": "session_runtime_projection_recorded", + "classification": "state_refreshed", "delivery_outcome": "outcome_progress", - "session_runtime_readonly_projection": projection, "json_path": str(run_path), "markdown_path": str(markdown_path), } + if include_work_projection: + record["session_runtime_readonly_projection"] = projection run_path.write_text(json.dumps(record)) markdown_path.write_text("# Compact session-runtime observation\n") (runs / "index.jsonl").write_text(json.dumps(record) + "\n") @@ -61,18 +68,28 @@ def test_persisted_session_work_prevents_closeout_after_display_trimming(tmp_pat item = next(item for item in result["attention_queue"]["items"] if item["goal_id"] == "demo") assert item["agent_todos"]["open_count"] == 0 assert "work_lane_contract" not in item - assert item["session_runtime_projection"]["work_lane_contract"]["must_attempt_work"] is True if display_limit == 0: assert goal["latest_runs"] == [] lifecycle = goal["artifact_lifecycle"] assert lifecycle["guards"] == [] assert all(marker["reached"] for marker in lifecycle["milestones"]) - assert lifecycle["lifecycle_phase"] == "qualifying" - assert lifecycle["next_transitions"] == [{ - "target_phase": "qualifying", "precondition": "Verify the remaining evidence", - "reason_codes": ["work_lane_selected"], - }] - assert "Verify the remaining evidence" in render_status_markdown(result) + if include_work_projection: + assert item["session_runtime_projection"]["work_lane_contract"]["must_attempt_work"] is True + assert lifecycle["lifecycle_phase"] == "qualifying" + assert lifecycle["next_transitions"] == [{ + "target_phase": "qualifying", "precondition": "Verify the remaining evidence", + "reason_codes": ["work_lane_selected"], + }] + assert "Verify the remaining evidence" in render_status_markdown(result) + else: + # Adapter naming alone is not a work observation or proof of no work. + assert "session_runtime_projection" not in item + assert lifecycle["lifecycle_phase"] == "closing" + assert lifecycle["next_transitions"][0]["target_phase"] == "closing" + assert lifecycle["next_transitions"][0]["reason_codes"] == [ + "no_open_agent_work", "acceptance_unverified", + ] + assert "next: closed" not in render_status_markdown(result) assert {path: path.read_bytes() for path in before} == before @@ -120,3 +137,32 @@ def test_lifecycle_validates_work_observation_text_before_rendering(): assert projection["lifecycle_phase"] == "qualifying" assert projection["next_transitions"][0]["precondition"] == "advance the selected lane" validate_public_safe_value(projection) + + +@pytest.mark.parametrize("status", ["active", "closed"]) +def test_lifecycle_v0_never_acquires_terminal_advice_from_acceptance_owner(monkeypatch, status): + """A future producer change must not silently expand this v0 readout's scope.""" + from loopx.control_plane.goals import artifact_lifecycle + + original = artifact_lifecycle.build_goal_acceptance_observation + + def future_observation(*args, **kwargs): + return {**original(*args, **kwargs), "acceptance_assessed": True, + "coverage": "complete", "missing_sources": []} + + # Deliberately inject a verdict the bounded owner cannot currently produce. + # This is a counterfactual, not a new supported acceptance contract. + monkeypatch.setattr(artifact_lifecycle, "build_goal_acceptance_observation", future_observation) + projection = artifact_lifecycle.build_goal_artifact_lifecycle_projection( + goal_id="demo", goal={"status": status}, + user_todo_summary={"gate_open_items": []}, agent_todo_summary={"open_count": 0}, + run_history={"latest_runs": [{"delivery_outcome": "outcome_progress"}]}, + ) + if status == "closed": + assert projection["lifecycle_phase"] == "closed" + assert projection["next_transitions"] == [] + else: + assert projection["lifecycle_phase"] == "closing" + transition = projection["next_transitions"][0] + assert transition["target_phase"] == "closing" + assert transition["reason_codes"] == ["no_open_agent_work", "acceptance_unverified"] From 33f30d2c3eda49a8cb88920d3bbd4ab10611037e Mon Sep 17 00:00:00 2001 From: song Date: Wed, 16 Sep 2026 00:08:29 +0800 Subject: [PATCH 11/12] docs(goals): define lifecycle advice and work observation coverage Signed-off-by: song --- .../reference/goal-acceptance-observations.md | 43 ++++++++++++++++--- 1 file changed, 37 insertions(+), 6 deletions(-) diff --git a/docs/reference/goal-acceptance-observations.md b/docs/reference/goal-acceptance-observations.md index 313a57cbe4..16fddbf6ab 100644 --- a/docs/reference/goal-acceptance-observations.md +++ b/docs/reference/goal-acceptance-observations.md @@ -35,10 +35,27 @@ and Markdown summary: observed phase, evidence milestones, guards and next steps It consumes the current Goal's session-runtime work observation before display trimming. Outstanding required work keeps the phase `qualifying` even when Todos are complete and historical progress is reached. An absent work observation -does not invent a work requirement. `closing` still does not certify acceptance -or recommend `closed` without an acceptance verdict. This readout grants no -execution or completion authority; the Dashboard card above keeps its separate -acceptance-observation contract. +does not invent a work requirement. This v0 readout **never recommends a terminal +transition**: `closing` stays a verification step with the machine-readable +`next_transitions[].reason_codes` value `acceptance_unverified`, both when sources +are missing and when all bounded sources were observed. Consumers use that code, +not English `precondition` text. A Goal already recorded as terminal still displays +`lifecycle_phase: closed` with no next transitions. Adding acceptance-driven +terminal advice requires a separate contract change and producer-side validation. +This readout grants no execution or completion authority; the Dashboard card +above keeps its separate acceptance-observation contract. + +Work observation coverage follows the supplied projection, not `adapter.kind`: + +| Source delivered to status | Work observation coverage | +| --- | --- | +| Session-runtime adapter, or another adapter emitting the same session-runtime projection | Available only with `session_runtime_readonly_projection_v0`, a matching Goal id, and work facts | +| Adapter without that projection, or a projection with a mismatched schema/Goal id | Unavailable; this readout does not query quota or other lane owners | + +Without that source a Goal may read `closing` from Todo/history even when a lane +outside the observation still requires work. Neither `closing` nor absence of +`work_lane_selected` proves all work is complete; the lane and completion owners +retain their decisions. Validation: `python -m pytest tests/control_plane/test_goal_acceptance_observation.py` and `python -m pytest tests/control_plane/test_goal_artifact_work_observation.py`, @@ -68,7 +85,21 @@ check consumes real status collection over a disposable synthetic Goal; set status 同时在独立的 `run_history.goals[].artifact_lifecycle` 和 Markdown 摘要中展示 观测阶段、证据里程碑、门禁和下一步。它在展示截断前读取当前 Goal 的 session-runtime 工作观察:即使 Todo 全部完成且历史进展已达成,只要仍有必须执行的工作,阶段就保持 -`qualifying`。缺少工作观察不会凭空产生执行要求;`closing` 不代表验收通过,未取得验收结论 -时也不会建议 `closed`。此读出不授予执行或完成权威,Dashboard 卡片仍使用独立的验收观察合同。 +`qualifying`。缺少工作观察不会凭空产生执行要求。此 v0 读出**始终不建议终态迁移**: +`closing` 保持为验收核验步骤,无论是否缺少来源,均通过 +`next_transitions[].reason_codes` 中的 `acceptance_unverified` 表达本读出未验证验收。 +机器消费者读取该 code,无需匹配英文 `precondition`。已记录为终态的 Goal 仍展示 +`lifecycle_phase: closed`,下一步列表为空。未来若增加基于验收的终态建议,必须另行变更合同并验证产出侧。 +此读出不授予执行或完成权威,Dashboard 卡片仍使用独立的验收观察合同。 + +工作观察覆盖取决于实际提供的投影,不按 `adapter.kind` 名称判断: + +| status 接收的来源 | 工作观察覆盖 | +| --- | --- | +| session-runtime adapter,或提供相同 session-runtime 投影的其它 adapter | schema 为 `session_runtime_readonly_projection_v0`、Goal id 匹配且包含工作事实时可用 | +| 不提供该投影的 adapter,或 schema/Goal id 不匹配的投影 | 不可用;此读出不会额外查询 quota 或其它工作通道权威 | + +缺少该来源时,即使观察范围外仍有必须执行的工作,Todo/历史也可能让 Goal 显示 `closing`。 +`closing` 或缺少 `work_lane_selected` 均不证明所有工作完成,工作通道与完成权威仍保留各自的判断。 上面的测试命令覆盖合成 Goal 的生产 refresh-state 写入、 真实 status 收集和浏览器入口;打包验证使用 `LOOPX_GOAL_ACCEPTANCE_PACKAGED=1`。 From d70419e42583c3ac287e4ce690638f27cbfe0aff Mon Sep 17 00:00:00 2001 From: song Date: Wed, 16 Sep 2026 08:36:16 +0800 Subject: [PATCH 12/12] test(chat): isolate unbound channel fixture from installed runtime Signed-off-by: song (cherry picked from commit db58562bf8db078504006f02af6f8a10f3863849) Signed-off-by: song --- tests/test_manager_channel_binding.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_manager_channel_binding.py b/tests/test_manager_channel_binding.py index eab42ede18..5d4b896fd8 100644 --- a/tests/test_manager_channel_binding.py +++ b/tests/test_manager_channel_binding.py @@ -420,9 +420,12 @@ def test_the_channel_quotes_the_session_mode_instead_of_deriving_it(): assert binding["session_status"] == "busy" -def test_a_channel_without_a_session_reads_as_unbound(): +def test_a_channel_without_a_session_reads_as_unbound(monkeypatch): """A ready managed endpoint is not evidence that the channel is bound.""" + monkeypatch.setattr( + host_binding, "dsh_runtime_importable", lambda *args, **kwargs: True + ) binding = manager_channel_binding({"DEEPSEEK_API_KEY": "fixture"}) assert binding["available"] is True