--- name: nexus-stack description: Reale Stack-Konventionen von nexus (Family Knowledge Hub) gegen den gemergten Code-Stand (K-101/103/104/106). Verwenden bei jeder Implementierungs-Karte im Repo l.kirchner/nexus-hub — deckt ab App-Factory, Settings/structlog-Redaction, den visibility-Lese-Pfad mit Architektur-Test, router.run als einzige LLM-Schnittstelle, Session-Auth, Alembic-Fluss, Audit und die CI-Test-DB. Quelle ist der Code, nicht Vermutung. --- # nexus Stack-Konventionen (Stand: K-104 gemerged) Präskriptive Muster gegen den realen Code. Bei Abweichung gilt der Code; melde Drift in diesem Skill. ## Layout & Kommandos - uv-Projekt, Python 3.12, src-Layout `src/nexus/`. Console-Scripts: `nexus-api`, `nexus-worker`, `nexus-seed`. Web: `web/` (Vite/React/TS/Tailwind), Build von FastAPI unter `/` ausgeliefert. - Gates (verbindlich vor Merge, identisch in CI): `uv run ruff check && uv run mypy && uv run pytest`; bei Web-Änderungen `npm run build` in `web/`. - mypy ist `strict`; ruff `line-length=100`. Neue Module fügen sich ein, nicht umgekehrt. ## FastAPI-App-Factory (`src/nexus/main.py`) `create_app()` ist die EINE Stelle, die zusammensteckt — kein App-Aufbau in Routen/Tests: ```python def create_app() -> FastAPI: settings = get_settings() configure_logging(settings.log_level) # IMMER zuerst (PRIV-5) app = FastAPI(docs_url="/api/docs", openapi_url="/api/openapi.json") app.state.settings = settings app.state.oauth = auth_routes.build_oauth(settings) # None ⇒ Login 503 app.add_middleware(SessionMiddleware, secret_key=..., same_site="lax", https_only=settings.public_url.startswith("https://")) app.include_router(health.router); app.include_router(auth_routes.router) app.include_router(routes.router) # StaticFiles-SPA zuletzt unter "/" (API-Routen gewinnen) ``` Regeln: `configure_logging` vor allem anderen. Neue Router über `include_router` VOR dem SPA-Mount. uvicorn nie per CLI starten — Entrypoint `nexus-api` (`run()`) mit `log_config=None`, `access_log=False`, bindet an `settings.host`. ## Settings (`src/nexus/config.py`) Eine `Settings`-Klasse (pydantic-settings, Prefix `NEXUS_`), Zugriff via `get_settings()` (lru_cache). Tests leeren den Cache. `Settings.__repr__` redacted Secrets — bei neuen Secret-Feldern (`*_secret`, `*_password`, DSN) beibehalten. Werte kommen prod aus `/etc/nexus/env`. Reale Felder u.a.: `host` (Default `127.0.0.1`), `public_url`, `database_url`, `oidc_*`, `session_secret`, `llm_policy_path`, `anthropic_api_key`. **Fail-closed by default:** fehlende Konfiguration ⇒ Feature aus (None), nie ein unsicherer Default. ## Lese-Pfad: `nexus/visibility.py` ist der EINZIGE Query-Weg (SICHT-1/2/3) Jede Lese-Query auf `Entry/EntryVersion/EntryShare/EntrySource/Proposal/Source` lebt in `nexus/visibility.py` — nirgends sonst. Pattern: ```python # in der Route: viewer: User = Depends(get_current_user) return list(session.scalars(visibility.visible_entries(viewer))) # nie select(Entry) direkt entry = visibility.get_entry(session, viewer, entry_id) # Einzelzugriff if entry is None: raise HTTPException(404, "not found") # nicht-sichtbar ≡ nicht-existent ``` - Sichtbarkeit wird IN der Query durchgesetzt (kein Python-Nachfilter); `viewer` ist Pflichtparameter. Share-Zeilen wirken nur bei `visibility=='geteilt'`. - SICHT-2: nicht-sichtbar liefert `None` ⇒ überall dieselbe 404 (gleicher Text), nie ein abweichender Code/Zählwert. - Kein Admin-Bypass auf Inhalte. - **Erzwungen:** `tests/test_architecture.py` scannt `src/` und verbietet `select(Entry…)`, `.get(Entry…)`, `aliased(…)`, `models.X` und Raw-SQL auf die geschützten Tabellen außerhalb von `visibility.py`/`models.py`. Wer eine neue sichtbarkeitsrelevante Query braucht: Helper IN `visibility.py` ergänzen, nicht den Test aufweichen. Jede neue Lese-Route kommt zusätzlich in die **SICHT-4-Suite** (`tests/test_sicht4.py`). ## LLM: `router.run(...)` ist die EINZIGE Modell-Schnittstelle (PRIV-1…6) Fach-Code importiert nie `httpx`/Modell-Clients — nur `LLMRouter`. Ein AST-Architektur-Test (`tests/test_llm_router_architecture.py`) verbietet HTTP-Client-Importe außerhalb von `llm_router.py`. ```python with LLMRouter() as r: result = r.run(task_profile, privacy_class, payload) # status: completed | blocked ``` Hartverdrahtete Garantien (nicht umbauen ohne ADR): - **Reihenfolge in `run`:** PRIV-6-Sensitivitäts-Pre-Check → Policy-Decision → forbidden? → Endpoint-Auflösung → **Hard Guard** → Health-Check (lokal) → Call. Jeder Fehlerpfad endet in `blocked` + Audit; **kein** externer Fallback, auch nicht bei Timeout. - **Hard Guard:** `privat`/`familie` an `kind=="external"` ⇒ `blocked` — zweite Verteidigungslinie vor der Policy. - **Allowlist:** `EndpointConfig` validiert `kind=="local"` gegen `LOCAL_ENDPOINT_ALLOWLIST` (gepinnte luki-ai host:port) — eine als „local" getarnte externe URL wird beim Policy-Load abgewiesen. Endpoints sind Konfiguration (`config/llm_policy.default.json`), nie Code. - Policy-Änderung nur über `write_policy_as_admin` (admin-only, auditiert). ## Auth: Session-Identität über `get_current_user` (K-104) ```python from nexus.auth import get_current_user, require_admin viewer: User = Depends(get_current_user) # 401 ohne gültige Session admin: User = Depends(require_admin) # 404 für member (AUTH-3, nicht 403) ``` - Identität kommt AUSSCHLIESSLICH aus der signierten Session (`SESSION_USER_KEY`), gesetzt vom OIDC-Callback. Es gibt keinen Header-/Dev-Mechanismus mehr (in K-104 entfernt) — nicht wieder einführen. - OIDC-Flow in `api/auth_routes.py` (authlib, Code+PKCE). **Session-State (state/nonce/PKCE) NIE vor `authorize_access_token` räumen** — `session.clear()` erst nach dem Exchange (Fehlerpfade einzeln, als HTTPException durch die SessionMiddleware). Rollen aus dem `groups`-Claim (`nexus-admin`/`nexus-member`), Sync bei jedem Login. - Tests authentifizieren über echte Session-Cookies (Helper in `tests/conftest.py`), nicht über einen Parallel-Mechanismus. ## Audit: `nexus/audit.record(...)` (AUDIT-1…3) Einziger Schreibpfad. `meta`-Keys sind whitelisted (`ALLOWED_META_KEYS`) und Werte typvalidiert — Inhalte (Bodies/Titel/Claims) können strukturell nicht ins Audit. `audit_events` ist append-only (DB-Trigger gegen UPDATE/DELETE/TRUNCATE). Neue Event-Typen: nur Metadaten/IDs/Codes loggen. ## Logging (PRIV-5, `nexus/logging_setup.py`) Immer `configure_logging()` — nie eigenes logging, nie `print`. Der Redaction-Processor setzt content-tragende Keys (`CONTENT_KEYS`: body/content/ocr_text/title/filename/…) rekursiv auf `***`, scrubbt URI-Credentials und literale Secrets. Neue content-tragende Felder in `CONTENT_KEYS` ergänzen, nicht umbenennen, um Redaction zu umgehen. ## Datenmodell & Alembic SQLAlchemy 2 (`models.py`, `Base`). `alembic/env.py` zieht `target_metadata = Base.metadata`, DSN aus `NEXUS_DATABASE_URL`. Fluss: Modell ändern → `uv run alembic revision --autogenerate -m "K-1xx: …"` → generierte Migration PRÜFEN (Constraints benennen, Trigger/Extensions wie `CREATE EXTENSION vector` von Hand ergänzen, `--autogenerate` erfasst die nicht) → `uv run alembic upgrade head`. `/healthz` meldet `db: connected` + Alembic-Revision. ## CI-Test-DB: Wegwerf-pgvector-Container je Lauf CI startet pro Lauf einen frischen `pgvector/pgvector:pg16`-Container (ci.yml Port 5433, deploy-Gate 5434; braucht Docker via LXC-Nesting auf dem Runner), migriert von leer (`alembic upgrade head`) und fährt die SICHT-4-Suite als eigenen benannten, Merge-blockierenden Schritt. Lokal identisch: ```bash docker run -d --rm --name nexus-test-pg -e POSTGRES_PASSWORD=test \ -e POSTGRES_USER=nexus -e POSTGRES_DB=nexus -p 127.0.0.1:5433:5432 \ pgvector/pgvector:pg16 ``` Tests gegen die Test-DB nutzen transaktional isolierte Sessions (Rollback je Test); abweichende URL via `NEXUS_TEST_DATABASE_URL`. ## Rote Linien (Verstoß = Review-Stopp) - Kein direkter `select()` auf sichtbarkeitsrelevante Tabellen außerhalb `visibility.py`; kein HTTP-Client außerhalb `llm_router.py`. - Kein LLM-Call am `router.run` vorbei; kein externer Fallback für `privat`/`familie`. - Keine zweite Auth-Identität neben der Session; OIDC-State nicht vor dem Token-Exchange räumen. - Inhalte nie in Logs/Audit; Secrets nie in Repr/Repo/Wiki/Chat. - Keine Karte ohne Tests für ihre Akzeptanzkriterien; jede neue Lese-Route in die SICHT-4-Suite.