AP0.5: Minimaler Vorhaben- und Maßnahmen-Slice mit Migration 006, CRUD, Assignments und Tests.
Some checks failed
Deploy Development / deploy (push) Successful in 33s
Test Suite / pytest-backend (push) Failing after 28s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 7s
Test Suite / compose-smoke (push) Has been skipped

This commit is contained in:
Lars 2026-07-05 06:15:40 +02:00
parent 53047b6c16
commit afa68150c0
15 changed files with 1544 additions and 7 deletions

View File

@ -132,6 +132,44 @@ Migrationen & idempotente Data-Seeds: [docs/MIGRATIONS.md](docs/MIGRATIONS.md)
| `/api/me/admin/demo` | GET | `X-Auth-Token` + `kairo.admin.access` | Beispiel-Endpoint mit `require_capability` | | `/api/me/admin/demo` | GET | `X-Auth-Token` + `kairo.admin.access` | Beispiel-Endpoint mit `require_capability` |
| `/api/me/tenant` | POST | `X-Auth-Token` | Aktiven Tenant wechseln (nur Memberships) | | `/api/me/tenant` | POST | `X-Auth-Token` | Aktiven Tenant wechseln (nur Memberships) |
Vorhaben und Maßnahmen (AP0.5 minimaler Slice):
| Endpoint | Methode | Capability | Beschreibung |
|----------|---------|------------|--------------|
| `/api/initiatives` | GET | `kairo.initiative.read` | Vorhaben im aktiven Tenant |
| `/api/initiatives` | POST | `kairo.initiative.manage` | Vorhaben anlegen |
| `/api/initiatives/{id}` | GET | `kairo.initiative.read` | Einzelnes Vorhaben |
| `/api/initiatives/{id}` | PATCH | `kairo.initiative.manage` | Vorhaben bearbeiten |
| `/api/initiatives/{id}` | DELETE | `kairo.initiative.manage` | Vorhaben löschen |
| `/api/initiatives/{id}/actions` | GET | `kairo.action.read` | Maßnahmen eines Vorhabens |
| `/api/initiatives/{id}/actions` | POST | `kairo.action.manage` | Maßnahme anlegen |
| `/api/actions/{id}` | GET | `kairo.action.read` | Einzelne Maßnahme |
| `/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 |
**Status Vorhaben:** `active`, `paused`, `completed`, `archived`
**Status Maßnahme:** `open`, `in_progress`, `blocked`, `done`, `discarded`
**Priorität:** `low`, `normal`, `high`
```bash
# Vorhaben anlegen
curl -s -X POST http://localhost:8097/api/initiatives \
-H "X-Auth-Token: TOKEN" -H "Content-Type: application/json" \
-d '{"title":"Erstes Vorhaben","goal":"MVP validieren","priority":"high"}'
# Maßnahme mit Zuweisung
curl -s -X POST http://localhost:8097/api/initiatives/INITIATIVE_ID/actions \
-H "X-Auth-Token: TOKEN" -H "Content-Type: application/json" \
-d '{"title":"API testen","assigned_actor_ids":["ACTOR_ID"]}'
# Meine offenen Maßnahmen
curl -s http://localhost:8097/api/actions/me/open -H "X-Auth-Token: TOKEN"
```
### Registries (AP0.4) ### Registries (AP0.4)
Feature-, Prompt-, Placeholder- und Config-Registry analog zur Rights Registry: Code-Registrierung → Startup-Sync → DB. Feature-, Prompt-, Placeholder- und Config-Registry analog zur Rights Registry: Code-Registrierung → Startup-Sync → DB.
@ -183,6 +221,10 @@ Registry-first: Capabilities werden in `backend/rights_registrations/` registrie
| `kairo.admin.access` | platform | Portal-Administration | | `kairo.admin.access` | platform | Portal-Administration |
| `kairo.tenant.manage` | tenant | Tenant-Verwaltung | | `kairo.tenant.manage` | tenant | Tenant-Verwaltung |
| `kairo.actor.manage` | tenant | Actor-Verwaltung | | `kairo.actor.manage` | tenant | Actor-Verwaltung |
| `kairo.initiative.read` | initiative | Vorhaben lesen |
| `kairo.initiative.manage` | initiative | Vorhaben verwalten |
| `kairo.action.read` | action | Maßnahmen lesen |
| `kairo.action.manage` | action | Maßnahmen verwalten / zuweisen |
| `kairo.context.read` | platform | TenantContext lesen | | `kairo.context.read` | platform | TenantContext lesen |
| `kairo.entitlements.read` | platform | Entitlements lesen | | `kairo.entitlements.read` | platform | Entitlements lesen |

View File

@ -53,13 +53,15 @@ app.add_middleware(
allow_headers=["*"], allow_headers=["*"],
) )
from routers import auth, config, features, me, prompts # noqa: E402 from routers import actions, auth, config, features, initiatives, me, prompts # noqa: E402
app.include_router(auth.router) app.include_router(auth.router)
app.include_router(me.router) app.include_router(me.router)
app.include_router(features.router) app.include_router(features.router)
app.include_router(prompts.router) app.include_router(prompts.router)
app.include_router(config.router) app.include_router(config.router)
app.include_router(initiatives.router)
app.include_router(actions.router)
@app.get("/api/health") @app.get("/api/health")

View File

@ -0,0 +1,49 @@
-- AP0.5: Minimal initiatives (Vorhaben), actions (Maßnahmen), action_assignments
CREATE TABLE initiatives (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
goal TEXT NOT NULL DEFAULT '',
status VARCHAR(32) NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'paused', 'completed', 'archived')),
priority VARCHAR(16) NOT NULL DEFAULT 'normal'
CHECK (priority IN ('low', 'normal', 'high')),
owner_actor_id UUID NOT NULL REFERENCES actors(id) ON DELETE RESTRICT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_initiatives_tenant ON initiatives(tenant_id);
CREATE INDEX idx_initiatives_owner ON initiatives(owner_actor_id);
CREATE TABLE actions (
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 'open'
CHECK (status IN ('open', 'in_progress', 'blocked', 'done', 'discarded')),
priority VARCHAR(16) NOT NULL DEFAULT 'normal'
CHECK (priority IN ('low', 'normal', 'high')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_actions_tenant ON actions(tenant_id);
CREATE INDEX idx_actions_initiative ON actions(initiative_id);
CREATE INDEX idx_actions_status ON actions(tenant_id, status);
CREATE TABLE action_assignments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
action_id UUID NOT NULL REFERENCES actions(id) ON DELETE CASCADE,
actor_id UUID NOT NULL REFERENCES actors(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (action_id, actor_id)
);
CREATE INDEX idx_action_assignments_tenant ON action_assignments(tenant_id);
CREATE INDEX idx_action_assignments_actor ON action_assignments(actor_id);
CREATE INDEX idx_action_assignments_action ON action_assignments(action_id);

View File

@ -1,5 +1,5 @@
"""Import all module registrations — side effect registers capabilities.""" """Import all module registrations — side effect registers capabilities."""
from . import platform, registry_ops, tenant_ops # noqa: F401 from . import initiative_ops, platform, registry_ops, tenant_ops # noqa: F401
__all__ = ["platform", "registry_ops", "tenant_ops"] __all__ = ["initiative_ops", "platform", "registry_ops", "tenant_ops"]

View File

@ -0,0 +1,63 @@
"""Initiative and action capabilities (AP0.5)."""
from __future__ import annotations
from rights_registry import CapabilityRegistration, register_capability
register_capability(
CapabilityRegistration(
key="kairo.initiative.read",
module="initiative",
description="Vorhaben im aktiven Tenant lesen",
default_grants=(
("portal", "admin"),
("portal", "user"),
("tenant", "owner"),
("tenant", "admin"),
("tenant", "member"),
),
)
)
register_capability(
CapabilityRegistration(
key="kairo.initiative.manage",
module="initiative",
description="Vorhaben im aktiven Tenant anlegen und bearbeiten",
default_grants=(
("portal", "admin"),
("tenant", "owner"),
("tenant", "admin"),
("tenant", "member"),
),
)
)
register_capability(
CapabilityRegistration(
key="kairo.action.read",
module="action",
description="Maßnahmen im aktiven Tenant lesen",
default_grants=(
("portal", "admin"),
("portal", "user"),
("tenant", "owner"),
("tenant", "admin"),
("tenant", "member"),
),
)
)
register_capability(
CapabilityRegistration(
key="kairo.action.manage",
module="action",
description="Maßnahmen anlegen, bearbeiten, zuweisen und Status ändern",
default_grants=(
("portal", "admin"),
("tenant", "owner"),
("tenant", "admin"),
("tenant", "member"),
),
)
)

103
backend/routers/actions.py Normal file
View File

@ -0,0 +1,103 @@
"""Action (Maßnahme) API — AP0.5."""
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 actions as action_service
from tenant_context import TenantContext
router = APIRouter(prefix="/api/actions", tags=["actions"])
class ActionUpdateRequest(BaseModel):
title: Optional[str] = Field(default=None, min_length=1, max_length=255)
description: Optional[str] = None
status: Optional[Literal["open", "in_progress", "blocked", "done", "discarded"]] = None
priority: Optional[Literal["low", "normal", "high"]] = None
class ActionAssignmentsRequest(BaseModel):
actor_ids: list[str] = Field(default_factory=list)
@router.get("/me/open")
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,
)
@router.get("/{action_id}")
def get_action(
action_id: str,
ctx: TenantContext = Depends(require_capability("kairo.action.read")),
):
item = action_service.get_action(tenant_id=ctx.tenant_id, action_id=action_id)
if not item:
raise HTTPException(status_code=404, detail="Maßnahme nicht gefunden")
return item
@router.patch("/{action_id}")
def update_action(
action_id: str,
body: ActionUpdateRequest,
ctx: TenantContext = Depends(require_capability("kairo.action.manage")),
):
try:
item = action_service.update_action(
tenant_id=ctx.tenant_id,
action_id=action_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="Maßnahme nicht gefunden")
return item
@router.put("/{action_id}/assignments")
def set_action_assignments(
action_id: str,
body: ActionAssignmentsRequest,
ctx: TenantContext = Depends(require_capability("kairo.action.manage")),
):
try:
item = action_service.set_action_assignments(
tenant_id=ctx.tenant_id,
action_id=action_id,
actor_ids=body.actor_ids,
user_id=ctx.user_id,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if not item:
raise HTTPException(status_code=404, detail="Maßnahme nicht gefunden")
return item
@router.delete("/{action_id}", status_code=204)
def delete_action(
action_id: str,
ctx: TenantContext = Depends(require_capability("kairo.action.manage")),
):
if not action_service.delete_action(
tenant_id=ctx.tenant_id,
action_id=action_id,
user_id=ctx.user_id,
):
raise HTTPException(status_code=404, detail="Maßnahme nicht gefunden")

View File

@ -0,0 +1,155 @@
"""Initiative (Vorhaben) API — AP0.5."""
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 actions as action_service
from services import initiatives as initiative_service
from tenant_context import TenantContext
router = APIRouter(prefix="/api/initiatives", tags=["initiatives"])
class InitiativeCreateRequest(BaseModel):
title: str = Field(min_length=1, max_length=255)
goal: str = ""
status: Literal["active", "paused", "completed", "archived"] = "active"
priority: Literal["low", "normal", "high"] = "normal"
owner_actor_id: Optional[str] = None
class InitiativeUpdateRequest(BaseModel):
title: Optional[str] = Field(default=None, min_length=1, max_length=255)
goal: Optional[str] = None
status: Optional[Literal["active", "paused", "completed", "archived"]] = None
priority: Optional[Literal["low", "normal", "high"]] = None
owner_actor_id: Optional[str] = None
class ActionCreateRequest(BaseModel):
title: str = Field(min_length=1, max_length=255)
description: str = ""
status: Literal["open", "in_progress", "blocked", "done", "discarded"] = "open"
priority: Literal["low", "normal", "high"] = "normal"
assigned_actor_ids: list[str] = Field(default_factory=list)
@router.get("")
def list_initiatives(
ctx: TenantContext = Depends(require_capability("kairo.initiative.read")),
):
return initiative_service.list_initiatives(tenant_id=ctx.tenant_id)
@router.post("", status_code=201)
def create_initiative(
body: InitiativeCreateRequest,
ctx: TenantContext = Depends(require_capability("kairo.initiative.manage")),
):
owner_actor_id = body.owner_actor_id or ctx.actor_id
if not owner_actor_id:
raise HTTPException(status_code=400, detail="Kein Actor im TenantContext")
try:
return initiative_service.create_initiative(
tenant_id=ctx.tenant_id,
title=body.title,
goal=body.goal,
status=body.status,
priority=body.priority,
owner_actor_id=owner_actor_id,
user_id=ctx.user_id,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.get("/{initiative_id}")
def get_initiative(
initiative_id: str,
ctx: TenantContext = Depends(require_capability("kairo.initiative.read")),
):
item = initiative_service.get_initiative(
tenant_id=ctx.tenant_id, initiative_id=initiative_id
)
if not item:
raise HTTPException(status_code=404, detail="Vorhaben nicht gefunden")
return item
@router.patch("/{initiative_id}")
def update_initiative(
initiative_id: str,
body: InitiativeUpdateRequest,
ctx: TenantContext = Depends(require_capability("kairo.initiative.manage")),
):
try:
item = initiative_service.update_initiative(
tenant_id=ctx.tenant_id,
initiative_id=initiative_id,
user_id=ctx.user_id,
title=body.title,
goal=body.goal,
status=body.status,
priority=body.priority,
owner_actor_id=body.owner_actor_id,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if not item:
raise HTTPException(status_code=404, detail="Vorhaben nicht gefunden")
return item
@router.delete("/{initiative_id}", status_code=204)
def delete_initiative(
initiative_id: str,
ctx: TenantContext = Depends(require_capability("kairo.initiative.manage")),
):
if not initiative_service.delete_initiative(
tenant_id=ctx.tenant_id,
initiative_id=initiative_id,
user_id=ctx.user_id,
):
raise HTTPException(status_code=404, detail="Vorhaben nicht gefunden")
@router.get("/{initiative_id}/actions")
def list_initiative_actions(
initiative_id: str,
ctx: TenantContext = Depends(require_capability("kairo.action.read")),
):
if not initiative_service.get_initiative(
tenant_id=ctx.tenant_id, initiative_id=initiative_id
):
raise HTTPException(status_code=404, detail="Vorhaben nicht gefunden")
return action_service.list_actions_for_initiative(
tenant_id=ctx.tenant_id, initiative_id=initiative_id
)
@router.post("/{initiative_id}/actions", status_code=201)
def create_initiative_action(
initiative_id: str,
body: ActionCreateRequest,
ctx: TenantContext = Depends(require_capability("kairo.action.manage")),
):
try:
return action_service.create_action(
tenant_id=ctx.tenant_id,
initiative_id=initiative_id,
title=body.title,
description=body.description,
status=body.status,
priority=body.priority,
assigned_actor_ids=body.assigned_actor_ids,
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

398
backend/services/actions.py Normal file
View File

@ -0,0 +1,398 @@
"""Action (Maßnahme) service — tenant-scoped CRUD, assignments, open actions."""
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 PRIORITIES, get_initiative
ActionStatus = Literal["open", "in_progress", "blocked", "done", "discarded"]
ACTION_STATUSES = frozenset({"open", "in_progress", "blocked", "done", "discarded"})
OPEN_ACTION_STATUSES = frozenset({"open", "in_progress", "blocked"})
def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
result = dict(row)
for key in ("id", "tenant_id", "initiative_id", "owner_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_action_status(status: str) -> None:
if status not in ACTION_STATUSES:
raise ValueError(f"Ungültiger Action-Status: {status}")
def _validate_priority(priority: str) -> None:
if priority not in PRIORITIES:
raise ValueError(f"Ungültige Priorität: {priority}")
def _load_assignments(*, tenant_id: str, action_ids: list[str]) -> dict[str, list[str]]:
if not action_ids:
return {}
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT action_id, actor_id
FROM action_assignments
WHERE tenant_id = %s AND action_id = ANY(%s::uuid[])
ORDER BY created_at
""",
(tenant_id, action_ids),
)
result: dict[str, list[str]] = {aid: [] for aid in action_ids}
for row in cur.fetchall():
result[str(row["action_id"])].append(str(row["actor_id"]))
return result
finally:
conn.close()
def _attach_assignments(
actions: list[dict[str, Any]], *, tenant_id: str
) -> list[dict[str, Any]]:
if not actions:
return actions
action_ids = [a["id"] for a in actions]
assignments = _load_assignments(tenant_id=tenant_id, action_ids=action_ids)
for action in actions:
action["assigned_actor_ids"] = assignments.get(action["id"], [])
return actions
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 create_action(
*,
tenant_id: str,
initiative_id: str,
title: str,
description: str = "",
status: ActionStatus = "open",
priority: str = "normal",
assigned_actor_ids: Optional[list[str]] = None,
user_id: Optional[str] = None,
) -> dict[str, Any]:
title = title.strip()
if not title:
raise ValueError("Titel ist erforderlich")
_validate_action_status(status)
_validate_priority(priority)
if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
raise ValueError("Initiative nicht gefunden")
assigned_actor_ids = assigned_actor_ids or []
for actor_id in assigned_actor_ids:
if not _actor_in_tenant(tenant_id=tenant_id, actor_id=actor_id):
raise ValueError(f"Actor {actor_id} gehört nicht zum Tenant")
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
INSERT INTO actions (
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, created_at, updated_at
""",
(tenant_id, initiative_id, title, description, status, priority),
)
row = _serialize_row(dict(cur.fetchone()))
for actor_id in assigned_actor_ids:
cur.execute(
"""
INSERT INTO action_assignments (tenant_id, action_id, actor_id)
VALUES (%s, %s, %s)
""",
(tenant_id, row["id"], actor_id),
)
conn.commit()
finally:
conn.close()
row["assigned_actor_ids"] = assigned_actor_ids
log_audit(
"action.created",
user_id=user_id,
tenant_id=tenant_id,
details={
"action_id": row["id"],
"initiative_id": initiative_id,
"title": title,
"status": status,
"assigned_actor_ids": assigned_actor_ids,
},
)
return row
def list_actions_for_initiative(
*, tenant_id: str, initiative_id: str
) -> list[dict[str, Any]]:
if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
return []
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT id, tenant_id, initiative_id, title, description,
status, priority, created_at, updated_at
FROM actions
WHERE tenant_id = %s AND initiative_id = %s
ORDER BY updated_at DESC, title
""",
(tenant_id, initiative_id),
)
actions = [_serialize_row(dict(row)) for row in cur.fetchall()]
finally:
conn.close()
return _attach_assignments(actions, tenant_id=tenant_id)
def get_action(*, tenant_id: str, action_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, created_at, updated_at
FROM actions
WHERE id = %s AND tenant_id = %s
""",
(action_id, tenant_id),
)
row = cur.fetchone()
if not row:
return None
action = _serialize_row(dict(row))
finally:
conn.close()
return _attach_assignments([action], tenant_id=tenant_id)[0]
def update_action(
*,
tenant_id: str,
action_id: str,
user_id: Optional[str] = None,
title: Optional[str] = None,
description: Optional[str] = None,
status: Optional[ActionStatus] = None,
priority: Optional[str] = None,
) -> Optional[dict[str, Any]]:
existing = get_action(tenant_id=tenant_id, action_id=action_id)
if not existing:
return None
updates: list[str] = []
params: list[Any] = []
old_status = existing["status"]
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_action_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([action_id, tenant_id])
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
f"""
UPDATE actions
SET {", ".join(updates)}
WHERE id = %s AND tenant_id = %s
RETURNING id, tenant_id, initiative_id, title, description,
status, priority, created_at, updated_at
""",
params,
)
row = cur.fetchone()
if not row:
return None
result = _serialize_row(dict(row))
conn.commit()
finally:
conn.close()
result = get_action(tenant_id=tenant_id, action_id=action_id)
assert result is not None
log_audit(
"action.updated",
user_id=user_id,
tenant_id=tenant_id,
details={"action_id": action_id, "fields": updates},
)
if status is not None and status != old_status:
log_audit(
"action.status_changed",
user_id=user_id,
tenant_id=tenant_id,
details={
"action_id": action_id,
"from_status": old_status,
"to_status": status,
},
)
return result
def set_action_assignments(
*,
tenant_id: str,
action_id: str,
actor_ids: list[str],
user_id: Optional[str] = None,
) -> Optional[dict[str, Any]]:
existing = get_action(tenant_id=tenant_id, action_id=action_id)
if not existing:
return None
unique_actor_ids = list(dict.fromkeys(actor_ids))
for actor_id in unique_actor_ids:
if not _actor_in_tenant(tenant_id=tenant_id, actor_id=actor_id):
raise ValueError(f"Actor {actor_id} gehört nicht zum Tenant")
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"DELETE FROM action_assignments WHERE action_id = %s AND tenant_id = %s",
(action_id, tenant_id),
)
for actor_id in unique_actor_ids:
cur.execute(
"""
INSERT INTO action_assignments (tenant_id, action_id, actor_id)
VALUES (%s, %s, %s)
""",
(tenant_id, action_id, actor_id),
)
cur.execute(
"UPDATE actions SET updated_at = NOW() WHERE id = %s AND tenant_id = %s",
(action_id, tenant_id),
)
conn.commit()
finally:
conn.close()
log_audit(
"action.assigned",
user_id=user_id,
tenant_id=tenant_id,
details={
"action_id": action_id,
"actor_ids": unique_actor_ids,
"previous_actor_ids": existing.get("assigned_actor_ids", []),
},
)
return get_action(tenant_id=tenant_id, action_id=action_id)
def delete_action(
*,
tenant_id: str,
action_id: str,
user_id: Optional[str] = None,
) -> bool:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"DELETE FROM actions WHERE id = %s AND tenant_id = %s RETURNING id",
(action_id, tenant_id),
)
deleted = cur.fetchone() is not None
conn.commit()
finally:
conn.close()
if deleted:
log_audit(
"action.deleted",
user_id=user_id,
tenant_id=tenant_id,
details={"action_id": action_id},
)
return deleted
def list_open_actions_for_actor(
*, tenant_id: str, actor_id: 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, list(OPEN_ACTION_STATUSES)),
)
actions = [_serialize_row(dict(row)) for row in cur.fetchall()]
finally:
conn.close()
return _attach_assignments(actions, tenant_id=tenant_id)

View File

@ -0,0 +1,252 @@
"""Initiative (Vorhaben) service — tenant-scoped CRUD."""
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
InitiativeStatus = Literal["active", "paused", "completed", "archived"]
Priority = Literal["low", "normal", "high"]
INITIATIVE_STATUSES = frozenset({"active", "paused", "completed", "archived"})
PRIORITIES = frozenset({"low", "normal", "high"})
def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
result = dict(row)
for key in ("id", "tenant_id", "owner_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_initiative_status(status: str) -> None:
if status not in INITIATIVE_STATUSES:
raise ValueError(f"Ungültiger Initiative-Status: {status}")
def _validate_priority(priority: str) -> None:
if priority not in PRIORITIES:
raise ValueError(f"Ungültige Priorität: {priority}")
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 create_initiative(
*,
tenant_id: str,
title: str,
owner_actor_id: str,
goal: str = "",
status: InitiativeStatus = "active",
priority: Priority = "normal",
user_id: Optional[str] = None,
) -> dict[str, Any]:
title = title.strip()
if not title:
raise ValueError("Titel ist erforderlich")
_validate_initiative_status(status)
_validate_priority(priority)
if not _actor_in_tenant(tenant_id=tenant_id, actor_id=owner_actor_id):
raise ValueError("Owner-Actor gehört nicht zum Tenant")
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
INSERT INTO initiatives (
tenant_id, title, goal, status, priority, owner_actor_id
)
VALUES (%s, %s, %s, %s, %s, %s)
RETURNING id, tenant_id, title, goal, status, priority,
owner_actor_id, created_at, updated_at
""",
(tenant_id, title, goal, status, priority, owner_actor_id),
)
row = _serialize_row(dict(cur.fetchone()))
conn.commit()
finally:
conn.close()
log_audit(
"initiative.created",
user_id=user_id,
tenant_id=tenant_id,
details={"initiative_id": row["id"], "title": title, "status": status},
)
return row
def list_initiatives(*, tenant_id: str) -> list[dict[str, Any]]:
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT id, tenant_id, title, goal, status, priority,
owner_actor_id, created_at, updated_at
FROM initiatives
WHERE tenant_id = %s
ORDER BY updated_at DESC, title
""",
(tenant_id,),
)
return [_serialize_row(dict(row)) for row in cur.fetchall()]
finally:
conn.close()
def get_initiative(*, tenant_id: str, initiative_id: str) -> Optional[dict[str, Any]]:
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT id, tenant_id, title, goal, status, priority,
owner_actor_id, created_at, updated_at
FROM initiatives
WHERE id = %s AND tenant_id = %s
""",
(initiative_id, tenant_id),
)
row = cur.fetchone()
return _serialize_row(dict(row)) if row else None
finally:
conn.close()
def update_initiative(
*,
tenant_id: str,
initiative_id: str,
user_id: Optional[str] = None,
title: Optional[str] = None,
goal: Optional[str] = None,
status: Optional[InitiativeStatus] = None,
priority: Optional[Priority] = None,
owner_actor_id: Optional[str] = None,
) -> Optional[dict[str, Any]]:
existing = get_initiative(tenant_id=tenant_id, initiative_id=initiative_id)
if not existing:
return None
updates: list[str] = []
params: list[Any] = []
old_status = existing["status"]
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 is not None:
updates.append("goal = %s")
params.append(goal)
if status is not None:
_validate_initiative_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 owner_actor_id is not None:
if not _actor_in_tenant(tenant_id=tenant_id, actor_id=owner_actor_id):
raise ValueError("Owner-Actor gehört nicht zum Tenant")
updates.append("owner_actor_id = %s")
params.append(owner_actor_id)
if not updates:
return existing
updates.append("updated_at = NOW()")
params.extend([initiative_id, tenant_id])
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
f"""
UPDATE initiatives
SET {", ".join(updates)}
WHERE id = %s AND tenant_id = %s
RETURNING id, tenant_id, title, goal, status, priority,
owner_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(
"initiative.updated",
user_id=user_id,
tenant_id=tenant_id,
details={"initiative_id": initiative_id, "fields": updates},
)
if status is not None and status != old_status:
log_audit(
"initiative.status_changed",
user_id=user_id,
tenant_id=tenant_id,
details={
"initiative_id": initiative_id,
"from_status": old_status,
"to_status": status,
},
)
return result
def delete_initiative(
*,
tenant_id: str,
initiative_id: str,
user_id: Optional[str] = None,
) -> bool:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"DELETE FROM initiatives WHERE id = %s AND tenant_id = %s RETURNING id",
(initiative_id, tenant_id),
)
deleted = cur.fetchone() is not None
conn.commit()
finally:
conn.close()
if deleted:
log_audit(
"initiative.deleted",
user_id=user_id,
tenant_id=tenant_id,
details={"initiative_id": initiative_id},
)
return deleted

View File

@ -0,0 +1,316 @@
"""Initiatives and actions tests (AP0.5)."""
from __future__ import annotations
import pytest
from auth import AUTH_HEADER
from db import get_connection
from services.actors import create_actor
from tests.factories import provision_user_in_tenant
def _login(client, user: dict) -> str:
res = client.post(
"/api/auth/login",
json={"email": user["email"], "password": user["password"]},
)
assert res.status_code == 200
return res.json()["token"]
def _auth(token: str) -> dict:
return {AUTH_HEADER: token}
def _create_initiative(client, token: str, *, title: str = "Test Vorhaben", **kwargs):
body = {"title": title, **kwargs}
return client.post("/api/initiatives", json=body, headers=_auth(token))
def _create_action(client, token: str, initiative_id: str, *, title: str = "Test Maßnahme", **kwargs):
body = {"title": title, **kwargs}
return client.post(
f"/api/initiatives/{initiative_id}/actions",
json=body,
headers=_auth(token),
)
def test_initiative_crud(client):
user = provision_user_in_tenant(tenant_role="member")
token = _login(client, user)
created = _create_initiative(
client,
token,
title="Kairo MVP",
goal="Erster Slice",
priority="high",
)
assert created.status_code == 201
initiative = created.json()
assert initiative["title"] == "Kairo MVP"
assert initiative["goal"] == "Erster Slice"
assert initiative["status"] == "active"
assert initiative["priority"] == "high"
assert initiative["owner_actor_id"] == user["actor_id"]
assert initiative["tenant_id"] == user["tenant_id"]
listed = client.get("/api/initiatives", headers=_auth(token))
assert listed.status_code == 200
assert any(i["id"] == initiative["id"] for i in listed.json())
fetched = client.get(f"/api/initiatives/{initiative['id']}", headers=_auth(token))
assert fetched.status_code == 200
updated = client.patch(
f"/api/initiatives/{initiative['id']}",
json={"status": "paused", "goal": "Angepasst"},
headers=_auth(token),
)
assert updated.status_code == 200
assert updated.json()["status"] == "paused"
assert updated.json()["goal"] == "Angepasst"
deleted = client.delete(f"/api/initiatives/{initiative['id']}", headers=_auth(token))
assert deleted.status_code == 204
assert client.get(f"/api/initiatives/{initiative['id']}", headers=_auth(token)).status_code == 404
def test_action_crud_and_status_change(client):
user = provision_user_in_tenant(tenant_role="member")
token = _login(client, user)
initiative_id = _create_initiative(client, token).json()["id"]
created = _create_action(
client,
token,
initiative_id,
title="Status prüfen",
assigned_actor_ids=[user["actor_id"]],
)
assert created.status_code == 201
action = created.json()
assert action["status"] == "open"
assert action["assigned_actor_ids"] == [user["actor_id"]]
listed = client.get(f"/api/initiatives/{initiative_id}/actions", headers=_auth(token))
assert listed.status_code == 200
assert len(listed.json()) == 1
updated = client.patch(
f"/api/actions/{action['id']}",
json={"status": "in_progress"},
headers=_auth(token),
)
assert updated.status_code == 200
assert updated.json()["status"] == "in_progress"
done = client.patch(
f"/api/actions/{action['id']}",
json={"status": "done"},
headers=_auth(token),
)
assert done.status_code == 200
assert done.json()["status"] == "done"
def test_action_assignments(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="Helper Agent")
initiative_id = _create_initiative(client, token).json()["id"]
action_id = _create_action(client, token, initiative_id).json()["id"]
assigned = client.put(
f"/api/actions/{action_id}/assignments",
json={"actor_ids": [user["actor_id"], agent["id"]]},
headers=_auth(token),
)
assert assigned.status_code == 200
body = assigned.json()
assert set(body["assigned_actor_ids"]) == {user["actor_id"], agent["id"]}
def test_my_open_actions(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,
title="Offen",
assigned_actor_ids=[user["actor_id"]],
).json()
in_progress = _create_action(
client,
token,
initiative_id,
title="In Arbeit",
assigned_actor_ids=[user["actor_id"]],
).json()
client.patch(
f"/api/actions/{in_progress['id']}",
json={"status": "in_progress"},
headers=_auth(token),
)
done_action = _create_action(
client,
token,
initiative_id,
title="Erledigt",
assigned_actor_ids=[user["actor_id"]],
).json()
client.patch(
f"/api/actions/{done_action['id']}",
json={"status": "done"},
headers=_auth(token),
)
unassigned = _create_action(
client,
token,
initiative_id,
title="Nicht zugewiesen",
).json()
open_res = client.get("/api/actions/me/open", headers=_auth(token))
assert open_res.status_code == 200
open_ids = {a["id"] for a in open_res.json()}
assert open_action["id"] in open_ids
assert in_progress["id"] in open_ids
assert done_action["id"] not in open_ids
assert unassigned["id"] not in open_ids
for item in open_res.json():
assert "initiative_title" in item
def test_tenant_isolation_initiatives(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, title="Tenant A only").json()["id"]
assert client.get(f"/api/initiatives/{initiative_id}", headers=_auth(token_b)).status_code == 404
assert (
client.patch(
f"/api/initiatives/{initiative_id}",
json={"title": "Hack"},
headers=_auth(token_b),
).status_code
== 404
)
assert client.get("/api/initiatives", headers=_auth(token_b)).json() == []
def test_tenant_isolation_actions(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"]
action_id = _create_action(
client,
token_a,
initiative_id,
assigned_actor_ids=[user_a["actor_id"]],
).json()["id"]
assert client.get(f"/api/actions/{action_id}", headers=_auth(token_b)).status_code == 404
assert (
client.patch(
f"/api/actions/{action_id}",
json={"status": "done"},
headers=_auth(token_b),
).status_code
== 404
)
assert client.get("/api/actions/me/open", headers=_auth(token_b)).json() == []
def test_cross_tenant_actor_assignment_rejected(client):
user_a = provision_user_in_tenant()
user_b = provision_user_in_tenant()
token_a = _login(client, user_a)
initiative_id = _create_initiative(client, token_a).json()["id"]
action_id = _create_action(client, token_a, initiative_id).json()["id"]
res = client.put(
f"/api/actions/{action_id}/assignments",
json={"actor_ids": [user_b["actor_id"]]},
headers=_auth(token_a),
)
assert res.status_code == 400
def test_audit_on_create_and_status_change(client):
user = provision_user_in_tenant()
token = _login(client, user)
initiative_id = _create_initiative(client, token, title="Audit Test").json()["id"]
action_id = _create_action(client, token, initiative_id).json()["id"]
client.patch(
f"/api/actions/{action_id}",
json={"status": "blocked"},
headers=_auth(token),
)
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT action FROM audit_log
WHERE tenant_id = %s::uuid
AND action IN (
'initiative.created', 'action.created', 'action.status_changed'
)
ORDER BY created_at
""",
(user["tenant_id"],),
)
actions = {row[0] for row in cur.fetchall()}
finally:
conn.close()
assert "initiative.created" in actions
assert "action.created" in actions
assert "action.status_changed" in actions
def test_unauthenticated_rejected(client):
assert client.get("/api/initiatives").status_code == 401
assert client.get("/api/actions/me/open").status_code == 401
@pytest.fixture()
def enforce_capabilities(monkeypatch):
monkeypatch.setenv("CAPABILITY_ENFORCE", "enforce")
def test_member_has_initiative_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.initiative.read" in ctx["capabilities"]
assert "kairo.initiative.manage" in ctx["capabilities"]
assert "kairo.action.read" in ctx["capabilities"]
assert "kairo.action.manage" in ctx["capabilities"]
def test_invalid_status_rejected(client):
user = provision_user_in_tenant()
token = _login(client, user)
initiative_id = _create_initiative(client, token).json()["id"]
action_id = _create_action(client, token, initiative_id).json()["id"]
res = client.patch(
f"/api/actions/{action_id}",
json={"status": "invalid_status"},
headers=_auth(token),
)
assert res.status_code == 422

View File

@ -36,6 +36,7 @@ def test_migration_runner_finds_migrations():
assert "003_data_seeds_tracking" in names assert "003_data_seeds_tracking" in names
assert "004_capabilities_registry" in names assert "004_capabilities_registry" in names
assert "005_prompt_feature_config_registry" in names assert "005_prompt_feature_config_registry" in names
assert "006_initiatives_actions" in names
def test_migration_runner_is_idempotent(): def test_migration_runner_is_idempotent():
@ -50,6 +51,7 @@ def test_migration_runner_is_idempotent():
assert "003_data_seeds_tracking" in executed assert "003_data_seeds_tracking" in executed
assert "004_capabilities_registry" in executed assert "004_capabilities_registry" in executed
assert "005_prompt_feature_config_registry" in executed assert "005_prompt_feature_config_registry" in executed
assert "006_initiatives_actions" in executed
def test_core_table_exists(): def test_core_table_exists():

View File

@ -22,6 +22,10 @@ def test_registry_contains_initial_capabilities():
"kairo.config.registry.read", "kairo.config.registry.read",
"kairo.config.registry.manage", "kairo.config.registry.manage",
"kairo.prompt.render", "kairo.prompt.render",
"kairo.initiative.read",
"kairo.initiative.manage",
"kairo.action.read",
"kairo.action.manage",
} }
@ -33,7 +37,7 @@ def test_sync_is_idempotent():
try: try:
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute("SELECT COUNT(*) FROM capabilities") cur.execute("SELECT COUNT(*) FROM capabilities")
assert cur.fetchone()[0] == 12 assert cur.fetchone()[0] == 16
cur.execute("SELECT COUNT(*) FROM role_capability_grants") cur.execute("SELECT COUNT(*) FROM role_capability_grants")
assert cur.fetchone()[0] >= 5 assert cur.fetchone()[0] >= 5
finally: finally:

View File

@ -1,3 +1,3 @@
APP_VERSION = "0.4.0-ap0.4" APP_VERSION = "0.5.0-ap0.5"
DB_SCHEMA_VERSION = "005" DB_SCHEMA_VERSION = "006"
APP_NAME = "jinkendo-kairo" APP_NAME = "jinkendo-kairo"

View File

@ -0,0 +1,151 @@
# AP0.5 Abschlussbericht Minimaler Vorhaben- und Maßnahmen-Slice
**Status:** implementiert
**Stand:** 2026-07-04
**Version:** `0.5.0-ap0.5` · Schema `006`
---
## 1. Scope und Einordnung
AP0.5 liefert den **ersten fachlich nutzbaren Kairo-Slice**: Ein angemeldeter Actor kann innerhalb eines Tenants Vorhaben und Maßnahmen verwalten, zuweisen und seinen offenen Maßnahmen-Backlog abrufen.
| Anforderung (AP0.5) | Status |
|---------------------|--------|
| Migration `006` (initiatives, actions, action_assignments) | ✓ |
| Tenant-Bezug für alle Objekte | ✓ |
| Owner Actor für Initiative | ✓ |
| Action gehört zu Initiative | ✓ |
| Action → ein oder mehrere Actors (Assignments) | ✓ |
| CRUD-Minimum Initiatives | ✓ |
| CRUD-Minimum Actions | ✓ |
| Endpoint „Meine offenen Maßnahmen“ | ✓ |
| Statusmodell minimal | ✓ |
| Priority minimal | ✓ |
| Capability-Gates (4 neue Capabilities) | ✓ |
| Audit bei Create/Update/Statuswechsel | ✓ |
| Tests (Isolation, CRUD, Assignment, Status) | ✓ |
| README/API-Doku | ✓ |
**Bewusst nicht in AP0.5:** Projects, Milestones, Programs, Reviews, Evidence, Blocker-Objekte, KI/Prompt-Erweiterung, MCP, automatische Priorisierung, komplexe UI, Workflow Engine.
---
## 2. Umgesetzte Dateien
### 2.1 Migration & Schema
| Datei | Zweck |
|-------|--------|
| `backend/migrations/006_initiatives_actions.sql` | Tabellen initiatives, actions, action_assignments |
### 2.2 Services
| Datei | Zweck |
|-------|--------|
| `backend/services/initiatives.py` | Vorhaben-CRUD, Validierung, Audit |
| `backend/services/actions.py` | Maßnahmen-CRUD, Assignments, offene Maßnahmen |
### 2.3 API & Capabilities
| Datei | Zweck |
|-------|--------|
| `backend/routers/initiatives.py` | Initiative-Endpoints + nested Action-Create/List |
| `backend/routers/actions.py` | Action-Endpoints + `/me/open` |
| `backend/rights_registrations/initiative_ops.py` | 4 AP0.5-Capabilities |
| `backend/rights_registrations/__init__.py` | Import initiative_ops |
| `backend/main.py` | Router-Registrierung |
| `backend/version.py` | `0.5.0-ap0.5`, Schema `006` |
### 2.4 Tests & Doku
| Datei | Zweck |
|-------|--------|
| `backend/tests/test_initiatives_actions.py` | CRUD, Isolation, Assignment, Status, Audit |
| `backend/tests/test_migrations.py` | Migration 006 |
| `backend/tests/test_rights_registry.py` | 16 Capabilities |
| `README.md` | API-Doku AP0.5 |
| `frontend/src/App.jsx` | Header AP0.5 |
---
## 3. Neue Migrationen
**`006_initiatives_actions.sql`**
| Tabelle | Zweck |
|---------|--------|
| `initiatives` | Vorhaben (tenant-scoped, owner_actor_id) |
| `actions` | Maßnahmen (gehört zu initiative) |
| `action_assignments` | Actor-Zuweisungen (n:m) |
---
## 4. Datenmodell
```
tenants
└── initiatives
id, tenant_id, title, goal, status, priority
owner_actor_id → actors
└── actions
id, tenant_id, initiative_id, title, description
status, priority
└── action_assignments
id, tenant_id, action_id, actor_id
UNIQUE (action_id, actor_id)
```
---
## 5. Endpoints
Siehe `README.md` — Abschnitt AP0.5.
---
## 6. Statusmodell
**Initiative:** `active` · `paused` · `completed` · `archived`
**Action:** `open` · `in_progress` · `blocked` · `done` · `discarded`
**Priorität:** `low` · `normal` · `high`
Offene Maßnahmen: Status ∈ `{open, in_progress, blocked}` und zugewiesen an `ctx.actor_id`.
---
## 7. Assignment-Modell
n:m über `action_assignments`; `PUT /api/actions/{id}/assignments` ersetzt die Liste; Validierung gegen Tenant-Actors.
---
## 8. Capability-Nutzung
`kairo.initiative.read/manage` · `kairo.action.read/manage` — Grants für tenant owner/admin/member.
---
## 9. Tenant-Isolation
Alle Queries tenant-scoped; Cross-Tenant → 404/400; Tests in `test_initiatives_actions.py`.
---
## 10. Tests
11 Tests in `test_initiatives_actions.py` + aktualisierte Registry/Migration-Tests.
Ausführung (Dev-Stack):
```bash
docker compose -f docker-compose.dev-env.yml exec backend python -m pytest tests -ra -vv
```
---
## 11. Empfehlung nächster kleinster Slice
**AP0.6 Kommentare pro Maßnahme** (`action_comments`, read/manage Capability, minimal Audit) — operativer Mehrwert ohne Projects/Milestones/KI.

View File

@ -183,7 +183,7 @@ export default function App() {
<main className="shell"> <main className="shell">
<header className="hero"> <header className="hero">
<h1>Jinkendo Kairo</h1> <h1>Jinkendo Kairo</h1>
<p>Operativer Program Director Sprint 0 / AP0.4</p> <p>Operativer Program Director Sprint 0 / AP0.5</p>
<p className="muted">Capability Registry &amp; Entitlements</p> <p className="muted">Capability Registry &amp; Entitlements</p>
</header> </header>