diff --git a/README.md b/README.md
index 3e5c567..fbac34a 100644
--- a/README.md
+++ b/README.md
@@ -230,7 +230,17 @@ Operating Model Extension II (AP0.9, Schema `008`):
Capabilities gesamt nach AP0.9: **33**.
-Zielarchitektur-Einordnung: AP0.9 ist Schritt 7 der Evolutionslinie (Evidence/Review/Decision/Recurring) — siehe `docs/sprints/Sprint0_AP0_9_Assignment_v0.1.md`.
+Operating Model Integration (AP0.10, Schema weiter `008`):
+
+| Endpoint | Methode | Capability | Beschreibung |
+|----------|---------|------------|--------------|
+| `/api/initiatives/{id}/steering-snapshot` | GET | `kairo.initiative.read` | Verknüpfter Graph + Operating Phase |
+
+**Operating-Transitions:** Blocker gelöst → Maßnahme entblocken; Review abgeschlossen → Maßnahme `review_required` → `done`
+
+**Attention Regel 10:** `action_review_required` — Maßnahme ohne geplantes Review
+
+Version nach AP0.10: **`0.10.0-ap0.10`**. Brücke zum Steering Core: `docs/architecture/Kairo_Operating_Model_Steering_Bridge_v0.1.md`
Tenant-Invarianten: `docs/architecture/Kairo_Tenant_Invariants_v0.1.md`
`docs/architecture/Kairo_Tenant_Invariants_v0.1.md`
diff --git a/backend/data_layer/attention.py b/backend/data_layer/attention.py
index 6f217f1..2b043f5 100644
--- a/backend/data_layer/attention.py
+++ b/backend/data_layer/attention.py
@@ -20,6 +20,7 @@ AttentionKind = Literal[
"overdue_action",
"review_due",
"recurring_due",
+ "action_review_required",
]
NextActionKind = Literal[
@@ -275,6 +276,41 @@ def _milestones_at_risk(cur, ctx: TenantContext) -> list[dict[str, Any]]:
return [_serialize_attention(dict(r)) for r in cur.fetchall()]
+def _actions_review_required(cur, ctx: TenantContext) -> list[dict[str, Any]]:
+ cur.execute(
+ """
+ SELECT
+ 'action_review_required' AS kind,
+ 'warning' AS severity,
+ a.title AS title,
+ 'Maßnahme wartet auf Review — kein geplantes Review verknüpft' AS summary,
+ 'action' AS scope_type,
+ a.id AS scope_id,
+ a.initiative_id,
+ a.id AS action_id,
+ NULL::uuid AS blocker_id,
+ NULL::uuid AS milestone_id,
+ NULL::uuid AS review_id,
+ NULL::uuid AS recurring_element_id,
+ 'action_review_required_no_review' AS reason_code,
+ 'actions' AS data_source
+ FROM actions a
+ WHERE a.tenant_id = %s
+ AND a.status = 'review_required'
+ AND NOT EXISTS (
+ SELECT 1 FROM reviews r
+ WHERE r.tenant_id = a.tenant_id
+ AND r.action_id = a.id
+ AND r.status = 'planned'
+ )
+ ORDER BY a.updated_at DESC
+ LIMIT 20
+ """,
+ (ctx.tenant_id,),
+ )
+ return [_serialize_attention(dict(r)) for r in cur.fetchall()]
+
+
def _overdue_actions(cur, ctx: TenantContext) -> list[dict[str, Any]]:
cur.execute(
"""
@@ -389,6 +425,7 @@ def get_attention_items(ctx: TenantContext) -> list[dict[str, Any]]:
items.extend(_stale_initiatives(cur, ctx))
items.extend(_milestones_at_risk(cur, ctx))
items.extend(_overdue_actions(cur, ctx))
+ items.extend(_actions_review_required(cur, ctx))
items.extend(_reviews_due(cur, ctx))
items.extend(_recurring_due(cur, ctx))
diff --git a/backend/data_layer/initiative_snapshot.py b/backend/data_layer/initiative_snapshot.py
new file mode 100644
index 0000000..e7bbcd9
--- /dev/null
+++ b/backend/data_layer/initiative_snapshot.py
@@ -0,0 +1,349 @@
+"""Initiative steering snapshot — verknüpftes Read-Model (AP0.10a).
+
+Baut aus flachen OM-Tabellen einen erklärbaren Graph für UI und spätere Steering Core.
+"""
+
+from __future__ import annotations
+
+from typing import Any, Literal, Optional
+
+from psycopg2.extras import RealDictCursor
+
+from db import get_connection
+from services.initiatives import get_initiative
+from tenant_context import TenantContext
+
+OperatingPhase = Literal[
+ "capture",
+ "triage",
+ "structure",
+ "commit",
+ "execute",
+ "verify",
+ "review",
+ "adapt",
+ "closure",
+]
+
+OPEN_BLOCKER = ("open", "in_progress")
+OPEN_ACTION = ("open", "ready", "in_progress", "blocked", "review_required")
+ACTIVE_INITIATIVE = ("active", "paused")
+
+
+def _sid(value: Any) -> Optional[str]:
+ return str(value) if value else None
+
+
+def _iso(value: Any) -> Optional[str]:
+ return value.isoformat() if value else None
+
+
+def _derive_operating_phase(
+ *,
+ initiative_status: str,
+ open_actions: int,
+ blocked_actions: int,
+ review_required_actions: int,
+ open_blockers: int,
+ backlog_new: int,
+ planned_reviews_due: int,
+) -> tuple[OperatingPhase, list[str]]:
+ """Heuristische, erklärbare Phase — kein Lifecycle-State-Machine-Ersatz."""
+ signals: list[str] = []
+
+ if initiative_status in ("completed", "archived"):
+ return "closure", ["initiative_terminal"]
+
+ if open_actions == 0 and backlog_new > 0:
+ signals.append("backlog_awaiting_commit")
+ return "triage", signals
+
+ if open_actions == 0 and backlog_new == 0:
+ signals.append("no_open_work")
+ return "structure", signals
+
+ if blocked_actions > 0 or open_blockers > 0:
+ if blocked_actions:
+ signals.append("actions_blocked")
+ if open_blockers:
+ signals.append("open_blockers")
+ return "execute", signals
+
+ if review_required_actions > 0 or planned_reviews_due > 0:
+ if review_required_actions:
+ signals.append("actions_need_review")
+ if planned_reviews_due:
+ signals.append("reviews_due")
+ return "review", signals
+
+ if open_actions > 0:
+ signals.append("work_in_progress")
+ return "execute", signals
+
+ return "adapt", signals
+
+
+def get_initiative_steering_snapshot(
+ ctx: TenantContext, *, initiative_id: str
+) -> Optional[dict[str, Any]]:
+ """Tenant-scoped Graph-Snapshot eines Vorhabens."""
+ initiative = get_initiative(tenant_id=ctx.tenant_id, initiative_id=initiative_id)
+ if not initiative:
+ return None
+
+ conn = get_connection()
+ try:
+ with conn.cursor(cursor_factory=RealDictCursor) as cur:
+ cur.execute(
+ """
+ SELECT id, title, description, status, priority, due_at,
+ created_at, updated_at
+ FROM actions
+ WHERE tenant_id = %s AND initiative_id = %s
+ ORDER BY updated_at DESC
+ """,
+ (ctx.tenant_id, initiative_id),
+ )
+ actions_raw = [dict(r) for r in cur.fetchall()]
+
+ cur.execute(
+ """
+ SELECT id, action_id, milestone_id, title, status, created_at, updated_at
+ FROM blockers
+ WHERE tenant_id = %s AND initiative_id = %s
+ ORDER BY updated_at DESC
+ """,
+ (ctx.tenant_id, initiative_id),
+ )
+ blockers = [dict(r) for r in cur.fetchall()]
+
+ cur.execute(
+ """
+ SELECT id, action_id, milestone_id, title, status, created_at, updated_at
+ FROM evidence
+ WHERE tenant_id = %s AND initiative_id = %s
+ ORDER BY updated_at DESC
+ """,
+ (ctx.tenant_id, initiative_id),
+ )
+ evidence = [dict(r) for r in cur.fetchall()]
+
+ cur.execute(
+ """
+ SELECT id, action_id, milestone_id, title, status, due_at,
+ created_at, updated_at
+ FROM reviews
+ WHERE tenant_id = %s AND initiative_id = %s
+ ORDER BY updated_at DESC
+ """,
+ (ctx.tenant_id, initiative_id),
+ )
+ reviews = [dict(r) for r in cur.fetchall()]
+
+ cur.execute(
+ """
+ SELECT id, title, status, priority, converted_action_id, created_at, updated_at
+ FROM backlog_items
+ WHERE tenant_id = %s AND initiative_id = %s
+ ORDER BY updated_at DESC
+ """,
+ (ctx.tenant_id, initiative_id),
+ )
+ backlog = [dict(r) for r in cur.fetchall()]
+
+ cur.execute(
+ """
+ SELECT id, title, status, target_date, created_at, updated_at
+ FROM milestones
+ WHERE tenant_id = %s AND initiative_id = %s
+ ORDER BY updated_at DESC
+ """,
+ (ctx.tenant_id, initiative_id),
+ )
+ milestones_raw = [dict(r) for r in cur.fetchall()]
+
+ cur.execute(
+ """
+ SELECT id, title, status, outcome, created_at, updated_at
+ FROM decisions
+ WHERE tenant_id = %s AND initiative_id = %s
+ ORDER BY updated_at DESC
+ """,
+ (ctx.tenant_id, initiative_id),
+ )
+ decisions = [dict(r) for r in cur.fetchall()]
+
+ cur.execute(
+ """
+ SELECT id, title, status, next_due_at, created_at, updated_at
+ FROM recurring_elements
+ WHERE tenant_id = %s AND initiative_id = %s
+ ORDER BY updated_at DESC
+ """,
+ (ctx.tenant_id, initiative_id),
+ )
+ recurring = [dict(r) for r in cur.fetchall()]
+ finally:
+ conn.close()
+
+ blockers_by_action: dict[str, list[dict]] = {}
+ unlinked_blockers: list[dict] = []
+ for b in blockers:
+ item = {
+ "id": _sid(b["id"]),
+ "title": b["title"],
+ "status": b["status"],
+ "action_id": _sid(b.get("action_id")),
+ "milestone_id": _sid(b.get("milestone_id")),
+ }
+ aid = item["action_id"]
+ if aid:
+ blockers_by_action.setdefault(aid, []).append(item)
+ else:
+ unlinked_blockers.append(item)
+
+ evidence_by_action: dict[str, list[dict]] = {}
+ unlinked_evidence: list[dict] = []
+ for e in evidence:
+ item = {
+ "id": _sid(e["id"]),
+ "title": e["title"],
+ "status": e["status"],
+ "action_id": _sid(e.get("action_id")),
+ "milestone_id": _sid(e.get("milestone_id")),
+ }
+ aid = item["action_id"]
+ if aid:
+ evidence_by_action.setdefault(aid, []).append(item)
+ else:
+ unlinked_evidence.append(item)
+
+ reviews_by_action: dict[str, list[dict]] = {}
+ reviews_by_milestone: dict[str, list[dict]] = {}
+ unlinked_reviews: list[dict] = []
+ planned_reviews_due = 0
+ for r in reviews:
+ item = {
+ "id": _sid(r["id"]),
+ "title": r["title"],
+ "status": r["status"],
+ "due_at": _iso(r.get("due_at")),
+ "action_id": _sid(r.get("action_id")),
+ "milestone_id": _sid(r.get("milestone_id")),
+ }
+ if r["status"] == "planned" and r.get("due_at"):
+ planned_reviews_due += 1
+ aid, mid = item["action_id"], item["milestone_id"]
+ if aid:
+ reviews_by_action.setdefault(aid, []).append(item)
+ elif mid:
+ reviews_by_milestone.setdefault(mid, []).append(item)
+ else:
+ unlinked_reviews.append(item)
+
+ actions: list[dict[str, Any]] = []
+ open_actions = blocked_actions = review_required_actions = 0
+ for a in actions_raw:
+ aid = _sid(a["id"])
+ status = a["status"]
+ if status in OPEN_ACTION:
+ open_actions += 1
+ if status == "blocked":
+ blocked_actions += 1
+ if status == "review_required":
+ review_required_actions += 1
+ action_blockers = blockers_by_action.get(aid, [])
+ open_blocker_count = sum(1 for b in action_blockers if b["status"] in OPEN_BLOCKER)
+ actions.append(
+ {
+ "id": aid,
+ "title": a["title"],
+ "status": status,
+ "priority": a["priority"],
+ "due_at": _iso(a.get("due_at")),
+ "blockers": action_blockers,
+ "evidence": evidence_by_action.get(aid, []),
+ "reviews": reviews_by_action.get(aid, []),
+ "open_blocker_count": open_blocker_count,
+ "has_open_blocker": open_blocker_count > 0,
+ }
+ )
+
+ milestones = []
+ for m in milestones_raw:
+ mid = _sid(m["id"])
+ milestones.append(
+ {
+ "id": mid,
+ "title": m["title"],
+ "status": m["status"],
+ "target_date": m["target_date"].isoformat() if m.get("target_date") else None,
+ "reviews": reviews_by_milestone.get(mid, []),
+ }
+ )
+
+ open_blockers = sum(
+ 1 for b in blockers if b["status"] in OPEN_BLOCKER
+ )
+ backlog_new = sum(1 for bi in backlog if bi["status"] in ("new", "triaged", "accepted"))
+
+ phase, phase_signals = _derive_operating_phase(
+ initiative_status=initiative["status"],
+ open_actions=open_actions,
+ blocked_actions=blocked_actions,
+ review_required_actions=review_required_actions,
+ open_blockers=open_blockers,
+ backlog_new=backlog_new,
+ planned_reviews_due=planned_reviews_due,
+ )
+
+ return {
+ "initiative_id": initiative_id,
+ "initiative_title": initiative["title"],
+ "initiative_status": initiative["status"],
+ "operating_phase": phase,
+ "phase_signals": phase_signals,
+ "counts": {
+ "actions_open": open_actions,
+ "actions_blocked": blocked_actions,
+ "actions_review_required": review_required_actions,
+ "blockers_open": open_blockers,
+ "backlog_open": backlog_new,
+ "decisions": len(decisions),
+ "reviews_planned_due": planned_reviews_due,
+ "recurring_active": sum(1 for r in recurring if r["status"] == "active"),
+ "unlinked_blockers": len(unlinked_blockers),
+ "unlinked_evidence": len(unlinked_evidence),
+ "unlinked_reviews": len(unlinked_reviews),
+ },
+ "actions": actions,
+ "milestones": milestones,
+ "initiative_level": {
+ "blockers": unlinked_blockers,
+ "evidence": unlinked_evidence,
+ "reviews": unlinked_reviews,
+ "decisions": [
+ {"id": _sid(d["id"]), "title": d["title"], "status": d["status"]}
+ for d in decisions
+ ],
+ "backlog": [
+ {
+ "id": _sid(b["id"]),
+ "title": b["title"],
+ "status": b["status"],
+ "converted_action_id": _sid(b.get("converted_action_id")),
+ }
+ for b in backlog
+ ],
+ "recurring": [
+ {
+ "id": _sid(r["id"]),
+ "title": r["title"],
+ "status": r["status"],
+ "next_due_at": _iso(r.get("next_due_at")),
+ }
+ for r in recurring
+ ],
+ },
+ "data_source": "initiative_steering_snapshot",
+ }
diff --git a/backend/routers/initiatives.py b/backend/routers/initiatives.py
index 2377434..1ecb834 100644
--- a/backend/routers/initiatives.py
+++ b/backend/routers/initiatives.py
@@ -6,6 +6,7 @@ from datetime import datetime
from typing import Literal, Optional
from capabilities import require_capability
+from data_layer import initiative_snapshot as dl_initiative_snapshot
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from services import actions as action_service
@@ -154,6 +155,19 @@ def get_initiative(
return item
+@router.get("/{initiative_id}/steering-snapshot")
+def get_initiative_steering_snapshot(
+ initiative_id: str,
+ ctx: TenantContext = Depends(require_capability("kairo.initiative.read")),
+):
+ snapshot = dl_initiative_snapshot.get_initiative_steering_snapshot(
+ ctx, initiative_id=initiative_id
+ )
+ if not snapshot:
+ raise HTTPException(status_code=404, detail="Vorhaben nicht gefunden")
+ return snapshot
+
+
@router.patch("/{initiative_id}")
def update_initiative(
initiative_id: str,
diff --git a/backend/services/blockers.py b/backend/services/blockers.py
index 20c67fb..83ab22b 100644
--- a/backend/services/blockers.py
+++ b/backend/services/blockers.py
@@ -264,6 +264,16 @@ def update_blocker(
"to_status": status,
},
)
+ from services.operating_transitions import after_blocker_status_change
+
+ after_blocker_status_change(
+ tenant_id=tenant_id,
+ blocker_id=blocker_id,
+ action_id=result.get("action_id"),
+ old_status=old_status,
+ new_status=status,
+ user_id=user_id,
+ )
return result
diff --git a/backend/services/operating_transitions.py b/backend/services/operating_transitions.py
new file mode 100644
index 0000000..4c37f8f
--- /dev/null
+++ b/backend/services/operating_transitions.py
@@ -0,0 +1,147 @@
+"""Minimale Operating-Transitions — Vorstufe zum Steering Orchestrator (AP0.10a).
+
+Side Effects bei Statusänderungen. Später: Hook Registry + Method Strategies.
+"""
+
+from __future__ import annotations
+
+from typing import Any, Optional
+
+from psycopg2.extras import RealDictCursor
+
+from db import get_connection
+
+RESOLVED_BLOCKER_STATUSES = frozenset({"resolved", "accepted_risk", "dismissed"})
+
+
+def after_blocker_status_change(
+ *,
+ tenant_id: str,
+ blocker_id: str,
+ action_id: Optional[str],
+ old_status: str,
+ new_status: str,
+ user_id: Optional[str] = None,
+) -> list[dict[str, Any]]:
+ """Blocker geschlossen → verknüpfte blockierte Maßnahme entblocken."""
+ effects: list[dict[str, Any]] = []
+ if new_status == old_status:
+ return effects
+ if new_status not in RESOLVED_BLOCKER_STATUSES or not action_id:
+ return effects
+
+ conn = get_connection()
+ try:
+ with conn.cursor(cursor_factory=RealDictCursor) as cur:
+ cur.execute(
+ """
+ SELECT id, status FROM actions
+ WHERE id = %s AND tenant_id = %s
+ """,
+ (action_id, tenant_id),
+ )
+ action = cur.fetchone()
+ if not action or action["status"] != "blocked":
+ return effects
+
+ cur.execute(
+ """
+ SELECT COUNT(*) FROM blockers
+ WHERE tenant_id = %s AND action_id = %s
+ AND id != %s AND status IN ('open', 'in_progress')
+ """,
+ (tenant_id, action_id, blocker_id),
+ )
+ remaining = cur.fetchone()[0]
+ if remaining > 0:
+ effects.append(
+ {
+ "kind": "action_still_blocked",
+ "action_id": str(action_id),
+ "reason": "other_open_blockers",
+ }
+ )
+ return effects
+
+ cur.execute(
+ """
+ UPDATE actions SET status = 'open', updated_at = NOW()
+ WHERE id = %s AND tenant_id = %s AND status = 'blocked'
+ RETURNING id
+ """,
+ (action_id, tenant_id),
+ )
+ if cur.fetchone():
+ effects.append(
+ {
+ "kind": "action_unblocked",
+ "action_id": str(action_id),
+ "trigger": "blocker_resolved",
+ "blocker_id": blocker_id,
+ }
+ )
+ conn.commit()
+ finally:
+ conn.close()
+
+ if effects and user_id:
+ from services.audit import log_audit
+
+ log_audit(
+ "operating_transition.action_unblocked",
+ user_id=user_id,
+ tenant_id=tenant_id,
+ details={"effects": effects, "blocker_id": blocker_id},
+ )
+ return effects
+
+
+def after_review_status_change(
+ *,
+ tenant_id: str,
+ review_id: str,
+ action_id: Optional[str],
+ old_status: str,
+ new_status: str,
+ user_id: Optional[str] = None,
+) -> list[dict[str, Any]]:
+ """Review abgeschlossen → Maßnahme aus review_required weiterführen."""
+ effects: list[dict[str, Any]] = []
+ if new_status == old_status or new_status != "completed" or not action_id:
+ return effects
+
+ conn = get_connection()
+ try:
+ with conn.cursor(cursor_factory=RealDictCursor) as cur:
+ cur.execute(
+ """
+ UPDATE actions
+ SET status = 'done', updated_at = NOW()
+ WHERE id = %s AND tenant_id = %s AND status = 'review_required'
+ RETURNING id
+ """,
+ (action_id, tenant_id),
+ )
+ if cur.fetchone():
+ effects.append(
+ {
+ "kind": "action_completed_after_review",
+ "action_id": str(action_id),
+ "trigger": "review_completed",
+ "review_id": review_id,
+ }
+ )
+ conn.commit()
+ finally:
+ conn.close()
+
+ if effects and user_id:
+ from services.audit import log_audit
+
+ log_audit(
+ "operating_transition.action_completed_after_review",
+ user_id=user_id,
+ tenant_id=tenant_id,
+ details={"effects": effects, "review_id": review_id},
+ )
+ return effects
diff --git a/backend/services/reviews.py b/backend/services/reviews.py
index e7f2a6d..d16f954 100644
--- a/backend/services/reviews.py
+++ b/backend/services/reviews.py
@@ -309,6 +309,16 @@ def update_review(
"to_status": status,
},
)
+ from services.operating_transitions import after_review_status_change
+
+ after_review_status_change(
+ tenant_id=tenant_id,
+ review_id=review_id,
+ action_id=result.get("action_id"),
+ old_status=old_status,
+ new_status=status,
+ user_id=user_id,
+ )
return result
diff --git a/backend/tests/test_ap10_integration.py b/backend/tests/test_ap10_integration.py
new file mode 100644
index 0000000..d896172
--- /dev/null
+++ b/backend/tests/test_ap10_integration.py
@@ -0,0 +1,90 @@
+"""AP0.10 — Integration: Snapshot, Transitions, Attention."""
+
+from __future__ import annotations
+
+from tests.factories import provision_user_in_tenant
+from tests.test_initiatives_actions import (
+ _auth,
+ _create_action,
+ _create_initiative,
+ _login,
+)
+
+
+def test_steering_snapshot_graph(client):
+ user = provision_user_in_tenant(tenant_role="member")
+ token = _login(client, user)
+ initiative_id = _create_initiative(client, token).json()["id"]
+ action = _create_action(client, token, initiative_id, title="Linked Action").json()
+
+ blocker = client.post(
+ f"/api/initiatives/{initiative_id}/blockers",
+ json={"title": "B", "action_id": action["id"]},
+ headers=_auth(token),
+ )
+ assert blocker.status_code == 201
+
+ snap = client.get(
+ f"/api/initiatives/{initiative_id}/steering-snapshot",
+ headers=_auth(token),
+ )
+ assert snap.status_code == 200
+ data = snap.json()
+ assert data["operating_phase"] in (
+ "execute",
+ "triage",
+ "structure",
+ "review",
+ "adapt",
+ "commit",
+ "capture",
+ "verify",
+ "closure",
+ )
+ linked = next(a for a in data["actions"] if a["id"] == action["id"])
+ assert len(linked["blockers"]) == 1
+ assert linked["open_blocker_count"] == 1
+
+
+def test_blocker_resolve_unblocks_action(client):
+ user = provision_user_in_tenant(tenant_role="member")
+ token = _login(client, user)
+ initiative_id = _create_initiative(client, token).json()["id"]
+ action = _create_action(client, token, initiative_id).json()["id"]
+
+ created = client.post(
+ f"/api/initiatives/{initiative_id}/blockers",
+ json={
+ "title": "Block",
+ "action_id": action,
+ "set_action_blocked": True,
+ },
+ headers=_auth(token),
+ )
+ blocker_id = created.json()["id"]
+ blocked = client.get(f"/api/actions/{action}", headers=_auth(token)).json()
+ assert blocked["status"] == "blocked"
+
+ client.patch(
+ f"/api/blockers/{blocker_id}",
+ json={"status": "resolved"},
+ headers=_auth(token),
+ )
+ unblocked = client.get(f"/api/actions/{action}", headers=_auth(token)).json()
+ assert unblocked["status"] == "open"
+
+
+def test_attention_action_review_required_without_review(client):
+ user = provision_user_in_tenant(tenant_role="member")
+ token = _login(client, user)
+ initiative_id = _create_initiative(client, token).json()["id"]
+ action = _create_action(client, token, initiative_id).json()
+ client.patch(
+ f"/api/actions/{action['id']}",
+ json={"status": "review_required"},
+ headers=_auth(token),
+ )
+
+ res = client.get("/api/workspace/attention", headers=_auth(token))
+ assert res.status_code == 200
+ assert any(i["kind"] == "action_review_required" for i in res.json())
diff --git a/backend/version.py b/backend/version.py
index 2d6adbe..8fdd795 100644
--- a/backend/version.py
+++ b/backend/version.py
@@ -1,3 +1,3 @@
-APP_VERSION = "0.9.0-ap0.9"
+APP_VERSION = "0.10.0-ap0.10"
DB_SCHEMA_VERSION = "008"
APP_NAME = "jinkendo-kairo"
diff --git a/docs/architecture/Kairo_Operating_Model_Steering_Bridge_v0.1.md b/docs/architecture/Kairo_Operating_Model_Steering_Bridge_v0.1.md
new file mode 100644
index 0000000..f21ea3c
--- /dev/null
+++ b/docs/architecture/Kairo_Operating_Model_Steering_Bridge_v0.1.md
@@ -0,0 +1,146 @@
+# Kairo — Operating Model → Steering Bridge
+
+**Status:** Architektur-Einordnung
+**Stand:** 2026-07-05
+**Zweck:** Ehrliche Antwort auf die Frage, ob AP0.8/0.9 auf spätere Steuerungslogik einzahlen — und was noch fehlt.
+
+---
+
+## 1. Kurzantwort
+
+**Ja, aber indirekt.** AP0.8/0.9 haben das **Vokabular und die Persistenzschicht** des kanonischen Operating Models geliefert — nicht den **Steuerungsgraph**, nicht den **Lifecycle-Orchestrator** und nicht die **Method Registry**.
+
+Das ist kein Versehen, sondern die dokumentierte Reihenfolge:
+
+```text
+Foundation (AP0.1–0.7)
+ → Operating Model Entitäten (AP0.8–0.9) ← wir sind hier
+ → Integration & Validation (AP0.10)
+ → Steering Core Foundation (Sprint 1 / AP1.x)
+ → Method Registry, Hooks, Lifecycle Runtime
+```
+
+Ohne die Entitäten hätte Steering nichts zu lesen und zu steuern.
+Mit nur den Entitäten **ohne Verbindungslogik** wirkt Kairo zu Recht wie eine Sammlung paralleler Tabellen.
+
+---
+
+## 2. Was AP0.8/0.9 tatsächlich vorbereitet
+
+| Baustein | Beitrag für späteres Steering | Noch nicht da |
+|----------|------------------------------|---------------|
+| **Entitäten** | Blocker, Backlog, Milestone, Evidence, Decision, Review, Recurring | SteeringContext, Project, Dependency |
+| **Schwache FKs** | `blocker.action_id`, `evidence.action_id`, `review.action_id` | Pflicht-Struktur, Graph-Traversierung in UI |
+| **Attention (Regeln 1–9)** | Proto-Signal-Engine, erklärbare DTOs | Methoden-spezifische Signal Rules, Ranking |
+| **NextActionCandidates** | Regelbasierte Empfehlungen | Hook `on_next_action_requested`, Strategien |
+| **Audit-Events** | Nachvollziehbarkeit für Agenten/Steering | Lifecycle-Audit-Kette |
+| **Actor-Assignments** | Wer handelt | Waiting/Reminder/Escalation |
+| **Status-Enums** | Anknüpfpunkte für Lifecycle-Transitions | State Machine + Guards |
+
+**Fazit:** AP0.8/0.9 = **System of Record für OM-Objekte** + **regelbasierte Signale**.
+Das ist Schritt 5–7 der Evolutionslinie (`Kairo_System_Target_State_v0.1.md` §24), **nicht** Schritt 2–4 (Steering Core, Method Registry, Lifecycle).
+
+---
+
+## 3. Kanonisches Modell vs. Ist-Implementierung
+
+### Soll (Canonical Operating Model)
+
+```text
+Initiative
+ ├── Milestone
+ ├── BacklogItem
+ ├── Action
+ │ ├── Assignment
+ │ ├── Blocker ← primär an Maßnahme
+ │ └── Evidence ← primär an Maßnahme
+ ├── Decision
+ ├── Review
+ └── RecurringElement
+```
+
+### Ist (AP0.9)
+
+```text
+Initiative
+ ├── [parallele Sektionen in UI]
+ ├── Action (Liste)
+ ├── Blocker (Liste, action_id optional)
+ ├── Evidence (Liste, action_id optional)
+ ├── …
+```
+
+**Lücke:** Objekte existieren, aber **Struktur und Flow sind nicht sichtbar**.
+Der Nutzer sieht Listen, keinen **Steuerungszustand** eines Vorhabens.
+
+---
+
+## 4. Was „Steuerungslogik“ später bedeutet
+
+Aus `Kairo_Target_Architecture_Method_Driven_Adaptive_Steering_Core_v0.1.md`:
+
+| Komponente | Rolle |
+|------------|-------|
+| **SteeringContext** | Bindet Initiative an Methode, Lifecycle, Domain |
+| **Method Registry** | Liefert Structure Builder, Signal Rules, Strategien |
+| **Standard Lifecycle** | intake → … → closure mit Guards |
+| **Hook Registry** | `on_review_due`, `on_blocker_open`, `on_next_action_requested` |
+| **Signal Engine** | Attention + NextAction aus Methode + Kontext |
+| **Transition Orchestrator** | Statusänderung → Side Effects (unblock, review anstoßen) |
+
+AP0.8/0.9 **ersetzen** das nicht — sie liefern die **Daten**, an die Hooks und Regeln andocken.
+
+---
+
+## 5. AP0.10 — Integration statt nur Validation
+
+AP0.10 wird daher **zweigeteilt**:
+
+### A) Operating Model Integration (technisch)
+
+- **Steering-Snapshot** Read-Model: Initiative als verknüpfter Graph (Actions mit Blockern/Evidence/Reviews)
+- **Operating Cycle Phase** (heuristisch, erklärbar): triage / execute / verify / review / adapt
+- **Transition-Orchestrierung minimal:** z. B. Blocker gelöst → verknüpfte Maßnahme entblocken; Review abgeschlossen → Maßnahme weiterführen
+- **UI:** Verknüpfungen sichtbar (Maßnahme ↔ Blocker/Evidence), nicht nur parallele Sektionen
+- **Attention:** Regeln, die **Beziehungen** nutzen (Maßnahme blockiert + offener Blocker auf derselben Maßnahme)
+
+### B) MVP Validation (fachlich)
+
+Testvorhaben aus Roadmap (Kairo, Gewaltschutzkurs, …) — Prüffragen: Hilft es bei täglicher Steuerung? Fehlt was **wirklich** als Nächstes?
+
+---
+
+## 6. Evolutionspfad (konkret)
+
+```text
+AP0.10 Integration + Validation ← nächster Schritt
+AP1.0 backend/steering/ Foundation (SteeringContext, Lifecycle stub)
+AP1.1 Method Registry minimal + Hook Registry
+AP1.2 Signal Engine aus attention.py → steering/signals/
+AP1.3 Transition Orchestrator (ersetzt operating_transitions.py)
+Sprint 2+ Dependencies, Waiting, Method Profiles
+```
+
+**Regel:** Keine neuen parallelen Tabellen ohne OM-Bezug.
+Neue Arbeit muss either **verbinden**, **orchestratieren** oder **validieren**.
+
+---
+
+## 7. Leitfrage-Check
+
+> Zahlt jede Entität auf „Welcher nächste Schritt bringt das Vorhaben voran?“ ein?
+
+| Entität | Heute | Nach AP0.10 | Nach Steering Core |
+|---------|-------|-------------|-------------------|
+| Action | ✓ Commit/Execute | ✓ mit Kontext | ✓ NextAction-Ziel |
+| Blocker | △ Signal | ✓ an Maßnahme | ✓ Hook on_blocker_open |
+| Backlog | ✓ Triage | ✓ Convert-Flow | ✓ Structure Builder |
+| Milestone | △ Signal at_risk | ✓ mit Reviews | ✓ Roadmap-Lane |
+| Evidence | △ passive | ✓ an Maßnahme | ✓ Verify-Phase |
+| Review | △ due signal | ✓ schließt Execute | ✓ Review Strategy |
+| Decision | △ passive | △ Adapt-Hinweis | ✓ Adapt-Hook |
+| Recurring | △ due signal | △ Routine-Hinweis | ✓ routine_control |
+
+---
+
+*v0.1 — Referenz für AP0.10 und Sprint-1-Planung*
diff --git a/docs/sprints/Sprint0_AP0_10_Assignment_v0.1.md b/docs/sprints/Sprint0_AP0_10_Assignment_v0.1.md
new file mode 100644
index 0000000..fa73323
--- /dev/null
+++ b/docs/sprints/Sprint0_AP0_10_Assignment_v0.1.md
@@ -0,0 +1,50 @@
+# AP0.10 — Operating Model Integration & MVP Validation
+## Implementierungsauftrag v0.1
+
+**Status:** freigegeben
+**Stand:** 2026-07-05
+**Vorgänger:** AP0.9 ✓
+**Zielversion:** `0.10.0-ap0.10` (keine Schema-Migration)
+
+---
+
+## Warum AP0.10 anders ist als AP0.8/0.9
+
+AP0.8/0.9 lieferten **Entitäten + Signale**.
+AP0.10 liefert **Verknüpfung + erklärbaren Zustand + minimale Flows** — Brücke zum Steering Core.
+
+Referenz: `docs/architecture/Kairo_Operating_Model_Steering_Bridge_v0.1.md`
+
+---
+
+## Teil A — Integration (technisch)
+
+| Slice | Inhalt |
+|-------|--------|
+| 10a | Steering-Snapshot Read-Model + API |
+| 10b | Operating-Transitions (Blocker→Action, Review→Action) |
+| 10c | Attention Regel: review_required ohne Review |
+| 10d | UI: Steuerungszustand + Verknüpfungen an Maßnahmen |
+
+## Teil B — Validation (fachlich)
+
+Testvorhaben aus Roadmap durchspielen, Lücken dokumentieren, AP1.0-Prioritäten ableiten.
+
+## Nicht-Scope
+
+- SteeringContext, Method Registry, Dependencies, Sprints
+- UI-Redesign
+- KI/MCP/Workflow
+
+---
+
+## Abnahme Teil A
+
+- `GET /api/initiatives/{id}/steering-snapshot` liefert Graph + operating_phase
+- Blocker resolved → verknüpfte Maßnahme entblockt (wenn keine weiteren offenen Blocker)
+- Review completed → Maßnahme in review_required → done
+- UI zeigt Phase + Maßnahmen-Kontext (Blocker/Evidence)
+
+---
+
+*Freigegeben — Integration vor Validation.*
diff --git a/frontend/package.json b/frontend/package.json
index 8a8075d..c47ccd0 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,6 +1,6 @@
{
"name": "kairo-jinkendo-frontend",
- "version": "0.9.0-ap0.9",
+ "version": "0.10.0-ap0.10",
"private": true,
"type": "module",
"scripts": {
diff --git a/frontend/src/api/initiatives.js b/frontend/src/api/initiatives.js
index f5e79fb..dea76de 100644
--- a/frontend/src/api/initiatives.js
+++ b/frontend/src/api/initiatives.js
@@ -8,6 +8,10 @@ export function getInitiative(id) {
return apiFetch(`/api/initiatives/${id}`)
}
+export function getInitiativeSteeringSnapshot(id) {
+ return apiFetch(`/api/initiatives/${id}/steering-snapshot`)
+}
+
export function createInitiative(payload) {
return apiFetch('/api/initiatives', {
method: 'POST',
diff --git a/frontend/src/components/SteeringSnapshotPanel.jsx b/frontend/src/components/SteeringSnapshotPanel.jsx
new file mode 100644
index 0000000..7119172
--- /dev/null
+++ b/frontend/src/components/SteeringSnapshotPanel.jsx
@@ -0,0 +1,51 @@
+import { PHASE_LABELS } from '../constants/operating.js'
+
+export function SteeringSnapshotPanel({ snapshot }) {
+ if (!snapshot) return null
+
+ const { operating_phase, phase_signals, counts } = snapshot
+ const phaseLabel = PHASE_LABELS[operating_phase] || operating_phase
+
+ return (
+
+ Signale: {phase_signals.join(' · ')}
+
+ Kanonischer Operating Cycle — noch heuristisch, später methodengeführt über Steering Core.
+ Steuerungszustand
+ {phaseLabel}
+
+
+
{error}
} + {capabilities.has('kairo.initiative.read') && ( ++ {ctx.open_blocker_count > 0 && `${ctx.open_blocker_count} Blocker · `} + {ctx.evidence?.length > 0 && `${ctx.evidence.length} Evidence · `} + {ctx.reviews?.length > 0 && `${ctx.reviews.length} Review(s)`} +
+ )}