From 17add13d9dce715b289ab2ac474befe8a9407da5 Mon Sep 17 00:00:00 2001
From: Lars
Date: Mon, 3 Aug 2026 18:51:17 +0200
Subject: [PATCH] refactor(A1): Progression-Lanes aus Graph statt
Strang=Project
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
---
backend/services/archetype_starter_kit.py | 40 +---
backend/services/maturity_practice.py | 57 ++++--
backend/services/progression_graph.py | 171 ++++++++++++++----
backend/services/roadmap.py | 12 +-
backend/services/roadmap_criteria.py | 5 +-
backend/tests/test_a1_progression_join.py | 53 ++++++
...A1_Progression_Graph_Derived_Lanes_v0.1.md | 70 +++++++
...print1_AP2_1_MVP_Validation_Report_v0.3.md | 77 +++++++-
...AP2_1_Validation_Findings_Register_v0.1.md | 8 +-
frontend/src/components/JoinWaitlistPanel.jsx | 2 +-
.../src/components/WorkTodayPracticePanel.jsx | 25 +--
.../composition/resolveSteeringComposition.js | 1 -
12 files changed, 396 insertions(+), 125 deletions(-)
create mode 100644 docs/architecture/ADP_A1_Progression_Graph_Derived_Lanes_v0.1.md
diff --git a/backend/services/archetype_starter_kit.py b/backend/services/archetype_starter_kit.py
index d98ef5a..878ead1 100644
--- a/backend/services/archetype_starter_kit.py
+++ b/backend/services/archetype_starter_kit.py
@@ -316,9 +316,10 @@ def _retire_legacy_linear_stages_for_progression(
*,
tenant_id: str,
initiative_id: str,
+ keep_gate_ids: frozenset[str],
user_id: Optional[str] = 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.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":
continue
- if item.get("strand_project_id"):
+ if str(item["id"]) in keep_gate_ids:
continue
if item.get("status") not in ("active", "at_risk"):
continue
@@ -357,9 +358,8 @@ def apply_a1_progression_demo(
initiative_id: str,
user_id: Optional[str] = None,
) -> 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.projects import create_project, list_projects_for_initiative
from services.roadmap import add_dependency, create_roadmap_item, 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):
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(
tenant_id=tenant_id,
initiative_id=initiative_id,
@@ -399,7 +375,6 @@ def apply_a1_progression_demo(
item_type="maturity_stage",
status="active",
sort_order=10,
- strand_project_id=dehnung["id"],
user_id=user_id,
)
h1 = create_roadmap_item(
@@ -409,7 +384,6 @@ def apply_a1_progression_demo(
item_type="maturity_stage",
status="active",
sort_order=20,
- strand_project_id=huft["id"],
user_id=user_id,
)
join = create_roadmap_item(
@@ -428,7 +402,6 @@ def apply_a1_progression_demo(
item_type="maturity_stage",
status="planned",
sort_order=40,
- strand_project_id=dehnung["id"],
user_id=user_id,
)
h2 = create_roadmap_item(
@@ -438,7 +411,6 @@ def apply_a1_progression_demo(
item_type="maturity_stage",
status="planned",
sort_order=50,
- strand_project_id=huft["id"],
user_id=user_id,
)
@@ -470,12 +442,14 @@ def apply_a1_progression_demo(
_retire_legacy_linear_stages_for_progression(
tenant_id=tenant_id,
initiative_id=initiative_id,
+ keep_gate_ids=frozenset(
+ str(gate["id"]) for gate in (d1, h1, d2, h2)
+ ),
user_id=user_id,
)
return {
"applied": True,
- "projects": ["Dehnung", "Hüftrotation"],
"join": join["title"],
"active_gates": [d1["title"], h1["title"]],
}
diff --git a/backend/services/maturity_practice.py b/backend/services/maturity_practice.py
index b074deb..d130a26 100644
--- a/backend/services/maturity_practice.py
+++ b/backend/services/maturity_practice.py
@@ -102,14 +102,27 @@ def pause_all_practices(
return paused
-def pause_practices_except_stage(
+def pause_practices_on_same_lane_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]:
+ 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] = []
for item in list_recurring_for_initiative(
tenant_id=tenant_id, initiative_id=initiative_id
@@ -118,7 +131,7 @@ def pause_practices_except_stage(
continue
if item.get("roadmap_item_id") == keep_stage_id:
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
update_recurring_element(
tenant_id=tenant_id,
@@ -130,6 +143,23 @@ def pause_practices_except_stage(
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(
*,
tenant_id: str,
@@ -140,7 +170,6 @@ def seed_practices_for_stage(
"""Idempotent: create missing exercises for a stage; activate existing."""
stage_id = stage["id"]
stage_title = stage.get("title") or ""
- strand_project_id = stage.get("strand_project_id")
defs = STAGE_PRACTICE_SETS.get(stage_title)
if not defs:
defs = [
@@ -185,7 +214,6 @@ def seed_practices_for_stage(
interval_days=1,
next_due_at=now,
roadmap_item_id=stage_id,
- project_id=strand_project_id,
user_id=user_id,
)
created.append(row)
@@ -200,19 +228,12 @@ def activate_stage_practice_set(
stage: dict[str, Any],
user_id: Optional[str] = None,
) -> dict[str, Any]:
- strand_project_id = stage.get("strand_project_id")
- if strand_project_id:
- paused = pause_practices_except_stage(
- tenant_id=tenant_id,
- initiative_id=initiative_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
- )
+ paused = pause_practices_on_same_lane_except_stage(
+ tenant_id=tenant_id,
+ initiative_id=initiative_id,
+ keep_stage_id=stage["id"],
+ user_id=user_id,
+ )
exercises = seed_practices_for_stage(
tenant_id=tenant_id,
initiative_id=initiative_id,
diff --git a/backend/services/progression_graph.py b/backend/services/progression_graph.py
index 7e8746e..49b5af9 100644
--- a/backend/services/progression_graph.py
+++ b/backend/services/progression_graph.py
@@ -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(
*, tenant_id: str, initiative_id: str
) -> list[dict[str, Any]]:
@@ -71,12 +166,10 @@ def list_active_work_gates(
items = list_roadmap_items_for_initiative(
tenant_id=tenant_id, initiative_id=initiative_id
)
- has_join_graph = any(is_join_gate(item) for item in items)
active = [
item
for item in items
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", "")))
return active
@@ -87,7 +180,6 @@ def compute_join_states(
tenant_id: str,
initiative_id: str,
) -> 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
items = list_roadmap_items_for_initiative(
@@ -97,10 +189,6 @@ def compute_join_states(
dependencies = list_dependencies_for_initiative(
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)]
states: list[dict[str, Any]] = []
@@ -115,13 +203,10 @@ def compute_join_states(
if not gate:
continue
if gate.get("status") != "reached":
- strand_id = gate.get("strand_project_id")
pending.append(
{
"gate_id": 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"),
}
)
@@ -137,9 +222,6 @@ def compute_join_states(
{
"gate_id": 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
if output_id in item_by_id
@@ -160,7 +242,6 @@ def compute_progression_state(
{
"id": gate["id"],
"title": gate.get("title"),
- "strand_project_id": gate.get("strand_project_id"),
"status": gate.get("status"),
}
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,
*,
tenant_id: str,
initiative_id: str,
keep_active_id: str,
- strand_project_id: Optional[str],
) -> None:
- if strand_project_id:
- cur.execute(
- """
- UPDATE roadmap_items ri
- SET status = 'planned', updated_at = NOW()
- FROM roadmaps r
- WHERE ri.roadmap_id = r.id
- AND ri.tenant_id = %s
- AND r.initiative_id = %s
- AND ri.item_type = 'maturity_stage'
- AND ri.status IN ('active', 'at_risk')
- AND ri.strand_project_id = %s
- AND ri.id != %s
- """,
- (tenant_id, initiative_id, strand_project_id, keep_active_id),
- )
+ """Revoke other active work gates on the same graph-derived progression lane."""
+ 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
+ )
+ lanes = compute_progression_lanes(items=items, dependencies=dependencies)
+ keep_lane = lanes.get(str(keep_active_id))
+ if not keep_lane:
+ return
+
+ 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
cur.execute(
@@ -207,10 +291,27 @@ def enforce_single_active_work_gate_per_strand(
AND r.initiative_id = %s
AND ri.item_type = 'maturity_stage'
AND ri.status IN ('active', 'at_risk')
- AND ri.strand_project_id IS NULL
- AND ri.id != %s
+ AND ri.id = ANY(%s::uuid[])
""",
- (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,
)
diff --git a/backend/services/roadmap.py b/backend/services/roadmap.py
index 75ccc34..72764a7 100644
--- a/backend/services/roadmap.py
+++ b/backend/services/roadmap.py
@@ -400,17 +400,14 @@ def update_roadmap_item(
and existing.get("item_type") == "maturity_stage"
):
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,
tenant_id=tenant_id,
initiative_id=str(existing["initiative_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(
f"""
@@ -803,15 +800,14 @@ def reopen_roadmap_item(
with conn.cursor(cursor_factory=RealDictCursor) as cur:
if item.get("item_type") == "maturity_stage":
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,
tenant_id=tenant_id,
initiative_id=str(item["initiative_id"]),
keep_active_id=item_id,
- strand_project_id=item.get("strand_project_id"),
)
cur.execute(
"""
diff --git a/backend/services/roadmap_criteria.py b/backend/services/roadmap_criteria.py
index 96185ab..30f9ac7 100644
--- a/backend/services/roadmap_criteria.py
+++ b/backend/services/roadmap_criteria.py
@@ -74,14 +74,13 @@ def _reopen_item_if_reached(
if item["status"] != "reached":
return
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,
tenant_id=tenant_id,
initiative_id=str(item["initiative_id"]),
keep_active_id=item["id"],
- strand_project_id=item.get("strand_project_id"),
)
cur.execute(
"""
diff --git a/backend/tests/test_a1_progression_join.py b/backend/tests/test_a1_progression_join.py
index 3396db8..9fb6de4 100644
--- a/backend/tests/test_a1_progression_join.py
+++ b/backend/tests/test_a1_progression_join.py
@@ -2,10 +2,63 @@
from __future__ import annotations
+from services.progression_graph import compute_progression_lanes
from tests.factories import provision_user_in_tenant
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):
user = provision_user_in_tenant(tenant_role="admin")
token = _login(client, user)
diff --git a/docs/architecture/ADP_A1_Progression_Graph_Derived_Lanes_v0.1.md b/docs/architecture/ADP_A1_Progression_Graph_Derived_Lanes_v0.1.md
new file mode 100644
index 0000000..c302653
--- /dev/null
+++ b/docs/architecture/ADP_A1_Progression_Graph_Derived_Lanes_v0.1.md
@@ -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
diff --git a/docs/sprints/Sprint1_AP2_1_MVP_Validation_Report_v0.3.md b/docs/sprints/Sprint1_AP2_1_MVP_Validation_Report_v0.3.md
index 0c8c0e8..852fee0 100644
--- a/docs/sprints/Sprint1_AP2_1_MVP_Validation_Report_v0.3.md
+++ b/docs/sprints/Sprint1_AP2_1_MVP_Validation_Report_v0.3.md
@@ -1,8 +1,8 @@
# AP2.1 — MVP Validation Report
## v0.3 (Stufe A)
-**Status:** Entwurf — vom Product Owner auszufüllen
-**Stand:** 2026-07-28
+**Status:** Entwurf — Re-Abnahme A1 Progression ausstehend (2026-08-02)
+**Stand:** 2026-08-02 (§3.5 Progressions-Re-Abnahme ergänzt)
**Bezug:** `Kairo_MVP_Definition_v0.3.md` §5–8
**Assignment:** `Sprint1_AP2_1_MVP_Validation_Assignment_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).
+### 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)
diff --git a/docs/sprints/Sprint1_AP2_1_Validation_Findings_Register_v0.1.md b/docs/sprints/Sprint1_AP2_1_Validation_Findings_Register_v0.1.md
index 341d555..06b2222 100644
--- a/docs/sprints/Sprint1_AP2_1_Validation_Findings_Register_v0.1.md
+++ b/docs/sprints/Sprint1_AP2_1_Validation_Findings_Register_v0.1.md
@@ -216,12 +216,12 @@ Alternanz, volle Metric-Engine, Activity-Set-Automation → **nach** Slice 1–4
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)
-- **Übung** = `RecurringElement` am Gate + Strang; Rhythmus = Feld
-- **A1 Go MVP:** ≥2 Stränge + 1 Join + Gate-Activity-Set + Today — nicht nur Spagat-Demo
+- **Übung** = `RecurringElement` am Gate (`roadmap_item_id`); Rhythmus = Feld
+- **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.
---
diff --git a/frontend/src/components/JoinWaitlistPanel.jsx b/frontend/src/components/JoinWaitlistPanel.jsx
index c6b5170..c607733 100644
--- a/frontend/src/components/JoinWaitlistPanel.jsx
+++ b/frontend/src/components/JoinWaitlistPanel.jsx
@@ -27,7 +27,7 @@ export function JoinWaitlistPanel({ progressionState = null }) {
Join-Fortschritt
- Konsolidierte Meilensteine — welche Strang-Gates fehlen noch zum Schalten?
+ Konsolidierte Meilensteine — welche Eingangs-Gates fehlen noch zum Schalten?
diff --git a/frontend/src/components/WorkTodayPracticePanel.jsx b/frontend/src/components/WorkTodayPracticePanel.jsx
index 54f0433..b799bd5 100644
--- a/frontend/src/components/WorkTodayPracticePanel.jsx
+++ b/frontend/src/components/WorkTodayPracticePanel.jsx
@@ -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 }) {
const [measurement, setMeasurement] = useState('')
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({
initiativeId,
roadmapItems = [],
recurringItems = [],
- projects = [],
canManage = false,
onCompleted,
busy = false,
}) {
- const projectsById = useMemo(
- () => new Map((projects || []).map((project) => [project.id, project])),
- [projects],
- )
-
const gates = useMemo(() => activeWorkGates(roadmapItems), [roadmapItems])
const grouped = useMemo(() => {
return gates.map((gate) => ({
gate,
- strand: strandLabel(gate, projectsById),
exercises: exercisesForGate(recurringItems, gate.id),
}))
- }, [gates, recurringItems, projectsById])
+ }, [gates, recurringItems])
const todayExercises = grouped.flatMap((group) => group.exercises)
const pending = todayExercises.filter((item) => !item.today_completed)
@@ -137,7 +124,7 @@ export function WorkTodayPracticePanel({
{gates.length > 0
? `${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.'}
@@ -151,12 +138,10 @@ export function WorkTodayPracticePanel({
Alle Übungen für heute erledigt — nächste Fälligkeit morgen.
)}
- {grouped.map(({ gate, strand, exercises }) =>
+ {grouped.map(({ gate, exercises }) =>
exercises.length === 0 ? null : (
-
- {strand} — {gate.title}
-
+
{gate.title}
{exercises.map((exercise) => (
{