Merge pull request 'Ap04' (#5) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 33s
Test Suite / pytest-backend (push) Successful in 18s
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 13s

Reviewed-on: #5
This commit is contained in:
Lars 2026-07-04 23:32:20 +02:00
commit 7c547027f2
42 changed files with 2755 additions and 38 deletions

View File

@ -25,7 +25,17 @@ jobs:
fi fi
docker compose build --no-cache backend frontend docker compose build --no-cache backend frontend
echo "✓ Backend + Frontend gebaut (Frontend: npm run build im Dockerfile)" echo "✓ Backend + Frontend gebaut (Frontend: npm run build im Dockerfile)"
docker compose up -d --wait if ! docker compose up -d --wait; then
curl -sf http://localhost:8004/api/health && echo "✓ PROD API /api/health OK" echo "✗ compose up --wait fehlgeschlagen — Backend-Logs:"
docker compose logs backend --tail 150 || true
docker compose ps || true
exit 1
fi
if ! curl -sf http://localhost:8004/api/health; then
echo "✗ PROD API nicht erreichbar — Backend-Logs:"
docker compose logs backend --tail 150 || true
exit 1
fi
echo "✓ PROD API /api/health OK"
curl -sf http://localhost:3004/api/health && echo "✓ PROD Frontend-Proxy /api/health OK" curl -sf http://localhost:3004/api/health && echo "✓ PROD Frontend-Proxy /api/health OK"
echo "=== Kairo PROD Deploy complete ===" echo "=== Kairo PROD Deploy complete ==="

View File

@ -132,6 +132,48 @@ 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) |
### Registries (AP0.4)
Feature-, Prompt-, Placeholder- und Config-Registry analog zur Rights Registry: Code-Registrierung → Startup-Sync → DB.
| Endpoint | Methode | Capability | Beschreibung |
|----------|---------|------------|--------------|
| `/api/features` | GET | `kairo.feature.registry.read` | Feature-Katalog |
| `/api/prompts` | GET | `kairo.prompt.registry.read` | Prompt-Definitionen |
| `/api/prompts/{key}` | GET | `kairo.prompt.registry.read` | Definition + aktive Version |
| `/api/prompts/{key}/render` | POST | `kairo.prompt.render` | Single-Mode Rendering (ohne LLM) |
| `/api/config` | GET | `kairo.config.registry.read` | Konfiguration (global/tenant) |
| `/api/config` | POST | `kairo.config.registry.manage` | Konfiguration setzen |
Startup: `sync_prompt_feature_config.py` nach Rights-Sync (`SKIP_REGISTRY_SYNC=1` zum Überspringen).
**Prompt-Modell:** `PromptDefinition``PromptVersion` → optional `PromptStep` (workflow-/pipeline-fähig; AP0.4 führt nur `execution_mode=single` aus).
**Beispiel-Prompts:** `kairo.system.health_summary`, `kairo.context.debug_summary`
**Features im Entitlements-Snapshot:** `kairo.prompt.registry`, `kairo.config.registry`, `kairo.feature.registry`
| Capability | Modul | Kurzbeschreibung |
|------------|-------|------------------|
| `kairo.feature.registry.read` | feature | Feature-Katalog lesen |
| `kairo.feature.registry.manage` | feature | Feature-Metadaten verwalten |
| `kairo.prompt.registry.read` | prompt | Prompt Registry lesen |
| `kairo.prompt.registry.manage` | prompt | Prompt Registry verwalten |
| `kairo.config.registry.read` | config | Config lesen |
| `kairo.config.registry.manage` | config | Config schreiben |
| `kairo.prompt.render` | prompt | Prompt rendern (Test/Preview) |
Zusätzlich AP0.3-Capabilities (siehe oben).
Enforcement: `CAPABILITY_ENFORCE=probe` (Default) oder `enforce`.
```bash
curl -s http://localhost:8097/api/me/entitlements -H "X-Auth-Token: TOKEN"
curl -s -X POST http://localhost:8097/api/prompts/kairo.system.health_summary/render \
-H "X-Auth-Token: TOKEN" -H "Content-Type: application/json" \
-d '{"values":{"debug_message":"OK","debug_level":"info"},"use_context":false}'
```
### Capabilities (AP0.3) ### Capabilities (AP0.3)
Registry-first: Capabilities werden in `backend/rights_registrations/` registriert und beim Start via `sync_rights_registry.py` in die DB synchronisiert. Registry-first: Capabilities werden in `backend/rights_registrations/` registriert und beim Start via `sync_rights_registry.py` in die DB synchronisiert.

View File

@ -9,7 +9,7 @@ from fastapi import Depends, HTTPException
from rights_registry import load_grants_for_roles from rights_registry import load_grants_for_roles
from services.audit import log_audit from services.audit import log_audit
from tenant_context import TenantContext, require_tenant_context from tenant_context import TenantContext, get_tenant_context, require_tenant_context
CAPABILITY_ENFORCE_ENV = "CAPABILITY_ENFORCE" CAPABILITY_ENFORCE_ENV = "CAPABILITY_ENFORCE"
@ -40,6 +40,21 @@ def require_capability(capability_key: str) -> Callable[..., TenantContext]:
"""FastAPI dependency — blocks in enforce mode, probes in probe mode.""" """FastAPI dependency — blocks in enforce mode, probes in probe mode."""
def _dependency(ctx: TenantContext = Depends(require_tenant_context)) -> TenantContext: def _dependency(ctx: TenantContext = Depends(require_tenant_context)) -> TenantContext:
return _enforce_capability(ctx, capability_key)
return _dependency
def require_capability_ctx(capability_key: str) -> Callable[..., TenantContext]:
"""Like require_capability but allows portal context without active tenant."""
def _dependency(ctx: TenantContext = Depends(get_tenant_context)) -> TenantContext:
return _enforce_capability(ctx, capability_key)
return _dependency
def _enforce_capability(ctx: TenantContext, capability_key: str) -> TenantContext:
result = check_capability(ctx, capability_key) result = check_capability(ctx, capability_key)
if result["allowed"]: if result["allowed"]:
return ctx return ctx
@ -62,5 +77,3 @@ def require_capability(capability_key: str) -> Callable[..., TenantContext]:
detail=f"Capability fehlt: {capability_key}", detail=f"Capability fehlt: {capability_key}",
) )
return ctx return ctx
return _dependency

208
backend/config_service.py Normal file
View File

@ -0,0 +1,208 @@
"""Central configuration service — no plaintext secrets."""
from __future__ import annotations
import json
from typing import Any
from db import get_connection
from services.audit import log_audit
SECRET_VALUE_FORBIDDEN = (
"Configuration entries with is_secret=true must not store plaintext values; "
"use environment or secret management."
)
def _validate_secret_entry(*, is_secret: bool, value: Any) -> None:
if not is_secret:
return
if value not in (None, {}, "", {"ref": "env"}):
raise ValueError(SECRET_VALUE_FORBIDDEN)
def get_config(
config_key: str,
*,
scope: str = "global",
tenant_id: str | None = None,
) -> dict | None:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT config_key, scope, tenant_id, value, value_type, description, is_secret
FROM configuration_entries
WHERE config_key = %s AND scope = %s
AND (
(scope = 'global' AND tenant_id IS NULL)
OR (scope = 'tenant' AND tenant_id = %s::uuid)
)
""",
(config_key, scope, tenant_id),
)
row = cur.fetchone()
if not row:
return None
return {
"config_key": row[0],
"scope": row[1],
"tenant_id": str(row[2]) if row[2] else None,
"value": row[3],
"value_type": row[4],
"description": row[5],
"is_secret": row[6],
}
finally:
conn.close()
def list_configs(*, scope: str | None = None, tenant_id: str | None = None) -> list[dict]:
conn = get_connection()
try:
with conn.cursor() as cur:
if scope == "global":
cur.execute(
"""
SELECT config_key, scope, tenant_id, value, value_type, description, is_secret
FROM configuration_entries
WHERE scope = 'global' AND tenant_id IS NULL
ORDER BY config_key
"""
)
elif scope == "tenant" and tenant_id:
cur.execute(
"""
SELECT config_key, scope, tenant_id, value, value_type, description, is_secret
FROM configuration_entries
WHERE scope = 'tenant' AND tenant_id = %s::uuid
ORDER BY config_key
""",
(tenant_id,),
)
else:
cur.execute(
"""
SELECT config_key, scope, tenant_id, value, value_type, description, is_secret
FROM configuration_entries
ORDER BY scope, config_key
"""
)
rows = []
for row in cur.fetchall():
item = {
"config_key": row[0],
"scope": row[1],
"tenant_id": str(row[2]) if row[2] else None,
"value_type": row[4],
"description": row[5],
"is_secret": row[6],
}
if row[6]:
item["value"] = {"ref": "env"}
else:
item["value"] = row[3]
rows.append(item)
return rows
finally:
conn.close()
def upsert_config(
*,
config_key: str,
scope: str,
value: Any,
value_type: str = "string",
description: str = "",
is_secret: bool = False,
tenant_id: str | None = None,
user_id: str | None = None,
) -> dict:
_validate_secret_entry(is_secret=is_secret, value=value)
if scope == "tenant" and not tenant_id:
raise ValueError("tenant_id required for tenant-scoped config")
if scope == "global":
tenant_id = None
conn = get_connection()
try:
with conn.cursor() as cur:
if scope == "global":
cur.execute(
"""
INSERT INTO configuration_entries (
config_key, scope, tenant_id, value, value_type, description, is_secret
)
VALUES (%s, 'global', NULL, %s::jsonb, %s, %s, %s)
ON CONFLICT (config_key) WHERE scope = 'global' DO UPDATE SET
value = EXCLUDED.value,
value_type = EXCLUDED.value_type,
description = EXCLUDED.description,
is_secret = EXCLUDED.is_secret,
updated_at = NOW()
""",
(
config_key,
json.dumps(value if not is_secret else {"ref": "env"}),
value_type,
description,
is_secret,
),
)
else:
cur.execute(
"""
INSERT INTO configuration_entries (
config_key, scope, tenant_id, value, value_type, description, is_secret
)
VALUES (%s, 'tenant', %s::uuid, %s::jsonb, %s, %s, %s)
ON CONFLICT (config_key, tenant_id) WHERE scope = 'tenant' DO UPDATE SET
value = EXCLUDED.value,
value_type = EXCLUDED.value_type,
description = EXCLUDED.description,
is_secret = EXCLUDED.is_secret,
updated_at = NOW()
""",
(
config_key,
tenant_id,
json.dumps(value if not is_secret else {"ref": "env"}),
value_type,
description,
is_secret,
),
)
conn.commit()
finally:
conn.close()
log_audit(
"config.entry.changed",
user_id=user_id,
tenant_id=tenant_id,
details={"config_key": config_key, "scope": scope, "is_secret": is_secret},
)
result = get_config(config_key, scope=scope, tenant_id=tenant_id)
assert result is not None
return result
def sync_default_configs(defaults: list[dict]) -> int:
if not defaults:
return 0
for item in defaults:
existing = get_config(item["config_key"], scope=item.get("scope", "global"))
if existing:
continue
upsert_config(
config_key=item["config_key"],
scope=item.get("scope", "global"),
value=item["value"],
value_type=item.get("value_type", "string"),
description=item.get("description", ""),
is_secret=item.get("is_secret", False),
)
print(f"[config_service] Default configs ensured — {len(defaults)} item(s)")
return 0

View File

@ -5,6 +5,7 @@ from __future__ import annotations
from typing import Any from typing import Any
from capabilities import capability_enforcement_mode, check_capability from capabilities import capability_enforcement_mode, check_capability
from feature_registry import load_active_features
from rights_registry import get_registered_capabilities from rights_registry import get_registered_capabilities
from tenant_context import TenantContext from tenant_context import TenantContext
@ -17,7 +18,20 @@ def _capability_map(ctx: TenantContext) -> dict[str, dict[str, Any]]:
return snapshot return snapshot
def _feature_snapshot() -> dict[str, dict[str, Any]]:
features = load_active_features()
return {
key: {
"enabled": meta["enabled"],
"module": meta["module"],
"name": meta["name"],
}
for key, meta in features.items()
}
def build_entitlements_snapshot(ctx: TenantContext) -> dict[str, Any]: def build_entitlements_snapshot(ctx: TenantContext) -> dict[str, Any]:
features = _feature_snapshot()
tenant_block = None tenant_block = None
if ctx.tenant_id: if ctx.tenant_id:
tenant_block = { tenant_block = {
@ -30,7 +44,7 @@ def build_entitlements_snapshot(ctx: TenantContext) -> dict[str, Any]:
for key, value in _capability_map(ctx).items() for key, value in _capability_map(ctx).items()
if key in ctx.capabilities if key in ctx.capabilities
}, },
"features": {}, "features": features,
} }
return { return {
@ -40,7 +54,7 @@ def build_entitlements_snapshot(ctx: TenantContext) -> dict[str, Any]:
"display_name": ctx.display_name, "display_name": ctx.display_name,
"portal_role": ctx.portal_role, "portal_role": ctx.portal_role,
"capabilities": _capability_map(ctx), "capabilities": _capability_map(ctx),
"features": {}, "features": features,
}, },
"tenant": tenant_block, "tenant": tenant_block,
"actor": ( "actor": (
@ -56,7 +70,7 @@ def build_entitlements_snapshot(ctx: TenantContext) -> dict[str, Any]:
"tenant": ctx.tenant_role, "tenant": ctx.tenant_role,
}, },
"capabilities": sorted(ctx.capabilities), "capabilities": sorted(ctx.capabilities),
"features": {}, "features": features,
"enforcement": { "enforcement": {
"capabilities": capability_enforcement_mode(), "capabilities": capability_enforcement_mode(),
"features": "probe", "features": "probe",

View File

@ -21,6 +21,12 @@ else
echo "[SKIP_RIGHTS_SYNC] Rights-Registry-Sync übersprungen" echo "[SKIP_RIGHTS_SYNC] Rights-Registry-Sync übersprungen"
fi fi
if [ "${SKIP_REGISTRY_SYNC}" != "1" ] && [ "${SKIP_REGISTRY_SYNC}" != "true" ] && [ "${SKIP_REGISTRY_SYNC}" != "yes" ]; then
python sync_prompt_feature_config.py
else
echo "[SKIP_REGISTRY_SYNC] Feature/Prompt/Config-Sync übersprungen"
fi
export KAIRO_DB_READY=1 export KAIRO_DB_READY=1
echo "=== Starte Anwendung: $* ===" echo "=== Starte Anwendung: $* ==="
exec "$@" exec "$@"

View File

@ -0,0 +1,3 @@
"""Side-effect imports for feature registrations."""
from feature_registrations import platform, prompt_ops # noqa: F401

View File

@ -0,0 +1,32 @@
"""Platform feature catalog entries."""
from __future__ import annotations
from feature_registry import FeatureRegistration, register_feature
register_feature(
FeatureRegistration(
key="kairo.feature.registry",
module="platform",
name="Feature Registry",
description="Metadatenkatalog registrierter Kairo-Features",
)
)
register_feature(
FeatureRegistration(
key="kairo.config.registry",
module="config",
name="Configuration Registry",
description="Zentrale fachliche Konfiguration (global/tenant)",
)
)
register_feature(
FeatureRegistration(
key="kairo.prompt.registry",
module="prompt",
name="Prompt Registry",
description="Workflow-ready Prompt-Definitionen und Versionen",
)
)

View File

@ -0,0 +1,5 @@
"""Prompt-related feature metadata."""
from __future__ import annotations
# Reserved for future prompt-domain features beyond registry catalog.

108
backend/feature_registry.py Normal file
View File

@ -0,0 +1,108 @@
"""In-memory feature registry with startup sync to PostgreSQL."""
from __future__ import annotations
from dataclasses import dataclass
from db import get_connection
VALID_CONTEXT_KINDS = frozenset(
{
"kairo.tenant_context",
"kairo.actor_context",
"kairo.debug_context",
}
)
VALID_EXECUTION_MODES = frozenset({"single", "pipeline", "workflow"})
@dataclass(frozen=True)
class FeatureRegistration:
key: str
module: str
name: str
description: str
_REGISTRY: dict[str, FeatureRegistration] = {}
def register_feature(registration: FeatureRegistration) -> None:
if not registration.key or not registration.key.strip():
raise ValueError("Feature key is required")
if not registration.module or not registration.module.strip():
raise ValueError("Feature module is required")
if not registration.name or not registration.name.strip():
raise ValueError("Feature name is required")
if not registration.description or not registration.description.strip():
raise ValueError("Feature description is required")
if registration.key in _REGISTRY:
raise ValueError(f"Feature already registered: {registration.key}")
_REGISTRY[registration.key] = registration
def get_registered_features() -> tuple[FeatureRegistration, ...]:
return tuple(_REGISTRY.values())
def clear_feature_registry_for_tests() -> None:
_REGISTRY.clear()
def sync_features_to_db() -> int:
if not _REGISTRY:
print("[feature_registry] Keine Features registriert — Sync übersprungen")
return 0
conn = get_connection()
try:
with conn.cursor() as cur:
for reg in _REGISTRY.values():
cur.execute(
"""
INSERT INTO features (feature_key, module, name, description, is_active)
VALUES (%s, %s, %s, %s, TRUE)
ON CONFLICT (feature_key) DO UPDATE SET
module = EXCLUDED.module,
name = EXCLUDED.name,
description = EXCLUDED.description,
is_active = TRUE,
updated_at = NOW()
""",
(reg.key, reg.module, reg.name, reg.description),
)
conn.commit()
print(f"[feature_registry] Sync OK — {len(_REGISTRY)} Feature(s)")
return 0
except Exception as exc:
conn.rollback()
print(f"[feature_registry] Sync FAIL: {exc}")
return 1
finally:
conn.close()
def load_active_features() -> dict[str, dict]:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT feature_key, module, name, description
FROM features
WHERE is_active = TRUE
ORDER BY feature_key
"""
)
return {
row[0]: {
"enabled": True,
"module": row[1],
"name": row[2],
"description": row[3],
}
for row in cur.fetchall()
}
finally:
conn.close()

View File

@ -53,10 +53,13 @@ app.add_middleware(
allow_headers=["*"], allow_headers=["*"],
) )
from routers import auth, me # noqa: E402 from routers import auth, config, features, 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(prompts.router)
app.include_router(config.router)
@app.get("/api/health") @app.get("/api/health")

View File

@ -0,0 +1,121 @@
-- AP0.4: Feature, Prompt, Placeholder and Configuration registries
CREATE TABLE features (
feature_key VARCHAR(128) PRIMARY KEY,
module VARCHAR(64) NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT NOT NULL DEFAULT '',
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE prompt_definitions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
prompt_key VARCHAR(128) NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL,
purpose TEXT NOT NULL DEFAULT '',
context_kind VARCHAR(64) NOT NULL,
execution_mode VARCHAR(32) NOT NULL DEFAULT 'single'
CHECK (execution_mode IN ('single', 'pipeline', 'workflow')),
status VARCHAR(32) NOT NULL DEFAULT 'active'
CHECK (status IN ('draft', 'active', 'archived')),
active_version_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE prompt_versions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
prompt_definition_id UUID NOT NULL REFERENCES prompt_definitions(id) ON DELETE CASCADE,
version VARCHAR(64) NOT NULL,
body TEXT NOT NULL DEFAULT '',
input_schema JSONB,
output_schema JSONB,
model_policy_key VARCHAR(128),
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
changelog TEXT NOT NULL DEFAULT '',
UNIQUE (prompt_definition_id, version)
);
ALTER TABLE prompt_definitions
ADD CONSTRAINT fk_prompt_definitions_active_version
FOREIGN KEY (active_version_id) REFERENCES prompt_versions(id) ON DELETE SET NULL;
CREATE TABLE prompt_steps (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
prompt_version_id UUID NOT NULL REFERENCES prompt_versions(id) ON DELETE CASCADE,
step_key VARCHAR(128) NOT NULL,
step_order INT NOT NULL DEFAULT 0,
step_type VARCHAR(32) NOT NULL DEFAULT 'template'
CHECK (step_type IN ('template', 'resolver', 'llm_call', 'postprocess', 'validation')),
template_body TEXT NOT NULL DEFAULT '',
input_mapping JSONB,
output_mapping JSONB,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (prompt_version_id, step_key)
);
CREATE INDEX idx_prompt_steps_version_order
ON prompt_steps(prompt_version_id, step_order);
CREATE TABLE placeholder_definitions (
placeholder_key VARCHAR(128) NOT NULL,
context_kind VARCHAR(64) NOT NULL,
value_type VARCHAR(32) NOT NULL DEFAULT 'string'
CHECK (value_type IN ('string', 'number', 'boolean', 'object', 'array', 'json')),
description TEXT NOT NULL DEFAULT '',
required BOOLEAN NOT NULL DEFAULT FALSE,
source VARCHAR(64) NOT NULL DEFAULT 'input',
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (placeholder_key, context_kind)
);
CREATE TABLE configuration_entries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
config_key VARCHAR(128) NOT NULL,
scope VARCHAR(32) NOT NULL DEFAULT 'global'
CHECK (scope IN ('global', 'tenant')),
tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE,
value JSONB NOT NULL DEFAULT '{}',
value_type VARCHAR(32) NOT NULL DEFAULT 'string'
CHECK (value_type IN ('string', 'number', 'boolean', 'object', 'array', 'json')),
description TEXT NOT NULL DEFAULT '',
is_secret BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CHECK (
(scope = 'global' AND tenant_id IS NULL)
OR (scope = 'tenant' AND tenant_id IS NOT NULL)
)
);
CREATE UNIQUE INDEX uq_configuration_entries_global
ON configuration_entries (config_key)
WHERE scope = 'global';
CREATE UNIQUE INDEX uq_configuration_entries_tenant
ON configuration_entries (config_key, tenant_id)
WHERE scope = 'tenant';
CREATE TABLE prompt_execution_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
prompt_definition_id UUID REFERENCES prompt_definitions(id) ON DELETE SET NULL,
prompt_version_id UUID REFERENCES prompt_versions(id) ON DELETE SET NULL,
execution_mode VARCHAR(32) NOT NULL,
status VARCHAR(32) NOT NULL
CHECK (status IN ('success', 'error', 'skipped')),
rendered_preview TEXT,
input_summary JSONB,
error_message TEXT,
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_prompt_execution_logs_created
ON prompt_execution_logs(created_at DESC);

View File

@ -0,0 +1,3 @@
"""Side-effect imports for placeholder registrations."""
from placeholder_registrations import actor_context, debug, tenant_context # noqa: F401

View File

@ -0,0 +1,17 @@
"""Actor context placeholders."""
from __future__ import annotations
from placeholder_registry import PlaceholderRegistration, register_placeholder
_CTX = "kairo.actor_context"
register_placeholder(
PlaceholderRegistration("actor_id", _CTX, "string", "UUID des Actors", required=True)
)
register_placeholder(
PlaceholderRegistration("actor_type", _CTX, "string", "Actor-Typ", required=True)
)
register_placeholder(
PlaceholderRegistration("tenant_id", _CTX, "string", "UUID des Tenants", required=True)
)

View File

@ -0,0 +1,14 @@
"""Debug context placeholders."""
from __future__ import annotations
from placeholder_registry import PlaceholderRegistration, register_placeholder
_CTX = "kairo.debug_context"
register_placeholder(
PlaceholderRegistration("debug_message", _CTX, "string", "Freier Debug-Text", required=True)
)
register_placeholder(
PlaceholderRegistration("debug_level", _CTX, "string", "Log-Level oder Kategorie", required=False)
)

View File

@ -0,0 +1,38 @@
"""TenantContext placeholders for prompt rendering."""
from __future__ import annotations
from placeholder_registry import PlaceholderRegistration, register_placeholder
_CTX = "kairo.tenant_context"
register_placeholder(
PlaceholderRegistration("tenant_name", _CTX, "string", "Name des aktiven Tenants", required=True)
)
register_placeholder(
PlaceholderRegistration("tenant_id", _CTX, "string", "UUID des aktiven Tenants", required=True)
)
register_placeholder(
PlaceholderRegistration("actor_name", _CTX, "string", "Anzeigename des Human Actors", required=False)
)
register_placeholder(
PlaceholderRegistration("actor_id", _CTX, "string", "UUID des Human Actors", required=False)
)
register_placeholder(
PlaceholderRegistration("actor_type", _CTX, "string", "Actor-Typ (z. B. human)", required=False)
)
register_placeholder(
PlaceholderRegistration("portal_role", _CTX, "string", "Portal-Rolle des Users", required=True)
)
register_placeholder(
PlaceholderRegistration("tenant_role", _CTX, "string", "Tenant-Rolle in der Membership", required=False)
)
register_placeholder(
PlaceholderRegistration(
"capabilities",
_CTX,
"json",
"Aufgelöste Capability-Keys als JSON-Array",
required=False,
)
)

View File

@ -0,0 +1,117 @@
"""Placeholder definitions registry with DB sync."""
from __future__ import annotations
from dataclasses import dataclass
from db import get_connection
from feature_registry import VALID_CONTEXT_KINDS
VALID_VALUE_TYPES = frozenset({"string", "number", "boolean", "object", "array", "json"})
@dataclass(frozen=True)
class PlaceholderRegistration:
placeholder_key: str
context_kind: str
value_type: str
description: str
required: bool = False
source: str = "input"
_REGISTRY: list[PlaceholderRegistration] = []
def register_placeholder(registration: PlaceholderRegistration) -> None:
if not registration.placeholder_key or not registration.placeholder_key.strip():
raise ValueError("Placeholder key is required")
if registration.context_kind not in VALID_CONTEXT_KINDS:
raise ValueError(f"Unknown context_kind: {registration.context_kind}")
if registration.value_type not in VALID_VALUE_TYPES:
raise ValueError(f"Unknown value_type: {registration.value_type}")
if not registration.description or not registration.description.strip():
raise ValueError("Placeholder description is required")
key = (registration.placeholder_key, registration.context_kind)
if any((r.placeholder_key, r.context_kind) == key for r in _REGISTRY):
raise ValueError(f"Placeholder already registered: {key}")
_REGISTRY.append(registration)
def get_registered_placeholders() -> tuple[PlaceholderRegistration, ...]:
return tuple(_REGISTRY)
def clear_placeholder_registry_for_tests() -> None:
_REGISTRY.clear()
def sync_placeholders_to_db() -> int:
if not _REGISTRY:
print("[placeholder_registry] Keine Placeholder registriert — Sync übersprungen")
return 0
conn = get_connection()
try:
with conn.cursor() as cur:
for reg in _REGISTRY:
cur.execute(
"""
INSERT INTO placeholder_definitions (
placeholder_key, context_kind, value_type, description,
required, source, is_active
)
VALUES (%s, %s, %s, %s, %s, %s, TRUE)
ON CONFLICT (placeholder_key, context_kind) DO UPDATE SET
value_type = EXCLUDED.value_type,
description = EXCLUDED.description,
required = EXCLUDED.required,
source = EXCLUDED.source,
is_active = TRUE,
updated_at = NOW()
""",
(
reg.placeholder_key,
reg.context_kind,
reg.value_type,
reg.description,
reg.required,
reg.source,
),
)
conn.commit()
print(f"[placeholder_registry] Sync OK — {len(_REGISTRY)} Placeholder(s)")
return 0
except Exception as exc:
conn.rollback()
print(f"[placeholder_registry] Sync FAIL: {exc}")
return 1
finally:
conn.close()
def load_placeholders_for_context(context_kind: str) -> list[dict]:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT placeholder_key, value_type, description, required, source
FROM placeholder_definitions
WHERE context_kind = %s AND is_active = TRUE
ORDER BY placeholder_key
""",
(context_kind,),
)
return [
{
"placeholder_key": row[0],
"value_type": row[1],
"description": row[2],
"required": row[3],
"source": row[4],
}
for row in cur.fetchall()
]
finally:
conn.close()

44
backend/prompt_context.py Normal file
View File

@ -0,0 +1,44 @@
"""Build placeholder value maps from TenantContext."""
from __future__ import annotations
import json
from typing import Any
from tenant_context import TenantContext
def build_tenant_context_values(ctx: TenantContext) -> dict[str, Any]:
return {
"tenant_name": ctx.tenant_name or "",
"tenant_id": ctx.tenant_id or "",
"actor_name": ctx.display_name,
"actor_id": ctx.actor_id or "",
"actor_type": ctx.actor_type or "",
"portal_role": ctx.portal_role,
"tenant_role": ctx.tenant_role or "",
"capabilities": sorted(ctx.capabilities),
}
def build_actor_context_values(ctx: TenantContext) -> dict[str, Any]:
return {
"actor_id": ctx.actor_id or "",
"actor_type": ctx.actor_type or "",
"tenant_id": ctx.tenant_id or "",
}
def merge_context_values(ctx: TenantContext, context_kind: str, extra: dict[str, Any]) -> dict[str, Any]:
base: dict[str, Any] = {}
if context_kind == "kairo.tenant_context":
base = build_tenant_context_values(ctx)
elif context_kind == "kairo.actor_context":
base = build_actor_context_values(ctx)
merged = {**base, **extra}
for key, value in merged.items():
if key == "capabilities" and isinstance(value, list):
merged[key] = value
elif isinstance(value, (dict, list)) and key != "capabilities":
continue
return merged

View File

@ -0,0 +1,3 @@
"""Side-effect imports for prompt registrations."""
from prompt_registrations import debug, platform # noqa: F401

View File

@ -0,0 +1,23 @@
"""Debug prompts for actor context kind."""
from __future__ import annotations
from prompt_registry import PromptRegistration, PromptVersionRegistration, register_prompt
register_prompt(
PromptRegistration(
prompt_key="kairo.actor.debug_summary",
name="Actor Context Debug",
purpose="Minimaler Prompt für kairo.actor_context",
context_kind="kairo.actor_context",
execution_mode="single",
active_version="1.0.0",
versions=(
PromptVersionRegistration(
version="1.0.0",
body="Actor {{actor_id}} ({{actor_type}}) in tenant {{tenant_id}}.",
changelog="Actor context smoke",
),
),
)
)

View File

@ -0,0 +1,80 @@
"""Technical smoke-test prompts — no product logic."""
from __future__ import annotations
from prompt_registry import (
PromptRegistration,
PromptStepRegistration,
PromptVersionRegistration,
register_prompt,
)
register_prompt(
PromptRegistration(
prompt_key="kairo.system.health_summary",
name="System Health Summary",
purpose="Smoke-Test für Registry, Versionierung und Rendering",
context_kind="kairo.debug_context",
execution_mode="single",
active_version="1.0.0",
versions=(
PromptVersionRegistration(
version="1.0.0",
body="",
changelog="Initial smoke prompt",
steps=(
PromptStepRegistration(
step_key="summary",
step_order=0,
step_type="template",
template_body=(
"Kairo Systemstatus: {{debug_message}} "
"(Level: {{debug_level}})"
),
),
),
),
),
)
)
register_prompt(
PromptRegistration(
prompt_key="kairo.context.debug_summary",
name="Tenant Context Debug Summary",
purpose="Smoke-Test für TenantContext-Placeholder",
context_kind="kairo.tenant_context",
execution_mode="single",
active_version="1.0.0",
versions=(
PromptVersionRegistration(
version="1.0.0",
body=(
"Tenant {{tenant_name}} ({{tenant_id}}): "
"Portal={{portal_role}}, Tenant-Rolle={{tenant_role}}, "
"Actor={{actor_id}} ({{actor_type}})."
),
changelog="Initial tenant context prompt",
),
),
)
)
# Pipeline/workflow structural sample — not executed in AP0.4
register_prompt(
PromptRegistration(
prompt_key="kairo.pipeline.placeholder",
name="Pipeline Placeholder",
purpose="Strukturelle Vorbereitung für pipeline execution_mode",
context_kind="kairo.debug_context",
execution_mode="pipeline",
active_version="0.1.0",
versions=(
PromptVersionRegistration(
version="0.1.0",
body="Pipeline placeholder: {{debug_message}}",
changelog="Not executed in AP0.4",
),
),
)
)

310
backend/prompt_registry.py Normal file
View File

@ -0,0 +1,310 @@
"""Prompt definition registry with versioning and optional steps."""
from __future__ import annotations
import json
from dataclasses import dataclass
from db import get_connection
from feature_registry import VALID_CONTEXT_KINDS, VALID_EXECUTION_MODES
VALID_STEP_TYPES = frozenset(
{"template", "resolver", "llm_call", "postprocess", "validation"}
)
@dataclass(frozen=True)
class PromptStepRegistration:
step_key: str
step_order: int
step_type: str
template_body: str
input_mapping: dict | None = None
output_mapping: dict | None = None
enabled: bool = True
@dataclass(frozen=True)
class PromptVersionRegistration:
version: str
body: str
input_schema: dict | None = None
output_schema: dict | None = None
model_policy_key: str | None = None
changelog: str = ""
steps: tuple[PromptStepRegistration, ...] = ()
@dataclass(frozen=True)
class PromptRegistration:
prompt_key: str
name: str
purpose: str
context_kind: str
execution_mode: str
active_version: str
versions: tuple[PromptVersionRegistration, ...]
_REGISTRY: dict[str, PromptRegistration] = {}
def register_prompt(registration: PromptRegistration) -> None:
if not registration.prompt_key or not registration.prompt_key.strip():
raise ValueError("Prompt key is required")
if registration.context_kind not in VALID_CONTEXT_KINDS:
raise ValueError(f"Unknown context_kind: {registration.context_kind}")
if registration.execution_mode not in VALID_EXECUTION_MODES:
raise ValueError(f"Unknown execution_mode: {registration.execution_mode}")
if not registration.versions:
raise ValueError("At least one prompt version is required")
version_ids = {v.version for v in registration.versions}
if registration.active_version not in version_ids:
raise ValueError(f"active_version not found in versions: {registration.active_version}")
for step in _iter_steps(registration):
if step.step_type not in VALID_STEP_TYPES:
raise ValueError(f"Unknown step_type: {step.step_type}")
if registration.prompt_key in _REGISTRY:
raise ValueError(f"Prompt already registered: {registration.prompt_key}")
_REGISTRY[registration.prompt_key] = registration
def _iter_steps(registration: PromptRegistration):
for version in registration.versions:
yield from version.steps
def get_registered_prompts() -> tuple[PromptRegistration, ...]:
return tuple(_REGISTRY.values())
def clear_prompt_registry_for_tests() -> None:
_REGISTRY.clear()
def sync_prompts_to_db() -> int:
if not _REGISTRY:
print("[prompt_registry] Keine Prompts registriert — Sync übersprungen")
return 0
conn = get_connection()
try:
with conn.cursor() as cur:
for reg in _REGISTRY.values():
cur.execute(
"""
INSERT INTO prompt_definitions (
prompt_key, name, purpose, context_kind, execution_mode, status
)
VALUES (%s, %s, %s, %s, %s, 'active')
ON CONFLICT (prompt_key) DO UPDATE SET
name = EXCLUDED.name,
purpose = EXCLUDED.purpose,
context_kind = EXCLUDED.context_kind,
execution_mode = EXCLUDED.execution_mode,
status = 'active',
updated_at = NOW()
RETURNING id
""",
(
reg.prompt_key,
reg.name,
reg.purpose,
reg.context_kind,
reg.execution_mode,
),
)
definition_id = cur.fetchone()[0]
active_version_id = None
for version in reg.versions:
cur.execute(
"""
INSERT INTO prompt_versions (
prompt_definition_id, version, body,
input_schema, output_schema, model_policy_key, changelog
)
VALUES (%s, %s, %s, %s::jsonb, %s::jsonb, %s, %s)
ON CONFLICT (prompt_definition_id, version) DO UPDATE SET
body = EXCLUDED.body,
input_schema = EXCLUDED.input_schema,
output_schema = EXCLUDED.output_schema,
model_policy_key = EXCLUDED.model_policy_key,
changelog = EXCLUDED.changelog
RETURNING id
""",
(
definition_id,
version.version,
version.body,
json.dumps(version.input_schema) if version.input_schema else None,
json.dumps(version.output_schema) if version.output_schema else None,
version.model_policy_key,
version.changelog,
),
)
version_id = cur.fetchone()[0]
if version.version == reg.active_version:
active_version_id = version_id
for step in version.steps:
cur.execute(
"""
INSERT INTO prompt_steps (
prompt_version_id, step_key, step_order, step_type,
template_body, input_mapping, output_mapping, enabled
)
VALUES (%s, %s, %s, %s, %s, %s::jsonb, %s::jsonb, %s)
ON CONFLICT (prompt_version_id, step_key) DO UPDATE SET
step_order = EXCLUDED.step_order,
step_type = EXCLUDED.step_type,
template_body = EXCLUDED.template_body,
input_mapping = EXCLUDED.input_mapping,
output_mapping = EXCLUDED.output_mapping,
enabled = EXCLUDED.enabled,
updated_at = NOW()
""",
(
version_id,
step.step_key,
step.step_order,
step.step_type,
step.template_body,
json.dumps(step.input_mapping) if step.input_mapping else None,
json.dumps(step.output_mapping) if step.output_mapping else None,
step.enabled,
),
)
if active_version_id:
cur.execute(
"""
UPDATE prompt_definitions
SET active_version_id = %s, updated_at = NOW()
WHERE id = %s
""",
(active_version_id, definition_id),
)
conn.commit()
print(f"[prompt_registry] Sync OK — {len(_REGISTRY)} Prompt(s)")
return 0
except Exception as exc:
conn.rollback()
print(f"[prompt_registry] Sync FAIL: {exc}")
return 1
finally:
conn.close()
def load_prompt_definition(prompt_key: str) -> dict | None:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT id, prompt_key, name, purpose, context_kind, execution_mode,
status, active_version_id
FROM prompt_definitions
WHERE prompt_key = %s
""",
(prompt_key,),
)
row = cur.fetchone()
if not row:
return None
return {
"id": str(row[0]),
"prompt_key": row[1],
"name": row[2],
"purpose": row[3],
"context_kind": row[4],
"execution_mode": row[5],
"status": row[6],
"active_version_id": str(row[7]) if row[7] else None,
}
finally:
conn.close()
def load_active_prompt_version(definition: dict) -> dict | None:
version_id = definition.get("active_version_id")
if not version_id:
return None
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT id, version, body, input_schema, output_schema, model_policy_key
FROM prompt_versions
WHERE id = %s
""",
(version_id,),
)
row = cur.fetchone()
if not row:
return None
return {
"id": str(row[0]),
"version": row[1],
"body": row[2],
"input_schema": row[3],
"output_schema": row[4],
"model_policy_key": row[5],
}
finally:
conn.close()
def load_template_steps(version_id: str) -> list[dict]:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT step_key, step_order, step_type, template_body, enabled
FROM prompt_steps
WHERE prompt_version_id = %s AND enabled = TRUE
ORDER BY step_order, step_key
""",
(version_id,),
)
return [
{
"step_key": row[0],
"step_order": row[1],
"step_type": row[2],
"template_body": row[3],
"enabled": row[4],
}
for row in cur.fetchall()
]
finally:
conn.close()
def list_prompt_definitions() -> list[dict]:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT prompt_key, name, purpose, context_kind, execution_mode, status
FROM prompt_definitions
ORDER BY prompt_key
"""
)
return [
{
"prompt_key": row[0],
"name": row[1],
"purpose": row[2],
"context_kind": row[3],
"execution_mode": row[4],
"status": row[5],
}
for row in cur.fetchall()
]
finally:
conn.close()

172
backend/prompt_rendering.py Normal file
View File

@ -0,0 +1,172 @@
"""Single-mode prompt rendering without LLM execution."""
from __future__ import annotations
import json
import re
from typing import Any
from db import get_connection
from prompt_registry import load_active_prompt_version, load_prompt_definition, load_template_steps
from prompt_validation import PLACEHOLDER_PATTERN, PlaceholderValidationError, validate_placeholder_input
from services.audit import log_audit
PLACEHOLDER_SUB = re.compile(r"\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}")
class PromptRenderError(Exception):
def __init__(self, message: str, *, code: str = "render_error"):
super().__init__(message)
self.code = code
def _render_template(template: str, values: dict[str, Any]) -> str:
def replacer(match: re.Match[str]) -> str:
key = match.group(1)
if key not in values:
return match.group(0)
value = values[key]
if isinstance(value, (dict, list)):
return json.dumps(value, ensure_ascii=False)
return str(value)
return PLACEHOLDER_SUB.sub(replacer, template)
def _resolve_template_body(version: dict) -> tuple[str, str]:
"""Return (template_body, source) where source is 'body' or 'step'."""
steps = load_template_steps(version["id"])
template_steps = [s for s in steps if s["step_type"] == "template" and s["template_body"]]
if template_steps:
step = template_steps[0]
return step["template_body"], f"step:{step['step_key']}"
if version.get("body"):
return version["body"], "body"
raise PromptRenderError("No template body or template step found", code="no_template")
def _write_execution_log(
*,
definition: dict,
version: dict,
status: str,
rendered_preview: str | None,
input_summary: dict | None,
error_message: str | None,
user_id: str | None,
) -> None:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO prompt_execution_logs (
prompt_definition_id, prompt_version_id, execution_mode,
status, rendered_preview, input_summary, error_message, created_by
)
VALUES (%s::uuid, %s::uuid, %s, %s, %s, %s::jsonb, %s, %s::uuid)
""",
(
definition["id"],
version["id"],
definition["execution_mode"],
status,
rendered_preview,
json.dumps(input_summary) if input_summary else None,
error_message,
user_id,
),
)
conn.commit()
finally:
conn.close()
def render_prompt_single(
prompt_key: str,
*,
values: dict[str, Any],
user_id: str | None = None,
log_execution: bool = True,
) -> dict[str, Any]:
definition = load_prompt_definition(prompt_key)
if not definition:
raise PromptRenderError(f"Unknown prompt: {prompt_key}", code="not_found")
if definition["status"] != "active":
raise PromptRenderError(f"Prompt not active: {prompt_key}", code="inactive")
execution_mode = definition["execution_mode"]
if execution_mode in ("pipeline", "workflow"):
raise PromptRenderError(
f"execution_mode '{execution_mode}' is not implemented in AP0.4",
code="mode_not_implemented",
)
version = load_active_prompt_version(definition)
if not version:
raise PromptRenderError("No active prompt version", code="no_version")
template_body, template_source = _resolve_template_body(version)
try:
validation = validate_placeholder_input(
template=template_body,
context_kind=definition["context_kind"],
values=values,
)
rendered = _render_template(template_body, values)
result = {
"prompt_key": prompt_key,
"version": version["version"],
"execution_mode": execution_mode,
"template_source": template_source,
"rendered_text": rendered,
"validation": validation,
}
if log_execution:
_write_execution_log(
definition=definition,
version=version,
status="success",
rendered_preview=rendered[:2000],
input_summary={"keys": sorted(values.keys())},
error_message=None,
user_id=user_id,
)
return result
except PlaceholderValidationError as exc:
if log_execution:
_write_execution_log(
definition=definition,
version=version,
status="error",
rendered_preview=None,
input_summary={"keys": sorted(values.keys())},
error_message=str(exc),
user_id=user_id,
)
log_audit(
"prompt.render.failed",
user_id=user_id,
details={
"prompt_key": prompt_key,
"missing": exc.missing,
"type_errors": exc.type_errors,
"unknown": exc.unknown,
},
)
raise PromptRenderError(str(exc), code="validation_failed") from exc
except PromptRenderError:
raise
except Exception as exc:
if log_execution:
_write_execution_log(
definition=definition,
version=version,
status="error",
rendered_preview=None,
input_summary={"keys": sorted(values.keys())},
error_message=str(exc),
user_id=user_id,
)
raise PromptRenderError(str(exc), code="render_error") from exc

View File

@ -0,0 +1,81 @@
"""Placeholder extraction and validation for prompt templates."""
from __future__ import annotations
import re
from typing import Any
from placeholder_registry import load_placeholders_for_context
PLACEHOLDER_PATTERN = re.compile(r"\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}")
class PlaceholderValidationError(Exception):
def __init__(
self,
message: str,
*,
missing: list[str] | None = None,
type_errors: list[str] | None = None,
unknown: list[str] | None = None,
):
super().__init__(message)
self.missing = missing or []
self.type_errors = type_errors or []
self.unknown = unknown or []
def extract_placeholders(template: str) -> set[str]:
return set(PLACEHOLDER_PATTERN.findall(template))
def _check_value_type(value: Any, value_type: str) -> bool:
if value_type == "string":
return isinstance(value, str)
if value_type == "number":
return isinstance(value, (int, float)) and not isinstance(value, bool)
if value_type == "boolean":
return isinstance(value, bool)
if value_type == "object":
return isinstance(value, dict)
if value_type == "array":
return isinstance(value, list)
if value_type == "json":
return True
return True
def validate_placeholder_input(
*,
template: str,
context_kind: str,
values: dict[str, Any],
) -> dict[str, Any]:
"""Validate input values against registry; return metadata including unknown placeholders."""
registry = {p["placeholder_key"]: p for p in load_placeholders_for_context(context_kind)}
used = extract_placeholders(template)
missing: list[str] = []
type_errors: list[str] = []
unknown = sorted(key for key in used if key not in registry)
for key, meta in registry.items():
if meta["required"] and key not in values:
missing.append(key)
if key in values and not _check_value_type(values[key], meta["value_type"]):
type_errors.append(f"{key}: expected {meta['value_type']}")
if missing or type_errors:
raise PlaceholderValidationError(
"Placeholder validation failed",
missing=missing,
type_errors=type_errors,
unknown=unknown,
)
return {
"used_placeholders": sorted(used),
"unknown_placeholders": unknown,
"optional_missing": sorted(
key for key in registry if not registry[key]["required"] and key not in values
),
}

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, tenant_ops # noqa: F401 from . import platform, registry_ops, tenant_ops # noqa: F401
__all__ = ["platform", "tenant_ops"] __all__ = ["platform", "registry_ops", "tenant_ops"]

View File

@ -0,0 +1,82 @@
"""AP0.4 registry and prompt capabilities."""
from __future__ import annotations
from rights_registry import CapabilityRegistration, register_capability
_TENANT_READ = (
("portal", "admin"),
("portal", "user"),
("tenant", "owner"),
("tenant", "admin"),
("tenant", "member"),
)
_TENANT_ADMIN = (
("portal", "admin"),
("tenant", "owner"),
("tenant", "admin"),
)
register_capability(
CapabilityRegistration(
key="kairo.feature.registry.read",
module="feature",
description="Feature Registry lesen",
default_grants=_TENANT_READ,
)
)
register_capability(
CapabilityRegistration(
key="kairo.feature.registry.manage",
module="feature",
description="Feature Registry verwalten (Sync/Metadaten)",
default_grants=(("portal", "admin"),),
)
)
register_capability(
CapabilityRegistration(
key="kairo.prompt.registry.read",
module="prompt",
description="Prompt Registry lesen",
default_grants=_TENANT_READ,
)
)
register_capability(
CapabilityRegistration(
key="kairo.prompt.registry.manage",
module="prompt",
description="Prompt Registry verwalten",
default_grants=(("portal", "admin"),),
)
)
register_capability(
CapabilityRegistration(
key="kairo.config.registry.read",
module="config",
description="Configuration Registry lesen",
default_grants=_TENANT_ADMIN,
)
)
register_capability(
CapabilityRegistration(
key="kairo.config.registry.manage",
module="config",
description="Configuration Registry schreiben",
default_grants=_TENANT_ADMIN,
)
)
register_capability(
CapabilityRegistration(
key="kairo.prompt.render",
module="prompt",
description="Prompts rendern (ohne LLM)",
default_grants=_TENANT_READ,
)
)

61
backend/routers/config.py Normal file
View File

@ -0,0 +1,61 @@
"""Configuration registry API."""
from __future__ import annotations
from typing import Any
from capabilities import require_capability_ctx
from config_service import SECRET_VALUE_FORBIDDEN, list_configs, upsert_config
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from tenant_context import TenantContext
router = APIRouter(prefix="/api/config", tags=["config"])
class ConfigUpsertRequest(BaseModel):
config_key: str
scope: str = "global"
value: Any = None
value_type: str = "string"
description: str = ""
is_secret: bool = False
tenant_id: str | None = None
@router.get("")
def get_configs(
scope: str | None = None,
ctx: TenantContext = Depends(require_capability_ctx("kairo.config.registry.read")),
):
tenant_id = ctx.tenant_id if scope == "tenant" else None
if scope == "tenant" and not tenant_id:
raise HTTPException(status_code=400, detail="Aktiver Tenant erforderlich für tenant-scope")
return list_configs(scope=scope, tenant_id=tenant_id)
@router.post("")
def post_config(
body: ConfigUpsertRequest,
ctx: TenantContext = Depends(require_capability_ctx("kairo.config.registry.manage")),
):
tenant_id = body.tenant_id
if body.scope == "tenant":
tenant_id = tenant_id or ctx.tenant_id
if not tenant_id:
raise HTTPException(status_code=400, detail="tenant_id erforderlich")
try:
return upsert_config(
config_key=body.config_key,
scope=body.scope,
value=body.value,
value_type=body.value_type,
description=body.description,
is_secret=body.is_secret,
tenant_id=tenant_id,
user_id=ctx.user_id,
)
except ValueError as exc:
if str(exc) == SECRET_VALUE_FORBIDDEN:
raise HTTPException(status_code=400, detail=str(exc)) from exc
raise HTTPException(status_code=400, detail=str(exc)) from exc

View File

@ -0,0 +1,35 @@
"""Feature registry API."""
from __future__ import annotations
from capabilities import require_capability_ctx
from db import get_connection
from fastapi import APIRouter, Depends
router = APIRouter(prefix="/api/features", tags=["features"])
@router.get("")
def list_features(_ctx=Depends(require_capability_ctx("kairo.feature.registry.read"))):
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT feature_key, module, name, description, is_active
FROM features
ORDER BY feature_key
"""
)
return [
{
"feature_key": row[0],
"module": row[1],
"name": row[2],
"description": row[3],
"is_active": row[4],
}
for row in cur.fetchall()
]
finally:
conn.close()

View File

@ -0,0 +1,74 @@
"""Prompt registry and rendering API."""
from __future__ import annotations
from typing import Any
from capabilities import require_capability_ctx
from fastapi import APIRouter, Depends, HTTPException
from prompt_context import merge_context_values
from prompt_registry import list_prompt_definitions, load_active_prompt_version, load_prompt_definition
from prompt_rendering import PromptRenderError, render_prompt_single
from pydantic import BaseModel, Field
from tenant_context import TenantContext
router = APIRouter(prefix="/api/prompts", tags=["prompts"])
class RenderPromptRequest(BaseModel):
values: dict[str, Any] = Field(default_factory=dict)
use_context: bool = True
@router.get("")
def list_prompts(_ctx=Depends(require_capability_ctx("kairo.prompt.registry.read"))):
return list_prompt_definitions()
@router.get("/{prompt_key}")
def get_prompt(prompt_key: str, _ctx=Depends(require_capability_ctx("kairo.prompt.registry.read"))):
definition = load_prompt_definition(prompt_key)
if not definition:
raise HTTPException(status_code=404, detail="Prompt nicht gefunden")
version = load_active_prompt_version(definition)
return {
"definition": definition,
"active_version": version,
}
@router.post("/{prompt_key}/render")
def render_prompt(
prompt_key: str,
body: RenderPromptRequest,
ctx: TenantContext = Depends(require_capability_ctx("kairo.prompt.render")),
):
definition = load_prompt_definition(prompt_key)
if not definition:
raise HTTPException(status_code=404, detail="Prompt nicht gefunden")
if body.use_context and definition["context_kind"] in (
"kairo.tenant_context",
"kairo.actor_context",
):
if not ctx.tenant_id:
raise HTTPException(status_code=403, detail="Aktiver Tenant erforderlich für diesen Prompt")
values = body.values
if body.use_context:
values = merge_context_values(ctx, definition["context_kind"], body.values)
try:
return render_prompt_single(
prompt_key,
values=values,
user_id=ctx.user_id,
log_execution=True,
)
except PromptRenderError as exc:
status = 400
if exc.code == "not_found":
status = 404
elif exc.code == "mode_not_implemented":
status = 501
raise HTTPException(status_code=status, detail=str(exc)) from exc

View File

@ -0,0 +1,60 @@
"""Startup sync for feature, prompt, placeholder and default config registries."""
from __future__ import annotations
import sys
import feature_registrations # noqa: F401
import placeholder_registrations # noqa: F401
import prompt_registrations # noqa: F401
from config_service import sync_default_configs
from feature_registry import sync_features_to_db
from placeholder_registry import sync_placeholders_to_db
from prompt_registry import sync_prompts_to_db
from services.audit import log_audit
DEFAULT_CONFIGS = [
{
"config_key": "prompt.default_execution_mode",
"scope": "global",
"value": "single",
"value_type": "string",
"description": "Default execution mode for prompt rendering",
},
{
"config_key": "prompt.render_logging_enabled",
"scope": "global",
"value": True,
"value_type": "boolean",
"description": "Write prompt_execution_logs on render",
},
{
"config_key": "features.registry_sync_enabled",
"scope": "global",
"value": True,
"value_type": "boolean",
"description": "Enable registry sync on startup",
},
]
def main() -> int:
results = [
sync_features_to_db(),
sync_placeholders_to_db(),
sync_prompts_to_db(),
]
sync_default_configs(DEFAULT_CONFIGS)
log_audit(
"registry.sync.completed",
details={
"features": results[0] == 0,
"placeholders": results[1] == 0,
"prompts": results[2] == 0,
},
)
return 0 if all(code == 0 for code in results) else 1
if __name__ == "__main__":
sys.exit(main())

View File

@ -10,6 +10,7 @@ os.environ.setdefault("SKIP_DB_MIGRATE", "1")
os.environ.setdefault("SKIP_SEEDS", "1") os.environ.setdefault("SKIP_SEEDS", "1")
os.environ.setdefault("SKIP_BOOTSTRAP", "1") os.environ.setdefault("SKIP_BOOTSTRAP", "1")
os.environ.setdefault("SKIP_RIGHTS_SYNC", "1") os.environ.setdefault("SKIP_RIGHTS_SYNC", "1")
os.environ.setdefault("SKIP_REGISTRY_SYNC", "1")
def _db_available() -> bool: def _db_available() -> bool:
@ -57,6 +58,16 @@ def _sync_rights_registry():
assert sync_rights_registry_to_db() == 0 assert sync_rights_registry_to_db() == 0
@pytest.fixture(scope="session", autouse=True)
def _sync_prompt_feature_config():
import feature_registrations # noqa: F401
import placeholder_registrations # noqa: F401
import prompt_registrations # noqa: F401
from sync_prompt_feature_config import main as sync_registries
assert sync_registries() == 0
@pytest.fixture() @pytest.fixture()
def client(): def client():
import importlib import importlib

View File

@ -0,0 +1,66 @@
"""Configuration registry tests."""
from __future__ import annotations
import pytest
from auth import AUTH_HEADER
from config_service import SECRET_VALUE_FORBIDDEN, get_config, list_configs, upsert_config
from tests.factories import provision_user_in_tenant
def test_global_config_upsert_and_read():
upsert_config(
config_key="test.ap0.config",
scope="global",
value="enabled",
value_type="string",
description="pytest config",
)
item = get_config("test.ap0.config", scope="global")
assert item is not None
assert item["value"] == "enabled"
def test_secret_config_rejects_plaintext():
with pytest.raises(ValueError, match=SECRET_VALUE_FORBIDDEN):
upsert_config(
config_key="test.secret",
scope="global",
value="super-secret-token",
is_secret=True,
)
def test_config_api(client):
user = provision_user_in_tenant(portal_role="admin", tenant_role="owner")
login = client.post(
"/api/auth/login",
json={"email": user["email"], "password": user["password"]},
)
token = login.json()["token"]
res = client.get("/api/config", headers={AUTH_HEADER: token})
assert res.status_code == 200
keys = {item["config_key"] for item in res.json()}
assert "prompt.default_execution_mode" in keys
def test_config_post_api(client):
user = provision_user_in_tenant(portal_role="admin", tenant_role="owner")
login = client.post(
"/api/auth/login",
json={"email": user["email"], "password": user["password"]},
)
token = login.json()["token"]
res = client.post(
"/api/config",
json={
"config_key": "test.api.config",
"scope": "global",
"value": True,
"value_type": "boolean",
"description": "via api",
},
headers={AUTH_HEADER: token},
)
assert res.status_code == 200
assert res.json()["config_key"] == "test.api.config"

View File

@ -28,4 +28,5 @@ def test_entitlements_snapshot_shape(client):
assert "kairo.admin.access" in body["capabilities"] assert "kairo.admin.access" in body["capabilities"]
assert body["enforcement"]["capabilities"] in ("probe", "enforce") assert body["enforcement"]["capabilities"] in ("probe", "enforce")
assert body["enforcement"]["features"] == "probe" assert body["enforcement"]["features"] == "probe"
assert body["features"] == {} assert "kairo.prompt.registry" in body["features"]
assert body["features"]["kairo.prompt.registry"]["enabled"] is True

View File

@ -0,0 +1,48 @@
"""Feature registry tests."""
from __future__ import annotations
import feature_registrations # noqa: F401
from auth import AUTH_HEADER
from db import get_connection
from feature_registry import get_registered_features, sync_features_to_db
from tests.factories import provision_user_in_tenant
def test_feature_registration_required_fields():
keys = {f.key for f in get_registered_features()}
assert keys == {
"kairo.feature.registry",
"kairo.config.registry",
"kairo.prompt.registry",
}
def test_feature_sync_idempotent():
assert sync_features_to_db() == 0
assert sync_features_to_db() == 0
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute("SELECT COUNT(*) FROM features WHERE is_active = TRUE")
assert cur.fetchone()[0] == 3
finally:
conn.close()
def test_features_in_entitlements(client):
user = provision_user_in_tenant(tenant_role="member", portal_role="user")
login = client.post(
"/api/auth/login",
json={"email": user["email"], "password": user["password"]},
)
token = login.json()["token"]
res = client.get("/api/me/entitlements", headers={AUTH_HEADER: token})
features = res.json()["features"]
assert "kairo.prompt.registry" in features
assert features["kairo.prompt.registry"]["enabled"] is True
assert features["kairo.prompt.registry"]["module"] == "prompt"
def test_features_api_requires_auth(client):
assert client.get("/api/features").status_code == 401

View File

@ -35,6 +35,7 @@ def test_migration_runner_finds_migrations():
assert "002_auth_identity_tenant_actor" in names assert "002_auth_identity_tenant_actor" in names
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
def test_migration_runner_is_idempotent(): def test_migration_runner_is_idempotent():
@ -48,6 +49,7 @@ def test_migration_runner_is_idempotent():
assert "002_auth_identity_tenant_actor" in executed assert "002_auth_identity_tenant_actor" in executed
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
def test_core_table_exists(): def test_core_table_exists():

View File

@ -0,0 +1,109 @@
"""Prompt registry and rendering tests."""
from __future__ import annotations
import prompt_registrations # noqa: F401
from auth import AUTH_HEADER
from db import get_connection
from prompt_registry import get_registered_prompts, sync_prompts_to_db
from prompt_rendering import PromptRenderError, render_prompt_single
from prompt_validation import PlaceholderValidationError, validate_placeholder_input
from tests.factories import provision_user_in_tenant
def test_prompt_registry_contains_smoke_prompts():
keys = {p.prompt_key for p in get_registered_prompts()}
assert "kairo.system.health_summary" in keys
assert "kairo.context.debug_summary" in keys
assert "kairo.pipeline.placeholder" in keys
def test_prompt_sync_idempotent():
assert sync_prompts_to_db() == 0
assert sync_prompts_to_db() == 0
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute("SELECT COUNT(*) FROM prompt_definitions")
assert cur.fetchone()[0] >= 4
cur.execute("SELECT COUNT(*) FROM prompt_steps")
assert cur.fetchone()[0] >= 1
finally:
conn.close()
def test_placeholder_required_missing():
try:
validate_placeholder_input(
template="Hello {{debug_message}}",
context_kind="kairo.debug_context",
values={},
)
assert False, "expected validation error"
except PlaceholderValidationError as exc:
assert "debug_message" in exc.missing
def test_placeholder_optional_ok():
meta = validate_placeholder_input(
template="Level {{debug_level}}",
context_kind="kairo.debug_context",
values={"debug_message": "ok"},
)
assert "debug_level" in meta["optional_missing"]
def test_render_debug_prompt():
result = render_prompt_single(
"kairo.system.health_summary",
values={"debug_message": "All systems nominal", "debug_level": "info"},
log_execution=False,
)
assert "All systems nominal" in result["rendered_text"]
assert result["template_source"].startswith("step:")
def test_render_pipeline_mode_rejected():
try:
render_prompt_single(
"kairo.pipeline.placeholder",
values={"debug_message": "x"},
log_execution=False,
)
assert False, "expected error"
except PromptRenderError as exc:
assert exc.code == "mode_not_implemented"
def test_render_api(client):
user = provision_user_in_tenant(tenant_role="owner", portal_role="admin")
login = client.post(
"/api/auth/login",
json={"email": user["email"], "password": user["password"]},
)
token = login.json()["token"]
client.post(
"/api/me/tenant",
json={"tenant_id": user["tenant_id"]},
headers={AUTH_HEADER: token},
)
res = client.post(
"/api/prompts/kairo.context.debug_summary/render",
json={"values": {}, "use_context": True},
headers={AUTH_HEADER: token},
)
assert res.status_code == 200
assert user["tenant_id"] in res.json()["rendered_text"]
def test_prompts_list_api(client):
user = provision_user_in_tenant(portal_role="admin", tenant_role="owner")
login = client.post(
"/api/auth/login",
json={"email": user["email"], "password": user["password"]},
)
token = login.json()["token"]
res = client.get("/api/prompts", headers={AUTH_HEADER: token})
assert res.status_code == 200
keys = {item["prompt_key"] for item in res.json()}
assert "kairo.system.health_summary" in keys

View File

@ -15,6 +15,13 @@ def test_registry_contains_initial_capabilities():
"kairo.actor.manage", "kairo.actor.manage",
"kairo.context.read", "kairo.context.read",
"kairo.entitlements.read", "kairo.entitlements.read",
"kairo.feature.registry.read",
"kairo.feature.registry.manage",
"kairo.prompt.registry.read",
"kairo.prompt.registry.manage",
"kairo.config.registry.read",
"kairo.config.registry.manage",
"kairo.prompt.render",
} }
@ -26,7 +33,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] == 5 assert cur.fetchone()[0] == 12
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.3.0-ap0.3" APP_VERSION = "0.4.0-ap0.4"
DB_SCHEMA_VERSION = "004" DB_SCHEMA_VERSION = "005"
APP_NAME = "jinkendo-kairo" APP_NAME = "jinkendo-kairo"

View File

@ -75,9 +75,24 @@ Aktuell keine Medien-Speicherung. Bei Bedarf: NAS-Mount + `docker-compose.overri
--- ---
## Dev-Datenbank wechseln ## Datenbank-Passwort (Dev & Prod)
PostgreSQL im Compose-Stack initialisiert User/Passwort **nur beim ersten Start** des Volumes (`dev-kairo-db-data`). PostgreSQL im Compose-Stack initialisiert User/Passwort **nur beim ersten Start** des Volumes.
Wenn `DB_PASSWORD` in `.env` nicht mehr zum Volume passt, scheitert das Backend mit
`password authentication failed for user "kairo_user"` — der Container bleibt `unhealthy`.
**Prod (Daten behalten):** Passwort in der laufenden DB an `.env` anpassen:
```bash
cd /home/lars/docker/kairo
set -a && source .env && set +a
docker compose exec -T postgres psql -U kairo_user -d kairo \
-c "ALTER USER kairo_user WITH PASSWORD '${DB_PASSWORD}';"
docker compose restart backend
curl -sf http://localhost:8004/api/health
```
**Dev (Volume neu, inkl. Testdaten):** Volume löschen und neu starten (`dev-kairo-db-data`).
Wenn du `DB_NAME`, `DB_USER` oder `DB_PASSWORD` in `.env` änderst, muss das Volume neu angelegt werden: Wenn du `DB_NAME`, `DB_USER` oder `DB_PASSWORD` in `.env` änderst, muss das Volume neu angelegt werden:
```bash ```bash

View File

@ -0,0 +1,322 @@
# AP0.3 Abschlussbericht Capability / Rights Registry & Entitlements Snapshot
**Status:** abgeschlossen
**Stand:** 2026-07-04 (final inkl. Prod-Deploy-Nacharbeit)
**Branch:** `develop` · Dev-Deploy und Test Suite grün · **Prod verifiziert** (Schema `004`, Health ok)
---
## 1. Scope und Einordnung
AP0.3 liefert die **minimale Capability-/Rights-Grundlage** und den **Entitlements-Snapshot** für Sprint 0. Auth, TenantContext und Sessions stammen aus AP0.2; AP0.3 ergänzt Registry, DB-Sync, zentrale Capability-Prüfung und `/api/me/entitlements`.
| Anforderung (AP0.3) | Status |
|---------------------|--------|
| Migration Capabilities + Role-Grants | ✓ |
| Runtime Registry + Modul-Registrierungen | ✓ |
| Startup-Sync Registry → DB | ✓ |
| TenantContext.capabilities | ✓ |
| `require_capability()` Dependency | ✓ |
| `GET /api/me/entitlements` | ✓ |
| Beispiel-Endpoint mit Capability-Gate | ✓ |
| Tests (Registry, Sync, Auflösung, Enforce/Probe) | ✓ |
| Trennung Auth / Capability / Feature | ✓ |
**Bewusst nicht in AP0.3:** Feature-Limits, Billing, Usage-Zähler, Prompt Registry, produktive Admin-UI, Governance/Object-ACL.
---
## 2. Umgesetzte Dateien
| Datei | Zweck |
|-------|--------|
| `backend/migrations/004_capabilities_registry.sql` | Tabellen `capabilities`, `role_capability_grants` |
| `backend/rights_registry.py` | In-Memory-Registry, Validierung, DB-Sync, Grant-Lookup |
| `backend/rights_registrations/__init__.py` | Side-Effect-Import aller Modul-Registrierungen |
| `backend/rights_registrations/platform.py` | Platform-Capabilities |
| `backend/rights_registrations/tenant_ops.py` | Tenant-/Actor-Capabilities |
| `backend/capabilities.py` | Auflösung, `check_capability`, `require_capability` |
| `backend/entitlements.py` | Entitlements-Snapshot-Builder |
| `backend/sync_rights_registry.py` | CLI-Sync beim Container-Start |
| `backend/tenant_context.py` | `TenantContext.capabilities` |
| `backend/routers/me.py` | `/entitlements`, `/admin/demo` |
| `backend/entrypoint.sh` | Rights-Sync nach Seeds |
| `backend/version.py` | `0.3.0-ap0.3`, Schema `004` |
| `backend/tests/test_rights_registry.py` | Registry + Sync |
| `backend/tests/test_capabilities.py` | Grants, enforce/probe, Demo-Endpoint |
| `backend/tests/test_entitlements.py` | Snapshot-Form |
| `backend/tests/conftest.py` | Registry-Sync in Test-Session |
| `frontend/src/App.jsx` | Header AP0.3; `/api/me/context` zeigt Capabilities |
| `README.md` | API-Doku AP0.3 |
| `.gitea/workflows/deploy-prod.yml` | Backend-Logs bei `--wait`-Fehler (Parität zu Dev) |
| `docs/DEPLOYMENT.md` | Abschnitt DB-Passwort Dev/Prod inkl. `ALTER USER`-Fix |
**Wesentliche Commits:** `910b1d7` (Implementierung), `eaa0e25` (pytest-Fix), `4069cb0` (GUI-Sprint-Label), `39e8084` (Abschlussbericht v0.2).
**Nacharbeit (noch auf `develop`):** `deploy-prod.yml`, `DEPLOYMENT.md`, dieser Bericht v0.3.
---
## 3. Neue Migrationen
**`004_capabilities_registry.sql`**
- **`capabilities`** — `capability_key`, `module`, `description`, `is_active`
- **`role_capability_grants`** — `role_scope` (`portal` \| `tenant`), `role_code`, `capability_key`
Kein Feature-/Limit-Schema (Vorbereitung AP0.4).
---
## 4. Registry-Struktur
```
backend/
├── rights_registry.py
├── rights_registrations/
│ ├── __init__.py
│ ├── platform.py # admin, context, entitlements
│ └── tenant_ops.py # tenant.manage, actor.manage
└── sync_rights_registry.py
```
**Modell:** `@dataclass(frozen=True) CapabilityRegistration` mit `key`, `module`, `description`, `default_grants`.
**Startup:** `entrypoint.sh` → Migrationen → Seeds → `python sync_rights_registry.py` → Uvicorn (`SKIP_RIGHTS_SYNC=1` zum Überspringen).
---
## 5. Endpoints
| Endpoint | Methode | Auth / Gate | Status |
|----------|---------|-------------|--------|
| `/api/me/context` | GET | Session | geändert — `capabilities[]` |
| `/api/me/entitlements` | GET | Session | neu |
| `/api/me/admin/demo` | GET | Session + `kairo.admin.access` | neu (Beispiel `require_capability`) |
OpenAPI (Dev): `/api/docs`
---
## 6. Capability-Auflösungslogik
```
Session (portal_role, active_tenant_id)
→ tenant_role aus Membership (optional)
→ load_grants_for_roles(portal_role, tenant_role)
→ Union Portal-Grants + Tenant-Grants
→ TenantContext.capabilities (frozenset)
```
**`require_capability(key)`:** prüft Mitgliedschaft in `ctx.capabilities`.
| Modus | Env | Verhalten |
|-------|-----|-----------|
| probe | `CAPABILITY_ENFORCE=probe` (Default) | Zugriff erlaubt; Verweigerung → Audit `capability.denied` |
| enforce | `CAPABILITY_ENFORCE=enforce` | HTTP 403 ohne Grant |
---
## 7. Initiale Capabilities
| Capability | Portal admin | Portal user | Tenant owner | Tenant admin | Tenant member |
|------------|:------------:|:-----------:|:------------:|:------------:|:-------------:|
| `kairo.admin.access` | ✓ | | | | |
| `kairo.tenant.manage` | ✓ | | ✓ | ✓ | |
| `kairo.actor.manage` | ✓ | | ✓ | ✓ | |
| `kairo.context.read` | ✓ | ✓ | ✓ | ✓ | ✓ |
| `kairo.entitlements.read` | ✓ | ✓ | ✓ | ✓ | ✓ |
---
## 8. Tests und Verifikation
### pytest (CI, Dev-Container)
```bash
docker compose -f docker-compose.dev-env.yml exec backend python -m pytest tests -ra -vv
```
| Testdatei | Abdeckung | Status |
|-----------|-----------|--------|
| `test_rights_registry.py` | 5 Capabilities, Sync idempotent | ✓ |
| `test_capabilities.py` | Admin/Member enforce, Owner-Grants, probe | ✓ |
| `test_entitlements.py` | Snapshot-Struktur | ✓ |
| `test_migrations.py` | Migration 004 | ✓ |
| Gesamt Test Suite (Gitea) | nach Push `develop` | ✓ |
### Manuell (Dev-GUI)
| Prüfung | Ergebnis |
|---------|----------|
| Login `lars@stommer.com` | ✓ |
| `/api/me/context``capabilities` sichtbar | ✓ |
| API Health → `"schema": "004"` | ✓ |
| Header Sprint-Anzeige AP0.3 | ✓ |
**Hinweis:** Entitlements und Admin-Demo sind in der GUI noch nicht als eigene Karten — prüfbar über Swagger (`/api/docs`) oder DevTools.
### Production (nach Nacharbeit 2026-07-04)
| Prüfung | Ergebnis |
|---------|----------|
| `curl http://localhost:8004/api/health` | ✓ — `0.3.0-ap0.3`, Schema `004`, `db: ok` |
| `curl http://localhost:3004/api/health` | ✓ |
| Migration 004 auf Prod-Volume | ✓ (nach DB-Reconnect) |
| Rights-Sync beim Start | ✓ |
| Bootstrap-Admin (`.env`) | unverändert nutzbar |
---
## 9. Prod-Deploy-Nacharbeit (Gitea `deploy-prod` #7154)
### Symptom
Push auf `main` → Workflow `deploy-prod` schlug fehl:
```
dependency failed to start: container kairo-api is unhealthy
```
Backend-Logs (Pi):
```
[FAIL] ... FATAL: password authentication failed for user "kairo_user"
```
### Ursache
**Kein AP0.3-Codefehler.** PostgreSQL initialisiert User/Passwort nur beim **ersten Start** des Volumes (`kairo-db-data`). Das Passwort in `/home/lars/docker/kairo/.env` wich vom in der DB gespeicherten Passwort ab — identisches Muster wie beim Dev-Deploy (#7100).
Der Entrypoint bricht bei DB-Auth-Fehlern fail-fast ab (seit AP0.2/`f8b3ae3`); Uvicorn startet nicht → Healthcheck scheitert nach wenigen Sekunden.
### Behebung (Prod, ohne Datenverlust)
```bash
cd /home/lars/docker/kairo
set -a && source .env && set +a
docker compose exec -T postgres psql -U kairo_user -d kairo \
-c "ALTER USER kairo_user WITH PASSWORD '${DB_PASSWORD}';"
docker compose restart backend
docker compose up -d --wait
```
Danach: alle Container `healthy`, API und Frontend-Proxy antworten.
### Prävention / Doku
| Maßnahme | Datei |
|----------|-------|
| Prod-Fix ohne Volume-Löschung dokumentiert | `docs/DEPLOYMENT.md` § Datenbank-Passwort |
| Backend-Logs bei `compose up --wait`-Fehler | `.gitea/workflows/deploy-prod.yml` (wie Dev) |
**Wichtig:** `docker compose down -v` auf Prod löscht alle Anwendungsdaten. Bei Passwort-Wechsel in `.env` bevorzugt `ALTER USER` verwenden.
---
## 10. Deployment & Betrieb
| | Development | Production |
|---|-------------|------------|
| Verzeichnis | `/home/lars/docker/kairo-dev` | `/home/lars/docker/kairo` |
| Ports | 3097 / 8097 | 3004 / 8004 |
| Postgres | eigener Container + Volume | eigener Container + Volume (`kairo-db-data`) |
| Dev-Seeds | ja | nein |
| Admin | `lars@stommer.com` (Seed) | `KAIRO_BOOTSTRAP_*` in `.env` |
| Schema nach AP0.3 | `004` | `004` |
**Startup-Reihenfolge:** Entrypoint → Schema-Migrationen → Data-Seeds → Rights-Sync → Uvicorn.
---
## 11. Übernommene Muster aus Shinkan
| Muster | Kairo-Umsetzung |
|--------|-----------------|
| Registry-first | `rights_registry.py` + `rights_registrations/` |
| `register_capability()` mit Validierung | Pflichtfelder `key`, `module`, `description` |
| Frozen Dataclass-Definitionen | `CapabilityRegistration` |
| Startup-Sync Registry → DB | `sync_rights_registry_to_db()` |
| Modul-Ownership | `platform`, `tenant` |
| Default Grants in Code | `default_grants` pro Capability |
| `require_capability` Dependency | `capabilities.py` |
| `/api/me/entitlements` Snapshot | `entitlements.py` |
| Probe vs. Enforce | `CAPABILITY_ENFORCE` |
**Nicht übernommen:** `club_id`, Vereinsrollen, Übungs-/Trainingsrechte, Club-Feature-Kontingente.
---
## 12. Übernommene Muster aus Mitai
| Muster | Kairo-Umsetzung |
|--------|-----------------|
| Entitlements als zentrale Schicht | `build_entitlements_snapshot()` |
| Probe-vs-Enforce-Denken | Default `probe`, optional `enforce` |
| Feature-Registry vorbereitet | `features: {}` im Snapshot |
**Nicht übernommen:** Tier/Subscription, Usage-Zähler, Billing, Legacy `check_feature_access`.
---
## 13. Bewusst nicht übernommen
- Shinkan-/Mitai-Domänenbegriffe (`club`, `profile`, Tier)
- Feature-Limits, Billing, Coupons
- Prompt Registry, Vorhaben-/Projektlogik
- MCP, produktive Admin-UI (nur Debug-JSON + Swagger)
- Capabilities nur in SQL pflegen
- Governance/Object-ACL
---
## 14. Abweichungen von Designprinzipien
| Thema | Abweichung | Begründung |
|-------|------------|------------|
| Entitlements-Struktur | Account- + Tenant-Block | Kairo Hybrid laut Family Entitlement Model |
| Feature-Registry | leeres Objekt | AP0.4 |
| Grant-Overrides | Sync nur additive Grants | Sprint-0-Minimal |
| `linked_feature_id` | nicht modelliert | Keine Limits in AP0.3 |
| Frontend | kein Entitlements-Panel | Minimal-UI; AP0.4/UX optional |
**Eingehalten:** zentrale Capability-Prüfung; Portal- vs. Tenant-Rolle getrennt; TenantContext als Auflösungsschicht.
---
## 15. Offene Entscheidungen
1. **Prod `CAPABILITY_ENFORCE=enforce`** — wann umstellen?
2. **Grant-Overrides in DB** vs. reiner Code-Kanon
3. **Prod-pytest-Cleanup** (`*@example.com`) — aus AP0.2 offen
4. **Frontend:** Entitlements-Karte + Admin-Demo-Button
5. **Feature-Registry** — AP0.4
---
## 16. Empfehlung für AP0.4
Laut Foundation **AP0.4 Prompt, Feature, Config Registry**:
- Feature Registry (Metadaten, ohne Billing-Limits in Sprint 0)
- PromptTemplate / PromptVersion / Placeholder
- ConfigurationEntry
- Prompt-Rendering mit Placeholder-Validation
- `features`-Block in Entitlements mit Registry-Sync
Optional vor AP0.4: Prod-Cleanup-Seed; GUI-Entitlements; `CAPABILITY_ENFORCE=enforce` in Prod nach Kalibrierung.
---
## Referenzen
- `docs/sprints/Jinkendo_Kairo_04_Sprint0_Foundation_v0.3.md` § AP0.3, §9
- `docs/reference/design-principles/shinkan/RIGHTS_REGISTRY_DESIGN_PRINCIPLES.md`
- `docs/reference/design-principles/shinkan/CAPABILITY_ENTITLEMENT_DESIGN_PRINCIPLES.md`
- `docs/reference/design-principles/alignment/FAMILY_ENTITLEMENT_MODEL.md`
- `docs/DEPLOYMENT.md` — DB-Passwort Dev/Prod
- Vorgänger: `docs/sprints/Sprint0_AP0_2_Completion_Report_v0.2.md`, `Sprint0_AP0_3_Completion_Report_v0.2.md`
---
*Abgeschlossen im Rahmen Sprint 0 AP0.3. Ersetzt `Sprint0_AP0_3_Completion_Report_v0.2.md`.*

View File

@ -0,0 +1,357 @@
# AP0.4 Abschlussbericht Workflow-ready Prompt, Feature & Config Registry
**Status:** abgeschlossen
**Stand:** 2026-07-04
**Branch:** `develop` · Schema `005` · Version `0.4.0-ap0.4`
---
## 1. Scope und Einordnung
AP0.4 liefert die **minimale, workflow-ready Grundlage** für Feature-, Prompt-, Placeholder- und Configuration-Registry in Kairo — ohne LLM-Aufrufe, ohne Workflow Engine, ohne Billing.
| Anforderung (AP0.4) | Status |
|---------------------|--------|
| Migration Feature/Prompt/Placeholder/Config/ExecutionLog | ✓ |
| Feature Registry + DB-Sync | ✓ |
| PromptDefinition / PromptVersion / PromptStep | ✓ |
| PlaceholderDefinition + Validation | ✓ |
| ConfigurationEntry + Config Service | ✓ |
| Single Prompt Rendering | ✓ |
| `features`-Block in Entitlements | ✓ |
| Capability-geschützte API | ✓ |
| Audit bei Sync/Config/Fehler-Render | ✓ |
| Tests | ✓ (lokal; CI nach Push) |
**Bewusst nicht in AP0.4:** Workflow/Pipeline-Ausführung, LLM, MCP, Tool Calling, Feature-Limits, Admin-UI, zentrale Jinkendo Prompt Engine.
---
## 2. Umgesetzte Dateien
| Datei | Zweck |
|-------|--------|
| `backend/migrations/005_prompt_feature_config_registry.sql` | Tabellen Feature/Prompt/Placeholder/Config/ExecutionLog |
| `backend/feature_registry.py` | Feature-Registry + DB-Sync |
| `backend/feature_registrations/` | Initiale Features |
| `backend/prompt_registry.py` | Prompt-Registry + Version/Step-Sync |
| `backend/prompt_registrations/` | Smoke-Prompts |
| `backend/placeholder_registry.py` | Placeholder-Registry + Sync |
| `backend/placeholder_registrations/` | Tenant-/Actor-/Debug-Placeholder |
| `backend/config_service.py` | Config lesen/schreiben, Secret-Schutz |
| `backend/prompt_validation.py` | Placeholder-Extraktion + Validierung |
| `backend/prompt_rendering.py` | Single-Mode Render + ExecutionLog |
| `backend/prompt_context.py` | TenantContext → Placeholder-Werte |
| `backend/sync_prompt_feature_config.py` | Startup-Sync + Default-Configs |
| `backend/rights_registrations/registry_ops.py` | 7 neue Capabilities |
| `backend/routers/features.py` | `GET /api/features` |
| `backend/routers/prompts.py` | Prompt-Liste, Detail, Render |
| `backend/routers/config.py` | Config GET/POST |
| `backend/entitlements.py` | `features`-Block aus DB |
| `backend/capabilities.py` | `require_capability_ctx` (ohne Tenant-Zwang) |
| `backend/entrypoint.sh` | Registry-Sync nach Rights-Sync |
| `backend/version.py` | `0.4.0-ap0.4`, Schema `005` |
| `backend/tests/test_feature_registry.py` | Feature-Tests |
| `backend/tests/test_prompt_registry.py` | Prompt/Validation/Render-Tests |
| `backend/tests/test_config_registry.py` | Config-Tests |
| `frontend/src/App.jsx` | Header AP0.4 |
| `README.md` | API-Doku AP0.4 |
---
## 3. Neue Migrationen
**`005_prompt_feature_config_registry.sql`**
| Tabelle | Zweck |
|---------|--------|
| `features` | Feature-Metadatenkatalog |
| `prompt_definitions` | PromptDefinition inkl. `execution_mode`, `active_version_id` |
| `prompt_versions` | Versionierter Body + JSON-Schemas + `model_policy_key` |
| `prompt_steps` | Workflow-/Pipeline-Schritte (AP0.4: nur `template` genutzt) |
| `placeholder_definitions` | Typisierte Placeholder pro `context_kind` |
| `configuration_entries` | Global/tenant Config (kein Secret-Klartext) |
| `prompt_execution_logs` | Render-/Test-Läufe |
---
## 4. Datenmodell
```
features
prompt_definitions ──< prompt_versions < prompt_steps
└── active_version_id → prompt_versions
placeholder_definitions (key + context_kind)
configuration_entries (global | tenant)
prompt_execution_logs → definition/version/user
```
**ExecutionMode:** `single` | `pipeline` | `workflow` (nur `single` ausführbar)
**ContextKind:** `kairo.tenant_context`, `kairo.actor_context`, `kairo.debug_context`
**StepType:** `template`, `resolver`, `llm_call`, `postprocess`, `validation` (nur `template` ausführbar)
---
## 5. Registry-Strukturen
Analog AP0.3 Rights Registry:
```
backend/
├── feature_registry.py + feature_registrations/
├── prompt_registry.py + prompt_registrations/
├── placeholder_registry.py + placeholder_registrations/
├── config_service.py
├── prompt_validation.py
├── prompt_rendering.py
└── sync_prompt_feature_config.py
```
Startup-Reihenfolge: Migrationen → Seeds → Rights-Sync → **Registry-Sync** → Uvicorn.
---
## 6. Feature Registry
Registrierte Features:
| Feature Key | Modul |
|-------------|-------|
| `kairo.feature.registry` | platform |
| `kairo.config.registry` | config |
| `kairo.prompt.registry` | prompt |
Keine Limits, Quotas oder Billing — reiner Metadatenkatalog im Entitlements-Snapshot (`enabled: true`).
---
## 7. Prompt-Modell
**Kernel-Konzepte (lokal in Kairo, extrahierbar):**
| Konzept | Umsetzung |
|---------|-----------|
| PromptDefinition | `prompt_definitions` + `PromptRegistration` |
| PromptVersion | `prompt_versions` |
| PromptStep | `prompt_steps` |
| PlaceholderDefinition | `placeholder_definitions` |
| ContextKind | Code-Konstanten + Spalte `context_kind` |
| ExecutionMode | Spalte `execution_mode` |
| InputSchema / OutputSchema | JSONB auf Version |
| ModelPolicyKey | Spalte (Vorbereitung, kein LLM) |
| PromptRenderService | `prompt_rendering.render_prompt_single` |
| PromptValidationService | `prompt_validation.validate_placeholder_input` |
| PromptExecutionLog | `prompt_execution_logs` |
**Smoke-Prompts:**
- `kairo.system.health_summary` — Debug-Context, Template via `PromptStep`
- `kairo.context.debug_summary` — TenantContext-Placeholder
- `kairo.actor.debug_summary` — Actor-Context
- `kairo.pipeline.placeholder``execution_mode=pipeline` (strukturell, nicht ausführbar)
---
## 8. Workflow-/Pipeline-Fähigkeit
| Vorbereitet | AP0.4 Verhalten |
|-------------|-----------------|
| `execution_mode=pipeline/workflow` | In DB/Registry akzeptiert; Render → HTTP 501 / `mode_not_implemented` |
| `prompt_steps` mit diversen `step_type` | Gespeichert; Single-Render nutzt ersten aktiven `template`-Step oder `version.body` |
| `input_mapping` / `output_mapping` | JSONB-Spalten, noch nicht ausgewertet |
**Spätere Pipeline:** Schritte der aktiven Version in `step_order` ausführen; pro `step_type` Handler registrieren (analog Rights-Registrations).
**Spätere Workflow:** `execution_mode=workflow` + Step-Graph/Orchestrator oberhalb des Render-Services — Tabellen müssen nicht migriert werden.
---
## 9. Placeholder Validation
- Mustache-Style: `{{placeholder_key}}`
- Required Placeholder aus Registry → kontrollierter `PlaceholderValidationError`
- Optionale Placeholder fehlen → kein Fehler
- Unbekannte Keys im Template → in `validation.unknown_placeholders` dokumentiert
- Typ-Prüfung basic (`string`, `number`, `boolean`, `object`, `array`, `json`)
- Template und Kontext getrennt (`use_context` + `prompt_context.py`)
Initial-Placeholder (Tenant): `tenant_name`, `tenant_id`, `actor_*`, `portal_role`, `tenant_role`, `capabilities`.
---
## 10. Configuration Registry
Default-Configs beim Sync:
| Key | Wert |
|-----|------|
| `prompt.default_execution_mode` | `single` |
| `prompt.render_logging_enabled` | `true` |
| `features.registry_sync_enabled` | `true` |
- Scopes: `global`, `tenant`
- `is_secret=true` → kein Klartext; nur `{"ref": "env"}` erlaubt
- Zugriff zentral über `config_service.py`
---
## 11. Entitlements-Erweiterung
`/api/me/entitlements` liefert nun `features` auf Root-, Account- und Tenant-Ebene:
```json
{
"features": {
"kairo.prompt.registry": {
"enabled": true,
"module": "prompt",
"name": "Prompt Registry"
}
}
}
```
Keine Usage-Zähler. Feature-Enforcement bleibt `probe`.
---
## 12. Neue/geänderte API-Endpunkte
| Endpoint | Auth | Capability |
|----------|------|------------|
| `GET /api/features` | Session | `kairo.feature.registry.read` |
| `GET /api/prompts` | Session | `kairo.prompt.registry.read` |
| `GET /api/prompts/{key}` | Session | `kairo.prompt.registry.read` |
| `POST /api/prompts/{key}/render` | Session | `kairo.prompt.render` |
| `GET /api/config` | Session | `kairo.config.registry.read` |
| `POST /api/config` | Session | `kairo.config.registry.manage` |
`require_capability_ctx` erlaubt Lese-Endpunkte ohne aktiven Tenant (Portal-Admin/User).
---
## 13. Audit
| Aktion | Event |
|--------|-------|
| Registry-Sync Startup | `registry.sync.completed` |
| Config geändert | `config.entry.changed` |
| Render fehlgeschlagen | `prompt.render.failed` |
| Capability verweigert | `capability.denied` (bestehend) |
Prompt-Definition-CRUD über API ist in AP0.4 nicht exponiert (nur Code-Registry + Sync) — Audit bei manuellem DB-Write wäre Lücke; AP0.5 Admin-Routen können CRUD + Audit ergänzen.
---
## 14. Tests und Verifikation
| Testdatei | Abdeckung |
|-----------|-----------|
| `test_feature_registry.py` | Registrierung, Sync, Entitlements, Auth |
| `test_prompt_registry.py` | Sync, Validation, Render, Pipeline-Ablehnung, API |
| `test_config_registry.py` | Global Config, Secret-Schutz, API |
| `test_entitlements.py` | Features im Snapshot |
| `test_migrations.py` | Migration 005 |
| `test_rights_registry.py` | 12 Capabilities |
```bash
docker compose -f docker-compose.dev-env.yml exec backend python -m pytest tests -ra -vv
```
---
## 15. Übernommene Muster aus Mitai
| Muster | Kairo |
|--------|-------|
| Prompt Template + Version | `PromptRegistration` / `prompt_versions` |
| Placeholder-Handling | Registry + Validation |
| Feature Registry Metadaten | `features` + Entitlements |
| Entitlements zentrale Schicht | `features`-Block ergänzt |
| Registry-Sync | Startup-Sync analog Rights |
| Preview/Testbarkeit | Render-Endpoint + ExecutionLog |
**Nicht übernommen:** Pipeline-Graphen, Gesundheits-Prompts, Tier/Billing, Usage-Zähler.
---
## 16. Übernommene Muster aus Shinkan
| Muster | Kairo |
|--------|-------|
| Mustache-Template-Prinzip | `{{key}}`-Rendering |
| Kontextarten | `context_kind` |
| Registry-first | Code → DB-Sync |
| Schlanke Runtime ohne Overengineering | nur Single-Render |
| Capability-Gates | `require_capability*` |
**Nicht übernommen:** Trainings-Prompts, Club-Logik, Übungsrechte.
---
## 17. Bewusst nicht übernommene Muster
- Vollständige Mitai Prompt Engine / Workflow-Graphen
- LLM Provider Routing, Tool Calling, MCP
- Shinkan-/Mitai-Domänenprompts
- Feature-Limits, Billing, Coupons
- Produktive Admin-UI
- Secrets in `configuration_entries`
---
## 18. Abweichungen von Designprinzipien
| Thema | Abweichung | Begründung |
|-------|------------|------------|
| Prompt-CRUD API | nur Code-Registry + Sync | Sprint-0-Minimal; Swagger-Read/Render reicht |
| Feature-Enforcement | immer `probe` | Limits erst später |
| Output-Validation | nicht implementiert | Spec: nicht erforderlich in AP0.4 |
| ContextKind | Code-Konstanten, keine eigene Tabelle | Ausreichend für 3 Kindes |
---
## 19. Offene Entscheidungen
1. **Wann Prompt/Feature-CRUD über Admin-API** (AP0.5)?
2. **Extraktion `jinkendo_prompt_kernel`** — Package-Grenze und Migrations-Ownership
3. **Pipeline-Executor** — Step-Handler-Registry vs. feste Kette
4. **Feature-Enforcement** — wann über reine Metadaten hinaus?
5. **Render-Logging in Prod**`prompt.render_logging_enabled` Default?
---
## 20. Empfehlung für AP0.5
Laut Foundation **AP0.5 Audit und minimale Admin-Prüfbarkeit**:
- AuditLog für Registry-/Prompt-Änderungen erweitern
- Minimale Admin-Routen oder Admin-Seite
- Optional: Entitlements-/Feature-Panel in GUI
- Prod-pytest-Cleanup (`*@example.com`)
---
## Extraktion `jinkendo_prompt_kernel` — Einschätzung
**Realistisch:** Die Module `prompt_registry`, `placeholder_registry`, `prompt_validation`, `prompt_rendering` und die Tabellen `prompt_*` / `placeholder_definitions` sind produktneutral genug (`kairo.*`-Keys nur in Registrations). Extraktion als Shared Python-Package mit injizierbarem DB-Layer und Audit-Hook ist ohne Schema-Bruch möglich.
**Risiken im aktuellen Zuschnitt:**
- Config und Feature Registry noch eng an Kairo-Entitlements gekoppelt
- Kein generischer Step-Executor — Pipeline-Integration erfordert noch Designarbeit
- `require_capability_ctx` vs. `require_capability` — zwei Patterns für Tenant-Pflicht
---
## Referenzen
- `docs/sprints/Jinkendo_Kairo_04_Sprint0_Foundation_v0.3.md` § AP0.4
- Vorgänger: `docs/sprints/Sprint0_AP0_3_Completion_Report_v0.3.md`
- Designprinzipien: Mitai Prompt/Registry/Feature, Shinkan AI Prompt Runtime
---
*Abgeschlossen im Rahmen Sprint 0 AP0.4.*

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.3</p> <p>Operativer Program Director Sprint 0 / AP0.4</p>
<p className="muted">Capability Registry &amp; Entitlements</p> <p className="muted">Capability Registry &amp; Entitlements</p>
</header> </header>