diff --git a/.cursor/rules/kairo-deployment-testing.mdc b/.cursor/rules/kairo-deployment-testing.mdc
new file mode 100644
index 0000000..bdb92e3
--- /dev/null
+++ b/.cursor/rules/kairo-deployment-testing.mdc
@@ -0,0 +1,27 @@
+---
+description: Deployment und Tests — kein lokaler Lauf; Verifikation über develop auf Raspberry
+alwaysApply: true
+---
+
+# Kairo — Deployment & Tests
+
+## Kein lokaler Test-/DB-Lauf
+
+Backend-Tests (pytest), Migrationen und Integrationstests **können auf der Entwicklungsmaschine in der Regel nicht ausgeführt werden** — PostgreSQL/Docker ist lokal oft nicht verfügbar.
+
+Versuche nicht wiederholt, lokale pytest-/Migration-Läufe als Abnahme zu erzwingen, wenn die DB nicht erreichbar ist.
+
+## Verifikation über Remote-Deploy
+
+Praktische Verifikation erfolgt durch **Deployment auf ein entferntes System (Raspberry Pi)**:
+
+1. Änderungen **committen** und auf Branch **`develop`** pushen
+2. Deployment wird dadurch ausgelöst (CI/Deploy-Pipeline auf dem Raspberry)
+3. Dort laufen Migrationen, Backend-Tests und Smoke-Checks gegen echte Infrastruktur
+
+## Konsequenz für Agenten
+
+- Code und Tests **schreiben** und dokumentieren — aber fehlende lokale DB nicht als Implementierungsblocker behandeln
+- Frontend: `npm test` / `vite build` lokal weiterhin sinnvoll, wenn Node verfügbar
+- Backend: nach AP-Umsetzung Abschlussbericht mit „Remote-Verifikation nach Push auf develop“ kennzeichnen
+- **Nicht** proaktiv committen/pushen, es sei denn, der Nutzer bittet explizit darum
diff --git a/README.md b/README.md
index bbed8e1..23b389d 100644
--- a/README.md
+++ b/README.md
@@ -189,7 +189,32 @@ Actor Directory und Workspace Data Layer (AP0.7):
| `/api/workspace/initiatives/active` | GET | `kairo.workspace.read` | Aktive/pausierte Vorhaben (`?limit=`) |
| `/api/workspace/actors/workload` | GET | `kairo.workspace.read` | Actor-Workload (Basic) |
+Operating Model Extension I (AP0.8, Schema `007`):
+
+| Endpoint | Methode | Capability | Beschreibung |
+|----------|---------|------------|--------------|
+| `/api/workspace/attention` | GET | `kairo.attention.read` | Regelbasierte Attention-Items |
+| `/api/workspace/next-actions` | GET | `kairo.attention.read` | NextActionCandidates (`?limit=`) |
+| `/api/initiatives/{id}/blockers` | GET/POST | `kairo.blocker.read/manage` | Blocker eines Vorhabens |
+| `/api/blockers/{id}` | GET/PATCH/DELETE | `kairo.blocker.read/manage` | Einzelner Blocker |
+| `/api/initiatives/{id}/backlog` | GET/POST | `kairo.backlog.read/manage` | Backlog-Items |
+| `/api/backlog/{id}` | GET/PATCH/DELETE | `kairo.backlog.read/manage` | Einzelnes Backlog-Item |
+| `/api/backlog/{id}/convert-to-action` | POST | `kairo.backlog.manage` | Backlog → Maßnahme |
+| `/api/initiatives/{id}/milestones` | GET/POST | `kairo.milestone.read/manage` | Meilensteine |
+| `/api/milestones/{id}` | GET/PATCH/DELETE | `kairo.milestone.read/manage` | Einzelner Meilenstein |
+
+**Status Blocker:** `open`, `in_progress`, `resolved`, `accepted_risk`, `dismissed`
+
+**Status Backlog:** `new`, `triaged`, `accepted`, `rejected`, `converted`
+
+**Status Meilenstein:** `planned`, `active`, `at_risk`, `reached`, `moved`, `discarded`
+
+Capabilities gesamt nach AP0.8: **25** (inkl. `kairo.attention.read` und Blocker/Backlog/Milestone read/manage).
+
+Zielarchitektur-Einordnung: AP0.8 ist Schritt 5 der Evolutionslinie (Signal/NextAction) und Operating-Model-Phase A — siehe `docs/architecture/Kairo_System_Target_State_v0.1.md` und `Sprint0_AP0_8_Assignment_v0.2.md`.
+
Tenant-Invarianten: `docs/architecture/Kairo_Tenant_Invariants_v0.1.md`
+ `docs/architecture/Kairo_Tenant_Invariants_v0.1.md`
**Status Vorhaben:** `active`, `paused`, `completed`, `archived`
@@ -218,9 +243,9 @@ Nach Login leitet Kairo auf den **Workspace** weiter.
| Route | Beschreibung |
|-------|--------------|
-| `/workspace` | Karten: Kontext, Überblick, offene/blockierte Maßnahmen, aktive Vorhaben |
+| `/workspace` | Karten: Kontext, Überblick, **Aufmerksamkeit**, offene/blockierte Maßnahmen, aktive Vorhaben |
| `/initiatives` | Vorhabenliste mit offenen Maßnahmen-Zähler |
-| `/initiatives/:id` | Vorhaben-Detail mit Maßnahmen CRUD, Status, Zuweisung |
+| `/initiatives/:id` | Vorhaben-Detail: Maßnahmen, **Meilensteine**, **Backlog**, **Blocker** |
| `/my-actions` | Alle offenen Maßnahmen des aktuellen Actors |
**App Shell (AP0.6b):** Desktop-Sidebar (≥1024px), Mobile-Header + Bottom-Navigation (<1024px), Tenant-/Actor-Kontext, Jinkendo-Family-Design-Tokens (`--jk-*`).
@@ -246,8 +271,9 @@ cd frontend && npm install && npm run test && npm run build
5. Responsive: DevTools Viewports **1440px**, **1024px**, **390px** — keine horizontale Scrollbar, Bottom-Nav sichtbar unter 1024px
6. PWA: Manifest unter `/manifest.webmanifest` erreichbar; „App installieren“ im Browser prüfbar
7. AP0.7: ActorSelect lädt `/api/actors`; Maßnahme einem Agent zuweisen; Workspace-Überblick sichtbar
+8. AP0.8: Attention-Widget auf Workspace; Vorhaben-Detail mit Blocker/Backlog/Meilenstein; Backlog → Maßnahme konvertieren
-Abschlussberichte: `docs/sprints/Sprint0_AP0_6_Completion_Report_v0.1.md`, `docs/sprints/Sprint0_AP0_6b_Completion_Report_v0.2.md`, `docs/sprints/Sprint0_AP0_7_Completion_Report_v0.2.md`
+Abschlussberichte: `docs/sprints/Sprint0_AP0_6_Completion_Report_v0.1.md`, `docs/sprints/Sprint0_AP0_6b_Completion_Report_v0.2.md`, `docs/sprints/Sprint0_AP0_7_Completion_Report_v0.2.md`, `docs/sprints/Sprint0_AP0_8_Completion_Report_v0.1.md`
### Registries (AP0.4)
diff --git a/backend/data_layer/attention.py b/backend/data_layer/attention.py
new file mode 100644
index 0000000..c87d8fb
--- /dev/null
+++ b/backend/data_layer/attention.py
@@ -0,0 +1,418 @@
+"""Attention and NextAction read-models — regelbasiert, tenant-scoped (AP0.8a)."""
+
+from __future__ import annotations
+
+from typing import Any, Literal, Optional
+
+from psycopg2.extras import RealDictCursor
+
+from db import get_connection
+from tenant_context import TenantContext
+
+AttentionKind = Literal[
+ "blocked_action",
+ "open_blocker",
+ "high_priority_action",
+ "unassigned_action",
+ "initiative_without_next_action",
+ "stale_initiative",
+ "milestone_at_risk",
+]
+
+NextActionKind = Literal[
+ "assign_action",
+ "resolve_blocker",
+ "create_action",
+ "convert_backlog",
+ "review_milestone",
+]
+
+Severity = Literal["info", "warning", "critical"]
+
+_SEVERITY_ORDER = {"critical": 0, "warning": 1, "info": 2}
+
+OPEN_BLOCKER_STATUSES = ("open", "in_progress")
+OPEN_ACTION_STATUSES = ("open", "in_progress", "blocked")
+ACTIVE_INITIATIVE_STATUSES = ("active", "paused")
+
+
+def _serialize_attention(row: dict[str, Any]) -> dict[str, Any]:
+ item = dict(row)
+ for key in ("scope_id", "initiative_id", "action_id", "blocker_id", "milestone_id"):
+ if item.get(key):
+ item[key] = str(item[key])
+ return item
+
+
+def _blocked_actions(cur, ctx: TenantContext) -> list[dict[str, Any]]:
+ cur.execute(
+ """
+ SELECT
+ 'blocked_action' AS kind,
+ 'critical' AS severity,
+ a.title AS title,
+ 'Maßnahme ist blockiert' 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,
+ 'action_blocked' AS reason_code,
+ 'actions' AS data_source
+ FROM actions a
+ WHERE a.tenant_id = %s AND a.status = 'blocked'
+ ORDER BY a.updated_at DESC
+ LIMIT 50
+ """,
+ (ctx.tenant_id,),
+ )
+ return [_serialize_attention(dict(r)) for r in cur.fetchall()]
+
+
+def _open_blockers(cur, ctx: TenantContext) -> list[dict[str, Any]]:
+ cur.execute(
+ """
+ SELECT
+ 'open_blocker' AS kind,
+ 'warning' AS severity,
+ b.title AS title,
+ 'Offener Blocker im Vorhaben' AS summary,
+ 'blocker' AS scope_type,
+ b.id AS scope_id,
+ b.initiative_id,
+ b.action_id,
+ b.id AS blocker_id,
+ NULL::uuid AS milestone_id,
+ 'blocker_open' AS reason_code,
+ 'blockers' AS data_source
+ FROM blockers b
+ WHERE b.tenant_id = %s AND b.status IN ('open', 'in_progress')
+ ORDER BY b.updated_at DESC
+ LIMIT 50
+ """,
+ (ctx.tenant_id,),
+ )
+ items = []
+ for row in cur.fetchall():
+ item = _serialize_attention(dict(row))
+ if item.get("action_id"):
+ item["action_id"] = str(item["action_id"])
+ items.append(item)
+ return items
+
+
+def _high_priority_actions(cur, ctx: TenantContext) -> list[dict[str, Any]]:
+ actor_filter = ""
+ params: list[Any] = [ctx.tenant_id]
+ if ctx.actor_id:
+ actor_filter = """
+ AND EXISTS (
+ SELECT 1 FROM action_assignments aa
+ WHERE aa.action_id = a.id
+ AND aa.tenant_id = a.tenant_id
+ AND aa.actor_id = %s
+ )
+ """
+ params.append(ctx.actor_id)
+ else:
+ return []
+
+ cur.execute(
+ f"""
+ SELECT
+ 'high_priority_action' AS kind,
+ 'warning' AS severity,
+ a.title AS title,
+ 'High-Priority Maßnahme offen' 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,
+ 'action_high_priority_open' AS reason_code,
+ 'actions' AS data_source
+ FROM actions a
+ WHERE a.tenant_id = %s
+ AND a.priority = 'high'
+ AND a.status IN ('open', 'in_progress')
+ {actor_filter}
+ ORDER BY a.updated_at DESC
+ LIMIT 30
+ """,
+ tuple(params),
+ )
+ return [_serialize_attention(dict(r)) for r in cur.fetchall()]
+
+
+def _unassigned_actions(cur, ctx: TenantContext) -> list[dict[str, Any]]:
+ cur.execute(
+ """
+ SELECT
+ 'unassigned_action' AS kind,
+ 'warning' AS severity,
+ a.title AS title,
+ 'Maßnahme ohne Zuweisung' 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,
+ 'action_unassigned' AS reason_code,
+ 'actions' AS data_source
+ FROM actions a
+ WHERE a.tenant_id = %s
+ AND a.status IN ('open', 'in_progress')
+ AND NOT EXISTS (
+ SELECT 1 FROM action_assignments aa
+ WHERE aa.action_id = a.id AND aa.tenant_id = a.tenant_id
+ )
+ ORDER BY a.updated_at DESC
+ LIMIT 30
+ """,
+ (ctx.tenant_id,),
+ )
+ return [_serialize_attention(dict(r)) for r in cur.fetchall()]
+
+
+def _initiatives_without_next_action(cur, ctx: TenantContext) -> list[dict[str, Any]]:
+ cur.execute(
+ """
+ SELECT
+ 'initiative_without_next_action' AS kind,
+ 'info' AS severity,
+ i.title AS title,
+ 'Keine offene nächste Maßnahme' AS summary,
+ 'initiative' AS scope_type,
+ i.id AS scope_id,
+ i.id AS initiative_id,
+ NULL::uuid AS action_id,
+ NULL::uuid AS blocker_id,
+ NULL::uuid AS milestone_id,
+ 'initiative_no_open_action' AS reason_code,
+ 'initiatives' AS data_source
+ FROM initiatives i
+ WHERE i.tenant_id = %s
+ AND i.status IN ('active', 'paused')
+ AND NOT EXISTS (
+ SELECT 1 FROM actions a
+ WHERE a.initiative_id = i.id
+ AND a.tenant_id = i.tenant_id
+ AND a.status IN ('open', 'in_progress', 'blocked')
+ )
+ ORDER BY i.updated_at DESC
+ LIMIT 30
+ """,
+ (ctx.tenant_id,),
+ )
+ return [_serialize_attention(dict(r)) for r in cur.fetchall()]
+
+
+def _stale_initiatives(cur, ctx: TenantContext) -> list[dict[str, Any]]:
+ cur.execute(
+ """
+ SELECT
+ 'stale_initiative' AS kind,
+ 'info' AS severity,
+ i.title AS title,
+ 'Vorhaben seit über 14 Tagen unverändert' AS summary,
+ 'initiative' AS scope_type,
+ i.id AS scope_id,
+ i.id AS initiative_id,
+ NULL::uuid AS action_id,
+ NULL::uuid AS blocker_id,
+ NULL::uuid AS milestone_id,
+ 'initiative_stale' AS reason_code,
+ 'initiatives' AS data_source
+ FROM initiatives i
+ WHERE i.tenant_id = %s
+ AND i.status = 'active'
+ AND i.updated_at < NOW() - INTERVAL '14 days'
+ ORDER BY i.updated_at ASC
+ LIMIT 20
+ """,
+ (ctx.tenant_id,),
+ )
+ return [_serialize_attention(dict(r)) for r in cur.fetchall()]
+
+
+def _milestones_at_risk(cur, ctx: TenantContext) -> list[dict[str, Any]]:
+ cur.execute(
+ """
+ SELECT
+ 'milestone_at_risk' AS kind,
+ 'warning' AS severity,
+ m.title AS title,
+ 'Meilenstein als gefährdet markiert' AS summary,
+ 'milestone' AS scope_type,
+ m.id AS scope_id,
+ m.initiative_id,
+ NULL::uuid AS action_id,
+ NULL::uuid AS blocker_id,
+ m.id AS milestone_id,
+ 'milestone_at_risk' AS reason_code,
+ 'milestones' AS data_source
+ FROM milestones m
+ WHERE m.tenant_id = %s AND m.status = 'at_risk'
+ ORDER BY m.updated_at DESC
+ LIMIT 20
+ """,
+ (ctx.tenant_id,),
+ )
+ return [_serialize_attention(dict(r)) for r in cur.fetchall()]
+
+
+def get_attention_items(ctx: TenantContext) -> list[dict[str, Any]]:
+ """Regelbasierte Attention Items — tenant-scoped, erklärbar."""
+ conn = get_connection()
+ try:
+ with conn.cursor(cursor_factory=RealDictCursor) as cur:
+ items: list[dict[str, Any]] = []
+ items.extend(_blocked_actions(cur, ctx))
+ items.extend(_open_blockers(cur, ctx))
+ items.extend(_high_priority_actions(cur, ctx))
+ items.extend(_unassigned_actions(cur, ctx))
+ items.extend(_initiatives_without_next_action(cur, ctx))
+ items.extend(_stale_initiatives(cur, ctx))
+ items.extend(_milestones_at_risk(cur, ctx))
+
+ items.sort(key=lambda x: _SEVERITY_ORDER.get(x["severity"], 99))
+ return items
+ finally:
+ conn.close()
+
+
+def get_next_action_candidates(
+ ctx: TenantContext, *, limit: int = 10
+) -> list[dict[str, Any]]:
+ """Regelbasierte NextActionCandidates — limitiert, tenant-scoped."""
+ if limit < 1:
+ limit = 1
+ if limit > 50:
+ limit = 50
+
+ conn = get_connection()
+ candidates: list[dict[str, Any]] = []
+ try:
+ with conn.cursor(cursor_factory=RealDictCursor) as cur:
+ cur.execute(
+ """
+ SELECT
+ 'resolve_blocker' AS kind,
+ b.title AS title,
+ 'Blocker klären oder Status aktualisieren' AS summary,
+ b.initiative_id,
+ b.action_id,
+ NULL::uuid AS backlog_item_id,
+ 'blocker_open' AS reason_code,
+ 'Blocker bearbeiten' AS recommended_action
+ FROM blockers b
+ WHERE b.tenant_id = %s AND b.status IN ('open', 'in_progress')
+ ORDER BY b.updated_at DESC
+ LIMIT %s
+ """,
+ (ctx.tenant_id, limit),
+ )
+ for row in cur.fetchall():
+ item = dict(row)
+ item["initiative_id"] = str(item["initiative_id"])
+ if item.get("action_id"):
+ item["action_id"] = str(item["action_id"])
+ candidates.append(item)
+
+ remaining = limit - len(candidates)
+ if remaining > 0:
+ cur.execute(
+ """
+ SELECT
+ 'assign_action' AS kind,
+ a.title AS title,
+ 'Actor zuweisen' AS summary,
+ a.initiative_id,
+ a.id AS action_id,
+ NULL::uuid AS backlog_item_id,
+ 'action_unassigned' AS reason_code,
+ 'Maßnahme zuweisen' AS recommended_action
+ FROM actions a
+ WHERE a.tenant_id = %s
+ AND a.status IN ('open', 'in_progress')
+ AND NOT EXISTS (
+ SELECT 1 FROM action_assignments aa
+ WHERE aa.action_id = a.id AND aa.tenant_id = a.tenant_id
+ )
+ ORDER BY a.updated_at DESC
+ LIMIT %s
+ """,
+ (ctx.tenant_id, remaining),
+ )
+ for row in cur.fetchall():
+ item = dict(row)
+ item["initiative_id"] = str(item["initiative_id"])
+ item["action_id"] = str(item["action_id"])
+ candidates.append(item)
+
+ remaining = limit - len(candidates)
+ if remaining > 0:
+ cur.execute(
+ """
+ SELECT
+ 'convert_backlog' AS kind,
+ bi.title AS title,
+ 'Freigegebenes Backlog-Item in Maßnahme umwandeln' AS summary,
+ bi.initiative_id,
+ NULL::uuid AS action_id,
+ bi.id AS backlog_item_id,
+ 'backlog_accepted_not_converted' AS reason_code,
+ 'In Maßnahme umwandeln' AS recommended_action
+ FROM backlog_items bi
+ WHERE bi.tenant_id = %s
+ AND bi.status = 'accepted'
+ AND bi.converted_action_id IS NULL
+ ORDER BY bi.updated_at DESC
+ LIMIT %s
+ """,
+ (ctx.tenant_id, remaining),
+ )
+ for row in cur.fetchall():
+ item = dict(row)
+ item["initiative_id"] = str(item["initiative_id"])
+ item["backlog_item_id"] = str(item["backlog_item_id"])
+ candidates.append(item)
+
+ remaining = limit - len(candidates)
+ if remaining > 0:
+ cur.execute(
+ """
+ SELECT
+ 'create_action' AS kind,
+ i.title AS title,
+ 'Nächste Maßnahme für Vorhaben anlegen' AS summary,
+ i.id AS initiative_id,
+ NULL::uuid AS action_id,
+ NULL::uuid AS backlog_item_id,
+ 'initiative_no_open_action' AS reason_code,
+ 'Maßnahme anlegen' AS recommended_action
+ FROM initiatives i
+ WHERE i.tenant_id = %s
+ AND i.status IN ('active', 'paused')
+ AND NOT EXISTS (
+ SELECT 1 FROM actions a
+ WHERE a.initiative_id = i.id
+ AND a.tenant_id = i.tenant_id
+ AND a.status IN ('open', 'in_progress', 'blocked')
+ )
+ ORDER BY i.updated_at DESC
+ LIMIT %s
+ """,
+ (ctx.tenant_id, remaining),
+ )
+ for row in cur.fetchall():
+ item = dict(row)
+ item["initiative_id"] = str(item["initiative_id"])
+ candidates.append(item)
+
+ return candidates[:limit]
+ finally:
+ conn.close()
diff --git a/backend/main.py b/backend/main.py
index 9d2f036..8bc4db1 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -53,7 +53,20 @@ app.add_middleware(
allow_headers=["*"],
)
-from routers import actions, actors, auth, config, features, initiatives, me, prompts, workspace # noqa: E402
+from routers import ( # noqa: E402
+ actions,
+ actors,
+ auth,
+ backlog,
+ blockers,
+ config,
+ features,
+ initiatives,
+ me,
+ milestones,
+ prompts,
+ workspace,
+)
app.include_router(auth.router)
app.include_router(me.router)
@@ -62,6 +75,9 @@ app.include_router(prompts.router)
app.include_router(config.router)
app.include_router(initiatives.router)
app.include_router(actions.router)
+app.include_router(blockers.router)
+app.include_router(backlog.router)
+app.include_router(milestones.router)
app.include_router(actors.router)
app.include_router(workspace.router)
diff --git a/backend/migrations/007_operating_model_extension_i.sql b/backend/migrations/007_operating_model_extension_i.sql
new file mode 100644
index 0000000..a1f9ad7
--- /dev/null
+++ b/backend/migrations/007_operating_model_extension_i.sql
@@ -0,0 +1,52 @@
+-- AP0.8: Operating Model Extension I — blockers, backlog_items, milestones
+
+CREATE TABLE blockers (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
+ initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE,
+ action_id UUID NULL REFERENCES actions(id) ON DELETE SET NULL,
+ title VARCHAR(255) NOT NULL,
+ description TEXT NOT NULL DEFAULT '',
+ status VARCHAR(32) NOT NULL DEFAULT 'open'
+ CHECK (status IN ('open', 'in_progress', 'resolved', 'accepted_risk', 'dismissed')),
+ reported_by_actor_id UUID NULL REFERENCES actors(id) ON DELETE SET NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX idx_blockers_tenant_initiative ON blockers(tenant_id, initiative_id);
+CREATE INDEX idx_blockers_tenant_status ON blockers(tenant_id, status);
+
+CREATE TABLE backlog_items (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
+ initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE,
+ title VARCHAR(255) NOT NULL,
+ description TEXT NOT NULL DEFAULT '',
+ status VARCHAR(32) NOT NULL DEFAULT 'new'
+ CHECK (status IN ('new', 'triaged', 'accepted', 'rejected', 'converted')),
+ priority VARCHAR(16) NOT NULL DEFAULT 'normal'
+ CHECK (priority IN ('low', 'normal', 'high')),
+ converted_action_id UUID NULL REFERENCES actions(id) ON DELETE SET NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX idx_backlog_items_tenant_initiative ON backlog_items(tenant_id, initiative_id);
+CREATE INDEX idx_backlog_items_tenant_status ON backlog_items(tenant_id, status);
+
+CREATE TABLE milestones (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
+ initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE,
+ title VARCHAR(255) NOT NULL,
+ goal_description TEXT NOT NULL DEFAULT '',
+ status VARCHAR(32) NOT NULL DEFAULT 'planned'
+ CHECK (status IN ('planned', 'active', 'at_risk', 'reached', 'moved', 'discarded')),
+ target_date DATE NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX idx_milestones_tenant_initiative ON milestones(tenant_id, initiative_id);
+CREATE INDEX idx_milestones_tenant_status ON milestones(tenant_id, status);
diff --git a/backend/rights_registrations/__init__.py b/backend/rights_registrations/__init__.py
index a8aca60..540dfa9 100644
--- a/backend/rights_registrations/__init__.py
+++ b/backend/rights_registrations/__init__.py
@@ -1,5 +1,19 @@
"""Import all module registrations — side effect registers capabilities."""
-from . import initiative_ops, platform, registry_ops, tenant_ops, workspace_ops # noqa: F401
+from . import ( # noqa: F401
+ initiative_ops,
+ operating_model_ops,
+ platform,
+ registry_ops,
+ tenant_ops,
+ workspace_ops,
+)
-__all__ = ["initiative_ops", "platform", "registry_ops", "tenant_ops", "workspace_ops"]
+__all__ = [
+ "initiative_ops",
+ "operating_model_ops",
+ "platform",
+ "registry_ops",
+ "tenant_ops",
+ "workspace_ops",
+]
diff --git a/backend/rights_registrations/operating_model_ops.py b/backend/rights_registrations/operating_model_ops.py
new file mode 100644
index 0000000..cffe738
--- /dev/null
+++ b/backend/rights_registrations/operating_model_ops.py
@@ -0,0 +1,83 @@
+"""Operating model capabilities (AP0.8)."""
+
+from __future__ import annotations
+
+from rights_registry import CapabilityRegistration, register_capability
+
+_MEMBER_READ = (
+ ("portal", "admin"),
+ ("portal", "user"),
+ ("tenant", "owner"),
+ ("tenant", "admin"),
+ ("tenant", "member"),
+)
+
+_MEMBER_MANAGE = (
+ ("portal", "admin"),
+ ("tenant", "owner"),
+ ("tenant", "admin"),
+ ("tenant", "member"),
+)
+
+register_capability(
+ CapabilityRegistration(
+ key="kairo.attention.read",
+ module="attention",
+ description="Attention- und NextAction-Sichten lesen",
+ default_grants=_MEMBER_READ,
+ )
+)
+
+register_capability(
+ CapabilityRegistration(
+ key="kairo.blocker.read",
+ module="blocker",
+ description="Blocker im aktiven Tenant lesen",
+ default_grants=_MEMBER_READ,
+ )
+)
+
+register_capability(
+ CapabilityRegistration(
+ key="kairo.blocker.manage",
+ module="blocker",
+ description="Blocker anlegen und bearbeiten",
+ default_grants=_MEMBER_MANAGE,
+ )
+)
+
+register_capability(
+ CapabilityRegistration(
+ key="kairo.backlog.read",
+ module="backlog",
+ description="Backlog-Items im aktiven Tenant lesen",
+ default_grants=_MEMBER_READ,
+ )
+)
+
+register_capability(
+ CapabilityRegistration(
+ key="kairo.backlog.manage",
+ module="backlog",
+ description="Backlog-Items anlegen, bearbeiten und konvertieren",
+ default_grants=_MEMBER_MANAGE,
+ )
+)
+
+register_capability(
+ CapabilityRegistration(
+ key="kairo.milestone.read",
+ module="milestone",
+ description="Meilensteine im aktiven Tenant lesen",
+ default_grants=_MEMBER_READ,
+ )
+)
+
+register_capability(
+ CapabilityRegistration(
+ key="kairo.milestone.manage",
+ module="milestone",
+ description="Meilensteine anlegen und bearbeiten",
+ default_grants=_MEMBER_MANAGE,
+ )
+)
diff --git a/backend/routers/backlog.py b/backend/routers/backlog.py
new file mode 100644
index 0000000..da9da27
--- /dev/null
+++ b/backend/routers/backlog.py
@@ -0,0 +1,103 @@
+"""BacklogItem API — AP0.8c."""
+
+from __future__ import annotations
+
+from typing import Literal, Optional
+
+from capabilities import require_capability
+from fastapi import APIRouter, Depends, HTTPException
+from pydantic import BaseModel, Field
+from services import backlog as backlog_service
+from tenant_context import TenantContext
+
+router = APIRouter(prefix="/api/backlog", tags=["backlog"])
+
+
+class BacklogCreateRequest(BaseModel):
+ title: str = Field(min_length=1, max_length=255)
+ description: str = ""
+ status: Literal["new", "triaged", "accepted", "rejected"] = "new"
+ priority: Literal["low", "normal", "high"] = "normal"
+
+
+class BacklogUpdateRequest(BaseModel):
+ title: Optional[str] = Field(default=None, min_length=1, max_length=255)
+ description: Optional[str] = None
+ status: Optional[Literal["new", "triaged", "accepted", "rejected"]] = None
+ priority: Optional[Literal["low", "normal", "high"]] = None
+
+
+class BacklogConvertRequest(BaseModel):
+ assigned_actor_ids: list[str] = Field(default_factory=list)
+
+
+@router.get("/{backlog_item_id}")
+def get_backlog_item(
+ backlog_item_id: str,
+ ctx: TenantContext = Depends(require_capability("kairo.backlog.read")),
+):
+ item = backlog_service.get_backlog_item(
+ tenant_id=ctx.tenant_id, backlog_item_id=backlog_item_id
+ )
+ if not item:
+ raise HTTPException(status_code=404, detail="Backlog-Item nicht gefunden")
+ return item
+
+
+@router.patch("/{backlog_item_id}")
+def update_backlog_item(
+ backlog_item_id: str,
+ body: BacklogUpdateRequest,
+ ctx: TenantContext = Depends(require_capability("kairo.backlog.manage")),
+):
+ try:
+ item = backlog_service.update_backlog_item(
+ tenant_id=ctx.tenant_id,
+ backlog_item_id=backlog_item_id,
+ user_id=ctx.user_id,
+ title=body.title,
+ description=body.description,
+ status=body.status,
+ priority=body.priority,
+ )
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+ if not item:
+ raise HTTPException(status_code=404, detail="Backlog-Item nicht gefunden")
+ return item
+
+
+@router.delete("/{backlog_item_id}", status_code=204)
+def delete_backlog_item(
+ backlog_item_id: str,
+ ctx: TenantContext = Depends(require_capability("kairo.backlog.manage")),
+):
+ if not backlog_service.delete_backlog_item(
+ tenant_id=ctx.tenant_id,
+ backlog_item_id=backlog_item_id,
+ user_id=ctx.user_id,
+ ):
+ raise HTTPException(status_code=404, detail="Backlog-Item nicht gefunden")
+
+
+@router.post("/{backlog_item_id}/convert-to-action", status_code=201)
+def convert_backlog_to_action(
+ backlog_item_id: str,
+ body: BacklogConvertRequest,
+ ctx: TenantContext = Depends(require_capability("kairo.backlog.manage")),
+):
+ assigned = body.assigned_actor_ids
+ if not assigned and ctx.actor_id:
+ assigned = [ctx.actor_id]
+ try:
+ return backlog_service.convert_backlog_to_action(
+ tenant_id=ctx.tenant_id,
+ backlog_item_id=backlog_item_id,
+ user_id=ctx.user_id,
+ assigned_actor_ids=assigned,
+ )
+ except ValueError as exc:
+ detail = str(exc)
+ if detail == "Backlog-Item nicht gefunden":
+ raise HTTPException(status_code=404, detail=detail) from exc
+ raise HTTPException(status_code=400, detail=detail) from exc
diff --git a/backend/routers/blockers.py b/backend/routers/blockers.py
new file mode 100644
index 0000000..fae1409
--- /dev/null
+++ b/backend/routers/blockers.py
@@ -0,0 +1,77 @@
+"""Blocker API — AP0.8b."""
+
+from __future__ import annotations
+
+from typing import Literal, Optional
+
+from capabilities import require_capability
+from fastapi import APIRouter, Depends, HTTPException
+from pydantic import BaseModel, Field
+from services import blockers as blocker_service
+from tenant_context import TenantContext
+
+router = APIRouter(prefix="/api/blockers", tags=["blockers"])
+
+
+class BlockerCreateRequest(BaseModel):
+ title: str = Field(min_length=1, max_length=255)
+ description: str = ""
+ status: Literal["open", "in_progress", "resolved", "accepted_risk", "dismissed"] = "open"
+ action_id: Optional[str] = None
+ set_action_blocked: bool = False
+
+
+class BlockerUpdateRequest(BaseModel):
+ title: Optional[str] = Field(default=None, min_length=1, max_length=255)
+ description: Optional[str] = None
+ status: Optional[
+ Literal["open", "in_progress", "resolved", "accepted_risk", "dismissed"]
+ ] = None
+ action_id: Optional[str] = None
+ clear_action_id: bool = False
+
+
+@router.get("/{blocker_id}")
+def get_blocker(
+ blocker_id: str,
+ ctx: TenantContext = Depends(require_capability("kairo.blocker.read")),
+):
+ item = blocker_service.get_blocker(tenant_id=ctx.tenant_id, blocker_id=blocker_id)
+ if not item:
+ raise HTTPException(status_code=404, detail="Blocker nicht gefunden")
+ return item
+
+
+@router.patch("/{blocker_id}")
+def update_blocker(
+ blocker_id: str,
+ body: BlockerUpdateRequest,
+ ctx: TenantContext = Depends(require_capability("kairo.blocker.manage")),
+):
+ try:
+ item = blocker_service.update_blocker(
+ tenant_id=ctx.tenant_id,
+ blocker_id=blocker_id,
+ user_id=ctx.user_id,
+ title=body.title,
+ description=body.description,
+ status=body.status,
+ action_id=body.action_id,
+ clear_action_id=body.clear_action_id,
+ )
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+ if not item:
+ raise HTTPException(status_code=404, detail="Blocker nicht gefunden")
+ return item
+
+
+@router.delete("/{blocker_id}", status_code=204)
+def delete_blocker(
+ blocker_id: str,
+ ctx: TenantContext = Depends(require_capability("kairo.blocker.manage")),
+):
+ if not blocker_service.delete_blocker(
+ tenant_id=ctx.tenant_id, blocker_id=blocker_id, user_id=ctx.user_id
+ ):
+ raise HTTPException(status_code=404, detail="Blocker nicht gefunden")
diff --git a/backend/routers/initiatives.py b/backend/routers/initiatives.py
index d2e4c99..7c444b6 100644
--- a/backend/routers/initiatives.py
+++ b/backend/routers/initiatives.py
@@ -8,7 +8,10 @@ from capabilities import require_capability
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from services import actions as action_service
+from services import backlog as backlog_service
+from services import blockers as blocker_service
from services import initiatives as initiative_service
+from services import milestones as milestone_service
from tenant_context import TenantContext
router = APIRouter(prefix="/api/initiatives", tags=["initiatives"])
@@ -38,6 +41,28 @@ class ActionCreateRequest(BaseModel):
assigned_actor_ids: list[str] = Field(default_factory=list)
+class BlockerCreateRequest(BaseModel):
+ title: str = Field(min_length=1, max_length=255)
+ description: str = ""
+ status: Literal["open", "in_progress", "resolved", "accepted_risk", "dismissed"] = "open"
+ action_id: Optional[str] = None
+ set_action_blocked: bool = False
+
+
+class BacklogCreateRequest(BaseModel):
+ title: str = Field(min_length=1, max_length=255)
+ description: str = ""
+ status: Literal["new", "triaged", "accepted", "rejected"] = "new"
+ priority: Literal["low", "normal", "high"] = "normal"
+
+
+class MilestoneCreateRequest(BaseModel):
+ title: str = Field(min_length=1, max_length=255)
+ goal_description: str = ""
+ status: Literal["planned", "active", "at_risk", "reached", "moved", "discarded"] = "planned"
+ target_date: Optional[str] = None
+
+
@router.get("")
def list_initiatives(
ctx: TenantContext = Depends(require_capability("kairo.initiative.read")),
@@ -153,3 +178,127 @@ def create_initiative_action(
if detail == "Initiative nicht gefunden":
raise HTTPException(status_code=404, detail=detail) from exc
raise HTTPException(status_code=400, detail=detail) from exc
+
+
+@router.get("/{initiative_id}/blockers")
+def list_initiative_blockers(
+ initiative_id: str,
+ ctx: TenantContext = Depends(require_capability("kairo.blocker.read")),
+):
+ try:
+ return blocker_service.list_blockers_for_initiative(
+ tenant_id=ctx.tenant_id, initiative_id=initiative_id
+ )
+ except ValueError as exc:
+ if str(exc) == "Initiative nicht gefunden":
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+
+
+@router.post("/{initiative_id}/blockers", status_code=201)
+def create_initiative_blocker(
+ initiative_id: str,
+ body: BlockerCreateRequest,
+ ctx: TenantContext = Depends(require_capability("kairo.blocker.manage")),
+):
+ try:
+ return blocker_service.create_blocker(
+ tenant_id=ctx.tenant_id,
+ initiative_id=initiative_id,
+ title=body.title,
+ description=body.description,
+ status=body.status,
+ action_id=body.action_id,
+ reported_by_actor_id=ctx.actor_id,
+ user_id=ctx.user_id,
+ set_action_blocked=body.set_action_blocked,
+ )
+ except ValueError as exc:
+ detail = str(exc)
+ if detail == "Initiative nicht gefunden":
+ raise HTTPException(status_code=404, detail=detail) from exc
+ raise HTTPException(status_code=400, detail=detail) from exc
+
+
+@router.get("/{initiative_id}/backlog")
+def list_initiative_backlog(
+ initiative_id: str,
+ ctx: TenantContext = Depends(require_capability("kairo.backlog.read")),
+):
+ try:
+ return backlog_service.list_backlog_for_initiative(
+ tenant_id=ctx.tenant_id, initiative_id=initiative_id
+ )
+ except ValueError as exc:
+ if str(exc) == "Initiative nicht gefunden":
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+
+
+@router.post("/{initiative_id}/backlog", status_code=201)
+def create_initiative_backlog_item(
+ initiative_id: str,
+ body: BacklogCreateRequest,
+ ctx: TenantContext = Depends(require_capability("kairo.backlog.manage")),
+):
+ try:
+ return backlog_service.create_backlog_item(
+ tenant_id=ctx.tenant_id,
+ initiative_id=initiative_id,
+ title=body.title,
+ description=body.description,
+ status=body.status,
+ priority=body.priority,
+ user_id=ctx.user_id,
+ )
+ except ValueError as exc:
+ detail = str(exc)
+ if detail == "Initiative nicht gefunden":
+ raise HTTPException(status_code=404, detail=detail) from exc
+ raise HTTPException(status_code=400, detail=detail) from exc
+
+
+@router.get("/{initiative_id}/milestones")
+def list_initiative_milestones(
+ initiative_id: str,
+ ctx: TenantContext = Depends(require_capability("kairo.milestone.read")),
+):
+ try:
+ return milestone_service.list_milestones_for_initiative(
+ tenant_id=ctx.tenant_id, initiative_id=initiative_id
+ )
+ except ValueError as exc:
+ if str(exc) == "Initiative nicht gefunden":
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+
+
+@router.post("/{initiative_id}/milestones", status_code=201)
+def create_initiative_milestone(
+ initiative_id: str,
+ body: MilestoneCreateRequest,
+ ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
+):
+ from datetime import date as date_type
+
+ target_date = None
+ if body.target_date:
+ try:
+ target_date = date_type.fromisoformat(body.target_date)
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail="Ungültiges target_date") from exc
+ try:
+ return milestone_service.create_milestone(
+ tenant_id=ctx.tenant_id,
+ initiative_id=initiative_id,
+ title=body.title,
+ goal_description=body.goal_description,
+ status=body.status,
+ target_date=target_date,
+ user_id=ctx.user_id,
+ )
+ except ValueError as exc:
+ detail = str(exc)
+ if detail == "Initiative nicht gefunden":
+ raise HTTPException(status_code=404, detail=detail) from exc
+ raise HTTPException(status_code=400, detail=detail) from exc
diff --git a/backend/routers/milestones.py b/backend/routers/milestones.py
new file mode 100644
index 0000000..bdb9a93
--- /dev/null
+++ b/backend/routers/milestones.py
@@ -0,0 +1,77 @@
+"""Milestone API — AP0.8d."""
+
+from __future__ import annotations
+
+from datetime import date
+from typing import Literal, Optional
+
+from capabilities import require_capability
+from fastapi import APIRouter, Depends, HTTPException
+from pydantic import BaseModel, Field
+from services import milestones as milestone_service
+from tenant_context import TenantContext
+
+router = APIRouter(prefix="/api/milestones", tags=["milestones"])
+
+
+class MilestoneCreateRequest(BaseModel):
+ title: str = Field(min_length=1, max_length=255)
+ goal_description: str = ""
+ status: Literal["planned", "active", "at_risk", "reached", "moved", "discarded"] = "planned"
+ target_date: Optional[date] = None
+
+
+class MilestoneUpdateRequest(BaseModel):
+ title: Optional[str] = Field(default=None, min_length=1, max_length=255)
+ goal_description: Optional[str] = None
+ status: Optional[
+ Literal["planned", "active", "at_risk", "reached", "moved", "discarded"]
+ ] = None
+ target_date: Optional[date] = None
+ clear_target_date: bool = False
+
+
+@router.get("/{milestone_id}")
+def get_milestone(
+ milestone_id: str,
+ ctx: TenantContext = Depends(require_capability("kairo.milestone.read")),
+):
+ item = milestone_service.get_milestone(tenant_id=ctx.tenant_id, milestone_id=milestone_id)
+ if not item:
+ raise HTTPException(status_code=404, detail="Meilenstein nicht gefunden")
+ return item
+
+
+@router.patch("/{milestone_id}")
+def update_milestone(
+ milestone_id: str,
+ body: MilestoneUpdateRequest,
+ ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
+):
+ try:
+ item = milestone_service.update_milestone(
+ tenant_id=ctx.tenant_id,
+ milestone_id=milestone_id,
+ user_id=ctx.user_id,
+ title=body.title,
+ goal_description=body.goal_description,
+ status=body.status,
+ target_date=body.target_date,
+ clear_target_date=body.clear_target_date,
+ )
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+ if not item:
+ raise HTTPException(status_code=404, detail="Meilenstein nicht gefunden")
+ return item
+
+
+@router.delete("/{milestone_id}", status_code=204)
+def delete_milestone(
+ milestone_id: str,
+ ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
+):
+ if not milestone_service.delete_milestone(
+ tenant_id=ctx.tenant_id, milestone_id=milestone_id, user_id=ctx.user_id
+ ):
+ raise HTTPException(status_code=404, detail="Meilenstein nicht gefunden")
diff --git a/backend/routers/workspace.py b/backend/routers/workspace.py
index 1b18754..6ab1a24 100644
--- a/backend/routers/workspace.py
+++ b/backend/routers/workspace.py
@@ -7,6 +7,7 @@ from typing import Optional
from capabilities import require_capability
from data_layer import actions as dl_actions
from data_layer import actors as dl_actors
+from data_layer import attention as dl_attention
from data_layer import initiatives as dl_initiatives
from data_layer import workspace as dl_workspace
from fastapi import APIRouter, Depends, HTTPException, Query
@@ -61,3 +62,18 @@ def workspace_actor_workload(
ctx: TenantContext = Depends(require_capability("kairo.workspace.read")),
):
return dl_actors.get_actor_workload(ctx)
+
+
+@router.get("/attention")
+def workspace_attention(
+ ctx: TenantContext = Depends(require_capability("kairo.attention.read")),
+):
+ return dl_attention.get_attention_items(ctx)
+
+
+@router.get("/next-actions")
+def workspace_next_actions(
+ limit: Optional[int] = Query(default=10, ge=1, le=50),
+ ctx: TenantContext = Depends(require_capability("kairo.attention.read")),
+):
+ return dl_attention.get_next_action_candidates(ctx, limit=limit or 10)
diff --git a/backend/services/backlog.py b/backend/services/backlog.py
new file mode 100644
index 0000000..c4e944e
--- /dev/null
+++ b/backend/services/backlog.py
@@ -0,0 +1,296 @@
+"""BacklogItem service — tenant-scoped CRUD + convert (AP0.8c)."""
+
+from __future__ import annotations
+
+from typing import Any, Literal, Optional
+
+from psycopg2.extras import RealDictCursor
+
+from db import get_connection
+from services.actions import create_action
+from services.audit import log_audit
+from services.initiatives import PRIORITIES, get_initiative
+
+BacklogStatus = Literal["new", "triaged", "accepted", "rejected", "converted"]
+
+BACKLOG_STATUSES = frozenset({"new", "triaged", "accepted", "rejected", "converted"})
+
+
+def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
+ result = dict(row)
+ for key in ("id", "tenant_id", "initiative_id", "converted_action_id"):
+ if result.get(key):
+ result[key] = str(result[key])
+ if result.get("created_at"):
+ result["created_at"] = result["created_at"].isoformat()
+ if result.get("updated_at"):
+ result["updated_at"] = result["updated_at"].isoformat()
+ return result
+
+
+def _validate_status(status: str) -> None:
+ if status not in BACKLOG_STATUSES:
+ raise ValueError(f"Ungültiger Backlog-Status: {status}")
+
+
+def _validate_priority(priority: str) -> None:
+ if priority not in PRIORITIES:
+ raise ValueError(f"Ungültige Priorität: {priority}")
+
+
+def create_backlog_item(
+ *,
+ tenant_id: str,
+ initiative_id: str,
+ title: str,
+ description: str = "",
+ status: BacklogStatus = "new",
+ priority: str = "normal",
+ user_id: Optional[str] = None,
+) -> dict[str, Any]:
+ title = title.strip()
+ if not title:
+ raise ValueError("Titel ist erforderlich")
+ _validate_status(status)
+ _validate_priority(priority)
+ 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(
+ """
+ INSERT INTO backlog_items (
+ tenant_id, initiative_id, title, description, status, priority
+ )
+ VALUES (%s, %s, %s, %s, %s, %s)
+ RETURNING id, tenant_id, initiative_id, title, description, status,
+ priority, converted_action_id, created_at, updated_at
+ """,
+ (tenant_id, initiative_id, title, description, status, priority),
+ )
+ row = _serialize_row(dict(cur.fetchone()))
+ conn.commit()
+ finally:
+ conn.close()
+
+ log_audit(
+ "backlog.created",
+ user_id=user_id,
+ tenant_id=tenant_id,
+ details={"backlog_item_id": row["id"], "initiative_id": initiative_id, "title": title},
+ )
+ return row
+
+
+def list_backlog_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 id, tenant_id, initiative_id, title, description, 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, title
+ """,
+ (tenant_id, initiative_id),
+ )
+ return [_serialize_row(dict(r)) for r in cur.fetchall()]
+ finally:
+ conn.close()
+
+
+def get_backlog_item(*, tenant_id: str, backlog_item_id: str) -> Optional[dict[str, Any]]:
+ conn = get_connection()
+ try:
+ with conn.cursor(cursor_factory=RealDictCursor) as cur:
+ cur.execute(
+ """
+ SELECT id, tenant_id, initiative_id, title, description, status,
+ priority, converted_action_id, created_at, updated_at
+ FROM backlog_items
+ WHERE id = %s AND tenant_id = %s
+ """,
+ (backlog_item_id, tenant_id),
+ )
+ row = cur.fetchone()
+ return _serialize_row(dict(row)) if row else None
+ finally:
+ conn.close()
+
+
+def update_backlog_item(
+ *,
+ tenant_id: str,
+ backlog_item_id: str,
+ user_id: Optional[str] = None,
+ title: Optional[str] = None,
+ description: Optional[str] = None,
+ status: Optional[BacklogStatus] = None,
+ priority: Optional[str] = None,
+) -> Optional[dict[str, Any]]:
+ existing = get_backlog_item(tenant_id=tenant_id, backlog_item_id=backlog_item_id)
+ if not existing:
+ return None
+ if existing["status"] == "converted":
+ raise ValueError("Konvertiertes Backlog-Item kann nicht bearbeitet werden")
+
+ 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 description is not None:
+ updates.append("description = %s")
+ params.append(description)
+ if status is not None:
+ _validate_status(status)
+ updates.append("status = %s")
+ params.append(status)
+ if priority is not None:
+ _validate_priority(priority)
+ updates.append("priority = %s")
+ params.append(priority)
+
+ if not updates:
+ return existing
+
+ updates.append("updated_at = NOW()")
+ params.extend([backlog_item_id, tenant_id])
+
+ conn = get_connection()
+ try:
+ with conn.cursor(cursor_factory=RealDictCursor) as cur:
+ cur.execute(
+ f"""
+ UPDATE backlog_items
+ SET {", ".join(updates)}
+ WHERE id = %s AND tenant_id = %s
+ RETURNING id, tenant_id, initiative_id, title, description, status,
+ priority, converted_action_id, created_at, updated_at
+ """,
+ params,
+ )
+ row = cur.fetchone()
+ if not row:
+ return None
+ result = _serialize_row(dict(row))
+ conn.commit()
+ finally:
+ conn.close()
+
+ log_audit(
+ "backlog.updated",
+ user_id=user_id,
+ tenant_id=tenant_id,
+ details={"backlog_item_id": backlog_item_id},
+ )
+ if status is not None and status != old_status:
+ log_audit(
+ "backlog.status_changed",
+ user_id=user_id,
+ tenant_id=tenant_id,
+ details={
+ "backlog_item_id": backlog_item_id,
+ "from_status": old_status,
+ "to_status": status,
+ },
+ )
+ return result
+
+
+def delete_backlog_item(
+ *,
+ tenant_id: str,
+ backlog_item_id: str,
+ user_id: Optional[str] = None,
+) -> bool:
+ conn = get_connection()
+ try:
+ with conn.cursor() as cur:
+ cur.execute(
+ "DELETE FROM backlog_items WHERE id = %s AND tenant_id = %s RETURNING id",
+ (backlog_item_id, tenant_id),
+ )
+ deleted = cur.fetchone() is not None
+ conn.commit()
+ finally:
+ conn.close()
+
+ if deleted:
+ log_audit(
+ "backlog.deleted",
+ user_id=user_id,
+ tenant_id=tenant_id,
+ details={"backlog_item_id": backlog_item_id},
+ )
+ return deleted
+
+
+def convert_backlog_to_action(
+ *,
+ tenant_id: str,
+ backlog_item_id: str,
+ user_id: Optional[str] = None,
+ assigned_actor_ids: Optional[list[str]] = None,
+) -> dict[str, Any]:
+ existing = get_backlog_item(tenant_id=tenant_id, backlog_item_id=backlog_item_id)
+ if not existing:
+ raise ValueError("Backlog-Item nicht gefunden")
+ if existing["status"] == "converted":
+ raise ValueError("Backlog-Item wurde bereits konvertiert")
+ if existing["status"] not in ("accepted", "triaged", "new"):
+ raise ValueError("Backlog-Item kann in diesem Status nicht konvertiert werden")
+
+ action = create_action(
+ tenant_id=tenant_id,
+ initiative_id=existing["initiative_id"],
+ title=existing["title"],
+ description=existing["description"] or "",
+ priority=existing["priority"],
+ assigned_actor_ids=assigned_actor_ids or [],
+ user_id=user_id,
+ )
+
+ conn = get_connection()
+ try:
+ with conn.cursor(cursor_factory=RealDictCursor) as cur:
+ cur.execute(
+ """
+ UPDATE backlog_items
+ SET status = 'converted',
+ converted_action_id = %s,
+ updated_at = NOW()
+ WHERE id = %s AND tenant_id = %s
+ RETURNING id, tenant_id, initiative_id, title, description, status,
+ priority, converted_action_id, created_at, updated_at
+ """,
+ (action["id"], backlog_item_id, tenant_id),
+ )
+ row = _serialize_row(dict(cur.fetchone()))
+ conn.commit()
+ finally:
+ conn.close()
+
+ log_audit(
+ "backlog.converted_to_action",
+ user_id=user_id,
+ tenant_id=tenant_id,
+ details={
+ "backlog_item_id": backlog_item_id,
+ "action_id": action["id"],
+ "initiative_id": existing["initiative_id"],
+ },
+ )
+ return {"backlog_item": row, "action": action}
diff --git a/backend/services/blockers.py b/backend/services/blockers.py
new file mode 100644
index 0000000..20c67fb
--- /dev/null
+++ b/backend/services/blockers.py
@@ -0,0 +1,295 @@
+"""Blocker service — tenant-scoped CRUD (AP0.8b)."""
+
+from __future__ import annotations
+
+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
+
+BlockerStatus = Literal["open", "in_progress", "resolved", "accepted_risk", "dismissed"]
+
+BLOCKER_STATUSES = frozenset(
+ {"open", "in_progress", "resolved", "accepted_risk", "dismissed"}
+)
+
+
+def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
+ result = dict(row)
+ for key in ("id", "tenant_id", "initiative_id", "action_id", "reported_by_actor_id"):
+ if result.get(key):
+ result[key] = str(result[key])
+ if result.get("created_at"):
+ result["created_at"] = result["created_at"].isoformat()
+ if result.get("updated_at"):
+ result["updated_at"] = result["updated_at"].isoformat()
+ return result
+
+
+def _validate_status(status: str) -> None:
+ if status not in BLOCKER_STATUSES:
+ raise ValueError(f"Ungültiger Blocker-Status: {status}")
+
+
+def _actor_in_tenant(*, tenant_id: str, actor_id: str) -> bool:
+ conn = get_connection()
+ try:
+ with conn.cursor() as cur:
+ cur.execute(
+ "SELECT 1 FROM actors WHERE id = %s AND tenant_id = %s AND is_active = TRUE",
+ (actor_id, tenant_id),
+ )
+ return cur.fetchone() is not None
+ finally:
+ conn.close()
+
+
+def _action_in_initiative(*, tenant_id: str, initiative_id: str, action_id: str) -> bool:
+ conn = get_connection()
+ try:
+ with conn.cursor() as cur:
+ cur.execute(
+ """
+ SELECT 1 FROM actions
+ WHERE id = %s AND tenant_id = %s AND initiative_id = %s
+ """,
+ (action_id, tenant_id, initiative_id),
+ )
+ return cur.fetchone() is not None
+ finally:
+ conn.close()
+
+
+def create_blocker(
+ *,
+ tenant_id: str,
+ initiative_id: str,
+ title: str,
+ description: str = "",
+ status: BlockerStatus = "open",
+ action_id: Optional[str] = None,
+ reported_by_actor_id: Optional[str] = None,
+ user_id: Optional[str] = None,
+ set_action_blocked: bool = False,
+) -> dict[str, Any]:
+ title = title.strip()
+ if not title:
+ raise ValueError("Titel ist erforderlich")
+ _validate_status(status)
+ if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
+ raise ValueError("Initiative nicht gefunden")
+ if action_id and not _action_in_initiative(
+ tenant_id=tenant_id, initiative_id=initiative_id, action_id=action_id
+ ):
+ raise ValueError("Maßnahme gehört nicht zum Vorhaben")
+ if reported_by_actor_id and not _actor_in_tenant(
+ tenant_id=tenant_id, actor_id=reported_by_actor_id
+ ):
+ raise ValueError("Reporter-Actor gehört nicht zum Tenant")
+
+ conn = get_connection()
+ try:
+ with conn.cursor(cursor_factory=RealDictCursor) as cur:
+ cur.execute(
+ """
+ INSERT INTO blockers (
+ tenant_id, initiative_id, action_id, title, description,
+ status, reported_by_actor_id
+ )
+ VALUES (%s, %s, %s, %s, %s, %s, %s)
+ RETURNING id, tenant_id, initiative_id, action_id, title, description,
+ status, reported_by_actor_id, created_at, updated_at
+ """,
+ (
+ tenant_id,
+ initiative_id,
+ action_id,
+ title,
+ description,
+ status,
+ reported_by_actor_id,
+ ),
+ )
+ row = _serialize_row(dict(cur.fetchone()))
+ if set_action_blocked and action_id:
+ cur.execute(
+ """
+ UPDATE actions SET status = 'blocked', updated_at = NOW()
+ WHERE id = %s AND tenant_id = %s
+ """,
+ (action_id, tenant_id),
+ )
+ conn.commit()
+ finally:
+ conn.close()
+
+ log_audit(
+ "blocker.created",
+ user_id=user_id,
+ tenant_id=tenant_id,
+ details={"blocker_id": row["id"], "initiative_id": initiative_id, "title": title},
+ )
+ return row
+
+
+def list_blockers_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 id, tenant_id, initiative_id, action_id, title, description,
+ status, reported_by_actor_id, created_at, updated_at
+ FROM blockers
+ WHERE tenant_id = %s AND initiative_id = %s
+ ORDER BY updated_at DESC, title
+ """,
+ (tenant_id, initiative_id),
+ )
+ return [_serialize_row(dict(r)) for r in cur.fetchall()]
+ finally:
+ conn.close()
+
+
+def get_blocker(*, tenant_id: str, blocker_id: str) -> Optional[dict[str, Any]]:
+ conn = get_connection()
+ try:
+ with conn.cursor(cursor_factory=RealDictCursor) as cur:
+ cur.execute(
+ """
+ SELECT id, tenant_id, initiative_id, action_id, title, description,
+ status, reported_by_actor_id, created_at, updated_at
+ FROM blockers
+ WHERE id = %s AND tenant_id = %s
+ """,
+ (blocker_id, tenant_id),
+ )
+ row = cur.fetchone()
+ return _serialize_row(dict(row)) if row else None
+ finally:
+ conn.close()
+
+
+def update_blocker(
+ *,
+ tenant_id: str,
+ blocker_id: str,
+ user_id: Optional[str] = None,
+ title: Optional[str] = None,
+ description: Optional[str] = None,
+ status: Optional[BlockerStatus] = None,
+ action_id: Optional[str] = None,
+ clear_action_id: bool = False,
+) -> Optional[dict[str, Any]]:
+ existing = get_blocker(tenant_id=tenant_id, blocker_id=blocker_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 description is not None:
+ updates.append("description = %s")
+ params.append(description)
+ if status is not None:
+ _validate_status(status)
+ updates.append("status = %s")
+ params.append(status)
+ if clear_action_id:
+ updates.append("action_id = NULL")
+ elif action_id is not None:
+ if not _action_in_initiative(
+ tenant_id=tenant_id,
+ initiative_id=existing["initiative_id"],
+ action_id=action_id,
+ ):
+ raise ValueError("Maßnahme gehört nicht zum Vorhaben")
+ updates.append("action_id = %s")
+ params.append(action_id)
+
+ if not updates:
+ return existing
+
+ updates.append("updated_at = NOW()")
+ params.extend([blocker_id, tenant_id])
+
+ conn = get_connection()
+ try:
+ with conn.cursor(cursor_factory=RealDictCursor) as cur:
+ cur.execute(
+ f"""
+ UPDATE blockers
+ SET {", ".join(updates)}
+ WHERE id = %s AND tenant_id = %s
+ RETURNING id, tenant_id, initiative_id, action_id, title, description,
+ status, reported_by_actor_id, created_at, updated_at
+ """,
+ params,
+ )
+ row = cur.fetchone()
+ if not row:
+ return None
+ result = _serialize_row(dict(row))
+ conn.commit()
+ finally:
+ conn.close()
+
+ log_audit(
+ "blocker.updated",
+ user_id=user_id,
+ tenant_id=tenant_id,
+ details={"blocker_id": blocker_id},
+ )
+ if status is not None and status != old_status:
+ log_audit(
+ "blocker.status_changed",
+ user_id=user_id,
+ tenant_id=tenant_id,
+ details={
+ "blocker_id": blocker_id,
+ "from_status": old_status,
+ "to_status": status,
+ },
+ )
+ return result
+
+
+def delete_blocker(
+ *,
+ tenant_id: str,
+ blocker_id: str,
+ user_id: Optional[str] = None,
+) -> bool:
+ conn = get_connection()
+ try:
+ with conn.cursor() as cur:
+ cur.execute(
+ "DELETE FROM blockers WHERE id = %s AND tenant_id = %s RETURNING id",
+ (blocker_id, tenant_id),
+ )
+ deleted = cur.fetchone() is not None
+ conn.commit()
+ finally:
+ conn.close()
+
+ if deleted:
+ log_audit(
+ "blocker.deleted",
+ user_id=user_id,
+ tenant_id=tenant_id,
+ details={"blocker_id": blocker_id},
+ )
+ return deleted
diff --git a/backend/services/milestones.py b/backend/services/milestones.py
new file mode 100644
index 0000000..dc4d553
--- /dev/null
+++ b/backend/services/milestones.py
@@ -0,0 +1,242 @@
+"""Milestone service — tenant-scoped CRUD (AP0.8d)."""
+
+from __future__ import annotations
+
+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
+
+MilestoneStatus = Literal[
+ "planned", "active", "at_risk", "reached", "moved", "discarded"
+]
+
+MILESTONE_STATUSES = frozenset(
+ {"planned", "active", "at_risk", "reached", "moved", "discarded"}
+)
+
+
+def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
+ result = dict(row)
+ for key in ("id", "tenant_id", "initiative_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"])
+ )
+ if result.get("created_at"):
+ result["created_at"] = result["created_at"].isoformat()
+ if result.get("updated_at"):
+ result["updated_at"] = result["updated_at"].isoformat()
+ return result
+
+
+def _validate_status(status: str) -> None:
+ if status not in MILESTONE_STATUSES:
+ raise ValueError(f"Ungültiger Meilenstein-Status: {status}")
+
+
+def create_milestone(
+ *,
+ tenant_id: str,
+ initiative_id: str,
+ title: str,
+ goal_description: str = "",
+ status: MilestoneStatus = "planned",
+ target_date: Optional[date] = None,
+ user_id: Optional[str] = None,
+) -> dict[str, Any]:
+ title = title.strip()
+ if not title:
+ raise ValueError("Titel ist erforderlich")
+ _validate_status(status)
+ 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(
+ """
+ INSERT INTO milestones (
+ tenant_id, initiative_id, title, goal_description, status, target_date
+ )
+ VALUES (%s, %s, %s, %s, %s, %s)
+ RETURNING id, tenant_id, initiative_id, title, goal_description, status,
+ target_date, created_at, updated_at
+ """,
+ (tenant_id, initiative_id, title, goal_description, status, target_date),
+ )
+ row = _serialize_row(dict(cur.fetchone()))
+ conn.commit()
+ finally:
+ conn.close()
+
+ log_audit(
+ "milestone.created",
+ user_id=user_id,
+ tenant_id=tenant_id,
+ details={"milestone_id": row["id"], "initiative_id": initiative_id, "title": title},
+ )
+ return row
+
+
+def list_milestones_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 id, tenant_id, initiative_id, title, goal_description, status,
+ target_date, created_at, updated_at
+ FROM milestones
+ WHERE tenant_id = %s AND initiative_id = %s
+ ORDER BY target_date NULLS LAST, updated_at DESC, title
+ """,
+ (tenant_id, initiative_id),
+ )
+ return [_serialize_row(dict(r)) for r in cur.fetchall()]
+ finally:
+ conn.close()
+
+
+def get_milestone(*, tenant_id: str, milestone_id: str) -> Optional[dict[str, Any]]:
+ conn = get_connection()
+ try:
+ with conn.cursor(cursor_factory=RealDictCursor) as cur:
+ cur.execute(
+ """
+ SELECT id, tenant_id, initiative_id, title, goal_description, status,
+ target_date, created_at, updated_at
+ FROM milestones
+ WHERE id = %s AND tenant_id = %s
+ """,
+ (milestone_id, tenant_id),
+ )
+ row = cur.fetchone()
+ return _serialize_row(dict(row)) if row else None
+ finally:
+ conn.close()
+
+
+def update_milestone(
+ *,
+ tenant_id: str,
+ milestone_id: str,
+ user_id: Optional[str] = None,
+ title: Optional[str] = None,
+ goal_description: Optional[str] = None,
+ status: Optional[MilestoneStatus] = None,
+ target_date: Optional[date] = None,
+ clear_target_date: bool = False,
+) -> Optional[dict[str, Any]]:
+ existing = get_milestone(tenant_id=tenant_id, milestone_id=milestone_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 status is not None:
+ _validate_status(status)
+ updates.append("status = %s")
+ params.append(status)
+ 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 not updates:
+ return existing
+
+ updates.append("updated_at = NOW()")
+ params.extend([milestone_id, tenant_id])
+
+ conn = get_connection()
+ try:
+ with conn.cursor(cursor_factory=RealDictCursor) as cur:
+ cur.execute(
+ f"""
+ UPDATE milestones
+ SET {", ".join(updates)}
+ WHERE id = %s AND tenant_id = %s
+ RETURNING id, tenant_id, initiative_id, title, goal_description, status,
+ target_date, created_at, updated_at
+ """,
+ params,
+ )
+ row = cur.fetchone()
+ if not row:
+ return None
+ result = _serialize_row(dict(row))
+ conn.commit()
+ finally:
+ conn.close()
+
+ log_audit(
+ "milestone.updated",
+ user_id=user_id,
+ tenant_id=tenant_id,
+ details={"milestone_id": milestone_id},
+ )
+ if status is not None and status != old_status:
+ log_audit(
+ "milestone.status_changed",
+ user_id=user_id,
+ tenant_id=tenant_id,
+ details={
+ "milestone_id": milestone_id,
+ "from_status": old_status,
+ "to_status": status,
+ },
+ )
+ return result
+
+
+def delete_milestone(
+ *,
+ tenant_id: str,
+ milestone_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 RETURNING id",
+ (milestone_id, tenant_id),
+ )
+ deleted = cur.fetchone() is not None
+ conn.commit()
+ finally:
+ conn.close()
+
+ if deleted:
+ log_audit(
+ "milestone.deleted",
+ user_id=user_id,
+ tenant_id=tenant_id,
+ details={"milestone_id": milestone_id},
+ )
+ return deleted
diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py
index 197d367..a6be23c 100644
--- a/backend/tests/conftest.py
+++ b/backend/tests/conftest.py
@@ -1,4 +1,8 @@
-"""Shared pytest fixtures for AP0.2 auth tests."""
+"""Shared pytest fixtures — Integrationstests benötigen PostgreSQL.
+
+Lokal typischerweise nicht verfügbar: Tests laufen nach Push auf develop/main
+im Backend-Container auf dem Pi (Gitea workflow test.yml).
+"""
from __future__ import annotations
@@ -23,6 +27,7 @@ def _db_available() -> bool:
dbname=os.getenv("DB_NAME", "kairo_dev"),
user=os.getenv("DB_USER", "kairo_dev"),
password=os.getenv("DB_PASSWORD", "dev_password"),
+ connect_timeout=3,
)
conn.close()
return True
@@ -30,7 +35,18 @@ def _db_available() -> bool:
return False
-pytestmark = pytest.mark.skipif(not _db_available(), reason="PostgreSQL nicht erreichbar")
+_REMOTE_HINT = (
+ "PostgreSQL lokal nicht erreichbar. "
+ "Backend-Integrationstests laufen nach Push auf develop/main "
+ "im deployten Backend-Container (Gitea: test.yml auf dem Pi)."
+)
+
+
+@pytest.fixture(scope="session", autouse=True)
+def _require_postgresql():
+ """Session-weiter Guard — verhindert Migrations-Setup ohne DB (keine ERROR-Spirale)."""
+ if not _db_available():
+ pytest.skip(_REMOTE_HINT)
@pytest.fixture(scope="session", autouse=True)
diff --git a/backend/tests/test_ap08_operating_model.py b/backend/tests/test_ap08_operating_model.py
new file mode 100644
index 0000000..160eb86
--- /dev/null
+++ b/backend/tests/test_ap08_operating_model.py
@@ -0,0 +1,279 @@
+"""AP0.8 — Operating Model Extension I tests.
+
+Ausführung: im Backend-Container auf dem Pi nach Deploy (develop/main),
+siehe .gitea/workflows/test.yml und docs/DEPLOYMENT.md.
+"""
+
+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 _create_blocker(client, token, initiative_id, **kwargs):
+ body = {"title": "Blocker Test", **kwargs}
+ return client.post(
+ f"/api/initiatives/{initiative_id}/blockers",
+ json=body,
+ headers=_auth(token),
+ )
+
+
+def _create_backlog(client, token, initiative_id, **kwargs):
+ body = {"title": "Backlog Test", **kwargs}
+ return client.post(
+ f"/api/initiatives/{initiative_id}/backlog",
+ json=body,
+ headers=_auth(token),
+ )
+
+
+def _create_milestone(client, token, initiative_id, **kwargs):
+ body = {"title": "Milestone Test", **kwargs}
+ return client.post(
+ f"/api/initiatives/{initiative_id}/milestones",
+ json=body,
+ headers=_auth(token),
+ )
+
+
+def test_attention_blocked_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, assigned_actor_ids=[user["actor_id"]]
+ ).json()
+ client.patch(
+ f"/api/actions/{action['id']}",
+ json={"status": "blocked"},
+ headers=_auth(token),
+ )
+
+ res = client.get("/api/workspace/attention", headers=_auth(token))
+ assert res.status_code == 200
+ items = res.json()
+ assert any(i["kind"] == "blocked_action" for i in items)
+ blocked = next(i for i in items if i["kind"] == "blocked_action")
+ assert blocked["reason_code"] == "action_blocked"
+ assert blocked["data_source"] == "actions"
+ assert blocked["scope_type"] == "action"
+
+
+def test_attention_unassigned_action(client):
+ user = provision_user_in_tenant(tenant_role="member")
+ token = _login(client, user)
+ initiative_id = _create_initiative(client, token).json()["id"]
+ _create_action(client, token, initiative_id, title="Unassigned")
+
+ res = client.get("/api/workspace/attention", headers=_auth(token))
+ assert res.status_code == 200
+ assert any(i["kind"] == "unassigned_action" for i in res.json())
+
+
+def test_attention_initiative_without_next_action(client):
+ user = provision_user_in_tenant(tenant_role="member")
+ token = _login(client, user)
+ _create_initiative(client, token, title="Empty Initiative")
+
+ res = client.get("/api/workspace/attention", headers=_auth(token))
+ assert res.status_code == 200
+ assert any(i["kind"] == "initiative_without_next_action" for i in res.json())
+
+
+def test_attention_open_blocker(client):
+ user = provision_user_in_tenant(tenant_role="member")
+ token = _login(client, user)
+ initiative_id = _create_initiative(client, token).json()["id"]
+ _create_blocker(client, token, initiative_id, title="Dependency missing")
+
+ res = client.get("/api/workspace/attention", headers=_auth(token))
+ assert res.status_code == 200
+ assert any(i["kind"] == "open_blocker" for i in res.json())
+
+
+def test_attention_milestone_at_risk(client):
+ user = provision_user_in_tenant(tenant_role="member")
+ token = _login(client, user)
+ initiative_id = _create_initiative(client, token).json()["id"]
+ ms = _create_milestone(client, token, initiative_id).json()
+ client.patch(
+ f"/api/milestones/{ms['id']}",
+ json={"status": "at_risk"},
+ headers=_auth(token),
+ )
+
+ res = client.get("/api/workspace/attention", headers=_auth(token))
+ assert any(i["kind"] == "milestone_at_risk" for i in res.json())
+
+
+def test_attention_severity_sorting(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": "blocked"},
+ headers=_auth(token),
+ )
+ _create_blocker(client, token, initiative_id)
+
+ items = client.get("/api/workspace/attention", headers=_auth(token)).json()
+ severities = [i["severity"] for i in items]
+ order = {"critical": 0, "warning": 1, "info": 2}
+ assert severities == sorted(severities, key=lambda s: order.get(s, 99))
+
+
+def test_next_action_convert_backlog(client):
+ user = provision_user_in_tenant(tenant_role="member")
+ token = _login(client, user)
+ initiative_id = _create_initiative(client, token).json()["id"]
+ item = _create_backlog(
+ client, token, initiative_id, status="accepted", title="Convert me"
+ ).json()
+
+ res = client.get("/api/workspace/next-actions?limit=10", headers=_auth(token))
+ assert res.status_code == 200
+ assert any(
+ c["kind"] == "convert_backlog" and c["backlog_item_id"] == item["id"]
+ for c in res.json()
+ )
+
+
+def test_next_action_limit(client):
+ user = provision_user_in_tenant(tenant_role="member")
+ token = _login(client, user)
+ initiative_id = _create_initiative(client, token).json()["id"]
+ for i in range(5):
+ _create_backlog(
+ client, token, initiative_id, status="accepted", title=f"Item {i}"
+ )
+
+ res = client.get("/api/workspace/next-actions?limit=2", headers=_auth(token))
+ assert len(res.json()) <= 2
+
+
+def test_blocker_crud(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()
+
+ created = _create_blocker(
+ client,
+ token,
+ initiative_id,
+ title="API Block",
+ action_id=action["id"],
+ )
+ assert created.status_code == 201
+ blocker = created.json()
+ assert blocker["initiative_id"] == initiative_id
+
+ listed = client.get(
+ f"/api/initiatives/{initiative_id}/blockers", headers=_auth(token)
+ )
+ assert listed.status_code == 200
+ assert any(b["id"] == blocker["id"] for b in listed.json())
+
+ updated = client.patch(
+ f"/api/blockers/{blocker['id']}",
+ json={"status": "resolved"},
+ headers=_auth(token),
+ )
+ assert updated.status_code == 200
+ assert updated.json()["status"] == "resolved"
+
+ deleted = client.delete(f"/api/blockers/{blocker['id']}", headers=_auth(token))
+ assert deleted.status_code == 204
+
+
+def test_blocker_cross_tenant(client):
+ user_a = provision_user_in_tenant()
+ user_b = provision_user_in_tenant()
+ token_a = _login(client, user_a)
+ token_b = _login(client, user_b)
+ initiative_id = _create_initiative(client, token_a).json()["id"]
+ blocker = _create_blocker(client, token_a, initiative_id).json()
+
+ assert (
+ client.get(f"/api/blockers/{blocker['id']}", headers=_auth(token_b)).status_code
+ == 404
+ )
+
+
+def test_backlog_convert_to_action(client):
+ user = provision_user_in_tenant(tenant_role="member")
+ token = _login(client, user)
+ initiative_id = _create_initiative(client, token).json()["id"]
+ item = _create_backlog(
+ client, token, initiative_id, status="accepted", title="Feature X"
+ ).json()
+
+ converted = client.post(
+ f"/api/backlog/{item['id']}/convert-to-action",
+ json={},
+ headers=_auth(token),
+ )
+ assert converted.status_code == 201
+ body = converted.json()
+ assert body["backlog_item"]["status"] == "converted"
+ assert body["action"]["title"] == "Feature X"
+ assert body["backlog_item"]["converted_action_id"] == body["action"]["id"]
+
+
+def test_backlog_crud(client):
+ user = provision_user_in_tenant(tenant_role="member")
+ token = _login(client, user)
+ initiative_id = _create_initiative(client, token).json()["id"]
+ created = _create_backlog(client, token, initiative_id, status="triaged")
+ assert created.status_code == 201
+ item = created.json()
+
+ patched = client.patch(
+ f"/api/backlog/{item['id']}",
+ json={"status": "accepted"},
+ headers=_auth(token),
+ )
+ assert patched.status_code == 200
+
+ deleted = client.delete(f"/api/backlog/{item['id']}", headers=_auth(token))
+ assert deleted.status_code == 204
+
+
+def test_milestone_crud(client):
+ user = provision_user_in_tenant(tenant_role="member")
+ token = _login(client, user)
+ initiative_id = _create_initiative(client, token).json()["id"]
+ created = _create_milestone(
+ client, token, initiative_id, status="active", target_date="2026-12-31"
+ )
+ assert created.status_code == 201
+ ms = created.json()
+ assert ms["target_date"] == "2026-12-31"
+
+ deleted = client.delete(f"/api/milestones/{ms['id']}", headers=_auth(token))
+ assert deleted.status_code == 204
+
+
+def test_member_has_ap08_capabilities(client):
+ member = provision_user_in_tenant(tenant_role="member")
+ token = _login(client, member)
+ ctx = client.get("/api/me/context", headers=_auth(token)).json()
+ caps = ctx["capabilities"]
+ for key in (
+ "kairo.attention.read",
+ "kairo.blocker.read",
+ "kairo.blocker.manage",
+ "kairo.backlog.read",
+ "kairo.backlog.manage",
+ "kairo.milestone.read",
+ "kairo.milestone.manage",
+ ):
+ assert key in caps
diff --git a/backend/tests/test_migrations.py b/backend/tests/test_migrations.py
index c5c352c..484fa3e 100644
--- a/backend/tests/test_migrations.py
+++ b/backend/tests/test_migrations.py
@@ -37,6 +37,7 @@ def test_migration_runner_finds_migrations():
assert "004_capabilities_registry" in names
assert "005_prompt_feature_config_registry" in names
assert "006_initiatives_actions" in names
+ assert "007_operating_model_extension_i" in names
def test_migration_runner_is_idempotent():
@@ -52,6 +53,7 @@ def test_migration_runner_is_idempotent():
assert "004_capabilities_registry" in executed
assert "005_prompt_feature_config_registry" in executed
assert "006_initiatives_actions" in executed
+ assert "007_operating_model_extension_i" in executed
def test_core_table_exists():
diff --git a/backend/tests/test_rights_registry.py b/backend/tests/test_rights_registry.py
index 038119c..3e55771 100644
--- a/backend/tests/test_rights_registry.py
+++ b/backend/tests/test_rights_registry.py
@@ -28,6 +28,13 @@ def test_registry_contains_initial_capabilities():
"kairo.action.read",
"kairo.action.manage",
"kairo.workspace.read",
+ "kairo.attention.read",
+ "kairo.blocker.read",
+ "kairo.blocker.manage",
+ "kairo.backlog.read",
+ "kairo.backlog.manage",
+ "kairo.milestone.read",
+ "kairo.milestone.manage",
}
@@ -39,7 +46,7 @@ def test_sync_is_idempotent():
try:
with conn.cursor() as cur:
cur.execute("SELECT COUNT(*) FROM capabilities")
- assert cur.fetchone()[0] == 18
+ assert cur.fetchone()[0] == 25
cur.execute("SELECT COUNT(*) FROM role_capability_grants")
assert cur.fetchone()[0] >= 5
finally:
diff --git a/backend/version.py b/backend/version.py
index ea240b6..77a465a 100644
--- a/backend/version.py
+++ b/backend/version.py
@@ -1,3 +1,3 @@
-APP_VERSION = "0.7.0-ap0.7"
-DB_SCHEMA_VERSION = "006"
+APP_VERSION = "0.8.0-ap0.8"
+DB_SCHEMA_VERSION = "007"
APP_NAME = "jinkendo-kairo"
diff --git a/docs/architecture/Kairo_System_Target_State_v0.1.md b/docs/architecture/Kairo_System_Target_State_v0.1.md
new file mode 100644
index 0000000..38d71f1
--- /dev/null
+++ b/docs/architecture/Kairo_System_Target_State_v0.1.md
@@ -0,0 +1,808 @@
+# Kairo – System Target State
+## v0.1 – Zielzustand des angedachten Gesamtsystems
+
+**Status:** konsolidiertes Zielbild / Nordstern-Dokument
+**Stand:** 2026-07-05
+**Zweck:** Beschreibung des angedachten späteren Zielzustands von Kairo als System, unabhängig von konkreten Implementierungsschnitten.
+
+---
+
+## 1. Executive Summary
+
+Kairo ist langfristig keine einfache Aufgabenliste, keine reine Projektmanagement-App, kein Roadmap-Tool und keine freie Workflow-Automatisierungsplattform.
+
+Kairo ist eine:
+
+```text
+adaptive, methodengeführte Entwicklungs- und Steuerungsplattform
+```
+
+Der Kern ist ein:
+
+```text
+Method-driven Adaptive Steering Core
+```
+
+Kairo soll unterschiedliche Entwicklungs- und Steuerungskontexte führen können:
+
+```text
+- Software- und Produktentwicklung
+- Buch- und Content-Entwicklung
+- Programmsteuerung
+- Projekt- und Initiativensteuerung
+- persönliche Entwicklung
+- Reifegradentwicklung
+- Lernpfade
+- Routinen / Betrieb
+- Review- und Auditkontexte
+- Agentenläufe
+- KI-/Tool-gestützte Steuerung
+```
+
+Die zentrale Leitfrage lautet:
+
+```text
+Welcher nächste Schritt bringt diesen steuerbaren Kontext jetzt am wirksamsten voran?
+```
+
+---
+
+## 2. Grundverständnis
+
+Kairo steuert nicht nur Aufgaben.
+
+Kairo steuert Entwicklungen.
+
+Eine Entwicklung kann sein:
+
+```text
+- ein Produkt
+- ein Programm
+- ein Projekt
+- eine Initiative
+- ein Buch
+- eine Fähigkeit
+- ein Reifegradziel
+- eine Routine
+- ein Review
+- ein Audit
+- ein Agentenauftrag
+```
+
+Kairo hält dabei fest:
+
+```text
+- Was soll entwickelt werden?
+- Warum ist es relevant?
+- Welche Methode gilt?
+- Welche Struktur braucht diese Methode?
+- Wo steht der Kontext aktuell?
+- Was fehlt?
+- Was blockiert?
+- Was braucht Aufmerksamkeit?
+- Was ist der nächste wirksame Schritt?
+- Wer oder welcher Agent soll handeln?
+- Worauf wartet Kairo?
+- Welche Nachweise, Reviews oder Entscheidungen sind nötig?
+- Wann wird angepasst, eskaliert oder abgeschlossen?
+```
+
+---
+
+## 3. Produktidentität
+
+Kairo ist:
+
+```text
+System of Record
+Steering Authority
+Actor-/Tenant-sicherer Kontextgeber
+methodengeführter Steuerungskern
+Roadmap- und Entwicklungsstrukturmodell
+Signal- und NextAction-System
+später Workflow-/Agenten-/Tool-orchestrierend
+```
+
+Kairo ist nicht:
+
+```text
+freie Workflow-Plattform
+beliebiger Automatisierungsbaukasten
+reines Projektmanagementsystem
+reine To-do-Liste
+reines Roadmap-Tool
+reine KI-Agentenplattform
+reines Prompt-System
+```
+
+---
+
+## 4. Kernmodell
+
+Das Zielmodell:
+
+```text
+Steerable Object
+ → Steering Context
+ → Steering Method
+ → Standard Lifecycle / Method Lifecycle
+ → Hook Slugs
+ → Structure Builder
+ → Signal Rules
+ → NextAction Strategy
+ → Assignment Strategy
+ → Waiting / Reminder / Escalation Strategy
+ → Result Intake
+ → Evidence / Review / Decision
+ → Adaptation / Closure
+ → optionale Workflow-Fragmente
+ → optionale Human / Agent / LLM / Tool Steps
+```
+
+---
+
+## 5. Method-driven Adaptive Steering Core
+
+Der Adaptive Steering Core ist der fachliche und technische Kern von Kairo.
+
+Er führt steuerbare Kontexte durch methodengeführte Steuerungsabläufe.
+
+Typischer Ablauf:
+
+```text
+1. Ziel / Entwicklungsabsicht erfassen
+2. Name, Scope und Kontext definieren
+3. passende Basismethode wählen oder vorschlagen
+4. gemäß Methode eine Steuerungsstruktur erzeugen
+ - Roadmap
+ - Backlog
+ - WBS
+ - Reifegradpfad
+ - Kapitelstruktur
+ - Feature Landscape
+ - WorkCycles
+ - Review Gates
+5. Next Best Action ableiten
+6. Actor zuweisen
+7. auf Ergebnis, Rückmeldung, Termin oder Ereignis warten
+8. erinnern, eskalieren oder nachsteuern
+9. Ergebnis zurückführen
+10. prüfen, reviewen, adaptieren oder abschließen
+```
+
+Dieser Ablauf ist der methodengeführte Steuerungsworkflow.
+
+Er ist nicht gleichbedeutend mit einer freien technischen Workflow Engine.
+
+---
+
+## 6. Standard Lifecycle
+
+Kairo besitzt einen minimalen Standard Lifecycle als gemeinsame Basis.
+
+```text
+intake
+method_selection
+structure_setup
+planning
+action_selection
+assignment
+waiting
+result_intake
+validation
+review
+adaptation
+closure
+```
+
+Methoden können diesen Lifecycle:
+
+```text
+- verwenden
+- teilweise überspringen
+- spezialisieren
+- erweitern
+- eigene Hooks ergänzen
+```
+
+Der Standard Lifecycle ist kein starrer Projektprozess, sondern ein gemeinsames Steuerungsraster.
+
+---
+
+## 7. Steering Methods
+
+Eine Methode ist kein Label, sondern ein registriertes Steuerungspaket.
+
+Eine Methode definiert:
+
+```text
+- supported_domains
+- supported_scope_types
+- default_lifecycle
+- required_structures
+- allowed_structures
+- hook_slugs
+- structure_builders
+- attention_rule_providers
+- next_action_strategy
+- assignment_strategy
+- waiting_strategy
+- reminder_strategy
+- escalation_strategy
+- result_intake_strategy
+- review_strategy
+- evidence_policy
+- closure_policy
+- allowed_workflow_fragments
+- allowed_node_types
+- default_configuration
+```
+
+Methoden werden zunächst built-in und code-first angelegt, später versioniert und begrenzt konfigurierbar.
+
+Langfristig können Method Profiles entstehen.
+
+---
+
+## 8. Method Registry
+
+Kairo benötigt eine Method Registry.
+
+Sie funktioniert ähnlich einem Widget-System:
+
+```text
+Eine Methode meldet sich mit Slug, Version, unterstützten Domains,
+Lifecycle, Hooks, Structure Builders, Strategies und Policies an.
+```
+
+Ziel:
+
+```text
+Neue Methoden sollen ergänzt werden können,
+ohne den Core jedes Mal umzubauen.
+```
+
+Beispiele für Methoden:
+
+```text
+simple_action_driven
+product_milestone_driven
+backlog_driven
+wbs_driven
+cycle_based
+content_chapter_based
+maturity_progression
+routine_control
+review_driven
+audit_driven
+agent_controlled
+```
+
+---
+
+## 9. Steering Domains
+
+Kairo unterstützt unterschiedliche Steuerungsdomänen.
+
+Beispiele:
+
+```text
+program
+project
+product_development
+content_development
+personal_development
+maturity_development
+routine_operations
+audit_review
+agent_execution
+custom
+```
+
+Jede Domain kann eigene typische Lifecycles, Roadmap-Strukturen, Methoden, Hooks und Signals haben.
+
+---
+
+## 10. Steerable Objects
+
+Steuerbar sind nicht nur Initiativen.
+
+Mögliche steuerbare Objekte:
+
+```text
+program
+initiative
+project
+roadmap
+roadmap_item
+backlog_item
+action
+blocker
+review
+evidence
+decision
+recurring_element
+work_cycle
+agent_run
+custom
+```
+
+Diese Objekte werden über Steering Contexts an Methoden und Domains gebunden.
+
+---
+
+## 11. Steering Context
+
+Ein Steering Context beschreibt:
+
+```text
+Für welches steuerbare Objekt gilt welche Methode, Domain und Lifecycle-Logik?
+```
+
+Konzeptuell:
+
+```text
+SteeringContext
+ - tenant_id
+ - scope_type
+ - scope_id
+ - steering_domain
+ - lifecycle_model
+ - steering_method
+ - method_profile_id optional
+ - method_version
+ - configuration_json
+ - inherits_from_parent
+ - is_active
+```
+
+Untergeordnete Objekte können den Steering Context ihres Parents erben oder überschreiben.
+
+---
+
+## 12. Roadmap / Development Model
+
+Roadmap ist ein Kernmodell, nicht nur eine UI-Ansicht.
+
+Kairo braucht Roadmaps, um Entwicklungsrichtung und Entwicklungsstruktur abzubilden.
+
+Roadmap-Struktur:
+
+```text
+Roadmap
+ → Roadmap Lane
+ → Roadmap Item
+```
+
+RoadmapItems können sein:
+
+```text
+milestone
+maturity_stage
+feature
+capability
+phase
+release
+chapter
+research_topic
+work_cycle
+learning_step
+review_gate
+decision_gate
+architecture_step
+dependency
+risk_reduction
+custom
+```
+
+Entscheidung:
+
+```text
+Milestone = RoadmapItem(type = milestone)
+```
+
+---
+
+## 13. Roadmap vs. Backlog vs. Action
+
+Zentrale Unterscheidung:
+
+```text
+RoadmapItem = Entwicklungsziel / Orientierung / geplanter Entwicklungsschritt
+BacklogItem = konkreter möglicher Handlungsbedarf
+Action = freigegebene operative Maßnahme
+```
+
+Beispiel Softwareentwicklung:
+
+```text
+RoadmapItem:
+Attention Engine regelbasiert
+
+BacklogItems:
+- Attention DTO definieren
+- Blockierte Maßnahmen auswerten
+- Widget bauen
+
+Actions:
+- Endpoint implementieren
+- Tests schreiben
+```
+
+Beispiel Buchentwicklung:
+
+```text
+RoadmapItem:
+Kapitel 3 – Argumentation ausarbeiten
+
+BacklogItems:
+- Recherche zur These prüfen
+- Beispiel suchen
+- Rohfassung schreiben
+
+Actions:
+- Heute Abschnitt 3.1 entwerfen
+```
+
+Beispiel Reifegradentwicklung:
+
+```text
+RoadmapItem:
+Reifegrad: stabile Seitkick-Beweglichkeit
+
+BacklogItems:
+- Hüftmobilität prüfen
+- Dehnroutine auswählen
+- Technikvideo aufnehmen
+
+Actions:
+- 10 Minuten Mobility durchführen
+```
+
+---
+
+## 14. Structure Builder
+
+Structure Builder erzeugen methodenspezifische Steuerungsstrukturen.
+
+Beispiele:
+
+```text
+roadmap_builder
+milestone_plan_builder
+feature_landscape_builder
+wbs_builder
+backlog_builder
+maturity_path_builder
+chapter_structure_builder
+cycle_structure_builder
+review_gate_builder
+routine_structure_builder
+```
+
+Sie sind zentrale Erweiterungspunkte für Methoden.
+
+Ein Structure Builder legt z. B. an:
+
+```text
+- Roadmap
+- Roadmap Lanes
+- Roadmap Items
+- initiale BacklogItems
+- Review Gates
+- WorkCycles
+```
+
+---
+
+## 15. Hooks
+
+Hooks sind stabile Einhängepunkte in Steuerungsabläufen.
+
+Beispiele:
+
+```text
+on_goal_captured
+on_scope_named
+on_method_selection_required
+on_method_selected
+on_structure_required
+on_roadmap_required
+on_backlog_required
+on_wbs_required
+on_maturity_path_required
+on_content_structure_required
+on_plan_required
+on_dependency_analysis_required
+on_dod_definition_required
+on_next_action_requested
+on_assignment_required
+on_wait_started
+on_due_date_reached
+on_result_overdue
+on_reminder_required
+on_escalation_required
+on_result_received
+on_evidence_required
+on_review_due
+on_reassessment_required
+on_replan_required
+on_closure_requested
+```
+
+An Hooks können später kleine Workflow-Fragmente, Rules, Prompts, Agent Steps oder Tool Steps gebunden werden.
+
+Hooks sind nicht beliebige technische Events, sondern fachlich stabile Steuerungspunkte.
+
+---
+
+## 16. Strategies
+
+Methoden bringen oder referenzieren Strategies.
+
+Zentrale Strategy-Typen:
+
+```text
+NextActionStrategy
+AttentionRuleProvider
+AssignmentStrategy
+WaitingStrategy
+ReminderStrategy
+EscalationStrategy
+ResultIntakeStrategy
+EvidencePolicy
+ReviewStrategy
+ClosurePolicy
+```
+
+Diese verhindern, dass methodenspezifische Logik monolithisch im Core wächst.
+
+---
+
+## 17. Signals
+
+Signals sind sichtbare Ausgaben des Steuerungskerns.
+
+Signal Types:
+
+```text
+attention
+next_action
+review_due
+evidence_required
+escalation
+blocker_signal
+progress_signal
+risk_signal
+decision_required
+dependency_signal
+dod_signal
+workflow_trigger
+```
+
+### Attention
+
+Beantwortet:
+
+```text
+Was braucht Aufmerksamkeit?
+```
+
+### NextActionCandidate
+
+Beantwortet:
+
+```text
+Was ist der nächste wirksame Schritt?
+```
+
+Signals müssen erklärbar sein:
+
+```text
+- warum erzeugt?
+- durch welche Methode?
+- durch welchen Hook?
+- auf welchen Scope bezogen?
+- welche Datenquelle?
+- welche empfohlene Handlung?
+```
+
+---
+
+## 18. Assignment / Waiting / Reminder / Escalation
+
+Der Steuerungskern endet nicht mit dem Erzeugen einer Action.
+
+Kairo muss steuern:
+
+```text
+- Actor auswählen
+- Actor zuweisen
+- auf Ergebnis warten
+- Fälligkeit überwachen
+- erinnern
+- eskalieren
+- externe Ereignisse aufnehmen
+- Agentenergebnisse zurückführen
+```
+
+Actors können sein:
+
+```text
+human
+agent
+working_group
+external_system
+```
+
+---
+
+## 19. Result Intake / Evidence / Review / Decision
+
+Kairo führt Ergebnisse zurück und entscheidet, was daraus folgt.
+
+Mögliche Rückführungen:
+
+```text
+- Action erledigt
+- Ergebnis unvollständig
+- Evidence erforderlich
+- Review erforderlich
+- Decision erforderlich
+- Blocker entstanden
+- RoadmapItem erreicht
+- Reifegrad plausibel erhöht
+- neue NextAction nötig
+```
+
+Evidence, Reviews und Decisions sind zentrale Qualitätssicherungs- und Steuerungsobjekte.
+
+---
+
+## 20. Workflow-Fragmente und Runtime
+
+Kairo soll langfristig Workflow-Fähigkeit haben.
+
+Aber:
+
+```text
+Workflows sind methodengeführte Steuerungsabläufe,
+keine beliebige freie Automatisierungsplattform als Produktkern.
+```
+
+Zielmodell:
+
+```text
+Hook
+ → Workflow Fragment
+ → Node / Step
+ → Result
+ → Rückführung in Kairo
+```
+
+Workflow-Fragmente können später enthalten:
+
+```text
+human_task
+approval
+decision_gate
+review
+reflection
+llm_prompt
+agent_task
+tool_call
+state_transition
+evidence_check
+dod_check
+dependency_analysis
+notification
+reminder
+escalation
+```
+
+---
+
+## 21. Agenten / LLMs / Tools
+
+Kairo bleibt Steering Authority.
+
+Agenten, LLMs und Tools sind Provider.
+
+Sie können später an Hooks und Workflow-Fragmente angebunden werden.
+
+Grundsatz:
+
+```text
+Kairo entscheidet Kontext, Methode, Auftrag und Rückführung.
+Agenten / LLMs / Tools führen einzelne Steps aus.
+```
+
+Agenten sind Actors.
+
+Das ist wichtig für:
+
+```text
+- Verantwortlichkeit
+- Audit
+- Assignments
+- Sichtbarkeit
+- Tenant-Kontext
+```
+
+---
+
+## 22. Security / Governance / Audit
+
+Kairo muss tenant- und actor-sicher bleiben.
+
+Grundsätze:
+
+```text
+- tenant_id immer aus TenantContext
+- keine tenant_id aus Client Input vertrauen
+- Actor-Bezüge tenant-intern validieren
+- Capabilities prüfen
+- Router dünn
+- Services schreiben
+- Data Layer liest
+- Audit-by-design
+- Signals und NextActions erklärbar
+- Agentenaktionen nachvollziehbar
+```
+
+---
+
+## 23. UI-Zielbild
+
+Die UI dient als Steuerungs- und Interaktionsschicht.
+
+Langfristige Views:
+
+```text
+Workspace
+Steering View
+Roadmap View
+Method View
+Attention / NextAction View
+Backlog View
+Action View
+Waiting / Reminder View
+Evidence / Review / Decision View
+Workflow Run View später
+Method/Profile Admin später
+Agent Run View später
+```
+
+Die UI darf nicht die Methode hart kodieren.
+
+Sie muss methodenbewusst und strukturgetrieben sein.
+
+---
+
+## 24. Architektur-Evolutionslinie
+
+Grobe Evolutionslinie:
+
+```text
+1. Existing Foundation
+2. Adaptive Steering Core Foundation
+3. Method Registry / Hook Registry / Standard Lifecycle
+4. Roadmap / Structure Builder Foundation
+5. Signal / NextAction Engine
+6. Assignment / Waiting / Reminder / Escalation
+7. Evidence / Review / Decision
+8. Workflow Fragments and Bindings
+9. Workflow Runtime
+10. Agent / LLM / Tool Providers
+11. Method Profiles
+12. Method Designer / Advanced Configuration
+13. Advanced AI Steering / Next Best Action Ranking
+```
+
+Dies ist keine Sprintplanung, sondern ein Zielbild der technischen Entwicklung.
+
+---
+
+## 25. Zielzustand in einem Satz
+
+Kairo soll ein methodengeführter adaptiver Steuerungskern werden, der Entwicklungen unterschiedlichster Art über registrierbare Methoden, gemeinsame Kernstrukturen, Roadmaps, Hooks, Signals, NextActions, Assignments, Waiting-/Reminder-/Escalation-Mechanismen und später Agent-/Workflow-Fragmente wirksam und nachvollziehbar steuert.
diff --git a/docs/sprints/Sprint0_AP0_8_Assignment_v0.1.md b/docs/sprints/Sprint0_AP0_8_Assignment_v0.1.md
index ca6a713..02d9fc6 100644
--- a/docs/sprints/Sprint0_AP0_8_Assignment_v0.1.md
+++ b/docs/sprints/Sprint0_AP0_8_Assignment_v0.1.md
@@ -1,7 +1,7 @@
# AP0.8 – Operating Model Extension I
## Implementierungsauftrag zur Prüfung v0.1
-**Status:** Entwurf zur Freigabe
+**Status:** ersetzt durch `Sprint0_AP0_8_Assignment_v0.2.md`
**Stand:** 2026-07-05
**Vorgänger:** AP0.7 ✓ · AP0.R1 ✓
**Zielversion:** `0.8.0-ap0.8` · Schema `007`
diff --git a/docs/sprints/Sprint0_AP0_8_Assignment_v0.2.md b/docs/sprints/Sprint0_AP0_8_Assignment_v0.2.md
new file mode 100644
index 0000000..d38976d
--- /dev/null
+++ b/docs/sprints/Sprint0_AP0_8_Assignment_v0.2.md
@@ -0,0 +1,402 @@
+# AP0.8 – Operating Model Extension I
+## Implementierungsauftrag v0.2 (freigegeben)
+
+**Status:** freigegeben zur Umsetzung
+**Stand:** 2026-07-05
+**Vorgänger:** AP0.7 ✓ · AP0.R1 ✓ · KAIRO-ARCH-01 ✓
+**Zielversion:** `0.8.0-ap0.8` · Schema `007`
+**Ersetzt:** `Sprint0_AP0_8_Assignment_v0.1.md` (Entwurf)
+
+---
+
+## Einordnung
+
+AP0.8 ist der **erste fachliche Ausbau nach dem Product Reset** und der **erste Schritt der Operating-Model-Evolutionslinie** gemäß System Target State §24:
+
+```text
+1. Existing Foundation ✓ AP0.1–AP0.7
+2. Adaptive Steering Core — nicht AP0.8 (→ AP1.x)
+3. Method Registry / Hooks — nicht AP0.8
+4. Roadmap / Structure Builder — nicht AP0.8 (Milestone als MVP-Brücke)
+5. Signal / NextAction Engine ← AP0.8a startet hier (regelbasiert, Data Layer)
+6. Assignment / Waiting / … — teilweise vorhanden; Waiting → AP0.9+
+7. Evidence / Review / Decision — AP0.9
+```
+
+Ziel: Kairo sichtbar über `Initiative → Action` hinausheben und die Leitfrage operationalisieren:
+
+> Welcher nächste Schritt bringt ein Vorhaben aktuell am wirkungsvollsten voran?
+
+AP0.8 baut auf AP0.7 auf (Tenant-Invarianten, Actor Directory, Data Layer) und bleibt **regelbasiert ohne KI, ohne Method Registry, ohne SteeringContext**.
+
+---
+
+## Verbindliche Referenzen
+
+Vor Umsetzung lesen:
+
+1. `docs/product/Kairo_Product_Definition_and_MVP_Reset_v0.1.md`
+2. `docs/product/Kairo_Canonical_Operating_Model_v0.1.md` — §5 Objektmodell, §7 Attention Logic, §8 Data Layer
+3. `docs/product/Kairo_Corrected_MVP_Roadmap_v0.1.md` — AP0.8
+4. `docs/architecture/Kairo_System_Target_State_v0.1.md` — §13 Roadmap/Backlog/Action, §17 Signals
+5. `docs/architecture/Kairo_Target_Architecture_Method_Driven_Adaptive_Steering_Core_v0.1.md` — Phasen A/B, AD-TA-08
+6. `docs/architecture/Kairo_Tenant_Invariants_v0.1.md`
+7. `docs/sprints/Sprint0_AP0_7_Completion_Report_v0.2.md`
+
+---
+
+## Abgleich System Target State
+
+| Zielbild (System Target State) | AP0.8-Umsetzung |
+|-------------------------------|-----------------|
+| RoadmapItem = Entwicklungsziel; BacklogItem = möglicher Handlungsbedarf; Action = committete Maßnahme | Backlog und Action **getrennt**; Convert-Flow |
+| Milestone = RoadmapItem(type=milestone) langfristig | **MVP-Brücke:** eigene `milestones`-Tabelle; Konsolidierung → RoadmapItem in Phase C (AD-TA-08) |
+| Attention + NextActionCandidate als Signal-Typen | Read Models in `data_layer/attention.py` |
+| Signals erklärbar (reason, scope, data_source) | DTO-Felder `reason_code`, `scope_type`, `scope_id`, `data_source` |
+| Blocker als Steuerungsobjekt | eigene Tabelle, nicht nur Action-Status |
+| Kein SteeringContext / Method Registry yet | explizit Nicht-Scope |
+| Kein Workflow / KI | explizit Nicht-Scope |
+
+---
+
+## Leitentscheidungen (freigegeben)
+
+| Entscheidung | Entscheidung | Begründung |
+|--------------|--------------|------------|
+| Umsetzungsreihenfolge | 4 Slices (8a→8d) in **einem** AP0.8 | Attention zuerst für Produktwirkung |
+| Blocker | **Eigenes Objekt** | Canonical Model + System Target State §10 |
+| Backlog vs. Action | **Getrennte Tabellen** | §13 Unterscheidung Program Director vs. To-do |
+| Milestone | **Eigene Tabelle (MVP-Brücke)** | Schnell prüfbar; Roadmap-Konsolidierung später |
+| Project | **Nicht in AP0.8** | Milestone direkt an Initiative |
+| Action-Status `ready`/`review_required` | **Nicht in AP0.8** | AP0.9 |
+| Due Dates auf Actions | **Nicht in AP0.8** | Regel 7 stub; Milestone `target_date` optional |
+| Attention: open Blockers | **Ja, Regel 1b** | Ergänzung zu blocked Actions |
+| Capabilities | 7 neue (1 read + 3×2) | Registry-first |
+| `kairo.attention.read` | **Eigene Capability** | Explizit, Grants wie workspace.read |
+| Member manage Blocker/Backlog/Milestone | **Ja** | wie Actions |
+| Convert Backlog→Action | **Pflicht, minimal** | Commit-Flow |
+| Blocker ↔ action.status=blocked | **Optional bei Create** | nicht zwingend synchron |
+| UI | AP0.6b-Optik | kein Redesign |
+
+---
+
+## Empfohlene Umsetzungsreihenfolge
+
+```text
+AP0.8a Attention / NextActionCandidate (Read Model + Widget)
+AP0.8b Blocker (Domäne + API + UI)
+AP0.8c BacklogItem (Domäne + API + UI + convert)
+AP0.8d Milestone minimal (Domäne + API + UI + Attention Regel 6)
+```
+
+Reihenfolge ist verbindlich für die Implementierung.
+
+---
+
+# Teil A — AP0.8a: Attention / NextActionCandidate
+
+## Ziel
+
+Regelbasierte **Signal-Schicht** im Data Layer — erste Instanz der Signal/NextAction-Engine (System Target State §17). Später Refactor in `backend/steering/signals/` mit Method Rule Providers.
+
+## Data Layer
+
+Neue Datei: `backend/data_layer/attention.py`
+
+```text
+get_attention_items(ctx) -> list[AttentionItemDTO]
+get_next_action_candidates(ctx, *, limit=10) -> list[NextActionCandidateDTO]
+```
+
+**AttentionItemDTO** (mit Erklärbarkeit):
+
+```json
+{
+ "kind": "blocked_action | open_blocker | high_priority_action | unassigned_action | initiative_without_next_action | stale_initiative | milestone_at_risk",
+ "severity": "info | warning | critical",
+ "title": "...",
+ "summary": "...",
+ "scope_type": "action | blocker | initiative | milestone",
+ "scope_id": "...",
+ "initiative_id": "...",
+ "action_id": "...",
+ "blocker_id": "...",
+ "milestone_id": "...",
+ "reason_code": "...",
+ "data_source": "actions | blockers | initiatives | milestones | backlog_items"
+}
+```
+
+**NextActionCandidateDTO:**
+
+```json
+{
+ "kind": "assign_action | resolve_blocker | create_action | convert_backlog | review_milestone",
+ "title": "...",
+ "summary": "...",
+ "initiative_id": "...",
+ "action_id": "...",
+ "backlog_item_id": "...",
+ "reason_code": "...",
+ "recommended_action": "..."
+}
+```
+
+### Regeln
+
+| # | Regel | Datenquelle | Severity |
+|---|-------|-------------|----------|
+| 1 | Blockierte Maßnahmen | `actions.status = blocked` | critical |
+| 1b | Offene Blocker | `blockers.status IN (open, in_progress)` | warning |
+| 2 | High-Priority offen | `priority = high`, open/in_progress; **personal** wenn Actor zugewiesen | warning |
+| 3 | Maßnahmen ohne Assignment | keine `action_assignments` | warning |
+| 4 | Vorhaben ohne offene Maßnahme | active/paused, keine open/in_progress/blocked Action | info |
+| 5 | Lange unveränderte Vorhaben | `updated_at` > 14 Tage, status active | info |
+| 6 | Meilenstein at_risk | ab AP0.8d: `milestones.status = at_risk` | warning |
+| 7–9 | Due Dates, Reviews, Recurring | AP0.9+ — stub/leer | — |
+
+**NextAction-Kandidaten (regelbasiert, limitiert):**
+
+- unassigned Action → `assign_action`
+- blocked Action / open Blocker → `resolve_blocker`
+- Initiative ohne offene Maßnahme → `create_action`
+- accepted BacklogItem ohne Action → `convert_backlog`
+
+Sortierung Attention: critical → warning → info. Keine Client-Priorisierung.
+
+## API
+
+```text
+GET /api/workspace/attention
+GET /api/workspace/next-actions?limit=10
+```
+
+Capability: **`kairo.attention.read`**
+
+## Frontend
+
+- **`AttentionWidget`** (Registry-Key `kairo.attention`, order 8, unter Überblick)
+- Links zu Vorhaben/Maßnahmen/Blockern
+- Loading / Error / Empty States
+
+## Tests
+
+- Regeln 1, 1b, 2–5; Regel 6 nach 8d
+- NextAction: convert_backlog, create_action
+- Cross-Tenant isolation
+- Router delegiert an Data Layer
+
+---
+
+# Teil B — AP0.8b: Blocker
+
+## Migration `007_operating_model_extension_i.sql`
+
+```sql
+blockers (
+ id UUID PK,
+ tenant_id UUID NOT NULL REFERENCES tenants(id),
+ initiative_id UUID NOT NULL REFERENCES initiatives(id),
+ action_id UUID NULL REFERENCES actions(id),
+ title VARCHAR(255) NOT NULL,
+ description TEXT DEFAULT '',
+ status VARCHAR(32) NOT NULL, -- open, in_progress, resolved, accepted_risk, dismissed
+ reported_by_actor_id UUID NULL REFERENCES actors(id),
+ created_at, updated_at
+)
+```
+
+Index: `(tenant_id, initiative_id)`, `(tenant_id, status)`
+
+## Service + API
+
+`backend/services/blockers.py` — CRUD, Audit: `blocker.created/updated/status_changed/deleted`
+
+```text
+GET/POST /api/initiatives/{id}/blockers
+GET/PATCH/DELETE /api/blockers/{id}
+```
+
+Capabilities: **`kairo.blocker.read`**, **`kairo.blocker.manage`**
+
+## Frontend
+
+Vorhaben-Detail: Sektion **Blocker**
+
+---
+
+# Teil C — AP0.8c: BacklogItem
+
+## Migration (gleiche `007`)
+
+```sql
+backlog_items (
+ id UUID PK,
+ tenant_id UUID NOT NULL,
+ initiative_id UUID NOT NULL REFERENCES initiatives(id),
+ title VARCHAR(255) NOT NULL,
+ description TEXT DEFAULT '',
+ status VARCHAR(32) NOT NULL, -- new, triaged, accepted, rejected, converted
+ priority VARCHAR(16) DEFAULT 'normal',
+ converted_action_id UUID NULL REFERENCES actions(id),
+ created_at, updated_at
+)
+```
+
+## Service + API
+
+```text
+GET/POST /api/initiatives/{id}/backlog
+GET/PATCH/DELETE /api/backlog/{id}
+POST /api/backlog/{id}/convert-to-action
+```
+
+Capabilities: **`kairo.backlog.read`**, **`kairo.backlog.manage`**
+
+Convert: erzeugt Action, setzt `converted_action_id`, status=converted, Audit.
+
+## Frontend
+
+Vorhaben-Detail: Sektion **Backlog** + Button „In Maßnahme umwandeln“
+
+---
+
+# Teil D — AP0.8d: Milestone minimal
+
+## Migration (gleiche `007`)
+
+```sql
+milestones (
+ id UUID PK,
+ tenant_id UUID NOT NULL,
+ initiative_id UUID NOT NULL REFERENCES initiatives(id),
+ title VARCHAR(255) NOT NULL,
+ goal_description TEXT DEFAULT '',
+ status VARCHAR(32) NOT NULL, -- planned, active, at_risk, reached, moved, discarded
+ target_date DATE NULL,
+ created_at, updated_at
+)
+```
+
+**Hinweis Zielarchitektur:** Langfristig `RoadmapItem(item_type=milestone)`. Diese Tabelle ist bewusste MVP-Brücke (AD-TA-08).
+
+## Service + API
+
+```text
+GET/POST /api/initiatives/{id}/milestones
+GET/PATCH/DELETE /api/milestones/{id}
+```
+
+Capabilities: **`kairo.milestone.read`**, **`kairo.milestone.manage`**
+
+Attention-Regel 6 nach Implementierung aktivieren.
+
+## Frontend
+
+Vorhaben-Detail: Sektion **Meilensteine** (kompakt, kein Gantt)
+
+---
+
+# Capabilities AP0.8
+
+| Capability | Modul | Grants |
+|------------|-------|--------|
+| `kairo.attention.read` | attention | Member+ (wie workspace.read) |
+| `kairo.blocker.read` | blocker | Member+ |
+| `kairo.blocker.manage` | blocker | Member+ |
+| `kairo.backlog.read` | backlog | Member+ |
+| `kairo.backlog.manage` | backlog | Member+ |
+| `kairo.milestone.read` | milestone | Member+ |
+| `kairo.milestone.manage` | milestone | Member+ |
+
+Registrierung: `backend/rights_registrations/operating_model_ops.py`
+Gesamt nach AP0.8: **25 Capabilities** (18 + 7)
+
+---
+
+# Architekturregeln (verbindlich)
+
+1. **Tenant-first** — `tenant_id` aus TenantContext
+2. **Actor-first** — `reported_by_actor_id` optional; Assignments unverändert
+3. **Data Layer read / Services write** — Attention nur Data Layer
+4. **Router dünn** — keine Aggregations-SQL in Routern
+5. **Signals erklärbar** — reason_code, scope_type, data_source in DTOs
+6. **Cross-Tenant → 404**
+7. **Migration `007`** — kein ad-hoc DDL
+8. **Kein Prompt/KI/MCP/Workflow/SteeringContext/Method Registry**
+9. **Product Reset** — drei Ebenen RoadmapItem/BacklogItem/Action respektieren
+
+---
+
+# Nicht-Scope AP0.8
+
+- Project / Program / SteeringContext / Method Registry / Hook Runtime
+- Evidence, Decision, Review, RecurringElement (→ AP0.9)
+- Action-Status `ready`, `review_required`
+- Due Dates auf Actions / Überfälligkeits-Engine
+- KI, MCP, Workflow Engine
+- Roadmap/RoadmapLane/RoadmapItem-Modell (→ Phase C)
+- Workspace-weite Backlog-Navigation
+- AP0.10 Validation Testvorhaben
+
+---
+
+# Tests (Mindestumfang)
+
+## Backend — `backend/tests/test_ap08_operating_model.py`
+
+| Bereich | Tests |
+|---------|-------|
+| Attention | Regeln 1, 1b, 2–6, Sortierung, Cross-Tenant |
+| NextAction | assign, convert_backlog, create_action, limit |
+| Blocker | CRUD, Tenant-Isolation, optional action_id |
+| Backlog | CRUD, convert-to-action |
+| Milestone | CRUD, at_risk in Attention |
+| Capabilities | Member grants, Gate 403, 25 sync |
+| Migration | `007` in test_migrations |
+
+## Frontend
+
+- Vitest: Registry enthält `kairo.attention`
+- Build grün
+
+---
+
+# Abnahmekriterien AP0.8
+
+1. Migration `007`; Schema `007`
+2. Attention/NextAction regelbasiert (Regeln 1–6)
+3. Blocker, Backlog, Milestone CRUD tenant-sicher
+4. `data_layer/attention.py`; Router delegieren
+5. AttentionWidget im Workspace
+6. Vorhaben-Detail: Blocker, Backlog, Meilensteine
+7. Convert Backlog → Action
+8. Cross-Tenant-Tests grün
+9. 25 Capabilities registriert
+10. AP0.6b UX erhalten
+11. Kein Prompt/KI/MCP/Steering-Scope
+12. Abschlussbericht + README
+
+---
+
+# Abschlussbericht (nach Umsetzung)
+
+Siehe Vorlage in v0.1 — Abschnitte 1–15.
+
+---
+
+# Freigegebene Punkte (ehemals offen)
+
+| # | Entscheidung |
+|---|--------------|
+| 1 | Ein AP0.8, 4 interne Slices |
+| 2 | Eigene Capability `kairo.attention.read` |
+| 3 | Blocker/Action-Status optional synchron |
+| 4 | Convert Backlog→Action Pflicht |
+| 5 | Member manage wie Actions |
+| 6 | Initiative draft → AP0.8.1 |
+| 7 | Backlog nur Initiative-Detail |
+
+---
+
+*Freigegeben — Umsetzung gemäß Slice-Reihenfolge 8a→8d.*
diff --git a/docs/sprints/Sprint0_AP0_8_Completion_Report_v0.1.md b/docs/sprints/Sprint0_AP0_8_Completion_Report_v0.1.md
new file mode 100644
index 0000000..0258741
--- /dev/null
+++ b/docs/sprints/Sprint0_AP0_8_Completion_Report_v0.1.md
@@ -0,0 +1,149 @@
+# AP0.8 – Abschlussbericht Operating Model Extension I
+
+**Status:** abgeschlossen (Implementierung) · QA mit DB ausstehend
+**Stand:** 2026-07-05
+**Version:** Backend/Frontend `0.8.0-ap0.8` · Schema `007`
+**Auftrag:** `Sprint0_AP0_8_Assignment_v0.2.md` (abgeglichen mit System Target State v0.1)
+
+---
+
+## 1. Scope und Einordnung
+
+AP0.8 ist der erste fachliche Ausbau nach dem Product Reset. Er operationalisiert die Leitfrage über regelbasierte Attention/NextAction und erweitert das Domänenmodell um Blocker, BacklogItem und Milestone.
+
+Einordnung in der Evolutionslinie (System Target State §24):
+
+- Foundation ✓ (AP0.7)
+- **Signal / NextAction Engine — erste Instanz** ✓ (AP0.8a, Data Layer)
+- Operating Model Phase A ✓ (Blocker, Backlog, Milestone)
+- SteeringContext / Method Registry — bewusst **nicht** in AP0.8
+
+---
+
+## 2. Definition of Done — Prüfmatrix
+
+| Kriterium | Ergebnis | Nachweis |
+|-----------|----------|----------|
+| Migration 007 | ✓ | `007_operating_model_extension_i.sql` |
+| Attention Regeln 1–6 | ✓ | `data_layer/attention.py` |
+| NextActionCandidates | ✓ | `get_next_action_candidates` |
+| Blocker CRUD | ✓ | `services/blockers.py`, Router |
+| Backlog CRUD + Convert | ✓ | `services/backlog.py` |
+| Milestone CRUD | ✓ | `services/milestones.py` |
+| AttentionWidget | ✓ | `widgets/AttentionWidget.jsx` |
+| Initiative-Detail Sektionen | ✓ | Blockers/Backlog/Milestones |
+| 25 Capabilities | ✓ | `operating_model_ops.py` |
+| Frontend Vitest + Build | ✓ | 9/9, build grün |
+| Backend pytest | ⏳ Remote | Gitea `test.yml` nach Push auf `develop` (Pi, Container) |
+| Kein KI/MCP/Steering | ✓ | Scope eingehalten |
+
+---
+
+## 3. Migration 007
+
+Tabellen: `blockers`, `backlog_items`, `milestones` — alle mit `tenant_id`, FK RESTRICT/CASCADE wie AP0.5.
+
+---
+
+## 4. Attention / NextActionCandidate
+
+- Datei: `backend/data_layer/attention.py`
+- API: `GET /api/workspace/attention`, `GET /api/workspace/next-actions`
+- Regeln: blocked_action, open_blocker, high_priority (personal), unassigned, initiative_without_next_action, stale_initiative, milestone_at_risk
+- DTOs mit `reason_code`, `scope_type`, `data_source` (Erklärbarkeit gemäß System Target State §17)
+
+---
+
+## 5. Blocker
+
+- Service + nested/flat API
+- Audit: `blocker.created/updated/status_changed/deleted`
+- Optional: `set_action_blocked` bei Create
+
+---
+
+## 6. BacklogItem
+
+- Getrennt von Actions (RoadmapItem/Backlog/Action-Unterscheidung)
+- Convert-to-action mit Audit `backlog.converted_to_action`
+
+---
+
+## 7. Milestone minimal
+
+- MVP-Brücke (langfristig RoadmapItem — AD-TA-08)
+- Attention-Regel 6 für `at_risk`
+
+---
+
+## 8. Data Layer Erweiterung
+
+Neu: `attention.py` — Read-only, tenant-scoped, keine Router-SQL.
+
+---
+
+## 9. API-Endpunkte
+
+Siehe README AP0.8-Tabelle.
+
+---
+
+## 10. Capabilities
+
+7 neue: `kairo.attention.read`, `kairo.blocker.*`, `kairo.backlog.*`, `kairo.milestone.*`
+Gesamt: 25
+
+---
+
+## 11. Frontend-Integration
+
+- `AttentionWidget` (order 8, capability `kairo.attention.read`)
+- `BlockersSection`, `BacklogSection`, `MilestonesSection` im Vorhaben-Detail
+- Status-Badges erweitert für blocker/backlog/milestone
+
+---
+
+## 12. Tests und Verifikation
+
+| Test | Status |
+|------|--------|
+| `test_ap08_operating_model.py` | 14 Tests — Lauf auf Pi via `test.yml` |
+| `test_rights_registry.py` | aktualisiert (25 Capabilities) |
+| `test_migrations.py` | Migration 007 ergänzt |
+| `conftest.py` | Session-Guard: skip lokal ohne DB, kein Migrations-Timeout |
+| Vitest | 9/9 grün (lokal) |
+| `vite build` | grün (lokal) |
+
+**Verifikation:** Push auf `develop` → `deploy-dev.yml` + `test.yml` (pytest im Backend-Container gegen PostgreSQL im Compose-Stack).
+
+---
+
+## 13. Abweichungen von diesem Auftrag
+
+| Punkt | Abweichung |
+|-------|------------|
+| Auftrag v0.1 | Ersetzt durch v0.2 nach Abgleich System Target State |
+| Attention DTO | Zusätzlich `scope_type`, `data_source`, Regel 1b open_blocker |
+| NextAction DTO | Explizit dokumentiert und implementiert |
+| Milestone API target_date | Router akzeptiert ISO-Date-String |
+
+Keine fachlichen Scope-Abweichungen.
+
+---
+
+## 14. Offene Punkte für AP0.9
+
+- Evidence, Decision, Review, RecurringElement
+- Action-Status `ready`, `review_required`
+- Due Dates auf Actions + Attention Regeln 7–9
+- Attention-Refactor in `backend/steering/signals/` (wenn Steering Core startet)
+
+---
+
+## 15. Empfehlung für AP0.10 / Validation
+
+Nach AP0.9 ein reales Testvorhaben durchspielen (Backlog → Action → Blocker → Meilenstein → Attention). Milestone→RoadmapItem-Konsolidierung als ADP vor Phase C klären.
+
+---
+
+*Implementierung abgeschlossen — Backend-pytest und Smoke auf dem Pi nach Push auf `develop`.*
diff --git a/frontend/package.json b/frontend/package.json
index 15629de..380ad51 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,6 +1,6 @@
{
"name": "kairo-jinkendo-frontend",
- "version": "0.7.0-ap0.7",
+ "version": "0.8.0-ap0.8",
"private": true,
"type": "module",
"scripts": {
diff --git a/frontend/src/api/attention.js b/frontend/src/api/attention.js
new file mode 100644
index 0000000..58090d2
--- /dev/null
+++ b/frontend/src/api/attention.js
@@ -0,0 +1,9 @@
+import { apiFetch } from './client.js'
+
+export function getAttentionItems() {
+ return apiFetch('/api/workspace/attention')
+}
+
+export function getNextActionCandidates(limit = 10) {
+ return apiFetch(`/api/workspace/next-actions?limit=${limit}`)
+}
diff --git a/frontend/src/api/backlog.js b/frontend/src/api/backlog.js
new file mode 100644
index 0000000..7470fc0
--- /dev/null
+++ b/frontend/src/api/backlog.js
@@ -0,0 +1,30 @@
+import { apiFetch } from './client.js'
+
+export function listInitiativeBacklog(initiativeId) {
+ return apiFetch(`/api/initiatives/${initiativeId}/backlog`)
+}
+
+export function createInitiativeBacklogItem(initiativeId, body) {
+ return apiFetch(`/api/initiatives/${initiativeId}/backlog`, {
+ method: 'POST',
+ body: JSON.stringify(body),
+ })
+}
+
+export function updateBacklogItem(backlogItemId, body) {
+ return apiFetch(`/api/backlog/${backlogItemId}`, {
+ method: 'PATCH',
+ body: JSON.stringify(body),
+ })
+}
+
+export function deleteBacklogItem(backlogItemId) {
+ return apiFetch(`/api/backlog/${backlogItemId}`, { method: 'DELETE' })
+}
+
+export function convertBacklogToAction(backlogItemId, body = {}) {
+ return apiFetch(`/api/backlog/${backlogItemId}/convert-to-action`, {
+ method: 'POST',
+ body: JSON.stringify(body),
+ })
+}
diff --git a/frontend/src/api/blockers.js b/frontend/src/api/blockers.js
new file mode 100644
index 0000000..46d33a3
--- /dev/null
+++ b/frontend/src/api/blockers.js
@@ -0,0 +1,23 @@
+import { apiFetch } from './client.js'
+
+export function listInitiativeBlockers(initiativeId) {
+ return apiFetch(`/api/initiatives/${initiativeId}/blockers`)
+}
+
+export function createInitiativeBlocker(initiativeId, body) {
+ return apiFetch(`/api/initiatives/${initiativeId}/blockers`, {
+ method: 'POST',
+ body: JSON.stringify(body),
+ })
+}
+
+export function updateBlocker(blockerId, body) {
+ return apiFetch(`/api/blockers/${blockerId}`, {
+ method: 'PATCH',
+ body: JSON.stringify(body),
+ })
+}
+
+export function deleteBlocker(blockerId) {
+ return apiFetch(`/api/blockers/${blockerId}`, { method: 'DELETE' })
+}
diff --git a/frontend/src/api/milestones.js b/frontend/src/api/milestones.js
new file mode 100644
index 0000000..b386644
--- /dev/null
+++ b/frontend/src/api/milestones.js
@@ -0,0 +1,23 @@
+import { apiFetch } from './client.js'
+
+export function listInitiativeMilestones(initiativeId) {
+ return apiFetch(`/api/initiatives/${initiativeId}/milestones`)
+}
+
+export function createInitiativeMilestone(initiativeId, body) {
+ return apiFetch(`/api/initiatives/${initiativeId}/milestones`, {
+ method: 'POST',
+ body: JSON.stringify(body),
+ })
+}
+
+export function updateMilestone(milestoneId, body) {
+ return apiFetch(`/api/milestones/${milestoneId}`, {
+ method: 'PATCH',
+ body: JSON.stringify(body),
+ })
+}
+
+export function deleteMilestone(milestoneId) {
+ return apiFetch(`/api/milestones/${milestoneId}`, { method: 'DELETE' })
+}
diff --git a/frontend/src/components/BacklogSection.jsx b/frontend/src/components/BacklogSection.jsx
new file mode 100644
index 0000000..3d1cd45
--- /dev/null
+++ b/frontend/src/components/BacklogSection.jsx
@@ -0,0 +1,115 @@
+import { useState } from 'react'
+import {
+ BACKLOG_STATUSES,
+ BACKLOG_STATUS_LABELS,
+} from '../constants/status.js'
+import { StatusBadge } from './StatusBadge.jsx'
+import { PriorityBadge } from './PriorityBadge.jsx'
+import { EmptyState } from './EmptyState.jsx'
+
+export function BacklogSection({
+ items,
+ canManage,
+ onCreate,
+ onUpdateStatus,
+ onConvert,
+ onDelete,
+ busy,
+}) {
+ const [title, setTitle] = useState('')
+ const [showForm, setShowForm] = useState(false)
+
+ async function handleSubmit(e) {
+ e.preventDefault()
+ if (!title.trim()) return
+ await onCreate({ title: title.trim() })
+ setTitle('')
+ setShowForm(false)
+ }
+
+ return (
+ {item.description} {blocker.description} {milestone.goal_description} Ziel: {milestone.target_date}Backlog
+ {canManage && (
+
+ )}
+
+ {items.map((item) => (
+
+ Blocker
+ {canManage && (
+
+ )}
+
+ {blockers.map((blocker) => (
+
+ Meilensteine
+ {canManage && (
+
+ )}
+
+ {milestones.map((milestone) => (
+
+
{item.summary}
} +