refactor(A1): Progression-Lanes aus Graph statt Strang=Project
All checks were successful
Deploy Development / deploy (push) Successful in 53s
Test Suite / pytest-backend (push) Successful in 4m52s
Test Suite / lint-backend (push) Successful in 3s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 18s
Test Suite / playwright-smoke (push) Successful in 31s

Ersetzt strand_project_id/Stream-Projects in der A1-Steuerung durch graph-abgeleitete Lanes (requires-Ketten + Join). Recurring bindet nur noch am Gate; Re-Abnahme-Docs und ADP dokumentieren die Korrektur.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-08-03 18:51:17 +02:00
parent 88a03a65f2
commit 17add13d9d
12 changed files with 396 additions and 125 deletions

View File

@ -316,9 +316,10 @@ def _retire_legacy_linear_stages_for_progression(
*, *,
tenant_id: str, tenant_id: str,
initiative_id: str, initiative_id: str,
keep_gate_ids: frozenset[str],
user_id: Optional[str] = None, user_id: Optional[str] = None,
) -> None: ) -> None:
"""Starter-Kit-Stufen ohne Strang zurückstellen, wenn Join-Graph angelegt wird.""" """Starter-Kit-Stufen zurückstellen, wenn Join-Graph angelegt wird."""
from services.maturity_practice import list_practices_for_stage, update_recurring_element from services.maturity_practice import list_practices_for_stage, update_recurring_element
from services.roadmap import list_roadmap_items_for_initiative, update_roadmap_item from services.roadmap import list_roadmap_items_for_initiative, update_roadmap_item
@ -327,7 +328,7 @@ def _retire_legacy_linear_stages_for_progression(
): ):
if item.get("item_type") != "maturity_stage": if item.get("item_type") != "maturity_stage":
continue continue
if item.get("strand_project_id"): if str(item["id"]) in keep_gate_ids:
continue continue
if item.get("status") not in ("active", "at_risk"): if item.get("status") not in ("active", "at_risk"):
continue continue
@ -357,9 +358,8 @@ def apply_a1_progression_demo(
initiative_id: str, initiative_id: str,
user_id: Optional[str] = None, user_id: Optional[str] = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""PO-Kernbild: 2 Stränge → Join → Ausgänge (AP-A1-PM-6). Idempotent wenn Struktur existiert.""" """PO-Kernbild: 2 parallele Gates → Join → Ausgänge (AP-A1-PM-6). Idempotent wenn Struktur existiert."""
from services.maturity_practice import seed_practices_for_stage from services.maturity_practice import seed_practices_for_stage
from services.projects import create_project, list_projects_for_initiative
from services.roadmap import add_dependency, create_roadmap_item, list_roadmap_items_for_initiative from services.roadmap import add_dependency, create_roadmap_item, list_roadmap_items_for_initiative
existing = list_roadmap_items_for_initiative( existing = list_roadmap_items_for_initiative(
@ -368,30 +368,6 @@ def apply_a1_progression_demo(
if any(item.get("item_type") == "join_gate" for item in existing): if any(item.get("item_type") == "join_gate" for item in existing):
return {"applied": False, "reason": "progression_exists"} return {"applied": False, "reason": "progression_exists"}
projects = list_projects_for_initiative(tenant_id=tenant_id, initiative_id=initiative_id)
dehnung = _find_by_title(projects, "Dehnung")
if not dehnung:
dehnung = create_project(
tenant_id=tenant_id,
initiative_id=initiative_id,
title="Dehnung",
description="Strang Dehnung",
container_kind="stream",
sort_order=0,
user_id=user_id,
)
huft = _find_by_title(projects, "Hüftrotation")
if not huft:
huft = create_project(
tenant_id=tenant_id,
initiative_id=initiative_id,
title="Hüftrotation",
description="Strang Hüftrotation",
container_kind="stream",
sort_order=1,
user_id=user_id,
)
d1 = create_roadmap_item( d1 = create_roadmap_item(
tenant_id=tenant_id, tenant_id=tenant_id,
initiative_id=initiative_id, initiative_id=initiative_id,
@ -399,7 +375,6 @@ def apply_a1_progression_demo(
item_type="maturity_stage", item_type="maturity_stage",
status="active", status="active",
sort_order=10, sort_order=10,
strand_project_id=dehnung["id"],
user_id=user_id, user_id=user_id,
) )
h1 = create_roadmap_item( h1 = create_roadmap_item(
@ -409,7 +384,6 @@ def apply_a1_progression_demo(
item_type="maturity_stage", item_type="maturity_stage",
status="active", status="active",
sort_order=20, sort_order=20,
strand_project_id=huft["id"],
user_id=user_id, user_id=user_id,
) )
join = create_roadmap_item( join = create_roadmap_item(
@ -428,7 +402,6 @@ def apply_a1_progression_demo(
item_type="maturity_stage", item_type="maturity_stage",
status="planned", status="planned",
sort_order=40, sort_order=40,
strand_project_id=dehnung["id"],
user_id=user_id, user_id=user_id,
) )
h2 = create_roadmap_item( h2 = create_roadmap_item(
@ -438,7 +411,6 @@ def apply_a1_progression_demo(
item_type="maturity_stage", item_type="maturity_stage",
status="planned", status="planned",
sort_order=50, sort_order=50,
strand_project_id=huft["id"],
user_id=user_id, user_id=user_id,
) )
@ -470,12 +442,14 @@ def apply_a1_progression_demo(
_retire_legacy_linear_stages_for_progression( _retire_legacy_linear_stages_for_progression(
tenant_id=tenant_id, tenant_id=tenant_id,
initiative_id=initiative_id, initiative_id=initiative_id,
keep_gate_ids=frozenset(
str(gate["id"]) for gate in (d1, h1, d2, h2)
),
user_id=user_id, user_id=user_id,
) )
return { return {
"applied": True, "applied": True,
"projects": ["Dehnung", "Hüftrotation"],
"join": join["title"], "join": join["title"],
"active_gates": [d1["title"], h1["title"]], "active_gates": [d1["title"], h1["title"]],
} }

View File

@ -102,14 +102,27 @@ def pause_all_practices(
return paused return paused
def pause_practices_except_stage( def pause_practices_on_same_lane_except_stage(
*, *,
tenant_id: str, tenant_id: str,
initiative_id: str, initiative_id: str,
keep_stage_id: str, keep_stage_id: str,
strand_project_id: Optional[str] = None,
user_id: Optional[str] = None, user_id: Optional[str] = None,
) -> list[str]: ) -> list[str]:
from services.progression_graph import progression_lanes_for_initiative
lanes = progression_lanes_for_initiative(
tenant_id=tenant_id, initiative_id=initiative_id
)
keep_lane = lanes.get(str(keep_stage_id))
if not keep_lane:
return pause_all_practices(
tenant_id=tenant_id, initiative_id=initiative_id, user_id=user_id
)
lane_gate_ids = {
gate_id for gate_id, lane in lanes.items() if lane == keep_lane
}
paused: list[str] = [] paused: list[str] = []
for item in list_recurring_for_initiative( for item in list_recurring_for_initiative(
tenant_id=tenant_id, initiative_id=initiative_id tenant_id=tenant_id, initiative_id=initiative_id
@ -118,7 +131,7 @@ def pause_practices_except_stage(
continue continue
if item.get("roadmap_item_id") == keep_stage_id: if item.get("roadmap_item_id") == keep_stage_id:
continue continue
if strand_project_id is not None and item.get("project_id") != strand_project_id: if str(item.get("roadmap_item_id") or "") not in lane_gate_ids:
continue continue
update_recurring_element( update_recurring_element(
tenant_id=tenant_id, tenant_id=tenant_id,
@ -130,6 +143,23 @@ def pause_practices_except_stage(
return paused return paused
def pause_practices_except_stage(
*,
tenant_id: str,
initiative_id: str,
keep_stage_id: str,
strand_project_id: Optional[str] = None,
user_id: Optional[str] = None,
) -> list[str]:
del strand_project_id
return pause_practices_on_same_lane_except_stage(
tenant_id=tenant_id,
initiative_id=initiative_id,
keep_stage_id=keep_stage_id,
user_id=user_id,
)
def seed_practices_for_stage( def seed_practices_for_stage(
*, *,
tenant_id: str, tenant_id: str,
@ -140,7 +170,6 @@ def seed_practices_for_stage(
"""Idempotent: create missing exercises for a stage; activate existing.""" """Idempotent: create missing exercises for a stage; activate existing."""
stage_id = stage["id"] stage_id = stage["id"]
stage_title = stage.get("title") or "" stage_title = stage.get("title") or ""
strand_project_id = stage.get("strand_project_id")
defs = STAGE_PRACTICE_SETS.get(stage_title) defs = STAGE_PRACTICE_SETS.get(stage_title)
if not defs: if not defs:
defs = [ defs = [
@ -185,7 +214,6 @@ def seed_practices_for_stage(
interval_days=1, interval_days=1,
next_due_at=now, next_due_at=now,
roadmap_item_id=stage_id, roadmap_item_id=stage_id,
project_id=strand_project_id,
user_id=user_id, user_id=user_id,
) )
created.append(row) created.append(row)
@ -200,19 +228,12 @@ def activate_stage_practice_set(
stage: dict[str, Any], stage: dict[str, Any],
user_id: Optional[str] = None, user_id: Optional[str] = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
strand_project_id = stage.get("strand_project_id") paused = pause_practices_on_same_lane_except_stage(
if strand_project_id: tenant_id=tenant_id,
paused = pause_practices_except_stage( initiative_id=initiative_id,
tenant_id=tenant_id, keep_stage_id=stage["id"],
initiative_id=initiative_id, user_id=user_id,
keep_stage_id=stage["id"], )
strand_project_id=strand_project_id,
user_id=user_id,
)
else:
paused = pause_all_practices(
tenant_id=tenant_id, initiative_id=initiative_id, user_id=user_id
)
exercises = seed_practices_for_stage( exercises = seed_practices_for_stage(
tenant_id=tenant_id, tenant_id=tenant_id,
initiative_id=initiative_id, initiative_id=initiative_id,

View File

@ -63,6 +63,101 @@ def successor_work_gate_ids(
] ]
def _work_gate_predecessor_ids(
gate_id: str,
*,
work_gate_ids: frozenset[str],
join_gate_ids: frozenset[str],
dependencies: list[dict[str, Any]],
) -> list[str]:
"""Direct requires predecessors that are work gates or join gates."""
preds: list[str] = []
for dep in _requires_deps(dependencies):
if str(dep["from_item_id"]) != str(gate_id):
continue
pred_id = str(dep["to_item_id"])
if pred_id in work_gate_ids or pred_id in join_gate_ids:
preds.append(pred_id)
return preds
def compute_progression_lanes(
*,
items: list[dict[str, Any]],
dependencies: list[dict[str, Any]],
) -> dict[str, str]:
"""
Graph-derived progression lane per work gate (not persisted).
Parallel active gates live on different lanes. Sequential gates on the same
requires-chain share a lane. First gate after a join starts a new lane.
"""
item_by_id = {str(item["id"]): item for item in items}
work_gate_ids = frozenset(
str(item["id"]) for item in items if is_work_gate(item)
)
join_gate_ids = frozenset(
str(item["id"]) for item in items if is_join_gate(item)
)
work_pred: dict[str, Optional[str]] = {}
for gate_id in work_gate_ids:
work_preds = [
pred_id
for pred_id in _work_gate_predecessor_ids(
gate_id,
work_gate_ids=work_gate_ids,
join_gate_ids=join_gate_ids,
dependencies=dependencies,
)
if pred_id in work_gate_ids
]
work_pred[gate_id] = work_preds[0] if len(work_preds) == 1 else None
lanes: dict[str, str] = {}
def resolve_lane(gate_id: str) -> str:
cached = lanes.get(gate_id)
if cached:
return cached
pred = work_pred.get(gate_id)
if pred:
lanes[gate_id] = resolve_lane(pred)
return lanes[gate_id]
join_preds = [
pred_id
for pred_id in _work_gate_predecessor_ids(
gate_id,
work_gate_ids=work_gate_ids,
join_gate_ids=join_gate_ids,
dependencies=dependencies,
)
if pred_id in join_gate_ids
]
if join_preds:
lanes[gate_id] = f"post-join:{gate_id}"
return lanes[gate_id]
lanes[gate_id] = f"source:{gate_id}"
return lanes[gate_id]
for gate_id in work_gate_ids:
resolve_lane(gate_id)
return lanes
def progression_lanes_for_initiative(
*, tenant_id: str, initiative_id: str
) -> dict[str, str]:
from services.roadmap import list_dependencies_for_initiative, list_roadmap_items_for_initiative
items = list_roadmap_items_for_initiative(
tenant_id=tenant_id, initiative_id=initiative_id
)
dependencies = list_dependencies_for_initiative(
tenant_id=tenant_id, initiative_id=initiative_id
)
return compute_progression_lanes(items=items, dependencies=dependencies)
def list_active_work_gates( def list_active_work_gates(
*, tenant_id: str, initiative_id: str *, tenant_id: str, initiative_id: str
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
@ -71,12 +166,10 @@ def list_active_work_gates(
items = list_roadmap_items_for_initiative( items = list_roadmap_items_for_initiative(
tenant_id=tenant_id, initiative_id=initiative_id tenant_id=tenant_id, initiative_id=initiative_id
) )
has_join_graph = any(is_join_gate(item) for item in items)
active = [ active = [
item item
for item in items for item in items
if is_work_gate(item) and item.get("status") in ACTIVE_WORK_STATUSES if is_work_gate(item) and item.get("status") in ACTIVE_WORK_STATUSES
and (not has_join_graph or item.get("strand_project_id") is not None)
] ]
active.sort(key=lambda item: (item.get("sort_order", 0), item.get("title", ""))) active.sort(key=lambda item: (item.get("sort_order", 0), item.get("title", "")))
return active return active
@ -87,7 +180,6 @@ def compute_join_states(
tenant_id: str, tenant_id: str,
initiative_id: str, initiative_id: str,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
from services.projects import list_projects_for_initiative
from services.roadmap import list_dependencies_for_initiative, list_roadmap_items_for_initiative from services.roadmap import list_dependencies_for_initiative, list_roadmap_items_for_initiative
items = list_roadmap_items_for_initiative( items = list_roadmap_items_for_initiative(
@ -97,10 +189,6 @@ def compute_join_states(
dependencies = list_dependencies_for_initiative( dependencies = list_dependencies_for_initiative(
tenant_id=tenant_id, initiative_id=initiative_id tenant_id=tenant_id, initiative_id=initiative_id
) )
projects = list_projects_for_initiative(
tenant_id=tenant_id, initiative_id=initiative_id
)
project_title = {str(p["id"]): p.get("title") or "" for p in projects}
joins = [item for item in items if is_join_gate(item)] joins = [item for item in items if is_join_gate(item)]
states: list[dict[str, Any]] = [] states: list[dict[str, Any]] = []
@ -115,13 +203,10 @@ def compute_join_states(
if not gate: if not gate:
continue continue
if gate.get("status") != "reached": if gate.get("status") != "reached":
strand_id = gate.get("strand_project_id")
pending.append( pending.append(
{ {
"gate_id": input_id, "gate_id": input_id,
"title": gate.get("title") or input_id, "title": gate.get("title") or input_id,
"strand_project_id": strand_id,
"strand_title": project_title.get(str(strand_id or ""), ""),
"status": gate.get("status"), "status": gate.get("status"),
} }
) )
@ -137,9 +222,6 @@ def compute_join_states(
{ {
"gate_id": output_id, "gate_id": output_id,
"title": (item_by_id.get(output_id) or {}).get("title") or output_id, "title": (item_by_id.get(output_id) or {}).get("title") or output_id,
"strand_project_id": (item_by_id.get(output_id) or {}).get(
"strand_project_id"
),
} }
for output_id in output_ids for output_id in output_ids
if output_id in item_by_id if output_id in item_by_id
@ -160,7 +242,6 @@ def compute_progression_state(
{ {
"id": gate["id"], "id": gate["id"],
"title": gate.get("title"), "title": gate.get("title"),
"strand_project_id": gate.get("strand_project_id"),
"status": gate.get("status"), "status": gate.get("status"),
} }
for gate in active_gates for gate in active_gates
@ -171,30 +252,33 @@ def compute_progression_state(
} }
def enforce_single_active_work_gate_per_strand( def enforce_single_active_work_gate_per_lane(
cur, cur,
*, *,
tenant_id: str, tenant_id: str,
initiative_id: str, initiative_id: str,
keep_active_id: str, keep_active_id: str,
strand_project_id: Optional[str],
) -> None: ) -> None:
if strand_project_id: """Revoke other active work gates on the same graph-derived progression lane."""
cur.execute( from services.roadmap import list_dependencies_for_initiative, list_roadmap_items_for_initiative
"""
UPDATE roadmap_items ri items = list_roadmap_items_for_initiative(
SET status = 'planned', updated_at = NOW() tenant_id=tenant_id, initiative_id=initiative_id
FROM roadmaps r )
WHERE ri.roadmap_id = r.id dependencies = list_dependencies_for_initiative(
AND ri.tenant_id = %s tenant_id=tenant_id, initiative_id=initiative_id
AND r.initiative_id = %s )
AND ri.item_type = 'maturity_stage' lanes = compute_progression_lanes(items=items, dependencies=dependencies)
AND ri.status IN ('active', 'at_risk') keep_lane = lanes.get(str(keep_active_id))
AND ri.strand_project_id = %s if not keep_lane:
AND ri.id != %s return
""",
(tenant_id, initiative_id, strand_project_id, keep_active_id), revoke_ids = [
) gate_id
for gate_id, lane in lanes.items()
if lane == keep_lane and gate_id != str(keep_active_id)
]
if not revoke_ids:
return return
cur.execute( cur.execute(
@ -207,10 +291,27 @@ def enforce_single_active_work_gate_per_strand(
AND r.initiative_id = %s AND r.initiative_id = %s
AND ri.item_type = 'maturity_stage' AND ri.item_type = 'maturity_stage'
AND ri.status IN ('active', 'at_risk') AND ri.status IN ('active', 'at_risk')
AND ri.strand_project_id IS NULL AND ri.id = ANY(%s::uuid[])
AND ri.id != %s
""", """,
(tenant_id, initiative_id, keep_active_id), (tenant_id, initiative_id, revoke_ids),
)
def enforce_single_active_work_gate_per_strand(
cur,
*,
tenant_id: str,
initiative_id: str,
keep_active_id: str,
strand_project_id: Optional[str] = None,
) -> None:
"""Deprecated — graph lanes replace strand/project grouping (ADP A1 lanes v0.1)."""
del strand_project_id
enforce_single_active_work_gate_per_lane(
cur,
tenant_id=tenant_id,
initiative_id=initiative_id,
keep_active_id=keep_active_id,
) )

View File

@ -400,17 +400,14 @@ def update_roadmap_item(
and existing.get("item_type") == "maturity_stage" and existing.get("item_type") == "maturity_stage"
): ):
from services.progression_graph import ( from services.progression_graph import (
enforce_single_active_work_gate_per_strand, enforce_single_active_work_gate_per_lane,
) )
enforce_single_active_work_gate_per_strand( enforce_single_active_work_gate_per_lane(
cur, cur,
tenant_id=tenant_id, tenant_id=tenant_id,
initiative_id=str(existing["initiative_id"]), initiative_id=str(existing["initiative_id"]),
keep_active_id=item_id, keep_active_id=item_id,
strand_project_id=strand_project_id
if strand_project_id is not None
else existing.get("strand_project_id"),
) )
cur.execute( cur.execute(
f""" f"""
@ -803,15 +800,14 @@ def reopen_roadmap_item(
with conn.cursor(cursor_factory=RealDictCursor) as cur: with conn.cursor(cursor_factory=RealDictCursor) as cur:
if item.get("item_type") == "maturity_stage": if item.get("item_type") == "maturity_stage":
from services.progression_graph import ( from services.progression_graph import (
enforce_single_active_work_gate_per_strand, enforce_single_active_work_gate_per_lane,
) )
enforce_single_active_work_gate_per_strand( enforce_single_active_work_gate_per_lane(
cur, cur,
tenant_id=tenant_id, tenant_id=tenant_id,
initiative_id=str(item["initiative_id"]), initiative_id=str(item["initiative_id"]),
keep_active_id=item_id, keep_active_id=item_id,
strand_project_id=item.get("strand_project_id"),
) )
cur.execute( cur.execute(
""" """

View File

@ -74,14 +74,13 @@ def _reopen_item_if_reached(
if item["status"] != "reached": if item["status"] != "reached":
return return
if item.get("item_type") == "maturity_stage": if item.get("item_type") == "maturity_stage":
from services.progression_graph import enforce_single_active_work_gate_per_strand from services.progression_graph import enforce_single_active_work_gate_per_lane
enforce_single_active_work_gate_per_strand( enforce_single_active_work_gate_per_lane(
cur, cur,
tenant_id=tenant_id, tenant_id=tenant_id,
initiative_id=str(item["initiative_id"]), initiative_id=str(item["initiative_id"]),
keep_active_id=item["id"], keep_active_id=item["id"],
strand_project_id=item.get("strand_project_id"),
) )
cur.execute( cur.execute(
""" """

View File

@ -2,10 +2,63 @@
from __future__ import annotations from __future__ import annotations
from services.progression_graph import compute_progression_lanes
from tests.factories import provision_user_in_tenant from tests.factories import provision_user_in_tenant
from tests.test_initiatives_actions import _auth, _create_initiative, _login from tests.test_initiatives_actions import _auth, _create_initiative, _login
def _item(item_id: str, item_type: str = "maturity_stage", **kwargs):
return {
"id": item_id,
"item_type": item_type,
"status": kwargs.get("status", "planned"),
"title": kwargs.get("title", item_id),
"sort_order": kwargs.get("sort_order", 0),
}
def _requires(from_id: str, to_id: str) -> dict:
return {
"from_item_id": from_id,
"to_item_id": to_id,
"dependency_type": "requires",
}
def test_progression_lanes_parallel_sources_and_post_join():
items = [
_item("d1", status="active", title="Dehnung — Einstieg"),
_item("h1", status="active", title="Hüftrotation — Einstieg"),
_item("join", item_type="join_gate", title="Join"),
_item("d2", title="Dehnung — nach Join"),
_item("h2", title="Hüftrotation — nach Join"),
]
deps = [
_requires("join", "d1"),
_requires("join", "h1"),
_requires("d2", "join"),
_requires("h2", "join"),
]
lanes = compute_progression_lanes(items=items, dependencies=deps)
assert lanes["d1"].startswith("source:")
assert lanes["h1"].startswith("source:")
assert lanes["h1"] != lanes["d1"]
assert lanes["d2"] == "post-join:d2"
assert lanes["h2"] == "post-join:h2"
assert lanes["d2"] != lanes["h2"]
def test_progression_lanes_sequential_chain_shares_lane():
items = [
_item("g1", status="active"),
_item("g2"),
_item("g3"),
]
deps = [_requires("g2", "g1"), _requires("g3", "g2")]
lanes = compute_progression_lanes(items=items, dependencies=deps)
assert lanes["g1"] == lanes["g2"] == lanes["g3"]
def test_progression_join_switches_and_activates_outputs(client): def test_progression_join_switches_and_activates_outputs(client):
user = provision_user_in_tenant(tenant_role="admin") user = provision_user_in_tenant(tenant_role="admin")
token = _login(client, user) token = _login(client, user)

View File

@ -0,0 +1,70 @@
# ADP — A1 Progression: Graph-abgeleitete Lanes statt Strang=Project
**Status:** PO/Architektur-Korrektur (2026-07-28)
**Stand:** 2026-07-28
**Auslöser:** AP2.1 — Strang=Project und `strand_project_id` vermischen Dimension A (Zielzustands-Graph) mit Dimension B (Project-Struktur)
**Bezug:** `ADP_Execution_Plan_and_Work_Package_Dependencies_v0.1.md`, `SPEC_A1_Progression_Model_PO_Lock_v0.1.md`, `SPEC_D_maturity_progression_v0.1.md`
---
## Entscheidung
**A1 Multi-Gate-Progression braucht kein Project pro parallelem Pfad.**
| Thema | Lock |
|-------|------|
| **Ziel-Horizont (A)** | Gates + `requires`-Graph + Join — modelliert **Zielzustände**, nicht Pfade |
| **Ist / Übungen** | `RecurringElement.roadmap_item_id` → aktives Gate — **eine** Bindung |
| **Parallele aktive Gates** | Erlaubt; Horizon-Regel „max. 1 aktiv pro Lane“ aus **Graph-Topologie** ableiten |
| **Lane** | Interner, nicht persistierter Schlüssel (`compute_progression_lanes`) — kein Nutzerobjekt |
| **Project** | **Nicht** Träger paralleler Progression in A1; optional nur für echte Struktur-Hierarchie (ADP Dimension B) |
| **`strand_project_id`** | **Deprecated** für Steuerlogik; Spalte bleibt nullable (Migration 032), wird nicht mehr gelesen |
| **`recurring.project_id` für A1** | **Nicht** setzen / nicht auswerten für Ready-Menge |
---
## Begründung
1. **ADP-Leitentscheidung:** Gate-Designer = Zielzustände; Pfad-/Container-Semantik gehört nicht in dieselbe Pflichtmodellierung.
2. **Nutzerbild:** Mehrere aktive Gates, Routinen am Gate, Join wertet Kanten aus — kein Pfad-Navigator nötig.
3. **Archetyp-Isolation:** Project=Strang hätte andere Archetypen (A2, B1) semantisch verunreinigt.
4. **Kein Doppelmodell:** Kanten im Designer genügen; kein Strang-Dropdown, keine Stream-Projects für A1-Kernbild.
---
## Lane-Algorithmus (MVP)
Aus `requires`-Kanten zwischen Work-Gates und Joins:
- Sequenzielle Work-Gates teilen eine Lane (Vorgänger-Kette).
- Parallele Quellen (kein Work-Gate-Vorgänger) → getrennte Lanes.
- Erstes Gate nach Join (`requires` Join) → neue Lane (`post-join:{gate_id}`).
Horizon: Beim Aktivieren eines Gates werden andere `active`/`at_risk` Gates **derselben Lane** auf `planned` gesetzt.
---
## Implementierung
| Modul | Änderung |
|-------|----------|
| `backend/services/progression_graph.py` | `compute_progression_lanes`, `enforce_single_active_work_gate_per_lane` |
| `backend/services/maturity_practice.py` | Pause/Seed nur über `roadmap_item_id` + Lane |
| `backend/services/archetype_starter_kit.py` | Demo ohne Stream-Projects / `strand_project_id` |
| `frontend/.../WorkTodayPracticePanel.jsx` | Gruppierung nach Gate-Titel |
Steuerung bleibt in **`maturity_progression`** — nicht im generischen `roadmap_engine`.
---
## Spec-Nachzug
`SPEC_A1_Progression_Model_PO_Lock_v0.1.md` §1/§4: „Strang = Project“ → **ersetzt durch** „parallele Lanes = graph-abgeleitete Requires-Ketten“. Project-Spiegel `project.maturity_journey` optional für Struktur-IA, nicht für Horizon.
---
## Nicht in Scope
- Spalten-Drop in Migration ( später, wenn keine Legacy-Daten )
- Join If/Else (Stufe B)
- Blueprint / Starter-Kit als Produktweg

View File

@ -1,8 +1,8 @@
# AP2.1 — MVP Validation Report # AP2.1 — MVP Validation Report
## v0.3 (Stufe A) ## v0.3 (Stufe A)
**Status:** Entwurf — vom Product Owner auszufüllen **Status:** Entwurf — Re-Abnahme A1 Progression ausstehend (2026-08-02)
**Stand:** 2026-07-28 **Stand:** 2026-08-02 (§3.5 Progressions-Re-Abnahme ergänzt)
**Bezug:** `Kairo_MVP_Definition_v0.3.md` §58 **Bezug:** `Kairo_MVP_Definition_v0.3.md` §58
**Assignment:** `Sprint1_AP2_1_MVP_Validation_Assignment_v0.1.md` **Assignment:** `Sprint1_AP2_1_MVP_Validation_Assignment_v0.1.md`
**Findings (Architektur-Review):** `Sprint1_AP2_1_Validation_Findings_Register_v0.1.md` **Findings (Architektur-Review):** `Sprint1_AP2_1_Validation_Findings_Register_v0.1.md`
@ -106,6 +106,79 @@ Produkt (PO): **A1 No-Go** — Cadence-Plumbing reicht nicht; Nutzerbild = **par
**Lücken / UX-Schmerz:** PO No-Go — Register F-A1-02…10; Minimum Slice §6.1 vor Re-Test. Kein Architektur-Neudesign — fehlender A1-Kern (CadenceInstance, Today=Übung, Gate-Fortschritt, IA). **Lücken / UX-Schmerz:** PO No-Go — Register F-A1-02…10; Minimum Slice §6.1 vor Re-Test. Kein Architektur-Neudesign — fehlender A1-Kern (CadenceInstance, Today=Übung, Gate-Fortschritt, IA).
### 3.5 Re-Abnahme A1 — Progressionsmodell (PO-Lock, ohne Starter-Kit)
**Zweck:** PO-Kernbild prüfen gemäß [`SPEC_A1_Progression_Model_PO_Lock_v0.1.md`](../product/archetypes/SPEC_A1_Progression_Model_PO_Lock_v0.1.md) §7 — **≥2 parallele Gates + 1 Join + Activity Sets + Today**, manuell modelliert (kein Spagat-Starter-Kit). Horizon/Lanes: [`ADP_A1_Progression_Graph_Derived_Lanes_v0.1.md`](../architecture/ADP_A1_Progression_Graph_Derived_Lanes_v0.1.md).
**Vorbedingungen**
| # | Check | erledigt |
|---|--------|----------|
| V1 | Deploy `develop` grün (pytest inkl. `test_a1_progression_join.py`) | ☐ |
| V2 | Health: `GET https://dev.kairo.jinkendo.de/api/health` | ☐ |
| V3 | PO-Entscheid: **kein Starter-Kit** — Anlage mit `"apply_starter_kit": false` (API) oder Spagat-Stufen nach Anlage löschen | ☐ |
**Referenz-Graph (MVP, 2 parallele Pfade — reicht für Go):**
```text
[Dehnung — Einstieg] ──requires──▶ [Join: Mawashi …] ◀──requires── [Hüftrotation — Einstieg]
┌───────────────────┴───────────────────┐
▼ ▼
[Dehnung — nach Join] [Hüftrotation — nach Join]
```
Alle Kanten: **Voraussetzung** (`requires`). Kein Parallelgruppe/Blockiert nötig. **Keine** Stream-Projects / `strand_project_id` nötig — parallele Pfade ergeben sich aus dem Graph.
---
**9 Smoke-Schritte**
| # | Schritt | Route / Aktion | Erwartung | ✓/✗ | Notiz |
|---|---------|----------------|-----------|-----|-------|
| **1** | Vorhaben anlegen | API `POST /api/initiatives` mit `archetype_key: initiative.maturity_journey`, **`apply_starter_kit: false`** | Archetyp A1, Methode `maturity_progression`, **keine** Spagat-Stufen | | |
| **2** | Operating Context | `GET …/initiatives/{id}/operating-context` oder Kontrolle | `maturity_stage`, `recurring_rhythm`; Default-Route Kontrolle | | |
| **3** | Gates + Join im Designer | Plan → Zielzustände → **Designer** | 5 Knoten: 4× `Reifegrad-Stufe` + 1× **Join/Schaltknoten**; D1+H1 **active**, Rest **planned** | | |
| **4** | Kanten ziehen | Designer, Kantentyp **Voraussetzung** | Join→D1, Join→H1, D2→Join, H2→Join (4 Kanten) | | |
| **5** | Activity Sets | Gate-Detail je Work-Gate: Recurring/Übung anlegen oder Kriterium + Übung | D1 und H1 je ≥1 Übung; Join **ohne** Checkliste/Verify-Button | | |
| **6** | Today (Start) | `/work/today` (Initiative-Scope) | Übungen von **beiden** aktiven Gates; **keine** flache AP-Wand; Join **nicht** in Today | | |
| **7** | Verify Pfad 1 | Gate D1: Evidence + **Verify** | D1 `reached`; Join wartet auf H1; **kein** auto-next auf D2; Today nur noch H1-Übungen | | |
| **8** | Join schaltet | Gate H1: Evidence + **Verify** | Join `reached` (automatisch); D2 + H2 **active**; neue Übungen in Today | | |
| **9** | Leitfrage + Kontrolle | Kontrolle `/control/status` + Stopuhr | Join-Warteliste zeigte offene Eingänge (Schritt 7); Leitfrage in **≤2 Min** beantwortbar: *Welche Übung heute, welcher Join wartet?* | | |
**Technische Referenz (Automatisierung):** `backend/tests/test_a1_progression_join.py`
---
**Go-Kriterien A1 Progression (alle nötig für Go)**
| # | Kriterium | ja/nein | Notiz |
|---|-----------|---------|-------|
| G1 | Multi-Gate: 2 aktive Work-Gates parallel (Schritt 6) | | |
| G2 | Join: Warteliste/Kontrolle zeigt pending inputs (Schritt 7) | | |
| G3 | Join schaltet Ausgänge gemeinsam (Schritt 8) | | |
| G4 | Today = Übungen am Gate, nicht AP-Liste (Schritt 6, 9) | | |
| G5 | Leitfrage ≤2 Min (Schritt 9) | | |
| G6 | Kein Anti-Pattern: flache Rhythmus-Liste als Hauptbild | | |
**Zwischenurteil Re-Abnahme:** ☐ Go · ☐ Bedingt · ☐ No-Go
| Feld | Wert |
|------|------|
| **Initiative-ID / Titel** | |
| **Deploy / Commit** | `develop` @ |
| **Testdatum Re-Abnahme** | |
**Leitfrage-Antwort (PO, ≤2 Min):**
**Nächster Schritt:**
**Warum:**
**Stopuhr (Min):**
**Bei Go:** §3 Zwischenurteil auf Go setzen; Findings Register F-A1-02…10 einzeln abhaken; Truth Table MVP-Abnahfe A1 aktualisieren.
**Bei No-Go:** neue Finding-IDs im Register; keine Stufe-A-Sign-off.
--- ---
## 4. Szenario A2 — Neue Küche (Linear) ## 4. Szenario A2 — Neue Küche (Linear)

View File

@ -216,12 +216,12 @@ Alternanz, volle Metric-Engine, Activity-Set-Automation → **nach** Slice 14
Kernentscheide: Kernentscheide:
- **Multi-Strang** (Projects) + **Gate-Graph** + **Join-Gates** - **Parallele Gates** + **Gate-Graph** + **Join-Gates** (Lanes graph-abgeleitet — ADP A1 Lanes v0.1)
- **Horizon:** mehrere aktive Gates (nicht eine Stufe initiative-weit) - **Horizon:** mehrere aktive Gates (nicht eine Stufe initiative-weit)
- **Übung** = `RecurringElement` am Gate + Strang; Rhythmus = Feld - **Übung** = `RecurringElement` am Gate (`roadmap_item_id`); Rhythmus = Feld
- **A1 Go MVP:** ≥2 Stränge + 1 Join + Gate-Activity-Set + Today — nicht nur Spagat-Demo - **A1 Go MVP:** ≥2 parallele Gates + 1 Join + Gate-Activity-Set + Today — nicht nur Spagat-Demo
**Validation A1:** pausiert bis `AP-A1-PM-4` (Plan Struktur + Gate-Detail). A2/B2b-Smoke **parallel** möglich. **Validation A1:** Re-Abnahme gemäß [`Sprint1_AP2_1_MVP_Validation_Report_v0.3.md`](Sprint1_AP2_1_MVP_Validation_Report_v0.3.md) **§3.5** (Progressionsmodell, ohne Starter-Kit). A2/B2b-Smoke **parallel** möglich.
--- ---

View File

@ -27,7 +27,7 @@ export function JoinWaitlistPanel({ progressionState = null }) {
<div> <div>
<h2>Join-Fortschritt</h2> <h2>Join-Fortschritt</h2>
<p className="section-lead muted"> <p className="section-lead muted">
Konsolidierte Meilensteine welche Strang-Gates fehlen noch zum Schalten? Konsolidierte Meilensteine welche Eingangs-Gates fehlen noch zum Schalten?
</p> </p>
</div> </div>
</div> </div>

View File

@ -25,12 +25,6 @@ function exercisesForGate(recurringItems = [], gateId) {
) )
} }
function strandLabel(gate, projectsById) {
const projectId = gate.strand_project_id
if (!projectId) return 'Training'
return projectsById.get(projectId)?.title || 'Strang'
}
function ExerciseRow({ exercise, canManage, busy, onCompleted }) { function ExerciseRow({ exercise, canManage, busy, onCompleted }) {
const [measurement, setMeasurement] = useState('') const [measurement, setMeasurement] = useState('')
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
@ -99,31 +93,24 @@ function ExerciseRow({ exercise, canManage, busy, onCompleted }) {
} }
/** /**
* A1 Heutige Übungen aller aktiven Work-Gates, gruppiert nach Strang. * A1 Heutige Übungen aller aktiven Work-Gates, gruppiert nach Gate.
*/ */
export function WorkTodayPracticePanel({ export function WorkTodayPracticePanel({
initiativeId, initiativeId,
roadmapItems = [], roadmapItems = [],
recurringItems = [], recurringItems = [],
projects = [],
canManage = false, canManage = false,
onCompleted, onCompleted,
busy = false, busy = false,
}) { }) {
const projectsById = useMemo(
() => new Map((projects || []).map((project) => [project.id, project])),
[projects],
)
const gates = useMemo(() => activeWorkGates(roadmapItems), [roadmapItems]) const gates = useMemo(() => activeWorkGates(roadmapItems), [roadmapItems])
const grouped = useMemo(() => { const grouped = useMemo(() => {
return gates.map((gate) => ({ return gates.map((gate) => ({
gate, gate,
strand: strandLabel(gate, projectsById),
exercises: exercisesForGate(recurringItems, gate.id), exercises: exercisesForGate(recurringItems, gate.id),
})) }))
}, [gates, recurringItems, projectsById]) }, [gates, recurringItems])
const todayExercises = grouped.flatMap((group) => group.exercises) const todayExercises = grouped.flatMap((group) => group.exercises)
const pending = todayExercises.filter((item) => !item.today_completed) const pending = todayExercises.filter((item) => !item.today_completed)
@ -137,7 +124,7 @@ export function WorkTodayPracticePanel({
<p className="section-lead muted"> <p className="section-lead muted">
{gates.length > 0 {gates.length > 0
? `${gates.length} aktive Gate(s) — pro Übung erledigen, optional Messwert notieren.` ? `${gates.length} aktive Gate(s) — pro Übung erledigen, optional Messwert notieren.`
: 'Tages-Tracking für aktive Work-Gates auf allen Strängen.'} : 'Tages-Tracking für aktive Work-Gates.'}
</p> </p>
</div> </div>
</div> </div>
@ -151,12 +138,10 @@ export function WorkTodayPracticePanel({
Alle Übungen für heute erledigt nächste Fälligkeit morgen. Alle Übungen für heute erledigt nächste Fälligkeit morgen.
</p> </p>
)} )}
{grouped.map(({ gate, strand, exercises }) => {grouped.map(({ gate, exercises }) =>
exercises.length === 0 ? null : ( exercises.length === 0 ? null : (
<div key={gate.id} className="work-today-practice__strand"> <div key={gate.id} className="work-today-practice__strand">
<h3 className="work-today-practice__strand-title"> <h3 className="work-today-practice__strand-title">{gate.title}</h3>
{strand} {gate.title}
</h3>
<ul className="item-list work-today-practice__list"> <ul className="item-list work-today-practice__list">
{exercises.map((exercise) => ( {exercises.map((exercise) => (
<ExerciseRow <ExerciseRow

View File

@ -212,7 +212,6 @@ export function buildProviderProps(provider, input) {
initiativeId: ops.initiativeId, initiativeId: ops.initiativeId,
roadmapItems: ops.roadmapItems || [], roadmapItems: ops.roadmapItems || [],
recurringItems: ops.recurringItems || [], recurringItems: ops.recurringItems || [],
projects: ops.projects || [],
canManage: capabilities.has('kairo.recurring.manage'), canManage: capabilities.has('kairo.recurring.manage'),
busy: ops.formBusy, busy: ops.formBusy,
onCompleted: async () => { onCompleted: async () => {