Kairo-Jinkendo/backend/services/roadmap.py
Lars 19e962d8ec
Some checks failed
Deploy Development / deploy (push) Successful in 49s
Test Suite / pytest-backend (push) Failing after 4m46s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
fix(A1): Übungen an Stufen binden, Gates entkoppeln, Tages-Abschluss
Activity Set pro Stufe statt Gate-Titel-Recurring; Single-Active-Stufe bei Reopen; Complete heute schliesst Next Action; Heute-Panel fuer alle Uebungen der aktiven Stufe.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 13:00:06 +02:00

844 lines
29 KiB
Python

"""Roadmap service — tenant-scoped Plan/Gates (AP1.4)."""
from __future__ import annotations
import json
from datetime import date
from typing import Any, Literal, Optional
from psycopg2.extras import RealDictCursor
from db import get_connection
from services.audit import log_audit
from services.initiatives import get_initiative
from services.roadmap_criteria import create_default_criterion, prepare_criteria_for_verify
RoadmapItemType = Literal["milestone", "review_gate", "maturity_stage", "work_cycle"]
RoadmapItemStatus = Literal[
"planned", "active", "at_risk", "reached", "moved", "discarded"
]
SequencingMode = Literal["sequential", "parallel", "optional"]
DependencyType = Literal["requires", "blocks", "related", "parallel_group", "optional_branch"]
ROADMAP_ITEM_TYPES = frozenset({"milestone", "review_gate", "maturity_stage", "work_cycle"})
ROADMAP_ITEM_STATUSES = frozenset(
{"planned", "active", "at_risk", "reached", "moved", "discarded"}
)
SEQUENCING_MODES = frozenset({"sequential", "parallel", "optional"})
DEPENDENCY_TYPES = frozenset({"requires", "blocks", "related", "parallel_group", "optional_branch"})
TERMINAL_STATUSES = frozenset({"reached", "moved", "discarded"})
def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
result = dict(row)
for key in ("id", "tenant_id", "roadmap_id", "initiative_id", "from_item_id", "to_item_id"):
if result.get(key):
result[key] = str(result[key])
if result.get("target_date"):
result["target_date"] = (
result["target_date"].isoformat()
if hasattr(result["target_date"], "isoformat")
else str(result["target_date"])
)
for ts in ("created_at", "updated_at"):
if result.get(ts):
result[ts] = result[ts].isoformat()
dod = result.get("definition_of_done")
if isinstance(dod, str):
result["definition_of_done"] = json.loads(dod)
return result
def _validate_item_type(item_type: str) -> None:
if item_type not in ROADMAP_ITEM_TYPES:
raise ValueError(f"Ungültiger RoadmapItem-Typ: {item_type}")
def _validate_status(status: str) -> None:
if status not in ROADMAP_ITEM_STATUSES:
raise ValueError(f"Ungültiger RoadmapItem-Status: {status}")
def _validate_sequencing_mode(mode: str) -> None:
if mode not in SEQUENCING_MODES:
raise ValueError(f"Ungültiger sequencing_mode: {mode}")
def _validate_dependency_type(dep_type: str) -> None:
if dep_type not in DEPENDENCY_TYPES:
raise ValueError(f"Ungültiger dependency_type: {dep_type}")
def _sync_milestone_compat_row(
cur,
*,
tenant_id: str,
initiative_id: str,
item: dict[str, Any],
) -> None:
"""Hält milestones-Tabelle für Evidence/Review-FKs synchron (Compat bis Drop)."""
if item["item_type"] != "milestone":
cur.execute(
"DELETE FROM milestones WHERE id = %s AND tenant_id = %s",
(item["id"], tenant_id),
)
return
target_date = item.get("target_date")
if target_date and isinstance(target_date, str):
target_date = date.fromisoformat(target_date)
cur.execute(
"""
INSERT INTO milestones (
id, tenant_id, initiative_id, title, goal_description, status, target_date
)
VALUES (%s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (id) DO UPDATE SET
title = EXCLUDED.title,
goal_description = EXCLUDED.goal_description,
status = EXCLUDED.status,
target_date = EXCLUDED.target_date,
updated_at = NOW()
""",
(
item["id"],
tenant_id,
initiative_id,
item["title"],
item.get("goal_description", ""),
item["status"],
target_date,
),
)
def ensure_roadmap(*, tenant_id: str, initiative_id: str) -> dict[str, Any]:
if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
raise ValueError("Initiative nicht gefunden")
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT id, tenant_id, initiative_id, title, created_at, updated_at
FROM roadmaps
WHERE tenant_id = %s AND initiative_id = %s
""",
(tenant_id, initiative_id),
)
row = cur.fetchone()
if row:
return _serialize_row(dict(row))
cur.execute(
"""
INSERT INTO roadmaps (tenant_id, initiative_id, title)
VALUES (%s, %s, 'Plan')
RETURNING id, tenant_id, initiative_id, title, created_at, updated_at
""",
(tenant_id, initiative_id),
)
result = _serialize_row(dict(cur.fetchone()))
conn.commit()
return result
finally:
conn.close()
def get_roadmap_for_initiative(*, tenant_id: str, initiative_id: str) -> Optional[dict[str, Any]]:
if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
raise ValueError("Initiative nicht gefunden")
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT id, tenant_id, initiative_id, title, created_at, updated_at
FROM roadmaps
WHERE tenant_id = %s AND initiative_id = %s
""",
(tenant_id, initiative_id),
)
row = cur.fetchone()
return _serialize_row(dict(row)) if row else None
finally:
conn.close()
def list_roadmap_items_for_initiative(
*, tenant_id: str, initiative_id: str
) -> list[dict[str, Any]]:
roadmap = get_roadmap_for_initiative(tenant_id=tenant_id, initiative_id=initiative_id)
if not roadmap:
return []
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT
ri.id, ri.tenant_id, ri.roadmap_id, r.initiative_id,
ri.item_type, ri.title, ri.goal_description, ri.definition_of_done,
ri.status, ri.sequencing_mode, ri.target_date, ri.sort_order,
ri.created_at, ri.updated_at
FROM roadmap_items ri
JOIN roadmaps r ON r.id = ri.roadmap_id AND r.tenant_id = ri.tenant_id
WHERE ri.tenant_id = %s AND r.initiative_id = %s
ORDER BY ri.sort_order ASC, ri.target_date NULLS LAST, ri.title
""",
(tenant_id, initiative_id),
)
return [_serialize_row(dict(r)) for r in cur.fetchall()]
finally:
conn.close()
def get_roadmap_item(*, tenant_id: str, item_id: str) -> Optional[dict[str, Any]]:
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT
ri.id, ri.tenant_id, ri.roadmap_id, r.initiative_id,
ri.item_type, ri.title, ri.goal_description, ri.definition_of_done,
ri.status, ri.sequencing_mode, ri.target_date, ri.sort_order,
ri.created_at, ri.updated_at
FROM roadmap_items ri
JOIN roadmaps r ON r.id = ri.roadmap_id AND r.tenant_id = ri.tenant_id
WHERE ri.id = %s AND ri.tenant_id = %s
""",
(item_id, tenant_id),
)
row = cur.fetchone()
return _serialize_row(dict(row)) if row else None
finally:
conn.close()
def create_roadmap_item(
*,
tenant_id: str,
initiative_id: str,
title: str,
item_type: RoadmapItemType = "milestone",
goal_description: str = "",
definition_of_done: Optional[list[Any]] = None,
status: RoadmapItemStatus = "planned",
sequencing_mode: SequencingMode = "sequential",
target_date: Optional[date] = None,
sort_order: int = 0,
initial_criterion_title: Optional[str] = None,
user_id: Optional[str] = None,
) -> dict[str, Any]:
title = title.strip()
if not title:
raise ValueError("Titel ist erforderlich")
_validate_item_type(item_type)
_validate_status(status)
if status == "reached":
raise ValueError("Status 'reached' nur über verify-reached")
_validate_sequencing_mode(sequencing_mode)
roadmap = ensure_roadmap(tenant_id=tenant_id, initiative_id=initiative_id)
dod = definition_of_done if definition_of_done is not None else []
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
INSERT INTO roadmap_items (
tenant_id, roadmap_id, item_type, title, goal_description,
definition_of_done, status, sequencing_mode, target_date, sort_order
)
VALUES (%s, %s, %s, %s, %s, %s::jsonb, %s, %s, %s, %s)
RETURNING id, tenant_id, roadmap_id, item_type, title, goal_description,
definition_of_done, status, sequencing_mode, target_date,
sort_order, created_at, updated_at
""",
(
tenant_id,
roadmap["id"],
item_type,
title,
goal_description,
json.dumps(dod),
status,
sequencing_mode,
target_date,
sort_order,
),
)
row = _serialize_row(dict(cur.fetchone()))
row["initiative_id"] = initiative_id
if item_type != "work_cycle":
create_default_criterion(
cur,
tenant_id=tenant_id,
roadmap_item_id=row["id"],
title=(initial_criterion_title or "Gate allgemein").strip()
or "Gate allgemein",
description=goal_description or "",
)
_sync_milestone_compat_row(
cur,
tenant_id=tenant_id,
initiative_id=initiative_id,
item=row,
)
conn.commit()
finally:
conn.close()
log_audit(
"roadmap_item.created",
user_id=user_id,
tenant_id=tenant_id,
details={
"roadmap_item_id": row["id"],
"initiative_id": initiative_id,
"item_type": item_type,
"title": title,
},
)
return row
def update_roadmap_item(
*,
tenant_id: str,
item_id: str,
user_id: Optional[str] = None,
title: Optional[str] = None,
goal_description: Optional[str] = None,
definition_of_done: Optional[list[Any]] = None,
item_type: Optional[RoadmapItemType] = None,
status: Optional[RoadmapItemStatus] = None,
sequencing_mode: Optional[SequencingMode] = None,
target_date: Optional[date] = None,
clear_target_date: bool = False,
sort_order: Optional[int] = None,
) -> Optional[dict[str, Any]]:
existing = get_roadmap_item(tenant_id=tenant_id, item_id=item_id)
if not existing:
return None
old_status = existing["status"]
updates: list[str] = []
params: list[Any] = []
if title is not None:
title = title.strip()
if not title:
raise ValueError("Titel ist erforderlich")
updates.append("title = %s")
params.append(title)
if goal_description is not None:
updates.append("goal_description = %s")
params.append(goal_description)
if definition_of_done is not None:
updates.append("definition_of_done = %s::jsonb")
params.append(json.dumps(definition_of_done))
if item_type is not None:
_validate_item_type(item_type)
updates.append("item_type = %s")
params.append(item_type)
if status is not None:
_validate_status(status)
if status in TERMINAL_STATUSES and status == "reached":
raise ValueError(
"Status 'reached' nur über verify-reached — nicht direkt setzen"
)
if status in ("moved", "discarded"):
raise ValueError(
"Status moved/discarded erfordert Decision — noch nicht implementiert"
)
updates.append("status = %s")
params.append(status)
if sequencing_mode is not None:
_validate_sequencing_mode(sequencing_mode)
updates.append("sequencing_mode = %s")
params.append(sequencing_mode)
if clear_target_date:
updates.append("target_date = NULL")
elif target_date is not None:
updates.append("target_date = %s")
params.append(target_date)
if sort_order is not None:
updates.append("sort_order = %s")
params.append(sort_order)
if not updates:
return existing
updates.append("updated_at = NOW()")
params.extend([item_id, tenant_id])
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
if status == "active" and existing.get("item_type") == "maturity_stage":
from services.maturity_practice import enforce_single_active_maturity_stage
enforce_single_active_maturity_stage(
cur,
tenant_id=tenant_id,
initiative_id=str(existing["initiative_id"]),
keep_active_id=item_id,
)
cur.execute(
f"""
UPDATE roadmap_items
SET {", ".join(updates)}
WHERE id = %s AND tenant_id = %s
RETURNING id, tenant_id, roadmap_id, item_type, title, goal_description,
definition_of_done, status, sequencing_mode, target_date,
sort_order, created_at, updated_at
""",
params,
)
row = cur.fetchone()
if not row:
return None
result = _serialize_row(dict(row))
result["initiative_id"] = existing["initiative_id"]
_sync_milestone_compat_row(
cur,
tenant_id=tenant_id,
initiative_id=existing["initiative_id"],
item=result,
)
conn.commit()
finally:
conn.close()
log_audit(
"roadmap_item.updated",
user_id=user_id,
tenant_id=tenant_id,
details={"roadmap_item_id": item_id},
)
if status is not None and status != old_status:
log_audit(
"roadmap_item.status_changed",
user_id=user_id,
tenant_id=tenant_id,
details={
"roadmap_item_id": item_id,
"from_status": old_status,
"to_status": status,
},
)
return result
def delete_roadmap_item(
*, tenant_id: str, item_id: str, user_id: Optional[str] = None
) -> bool:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"DELETE FROM milestones WHERE id = %s AND tenant_id = %s",
(item_id, tenant_id),
)
cur.execute(
"DELETE FROM roadmap_items WHERE id = %s AND tenant_id = %s RETURNING id",
(item_id, tenant_id),
)
deleted = cur.fetchone() is not None
conn.commit()
finally:
conn.close()
if deleted:
log_audit(
"roadmap_item.deleted",
user_id=user_id,
tenant_id=tenant_id,
details={"roadmap_item_id": item_id},
)
return deleted
def _serialize_dependency(row: dict[str, Any]) -> dict[str, Any]:
dep = dict(row)
for key in ("id", "tenant_id", "from_item_id", "to_item_id"):
if dep.get(key):
dep[key] = str(dep[key])
if dep.get("created_at"):
dep["created_at"] = dep["created_at"].isoformat()
if dep.get("group_key") is None:
dep.pop("group_key", None)
return dep
def list_dependencies(*, tenant_id: str, item_id: str) -> list[dict[str, Any]]:
item = get_roadmap_item(tenant_id=tenant_id, item_id=item_id)
if not item:
raise ValueError("RoadmapItem nicht gefunden")
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT id, tenant_id, from_item_id, to_item_id, dependency_type,
group_key, created_at
FROM roadmap_item_dependencies
WHERE tenant_id = %s AND (from_item_id = %s OR to_item_id = %s)
ORDER BY created_at ASC
""",
(tenant_id, item_id, item_id),
)
return [_serialize_dependency(dict(row)) for row in cur.fetchall()]
finally:
conn.close()
def list_dependencies_for_initiative(
*, tenant_id: str, initiative_id: str
) -> list[dict[str, Any]]:
if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
raise ValueError("Initiative nicht gefunden")
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT d.id, d.tenant_id, d.from_item_id, d.to_item_id,
d.dependency_type, d.group_key, d.created_at
FROM roadmap_item_dependencies d
INNER JOIN roadmap_items fi
ON fi.id = d.from_item_id AND fi.tenant_id = d.tenant_id
INNER JOIN roadmaps r
ON r.id = fi.roadmap_id AND r.tenant_id = fi.tenant_id
WHERE d.tenant_id = %s AND r.initiative_id = %s
ORDER BY d.created_at ASC
""",
(tenant_id, initiative_id),
)
return [_serialize_dependency(dict(row)) for row in cur.fetchall()]
finally:
conn.close()
def add_dependency(
*,
tenant_id: str,
from_item_id: str,
to_item_id: str,
dependency_type: DependencyType = "requires",
group_key: Optional[str] = None,
user_id: Optional[str] = None,
) -> dict[str, Any]:
_validate_dependency_type(dependency_type)
if from_item_id == to_item_id:
raise ValueError("Abhängigkeit auf sich selbst nicht erlaubt")
from_item = get_roadmap_item(tenant_id=tenant_id, item_id=from_item_id)
to_item = get_roadmap_item(tenant_id=tenant_id, item_id=to_item_id)
if not from_item or not to_item:
raise ValueError("RoadmapItem nicht gefunden")
if from_item["initiative_id"] != to_item["initiative_id"]:
raise ValueError("Abhängigkeiten nur innerhalb eines Vorhabens")
normalized_group_key: Optional[str] = None
if group_key is not None:
normalized_group_key = group_key.strip() or None
if dependency_type == "parallel_group" and not normalized_group_key:
raise ValueError("group_key ist für parallel_group erforderlich")
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT 1 FROM roadmap_item_dependencies
WHERE tenant_id = %s AND from_item_id = %s AND to_item_id = %s
AND dependency_type = %s
""",
(tenant_id, from_item_id, to_item_id, dependency_type),
)
if cur.fetchone():
raise ValueError("Abhängigkeit existiert bereits")
cur.execute(
"""
INSERT INTO roadmap_item_dependencies (
tenant_id, from_item_id, to_item_id, dependency_type, group_key
)
VALUES (%s, %s, %s, %s, %s)
RETURNING id, tenant_id, from_item_id, to_item_id, dependency_type,
group_key, created_at
""",
(tenant_id, from_item_id, to_item_id, dependency_type, normalized_group_key),
)
row = _serialize_dependency(dict(cur.fetchone()))
conn.commit()
finally:
conn.close()
log_audit(
"roadmap_item.dependency_added",
user_id=user_id,
tenant_id=tenant_id,
details={
"from_item_id": from_item_id,
"to_item_id": to_item_id,
"dependency_type": dependency_type,
"group_key": normalized_group_key,
},
)
return row
def delete_dependency(
*, tenant_id: str, dependency_id: str, user_id: Optional[str] = None
) -> bool:
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT id, from_item_id, to_item_id, dependency_type
FROM roadmap_item_dependencies
WHERE id = %s AND tenant_id = %s
""",
(dependency_id, tenant_id),
)
row = cur.fetchone()
if not row:
raise ValueError("Abhängigkeit nicht gefunden")
cur.execute(
"""
DELETE FROM roadmap_item_dependencies
WHERE id = %s AND tenant_id = %s
""",
(dependency_id, tenant_id),
)
conn.commit()
finally:
conn.close()
log_audit(
"roadmap_item.dependency_removed",
user_id=user_id,
tenant_id=tenant_id,
details={
"dependency_id": dependency_id,
"from_item_id": str(row["from_item_id"]),
"to_item_id": str(row["to_item_id"]),
"dependency_type": row["dependency_type"],
},
)
return True
def verify_reached(
*, tenant_id: str, item_id: str, user_id: Optional[str] = None
) -> dict[str, Any]:
"""Gate-Verify: reached wenn alle Kriterien erfüllt (satisfied/waived/deferred)."""
item = get_roadmap_item(tenant_id=tenant_id, item_id=item_id)
if not item:
raise ValueError("RoadmapItem nicht gefunden")
if item["status"] in TERMINAL_STATUSES:
raise ValueError(f"RoadmapItem bereits terminal: {item['status']}")
initiative_id = item["initiative_id"]
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
criteria_ok, criteria_reason = prepare_criteria_for_verify(
cur,
tenant_id=tenant_id,
initiative_id=initiative_id,
item_id=item_id,
)
verify_reason = "criteria_ready"
if not criteria_ok:
cur.execute(
"""
SELECT 1 FROM decisions
WHERE tenant_id = %s AND initiative_id = %s
AND status = 'decided'
AND title ILIKE %s
LIMIT 1
""",
(tenant_id, initiative_id, f"gate_override:{item_id}%"),
)
if cur.fetchone():
verify_reason = "gate_override_decision"
criteria_ok = True
else:
raise ValueError(
f"Verify fehlgeschlagen — {criteria_reason}. "
"Kriterien in der Checkliste prüfen oder Nachweis am Plan-Element einreichen."
)
elif criteria_reason != "no_criteria":
verify_reason = criteria_reason
cur.execute(
"""
UPDATE roadmap_items
SET status = 'reached', updated_at = NOW()
WHERE id = %s AND tenant_id = %s
RETURNING id, tenant_id, roadmap_id, item_type, title, goal_description,
definition_of_done, status, sequencing_mode, target_date,
sort_order, created_at, updated_at
""",
(item_id, tenant_id),
)
row = _serialize_row(dict(cur.fetchone()))
row["initiative_id"] = initiative_id
row["verify_reason"] = verify_reason
_sync_milestone_compat_row(
cur,
tenant_id=tenant_id,
initiative_id=initiative_id,
item=row,
)
conn.commit()
finally:
conn.close()
log_audit(
"roadmap_item.reached",
user_id=user_id,
tenant_id=tenant_id,
details={"roadmap_item_id": item_id, "verify_reason": verify_reason},
)
if row.get("item_type") == "maturity_stage":
from services.maturity_stage_transition import on_maturity_stage_reached
transition = on_maturity_stage_reached(
tenant_id=tenant_id,
initiative_id=initiative_id,
reached_item_id=item_id,
user_id=user_id,
)
if transition:
row["maturity_transition"] = transition
elif row.get("item_type") != "work_cycle":
from services.steering_events import emit_steering_event
steering = emit_steering_event(
tenant_id=tenant_id,
initiative_id=initiative_id,
event_kind="gate_verified",
payload={
"roadmap_item_id": item_id,
"item_type": row.get("item_type"),
"verify_reason": verify_reason,
},
user_id=user_id,
)
row["steering_event"] = {
"handled": steering.get("handled"),
"lifecycle_chain": steering.get("lifecycle_chain"),
"lifecycle_state": steering.get("evaluation", {})
.get("lifecycle", {})
.get("current_state"),
}
return row
def reopen_roadmap_item(
*,
tenant_id: str,
item_id: str,
user_id: Optional[str] = None,
reason: str = "",
) -> dict[str, Any]:
"""Reopen reached → active; Kriterien-Status bleibt unverändert."""
item = get_roadmap_item(tenant_id=tenant_id, item_id=item_id)
if not item:
raise ValueError("RoadmapItem nicht gefunden")
if item["status"] != "reached":
raise ValueError("Reopen nur für erreichte Plan-Elemente (status=reached)")
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
if item.get("item_type") == "maturity_stage":
from services.maturity_practice import enforce_single_active_maturity_stage
enforce_single_active_maturity_stage(
cur,
tenant_id=tenant_id,
initiative_id=str(item["initiative_id"]),
keep_active_id=item_id,
)
cur.execute(
"""
UPDATE roadmap_items
SET status = 'active', updated_at = NOW()
WHERE id = %s AND tenant_id = %s
RETURNING id, tenant_id, roadmap_id, item_type, title, goal_description,
definition_of_done, status, sequencing_mode, target_date,
sort_order, created_at, updated_at
""",
(item_id, tenant_id),
)
row = _serialize_row(dict(cur.fetchone()))
row["initiative_id"] = item["initiative_id"]
_sync_milestone_compat_row(
cur,
tenant_id=tenant_id,
initiative_id=item["initiative_id"],
item=row,
)
conn.commit()
finally:
conn.close()
if row.get("item_type") == "maturity_stage":
from services.maturity_stage_transition import on_maturity_stage_reopened
row["maturity_practice"] = on_maturity_stage_reopened(
tenant_id=tenant_id,
initiative_id=str(item["initiative_id"]),
stage_id=item_id,
user_id=user_id,
)
log_audit(
"roadmap_item.reopened",
user_id=user_id,
tenant_id=tenant_id,
details={
"roadmap_item_id": item_id,
"reason": reason.strip() or None,
"from_status": "reached",
"to_status": "active",
},
)
if row.get("item_type") != "work_cycle":
from services.steering_events import emit_steering_event
steering = emit_steering_event(
tenant_id=tenant_id,
initiative_id=str(row["initiative_id"]),
event_kind="gate_reopened",
payload={
"roadmap_item_id": item_id,
"item_type": row.get("item_type"),
"reason": reason.strip() or None,
},
user_id=user_id,
)
row["steering_event"] = {
"handled": steering.get("handled"),
"lifecycle_chain": steering.get("lifecycle_chain"),
}
return row