diff --git a/README.md b/README.md
index f86fa28..274c175 100644
--- a/README.md
+++ b/README.md
@@ -147,7 +147,21 @@ Vorhaben und Maßnahmen (AP0.5 minimaler Slice):
| `/api/actions/{id}` | PATCH | `kairo.action.manage` | Maßnahme bearbeiten / Status |
| `/api/actions/{id}/assignments` | PUT | `kairo.action.manage` | Actors zuweisen (ersetzt Liste) |
| `/api/actions/{id}` | DELETE | `kairo.action.manage` | Maßnahme löschen |
-| `/api/actions/me/open` | GET | `kairo.action.read` | Offene Maßnahmen des aktuellen Actors |
+| `/api/actions/me/open` | GET | `kairo.action.read` | Offene Maßnahmen des aktuellen Actors (via Data Layer) |
+
+Actor Directory und Workspace Data Layer (AP0.7):
+
+| Endpoint | Methode | Capability | Beschreibung |
+|----------|---------|------------|--------------|
+| `/api/actors` | GET | `kairo.actor.read` | Actors des aktiven Tenants (Filter: `actor_type`, `q`, `include_inactive`) |
+| `/api/actors/{id}` | GET | `kairo.actor.read` | Einzelner Actor (404 cross-tenant) |
+| `/api/workspace/summary` | GET | `kairo.workspace.read` | Workspace-Kennzahlen |
+| `/api/workspace/actions/open` | GET | `kairo.workspace.read` | Meine offenen Maßnahmen (open/in_progress) |
+| `/api/workspace/actions/blocked` | GET | `kairo.workspace.read` | Meine blockierten Maßnahmen |
+| `/api/workspace/initiatives/active` | GET | `kairo.workspace.read` | Aktive/pausierte Vorhaben (`?limit=`) |
+| `/api/workspace/actors/workload` | GET | `kairo.workspace.read` | Actor-Workload (Basic) |
+
+Tenant-Invarianten: `docs/architecture/Kairo_Tenant_Invariants_v0.1.md`
**Status Vorhaben:** `active`, `paused`, `completed`, `archived`
@@ -176,7 +190,7 @@ Nach Login leitet Kairo auf den **Workspace** weiter.
| Route | Beschreibung |
|-------|--------------|
-| `/workspace` | Karten: Kontext, offene Maßnahmen, blockierte Maßnahmen, aktive Vorhaben |
+| `/workspace` | Karten: Kontext, Überblick, offene/blockierte Maßnahmen, aktive Vorhaben |
| `/initiatives` | Vorhabenliste mit offenen Maßnahmen-Zähler |
| `/initiatives/:id` | Vorhaben-Detail mit Maßnahmen CRUD, Status, Zuweisung |
| `/my-actions` | Alle offenen Maßnahmen des aktuellen Actors |
@@ -203,10 +217,9 @@ cd frontend && npm install && npm run test && npm run build
4. Prüfen: Maßnahme verschwindet aus „Meine offenen Maßnahmen“
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
-**Bekannte Lücke:** Kein `GET /api/actors` — Zuweisung nutzt aktuell den Human Actor aus TenantContext (AP0.7).
-
-Abschlussberichte: `docs/sprints/Sprint0_AP0_6_Completion_Report_v0.1.md`, `docs/sprints/Sprint0_AP0_6b_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.1.md`
### Registries (AP0.4)
@@ -260,7 +273,9 @@ Registry-first: Capabilities werden in `backend/rights_registrations/` registrie
|------------|-------|------------------|
| `kairo.admin.access` | platform | Portal-Administration |
| `kairo.tenant.manage` | tenant | Tenant-Verwaltung |
-| `kairo.actor.manage` | tenant | Actor-Verwaltung |
+| `kairo.actor.read` | tenant | Actors im Tenant lesen (Directory) |
+| `kairo.actor.manage` | tenant | Actors verwalten |
+| `kairo.workspace.read` | workspace | Workspace Read-Models / Data Layer |
| `kairo.initiative.read` | initiative | Vorhaben lesen |
| `kairo.initiative.manage` | initiative | Vorhaben verwalten |
| `kairo.action.read` | action | Maßnahmen lesen |
diff --git a/backend/data_layer/__init__.py b/backend/data_layer/__init__.py
new file mode 100644
index 0000000..0c97dcc
--- /dev/null
+++ b/backend/data_layer/__init__.py
@@ -0,0 +1 @@
+"""Kairo Data Layer — read-oriented aggregation for workspace and dashboards."""
diff --git a/backend/data_layer/actions.py b/backend/data_layer/actions.py
new file mode 100644
index 0000000..bb88833
--- /dev/null
+++ b/backend/data_layer/actions.py
@@ -0,0 +1,85 @@
+"""Action read-models for workspace — tenant-scoped, actor-assignment based."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from psycopg2.extras import RealDictCursor
+
+from db import get_connection
+from services.actions import OPEN_ACTION_STATUSES, _attach_assignments, _serialize_row
+from tenant_context import TenantContext
+
+
+def _require_actor(ctx: TenantContext) -> str:
+ if not ctx.actor_id:
+ raise ValueError("Kein Actor im TenantContext")
+ return ctx.actor_id
+
+
+def _list_assigned_actions(
+ *,
+ tenant_id: str,
+ actor_id: str,
+ statuses: list[str],
+) -> list[dict[str, Any]]:
+ conn = get_connection()
+ try:
+ with conn.cursor(cursor_factory=RealDictCursor) as cur:
+ cur.execute(
+ """
+ SELECT a.id, a.tenant_id, a.initiative_id, a.title, a.description,
+ a.status, a.priority, a.created_at, a.updated_at,
+ i.title AS initiative_title
+ FROM actions a
+ JOIN action_assignments aa
+ ON aa.action_id = a.id AND aa.tenant_id = a.tenant_id
+ JOIN initiatives i
+ ON i.id = a.initiative_id AND i.tenant_id = a.tenant_id
+ WHERE a.tenant_id = %s
+ AND aa.actor_id = %s
+ AND a.status = ANY(%s)
+ ORDER BY
+ CASE a.priority
+ WHEN 'high' THEN 0
+ WHEN 'normal' THEN 1
+ WHEN 'low' THEN 2
+ END,
+ a.updated_at DESC
+ """,
+ (tenant_id, actor_id, statuses),
+ )
+ actions = [_serialize_row(dict(row)) for row in cur.fetchall()]
+ finally:
+ conn.close()
+ return _attach_assignments(actions, tenant_id=tenant_id)
+
+
+def get_my_open_actions(ctx: TenantContext) -> list[dict[str, Any]]:
+ """Open + in_progress actions assigned to current actor (excludes blocked)."""
+ actor_id = _require_actor(ctx)
+ return _list_assigned_actions(
+ tenant_id=ctx.tenant_id,
+ actor_id=actor_id,
+ statuses=["open", "in_progress"],
+ )
+
+
+def get_my_blocked_actions(ctx: TenantContext) -> list[dict[str, Any]]:
+ """Blocked actions assigned to current actor."""
+ actor_id = _require_actor(ctx)
+ return _list_assigned_actions(
+ tenant_id=ctx.tenant_id,
+ actor_id=actor_id,
+ statuses=["blocked"],
+ )
+
+
+def get_all_my_open_actions(ctx: TenantContext) -> list[dict[str, Any]]:
+ """All non-terminal open actions (open, in_progress, blocked) — legacy compat."""
+ actor_id = _require_actor(ctx)
+ return _list_assigned_actions(
+ tenant_id=ctx.tenant_id,
+ actor_id=actor_id,
+ statuses=list(OPEN_ACTION_STATUSES),
+ )
diff --git a/backend/data_layer/actors.py b/backend/data_layer/actors.py
new file mode 100644
index 0000000..fac2657
--- /dev/null
+++ b/backend/data_layer/actors.py
@@ -0,0 +1,61 @@
+"""Actor workload read-models — tenant-scoped aggregation."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from psycopg2.extras import RealDictCursor
+
+from db import get_connection
+from tenant_context import TenantContext
+
+
+def get_actor_workload(ctx: TenantContext) -> list[dict[str, Any]]:
+ """Open/blocked/in_progress action counts per active actor in tenant."""
+ conn = get_connection()
+ try:
+ with conn.cursor(cursor_factory=RealDictCursor) as cur:
+ cur.execute(
+ """
+ SELECT
+ a.id AS actor_id,
+ a.name AS display_name,
+ a.actor_type,
+ COUNT(*) FILTER (WHERE act.status = 'open') AS open_actions,
+ COUNT(*) FILTER (WHERE act.status = 'blocked') AS blocked_actions,
+ COUNT(*) FILTER (WHERE act.status = 'in_progress') AS in_progress_actions
+ FROM actors a
+ LEFT JOIN action_assignments aa
+ ON aa.actor_id = a.id AND aa.tenant_id = a.tenant_id
+ LEFT JOIN actions act
+ ON act.id = aa.action_id
+ AND act.tenant_id = aa.tenant_id
+ AND act.status IN ('open', 'in_progress', 'blocked')
+ WHERE a.tenant_id = %s AND a.is_active = TRUE
+ GROUP BY a.id, a.name, a.actor_type
+ ORDER BY
+ CASE a.actor_type
+ WHEN 'human' THEN 0
+ WHEN 'working_group' THEN 1
+ WHEN 'agent' THEN 2
+ ELSE 3
+ END,
+ a.name
+ """,
+ (ctx.tenant_id,),
+ )
+ result = []
+ for row in cur.fetchall():
+ result.append(
+ {
+ "actor_id": str(row["actor_id"]),
+ "display_name": row["display_name"],
+ "actor_type": row["actor_type"],
+ "open_actions": int(row["open_actions"] or 0),
+ "blocked_actions": int(row["blocked_actions"] or 0),
+ "in_progress_actions": int(row["in_progress_actions"] or 0),
+ }
+ )
+ return result
+ finally:
+ conn.close()
diff --git a/backend/data_layer/initiatives.py b/backend/data_layer/initiatives.py
new file mode 100644
index 0000000..319b98d
--- /dev/null
+++ b/backend/data_layer/initiatives.py
@@ -0,0 +1,37 @@
+"""Initiative read-models for workspace."""
+
+from __future__ import annotations
+
+from typing import Any, Optional
+
+from psycopg2.extras import RealDictCursor
+
+from db import get_connection
+from services.initiatives import _serialize_row
+from tenant_context import TenantContext
+
+
+def get_active_initiatives(
+ ctx: TenantContext,
+ *,
+ limit: Optional[int] = None,
+) -> list[dict[str, Any]]:
+ """Active and paused initiatives in the current tenant."""
+ conn = get_connection()
+ try:
+ with conn.cursor(cursor_factory=RealDictCursor) as cur:
+ sql = """
+ SELECT id, tenant_id, title, goal, status, priority,
+ owner_actor_id, created_at, updated_at
+ FROM initiatives
+ WHERE tenant_id = %s AND status IN ('active', 'paused')
+ ORDER BY updated_at DESC, title
+ """
+ params: list[Any] = [ctx.tenant_id]
+ if limit is not None:
+ sql += " LIMIT %s"
+ params.append(limit)
+ cur.execute(sql, params)
+ return [_serialize_row(dict(row)) for row in cur.fetchall()]
+ finally:
+ conn.close()
diff --git a/backend/data_layer/workspace.py b/backend/data_layer/workspace.py
new file mode 100644
index 0000000..dfba52c
--- /dev/null
+++ b/backend/data_layer/workspace.py
@@ -0,0 +1,70 @@
+"""Workspace summary read-model — tenant-scoped KPIs for current actor."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from psycopg2.extras import RealDictCursor
+
+from db import get_connection
+from tenant_context import TenantContext
+
+
+def get_workspace_summary(ctx: TenantContext) -> dict[str, Any]:
+ """
+ Summary for workspace dashboard.
+
+ Personal counts (current actor): open, blocked, recently done.
+ Tenant-wide: active initiatives count.
+ """
+ if not ctx.actor_id:
+ return {
+ "open_actions_count": 0,
+ "blocked_actions_count": 0,
+ "active_initiatives_count": 0,
+ "done_actions_recent_count": 0,
+ }
+
+ conn = get_connection()
+ try:
+ with conn.cursor(cursor_factory=RealDictCursor) as cur:
+ cur.execute(
+ """
+ SELECT
+ COUNT(*) FILTER (
+ WHERE a.status IN ('open', 'in_progress')
+ ) AS open_actions_count,
+ COUNT(*) FILTER (
+ WHERE a.status = 'blocked'
+ ) AS blocked_actions_count,
+ COUNT(*) FILTER (
+ WHERE a.status = 'done'
+ AND a.updated_at >= NOW() - INTERVAL '7 days'
+ ) AS done_actions_recent_count
+ FROM actions a
+ JOIN action_assignments aa
+ ON aa.action_id = a.id AND aa.tenant_id = a.tenant_id
+ WHERE a.tenant_id = %s AND aa.actor_id = %s
+ """,
+ (ctx.tenant_id, ctx.actor_id),
+ )
+ row = dict(cur.fetchone())
+
+ cur.execute(
+ """
+ SELECT COUNT(*) AS active_initiatives_count
+ FROM initiatives
+ WHERE tenant_id = %s AND status IN ('active', 'paused')
+ """,
+ (ctx.tenant_id,),
+ )
+ initiatives_row = dict(cur.fetchone())
+ finally:
+ conn.close()
+
+ return {
+ "open_actions_count": int(row["open_actions_count"] or 0),
+ "blocked_actions_count": int(row["blocked_actions_count"] or 0),
+ "active_initiatives_count": int(initiatives_row["active_initiatives_count"] or 0),
+ "done_actions_recent_count": int(row["done_actions_recent_count"] or 0),
+ }
diff --git a/backend/main.py b/backend/main.py
index b84ad8b..9d2f036 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -53,7 +53,7 @@ app.add_middleware(
allow_headers=["*"],
)
-from routers import actions, auth, config, features, initiatives, me, prompts # noqa: E402
+from routers import actions, actors, auth, config, features, initiatives, me, prompts, workspace # noqa: E402
app.include_router(auth.router)
app.include_router(me.router)
@@ -62,6 +62,8 @@ app.include_router(prompts.router)
app.include_router(config.router)
app.include_router(initiatives.router)
app.include_router(actions.router)
+app.include_router(actors.router)
+app.include_router(workspace.router)
@app.get("/api/health")
diff --git a/backend/rights_registrations/__init__.py b/backend/rights_registrations/__init__.py
index be2dd99..a8aca60 100644
--- a/backend/rights_registrations/__init__.py
+++ b/backend/rights_registrations/__init__.py
@@ -1,5 +1,5 @@
"""Import all module registrations — side effect registers capabilities."""
-from . import initiative_ops, platform, registry_ops, tenant_ops # noqa: F401
+from . import initiative_ops, platform, registry_ops, tenant_ops, workspace_ops # noqa: F401
-__all__ = ["initiative_ops", "platform", "registry_ops", "tenant_ops"]
+__all__ = ["initiative_ops", "platform", "registry_ops", "tenant_ops", "workspace_ops"]
diff --git a/backend/rights_registrations/workspace_ops.py b/backend/rights_registrations/workspace_ops.py
new file mode 100644
index 0000000..85aa93c
--- /dev/null
+++ b/backend/rights_registrations/workspace_ops.py
@@ -0,0 +1,35 @@
+"""Workspace read capabilities (AP0.7)."""
+
+from __future__ import annotations
+
+from rights_registry import CapabilityRegistration, register_capability
+
+register_capability(
+ CapabilityRegistration(
+ key="kairo.actor.read",
+ module="tenant",
+ description="Actors im aktiven Tenant lesen (Directory)",
+ default_grants=(
+ ("portal", "admin"),
+ ("portal", "user"),
+ ("tenant", "owner"),
+ ("tenant", "admin"),
+ ("tenant", "member"),
+ ),
+ )
+)
+
+register_capability(
+ CapabilityRegistration(
+ key="kairo.workspace.read",
+ module="workspace",
+ description="Workspace-Übersichten und aggregierte Read-Models lesen",
+ default_grants=(
+ ("portal", "admin"),
+ ("portal", "user"),
+ ("tenant", "owner"),
+ ("tenant", "admin"),
+ ("tenant", "member"),
+ ),
+ )
+)
diff --git a/backend/routers/actions.py b/backend/routers/actions.py
index 8140914..a29d436 100644
--- a/backend/routers/actions.py
+++ b/backend/routers/actions.py
@@ -1,10 +1,11 @@
-"""Action (Maßnahme) API — AP0.5."""
+"""Action (Maßnahme) API — AP0.5 / AP0.7."""
from __future__ import annotations
from typing import Literal, Optional
from capabilities import require_capability
+from data_layer import actions as dl_actions
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from services import actions as action_service
@@ -28,12 +29,10 @@ class ActionAssignmentsRequest(BaseModel):
def list_my_open_actions(
ctx: TenantContext = Depends(require_capability("kairo.action.read")),
):
- if not ctx.actor_id:
- raise HTTPException(status_code=400, detail="Kein Actor im TenantContext")
- return action_service.list_open_actions_for_actor(
- tenant_id=ctx.tenant_id,
- actor_id=ctx.actor_id,
- )
+ try:
+ return dl_actions.get_all_my_open_actions(ctx)
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.get("/{action_id}")
diff --git a/backend/routers/actors.py b/backend/routers/actors.py
new file mode 100644
index 0000000..5c26b30
--- /dev/null
+++ b/backend/routers/actors.py
@@ -0,0 +1,41 @@
+"""Actor directory API — AP0.7."""
+
+from __future__ import annotations
+
+from typing import Literal, Optional
+
+from capabilities import require_capability
+from fastapi import APIRouter, Depends, HTTPException, Query
+from services import actors as actor_service
+from tenant_context import TenantContext
+
+router = APIRouter(prefix="/api/actors", tags=["actors"])
+
+
+@router.get("")
+def list_actors(
+ actor_type: Optional[Literal["human", "agent", "working_group", "external_system"]] = None,
+ include_inactive: bool = Query(default=False),
+ q: Optional[str] = Query(default=None, max_length=100),
+ ctx: TenantContext = Depends(require_capability("kairo.actor.read")),
+):
+ try:
+ return actor_service.list_actors(
+ tenant_id=ctx.tenant_id,
+ actor_type=actor_type,
+ include_inactive=include_inactive,
+ q=q,
+ )
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+
+
+@router.get("/{actor_id}")
+def get_actor(
+ actor_id: str,
+ ctx: TenantContext = Depends(require_capability("kairo.actor.read")),
+):
+ item = actor_service.get_actor(tenant_id=ctx.tenant_id, actor_id=actor_id)
+ if not item:
+ raise HTTPException(status_code=404, detail="Actor nicht gefunden")
+ return item
diff --git a/backend/routers/workspace.py b/backend/routers/workspace.py
new file mode 100644
index 0000000..1b18754
--- /dev/null
+++ b/backend/routers/workspace.py
@@ -0,0 +1,63 @@
+"""Workspace read API — delegates to Data Layer (AP0.7)."""
+
+from __future__ import annotations
+
+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 initiatives as dl_initiatives
+from data_layer import workspace as dl_workspace
+from fastapi import APIRouter, Depends, HTTPException, Query
+from tenant_context import TenantContext
+
+router = APIRouter(prefix="/api/workspace", tags=["workspace"])
+
+
+def _require_actor_ctx(ctx: TenantContext) -> TenantContext:
+ if not ctx.actor_id:
+ raise HTTPException(status_code=400, detail="Kein Actor im TenantContext")
+ return ctx
+
+
+@router.get("/summary")
+def workspace_summary(
+ ctx: TenantContext = Depends(require_capability("kairo.workspace.read")),
+):
+ return dl_workspace.get_workspace_summary(ctx)
+
+
+@router.get("/actions/open")
+def workspace_open_actions(
+ ctx: TenantContext = Depends(require_capability("kairo.workspace.read")),
+):
+ try:
+ return dl_actions.get_my_open_actions(_require_actor_ctx(ctx))
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+
+
+@router.get("/actions/blocked")
+def workspace_blocked_actions(
+ ctx: TenantContext = Depends(require_capability("kairo.workspace.read")),
+):
+ try:
+ return dl_actions.get_my_blocked_actions(_require_actor_ctx(ctx))
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+
+
+@router.get("/initiatives/active")
+def workspace_active_initiatives(
+ limit: Optional[int] = Query(default=None, ge=1, le=100),
+ ctx: TenantContext = Depends(require_capability("kairo.workspace.read")),
+):
+ return dl_initiatives.get_active_initiatives(ctx, limit=limit)
+
+
+@router.get("/actors/workload")
+def workspace_actor_workload(
+ ctx: TenantContext = Depends(require_capability("kairo.workspace.read")),
+):
+ return dl_actors.get_actor_workload(ctx)
diff --git a/backend/services/actors.py b/backend/services/actors.py
index e2cee3d..7423131 100644
--- a/backend/services/actors.py
+++ b/backend/services/actors.py
@@ -1,4 +1,4 @@
-"""Actor creation helpers — User and Actor remain separate concepts."""
+"""Actor creation and directory helpers — User and Actor remain separate concepts."""
from __future__ import annotations
@@ -10,6 +10,20 @@ from db import get_connection
ActorType = Literal["human", "agent", "working_group", "external_system"]
+ACTOR_TYPES = frozenset({"human", "agent", "working_group", "external_system"})
+
+
+def _serialize_actor(row: dict[str, Any], *, include_user_id: bool = False) -> dict[str, Any]:
+ result = {
+ "id": str(row["id"]),
+ "name": row["name"],
+ "actor_type": row["actor_type"],
+ "is_active": bool(row["is_active"]),
+ }
+ if include_user_id and row.get("user_id"):
+ result["user_id"] = str(row["user_id"])
+ return result
+
def create_actor(
*,
@@ -66,3 +80,70 @@ def get_human_actor(tenant_id: str, user_id: str) -> Optional[dict[str, Any]]:
return result
finally:
conn.close()
+
+
+def list_actors(
+ *,
+ tenant_id: str,
+ actor_type: Optional[str] = None,
+ include_inactive: bool = False,
+ q: Optional[str] = None,
+) -> list[dict[str, Any]]:
+ if actor_type is not None and actor_type not in ACTOR_TYPES:
+ raise ValueError(f"Ungültiger actor_type: {actor_type}")
+
+ conditions = ["tenant_id = %s"]
+ params: list[Any] = [tenant_id]
+
+ if not include_inactive:
+ conditions.append("is_active = TRUE")
+ if actor_type:
+ conditions.append("actor_type = %s")
+ params.append(actor_type)
+ if q:
+ conditions.append("name ILIKE %s")
+ params.append(f"%{q.strip()}%")
+
+ where = " AND ".join(conditions)
+ conn = get_connection()
+ try:
+ with conn.cursor(cursor_factory=RealDictCursor) as cur:
+ cur.execute(
+ f"""
+ SELECT id, actor_type, name, user_id, is_active
+ FROM actors
+ WHERE {where}
+ ORDER BY
+ CASE actor_type
+ WHEN 'human' THEN 0
+ WHEN 'working_group' THEN 1
+ WHEN 'agent' THEN 2
+ ELSE 3
+ END,
+ name
+ """,
+ params,
+ )
+ return [_serialize_actor(dict(row)) for row in cur.fetchall()]
+ finally:
+ conn.close()
+
+
+def get_actor(*, tenant_id: str, actor_id: str) -> Optional[dict[str, Any]]:
+ conn = get_connection()
+ try:
+ with conn.cursor(cursor_factory=RealDictCursor) as cur:
+ cur.execute(
+ """
+ SELECT id, actor_type, name, user_id, is_active
+ FROM actors
+ WHERE id = %s AND tenant_id = %s
+ """,
+ (actor_id, tenant_id),
+ )
+ row = cur.fetchone()
+ if not row:
+ return None
+ return _serialize_actor(dict(row))
+ finally:
+ conn.close()
diff --git a/backend/tests/test_ap07.py b/backend/tests/test_ap07.py
new file mode 100644
index 0000000..f9bb5c6
--- /dev/null
+++ b/backend/tests/test_ap07.py
@@ -0,0 +1,247 @@
+"""AP0.7 — Actor directory, workspace data layer, tenant hardening tests."""
+
+from __future__ import annotations
+
+from auth import AUTH_HEADER
+from services.actors import create_actor
+from tests.factories import provision_user_in_tenant
+from tests.test_initiatives_actions import (
+ _auth,
+ _create_action,
+ _create_initiative,
+ _login,
+)
+
+
+def test_list_actors_tenant_scoped(client):
+ user = provision_user_in_tenant(tenant_role="member")
+ token = _login(client, user)
+ agent = create_actor(tenant_id=user["tenant_id"], actor_type="agent", name="Bot Alpha")
+
+ res = client.get("/api/actors", headers=_auth(token))
+ assert res.status_code == 200
+ body = res.json()
+ ids = {a["id"] for a in body}
+ assert user["actor_id"] in ids
+ assert agent["id"] in ids
+ types = {a["actor_type"] for a in body}
+ assert "human" in types
+ assert "agent" in types
+ for actor in body:
+ assert "user_id" not in actor
+
+
+def test_list_actors_filter_by_type(client):
+ user = provision_user_in_tenant(tenant_role="admin")
+ token = _login(client, user)
+ create_actor(tenant_id=user["tenant_id"], actor_type="agent", name="Filter Agent")
+
+ res = client.get("/api/actors?actor_type=agent", headers=_auth(token))
+ assert res.status_code == 200
+ assert all(a["actor_type"] == "agent" for a in res.json())
+
+
+def test_list_actors_excludes_inactive_by_default(client):
+ user = provision_user_in_tenant(tenant_role="admin")
+ token = _login(client, user)
+ inactive = create_actor(
+ tenant_id=user["tenant_id"], actor_type="external_system", name="Legacy"
+ )
+ conn = __import__("db").get_connection()
+ try:
+ with conn.cursor() as cur:
+ cur.execute("UPDATE actors SET is_active = FALSE WHERE id = %s", (inactive["id"],))
+ conn.commit()
+ finally:
+ conn.close()
+
+ res = client.get("/api/actors", headers=_auth(token))
+ assert inactive["id"] not in {a["id"] for a in res.json()}
+
+ res_active = client.get("/api/actors?include_inactive=true", headers=_auth(token))
+ assert inactive["id"] in {a["id"] for a in res_active.json()}
+
+
+def test_get_actor_detail(client):
+ user = provision_user_in_tenant()
+ token = _login(client, user)
+ agent = create_actor(tenant_id=user["tenant_id"], actor_type="working_group", name="Team A")
+
+ res = client.get(f"/api/actors/{agent['id']}", headers=_auth(token))
+ assert res.status_code == 200
+ assert res.json()["name"] == "Team A"
+ assert res.json()["actor_type"] == "working_group"
+
+
+def test_cross_tenant_actor_directory_isolation(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)
+ agent_a = create_actor(tenant_id=user_a["tenant_id"], actor_type="agent", name="Tenant A Agent")
+
+ list_b = client.get("/api/actors", headers=_auth(token_b)).json()
+ assert agent_a["id"] not in {a["id"] for a in list_b}
+ assert user_a["actor_id"] not in {a["id"] for a in list_b}
+
+ detail = client.get(f"/api/actors/{agent_a['id']}", headers=_auth(token_b))
+ assert detail.status_code == 404
+
+
+def test_member_has_actor_and_workspace_capabilities(client):
+ member = provision_user_in_tenant(tenant_role="member", portal_role="user")
+ token = _login(client, member)
+ ctx = client.get("/api/me/context", headers=_auth(token)).json()
+ assert "kairo.actor.read" in ctx["capabilities"]
+ assert "kairo.workspace.read" in ctx["capabilities"]
+
+
+def test_workspace_summary(client):
+ user = provision_user_in_tenant(tenant_role="member")
+ token = _login(client, user)
+ initiative_id = _create_initiative(client, token).json()["id"]
+
+ open_action = _create_action(
+ client,
+ token,
+ initiative_id,
+ assigned_actor_ids=[user["actor_id"]],
+ ).json()
+ blocked = _create_action(
+ client,
+ token,
+ initiative_id,
+ title="Blocked",
+ assigned_actor_ids=[user["actor_id"]],
+ ).json()
+ client.patch(
+ f"/api/actions/{blocked['id']}",
+ json={"status": "blocked"},
+ headers=_auth(token),
+ )
+ done = _create_action(
+ client,
+ token,
+ initiative_id,
+ title="Done",
+ assigned_actor_ids=[user["actor_id"]],
+ ).json()
+ client.patch(
+ f"/api/actions/{done['id']}",
+ json={"status": "done"},
+ headers=_auth(token),
+ )
+
+ res = client.get("/api/workspace/summary", headers=_auth(token))
+ assert res.status_code == 200
+ summary = res.json()
+ assert summary["open_actions_count"] >= 1
+ assert summary["blocked_actions_count"] >= 1
+ assert summary["active_initiatives_count"] >= 1
+ assert summary["done_actions_recent_count"] >= 1
+ assert open_action["id"] # used
+
+
+def test_workspace_open_and_blocked_actions(client):
+ user = provision_user_in_tenant(tenant_role="member")
+ token = _login(client, user)
+ initiative_id = _create_initiative(client, token).json()["id"]
+
+ open_id = _create_action(
+ client,
+ token,
+ initiative_id,
+ title="Open",
+ assigned_actor_ids=[user["actor_id"]],
+ ).json()["id"]
+ blocked_id = _create_action(
+ client,
+ token,
+ initiative_id,
+ title="Blocked",
+ assigned_actor_ids=[user["actor_id"]],
+ ).json()["id"]
+ client.patch(
+ f"/api/actions/{blocked_id}",
+ json={"status": "blocked"},
+ headers=_auth(token),
+ )
+
+ open_res = client.get("/api/workspace/actions/open", headers=_auth(token))
+ blocked_res = client.get("/api/workspace/actions/blocked", headers=_auth(token))
+ assert open_res.status_code == 200
+ assert blocked_res.status_code == 200
+ assert open_id in {a["id"] for a in open_res.json()}
+ assert blocked_id in {a["id"] for a in blocked_res.json()}
+ assert blocked_id not in {a["id"] for a in open_res.json()}
+
+
+def test_workspace_active_initiatives(client):
+ user = provision_user_in_tenant()
+ token = _login(client, user)
+ initiative_id = _create_initiative(client, token, title="Active One").json()["id"]
+
+ res = client.get("/api/workspace/initiatives/active?limit=5", headers=_auth(token))
+ assert res.status_code == 200
+ ids = {i["id"] for i in res.json()}
+ assert initiative_id in ids
+
+
+def test_workspace_actor_workload(client):
+ user = provision_user_in_tenant(tenant_role="admin")
+ token = _login(client, user)
+ agent = create_actor(tenant_id=user["tenant_id"], actor_type="agent", name="Workload Bot")
+ initiative_id = _create_initiative(client, token).json()["id"]
+ _create_action(
+ client,
+ token,
+ initiative_id,
+ assigned_actor_ids=[agent["id"]],
+ )
+
+ res = client.get("/api/workspace/actors/workload", headers=_auth(token))
+ assert res.status_code == 200
+ workload = {row["actor_id"]: row for row in res.json()}
+ assert agent["id"] in workload
+ assert workload[agent["id"]]["open_actions"] >= 1
+
+
+def test_cross_tenant_workspace_summary_isolation(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"]
+ _create_action(
+ client,
+ token_a,
+ initiative_id,
+ assigned_actor_ids=[user_a["actor_id"]],
+ )
+
+ summary_b = client.get("/api/workspace/summary", headers=_auth(token_b)).json()
+ assert summary_b["open_actions_count"] == 0
+
+ open_b = client.get("/api/workspace/actions/open", headers=_auth(token_b)).json()
+ assert open_b == []
+
+
+def test_assign_action_to_tenant_actor(client):
+ user = provision_user_in_tenant(tenant_role="admin")
+ token = _login(client, user)
+ agent = create_actor(tenant_id=user["tenant_id"], actor_type="agent", name="Assignee")
+ initiative_id = _create_initiative(client, token).json()["id"]
+ action_id = _create_action(client, token, initiative_id).json()["id"]
+
+ res = client.put(
+ f"/api/actions/{action_id}/assignments",
+ json={"actor_ids": [agent["id"]]},
+ headers=_auth(token),
+ )
+ assert res.status_code == 200
+ assert res.json()["assigned_actor_ids"] == [agent["id"]]
+
+
+def test_unauthenticated_workspace_rejected(client):
+ assert client.get("/api/workspace/summary").status_code == 401
+ assert client.get("/api/actors").status_code == 401
diff --git a/backend/tests/test_rights_registry.py b/backend/tests/test_rights_registry.py
index 3a78151..038119c 100644
--- a/backend/tests/test_rights_registry.py
+++ b/backend/tests/test_rights_registry.py
@@ -13,6 +13,7 @@ def test_registry_contains_initial_capabilities():
"kairo.admin.access",
"kairo.tenant.manage",
"kairo.actor.manage",
+ "kairo.actor.read",
"kairo.context.read",
"kairo.entitlements.read",
"kairo.feature.registry.read",
@@ -26,6 +27,7 @@ def test_registry_contains_initial_capabilities():
"kairo.initiative.manage",
"kairo.action.read",
"kairo.action.manage",
+ "kairo.workspace.read",
}
@@ -37,7 +39,7 @@ def test_sync_is_idempotent():
try:
with conn.cursor() as cur:
cur.execute("SELECT COUNT(*) FROM capabilities")
- assert cur.fetchone()[0] == 16
+ assert cur.fetchone()[0] == 18
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 8071c24..ea240b6 100644
--- a/backend/version.py
+++ b/backend/version.py
@@ -1,3 +1,3 @@
-APP_VERSION = "0.6.0-ap0.6"
+APP_VERSION = "0.7.0-ap0.7"
DB_SCHEMA_VERSION = "006"
APP_NAME = "jinkendo-kairo"
diff --git a/docs/architecture/Kairo_Tenant_Invariants_v0.1.md b/docs/architecture/Kairo_Tenant_Invariants_v0.1.md
new file mode 100644
index 0000000..25f1740
--- /dev/null
+++ b/docs/architecture/Kairo_Tenant_Invariants_v0.1.md
@@ -0,0 +1,62 @@
+# Kairo Tenant-Invarianten v0.1
+
+**Status:** verbindlich für Sprint 0 / AP0.7+
+**Stand:** 2026-07-05
+
+---
+
+## Zweck
+
+Dieses Dokument legt Mandanten-Invarianten für Kairo fest, damit fachliche Module tenant-sicher bleiben und Cross-Tenant-Leaks vermieden werden.
+
+Bei Abweichungen: Architecture Decision Proposal.
+
+---
+
+## Invarianten
+
+| # | Invariante | Umsetzung in Kairo |
+|---|------------|-------------------|
+| 1 | Jede fachliche Tabelle hat `tenant_id`, sofern nicht global | `initiatives`, `actions`, `action_assignments`, `actors` — alle mit `tenant_id` |
+| 2 | `tenant_id` kommt aus TenantContext, nicht aus Client-Body | Router nutzen `ctx.tenant_id` aus `require_tenant_context` / `require_capability` |
+| 3 | Jede fachliche Query filtert nach `tenant_id` | Services und Data Layer: `WHERE tenant_id = %s` |
+| 4 | Fremde Objekt-IDs → `404`, nicht `403` | Initiatives, Actions, Actors: Cross-Tenant-Zugriff liefert 404 |
+| 5 | Actor-Zuweisungen nur im selben Tenant | `_actor_in_tenant()` in Services; Assignment-Tests |
+| 6 | User ≠ Actor | Human Actor verknüpft via `actors.user_id`; Assignments an `actor_id` |
+| 7 | Agenten/Arbeitsgruppen/externe Systeme sind tenant-scoped Actors | `actors.tenant_id` + `actor_type` |
+| 8 | Audit Events enthalten mindestens `tenant_id` | `log_audit(..., tenant_id=...)` |
+| 9 | Neue fachliche Tabellen: explizite Tenant-Entscheidung | Nummerierte Migration + Doku-Ergänzung |
+| 10 | Router ohne eigene Mandantenlogik | Delegation an Services (Write) und Data Layer (Read-Aggregation) |
+
+---
+
+## Geprüfte Bereiche (AP0.7)
+
+| Bereich | Ergebnis |
+|---------|----------|
+| `initiatives` CRUD | ✓ tenant_id aus Context |
+| `actions` CRUD | ✓ tenant_id aus Context |
+| `action_assignments` | ✓ tenant_id + Actor-Validierung |
+| `actors` Directory | ✓ nur aktiver Tenant |
+| `/api/actions/me/open` | ✓ via Data Layer, actor-scoped |
+| `/api/workspace/*` | ✓ Data Layer, tenant-scoped |
+| Cross-Tenant Tests | ✓ `test_initiatives_actions`, `test_ap07` |
+
+---
+
+## Anti-Patterns (verboten)
+
+- `tenant_id` aus Request-Body übernehmen ohne Membership-Prüfung
+- Listen ohne `tenant_id`-Filter
+- Cross-Tenant-Existenz via 403 verraten
+- User-ID direkt in Assignments statt Actor-ID
+- Aggregations-SQL in Routern
+- Frontend-Widgets mit fachlicher KPI-Berechnung statt Data-Layer-Endpoints
+
+---
+
+## Referenzen
+
+- `docs/architecture/Kairo_Sprint0_Principle_Gate_v0.1.md` (G-01, G-02, G-03)
+- Shinkan: Access Layer / tenant-sichere Queries
+- AP0.7 Abschlussbericht: `docs/sprints/Sprint0_AP0_7_Completion_Report_v0.1.md`
diff --git a/docs/sprints/Sprint0_AP0_7_Completion_Report_v0.1.md b/docs/sprints/Sprint0_AP0_7_Completion_Report_v0.1.md
new file mode 100644
index 0000000..88f4d55
--- /dev/null
+++ b/docs/sprints/Sprint0_AP0_7_Completion_Report_v0.1.md
@@ -0,0 +1,342 @@
+# AP0.7 – Abschlussbericht Tenant Hardening, Actor Directory & Data Layer Minimum
+
+**Status:** abgeschlossen
+**Stand:** 2026-07-05
+**Branch:** `develop`
+**Version:** Backend `0.7.0-ap0.7` · Frontend `0.7.0-ap0.7` · Schema `006` (unverändert)
+
+---
+
+## 1. Scope und Einordnung
+
+AP0.7 ist ein **Basis-Härtungsauftrag** — keine neuen großen Fachfeatures.
+
+| Teil | Ziel | Status |
+|------|------|--------|
+| A | Tenant Hardening | ✓ Invarianten dokumentiert, bestehende Endpoints geprüft, Tests ergänzt |
+| B | Actor Directory | ✓ `GET /api/actors`, `GET /api/actors/{id}`, ActorSelect mit echter Liste |
+| C | Data Layer Minimum | ✓ `backend/data_layer/`, Workspace-Endpoints, Widget-Integration |
+| D | Audit-Härtung | ✓ Bewertung dokumentiert — `actor_id`-Spalte bewusst zurückgestellt |
+
+**Keine neue Migration** — Read-Models nutzen bestehendes Schema `006`.
+
+---
+
+## 2. Umgesetzte Dateien
+
+### Backend (neu)
+
+| Datei | Zweck |
+|-------|--------|
+| `backend/data_layer/__init__.py` | Data-Layer-Paket |
+| `backend/data_layer/actions.py` | Open/blocked Actions (Read) |
+| `backend/data_layer/initiatives.py` | Active Initiatives (Read) |
+| `backend/data_layer/actors.py` | Actor Workload (Read) |
+| `backend/data_layer/workspace.py` | Workspace Summary |
+| `backend/routers/actors.py` | Actor Directory API |
+| `backend/routers/workspace.py` | Workspace Read API |
+| `backend/rights_registrations/workspace_ops.py` | `kairo.actor.read`, `kairo.workspace.read` |
+| `backend/tests/test_ap07.py` | AP0.7 Tests (14 Tests) |
+
+### Backend (geändert)
+
+| Datei | Änderung |
+|-------|----------|
+| `backend/services/actors.py` | `list_actors`, `get_actor` |
+| `backend/routers/actions.py` | `/me/open` delegiert an Data Layer |
+| `backend/main.py` | Router actors + workspace |
+| `backend/version.py` | `0.7.0-ap0.7` |
+| `backend/tests/test_rights_registry.py` | 18 Capabilities |
+
+### Frontend (neu/geändert)
+
+| Datei | Zweck |
+|-------|--------|
+| `frontend/src/api/workspace.js` | Workspace API-Client |
+| `frontend/src/hooks/useActors.js` | Actor Directory Hook + Fallback |
+| `frontend/src/widgets/WorkspaceSummaryWidget.jsx` | Überblick-Karte |
+| `frontend/src/api/actors.js` | `listActors`, Fallback beibehalten |
+| Widgets, `ActorSelect`, `ActionForm`, `InitiativeDetailPage` | Data-Layer-Endpoints, echte Actors |
+| `frontend/src/registry/widgetRegistry.js` | + `kairo.workspace_summary` |
+
+### Doku
+
+| Datei | Zweck |
+|-------|--------|
+| `docs/architecture/Kairo_Tenant_Invariants_v0.1.md` | 10 Tenant-Invarianten |
+| `README.md` | AP0.7 API, Capabilities |
+
+---
+
+## 3. Tenant Hardening
+
+**Ergebnis der Code-Prüfung:** Bestehende fachliche Services (AP0.5) waren bereits tenant-sicher aufgebaut.
+
+| Prüfpunkt | Ergebnis |
+|-----------|----------|
+| `tenant_id` aus Context, nicht Body | ✓ alle Router |
+| Queries mit `tenant_id`-Filter | ✓ Services + Data Layer |
+| Cross-Tenant → 404 | ✓ Initiatives, Actions, Actors |
+| Assignment tenant-sicher | ✓ `_actor_in_tenant()` |
+| Keine Client-`tenant_id` | ✓ |
+
+**Gefundene Mandantenrisiken:** Keine neuen Lücken in AP0.5-Code. AP0.7 schließt die fehlende Actor-Directory-Isolation und Workspace-Aggregations-Tests ab.
+
+---
+
+## 4. Tenant-Invarianten
+
+Verbindlich dokumentiert in `docs/architecture/Kairo_Tenant_Invariants_v0.1.md` (10 Invarianten + Anti-Patterns).
+
+---
+
+## 5. Actor Directory
+
+### Endpoints
+
+| Endpoint | Capability | Verhalten |
+|----------|------------|-----------|
+| `GET /api/actors` | `kairo.actor.read` | Aktive Actors des Tenants; optional `actor_type`, `q`, `include_inactive` |
+| `GET /api/actors/{id}` | `kairo.actor.read` | Detail; Cross-Tenant → 404 |
+
+### Response-Shape
+
+```json
+{ "id": "...", "name": "...", "actor_type": "human|agent|working_group|external_system", "is_active": true }
+```
+
+`user_id` wird **nicht** exponiert (Datenschutz).
+
+### Default Grants `kairo.actor.read`
+
+Portal Admin/User, Tenant Owner/Admin/Member — Begründung: Assignment-UI und Workspace-Workload für alle operativen Tenant-Mitglieder.
+
+---
+
+## 6. ActorSelect / Assignment UX
+
+- `useActors()` lädt `/api/actors`
+- `ActorSelect` zeigt Name + Typ (deutsche Labels)
+- Mehrfachauswahl für Assignments
+- Fallback auf Human Actor aus Context bei API-Fehler
+- Loading/Error/Empty States
+- Maßnahmen können Agents/Working Groups aus dem Tenant zugewiesen werden
+
+---
+
+## 7. Data Layer Struktur
+
+```text
+backend/data_layer/
+ actions.py — get_my_open_actions, get_my_blocked_actions, get_all_my_open_actions
+ initiatives.py — get_active_initiatives
+ actors.py — get_actor_workload
+ workspace.py — get_workspace_summary
+```
+
+### Abgrenzung
+
+| Schicht | Verantwortung |
+|---------|---------------|
+| Router | HTTP, Capability-Gates, Serialisierung |
+| Data Layer | Read-Aggregation, tenant-scoped Queries, DTOs |
+| Services | Write/CRUD, Domänenvalidierung, Audit |
+
+`/api/actions/me/open` bleibt für Abwärtskompatibilität; delegiert intern an Data Layer.
+
+---
+
+## 8. Workspace Summary
+
+`GET /api/workspace/summary` → `kairo.workspace.read`
+
+```json
+{
+ "open_actions_count": 0,
+ "blocked_actions_count": 0,
+ "active_initiatives_count": 0,
+ "done_actions_recent_count": 0
+}
+```
+
+- Personal (current actor): open, blocked, done (7 Tage via `updated_at`)
+- Tenant-weit: `active_initiatives_count` (status active/paused)
+
+Frontend: `WorkspaceSummaryWidget` im Workspace.
+
+---
+
+## 9. Actor Workload Basic
+
+`GET /api/workspace/actors/workload` — pro aktivem Actor im Tenant:
+
+```json
+{
+ "actor_id": "...",
+ "display_name": "...",
+ "actor_type": "human",
+ "open_actions": 3,
+ "blocked_actions": 1,
+ "in_progress_actions": 2
+}
+```
+
+Noch **kein dediziertes UI-Widget** — Endpoint für spätere Dashboards; API + Tests vorhanden.
+
+---
+
+## 10. Neue/geänderte API-Endpunkte
+
+Siehe README AP0.7-Abschnitt. Keine Write-Endpoints hinzugefügt.
+
+---
+
+## 11. Capability-Nutzung
+
+| Capability | Neu? | Grants | Verwendung |
+|------------|------|--------|------------|
+| `kairo.actor.read` | ✓ | Member+ | Actor Directory |
+| `kairo.workspace.read` | ✓ | Member+ | Workspace Data Layer |
+| `kairo.actor.manage` | — | unverändert | Actor-Verwaltung (AP0.2) |
+| `kairo.action.read` | — | unverändert | CRUD-Read, `/me/open` |
+| `kairo.initiative.read` | — | unverändert | Initiatives |
+
+**Entscheidung:** Kein separates `kairo.workspace.metrics.read` — ein Read-Key für alle Workspace-Read-Models.
+
+Gesamt: **18 Capabilities** (vorher 16).
+
+---
+
+## 12. Audit-Härtung / actor_id-Bewertung
+
+**Ist-Zustand:** `audit_log` hat `user_id`, `tenant_id`, `details` (JSONB). Kein `actor_id`-Spalte.
+
+**Bewertung:**
+
+| Option | Empfehlung |
+|--------|------------|
+| `actor_id` in `details` | ✓ bereits bei Assignments (`actor_ids`) |
+| `actor_id` als Spalte | **Zurückgestellt** — Migration 007 + Backfill; sinnvoll in AP0.8 (Audit/Admin) |
+| Risiko ohne Spalte | Gering für Sprint 0 — Actor aus Context + details ausreichend für Nachvollziehbarkeit |
+
+Keine Migration in AP0.7 (Basis-Härtung ohne Schema-Change).
+
+---
+
+## 13. Frontend-Integration
+
+| Widget/Komponente | Vor AP0.7 | Nach AP0.7 |
+|-------------------|-----------|------------|
+| MyOpenActionsWidget | `/api/actions/me/open` + Client-Filter | `/api/workspace/actions/open` |
+| BlockedActionsWidget | `/me/open` + Filter | `/api/workspace/actions/blocked` |
+| InitiativesWidget | `/api/initiatives` + Client-Filter | `/api/workspace/initiatives/active?limit=5` |
+| WorkspaceSummaryWidget | — | `/api/workspace/summary` |
+| ActorSelect | Context-Fallback only | `/api/actors` + Fallback |
+
+**Keine fachliche KPI-Berechnung mehr in Widgets** (Filter/Slice entfernt).
+
+AP0.6b Look & Feel unverändert.
+
+---
+
+## 14. Tests und Verifikation
+
+### Backend (`test_ap07.py`)
+
+- Actor Directory: List, Filter, Inactive, Detail, Cross-Tenant
+- Workspace: Summary, Open, Blocked, Initiatives, Workload, Cross-Tenant
+- Capabilities: Member hat `kairo.actor.read`, `kairo.workspace.read`
+- Assignment an Tenant-Actor
+
+### Frontend
+
+- Vitest: 9/9 grün (Registry inkl. `workspace_summary`)
+- Build: OK
+
+### CI
+
+pytest im Backend-Container (wie bisher) — erwartet grün mit +14 Tests.
+
+---
+
+## 15. Übernommene Muster aus Mitai
+
+- Zentrale Data-Layer-Schicht für Read/Aggregation
+- Widgets konsumieren vorbereitete Daten
+- Trennung Rohdaten / Kennzahlen / UI
+
+**Nicht übernommen:** Mitai Tracking/Gesundheits-KPIs, Tier/Billing.
+
+---
+
+## 16. Übernommene Muster aus Shinkan
+
+- Tenant-sichere Queries mit Context-`tenant_id`
+- Capability Gates pro Endpoint
+- Cross-Tenant → 404
+- Access-Layer-Denken (Data Layer als Read-Access)
+
+**Nicht übernommen:** Vereins-/Trainingslogik.
+
+---
+
+## 17. Bewusst nicht übernommene Muster
+
+- Analytics-Plattform, Zeitreihen, Forecasting
+- Actor-Admin-UI, Actor-Erstellung in GUI
+- `audit_log.actor_id` Migration
+- Backend-Widget-Registry
+- Program-Director-Algorithmus
+
+---
+
+## 18. Abweichungen von der Spezifikation
+
+| Thema | Abweichung | Begründung |
+|-------|------------|------------|
+| Actor Workload UI | nur API, kein Widget | Endpoint für später; kein UI-Scope |
+| Breakpoint Shell | unverändert 1024px | AP0.6b unverändert |
+| `/api/actions/me/open` | beibehalten | Abwärtskompatibilität |
+| `done_actions_recent_count` | 7-Tage-Fenster via `updated_at` | pragmatisch, dokumentiert |
+
+---
+
+## 19. Offene Entscheidungen
+
+1. **`audit_log.actor_id`** — Migration in AP0.8?
+2. **Actor Workload Widget** — wann in Workspace anzeigen?
+3. **Initiative bearbeiten in UI** — weiter offen aus AP0.6
+4. **Foundation AP0.5 Audit/Admin-UI** — backlog
+
+---
+
+## 20. Empfehlung für AP0.8
+
+Kleinster Nutzwert (Priorität):
+
+1. **Audit/Admin minimal** — Audit-Log lesbar, optional `actor_id`-Spalte
+2. **Initiative bearbeiten in UI** — PATCH existiert
+3. **Actor Workload Widget** — kleine Karte im Workspace
+4. **Kommentare pro Maßnahme** — Kollaboration ohne Domänen-Explosion
+
+Danach Sprint-1-Fachscope gemäß Product Spec.
+
+---
+
+## Abnahme-Checkliste AP0.7
+
+| Kriterium | Erfüllt |
+|-----------|---------|
+| Tenant-Invarianten dokumentiert | ✓ |
+| Fachliche Endpoints tenant-geprüft | ✓ |
+| Cross-Tenant-Tests | ✓ |
+| `GET /api/actors` | ✓ |
+| ActorSelect echte Liste | ✓ |
+| Tenant-Actor-Zuweisung | ✓ |
+| Data Layer Minimum | ✓ |
+| Workspace Summary | ✓ |
+| Blocked/Active zentral | ✓ |
+| Actor Workload Basic | ✓ (API) |
+| Frontend nutzt Data Layer | ✓ |
+| Keine großen Fachfeatures | ✓ |
+| AP0.6b UX erhalten | ✓ |
diff --git a/frontend/package.json b/frontend/package.json
index 7cd5b3f..15629de 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,6 +1,6 @@
{
"name": "kairo-jinkendo-frontend",
- "version": "0.6.1-ap0.6b",
+ "version": "0.7.0-ap0.7",
"private": true,
"type": "module",
"scripts": {
diff --git a/frontend/src/api/actors.js b/frontend/src/api/actors.js
index 723a196..7b18f1b 100644
--- a/frontend/src/api/actors.js
+++ b/frontend/src/api/actors.js
@@ -1,9 +1,22 @@
-/**
- * AP0.6: Kein GET /api/actors im Backend.
- * Actor-Zuweisung nutzt TenantContext (aktueller Human Actor).
- * Siehe Abschlussbericht AP0.6 §9.
- */
+import { apiFetch } from './client.js'
+/**
+ * @param {{ actor_type?: string, include_inactive?: boolean, q?: string }} [params]
+ */
+export function listActors(params = {}) {
+ const search = new URLSearchParams()
+ if (params.actor_type) search.set('actor_type', params.actor_type)
+ if (params.include_inactive) search.set('include_inactive', 'true')
+ if (params.q) search.set('q', params.q)
+ const qs = search.toString()
+ return apiFetch(`/api/actors${qs ? `?${qs}` : ''}`)
+}
+
+export function getActor(id) {
+ return apiFetch(`/api/actors/${id}`)
+}
+
+/** Fallback wenn Actor Directory nicht erreichbar (AP0.6-Kompatibilität). */
export function actorsFromContext(context) {
if (!context?.actor?.id) return []
return [
diff --git a/frontend/src/api/workspace.js b/frontend/src/api/workspace.js
new file mode 100644
index 0000000..b699e53
--- /dev/null
+++ b/frontend/src/api/workspace.js
@@ -0,0 +1,21 @@
+import { apiFetch } from './client.js'
+
+export function getWorkspaceSummary() {
+ return apiFetch('/api/workspace/summary')
+}
+
+export function listWorkspaceOpenActions() {
+ return apiFetch('/api/workspace/actions/open')
+}
+
+export function listWorkspaceBlockedActions() {
+ return apiFetch('/api/workspace/actions/blocked')
+}
+
+export function listWorkspaceActiveInitiatives(limit = 5) {
+ return apiFetch(`/api/workspace/initiatives/active?limit=${limit}`)
+}
+
+export function getActorWorkload() {
+ return apiFetch('/api/workspace/actors/workload')
+}
diff --git a/frontend/src/components/ActionForm.jsx b/frontend/src/components/ActionForm.jsx
index 06dc360..2e79faf 100644
--- a/frontend/src/components/ActionForm.jsx
+++ b/frontend/src/components/ActionForm.jsx
@@ -5,6 +5,10 @@ import { ActorSelect } from './ActorSelect.jsx'
export function ActionForm({
initial = {},
actors = [],
+ actorsLoading = false,
+ actorsError = null,
+ actorsUsedFallback = false,
+ onReloadActors,
onSubmit,
onCancel,
busy = false,
@@ -55,7 +59,14 @@ export function ActionForm({
))}
-