AP1.0: Steering Foundation — Lifecycle, Context, Signal Engine
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 1m19s
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 18s
Test Suite / playwright-smoke (push) Successful in 11s

Persistierter Standard Lifecycle pro Initiative, backend/steering/ Skeleton, Attention-Refactor und Hook-Dispatch als Basis für Method Registry.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-07-05 17:09:20 +02:00
parent 451d62ed46
commit 95feba6204
36 changed files with 2006 additions and 724 deletions

View File

@ -165,4 +165,4 @@ Bei notwendiger Abweichung erstelle ein Architecture Decision Proposal.
Siehe `docs/product/Kairo_Corrected_MVP_Roadmap_v0.1.md` und Abschlussberichte AP0.7 / AP0.R1. Siehe `docs/product/Kairo_Corrected_MVP_Roadmap_v0.1.md` und Abschlussberichte AP0.7 / AP0.R1.
Foundation AP0.1AP0.7 ist abgeschlossen. Fachlicher Ausbau nur entlang des Canonical Operating Models. Foundation AP0.1AP0.7 ist abgeschlossen. **Strategiewechsel (2026-07-05):** AP1.0 Steering Foundation als nächstes Code-Paket — siehe `docs/architecture/ADP_AP1_0_Steering_Foundation_Scope_Lock_v0.1.md`. AP0.10c eingefroren. Keine parallele Steuerungslogik außerhalb `backend/steering/`.

View File

@ -234,7 +234,12 @@ Operating Model Integration (AP0.10, Schema weiter `008`):
| Endpoint | Methode | Capability | Beschreibung | | Endpoint | Methode | Capability | Beschreibung |
|----------|---------|------------|--------------| |----------|---------|------------|--------------|
| `/api/initiatives/{id}/steering-snapshot` | GET | `kairo.initiative.read` | Verknüpfter Graph + Operating Phase | | `/api/initiatives/{id}/steering-snapshot` | GET | `kairo.initiative.read` | Verknüpfter Graph + Lifecycle + Operating Phase (deprecated) |
| `/api/initiatives/{id}/steering-context` | GET | `kairo.initiative.read` | SteeringContext (Lifecycle, Methode) |
**Steering Core (AP1.0, Schema `009`):** `backend/steering/` — Standard Lifecycle, Hook/Method Registry, Signal Engine. Scope Lock: siehe `docs/architecture/ADP_AP1_0_Steering_Foundation_Scope_Lock_v0.1.md`.
Version nach AP1.0: **`0.11.0-ap1.0`**
**Operating-Transitions:** Blocker gelöst → Maßnahme entblocken; Review abgeschlossen → Maßnahme `review_required``done` **Operating-Transitions:** Blocker gelöst → Maßnahme entblocken; Review abgeschlossen → Maßnahme `review_required``done`
@ -254,6 +259,8 @@ Operating Model Integration UI (AP0.10b, Schema weiter `008`):
Version nach AP0.10b: **`0.10.1-ap0.10b`** Version nach AP0.10b: **`0.10.1-ap0.10b`**
Operating Model Integration UI (AP0.10b, Schema weiter `008`):
Tenant-Invarianten: `docs/architecture/Kairo_Tenant_Invariants_v0.1.md` Tenant-Invarianten: `docs/architecture/Kairo_Tenant_Invariants_v0.1.md`
`docs/architecture/Kairo_Tenant_Invariants_v0.1.md` `docs/architecture/Kairo_Tenant_Invariants_v0.1.md`

View File

@ -1,706 +1,25 @@
"""Attention and NextAction read-models — regelbasiert, tenant-scoped (AP0.8a).""" """Attention and NextAction — thin Data Layer wrapper (AP1.0)."""
from __future__ import annotations from __future__ import annotations
from typing import Any, Literal, Optional from steering.signals.engine import evaluate
from psycopg2.extras import RealDictCursor
from db import get_connection
from tenant_context import TenantContext from tenant_context import TenantContext
AttentionKind = Literal[
"blocked_action",
"open_blocker",
"high_priority_action",
"unassigned_action",
"initiative_without_next_action",
"stale_initiative",
"milestone_at_risk",
"overdue_action",
"review_due",
"recurring_due",
"action_review_required",
]
NextActionKind = Literal[ def get_attention_items(ctx: TenantContext):
"assign_action", return evaluate(ctx, kind="attention")
"resolve_blocker",
"create_action",
"convert_backlog",
"review_milestone",
]
Severity = Literal["info", "warning", "critical"]
_SEVERITY_ORDER = {"critical": 0, "warning": 1, "info": 2}
OPEN_BLOCKER_STATUSES = ("open", "in_progress")
OPEN_ACTION_STATUSES = ("open", "ready", "in_progress", "blocked", "review_required")
ACTIVE_INITIATIVE_STATUSES = ("active", "paused")
def _serialize_attention(row: dict[str, Any]) -> dict[str, Any]: def get_next_action_candidates(ctx: TenantContext, *, limit: int = 10):
item = dict(row) return evaluate(ctx, kind="next_action", limit=limit)
for key in (
"scope_id",
"initiative_id",
"action_id",
"blocker_id",
"milestone_id",
"review_id",
"recurring_element_id",
):
if item.get(key):
item[key] = str(item[key])
return item
def _blocked_actions(cur, ctx: TenantContext) -> list[dict[str, Any]]:
cur.execute(
"""
SELECT
'blocked_action' AS kind,
'critical' AS severity,
a.title AS title,
'Maßnahme ist blockiert' AS summary,
'action' AS scope_type,
a.id AS scope_id,
a.initiative_id,
a.id AS action_id,
NULL::uuid AS blocker_id,
NULL::uuid AS milestone_id,
'action_blocked' AS reason_code,
'actions' AS data_source
FROM actions a
WHERE a.tenant_id = %s AND a.status = 'blocked'
ORDER BY a.updated_at DESC
LIMIT 50
""",
(ctx.tenant_id,),
)
return [_serialize_attention(dict(r)) for r in cur.fetchall()]
def _open_blockers(cur, ctx: TenantContext) -> list[dict[str, Any]]:
cur.execute(
"""
SELECT
'open_blocker' AS kind,
'warning' AS severity,
b.title AS title,
'Offener Blocker im Vorhaben' AS summary,
'blocker' AS scope_type,
b.id AS scope_id,
b.initiative_id,
b.action_id,
b.id AS blocker_id,
NULL::uuid AS milestone_id,
'blocker_open' AS reason_code,
'blockers' AS data_source
FROM blockers b
WHERE b.tenant_id = %s AND b.status IN ('open', 'in_progress')
ORDER BY b.updated_at DESC
LIMIT 50
""",
(ctx.tenant_id,),
)
items = []
for row in cur.fetchall():
item = _serialize_attention(dict(row))
if item.get("action_id"):
item["action_id"] = str(item["action_id"])
items.append(item)
return items
def _high_priority_actions(cur, ctx: TenantContext) -> list[dict[str, Any]]:
actor_filter = ""
params: list[Any] = [ctx.tenant_id]
if ctx.actor_id:
actor_filter = """
AND EXISTS (
SELECT 1 FROM action_assignments aa
WHERE aa.action_id = a.id
AND aa.tenant_id = a.tenant_id
AND aa.actor_id = %s
)
"""
params.append(ctx.actor_id)
else:
return []
cur.execute(
f"""
SELECT
'high_priority_action' AS kind,
'warning' AS severity,
a.title AS title,
'High-Priority Maßnahme offen' AS summary,
'action' AS scope_type,
a.id AS scope_id,
a.initiative_id,
a.id AS action_id,
NULL::uuid AS blocker_id,
NULL::uuid AS milestone_id,
'action_high_priority_open' AS reason_code,
'actions' AS data_source
FROM actions a
WHERE a.tenant_id = %s
AND a.priority = 'high'
AND a.status IN ('open', 'ready', 'in_progress')
{actor_filter}
ORDER BY a.updated_at DESC
LIMIT 30
""",
tuple(params),
)
return [_serialize_attention(dict(r)) for r in cur.fetchall()]
def _unassigned_actions(cur, ctx: TenantContext) -> list[dict[str, Any]]:
cur.execute(
"""
SELECT
'unassigned_action' AS kind,
'warning' AS severity,
a.title AS title,
'Maßnahme ohne Zuweisung' AS summary,
'action' AS scope_type,
a.id AS scope_id,
a.initiative_id,
a.id AS action_id,
NULL::uuid AS blocker_id,
NULL::uuid AS milestone_id,
'action_unassigned' AS reason_code,
'actions' AS data_source
FROM actions a
WHERE a.tenant_id = %s
AND a.status IN ('open', 'ready', 'in_progress')
AND NOT EXISTS (
SELECT 1 FROM action_assignments aa
WHERE aa.action_id = a.id AND aa.tenant_id = a.tenant_id
)
ORDER BY a.updated_at DESC
LIMIT 30
""",
(ctx.tenant_id,),
)
return [_serialize_attention(dict(r)) for r in cur.fetchall()]
def _initiatives_without_next_action(cur, ctx: TenantContext) -> list[dict[str, Any]]:
cur.execute(
"""
SELECT
'initiative_without_next_action' AS kind,
'info' AS severity,
i.title AS title,
'Keine offene nächste Maßnahme' AS summary,
'initiative' AS scope_type,
i.id AS scope_id,
i.id AS initiative_id,
NULL::uuid AS action_id,
NULL::uuid AS blocker_id,
NULL::uuid AS milestone_id,
'initiative_no_open_action' AS reason_code,
'initiatives' AS data_source
FROM initiatives i
WHERE i.tenant_id = %s
AND i.status IN ('active', 'paused')
AND NOT EXISTS (
SELECT 1 FROM actions a
WHERE a.initiative_id = i.id
AND a.tenant_id = i.tenant_id
AND a.status IN ('open', 'ready', 'in_progress', 'blocked', 'review_required')
)
ORDER BY i.updated_at DESC
LIMIT 30
""",
(ctx.tenant_id,),
)
return [_serialize_attention(dict(r)) for r in cur.fetchall()]
def _stale_initiatives(cur, ctx: TenantContext) -> list[dict[str, Any]]:
cur.execute(
"""
SELECT
'stale_initiative' AS kind,
'info' AS severity,
i.title AS title,
'Vorhaben seit über 14 Tagen unverändert' AS summary,
'initiative' AS scope_type,
i.id AS scope_id,
i.id AS initiative_id,
NULL::uuid AS action_id,
NULL::uuid AS blocker_id,
NULL::uuid AS milestone_id,
'initiative_stale' AS reason_code,
'initiatives' AS data_source
FROM initiatives i
WHERE i.tenant_id = %s
AND i.status = 'active'
AND i.updated_at < NOW() - INTERVAL '14 days'
ORDER BY i.updated_at ASC
LIMIT 20
""",
(ctx.tenant_id,),
)
return [_serialize_attention(dict(r)) for r in cur.fetchall()]
def _milestones_at_risk(cur, ctx: TenantContext) -> list[dict[str, Any]]:
cur.execute(
"""
SELECT
'milestone_at_risk' AS kind,
'warning' AS severity,
m.title AS title,
'Meilenstein als gefährdet markiert' AS summary,
'milestone' AS scope_type,
m.id AS scope_id,
m.initiative_id,
NULL::uuid AS action_id,
NULL::uuid AS blocker_id,
m.id AS milestone_id,
'milestone_at_risk' AS reason_code,
'milestones' AS data_source
FROM milestones m
WHERE m.tenant_id = %s AND m.status = 'at_risk'
ORDER BY m.updated_at DESC
LIMIT 20
""",
(ctx.tenant_id,),
)
return [_serialize_attention(dict(r)) for r in cur.fetchall()]
def _actions_review_required(cur, ctx: TenantContext) -> list[dict[str, Any]]:
cur.execute(
"""
SELECT
'action_review_required' AS kind,
'warning' AS severity,
a.title AS title,
'Maßnahme wartet auf Review — kein geplantes Review verknüpft' AS summary,
'action' AS scope_type,
a.id AS scope_id,
a.initiative_id,
a.id AS action_id,
NULL::uuid AS blocker_id,
NULL::uuid AS milestone_id,
NULL::uuid AS review_id,
NULL::uuid AS recurring_element_id,
'action_review_required_no_review' AS reason_code,
'actions' AS data_source
FROM actions a
WHERE a.tenant_id = %s
AND a.status = 'review_required'
AND NOT EXISTS (
SELECT 1 FROM reviews r
WHERE r.tenant_id = a.tenant_id
AND r.action_id = a.id
AND r.status = 'planned'
)
ORDER BY a.updated_at DESC
LIMIT 20
""",
(ctx.tenant_id,),
)
return [_serialize_attention(dict(r)) for r in cur.fetchall()]
def _overdue_actions(cur, ctx: TenantContext) -> list[dict[str, Any]]:
cur.execute(
"""
SELECT
'overdue_action' AS kind,
'warning' AS severity,
a.title AS title,
'Maßnahme überfällig' AS summary,
'action' AS scope_type,
a.id AS scope_id,
a.initiative_id,
a.id AS action_id,
NULL::uuid AS blocker_id,
NULL::uuid AS milestone_id,
NULL::uuid AS review_id,
NULL::uuid AS recurring_element_id,
'action_overdue' AS reason_code,
'actions' AS data_source
FROM actions a
WHERE a.tenant_id = %s
AND a.due_at IS NOT NULL
AND a.due_at < NOW()
AND a.status IN ('open', 'ready', 'in_progress', 'blocked', 'review_required')
ORDER BY a.due_at ASC
LIMIT 30
""",
(ctx.tenant_id,),
)
return [_serialize_attention(dict(r)) for r in cur.fetchall()]
def _reviews_due(cur, ctx: TenantContext) -> list[dict[str, Any]]:
cur.execute(
"""
SELECT
'review_due' AS kind,
'warning' AS severity,
r.title AS title,
'Review fällig' AS summary,
'review' AS scope_type,
r.id AS scope_id,
r.initiative_id,
r.action_id,
NULL::uuid AS blocker_id,
r.milestone_id,
r.id AS review_id,
NULL::uuid AS recurring_element_id,
'review_due' AS reason_code,
'reviews' AS data_source
FROM reviews r
WHERE r.tenant_id = %s
AND r.status = 'planned'
AND r.due_at IS NOT NULL
AND r.due_at <= NOW()
ORDER BY r.due_at ASC
LIMIT 20
""",
(ctx.tenant_id,),
)
items = []
for row in cur.fetchall():
item = _serialize_attention(dict(row))
for fk in ("action_id", "milestone_id"):
if item.get(fk):
item[fk] = str(item[fk])
items.append(item)
return items
def _recurring_due(cur, ctx: TenantContext) -> list[dict[str, Any]]:
cur.execute(
"""
SELECT
'recurring_due' AS kind,
'info' AS severity,
re.title AS title,
'Wiederkehrendes Element fällig' AS summary,
'recurring_element' AS scope_type,
re.id AS scope_id,
re.initiative_id,
NULL::uuid AS action_id,
NULL::uuid AS blocker_id,
NULL::uuid AS milestone_id,
NULL::uuid AS review_id,
re.id AS recurring_element_id,
'recurring_due' AS reason_code,
'recurring_elements' AS data_source
FROM recurring_elements re
WHERE re.tenant_id = %s
AND re.status = 'active'
AND re.next_due_at IS NOT NULL
AND re.next_due_at <= NOW()
ORDER BY re.next_due_at ASC
LIMIT 20
""",
(ctx.tenant_id,),
)
return [_serialize_attention(dict(r)) for r in cur.fetchall()]
def get_attention_items(ctx: TenantContext) -> list[dict[str, Any]]:
"""Regelbasierte Attention Items — tenant-scoped, erklärbar."""
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
items: list[dict[str, Any]] = []
items.extend(_blocked_actions(cur, ctx))
items.extend(_open_blockers(cur, ctx))
items.extend(_high_priority_actions(cur, ctx))
items.extend(_unassigned_actions(cur, ctx))
items.extend(_initiatives_without_next_action(cur, ctx))
items.extend(_stale_initiatives(cur, ctx))
items.extend(_milestones_at_risk(cur, ctx))
items.extend(_overdue_actions(cur, ctx))
items.extend(_actions_review_required(cur, ctx))
items.extend(_reviews_due(cur, ctx))
items.extend(_recurring_due(cur, ctx))
items.sort(key=lambda x: _SEVERITY_ORDER.get(x["severity"], 99))
return items
finally:
conn.close()
def get_next_action_candidates(
ctx: TenantContext, *, limit: int = 10
) -> list[dict[str, Any]]:
"""Regelbasierte NextActionCandidates — limitiert, tenant-scoped."""
if limit < 1:
limit = 1
if limit > 50:
limit = 50
conn = get_connection()
candidates: list[dict[str, Any]] = []
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT
'resolve_blocker' AS kind,
b.title AS title,
'Blocker klären oder Status aktualisieren' AS summary,
b.initiative_id,
b.action_id,
NULL::uuid AS backlog_item_id,
'blocker_open' AS reason_code,
'Blocker bearbeiten' AS recommended_action
FROM blockers b
WHERE b.tenant_id = %s AND b.status IN ('open', 'in_progress')
ORDER BY b.updated_at DESC
LIMIT %s
""",
(ctx.tenant_id, limit),
)
for row in cur.fetchall():
item = dict(row)
item["initiative_id"] = str(item["initiative_id"])
if item.get("action_id"):
item["action_id"] = str(item["action_id"])
candidates.append(item)
remaining = limit - len(candidates)
if remaining > 0:
cur.execute(
"""
SELECT
'assign_action' AS kind,
a.title AS title,
'Actor zuweisen' AS summary,
a.initiative_id,
a.id AS action_id,
NULL::uuid AS backlog_item_id,
'action_unassigned' AS reason_code,
'Maßnahme zuweisen' AS recommended_action
FROM actions a
WHERE a.tenant_id = %s
AND a.status IN ('open', 'ready', 'in_progress')
AND NOT EXISTS (
SELECT 1 FROM action_assignments aa
WHERE aa.action_id = a.id AND aa.tenant_id = a.tenant_id
)
ORDER BY a.updated_at DESC
LIMIT %s
""",
(ctx.tenant_id, remaining),
)
for row in cur.fetchall():
item = dict(row)
item["initiative_id"] = str(item["initiative_id"])
item["action_id"] = str(item["action_id"])
candidates.append(item)
remaining = limit - len(candidates)
if remaining > 0:
cur.execute(
"""
SELECT
'convert_backlog' AS kind,
bi.title AS title,
'Freigegebenes Backlog-Item in Maßnahme umwandeln' AS summary,
bi.initiative_id,
NULL::uuid AS action_id,
bi.id AS backlog_item_id,
'backlog_accepted_not_converted' AS reason_code,
'In Maßnahme umwandeln' AS recommended_action
FROM backlog_items bi
WHERE bi.tenant_id = %s
AND bi.status = 'accepted'
AND bi.converted_action_id IS NULL
ORDER BY bi.updated_at DESC
LIMIT %s
""",
(ctx.tenant_id, remaining),
)
for row in cur.fetchall():
item = dict(row)
item["initiative_id"] = str(item["initiative_id"])
item["backlog_item_id"] = str(item["backlog_item_id"])
candidates.append(item)
remaining = limit - len(candidates)
if remaining > 0:
cur.execute(
"""
SELECT
'create_action' AS kind,
i.title AS title,
'Nächste Maßnahme für Vorhaben anlegen' AS summary,
i.id AS initiative_id,
NULL::uuid AS action_id,
NULL::uuid AS backlog_item_id,
'initiative_no_open_action' AS reason_code,
'Maßnahme anlegen' AS recommended_action
FROM initiatives i
WHERE i.tenant_id = %s
AND i.status IN ('active', 'paused')
AND NOT EXISTS (
SELECT 1 FROM actions a
WHERE a.initiative_id = i.id
AND a.tenant_id = i.tenant_id
AND a.status IN ('open', 'ready', 'in_progress', 'blocked', 'review_required')
)
ORDER BY i.updated_at DESC
LIMIT %s
""",
(ctx.tenant_id, remaining),
)
for row in cur.fetchall():
item = dict(row)
item["initiative_id"] = str(item["initiative_id"])
candidates.append(item)
return candidates[:limit]
finally:
conn.close()
def get_next_action_candidates_for_initiative( def get_next_action_candidates_for_initiative(
ctx: TenantContext, *, initiative_id: str, limit: int = 5 ctx: TenantContext, *, initiative_id: str, limit: int = 5
) -> list[dict[str, Any]]: ):
"""NextActionCandidates für ein Vorhaben — tenant-scoped.""" return evaluate(
if limit < 1: ctx,
limit = 1 kind="next_action",
if limit > 20: limit=limit,
limit = 20 initiative_id=initiative_id,
)
conn = get_connection()
candidates: list[dict[str, Any]] = []
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT
'resolve_blocker' AS kind,
b.title AS title,
'Blocker klären oder Status aktualisieren' AS summary,
b.initiative_id,
b.action_id,
NULL::uuid AS backlog_item_id,
'blocker_open' AS reason_code,
'Blocker bearbeiten' AS recommended_action
FROM blockers b
WHERE b.tenant_id = %s AND b.initiative_id = %s
AND b.status IN ('open', 'in_progress')
ORDER BY b.updated_at DESC
LIMIT %s
""",
(ctx.tenant_id, initiative_id, limit),
)
for row in cur.fetchall():
item = dict(row)
item["initiative_id"] = str(item["initiative_id"])
if item.get("action_id"):
item["action_id"] = str(item["action_id"])
candidates.append(item)
remaining = limit - len(candidates)
if remaining > 0:
cur.execute(
"""
SELECT
'assign_action' AS kind,
a.title AS title,
'Actor zuweisen' AS summary,
a.initiative_id,
a.id AS action_id,
NULL::uuid AS backlog_item_id,
'action_unassigned' AS reason_code,
'Maßnahme zuweisen' AS recommended_action
FROM actions a
WHERE a.tenant_id = %s AND a.initiative_id = %s
AND a.status IN ('open', 'ready', 'in_progress')
AND NOT EXISTS (
SELECT 1 FROM action_assignments aa
WHERE aa.action_id = a.id AND aa.tenant_id = a.tenant_id
)
ORDER BY a.updated_at DESC
LIMIT %s
""",
(ctx.tenant_id, initiative_id, remaining),
)
for row in cur.fetchall():
item = dict(row)
item["initiative_id"] = str(item["initiative_id"])
item["action_id"] = str(item["action_id"])
candidates.append(item)
remaining = limit - len(candidates)
if remaining > 0:
cur.execute(
"""
SELECT
'convert_backlog' AS kind,
bi.title AS title,
'Freigegebenes Backlog-Item in Maßnahme umwandeln' AS summary,
bi.initiative_id,
NULL::uuid AS action_id,
bi.id AS backlog_item_id,
'backlog_accepted_not_converted' AS reason_code,
'In Maßnahme umwandeln' AS recommended_action
FROM backlog_items bi
WHERE bi.tenant_id = %s AND bi.initiative_id = %s
AND bi.status = 'accepted'
AND bi.converted_action_id IS NULL
ORDER BY bi.updated_at DESC
LIMIT %s
""",
(ctx.tenant_id, initiative_id, remaining),
)
for row in cur.fetchall():
item = dict(row)
item["initiative_id"] = str(item["initiative_id"])
item["backlog_item_id"] = str(item["backlog_item_id"])
candidates.append(item)
remaining = limit - len(candidates)
if remaining > 0:
cur.execute(
"""
SELECT 1 FROM actions a
WHERE a.initiative_id = %s AND a.tenant_id = %s
AND a.status IN ('open', 'ready', 'in_progress', 'blocked', 'review_required')
LIMIT 1
""",
(initiative_id, ctx.tenant_id),
)
if not cur.fetchone():
cur.execute(
"""
SELECT title FROM initiatives
WHERE id = %s AND tenant_id = %s
""",
(initiative_id, ctx.tenant_id),
)
init_row = cur.fetchone()
if init_row:
candidates.append(
{
"kind": "create_action",
"title": init_row["title"],
"summary": "Nächste Maßnahme für Vorhaben anlegen",
"initiative_id": initiative_id,
"action_id": None,
"backlog_item_id": None,
"reason_code": "initiative_no_open_action",
"recommended_action": "Maßnahme anlegen",
}
)
return candidates[:limit]
finally:
conn.close()

View File

@ -11,6 +11,7 @@ from psycopg2.extras import RealDictCursor
from db import get_connection from db import get_connection
from data_layer.attention import get_next_action_candidates_for_initiative from data_layer.attention import get_next_action_candidates_for_initiative
from steering.context import get_steering_context_dto
from services.initiatives import get_initiative from services.initiatives import get_initiative
from tenant_context import TenantContext from tenant_context import TenantContext
@ -310,11 +311,18 @@ def get_initiative_steering_snapshot(
ctx, initiative_id=initiative_id, limit=5 ctx, initiative_id=initiative_id, limit=5
) )
steering = get_steering_context_dto(ctx, initiative_id=initiative_id)
return { return {
"initiative_id": initiative_id, "initiative_id": initiative_id,
"initiative_title": initiative["title"], "initiative_title": initiative["title"],
"initiative_status": initiative["status"], "initiative_status": initiative["status"],
"lifecycle_state": steering["lifecycle_state"],
"lifecycle_label": steering["lifecycle_label"],
"method_key": steering["method_key"],
"method_version": steering["method_version"],
"operating_phase": phase, "operating_phase": phase,
"operating_phase_deprecated": True,
"phase_signals": phase_signals, "phase_signals": phase_signals,
"upcoming_milestones": upcoming_milestones, "upcoming_milestones": upcoming_milestones,
"next_actions": next_actions, "next_actions": next_actions,

View File

@ -11,6 +11,8 @@ from fastapi.middleware.cors import CORSMiddleware
from db import check_db from db import check_db
from version import APP_NAME, APP_VERSION, DB_SCHEMA_VERSION from version import APP_NAME, APP_VERSION, DB_SCHEMA_VERSION
import steering # noqa: F401 — bootstrap hooks & methods
if os.getenv("KAIRO_DB_READY") != "1": if os.getenv("KAIRO_DB_READY") != "1":
if os.getenv("SKIP_DB_MIGRATE", "").strip().lower() not in ("1", "true", "yes"): if os.getenv("SKIP_DB_MIGRATE", "").strip().lower() not in ("1", "true", "yes"):
import run_migrations import run_migrations

View File

@ -0,0 +1,41 @@
-- AP1.0: Steering Foundation — steering_contexts
CREATE TABLE steering_contexts (
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,
method_key VARCHAR(64) NOT NULL DEFAULT 'generic_operating',
method_version VARCHAR(32) NOT NULL DEFAULT '0.1.0',
lifecycle_state VARCHAR(64) NOT NULL DEFAULT 'intake',
lifecycle_metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (tenant_id, initiative_id)
);
CREATE INDEX idx_steering_contexts_tenant ON steering_contexts(tenant_id);
CREATE INDEX idx_steering_contexts_initiative ON steering_contexts(tenant_id, initiative_id);
INSERT INTO steering_contexts (tenant_id, initiative_id, lifecycle_state)
SELECT
i.tenant_id,
i.id,
CASE
WHEN i.status IN ('completed', 'archived') THEN 'closure'
WHEN EXISTS (
SELECT 1 FROM actions a
WHERE a.initiative_id = i.id
AND a.tenant_id = i.tenant_id
AND a.status IN (
'open', 'ready', 'in_progress', 'blocked', 'review_required'
)
) THEN 'action_selection'
WHEN EXISTS (
SELECT 1 FROM backlog_items bi
WHERE bi.initiative_id = i.id
AND bi.tenant_id = i.tenant_id
AND bi.status IN ('new', 'triaged', 'accepted')
) THEN 'structure_setup'
ELSE 'planning'
END
FROM initiatives i;

View File

@ -9,6 +9,7 @@ from capabilities import require_capability
from data_layer import initiative_snapshot as dl_initiative_snapshot from data_layer import initiative_snapshot as dl_initiative_snapshot
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from steering.context import get_steering_context_dto
from services import actions as action_service from services import actions as action_service
from services import backlog as backlog_service from services import backlog as backlog_service
from services import blockers as blocker_service from services import blockers as blocker_service
@ -168,6 +169,18 @@ def get_initiative_steering_snapshot(
return snapshot return snapshot
@router.get("/{initiative_id}/steering-context")
def get_initiative_steering_context(
initiative_id: str,
ctx: TenantContext = Depends(require_capability("kairo.initiative.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 get_steering_context_dto(ctx, initiative_id=initiative_id)
@router.patch("/{initiative_id}") @router.patch("/{initiative_id}")
def update_initiative( def update_initiative(
initiative_id: str, initiative_id: str,

View File

@ -270,6 +270,7 @@ def update_blocker(
tenant_id=tenant_id, tenant_id=tenant_id,
blocker_id=blocker_id, blocker_id=blocker_id,
action_id=result.get("action_id"), action_id=result.get("action_id"),
initiative_id=result.get("initiative_id"),
old_status=old_status, old_status=old_status,
new_status=status, new_status=status,
user_id=user_id, user_id=user_id,

View File

@ -94,6 +94,13 @@ def create_initiative(
tenant_id=tenant_id, tenant_id=tenant_id,
details={"initiative_id": row["id"], "title": title, "status": status}, details={"initiative_id": row["id"], "title": title, "status": status},
) )
from services.steering_context import create_context_for_new_initiative
create_context_for_new_initiative(
tenant_id=tenant_id,
initiative_id=row["id"],
user_id=user_id,
)
return row return row

View File

@ -1,7 +1,4 @@
"""Minimale Operating-Transitions — Vorstufe zum Steering Orchestrator (AP0.10a). """Minimale Operating-Transitions — AP1.0 mit Hook Dispatch."""
Side Effects bei Statusänderungen. Später: Hook Registry + Method Strategies.
"""
from __future__ import annotations from __future__ import annotations
@ -10,6 +7,7 @@ from typing import Any, Optional
from psycopg2.extras import RealDictCursor from psycopg2.extras import RealDictCursor
from db import get_connection from db import get_connection
from steering.hooks.dispatch import dispatch_hook
RESOLVED_BLOCKER_STATUSES = frozenset({"resolved", "accepted_risk", "dismissed"}) RESOLVED_BLOCKER_STATUSES = frozenset({"resolved", "accepted_risk", "dismissed"})
@ -19,6 +17,7 @@ def after_blocker_status_change(
tenant_id: str, tenant_id: str,
blocker_id: str, blocker_id: str,
action_id: Optional[str], action_id: Optional[str],
initiative_id: Optional[str] = None,
old_status: str, old_status: str,
new_status: str, new_status: str,
user_id: Optional[str] = None, user_id: Optional[str] = None,
@ -86,6 +85,31 @@ def after_blocker_status_change(
finally: finally:
conn.close() conn.close()
if effects:
dispatch_hook(
"on_blocker_resolved",
None,
{
"blocker_id": blocker_id,
"action_id": action_id,
"initiative_id": initiative_id,
"effects": effects,
},
tenant_id=tenant_id,
user_id=user_id,
)
if initiative_id and user_id:
from steering.lifecycle.orchestrator import transition
transition(
None,
initiative_id,
"action_selection",
reason="blocker_resolved",
tenant_id=tenant_id,
user_id=user_id,
)
if effects and user_id: if effects and user_id:
from services.audit import log_audit from services.audit import log_audit
@ -103,6 +127,7 @@ def after_review_status_change(
tenant_id: str, tenant_id: str,
review_id: str, review_id: str,
action_id: Optional[str], action_id: Optional[str],
initiative_id: Optional[str] = None,
old_status: str, old_status: str,
new_status: str, new_status: str,
user_id: Optional[str] = None, user_id: Optional[str] = None,
@ -137,6 +162,20 @@ def after_review_status_change(
finally: finally:
conn.close() conn.close()
if effects:
dispatch_hook(
"on_review_completed",
None,
{
"review_id": review_id,
"action_id": action_id,
"initiative_id": initiative_id,
"effects": effects,
},
tenant_id=tenant_id,
user_id=user_id,
)
if effects and user_id: if effects and user_id:
from services.audit import log_audit from services.audit import log_audit

View File

@ -315,6 +315,7 @@ def update_review(
tenant_id=tenant_id, tenant_id=tenant_id,
review_id=review_id, review_id=review_id,
action_id=result.get("action_id"), action_id=result.get("action_id"),
initiative_id=result.get("initiative_id"),
old_status=old_status, old_status=old_status,
new_status=status, new_status=status,
user_id=user_id, user_id=user_id,

View File

@ -0,0 +1,208 @@
"""SteeringContext write service — AP1.0."""
from __future__ import annotations
import json
from typing import Any, Optional
from psycopg2.extras import RealDictCursor, Json
from db import get_connection
from services.audit import log_audit
from services.initiatives import get_initiative
from steering.lifecycle.states import validate_lifecycle_state
DEFAULT_METHOD_KEY = "generic_operating"
DEFAULT_METHOD_VERSION = "0.1.0"
def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
result = dict(row)
for key in ("id", "tenant_id", "initiative_id"):
if result.get(key):
result[key] = str(result[key])
if result.get("created_at"):
result["created_at"] = result["created_at"].isoformat()
if result.get("updated_at"):
result["updated_at"] = result["updated_at"].isoformat()
if isinstance(result.get("lifecycle_metadata"), dict):
pass
elif result.get("lifecycle_metadata") is not None:
result["lifecycle_metadata"] = dict(result["lifecycle_metadata"])
return result
def _infer_initial_lifecycle_state(*, tenant_id: str, initiative_id: str) -> str:
initiative = get_initiative(tenant_id=tenant_id, initiative_id=initiative_id)
if not initiative:
return "intake"
if initiative["status"] in ("completed", "archived"):
return "closure"
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT 1 FROM actions
WHERE tenant_id = %s AND initiative_id = %s
AND status IN (
'open', 'ready', 'in_progress', 'blocked', 'review_required'
)
LIMIT 1
""",
(tenant_id, initiative_id),
)
if cur.fetchone():
return "action_selection"
cur.execute(
"""
SELECT 1 FROM backlog_items
WHERE tenant_id = %s AND initiative_id = %s
AND status IN ('new', 'triaged', 'accepted')
LIMIT 1
""",
(tenant_id, initiative_id),
)
if cur.fetchone():
return "structure_setup"
finally:
conn.close()
return "planning"
def get_steering_context(
*, 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, initiative_id, method_key, method_version,
lifecycle_state, lifecycle_metadata, created_at, updated_at
FROM steering_contexts
WHERE tenant_id = %s AND initiative_id = %s
""",
(tenant_id, initiative_id),
)
row = cur.fetchone()
return _serialize_row(dict(row)) if row else None
finally:
conn.close()
def ensure_steering_context(
*,
tenant_id: str,
initiative_id: str,
initial_state: Optional[str] = None,
user_id: Optional[str] = None,
) -> dict[str, Any]:
existing = get_steering_context(tenant_id=tenant_id, initiative_id=initiative_id)
if existing:
return existing
if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
raise ValueError("Vorhaben nicht gefunden")
state = initial_state or _infer_initial_lifecycle_state(
tenant_id=tenant_id, initiative_id=initiative_id
)
validate_lifecycle_state(state)
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
INSERT INTO steering_contexts (
tenant_id, initiative_id, method_key, method_version,
lifecycle_state, lifecycle_metadata
)
VALUES (%s, %s, %s, %s, %s, %s)
RETURNING id, tenant_id, initiative_id, method_key, method_version,
lifecycle_state, lifecycle_metadata, created_at, updated_at
""",
(
tenant_id,
initiative_id,
DEFAULT_METHOD_KEY,
DEFAULT_METHOD_VERSION,
state,
Json({}),
),
)
row = _serialize_row(dict(cur.fetchone()))
conn.commit()
finally:
conn.close()
log_audit(
"steering_context.created",
user_id=user_id,
tenant_id=tenant_id,
details={"initiative_id": initiative_id, "lifecycle_state": state},
)
return row
def update_lifecycle_state(
*,
tenant_id: str,
initiative_id: str,
lifecycle_state: str,
user_id: Optional[str] = None,
reason: str = "",
from_state: Optional[str] = None,
) -> dict[str, Any]:
validate_lifecycle_state(lifecycle_state)
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
UPDATE steering_contexts
SET lifecycle_state = %s, updated_at = NOW()
WHERE tenant_id = %s AND initiative_id = %s
RETURNING id, tenant_id, initiative_id, method_key, method_version,
lifecycle_state, lifecycle_metadata, created_at, updated_at
""",
(lifecycle_state, tenant_id, initiative_id),
)
row = cur.fetchone()
if not row:
raise ValueError("SteeringContext nicht gefunden")
result = _serialize_row(dict(row))
conn.commit()
finally:
conn.close()
log_audit(
"steering_context.lifecycle_transition",
user_id=user_id,
tenant_id=tenant_id,
details={
"initiative_id": initiative_id,
"from_state": from_state,
"to_state": lifecycle_state,
"reason": reason,
},
)
return result
def create_context_for_new_initiative(
*,
tenant_id: str,
initiative_id: str,
user_id: Optional[str] = None,
) -> dict[str, Any]:
return ensure_steering_context(
tenant_id=tenant_id,
initiative_id=initiative_id,
initial_state="intake",
user_id=user_id,
)

View File

@ -0,0 +1,15 @@
"""Adaptive Steering Core — AP1.0 Foundation."""
from __future__ import annotations
from steering.hooks.registry import register_builtin_hooks
from steering.methods.registrations import generic_operating
def bootstrap_steering() -> None:
"""Register built-in hooks and methods (idempotent)."""
register_builtin_hooks()
generic_operating.register()
bootstrap_steering()

View File

@ -0,0 +1,35 @@
"""SteeringContext read helpers — AP1.0."""
from __future__ import annotations
from typing import Any, Optional
from services import steering_context as sc_service
from steering.lifecycle.states import lifecycle_label
from tenant_context import TenantContext
def get_steering_context_dto(
ctx: TenantContext, *, initiative_id: str
) -> Optional[dict[str, Any]]:
row = sc_service.get_steering_context(
tenant_id=ctx.tenant_id, initiative_id=initiative_id
)
if not row:
row = sc_service.ensure_steering_context(
tenant_id=ctx.tenant_id,
initiative_id=initiative_id,
)
return {
**row,
"lifecycle_label": lifecycle_label(row["lifecycle_state"]),
}
def get_or_create_for_initiative(
ctx: TenantContext, *, initiative_id: str
) -> dict[str, Any]:
dto = get_steering_context_dto(ctx, initiative_id=initiative_id)
if not dto:
raise ValueError("SteeringContext nicht verfügbar")
return dto

View File

View File

@ -0,0 +1,35 @@
"""Hook dispatch — AP1.0 stub with audit trail."""
from __future__ import annotations
from typing import Any, Optional
from steering.hooks.registry import get_hook
from tenant_context import TenantContext
def dispatch_hook(
slug: str,
ctx: TenantContext | None,
payload: dict[str, Any],
*,
tenant_id: Optional[str] = None,
user_id: Optional[str] = None,
) -> list[dict[str, Any]]:
"""Dispatch hook — AP1.0: audit only, no handlers yet."""
hook = get_hook(slug)
if not hook:
return [{"kind": "hook_unknown", "slug": slug}]
tid = tenant_id or (ctx.tenant_id if ctx else None)
if tid and user_id:
from services.audit import log_audit
log_audit(
f"steering.hook.{slug}",
user_id=user_id,
tenant_id=tid,
details={"slug": slug, "payload": payload},
)
return [{"kind": "hook_dispatched", "slug": slug, "lifecycle_step": hook.lifecycle_step}]

View File

@ -0,0 +1,63 @@
"""Hook Registry — stabile Einhängepunkte (AP1.0 definitions only)."""
from __future__ import annotations
from dataclasses import dataclass
_HOOKS: dict[str, "HookDefinition"] = {}
@dataclass(frozen=True)
class HookDefinition:
slug: str
lifecycle_step: str
description: str
since_version: str = "0.11.0"
def register_hook(defn: HookDefinition) -> None:
if defn.slug in _HOOKS:
raise ValueError(f"Hook already registered: {defn.slug}")
_HOOKS[defn.slug] = defn
def get_hook(slug: str) -> HookDefinition | None:
return _HOOKS.get(slug)
def list_hooks() -> tuple[HookDefinition, ...]:
return tuple(_HOOKS.values())
def clear_hooks_for_tests() -> None:
_HOOKS.clear()
def register_builtin_hooks() -> None:
"""Slugs aus Target Architecture §7.3 — Handler folgen in AP1.3."""
builtins = (
("on_goal_captured", "intake", "Ziel erfasst"),
("on_method_selected", "method_selection", "Methode gewählt"),
("on_structure_required", "structure_setup", "Struktur erforderlich"),
("on_backlog_required", "structure_setup", "Backlog erforderlich"),
("on_next_action_requested", "action_selection", "Nächste Aktion angefragt"),
("on_assignment_required", "assignment", "Zuweisung erforderlich"),
("on_blocker_open", "waiting", "Blocker gemeldet"),
("on_blocker_resolved", "action_selection", "Blocker geschlossen"),
("on_wait_started", "waiting", "Warten begonnen"),
("on_result_received", "result_intake", "Ergebnis eingegangen"),
("on_evidence_required", "validation", "Nachweis erforderlich"),
("on_review_due", "review", "Review fällig"),
("on_review_completed", "review", "Review abgeschlossen"),
("on_replan_required", "adaptation", "Anpassung nötig"),
("on_closure_requested", "closure", "Abschluss angefragt"),
)
for slug, step, desc in builtins:
if slug not in _HOOKS:
register_hook(
HookDefinition(
slug=slug,
lifecycle_step=step,
description=desc,
)
)

View File

View File

@ -0,0 +1,78 @@
"""Lifecycle orchestrator — persistierter Standard Lifecycle (AP1.0)."""
from __future__ import annotations
from typing import Any, Optional
from services import steering_context as sc_service
from steering.lifecycle.states import validate_lifecycle_state
from tenant_context import TenantContext
def get_lifecycle_state(ctx: TenantContext, initiative_id: str) -> str:
row = sc_service.get_steering_context(
tenant_id=ctx.tenant_id, initiative_id=initiative_id
)
if row:
return row["lifecycle_state"]
created = sc_service.ensure_steering_context(
tenant_id=ctx.tenant_id,
initiative_id=initiative_id,
initial_state="intake",
)
return created["lifecycle_state"]
def transition(
ctx: TenantContext | None,
initiative_id: str,
to_state: str,
*,
reason: str = "",
tenant_id: Optional[str] = None,
user_id: Optional[str] = None,
) -> dict[str, Any]:
"""Lifecycle-Transition — AP1.0: freier Übergang (Guards in AP1.1)."""
validate_lifecycle_state(to_state)
tid = tenant_id or (ctx.tenant_id if ctx else None)
if not tid:
raise ValueError("tenant_id erforderlich")
existing = sc_service.get_steering_context(tenant_id=tid, initiative_id=initiative_id)
if not existing:
existing = sc_service.ensure_steering_context(
tenant_id=tid,
initiative_id=initiative_id,
initial_state=to_state,
)
return {
"initiative_id": initiative_id,
"from_state": None,
"to_state": to_state,
"reason": reason,
}
from_state = existing["lifecycle_state"]
if from_state == to_state:
return {
"initiative_id": initiative_id,
"from_state": from_state,
"to_state": to_state,
"reason": reason,
"unchanged": True,
}
updated = sc_service.update_lifecycle_state(
tenant_id=tid,
initiative_id=initiative_id,
lifecycle_state=to_state,
user_id=user_id,
reason=reason,
from_state=from_state,
)
return {
"initiative_id": initiative_id,
"from_state": from_state,
"to_state": updated["lifecycle_state"],
"reason": reason,
}

View File

@ -0,0 +1,42 @@
"""Standard Lifecycle — gemeinsame Basis (AP1.0)."""
from __future__ import annotations
STANDARD_LIFECYCLE_STEPS: tuple[str, ...] = (
"intake",
"method_selection",
"structure_setup",
"planning",
"action_selection",
"assignment",
"waiting",
"result_intake",
"validation",
"review",
"adaptation",
"closure",
)
LIFECYCLE_LABELS: dict[str, str] = {
"intake": "Aufnahme",
"method_selection": "Methode wählen",
"structure_setup": "Struktur aufbauen",
"planning": "Planung",
"action_selection": "Maßnahmen auswählen",
"assignment": "Zuweisen",
"waiting": "Warten",
"result_intake": "Ergebnis aufnehmen",
"validation": "Validierung",
"review": "Review",
"adaptation": "Anpassung",
"closure": "Abschluss",
}
def lifecycle_label(state: str) -> str:
return LIFECYCLE_LABELS.get(state, state)
def validate_lifecycle_state(state: str) -> None:
if state not in STANDARD_LIFECYCLE_STEPS:
raise ValueError(f"Ungültiger Lifecycle-State: {state}")

View File

View File

@ -0,0 +1,19 @@
"""Built-in method: generic_operating — AP1.0."""
from __future__ import annotations
from steering.lifecycle.states import STANDARD_LIFECYCLE_STEPS
from steering.methods.registry import MethodDefinition, get_method, register_method
def register() -> None:
if get_method("generic_operating"):
return
register_method(
MethodDefinition(
key="generic_operating",
version="0.1.0",
description="Standard-Lifecycle ohne methodenspezifische Spezialisierung",
default_lifecycle_steps=STANDARD_LIFECYCLE_STEPS,
)
)

View File

@ -0,0 +1,33 @@
"""Method Registry — built-in steering methods (AP1.0)."""
from __future__ import annotations
from dataclasses import dataclass
_METHODS: dict[str, "MethodDefinition"] = {}
@dataclass(frozen=True)
class MethodDefinition:
key: str
version: str
description: str
default_lifecycle_steps: tuple[str, ...]
def register_method(defn: MethodDefinition) -> None:
if defn.key in _METHODS:
raise ValueError(f"Method already registered: {defn.key}")
_METHODS[defn.key] = defn
def get_method(key: str) -> MethodDefinition | None:
return _METHODS.get(key)
def list_methods() -> tuple[MethodDefinition, ...]:
return tuple(_METHODS.values())
def clear_methods_for_tests() -> None:
_METHODS.clear()

View File

View File

@ -0,0 +1,706 @@
"""Default Signal Rule Provider — regelbasiert, tenant-scoped (AP0.8a, AP1.0)."""
from __future__ import annotations
from typing import Any, Literal, Optional
from psycopg2.extras import RealDictCursor
from db import get_connection
from tenant_context import TenantContext
AttentionKind = Literal[
"blocked_action",
"open_blocker",
"high_priority_action",
"unassigned_action",
"initiative_without_next_action",
"stale_initiative",
"milestone_at_risk",
"overdue_action",
"review_due",
"recurring_due",
"action_review_required",
]
NextActionKind = Literal[
"assign_action",
"resolve_blocker",
"create_action",
"convert_backlog",
"review_milestone",
]
Severity = Literal["info", "warning", "critical"]
_SEVERITY_ORDER = {"critical": 0, "warning": 1, "info": 2}
OPEN_BLOCKER_STATUSES = ("open", "in_progress")
OPEN_ACTION_STATUSES = ("open", "ready", "in_progress", "blocked", "review_required")
ACTIVE_INITIATIVE_STATUSES = ("active", "paused")
def _serialize_attention(row: dict[str, Any]) -> dict[str, Any]:
item = dict(row)
for key in (
"scope_id",
"initiative_id",
"action_id",
"blocker_id",
"milestone_id",
"review_id",
"recurring_element_id",
):
if item.get(key):
item[key] = str(item[key])
return item
def _blocked_actions(cur, ctx: TenantContext) -> list[dict[str, Any]]:
cur.execute(
"""
SELECT
'blocked_action' AS kind,
'critical' AS severity,
a.title AS title,
'Maßnahme ist blockiert' AS summary,
'action' AS scope_type,
a.id AS scope_id,
a.initiative_id,
a.id AS action_id,
NULL::uuid AS blocker_id,
NULL::uuid AS milestone_id,
'action_blocked' AS reason_code,
'actions' AS data_source
FROM actions a
WHERE a.tenant_id = %s AND a.status = 'blocked'
ORDER BY a.updated_at DESC
LIMIT 50
""",
(ctx.tenant_id,),
)
return [_serialize_attention(dict(r)) for r in cur.fetchall()]
def _open_blockers(cur, ctx: TenantContext) -> list[dict[str, Any]]:
cur.execute(
"""
SELECT
'open_blocker' AS kind,
'warning' AS severity,
b.title AS title,
'Offener Blocker im Vorhaben' AS summary,
'blocker' AS scope_type,
b.id AS scope_id,
b.initiative_id,
b.action_id,
b.id AS blocker_id,
NULL::uuid AS milestone_id,
'blocker_open' AS reason_code,
'blockers' AS data_source
FROM blockers b
WHERE b.tenant_id = %s AND b.status IN ('open', 'in_progress')
ORDER BY b.updated_at DESC
LIMIT 50
""",
(ctx.tenant_id,),
)
items = []
for row in cur.fetchall():
item = _serialize_attention(dict(row))
if item.get("action_id"):
item["action_id"] = str(item["action_id"])
items.append(item)
return items
def _high_priority_actions(cur, ctx: TenantContext) -> list[dict[str, Any]]:
actor_filter = ""
params: list[Any] = [ctx.tenant_id]
if ctx.actor_id:
actor_filter = """
AND EXISTS (
SELECT 1 FROM action_assignments aa
WHERE aa.action_id = a.id
AND aa.tenant_id = a.tenant_id
AND aa.actor_id = %s
)
"""
params.append(ctx.actor_id)
else:
return []
cur.execute(
f"""
SELECT
'high_priority_action' AS kind,
'warning' AS severity,
a.title AS title,
'High-Priority Maßnahme offen' AS summary,
'action' AS scope_type,
a.id AS scope_id,
a.initiative_id,
a.id AS action_id,
NULL::uuid AS blocker_id,
NULL::uuid AS milestone_id,
'action_high_priority_open' AS reason_code,
'actions' AS data_source
FROM actions a
WHERE a.tenant_id = %s
AND a.priority = 'high'
AND a.status IN ('open', 'ready', 'in_progress')
{actor_filter}
ORDER BY a.updated_at DESC
LIMIT 30
""",
tuple(params),
)
return [_serialize_attention(dict(r)) for r in cur.fetchall()]
def _unassigned_actions(cur, ctx: TenantContext) -> list[dict[str, Any]]:
cur.execute(
"""
SELECT
'unassigned_action' AS kind,
'warning' AS severity,
a.title AS title,
'Maßnahme ohne Zuweisung' AS summary,
'action' AS scope_type,
a.id AS scope_id,
a.initiative_id,
a.id AS action_id,
NULL::uuid AS blocker_id,
NULL::uuid AS milestone_id,
'action_unassigned' AS reason_code,
'actions' AS data_source
FROM actions a
WHERE a.tenant_id = %s
AND a.status IN ('open', 'ready', 'in_progress')
AND NOT EXISTS (
SELECT 1 FROM action_assignments aa
WHERE aa.action_id = a.id AND aa.tenant_id = a.tenant_id
)
ORDER BY a.updated_at DESC
LIMIT 30
""",
(ctx.tenant_id,),
)
return [_serialize_attention(dict(r)) for r in cur.fetchall()]
def _initiatives_without_next_action(cur, ctx: TenantContext) -> list[dict[str, Any]]:
cur.execute(
"""
SELECT
'initiative_without_next_action' AS kind,
'info' AS severity,
i.title AS title,
'Keine offene nächste Maßnahme' AS summary,
'initiative' AS scope_type,
i.id AS scope_id,
i.id AS initiative_id,
NULL::uuid AS action_id,
NULL::uuid AS blocker_id,
NULL::uuid AS milestone_id,
'initiative_no_open_action' AS reason_code,
'initiatives' AS data_source
FROM initiatives i
WHERE i.tenant_id = %s
AND i.status IN ('active', 'paused')
AND NOT EXISTS (
SELECT 1 FROM actions a
WHERE a.initiative_id = i.id
AND a.tenant_id = i.tenant_id
AND a.status IN ('open', 'ready', 'in_progress', 'blocked', 'review_required')
)
ORDER BY i.updated_at DESC
LIMIT 30
""",
(ctx.tenant_id,),
)
return [_serialize_attention(dict(r)) for r in cur.fetchall()]
def _stale_initiatives(cur, ctx: TenantContext) -> list[dict[str, Any]]:
cur.execute(
"""
SELECT
'stale_initiative' AS kind,
'info' AS severity,
i.title AS title,
'Vorhaben seit über 14 Tagen unverändert' AS summary,
'initiative' AS scope_type,
i.id AS scope_id,
i.id AS initiative_id,
NULL::uuid AS action_id,
NULL::uuid AS blocker_id,
NULL::uuid AS milestone_id,
'initiative_stale' AS reason_code,
'initiatives' AS data_source
FROM initiatives i
WHERE i.tenant_id = %s
AND i.status = 'active'
AND i.updated_at < NOW() - INTERVAL '14 days'
ORDER BY i.updated_at ASC
LIMIT 20
""",
(ctx.tenant_id,),
)
return [_serialize_attention(dict(r)) for r in cur.fetchall()]
def _milestones_at_risk(cur, ctx: TenantContext) -> list[dict[str, Any]]:
cur.execute(
"""
SELECT
'milestone_at_risk' AS kind,
'warning' AS severity,
m.title AS title,
'Meilenstein als gefährdet markiert' AS summary,
'milestone' AS scope_type,
m.id AS scope_id,
m.initiative_id,
NULL::uuid AS action_id,
NULL::uuid AS blocker_id,
m.id AS milestone_id,
'milestone_at_risk' AS reason_code,
'milestones' AS data_source
FROM milestones m
WHERE m.tenant_id = %s AND m.status = 'at_risk'
ORDER BY m.updated_at DESC
LIMIT 20
""",
(ctx.tenant_id,),
)
return [_serialize_attention(dict(r)) for r in cur.fetchall()]
def _actions_review_required(cur, ctx: TenantContext) -> list[dict[str, Any]]:
cur.execute(
"""
SELECT
'action_review_required' AS kind,
'warning' AS severity,
a.title AS title,
'Ma├ƒnahme wartet auf Review ÔÇö kein geplantes Review verkn├╝pft' AS summary,
'action' AS scope_type,
a.id AS scope_id,
a.initiative_id,
a.id AS action_id,
NULL::uuid AS blocker_id,
NULL::uuid AS milestone_id,
NULL::uuid AS review_id,
NULL::uuid AS recurring_element_id,
'action_review_required_no_review' AS reason_code,
'actions' AS data_source
FROM actions a
WHERE a.tenant_id = %s
AND a.status = 'review_required'
AND NOT EXISTS (
SELECT 1 FROM reviews r
WHERE r.tenant_id = a.tenant_id
AND r.action_id = a.id
AND r.status = 'planned'
)
ORDER BY a.updated_at DESC
LIMIT 20
""",
(ctx.tenant_id,),
)
return [_serialize_attention(dict(r)) for r in cur.fetchall()]
def _overdue_actions(cur, ctx: TenantContext) -> list[dict[str, Any]]:
cur.execute(
"""
SELECT
'overdue_action' AS kind,
'warning' AS severity,
a.title AS title,
'Maßnahme überfällig' AS summary,
'action' AS scope_type,
a.id AS scope_id,
a.initiative_id,
a.id AS action_id,
NULL::uuid AS blocker_id,
NULL::uuid AS milestone_id,
NULL::uuid AS review_id,
NULL::uuid AS recurring_element_id,
'action_overdue' AS reason_code,
'actions' AS data_source
FROM actions a
WHERE a.tenant_id = %s
AND a.due_at IS NOT NULL
AND a.due_at < NOW()
AND a.status IN ('open', 'ready', 'in_progress', 'blocked', 'review_required')
ORDER BY a.due_at ASC
LIMIT 30
""",
(ctx.tenant_id,),
)
return [_serialize_attention(dict(r)) for r in cur.fetchall()]
def _reviews_due(cur, ctx: TenantContext) -> list[dict[str, Any]]:
cur.execute(
"""
SELECT
'review_due' AS kind,
'warning' AS severity,
r.title AS title,
'Review fällig' AS summary,
'review' AS scope_type,
r.id AS scope_id,
r.initiative_id,
r.action_id,
NULL::uuid AS blocker_id,
r.milestone_id,
r.id AS review_id,
NULL::uuid AS recurring_element_id,
'review_due' AS reason_code,
'reviews' AS data_source
FROM reviews r
WHERE r.tenant_id = %s
AND r.status = 'planned'
AND r.due_at IS NOT NULL
AND r.due_at <= NOW()
ORDER BY r.due_at ASC
LIMIT 20
""",
(ctx.tenant_id,),
)
items = []
for row in cur.fetchall():
item = _serialize_attention(dict(row))
for fk in ("action_id", "milestone_id"):
if item.get(fk):
item[fk] = str(item[fk])
items.append(item)
return items
def _recurring_due(cur, ctx: TenantContext) -> list[dict[str, Any]]:
cur.execute(
"""
SELECT
'recurring_due' AS kind,
'info' AS severity,
re.title AS title,
'Wiederkehrendes Element fällig' AS summary,
'recurring_element' AS scope_type,
re.id AS scope_id,
re.initiative_id,
NULL::uuid AS action_id,
NULL::uuid AS blocker_id,
NULL::uuid AS milestone_id,
NULL::uuid AS review_id,
re.id AS recurring_element_id,
'recurring_due' AS reason_code,
'recurring_elements' AS data_source
FROM recurring_elements re
WHERE re.tenant_id = %s
AND re.status = 'active'
AND re.next_due_at IS NOT NULL
AND re.next_due_at <= NOW()
ORDER BY re.next_due_at ASC
LIMIT 20
""",
(ctx.tenant_id,),
)
return [_serialize_attention(dict(r)) for r in cur.fetchall()]
def get_attention_items(ctx: TenantContext) -> list[dict[str, Any]]:
"""Regelbasierte Attention Items ÔÇö tenant-scoped, erkl├ñrbar."""
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
items: list[dict[str, Any]] = []
items.extend(_blocked_actions(cur, ctx))
items.extend(_open_blockers(cur, ctx))
items.extend(_high_priority_actions(cur, ctx))
items.extend(_unassigned_actions(cur, ctx))
items.extend(_initiatives_without_next_action(cur, ctx))
items.extend(_stale_initiatives(cur, ctx))
items.extend(_milestones_at_risk(cur, ctx))
items.extend(_overdue_actions(cur, ctx))
items.extend(_actions_review_required(cur, ctx))
items.extend(_reviews_due(cur, ctx))
items.extend(_recurring_due(cur, ctx))
items.sort(key=lambda x: _SEVERITY_ORDER.get(x["severity"], 99))
return items
finally:
conn.close()
def get_next_action_candidates(
ctx: TenantContext, *, limit: int = 10
) -> list[dict[str, Any]]:
"""Regelbasierte NextActionCandidates ÔÇö limitiert, tenant-scoped."""
if limit < 1:
limit = 1
if limit > 50:
limit = 50
conn = get_connection()
candidates: list[dict[str, Any]] = []
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT
'resolve_blocker' AS kind,
b.title AS title,
'Blocker klären oder Status aktualisieren' AS summary,
b.initiative_id,
b.action_id,
NULL::uuid AS backlog_item_id,
'blocker_open' AS reason_code,
'Blocker bearbeiten' AS recommended_action
FROM blockers b
WHERE b.tenant_id = %s AND b.status IN ('open', 'in_progress')
ORDER BY b.updated_at DESC
LIMIT %s
""",
(ctx.tenant_id, limit),
)
for row in cur.fetchall():
item = dict(row)
item["initiative_id"] = str(item["initiative_id"])
if item.get("action_id"):
item["action_id"] = str(item["action_id"])
candidates.append(item)
remaining = limit - len(candidates)
if remaining > 0:
cur.execute(
"""
SELECT
'assign_action' AS kind,
a.title AS title,
'Actor zuweisen' AS summary,
a.initiative_id,
a.id AS action_id,
NULL::uuid AS backlog_item_id,
'action_unassigned' AS reason_code,
'Maßnahme zuweisen' AS recommended_action
FROM actions a
WHERE a.tenant_id = %s
AND a.status IN ('open', 'ready', 'in_progress')
AND NOT EXISTS (
SELECT 1 FROM action_assignments aa
WHERE aa.action_id = a.id AND aa.tenant_id = a.tenant_id
)
ORDER BY a.updated_at DESC
LIMIT %s
""",
(ctx.tenant_id, remaining),
)
for row in cur.fetchall():
item = dict(row)
item["initiative_id"] = str(item["initiative_id"])
item["action_id"] = str(item["action_id"])
candidates.append(item)
remaining = limit - len(candidates)
if remaining > 0:
cur.execute(
"""
SELECT
'convert_backlog' AS kind,
bi.title AS title,
'Freigegebenes Backlog-Item in Maßnahme umwandeln' AS summary,
bi.initiative_id,
NULL::uuid AS action_id,
bi.id AS backlog_item_id,
'backlog_accepted_not_converted' AS reason_code,
'In Maßnahme umwandeln' AS recommended_action
FROM backlog_items bi
WHERE bi.tenant_id = %s
AND bi.status = 'accepted'
AND bi.converted_action_id IS NULL
ORDER BY bi.updated_at DESC
LIMIT %s
""",
(ctx.tenant_id, remaining),
)
for row in cur.fetchall():
item = dict(row)
item["initiative_id"] = str(item["initiative_id"])
item["backlog_item_id"] = str(item["backlog_item_id"])
candidates.append(item)
remaining = limit - len(candidates)
if remaining > 0:
cur.execute(
"""
SELECT
'create_action' AS kind,
i.title AS title,
'Nächste Maßnahme für Vorhaben anlegen' AS summary,
i.id AS initiative_id,
NULL::uuid AS action_id,
NULL::uuid AS backlog_item_id,
'initiative_no_open_action' AS reason_code,
'Maßnahme anlegen' AS recommended_action
FROM initiatives i
WHERE i.tenant_id = %s
AND i.status IN ('active', 'paused')
AND NOT EXISTS (
SELECT 1 FROM actions a
WHERE a.initiative_id = i.id
AND a.tenant_id = i.tenant_id
AND a.status IN ('open', 'ready', 'in_progress', 'blocked', 'review_required')
)
ORDER BY i.updated_at DESC
LIMIT %s
""",
(ctx.tenant_id, remaining),
)
for row in cur.fetchall():
item = dict(row)
item["initiative_id"] = str(item["initiative_id"])
candidates.append(item)
return candidates[:limit]
finally:
conn.close()
def get_next_action_candidates_for_initiative(
ctx: TenantContext, *, initiative_id: str, limit: int = 5
) -> list[dict[str, Any]]:
"""NextActionCandidates f├╝r ein Vorhaben ÔÇö tenant-scoped."""
if limit < 1:
limit = 1
if limit > 20:
limit = 20
conn = get_connection()
candidates: list[dict[str, Any]] = []
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT
'resolve_blocker' AS kind,
b.title AS title,
'Blocker klären oder Status aktualisieren' AS summary,
b.initiative_id,
b.action_id,
NULL::uuid AS backlog_item_id,
'blocker_open' AS reason_code,
'Blocker bearbeiten' AS recommended_action
FROM blockers b
WHERE b.tenant_id = %s AND b.initiative_id = %s
AND b.status IN ('open', 'in_progress')
ORDER BY b.updated_at DESC
LIMIT %s
""",
(ctx.tenant_id, initiative_id, limit),
)
for row in cur.fetchall():
item = dict(row)
item["initiative_id"] = str(item["initiative_id"])
if item.get("action_id"):
item["action_id"] = str(item["action_id"])
candidates.append(item)
remaining = limit - len(candidates)
if remaining > 0:
cur.execute(
"""
SELECT
'assign_action' AS kind,
a.title AS title,
'Actor zuweisen' AS summary,
a.initiative_id,
a.id AS action_id,
NULL::uuid AS backlog_item_id,
'action_unassigned' AS reason_code,
'Maßnahme zuweisen' AS recommended_action
FROM actions a
WHERE a.tenant_id = %s AND a.initiative_id = %s
AND a.status IN ('open', 'ready', 'in_progress')
AND NOT EXISTS (
SELECT 1 FROM action_assignments aa
WHERE aa.action_id = a.id AND aa.tenant_id = a.tenant_id
)
ORDER BY a.updated_at DESC
LIMIT %s
""",
(ctx.tenant_id, initiative_id, remaining),
)
for row in cur.fetchall():
item = dict(row)
item["initiative_id"] = str(item["initiative_id"])
item["action_id"] = str(item["action_id"])
candidates.append(item)
remaining = limit - len(candidates)
if remaining > 0:
cur.execute(
"""
SELECT
'convert_backlog' AS kind,
bi.title AS title,
'Freigegebenes Backlog-Item in Maßnahme umwandeln' AS summary,
bi.initiative_id,
NULL::uuid AS action_id,
bi.id AS backlog_item_id,
'backlog_accepted_not_converted' AS reason_code,
'In Maßnahme umwandeln' AS recommended_action
FROM backlog_items bi
WHERE bi.tenant_id = %s AND bi.initiative_id = %s
AND bi.status = 'accepted'
AND bi.converted_action_id IS NULL
ORDER BY bi.updated_at DESC
LIMIT %s
""",
(ctx.tenant_id, initiative_id, remaining),
)
for row in cur.fetchall():
item = dict(row)
item["initiative_id"] = str(item["initiative_id"])
item["backlog_item_id"] = str(item["backlog_item_id"])
candidates.append(item)
remaining = limit - len(candidates)
if remaining > 0:
cur.execute(
"""
SELECT 1 FROM actions a
WHERE a.initiative_id = %s AND a.tenant_id = %s
AND a.status IN ('open', 'ready', 'in_progress', 'blocked', 'review_required')
LIMIT 1
""",
(initiative_id, ctx.tenant_id),
)
if not cur.fetchone():
cur.execute(
"""
SELECT title FROM initiatives
WHERE id = %s AND tenant_id = %s
""",
(initiative_id, ctx.tenant_id),
)
init_row = cur.fetchone()
if init_row:
candidates.append(
{
"kind": "create_action",
"title": init_row["title"],
"summary": "Nächste Maßnahme für Vorhaben anlegen",
"initiative_id": initiative_id,
"action_id": None,
"backlog_item_id": None,
"reason_code": "initiative_no_open_action",
"recommended_action": "Maßnahme anlegen",
}
)
return candidates[:limit]
finally:
conn.close()

View File

@ -0,0 +1,26 @@
"""Signal Engine — delegates to default rule provider (AP1.0)."""
from __future__ import annotations
from typing import Any, Literal
from steering.signals import default_rules
from tenant_context import TenantContext
SignalKind = Literal["attention", "next_action"]
def evaluate(
ctx: TenantContext,
kind: SignalKind = "attention",
*,
limit: int = 10,
initiative_id: str | None = None,
) -> list[dict[str, Any]]:
if kind == "attention":
return default_rules.get_attention_items(ctx)
if initiative_id:
return default_rules.get_next_action_candidates_for_initiative(
ctx, initiative_id=initiative_id, limit=limit
)
return default_rules.get_next_action_candidates(ctx, limit=limit)

View File

@ -69,6 +69,7 @@ def _seed_hygiene():
@pytest.fixture(scope="session", autouse=True) @pytest.fixture(scope="session", autouse=True)
def _sync_rights_registry(): def _sync_rights_registry():
import rights_registrations # noqa: F401 import rights_registrations # noqa: F401
import steering # noqa: F401 — bootstrap hooks & methods
from rights_registry import sync_rights_registry_to_db from rights_registry import sync_rights_registry_to_db
assert sync_rights_registry_to_db() == 0 assert sync_rights_registry_to_db() == 0

View File

@ -0,0 +1,91 @@
"""AP1.0 — Steering Foundation tests."""
from __future__ import annotations
from tests.factories import provision_user_in_tenant
from tests.test_initiatives_actions import (
_auth,
_create_action,
_create_initiative,
_login,
)
def test_steering_context_on_initiative_create(client):
user = provision_user_in_tenant(tenant_role="member")
token = _login(client, user)
initiative_id = _create_initiative(client, token).json()["id"]
res = client.get(
f"/api/initiatives/{initiative_id}/steering-context",
headers=_auth(token),
)
assert res.status_code == 200
data = res.json()
assert data["initiative_id"] == initiative_id
assert data["lifecycle_state"] == "intake"
assert data["method_key"] == "generic_operating"
assert data["lifecycle_label"]
def test_steering_snapshot_has_lifecycle(client):
user = provision_user_in_tenant(tenant_role="member")
token = _login(client, user)
initiative_id = _create_initiative(client, token).json()["id"]
_create_action(client, token, initiative_id)
snap = client.get(
f"/api/initiatives/{initiative_id}/steering-snapshot",
headers=_auth(token),
)
assert snap.status_code == 200
data = snap.json()
assert "lifecycle_state" in data
assert data.get("operating_phase_deprecated") is True
assert data["lifecycle_state"] in (
"intake",
"planning",
"action_selection",
"structure_setup",
)
def test_steering_context_tenant_isolation(client):
user_a = provision_user_in_tenant(tenant_role="member")
token_a = _login(client, user_a)
initiative_id = _create_initiative(client, token_a).json()["id"]
user_b = provision_user_in_tenant(tenant_role="member")
token_b = _login(client, user_b)
res = client.get(
f"/api/initiatives/{initiative_id}/steering-context",
headers=_auth(token_b),
)
assert res.status_code == 404
def test_lifecycle_transition_on_blocker_resolve(client):
user = provision_user_in_tenant(tenant_role="member")
token = _login(client, user)
initiative_id = _create_initiative(client, token).json()["id"]
action = _create_action(
client, token, initiative_id, assigned_actor_ids=[user["actor_id"]]
).json()
blocker = client.post(
f"/api/initiatives/{initiative_id}/blockers",
json={"title": "B", "action_id": action["id"], "set_action_blocked": True},
headers=_auth(token),
).json()
client.patch(
f"/api/blockers/{blocker['id']}",
json={"status": "resolved"},
headers=_auth(token),
)
ctx = client.get(
f"/api/initiatives/{initiative_id}/steering-context",
headers=_auth(token),
).json()
assert ctx["lifecycle_state"] == "action_selection"

View File

@ -1,3 +1,3 @@
APP_VERSION = "0.10.1-ap0.10b" APP_VERSION = "0.11.0-ap1.0"
DB_SCHEMA_VERSION = "008" DB_SCHEMA_VERSION = "009"
APP_NAME = "jinkendo-kairo" APP_NAME = "jinkendo-kairo"

View File

@ -0,0 +1,240 @@
# Architecture Decision Proposal — AP1.0 Steering Foundation & Scope Lock
**Status:** angenommen (Umsetzung ausstehend)
**Stand:** 2026-07-05
**Autor:** Product/Architektur-Review (Cursor-Session)
**Auslöser:** AP0.80.10b liefern OM-Inventar + provisorische Steuerungsfragmente ohne `backend/steering/` — Architektur-Kohärenz und MVP-Nachweis fehlen.
---
## Problem
Kairo hat drei parallele Steuerungsfragmente ohne gemeinsamen Kern:
| Fragment | Ort | Rolle |
|----------|-----|--------|
| Attention / NextAction | `data_layer/attention.py` | Regelbasierte Signale |
| Transitions | `services/operating_transitions.py` | Blocker/Review Side Effects |
| Operating Phase | `initiative_snapshot._derive_operating_phase` | UI-Heuristik (nicht persistiert) |
Das **Zielmodell** (Standard Lifecycle, Method Registry, Hook Registry, SteeringContext) ist in Target Architecture und Universal Steering Engine **entschieden**, aber **nicht implementiert** (`backend/steering/` = 0 Dateien).
Gleichzeitig wurde der dokumentierte Pfad „Inventar → Validation → Steering Core“ **gebrochen**:
- AP0.10a/b lieferten Integration/UI statt Validation
- Offene Entscheidungen (Universal Steering §33: `steering_contexts`, `roadmap_item_id`) blieben ungeklärt
- `milestones`-Tabelle als MVP-Brücke ohne Roadmap-Konsolidierungs-ADP
**Leitfrage:** Bauen wir weiter provisorische Enden, die später teuer refactored werden müssen — oder wechseln wir zu **Foundation-first** mit schmalem Steering-Skeleton?
---
## Betroffene Regel
| Dokument | Regel |
|----------|--------|
| `Kairo_Product_Definition_and_MVP_Reset_v0.1.md` §4 | Kairo ≠ `Initiative → Action` |
| `Kairo_Canonical_Operating_Model_v0.1.md` §8 | Data Layer Read; Services Write |
| `Kairo_Target_Architecture_…_v0.1.md` §7 | Standard Lifecycle als gemeinsame Basis |
| `Kairo_Universal_Steering_Engine_Decision_v0.1.md` §32 | AP0.8 max. Ebene 1; spätere Fähigkeit nicht verbauen |
| `Kairo_Operating_Model_Steering_Bridge_v0.1.md` §6 | Evolutionspfad AP1.0 → AP1.3 |
| `Kairo_Sprint0_Principle_Gate_v0.1.md` G-01 | Verhindert teures Refactoring? |
| `Kairo_MVP_Usability_Recovery_Plan_v0.1.md` §3 | Roadmap-Korrektur (wird durch dieses ADP ersetzt) |
---
## Optionen
| Option | Kurzbeschreibung | Pro | Contra |
|--------|------------------|-----|--------|
| A | **Weiter wie bisher** — AP0.10c UI, mehr Heuristiken, AP1.0 „irgendwann“ | Kurzfristig sichtbar | Mehr lose Enden; Doppel-Wahrheit Phase/Lifecycle; teurer Refactor |
| B | **Strategiewechsel:** Scope Lock + AP1.0 Skeleton + Konsolidierung | Passt zu Zielarchitektur; eine Steuerungswahrheit | Kurz weniger neue UI-Features |
| C | **Big Bang** — vollständiger Steering Core + Method Registry sofort | Architektonisch „fertig“ | Over-Engineering; ohne Validation falsche Methoden |
---
## Empfehlung
**Option B — angenommen.**
Begründung:
1. OM-Tabellen (AP0.8/0.9) sind **Andockdaten**, kein Wegwerf-Inventar.
2. Was teuer wird, ist **verteilte Steuerungslogik** — nicht die Entitäten.
3. Ein **schmaler AP1.0-Skeleton** (persistierter Lifecycle, Registry-Gerüst, Signal-Engine-Interface) ist die fehlende Foundation, auf die Methoden und Features einrasten.
4. Validation (AP0.10d) **informiert** AP1.1 (erste Built-in-Methode), blockiert AP1.0 nicht.
---
## Scope Lock — ab sofort verbindlich
### Eingefroren (kein neues Code ohne ADP-Ausnahme)
| # | Verbot | Begründung |
|---|--------|------------|
| L1 | Neue Steuerungs-Heuristiken in `initiative_snapshot`, Routern, Frontend | Ersetzt durch `lifecycle_state` ab AP1.0 |
| L2 | Duplizierte NextAction-/Attention-Regeln (neue SQL-Varianten) | Konsolidierung in `steering/signals/` |
| L3 | **AP0.10c** Initiative Flow UI (Maßnahmen-Hub, Sektionen) | Erst nach AP1.0a — UI soll `lifecycle_state` lesen, nicht dritte Phase-Logik |
| L4 | Neue OM-Tabellen | Regel aus Recovery Plan bleibt |
| L5 | Workflow Runtime, Method Designer, KI/MCP | Product Direction |
| L6 | `operating_phase` als zweite Wahrheit ausbauen | Nur Anzeige-Proxy bis AP1.2; dann aus `SteeringContext` |
### Erlaubt ohne ADP
| # | Erlaubt |
|---|---------|
| E1 | Bugfixes, Tenant-Invarianten, Tests |
| E2 | AP0.10d Validation (Dokument + Checkliste, kein neues Steuerungs-SQL) |
| E3 | AP1.0 Implementierung gemäß Assignment |
| E4 | Refactor bestehender Regeln **in** `backend/steering/` (keine neuen Regeln) |
| E5 | UI-Labels, die `lifecycle_state` / Hook-Signale **anzeigen** (nach AP1.0) |
---
## AP1.0 — Minimaler Steering-Skeleton (nächstes Code-Paket)
**Ziel:** Eine persistierte Steuerungswahrheit pro Initiative — Methoden können später einhängen.
**Nicht-Ziel:** Workflow Runtime, Agenten, Method Designer, Roadmap-Konsolidierung, volle Method Packages.
### A) Schema (Migration 009)
```text
steering_contexts (
id, tenant_id, initiative_id UNIQUE,
method_key DEFAULT 'generic_operating',
method_version DEFAULT '0.1.0',
lifecycle_state NOT NULL, -- Standard Lifecycle Slug
lifecycle_metadata JSONB DEFAULT '{}',
created_at, updated_at
)
```
**Initial `lifecycle_state`:** bestehende Initiativen → `action_selection` wenn offene Actions, sonst `structure_setup` (Seed/Backfill-Logik im Assignment).
**Entscheidung Milestone-Brücke:** `milestones`-Tabelle **bleibt** bis AP1.2/ADP-TA-08 — keine Migration 009 zu `roadmap_items`.
### B) Backend-Modul `backend/steering/`
```text
steering/
__init__.py
lifecycle/
states.py # STANDARD_LIFECYCLE_STEPS (12 Slugs)
orchestrator.py # get_state, transition (minimal, Guards stub)
hooks/
registry.py # HookDefinition + register_hook (Slugs aus Target Arch §7.3)
dispatch.py # dispatch_hook(slug, ctx, payload) — no-op + audit stub
methods/
registry.py # MethodDefinition + register_method
registrations/
generic_operating.py # einzige Built-in-Methode AP1.0
signals/
engine.py # evaluate(ctx, scope) → ruft Provider auf
default_rules.py # Move/wrap aus attention.py (keine neuen Regeln)
context.py # get_or_create_steering_context, resolve_for_initiative
```
### C) Services-Integration (minimal)
| Heute | AP1.0 |
|-------|-------|
| `operating_transitions.after_blocker_*` | ruft `dispatch_hook('on_blocker_resolved', …)` + Lifecycle-Transition wenn Guard passt |
| `operating_transitions.after_review_*` | `on_review_completed` |
| `initiative_snapshot.operating_phase` | **Deprecated-Feld**; parallel `lifecycle_state` + `lifecycle_label` aus Context |
| `data_layer/attention.py` | Delegiert an `signals/engine.py` (Wrapper, Datei bleibt für API-Kompatibilität) |
### D) API (minimal)
| Endpoint | Beschreibung |
|----------|--------------|
| `GET /api/initiatives/{id}/steering-context` | Context + lifecycle_state + method_key |
| Erweiterung `steering-snapshot` | Felder `lifecycle_state`, `lifecycle_label`; `operating_phase` als deprecated alias |
### E) Frontend (minimal)
- Steuerungszustand-Panel: **Lifecycle-Label** aus API (fachliches Label via `operating.js` Mapping auf Standard Lifecycle)
- Kein AP0.10c Maßnahmen-Hub
### F) Tests
- SteeringContext tenant-scoped, Cross-Tenant 404
- Lifecycle-Transition bei Blocker resolved (bestehendes Verhalten, neuer Pfad)
- Signal Engine liefert gleiche Attention-Count wie vor Refactor (Regression)
### G) Version
- `0.11.0-ap1.0` · Schema `009`
---
## Konsolidierungs-Roadmap nach AP1.0
```text
AP1.0 Steering Foundation (dieses ADP) ← JETZT
AP0.10d MVP Validation (5 Vorhaben) ← parallel / unmittelbar danach
AP1.1 Method Registry + 2. Built-in-Methode (product_milestone_driven)
AP1.2 attention.py vollständig → signals/; operating_phase entfernen
AP1.3 operating_transitions → Hook Orchestrator
AP1.4 Milestone → RoadmapItem (ADP-TA-08) — nur mit separater ADP
AP0.10c Initiative Flow UI ← nach AP1.2
```
---
## Was bleibt / was wird refactored / was nicht weggeworfen
| Kategorie | Inhalt |
|-----------|--------|
| **Bleibt** | OM-Tabellen, Tenant/Actor/Capabilities, Data Layer Pattern, Backlog→Action, CRUD Services |
| **Refactor** | `attention.py`, `operating_transitions.py`, Snapshot-Phase |
| **Deprecated dann entfernen** | `operating_phase` Heuristik (AP1.2) |
| **Spätere ADP** | `milestones``roadmap_items`, Project-Entität, generisches Assignment |
---
## Risiko
| Risiko | Schwere | Mitigation |
|--------|---------|------------|
| AP1.0 verzögert Nutzer-UI | mittel | AP0.10b bleibt; Validation dokumentiert Lücken |
| Refactor bricht Attention-Tests | mittel | Regression-Suite vor Move |
| Lifecycle zu abstrakt | mittel | Fachliche Labels in UI; `generic_operating` Method |
| Doppelmodell Phase + Lifecycle kurz | niedrig | `operating_phase` deprecated; ADP L6 |
| Over-Engineering AP1.0 | mittel | Assignment definiert harte Nicht-Ziele |
---
## Rückbaubarkeit
- Migration 009 additiv; `steering_contexts` droppbar ohne OM-Tabellen zu berühren
- Wrapper in `attention.py` kann wieder direkt SQL ausführen
- `operating_transitions` bleibt bis AP1.3 als Fallback aufrufbar
- Method Registry mit einem Eintrag — Erweiterung ohne Schema-Change
---
## Auswirkung auf Sprint 0 / Sprint 1
| Thema | Auswirkung |
|-------|------------|
| AP0.80.9 | **Gültig** — System of Record |
| AP0.10a/b | **Gültig** — Integration/UI; keine weiteren Ausbauten |
| AP0.10c | **Eingefroren** bis AP1.2 |
| AP0.10d | **Freigegeben** — Validation Report als Gate für AP1.1 |
| Sprint 1 Start | AP1.0 = offizieller Sprint-1-Einstieg (Steering Foundation) |
| Foundation AP0.10.7 | Unverändert gültig |
---
## Nächste Schritte
1. ~~ADP (dieses Dokument)~~ — angenommen
2. `docs/sprints/Sprint1_AP1_0_Steering_Foundation_Assignment_v0.1.md` — Implementierungsauftrag
3. `Kairo_MVP_Usability_Recovery_Plan_v0.1.md` §3 — Roadmap an ADP anbinden
4. Umsetzung AP1.0 auf Branch `develop`
5. AP0.10d Validation Report vor AP1.1
---
*Referenz: KAIRO-ARCH-01, Operating Model Steering Bridge, Universal Steering Engine Decision §3132*

View File

@ -43,25 +43,23 @@ Das ist **nicht** Feature-Parität mit Todoist — sondern **Steuerungsmehrwert*
## 3. Strategische Korrektur der Roadmap ## 3. Strategische Korrektur der Roadmap
Die bisherige Reihenfolge: **Stand 2026-07-05 (Strategiewechsel):** Siehe `docs/architecture/ADP_AP1_0_Steering_Foundation_Scope_Lock_v0.1.md`
Die Reihenfolge „Inventar → mehr UI → irgendwann Core“ hat **lose Steuerungsfragmente** erzeugt ohne `backend/steering/`. **Neue Leitlinie: Foundation-first.**
```text ```text
AP0.8 Entitäten → AP0.9 Entitäten → AP0.10 Validation → Sprint 1 Steering Core AP0.10b Steering UI Minimum ✓ erledigt
Scope Lock aktiv ← keine neuen Heuristiken / kein AP0.10c
AP1.0 Steering Foundation Skeleton ← NÄCHSTES Code-Paket
AP0.10d MVP Validation (5 Vorhaben) ← parallel / unmittelbar nach AP1.0
AP1.1 Method Registry + 2. Methode
AP1.2 Signals konsolidieren; operating_phase entfernen
AP1.3 operating_transitions → Hook Orchestrator
AP0.10c Initiative Flow UI ← erst nach AP1.2 (lifecycle-aware)
AP1.4 Milestone → RoadmapItem ← separate ADP
``` ```
**Problem:** Validation auf CRUD-Listen liefert nur „fehlt alles“ — ohne vorherige **Steering Surface**. **Regel bis AP1.2:** Keine neuen OM-Tabellen. Keine parallele Steuerungslogik außerhalb `backend/steering/`.
**Korrigierte Reihenfolge:**
```text
AP0.10b Steering UI Minimum ← zuerst (Nutzen sichtbar)
AP0.10c Initiative Flow Minimum
AP0.10d MVP Validation (5 Vorhaben)
AP1.0 Steering Core Foundation (nur mit Validation-Ergebnis)
AP1.1 Method Registry minimal
```
**Regel bis AP1.0:** Keine neuen OM-Tabellen. Nur verbinden, anzeigen, orchestrieren, validieren.
--- ---
@ -82,9 +80,12 @@ AP1.1 Method Registry minimal
--- ---
### AP0.10c — Initiative Flow Minimum (Priorität 2) ### AP0.10c — Initiative Flow Minimum — **EINGEFROREN**
**Ziel:** Vorhaben-Detail beantwortet „Wo stehe ich?“ — nicht „Hier sind 8 Listen“. **Status:** eingefroren bis AP1.2 (ADP AP1.0 Scope Lock).
**Grund:** UI ohne Lifecycle-Foundation würde dritte Steuerungswahrheit erzeugen.
**Ziel (später):** Vorhaben-Detail beantwortet „Wo stehe ich?“ — nicht „Hier sind 8 Listen“.
| # | Lieferung | Aufwand | Nutzen | | # | Lieferung | Aufwand | Nutzen |
|---|-----------|---------|--------| |---|-----------|---------|--------|

View File

@ -0,0 +1,224 @@
# AP1.0 — Steering Foundation (Skeleton)
## Implementierungsauftrag v0.1
**Status:** umgesetzt
**Stand:** 2026-07-05
**Vorgänger:** AP0.10b ✓
**ADP:** `docs/architecture/ADP_AP1_0_Steering_Foundation_Scope_Lock_v0.1.md`
**Zielversion:** `0.11.0-ap1.0` · Schema `009`
---
## Ziel
**Eine persistierte Steuerungswahrheit pro Initiative** — Standard Lifecycle als Foundation, in die Methoden später einhängen.
Leitfrage bleibt: *Welcher nächste Schritt bringt ein Vorhaben am wirkungsvollsten voran?* — technisch beantwortet über `SteeringContext.lifecycle_state` + Signal Engine, nicht über verstreute Heuristiken.
---
## Scope Lock (verbindlich)
Siehe ADP § Scope Lock. **Kein AP0.10c.** Keine neuen Attention-Regeln. Keine neuen OM-Tabellen außer `steering_contexts`.
---
## Teil A — Schema 009
### Tabelle `steering_contexts`
```sql
CREATE TABLE steering_contexts (
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,
method_key VARCHAR(64) NOT NULL DEFAULT 'generic_operating',
method_version VARCHAR(32) NOT NULL DEFAULT '0.1.0',
lifecycle_state VARCHAR(64) NOT NULL DEFAULT 'intake',
lifecycle_metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (tenant_id, initiative_id)
);
CREATE INDEX idx_steering_contexts_tenant ON steering_contexts(tenant_id);
```
### Backfill (Seed oder Migration-Block)
Bestehende Initiativen erhalten Context:
| Bedingung | `lifecycle_state` |
|-----------|-------------------|
| `status IN (completed, archived)` | `closure` |
| offene Actions existieren | `action_selection` |
| nur Backlog (new/triaged/accepted), keine offenen Actions | `structure_setup` |
| sonst active/paused | `planning` |
---
## Teil B — `backend/steering/`
### B1 — Lifecycle
**Datei:** `steering/lifecycle/states.py`
```python
STANDARD_LIFECYCLE_STEPS = (
"intake", "method_selection", "structure_setup", "planning",
"action_selection", "assignment", "waiting", "result_intake",
"validation", "review", "adaptation", "closure",
)
```
**Datei:** `steering/lifecycle/orchestrator.py`
- `get_lifecycle_state(ctx, initiative_id) -> str`
- `transition(ctx, initiative_id, to_state, *, reason: str) -> dict` — Guard minimal: nur erlaubte Übergänge aus `generic_operating` oder freier Übergang für AP1.0 stub
- Audit-Log bei Transition
### B2 — Hook Registry
**Datei:** `steering/hooks/registry.py`
- `HookDefinition` dataclass (slug, lifecycle_step, description, since_version)
- `register_hook()`, `get_hook()`, `list_hooks()`
- Registrierung der Slugs aus Target Architecture §7.3 (Definition only, Implementierung stub)
**Datei:** `steering/hooks/dispatch.py`
- `dispatch_hook(slug, ctx, payload) -> list[dict]` — ruft registrierte Handler; AP1.0: leere Handler + Audit-Eintrag
### B3 — Method Registry
**Datei:** `steering/methods/registry.py` + `registrations/generic_operating.py`
- Eine Built-in-Methode: `generic_operating`
- Liefert: `default_lifecycle_steps`, erlaubte Hooks (alle globalen), keine Structure Builder
### B4 — Signal Engine
**Datei:** `steering/signals/default_rules.py`
- Bestehende Regeln aus `data_layer/attention.py` **verschieben** (nicht duplizieren)
- `evaluate_attention(ctx) -> list[dict]`
- `evaluate_next_actions(ctx, *, limit, initiative_id=None) -> list[dict]`
**Datei:** `steering/signals/engine.py`
- `evaluate(ctx, kind='attention'|'next_action', **kwargs)` — delegiert an default_rules
**Datei:** `data_layer/attention.py`
- Wird dünner Wrapper um `steering.signals.engine` (API-Kompatibilität)
### B5 — Steering Context Service
**Datei:** `steering/context.py` + `services/steering_context.py` (Write)
- `get_or_create_for_initiative(ctx, initiative_id)`
- `get_steering_context(ctx, initiative_id) -> Optional[dict]`
- Tenant-scoped; Cross-Tenant → None / 404
---
## Teil C — Integration bestehender Flows
### C1 — `operating_transitions.py`
Vor/nach bestehender Logik:
```python
from steering.hooks.dispatch import dispatch_hook
dispatch_hook("on_blocker_resolved", ctx, {...})
# optional: lifecycle transition assignment → action_selection
```
Verhalten Blocker→entblocken **unverändert** (Regression-Tests).
### C2 — `initiative_snapshot.py`
Snapshot ergänzt:
```json
{
"lifecycle_state": "action_selection",
"lifecycle_label": "Maßnahmen auswählen",
"operating_phase": "execute",
"operating_phase_deprecated": true
}
```
`_derive_operating_phase` bleibt für Rückwärtskompatibilität; markiert deprecated in Docstring.
### C3 — Initiative Create
Bei `create_initiative``steering_contexts` Zeile anlegen (`lifecycle_state=intake` oder Backfill-Regel).
---
## Teil D — API
| Endpoint | Capability | Beschreibung |
|----------|------------|--------------|
| `GET /api/initiatives/{id}/steering-context` | `kairo.initiative.read` | Context DTO |
| `GET .../steering-snapshot` | (bestehend) | + lifecycle Felder |
---
## Teil E — Frontend (minimal)
**Datei:** `frontend/src/constants/operating.js`
- `LIFECYCLE_LABELS` für 12 Standard-Schritte (DE)
- Steuerungszustand-Panel: primär `lifecycle_label`; `operating_phase` nur Fallback
Kein Maßnahmen-Hub. Keine neuen Widgets.
---
## Teil F — Tests
| Test | Erwartung |
|------|-----------|
| `test_ap1_steering_context.py` | CRUD Context, tenant isolation |
| `test_ap1_lifecycle.py` | Transition + Audit |
| `test_ap1_signals_regression.py` | Attention/NextAction gleich wie vor Refactor |
| `test_ap10_integration.py` | weiter grün; Snapshot hat lifecycle_state |
---
## Nicht-Scope
- Workflow Runtime, Waiting/Reminder Scheduler
- Zweite Built-in-Methode (`product_milestone_driven`) → AP1.1
- `roadmap_items`, `projects`, `milestones`-Migration
- AP0.10c UI
- Prompt/KI/MCP
- Entfernen von `operating_phase` → AP1.2
---
## Abnahme
1. Migration 009 deployed; bestehende Initiativen haben Context
2. `backend/steering/` existiert mit Lifecycle, Hooks, Methods, Signals
3. Attention/NextAction APIs unverändert im Verhalten (Regression)
4. Snapshot + neuer steering-context Endpoint liefern `lifecycle_state`
5. Scope Lock aus ADP eingehalten (kein AP0.10c)
6. pytest grün remote
---
## Reihenfolge Implementierung
1. Migration 009 + Backfill
2. `steering/lifecycle`, `steering/context`
3. Initiative-Create Hook
4. `steering/signals` Refactor aus attention.py
5. Hook dispatch in operating_transitions
6. API + Snapshot + Frontend Labels
7. Tests + Version bump
---
*Nächster Schritt nach Abnahme: AP0.10d Validation Report, dann AP1.1 Method Registry erweitern*

View File

@ -1,6 +1,6 @@
{ {
"name": "kairo-jinkendo-frontend", "name": "kairo-jinkendo-frontend",
"version": "0.10.1-ap0.10b", "version": "0.11.0-ap1.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@ -1,5 +1,6 @@
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { import {
LIFECYCLE_LABELS,
PHASE_LABELS, PHASE_LABELS,
PHASE_DESCRIPTIONS, PHASE_DESCRIPTIONS,
SIGNAL_LABELS, SIGNAL_LABELS,
@ -39,20 +40,30 @@ export function SteeringSnapshotPanel({ snapshot, loading, error }) {
} }
const { const {
lifecycle_state,
lifecycle_label,
operating_phase, operating_phase,
operating_phase_deprecated,
phase_signals, phase_signals,
counts, counts,
upcoming_milestones = [], upcoming_milestones = [],
next_actions = [], next_actions = [],
} = snapshot } = snapshot
const phaseLabel = PHASE_LABELS[operating_phase] || operating_phase const displayLabel =
const phaseDesc = PHASE_DESCRIPTIONS[operating_phase] || '' lifecycle_label ||
LIFECYCLE_LABELS[lifecycle_state] ||
PHASE_LABELS[operating_phase] ||
lifecycle_state ||
operating_phase
const phaseDesc = lifecycle_state
? null
: PHASE_DESCRIPTIONS[operating_phase] || ''
return ( return (
<section className="card steering-snapshot"> <section className="card steering-snapshot">
<div className="section-header"> <div className="section-header">
<h2>Steuerungszustand</h2> <h2>Steuerungszustand</h2>
<span className="badge status-badge status-active">{phaseLabel}</span> <span className="badge status-badge status-active">{displayLabel}</span>
</div> </div>
{phaseDesc && <p className="steering-phase-desc">{phaseDesc}</p>} {phaseDesc && <p className="steering-phase-desc">{phaseDesc}</p>}
@ -130,7 +141,8 @@ export function SteeringSnapshotPanel({ snapshot, loading, error }) {
</dl> </dl>
<p className="muted snapshot-hint"> <p className="muted snapshot-hint">
Heuristischer Steuerungszustand später methodengeführt über Steering Core. Steuerungszustand über Standard Lifecycle
{lifecycle_state ? ` (${lifecycle_state})` : ''}.
</p> </p>
</section> </section>
) )

View File

@ -1,3 +1,18 @@
export const LIFECYCLE_LABELS = {
intake: 'Aufnahme',
method_selection: 'Methode wählen',
structure_setup: 'Struktur aufbauen',
planning: 'Planung',
action_selection: 'Maßnahmen auswählen',
assignment: 'Zuweisen',
waiting: 'Warten',
result_intake: 'Ergebnis aufnehmen',
validation: 'Validierung',
review: 'Review',
adaptation: 'Anpassung',
closure: 'Abschluss',
}
export const OPERATING_PHASES = [ export const OPERATING_PHASES = [
'capture', 'capture',
'triage', 'triage',