Skip to content

Commit 55e74cc

Browse files
committed
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 <liusongstep@gmail.com>
1 parent 640b6ae commit 55e74cc

5 files changed

Lines changed: 249 additions & 11 deletions

File tree

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

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,123 @@ def assert_private_values_are_redacted_or_dropped() -> None:
302302
)
303303

304304

305+
def assert_batch_scale_never_promotes_an_outcome() -> None:
306+
"""The batch scale describes delivery width; it is not Goal evidence.
307+
308+
Regression for the counterexample the review raised: a `surface_only` run
309+
also carrying `delivery_batch_scale=multi_surface` must not become a reached
310+
milestone, because the canonical typed rule
311+
(`MATERIAL_DELIVERY_OUTCOMES`) excludes `surface_only`.
312+
"""
313+
314+
for scale in ("single_surface", "multi_surface"):
315+
for outcome in ("surface_only", "", "bogus", "multi_surface"):
316+
projection = build_goal_artifact_lifecycle_projection(
317+
goal_id=GOAL_ID,
318+
goal={"id": GOAL_ID, "status": "active"},
319+
run_history={
320+
"latest_runs": [
321+
{
322+
"delivery_outcome": outcome,
323+
"delivery_batch_scale": scale,
324+
"run_id": "run-1",
325+
}
326+
]
327+
},
328+
)
329+
assert projection["milestones"] == [], (outcome, scale, projection["milestones"])
330+
# Every canonical material outcome still counts, at any scale.
331+
for scale in ("single_surface", "multi_surface"):
332+
for outcome in ("outcome_gap", "outcome_progress", "primary_goal_outcome"):
333+
projection = build_goal_artifact_lifecycle_projection(
334+
goal_id=GOAL_ID,
335+
goal={"id": GOAL_ID, "status": "active"},
336+
run_history={
337+
"latest_runs": [
338+
{
339+
"delivery_outcome": outcome,
340+
"delivery_batch_scale": scale,
341+
"run_id": "run-1",
342+
}
343+
]
344+
},
345+
)
346+
reached = [item for item in projection["milestones"] if item["reached"]]
347+
assert [item["id"] for item in reached] == [outcome], (outcome, scale, reached)
348+
349+
350+
def assert_status_collection_attaches_a_readable_readout() -> None:
351+
"""The RFC's smallest slice includes one readout in status markdown.
352+
353+
Drives the same seam `loopx status` uses: collection attaches the
354+
projection, the presentation renderer prints it. The projection is derived
355+
from already-collected payloads, so this asserts no extra IO is required.
356+
"""
357+
358+
from loopx.control_plane.goals.artifact_lifecycle import (
359+
attach_goal_artifact_lifecycle_projections,
360+
)
361+
from loopx.presentation.renderers.status_markdown import render_status_markdown
362+
363+
payload = {
364+
"run_history": {
365+
"goals": [
366+
{
367+
"id": GOAL_ID,
368+
"status": "active",
369+
"acceptance": {"milestones": ["baseline_pass"]},
370+
}
371+
]
372+
},
373+
"attention_queue": {
374+
"items": [
375+
{
376+
"goal_id": GOAL_ID,
377+
"user_todo_summary": {
378+
"gate_open_items": [
379+
{
380+
"todo_id": "todo_gate",
381+
"text": "approve the release",
382+
"status": "open",
383+
"action_kind": "publish",
384+
}
385+
]
386+
},
387+
"agent_todo_summary": {"open_count": 0},
388+
}
389+
]
390+
},
391+
}
392+
attach_goal_artifact_lifecycle_projections(payload, history={"goals": []})
393+
goal = payload["run_history"]["goals"][0]
394+
projection = goal["artifact_lifecycle"]
395+
assert projection["schema_version"] == GOAL_ARTIFACT_LIFECYCLE_PROJECTION_SCHEMA_VERSION
396+
assert projection["lifecycle_phase"] == PHASE_WAITING_OWNER, projection
397+
assert [guard["id"] for guard in projection["guards"]] == ["todo_gate"]
398+
markdown = render_status_markdown(payload)
399+
assert "artifact lifecycle: phase=waiting_owner" in markdown, markdown
400+
assert "blocked by owner_decision (user): todo_gate" in markdown, markdown
401+
assert_no_public_leak(projection)
402+
403+
404+
def assert_control_plane_imports_no_presentation_module() -> None:
405+
"""The projection may not depend outward on the presentation layer."""
406+
407+
import ast
408+
409+
source = (
410+
REPO_ROOT / "loopx" / "control_plane" / "goals" / "artifact_lifecycle.py"
411+
).read_text(encoding="utf-8")
412+
imported: list[str] = []
413+
for node in ast.walk(ast.parse(source)):
414+
if isinstance(node, ast.ImportFrom) and node.module:
415+
imported.append(node.module)
416+
elif isinstance(node, ast.Import):
417+
imported.extend(alias.name for alias in node.names)
418+
offenders = [name for name in imported if "presentation" in name]
419+
assert offenders == [], offenders
420+
421+
305422
def main() -> int:
306423
assert_starting_phase_without_work()
307424
assert_declared_milestone_stays_unreached_while_gapped()
@@ -313,6 +430,9 @@ def main() -> int:
313430
assert_unreached_milestone_blocks_closeout()
314431
assert_reached_milestones_still_allow_closeout()
315432
assert_private_values_are_redacted_or_dropped()
433+
assert_batch_scale_never_promotes_an_outcome()
434+
assert_status_collection_attaches_a_readable_readout()
435+
assert_control_plane_imports_no_presentation_module()
316436
print("goal-artifact-lifecycle-projection-smoke ok")
317437
return 0
318438

‎loopx/control_plane/goals/artifact_lifecycle.py‎

Lines changed: 78 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,11 @@
2121
import re
2222
from typing import Any
2323

24-
from ...presentation.public_safety import redact_public_text
2524
from ...public_safe_text import find_private_text_match
25+
from ..work_items.delivery_outcome import (
26+
MATERIAL_DELIVERY_OUTCOMES,
27+
normalize_delivery_outcome,
28+
)
2629

2730
GOAL_ARTIFACT_LIFECYCLE_PROJECTION_SCHEMA_VERSION = (
2831
"goal_artifact_lifecycle_projection_v0"
@@ -48,19 +51,38 @@
4851
r"\b(?:gh[pousr]_[A-Za-z0-9]{16,}|sk-[A-Za-z0-9_-]{16,}|AKIA[0-9A-Z]{16})\b"
4952
)
5053

51-
# A material run outcome that a Goal's own acceptance can rest on.
52-
_MATERIAL_OUTCOMES = {"primary_goal_outcome", "outcome_progress", "multi_surface"}
54+
# Local path shapes this projection rewrites before publishing a label. The
55+
# control plane may not import the presentation layer's sanitizer, and the
56+
# lifecycle projection must not mint a second copy of the shared private-text
57+
# contract: it reuses `find_private_text_match` for classification and keeps
58+
# only this bounded, inward-safe rewrite.
59+
_PATH_SHAPES = (
60+
re.compile(r"/(?:Users|home|private|tmp|var)/[^\s`|,)]+"),
61+
re.compile(r"[A-Za-z]:\\\\Users\\\\[^\s`|,)]+"),
62+
)
63+
64+
_TRUNCATION_MARKER = "..."
65+
66+
67+
def _bounded_redacted_text(value: Any, *, limit: int) -> str:
68+
text = str(value or "").strip()
69+
for pattern in _PATH_SHAPES:
70+
text = pattern.sub("<local-path-redacted>", text)
71+
text = re.sub(r"\s+", " ", text)
72+
if len(text) > limit:
73+
return text[: max(0, limit - 1)].rstrip() + _TRUNCATION_MARKER
74+
return text
5375

5476

5577
def _compact_text(value: Any, *, limit: int = 240) -> str | None:
5678
"""Bound and redact one label; never carry a raw body, path or credential."""
5779

5880
if not isinstance(value, str):
5981
return None
60-
collapsed = redact_public_text(value, limit=limit)
82+
collapsed = _bounded_redacted_text(value, limit=limit)
6183
if not collapsed:
6284
return None
63-
# The shared sanitizer covers local paths; this projection additionally
85+
# The shared private-text contract classifies; this projection additionally
6486
# refuses a value that still matches a private-text or credential shape
6587
# rather than publishing a partly-redacted fragment.
6688
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]]:
125147
record = _mapping(run)
126148
if not record:
127149
continue
128-
outcome = str(record.get("delivery_outcome") or "").strip()
129-
scale = str(record.get("delivery_batch_scale") or "").strip()
130-
classification = str(record.get("classification") or "").strip()
131-
if outcome not in _MATERIAL_OUTCOMES and scale != "multi_surface":
150+
outcome = normalize_delivery_outcome(record.get("delivery_outcome"))
151+
# Only a canonical material delivery outcome is Goal evidence. The batch
152+
# scale describes how wide a delivery was, never whether it advanced the
153+
# Goal, so it cannot promote `surface_only` into a reached milestone.
154+
if outcome is None or outcome not in MATERIAL_DELIVERY_OUTCOMES:
132155
continue
133-
milestone_id = outcome or scale or classification
134-
if not milestone_id or milestone_id in seen:
156+
milestone_id = outcome.value
157+
if milestone_id in seen:
135158
continue
136159
seen.add(milestone_id)
137160
reference = _compact_text(record.get("evidence_ref") or record.get("run_id"), limit=120)
@@ -343,6 +366,49 @@ def build_goal_artifact_lifecycle_projection(
343366
}
344367

345368

369+
def attach_goal_artifact_lifecycle_projections(
370+
payload: dict[str, Any], *, history: dict[str, Any]
371+
) -> None:
372+
"""Attach one bounded lifecycle projection to every projected Goal.
373+
374+
Reads only payloads status collection already gathered, so the operator
375+
readout costs no extra IO and grants no authority. A Goal the projection
376+
cannot derive from is left without the key rather than given a placeholder.
377+
"""
378+
379+
sources = {str(goal.get("id")): goal for goal in _list(history.get("goals")) if _mapping(goal)}
380+
run_history = _mapping(payload.get("run_history"))
381+
items = _list(_mapping(payload.get("attention_queue")).get("items"))
382+
for goal in _list(run_history.get("goals")):
383+
record = _mapping(goal)
384+
goal_id = str(record.get("id") or "").strip()
385+
if not goal_id:
386+
continue
387+
source = {**sources.get(goal_id, {}), **record}
388+
item = next(
389+
(row for row in items if _mapping(row).get("goal_id") == goal_id), None
390+
)
391+
attention = _mapping(item)
392+
projection = build_goal_artifact_lifecycle_projection(
393+
goal_id=goal_id,
394+
goal=source,
395+
user_todo_summary=_mapping(
396+
attention.get("user_todo_summary") or source.get("user_todo_summary")
397+
),
398+
agent_todo_summary=_mapping(
399+
attention.get("agent_todo_summary") or source.get("agent_todo_summary")
400+
),
401+
run_history=run_history,
402+
work_lane_contract=_mapping(
403+
source.get("work_lane_contract") or attention.get("work_lane_contract")
404+
),
405+
acceptance_gaps=_list(
406+
source.get("acceptance_gaps") or attention.get("acceptance_gaps")
407+
),
408+
)
409+
record["artifact_lifecycle"] = projection
410+
411+
346412
__all__ = [
347413
"GOAL_ARTIFACT_LIFECYCLE_PROJECTION_SCHEMA_VERSION",
348414
"GUARD_KIND_EVIDENCE",
@@ -352,5 +418,6 @@ def build_goal_artifact_lifecycle_projection(
352418
"PHASE_QUALIFYING",
353419
"PHASE_STARTING",
354420
"PHASE_WAITING_OWNER",
421+
"attach_goal_artifact_lifecycle_projections",
355422
"build_goal_artifact_lifecycle_projection",
356423
]

‎loopx/control_plane/status/collection.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from typing import Any, Callable
1010

1111
from ..goals.acceptance_observation import attach_goal_acceptance_observations
12+
from ..goals.artifact_lifecycle import attach_goal_artifact_lifecycle_projections
1213
from ..goals.contract_health import project_contract_health_for_goal
1314
from ..goals.activation import (
1415
GoalActivationState,
@@ -243,4 +244,5 @@ def collect_status(
243244
goal_channel_notification_projection
244245
)
245246
attach_goal_acceptance_observations(payload, history=history)
247+
attach_goal_artifact_lifecycle_projections(payload, history=history)
246248
return payload
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
"""Render the derived Goal artifact lifecycle projection owned by status collection."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Any
6+
7+
from ...control_plane.goals.artifact_lifecycle import (
8+
GOAL_ARTIFACT_LIFECYCLE_PROJECTION_SCHEMA_VERSION,
9+
)
10+
from ..markdown import as_dict, as_list, markdown_scalar
11+
12+
13+
def append_goal_artifact_lifecycle_markdown(
14+
lines: list[str], goal: dict[str, Any]
15+
) -> None:
16+
projection = as_dict(goal.get("artifact_lifecycle"))
17+
if (
18+
projection.get("schema_version")
19+
!= GOAL_ARTIFACT_LIFECYCLE_PROJECTION_SCHEMA_VERSION
20+
):
21+
return
22+
milestones = as_list(projection.get("milestones"))
23+
reached = [
24+
milestone
25+
for milestone in milestones
26+
if isinstance(milestone, dict) and milestone.get("reached") is True
27+
]
28+
lines.append(
29+
" - artifact lifecycle: "
30+
f"phase={markdown_scalar(projection.get('lifecycle_phase') or 'unknown')} "
31+
f"milestones={len(reached)}/{len(milestones)} "
32+
f"guards={len(as_list(projection.get('guards')))}"
33+
)
34+
for guard in as_list(projection.get("guards")):
35+
if not isinstance(guard, dict) or guard.get("blocked") is not True:
36+
continue
37+
lines.append(
38+
f" - blocked by {markdown_scalar(guard.get('kind') or 'unknown')} "
39+
f"({markdown_scalar(guard.get('owner') or 'unowned')}): "
40+
f"{markdown_scalar(guard.get('id') or 'unknown')}"
41+
)
42+
for transition in as_list(projection.get("next_transitions")):
43+
if isinstance(transition, dict):
44+
lines.append(
45+
f" - next: {markdown_scalar(transition.get('target_phase') or 'unknown')} "
46+
f"({markdown_scalar(transition.get('precondition') or 'unknown')})"
47+
)

‎loopx/presentation/renderers/status_markdown.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from ...orchestration import orchestration_policy_summary
1111
from ..markdown import as_dict, as_list, markdown_scalar
1212
from .goal_acceptance_observation_markdown import append_goal_acceptance_observation_markdown
13+
from .goal_artifact_lifecycle_markdown import append_goal_artifact_lifecycle_markdown
1314
from .reward_memory_markdown import append_agent_reward_memory_markdown
1415

1516

@@ -246,6 +247,7 @@ def append_run_history_markdown(lines: list[str], run_history: dict[str, Any]) -
246247
f"unique_runs={goal.get('unique_runs')}"
247248
)
248249
append_goal_acceptance_observation_markdown(lines, goal)
250+
append_goal_artifact_lifecycle_markdown(lines, goal)
249251
quota = goal.get("quota") if isinstance(goal.get("quota"), dict) else {}
250252
if quota:
251253
lines.append(

0 commit comments

Comments
 (0)