AP0.10a: OM-Integration — Steering-Snapshot, Transitions, Verknüpfungen
Some checks failed
Deploy Development / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Failing after 1m10s
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 1s
Test Suite / compose-smoke (push) Has been skipped

Beantwortet die Lücke zwischen parallelen Tabellen und späterem Steering Core: Graph-Read-Model, minimale Flows, sichtbarer Steuerungszustand.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-07-05 15:32:33 +02:00
parent a025542545
commit e25ff86420
17 changed files with 1034 additions and 5 deletions

View File

@ -230,7 +230,17 @@ Operating Model Extension II (AP0.9, Schema `008`):
Capabilities gesamt nach AP0.9: **33**. 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` Tenant-Invarianten: `docs/architecture/Kairo_Tenant_Invariants_v0.1.md`
`docs/architecture/Kairo_Tenant_Invariants_v0.1.md` `docs/architecture/Kairo_Tenant_Invariants_v0.1.md`

View File

@ -20,6 +20,7 @@ AttentionKind = Literal[
"overdue_action", "overdue_action",
"review_due", "review_due",
"recurring_due", "recurring_due",
"action_review_required",
] ]
NextActionKind = Literal[ 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()] 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]]: def _overdue_actions(cur, ctx: TenantContext) -> list[dict[str, Any]]:
cur.execute( cur.execute(
""" """
@ -389,6 +425,7 @@ def get_attention_items(ctx: TenantContext) -> list[dict[str, Any]]:
items.extend(_stale_initiatives(cur, ctx)) items.extend(_stale_initiatives(cur, ctx))
items.extend(_milestones_at_risk(cur, ctx)) items.extend(_milestones_at_risk(cur, ctx))
items.extend(_overdue_actions(cur, ctx)) items.extend(_overdue_actions(cur, ctx))
items.extend(_actions_review_required(cur, ctx))
items.extend(_reviews_due(cur, ctx)) items.extend(_reviews_due(cur, ctx))
items.extend(_recurring_due(cur, ctx)) items.extend(_recurring_due(cur, ctx))

View File

@ -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",
}

View File

@ -6,6 +6,7 @@ from datetime import datetime
from typing import Literal, Optional from typing import Literal, Optional
from capabilities import require_capability from capabilities import require_capability
from data_layer import initiative_snapshot as dl_initiative_snapshot
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from services import actions as action_service from services import actions as action_service
@ -154,6 +155,19 @@ def get_initiative(
return item 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}") @router.patch("/{initiative_id}")
def update_initiative( def update_initiative(
initiative_id: str, initiative_id: str,

View File

@ -264,6 +264,16 @@ def update_blocker(
"to_status": status, "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 return result

View File

@ -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

View File

@ -309,6 +309,16 @@ def update_review(
"to_status": status, "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 return result

View File

@ -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())

View File

@ -1,3 +1,3 @@
APP_VERSION = "0.9.0-ap0.9" APP_VERSION = "0.10.0-ap0.10"
DB_SCHEMA_VERSION = "008" DB_SCHEMA_VERSION = "008"
APP_NAME = "jinkendo-kairo" APP_NAME = "jinkendo-kairo"

View File

@ -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.10.7)
→ Operating Model Entitäten (AP0.80.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 19)** | 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 57 der Evolutionslinie (`Kairo_System_Target_State_v0.1.md` §24), **nicht** Schritt 24 (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*

View File

@ -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.*

View File

@ -1,6 +1,6 @@
{ {
"name": "kairo-jinkendo-frontend", "name": "kairo-jinkendo-frontend",
"version": "0.9.0-ap0.9", "version": "0.10.0-ap0.10",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@ -8,6 +8,10 @@ export function getInitiative(id) {
return apiFetch(`/api/initiatives/${id}`) return apiFetch(`/api/initiatives/${id}`)
} }
export function getInitiativeSteeringSnapshot(id) {
return apiFetch(`/api/initiatives/${id}/steering-snapshot`)
}
export function createInitiative(payload) { export function createInitiative(payload) {
return apiFetch('/api/initiatives', { return apiFetch('/api/initiatives', {
method: 'POST', method: 'POST',

View File

@ -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 (
<section className="card steering-snapshot">
<div className="section-header">
<h2>Steuerungszustand</h2>
<span className="badge status-badge status-active">{phaseLabel}</span>
</div>
{phase_signals?.length > 0 && (
<p className="muted snapshot-signals">
Signale: {phase_signals.join(' · ')}
</p>
)}
<dl className="snapshot-counts">
<div>
<dt>Offene Maßnahmen</dt>
<dd>{counts.actions_open}</dd>
</div>
<div>
<dt>Blockiert</dt>
<dd>{counts.actions_blocked}</dd>
</div>
<div>
<dt>Review nötig</dt>
<dd>{counts.actions_review_required}</dd>
</div>
<div>
<dt>Offene Blocker</dt>
<dd>{counts.blockers_open}</dd>
</div>
{(counts.unlinked_blockers > 0 || counts.unlinked_evidence > 0) && (
<div className="snapshot-warn">
<dt>Unverknüpft</dt>
<dd>
{counts.unlinked_blockers} Blocker, {counts.unlinked_evidence} Evidence
</dd>
</div>
)}
</dl>
<p className="muted snapshot-hint">
Kanonischer Operating Cycle noch heuristisch, später methodengeführt über Steering Core.
</p>
</section>
)
}

View File

@ -0,0 +1,23 @@
export const OPERATING_PHASES = [
'capture',
'triage',
'structure',
'commit',
'execute',
'verify',
'review',
'adapt',
'closure',
]
export const PHASE_LABELS = {
capture: 'Erfassung',
triage: 'Einordnung',
structure: 'Struktur',
commit: 'Commit',
execute: 'Umsetzung',
verify: 'Nachweis',
review: 'Review',
adapt: 'Anpassung',
closure: 'Abschluss',
}

View File

@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react'
import { Link, useParams } from 'react-router-dom' import { Link, useParams } from 'react-router-dom'
import { import {
getInitiative, getInitiative,
getInitiativeSteeringSnapshot,
listInitiativeActions, listInitiativeActions,
createInitiativeAction, createInitiativeAction,
} from '../api/initiatives.js' } from '../api/initiatives.js'
@ -60,6 +61,7 @@ import { EvidenceSection } from '../components/EvidenceSection.jsx'
import { DecisionsSection } from '../components/DecisionsSection.jsx' import { DecisionsSection } from '../components/DecisionsSection.jsx'
import { ReviewsSection } from '../components/ReviewsSection.jsx' import { ReviewsSection } from '../components/ReviewsSection.jsx'
import { RecurringSection } from '../components/RecurringSection.jsx' import { RecurringSection } from '../components/RecurringSection.jsx'
import { SteeringSnapshotPanel } from '../components/SteeringSnapshotPanel.jsx'
import { EmptyState } from '../components/EmptyState.jsx' import { EmptyState } from '../components/EmptyState.jsx'
import { ErrorState } from '../components/ErrorState.jsx' import { ErrorState } from '../components/ErrorState.jsx'
import { LoadingState } from '../components/LoadingState.jsx' import { LoadingState } from '../components/LoadingState.jsx'
@ -88,6 +90,7 @@ export function InitiativeDetailPage() {
const [decisions, setDecisions] = useState([]) const [decisions, setDecisions] = useState([])
const [reviews, setReviews] = useState([]) const [reviews, setReviews] = useState([])
const [recurringItems, setRecurringItems] = useState([]) const [recurringItems, setRecurringItems] = useState([])
const [steeringSnapshot, setSteeringSnapshot] = useState(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState(null) const [error, setError] = useState(null)
const [showActionForm, setShowActionForm] = useState(false) const [showActionForm, setShowActionForm] = useState(false)
@ -119,6 +122,11 @@ export function InitiativeDetailPage() {
loads.push(listInitiativeDecisions(id).then(setDecisions).catch(() => setDecisions([]))) loads.push(listInitiativeDecisions(id).then(setDecisions).catch(() => setDecisions([])))
loads.push(listInitiativeReviews(id).then(setReviews).catch(() => setReviews([]))) loads.push(listInitiativeReviews(id).then(setReviews).catch(() => setReviews([])))
loads.push(listInitiativeRecurring(id).then(setRecurringItems).catch(() => setRecurringItems([]))) loads.push(listInitiativeRecurring(id).then(setRecurringItems).catch(() => setRecurringItems([])))
loads.push(
getInitiativeSteeringSnapshot(id)
.then(setSteeringSnapshot)
.catch(() => setSteeringSnapshot(null))
)
} }
await Promise.all(loads) await Promise.all(loads)
} catch (err) { } catch (err) {
@ -136,6 +144,10 @@ export function InitiativeDetailPage() {
? actions.filter((a) => a.status !== 'done' && a.status !== 'discarded') ? actions.filter((a) => a.status !== 'done' && a.status !== 'discarded')
: actions : actions
const actionContextById = Object.fromEntries(
(steeringSnapshot?.actions || []).map((a) => [a.id, a])
)
async function handleCreateAction(payload) { async function handleCreateAction(payload) {
setFormBusy(true) setFormBusy(true)
try { try {
@ -200,6 +212,23 @@ export function InitiativeDetailPage() {
} }
} }
async function handleCreateBlockerForAction(actionId) {
if (!capabilities.has('kairo.blocker.manage')) return
setFormBusy(true)
try {
await createInitiativeBlocker(id, {
title: 'Blocker',
action_id: actionId,
set_action_blocked: true,
})
await load()
} catch (err) {
setError(err.message)
} finally {
setFormBusy(false)
}
}
async function handleBlockerStatus(blockerId, status) { async function handleBlockerStatus(blockerId, status) {
try { try {
await updateBlocker(blockerId, { status }) await updateBlocker(blockerId, { status })
@ -453,6 +482,10 @@ export function InitiativeDetailPage() {
{error && <p className="error">{error}</p>} {error && <p className="error">{error}</p>}
{capabilities.has('kairo.initiative.read') && (
<SteeringSnapshotPanel snapshot={steeringSnapshot} />
)}
<section className="card"> <section className="card">
<div className="section-header"> <div className="section-header">
<h2>Maßnahmen</h2> <h2>Maßnahmen</h2>
@ -498,7 +531,9 @@ export function InitiativeDetailPage() {
)} )}
<ul className="item-list actions-detail-list"> <ul className="item-list actions-detail-list">
{visibleActions.map((action) => ( {visibleActions.map((action) => {
const ctx = actionContextById[action.id]
return (
<li key={action.id} className="list-item card-list-item"> <li key={action.id} className="list-item card-list-item">
{editingAction === action.id ? ( {editingAction === action.id ? (
<ActionForm <ActionForm
@ -530,6 +565,13 @@ export function InitiativeDetailPage() {
Fällig: {new Date(action.due_at).toLocaleString('de-DE')} Fällig: {new Date(action.due_at).toLocaleString('de-DE')}
</p> </p>
)} )}
{ctx && (ctx.open_blocker_count > 0 || ctx.evidence?.length > 0 || ctx.reviews?.length > 0) && (
<p className="muted list-item-sub action-links">
{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)`}
</p>
)}
</div> </div>
<div className="list-item-meta action-controls"> <div className="list-item-meta action-controls">
<StatusBadge status={action.status} /> <StatusBadge status={action.status} />
@ -548,6 +590,16 @@ export function InitiativeDetailPage() {
</option> </option>
))} ))}
</select> </select>
{capabilities.has('kairo.blocker.manage') && action.status !== 'done' && (
<button
type="button"
className="btn btn-secondary"
onClick={() => handleCreateBlockerForAction(action.id)}
disabled={formBusy}
>
Blocker
</button>
)}
<button <button
type="button" type="button"
className="btn btn-secondary" className="btn btn-secondary"
@ -564,7 +616,8 @@ export function InitiativeDetailPage() {
</> </>
)} )}
</li> </li>
))} )
})}
</ul> </ul>
</section> </section>

View File

@ -597,3 +597,38 @@
font-size: 9px; font-size: 9px;
} }
} }
.steering-snapshot .snapshot-counts {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(8rem, 1fr));
gap: 0.75rem 1rem;
margin: 0.75rem 0 0;
}
.steering-snapshot .snapshot-counts div {
margin: 0;
}
.steering-snapshot .snapshot-counts dt {
font-size: 0.75rem;
color: var(--jk-muted, #666);
margin: 0;
}
.steering-snapshot .snapshot-counts dd {
margin: 0.15rem 0 0;
font-weight: 600;
}
.steering-snapshot .snapshot-warn dd {
color: var(--jk-warning, #b45309);
}
.steering-snapshot .snapshot-hint {
font-size: 0.8rem;
margin: 0.75rem 0 0;
}
.action-links {
font-size: 0.85rem;
}