Skip to content

Commit b9d7160

Browse files
committed
test(goals): keep the two Goal projections distinct now that both ship
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 <liusongstep@gmail.com>
1 parent 55e74cc commit b9d7160

2 files changed

Lines changed: 53 additions & 1 deletion

File tree

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

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,6 +401,49 @@ def assert_status_collection_attaches_a_readable_readout() -> None:
401401
assert_no_public_leak(projection)
402402

403403

404+
def assert_the_two_goal_projections_stay_distinct() -> None:
405+
"""The lifecycle contract and the narrow acceptance observation must not mix.
406+
407+
`#4248` shipped `goal_acceptance_observation_projection_v0` as bounded
408+
historical evidence and guarded that it is not the full lifecycle contract.
409+
Both now ship, so each renderer must refuse the other's schema rather than
410+
print a half-understood payload under its own heading.
411+
"""
412+
413+
from loopx.presentation.renderers.goal_acceptance_observation_markdown import (
414+
append_goal_acceptance_observation_markdown,
415+
)
416+
from loopx.presentation.renderers.goal_artifact_lifecycle_markdown import (
417+
append_goal_artifact_lifecycle_markdown,
418+
)
419+
420+
lifecycle = build_goal_artifact_lifecycle_projection(
421+
goal_id=GOAL_ID, goal={"id": GOAL_ID, "status": "active"}
422+
)
423+
narrow = {
424+
"schema_version": "goal_acceptance_observation_projection_v0",
425+
"guards": [],
426+
"acceptance_gaps": [],
427+
"historical_progress": [],
428+
}
429+
# Each renderer prints only its own contract, whichever key carries it.
430+
for goal in ({"artifact_lifecycle": narrow}, {"artifact_lifecycle": {}}):
431+
lines: list[str] = []
432+
append_goal_artifact_lifecycle_markdown(lines, goal)
433+
assert lines == [], (goal, lines)
434+
for goal in ({"acceptance_observation": lifecycle}, {"acceptance_observation": {}}):
435+
lines = []
436+
append_goal_acceptance_observation_markdown(lines, goal)
437+
assert lines == [], (goal, lines)
438+
# And the lifecycle renderer does print its own contract.
439+
lines = []
440+
append_goal_artifact_lifecycle_markdown(lines, {"artifact_lifecycle": lifecycle})
441+
assert any("artifact lifecycle" in line for line in lines), lines
442+
# The phase/milestone/transition vocabulary belongs to the lifecycle alone.
443+
assert {"lifecycle_phase", "milestones", "next_transitions"} <= set(lifecycle)
444+
assert {"lifecycle_phase", "milestones", "next_transitions"}.isdisjoint(narrow)
445+
446+
404447
def assert_control_plane_imports_no_presentation_module() -> None:
405448
"""The projection may not depend outward on the presentation layer."""
406449

@@ -433,6 +476,7 @@ def main() -> int:
433476
assert_batch_scale_never_promotes_an_outcome()
434477
assert_status_collection_attaches_a_readable_readout()
435478
assert_control_plane_imports_no_presentation_module()
479+
assert_the_two_goal_projections_stay_distinct()
436480
print("goal-artifact-lifecycle-projection-smoke ok")
437481
return 0
438482

‎tests/control_plane/test_goal_acceptance_observation.py‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,9 +203,17 @@ def test_real_collection_preserves_acceptance_before_display_run_trimming(tmp_pa
203203
result = collect_fixture(tmp_path)
204204
goal = result["run_history"]["goals"][0]
205205
assert goal["latest_runs"] == []
206-
assert "artifact_lifecycle" not in goal
207206
projection = goal["acceptance_observation"]
208207
assert projection["schema_version"] == "goal_acceptance_observation_projection_v0"
208+
# The full lifecycle contract now ships beside this one. They stay distinct
209+
# projections under distinct keys and schema versions: this observation is
210+
# bounded historical evidence, never the lifecycle's phase/milestone answer.
211+
lifecycle = goal["artifact_lifecycle"]
212+
assert lifecycle["schema_version"] == "goal_artifact_lifecycle_projection_v0"
213+
assert lifecycle["schema_version"] != projection["schema_version"]
214+
# Only the lifecycle projection answers phase, milestones and transitions.
215+
assert {"lifecycle_phase", "milestones", "next_transitions"} <= set(lifecycle)
216+
assert {"lifecycle_phase", "milestones", "next_transitions"}.isdisjoint(projection)
209217
assert (
210218
projection["acceptance_gaps"][0]["evidence_required"]
211219
== "Independent verification report"

0 commit comments

Comments
 (0)