Skip to content

Commit 692c1b8

Browse files
committed
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 <liusongstep@gmail.com>
1 parent 55f9ee4 commit 692c1b8

3 files changed

Lines changed: 114 additions & 115 deletions

File tree

‎examples/control_plane/goal-artifact-lifecycle-projection-smoke.py‎

Lines changed: 16 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -65,28 +65,6 @@ def assert_starting_phase_without_work() -> None:
6565
assert_no_public_leak(projection)
6666

6767

68-
def assert_declared_milestone_stays_unreached_while_gapped() -> None:
69-
"""A declared marker with an open acceptance gap is not reached."""
70-
71-
projection = build_goal_artifact_lifecycle_projection(
72-
goal_id=GOAL_ID,
73-
goal={
74-
"id": GOAL_ID,
75-
"status": "active",
76-
"acceptance": {"milestones": ["environment_ready", "baseline_pass"]},
77-
},
78-
agent_todo_summary={"open_count": 2},
79-
run_history={"latest_runs": []},
80-
acceptance_gaps=[
81-
{"kind": "vision_acceptance_gap", "agent_id": "agent-a"},
82-
],
83-
)
84-
reached = {item["id"]: item["reached"] for item in projection["milestones"]}
85-
assert reached == {"environment_ready": False, "baseline_pass": False}, projection
86-
assert projection["lifecycle_phase"] == PHASE_QUALIFYING, projection
87-
assert_no_public_leak(projection)
88-
89-
9068
def assert_outcome_gap_is_material_but_not_reached() -> None:
9169
"""A recorded gap is retained history, never a reached marker.
9270
@@ -230,7 +208,8 @@ def assert_closing_then_closed_phase() -> None:
230208
)
231209
assert closing["lifecycle_phase"] == PHASE_CLOSING, closing
232210
transition = closing["next_transitions"][0]
233-
assert transition["target_phase"] == PHASE_CLOSED, closing
211+
assert transition["target_phase"] == PHASE_CLOSING, closing
212+
assert transition["target_phase"] != PHASE_CLOSED, closing
234213
# Closing is the todo-completion reading, not an acceptance verdict. The
235214
# acceptance observation could not read agent vision here, so the closeout
236215
# step names that source instead of implying a verified acceptance.
@@ -265,37 +244,17 @@ def assert_projection_is_pure_and_reads_no_state() -> None:
265244
assert first["lifecycle_phase"] == PHASE_QUALIFYING, first
266245

267246

268-
def assert_unreached_milestone_blocks_closeout() -> None:
269-
"""An unclaimed-acceptance Goal must not be told to close."""
270-
271-
projection = build_goal_artifact_lifecycle_projection(
272-
goal_id=GOAL_ID,
273-
goal={
274-
"id": GOAL_ID,
275-
"status": "active",
276-
"acceptance": {"milestones": ["baseline_pass"]},
277-
},
278-
user_todo_summary={"gate_open_items": []},
279-
agent_todo_summary={"open_count": 0},
280-
run_history={"latest_runs": []},
281-
)
282-
assert projection["lifecycle_phase"] == PHASE_QUALIFYING, projection
283-
transitions = projection["next_transitions"]
284-
assert transitions, projection
285-
assert transitions[0]["target_phase"] != PHASE_CLOSED, projection
286-
assert transitions[0]["reason_codes"] == ["milestone_unreached"], projection
287-
247+
def assert_progress_evidence_alone_does_not_authorize_closeout() -> None:
248+
"""Evidence reaches the closing phase; only a verdict recommends closed.
288249
289-
def assert_reached_milestones_still_allow_closeout() -> None:
290-
"""The same inputs with evidence present do reach the closing phase."""
250+
The canonical progress outcomes prove the Goal advanced. They do not prove
251+
the declared acceptance was assessed, and the acceptance owner reports it
252+
was not, so the step stays inside closing.
253+
"""
291254

292255
projection = build_goal_artifact_lifecycle_projection(
293256
goal_id=GOAL_ID,
294-
goal={
295-
"id": GOAL_ID,
296-
"status": "active",
297-
"acceptance": {"milestones": ["primary_goal_outcome"]},
298-
},
257+
goal={"id": GOAL_ID, "status": "active"},
299258
user_todo_summary={"gate_open_items": []},
300259
agent_todo_summary={"open_count": 0},
301260
run_history={
@@ -307,8 +266,13 @@ def assert_reached_milestones_still_allow_closeout() -> None:
307266
]
308267
},
309268
)
269+
reached = {item["id"]: item["reached"] for item in projection["milestones"]}
270+
assert reached == {"primary_goal_outcome": True}, projection
310271
assert projection["lifecycle_phase"] == PHASE_CLOSING, projection
311-
assert projection["next_transitions"][0]["target_phase"] == PHASE_CLOSED, projection
272+
transition = projection["next_transitions"][0]
273+
assert transition["target_phase"] == PHASE_CLOSING, projection
274+
assert transition["target_phase"] != PHASE_CLOSED, projection
275+
assert "acceptance_unverified" in transition["reason_codes"], projection
312276

313277

314278
def assert_private_values_are_redacted_or_dropped() -> None:
@@ -414,7 +378,6 @@ def assert_status_collection_attaches_a_readable_readout() -> None:
414378
{
415379
"id": GOAL_ID,
416380
"status": "active",
417-
"acceptance": {"milestones": ["baseline_pass"]},
418381
}
419382
]
420383
},
@@ -513,16 +476,14 @@ def assert_control_plane_imports_no_presentation_module() -> None:
513476

514477
def main() -> int:
515478
assert_starting_phase_without_work()
516-
assert_declared_milestone_stays_unreached_while_gapped()
517479
assert_outcome_gap_is_material_but_not_reached()
518480
assert_gap_only_evidence_blocks_closeout()
519481
assert_evidence_milestone_reached_from_run_history()
520482
assert_open_owner_gate_blocks_the_next_transition()
521483
assert_evidence_guard_is_required_and_owned_by_the_agent()
522484
assert_closing_then_closed_phase()
523485
assert_projection_is_pure_and_reads_no_state()
524-
assert_unreached_milestone_blocks_closeout()
525-
assert_reached_milestones_still_allow_closeout()
486+
assert_progress_evidence_alone_does_not_authorize_closeout()
526487
assert_private_values_are_redacted_or_dropped()
527488
assert_batch_scale_never_promotes_an_outcome()
528489
assert_status_collection_attaches_a_readable_readout()

‎loopx/control_plane/goals/artifact_lifecycle.py‎

Lines changed: 43 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -85,40 +85,15 @@ def _is_closed(goal: dict[str, Any]) -> bool:
8585
return _goal_status(goal) in _TERMINAL_GOAL_STATUSES
8686

8787

88-
def _declared_milestones(goal: dict[str, Any]) -> list[dict[str, Any]]:
89-
"""Read Goal-declared acceptance markers, if the Goal records any."""
90-
91-
acceptance = _mapping(goal.get("acceptance"))
92-
raw = _list(acceptance.get("milestones")) or _list(goal.get("milestones"))
93-
milestones: list[dict[str, Any]] = []
94-
for index, item in enumerate(raw):
95-
record = _mapping(item)
96-
if not isinstance(item, str) and not record:
97-
continue
98-
milestone_id = (
99-
_compact_text(item, limit=120) if isinstance(item, str)
100-
else _compact_text(record.get("id") or record.get("milestone_id"), limit=120)
101-
)
102-
if not milestone_id:
103-
milestone_id = f"milestone_{index + 1}"
104-
label = (
105-
None if isinstance(item, str)
106-
else _compact_text(record.get("label") or record.get("summary"))
107-
)
108-
milestones.append(
109-
{
110-
"id": milestone_id,
111-
"label": label or milestone_id,
112-
"reached": False,
113-
"reached_evidence_refs": [],
114-
"source": "declared",
115-
}
116-
)
117-
return milestones
118-
119-
12088
def _evidence_milestones(run_history: dict[str, Any]) -> list[dict[str, Any]]:
121-
"""Fall back to material evidence the run history already recorded."""
89+
"""Markers this readout can evidence from material run history.
90+
91+
Goal-declared acceptance markers are deliberately not read: no authoring
92+
or collection path records `goal.acceptance.milestones` or
93+
`goal.milestones`, so reading them promised a readout that no user
94+
declaration could reach, while carrying the only guard able to hold back a
95+
closeout. Add them back together with the producer that writes them.
96+
"""
12297

12398
milestones: list[dict[str, Any]] = []
12499
seen: set[str] = set()
@@ -159,23 +134,6 @@ def _evidence_milestones(run_history: dict[str, Any]) -> list[dict[str, Any]]:
159134
return milestones
160135

161136

162-
def _milestones(
163-
goal: dict[str, Any],
164-
run_history: dict[str, Any],
165-
) -> list[dict[str, Any]]:
166-
declared = _declared_milestones(goal)
167-
evidence = _evidence_milestones(run_history)
168-
if not declared:
169-
return evidence
170-
# A declared marker is a claim about what the Goal intends to reach, not
171-
# proof that it did. It counts as reached only when evidence already
172-
# records that outcome.
173-
reached_outcomes = {item["id"] for item in evidence if item["reached"]}
174-
for milestone in declared:
175-
milestone["reached"] = milestone["id"] in reached_outcomes
176-
return declared + [item for item in evidence if item["id"] not in {m["id"] for m in declared}]
177-
178-
179137
def _guards(
180138
observation: dict[str, Any],
181139
acceptance_gaps: list[Any],
@@ -217,6 +175,26 @@ def _guards(
217175
return guards
218176

219177

178+
def _acceptance_supports_closeout(observation: dict[str, Any]) -> bool:
179+
"""Whether the acceptance owner has actually assessed the declared acceptance.
180+
181+
An empty `missing_sources` only means the bounded observation read every
182+
source it knows about, never that acceptance was verified: this projection
183+
reports `acceptance_assessed=False` and a coverage that is `partial` or
184+
`unavailable`. Treating "nothing left to name" as a verdict is what let a
185+
fully observed Goal be recommended for closeout with no acceptance behind
186+
it, so the verdict fields are read directly.
187+
"""
188+
189+
if not observation:
190+
return False
191+
if observation.get("acceptance_assessed") is not True:
192+
return False
193+
if observation.get("coverage") != "complete":
194+
return False
195+
return not _list(observation.get("missing_sources"))
196+
197+
220198
def _unobserved_acceptance_sources(observation: dict[str, Any]) -> list[str]:
221199
"""Name the acceptance sources the bounded observation could not read.
222200
@@ -318,17 +296,26 @@ def _next_transitions(
318296
# acceptance verdict. When the acceptance owner could not read some of
319297
# its sources, the closeout step says so instead of implying a verified
320298
# acceptance, and the reader keeps the decision.
299+
if _acceptance_supports_closeout(observation):
300+
return [
301+
{
302+
"target_phase": PHASE_CLOSED,
303+
"precondition": "record the terminal no-follow-up outcome",
304+
"reason_codes": ["no_open_agent_work"],
305+
}
306+
]
307+
# Without that verdict the step stays inside closing: recommending a
308+
# terminal outcome is the actionable error, and annotating the reason
309+
# codes does not undo it. Name the unread sources when there are any.
321310
unobserved = _unobserved_acceptance_sources(observation)
322-
precondition = "record the terminal no-follow-up outcome"
323-
reason_codes = ["no_open_agent_work"]
311+
precondition = "verify the declared acceptance with its existing owner"
324312
if unobserved:
325313
precondition += "; this readout could not observe " + ", ".join(unobserved)
326-
reason_codes.append("acceptance_unverified")
327314
return [
328315
{
329-
"target_phase": PHASE_CLOSED,
316+
"target_phase": PHASE_CLOSING,
330317
"precondition": precondition,
331-
"reason_codes": reason_codes,
318+
"reason_codes": ["no_open_agent_work", "acceptance_unverified"],
332319
}
333320
]
334321
return []
@@ -376,7 +363,7 @@ def build_goal_artifact_lifecycle_projection(
376363
gaps = [gap for gap in _list(acceptance_gaps) if _mapping(gap)]
377364
if acceptance_gaps is None:
378365
gaps = _list(observation.get("acceptance_gaps"))
379-
milestones = _milestones(goal_record, history)
366+
milestones = _evidence_milestones(history)
380367
guards = _guards(observation, gaps, agent_id=agent_id)
381368
phase = _lifecycle_phase(
382369
goal_record, guards=guards, milestones=milestones, agent_summary=agent_summary,

‎tests/control_plane/test_goal_acceptance_observation.py‎

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -330,13 +330,14 @@ def test_lifecycle_public_safety_covers_all_emitted_text():
330330
]
331331
for value in unsafe_values:
332332
projection = build_goal_artifact_lifecycle_projection(
333-
goal_id="demo", goal={"acceptance": {"milestones": [{"id": "baseline", "label": value}]}},
333+
goal_id="demo", goal={},
334334
agent_id=value, acceptance_gaps=[{"kind": "gap"}],
335335
run_history={"latest_runs": [{"delivery_outcome": "outcome_progress", "recommended_action": value, "evidence_ref": value}]},
336336
)
337337
validate_public_safe_value(projection)
338-
assert projection["milestones"][0]["label"] == "baseline"
339-
evidence = projection["milestones"][1]
338+
# An unsafe label and locator are both dropped, so the marker falls
339+
# back to its canonical outcome id and publishes no evidence ref.
340+
evidence = projection["milestones"][0]
340341
assert evidence["label"] == "outcome_progress"
341342
assert evidence["reached_evidence_refs"] == []
342343
assert projection["guards"][0]["agent_id"] is None
@@ -433,8 +434,58 @@ def test_lifecycle_closeout_names_unobserved_acceptance_sources():
433434
agent_todo_summary={"open_count": 0},
434435
run_history={"latest_runs": [{"delivery_outcome": "outcome_progress"}]},
435436
)
437+
# Closing is the todo-completion reading. Without an acceptance verdict the
438+
# step stays inside closing and names what was not observed, rather than
439+
# recommending the terminal outcome with a caveat attached.
436440
assert projection["lifecycle_phase"] == "closing"
437441
transition = projection["next_transitions"][0]
438-
assert transition["target_phase"] == "closed"
442+
assert transition["target_phase"] == "closing"
439443
assert transition["reason_codes"] == ["no_open_agent_work", "acceptance_unverified"]
440444
assert transition["precondition"].endswith("this readout could not observe agent_vision")
445+
446+
447+
def test_lifecycle_fully_observed_goal_still_needs_an_acceptance_verdict():
448+
"""An empty `missing_sources` is not an acceptance verdict.
449+
450+
With an attention item and agent vision both present the observation has
451+
nothing left to name, but it still reports `acceptance_assessed=False` and
452+
a `partial` coverage. Reading "nothing missing" as "acceptance verified"
453+
would recommend the terminal outcome with no acceptance behind it and no
454+
disclosure attached.
455+
"""
456+
457+
from loopx.control_plane.goals.acceptance_observation import (
458+
build_goal_acceptance_observation,
459+
)
460+
from loopx.control_plane.goals.artifact_lifecycle import (
461+
build_goal_artifact_lifecycle_projection,
462+
)
463+
464+
runs = [{
465+
"delivery_outcome": "outcome_progress",
466+
"agent_id": "agent-a",
467+
"agent_vision": {"agent_id": "agent-a", "acceptance_met": True},
468+
}]
469+
attention = {
470+
"goal_id": "demo",
471+
"user_todos": {"gate_open_items": []},
472+
"agent_todos": {"open_count": 0},
473+
}
474+
observation = build_goal_acceptance_observation(
475+
{"id": "demo", "status": "active", "latest_runs": runs}, attention
476+
)
477+
assert observation["missing_sources"] == []
478+
assert observation["acceptance_assessed"] is False
479+
assert observation["coverage"] != "complete"
480+
481+
projection = build_goal_artifact_lifecycle_projection(
482+
goal_id="demo", goal={"id": "demo", "status": "active"},
483+
user_todo_summary={"gate_open_items": []},
484+
agent_todo_summary={"open_count": 0},
485+
run_history={"latest_runs": runs},
486+
attention_item=attention,
487+
)
488+
transition = projection["next_transitions"][0]
489+
assert transition["target_phase"] == "closing"
490+
assert transition["reason_codes"] == ["no_open_agent_work", "acceptance_unverified"]
491+
assert "could not observe" not in transition["precondition"]

0 commit comments

Comments
 (0)