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 }) {
- Konsolidierte Meilensteine — welche Strang-Gates fehlen noch zum Schalten? + Konsolidierte Meilensteine — welche Eingangs-Gates fehlen noch zum Schalten?
{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 : (