Ctrl K
ComunidadGuías…

docs/architecture.de.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
---
source_sha: "a709a06f672e"
---

# Architektur { #architecture }

Dieses Projekt folgt einer geschichteten **Repository-und-Service**-Architektur.
Jedes Feature — Nutzer, Unterhaltungen, Dateien, RAG-Dokumente, Sync-Quellen —
nutzt dasselbe Muster: **Models → Schemas → Repositories → Services → Endpoints**.

## Ablauf einer Anfrage { #request-flow }

```mermaid
flowchart LR
    Q([HTTP request]) --> R[API route]
    R --> S[Service]
    S --> P[Repository]
    P --> D[(PostgreSQL)]
    D -.-> P
    P -.-> S
    S -.-> R
    R -.-> A([Response])
```

Routes enthalten nie direkte Datenbankaufrufe. Jeder Datenzugriff läuft über
Services, die ihrerseits an Repositories delegieren.

!!! info "Es ist ein Test, keine Konvention"

    `backend/tests/test_route_layering.py` schlägt fehl, wenn eine Route ein
    Repository importiert - und ebenso laut, wenn seine Allowlist eine Ausnahme
    behält, die nicht mehr gilt.

Die Regel war in fünf Modulen verrutscht, bevor irgendetwas danach gesucht hat —
kein einziges davon ein Leck, denn jeder Handler hat den Scope übergeben, den er
zufällig kannte. Das ist der Preis: Ein Scope, den eine Route besitzt, ist ein
Scope, den kein Service-Test sehen kann, und der nächste Leser der Entität muss
wissen, dass er dasselbe übergeben muss. Die einzige Ausnahme ist ein `Literal`
von Sortierreihenfolgen, als Typ importiert und nicht als Datenzugriff.

## Verzeichnisstruktur (`backend/app/`) { #directory-structure-backendapp }

| Verzeichnis / Datei   | Zweck                                                         |
| --------------------- | ------------------------------------------------------------- |
| `api/routes/v1/`      | HTTP-Endpunkte, Validierung der Anfrage, Auth                 |
| `api/deps.py`         | Dependency Injection (DB-Session, aktueller Nutzer)           |
| **`services/`**       | **Geschäftslogik, Orchestrierung**                            |
| ↳ `user.py`           | Nutzer-CRUD, Profilaktualisierungen                           |
| ↳ `conversation.py`   | Verwaltung von Unterhaltungen und Nachrichten                 |
| ↳ `message_rating.py` | CRUD für Nachrichtenbewertungen, Statistiken, Export          |
| ↳ `file_upload.py`    | Verarbeitung von Datei-Uploads im Chat                        |
| ↳ `file_storage.py`   | Abstraktion der Dateiablage (lokal / S3)                      |
| ↳ `rag_document.py`   | Lebenszyklus eines RAG-Dokuments                              |
| ↳ `rag_sync.py`       | Orchestrierung der Synchronisation entfernter Quellen         |
| ↳ `sync_source.py`    | CRUD für Sync-Quellen, und die Laufhistorie einer Quelle      |
| ↳ `audit.py`          | Lesen des Audit-Trails der eigenen Organisation des Aufrufers |
| **`repositories/`**   | **Datenzugriffsschicht, Datenbankabfragen**                   |
| ↳ `user.py`           | Nutzerabfragen                                                |
| ↳ `conversation.py`   | Abfragen zu Unterhaltungen                                    |
| ↳ `chat_file.py`      | Abfragen zu Chat-Dateien                                      |
| ↳ `message_rating.py` | Abfragen zu Nachrichtenbewertungen                            |
| ↳ `rag_document.py`   | Abfragen zu RAG-Dokumenten                                    |
| ↳ `sync_log.py`       | Abfragen zu Sync-Logs                                         |
| ↳ `sync_source.py`    | Abfragen zu Sync-Quellen                                      |
| **`schemas/`**        | **Pydantic-Modelle für Anfrage und Antwort**                  |
| ↳ `user.py`           | Nutzer-Schemas                                                |
| ↳ `conversation.py`   | Schemas für Unterhaltungen und Nachrichten                    |
| ↳ `file.py`           | Schemas für Datei-Uploads                                     |
| ↳ `message_rating.py` | Schemas für Nachrichtenbewertungen                            |
| ↳ `rag.py`            | Schemas für RAG-Abfrage und -Antwort                          |
| ↳ `sync_source.py`    | Schemas für Sync-Quellen                                      |
| **`db/models/`**      | **SQLAlchemy-2.1-Modelle**                                    |
| ↳ `user.py`           | Nutzer-Modell                                                 |
| ↳ `conversation.py`   | Modelle für Unterhaltung und Nachricht                        |
| ↳ `chat_file.py`      | Modell für Chat-Dateien                                       |
| ↳ `message_rating.py` | Modell für Nachrichtenbewertungen                             |
| ↳ `webhook.py`        | Webhook-Modell                                                |
| ↳ `rag_document.py`   | Modell für RAG-Dokumente                                      |
| ↳ `sync_log.py`       | Modell für Sync-Logs                                          |
| ↳ `sync_source.py`    | Modell für Sync-Quellen                                       |
| `core/config.py`      | Einstellungen über pydantic-settings                          |
| `core/security.py`    | Hilfsfunktionen für JWT / API-Key                             |
| `agents/`             | KI-Agents und Tools                                           |
| `rag/`                | RAG-Modul (Embeddings, Vector Store, Retrieval)               |
| `rag/connectors/`     | Sync-Connectors (Google Drive, S3, Websites)                  |
| `commands/`           | CLI-Kommandos im Django-Stil                                  |

## Verantwortlichkeiten der Schichten { #layer-responsibilities }

### API-Routes (`api/routes/v1/`) { #api-routes-apiroutesv1 }

- Behandlung von HTTP-Anfrage und -Antwort
- Eingabevalidierung über Pydantic-Schemas
- Prüfungen der Authentifizierung und Autorisierung
- Enthält **nie** direkte DB-Aufrufe — delegiert immer an einen Service
- **Parst nie** nicht vertrauenswürdige Eingaben im Route-Ausdruck. Ein dort
  ausgelöster `ValidationError` ist ein `ValueError`, aber kein
  `RequestValidationError`, also bildet ihn kein Handler ab und der Aufrufer
  bekommt eine 500 mit `details: null` — und genau so wurde jeder Fehler in einem
  von Hand bearbeiteten Spec-YAML als Absturz gemeldet (#873). Das Parsen ist
  Aufgabe des zuständigen Service, und die Ablehnung ebenso: `import_spec` auf
  `AgentRegistryService` beantwortet ein defektes Dokument mit einer 400, die das
  Feld benennt, und zitiert dem Aufrufer nie zurück, was er gesendet hat.

### Services (`services/`) { #services-services }

- Geschäftslogik und Validierung
- Orchestriert einen oder mehrere Repository-Aufrufe
- Löst Domänen-Exceptions aus (`NotFoundError`, `AlreadyExistsError` usw.)
- Verwaltet Transaktionsgrenzen

### Repositories (`repositories/`) { #repositories-repositories }

- Nur Datenbankoperationen
- Keine Geschäftslogik
- Nutzt `db.flush()`, nicht `commit()` — die Session der Anfrage besitzt die
  Transaktion und [committet sie, bevor die Antwort gesendet wird](#the-requests-transaction)
- Gibt Domänenmodelle zurück

### Schemas (`schemas/`) { #schemas-schemas }

- Getrennte `Create`-, `Update`- und `Response`-Modelle je Entität
- `Response`-Schemas nutzen `model_config = ConfigDict(from_attributes=True)` für die ORM-Umwandlung

### Models (`db/models/`) { #models-dbmodels }

- Modelldefinitionen für SQLAlchemy 2.1
- Beziehungen, Indizes und Spaltenvorgaben stehen hier

### RAG-Connectors (`rag/connectors/`) { #rag-connectors-ragconnectors }

- Einsteckbare Sync-Adapter, die `BaseSyncConnector` implementieren
- Jeder Connector stellt `list_files()` und `download_file()` bereit
- Registriert in `CONNECTOR_REGISTRY`, um zur Laufzeit gefunden zu werden

## Die Transaktion der Anfrage { #the-requests-transaction }

Eine Anfrage, eine Session, eine Transaktion, an einer Stelle committet — und die
Stelle zählt ebenso sehr wie die Tatsache.

Eine Route fordert `DBSession` an (`app/api/deps.py`), was `get_db_session`
auflöst (`app/db/session.py`). Alles unterhalb der Route teilt sich diese eine
Session: Services nehmen sie in ihrem Konstruktor, Repositories als erstes
Argument, und keines von beiden ruft je `commit()` auf — mit einer bewussten
Ausnahme, dem Pfad eines Agent-Runs und dem Sprachanruf, der einer ist,
[unten](#the-run-paths-two-commits) beschrieben. `flush()` sendet die Statements, sodass die Zeile eine Id hat und die
Constraints geprüft sind; der Commit passiert einmal, auf dem Weg hinaus.

**Auf dem Weg hinaus heißt: bevor die Antwort geschrieben wird.** Der Alias
deklariert `Depends(get_db_session, scope="function")`, was den Exit-Code der
Session auf dem Exit-Stack registriert, den FastAPI zwischen der Rückkehr der
Path-Operation und `await response(scope, receive, send)` abbaut. Die Reihenfolge
für eine Anfrage ist also:

1. die Route kehrt zurück, und `response_model` serialisiert, was sie zurückgab;
2. die Transaktion committet — oder rollt zurück, falls etwas ausgelöst wurde;
3. die von der Anfrage aufgeschobene Hintergrundarbeit wird gestartet (unten);
4. die Antwort wird in den Socket geschrieben;
5. die Session wird geschlossen.

```mermaid
sequenceDiagram
    autonumber
    participant C as Client
    participant R as Route
    participant S as Session
    C->>R: request
    R->>S: flush (ids, constraints)
    R-->>R: return, response_model serializes
    R->>S: COMMIT
    S-->>R: committed
    R->>R: start deferred background work
    R-->>C: 2xx written
    R->>S: close
```

!!! danger "Eine 2xx heißt, dass der Schreibvorgang lesbar ist, nicht bloß angenommen"

    Diese Reihenfolge ist der ganze Vertrag. Ein blankes
    `Depends(get_db_session)` an irgendeiner Stelle führt FastAPIs Voreinstellung
    wieder ein und vertauscht Schritt 2 und 4 -
    `tests/api/test_db_session_scope.py` schlägt bei einem solchen fehl.

Diese Reihenfolge ist es, die einen Client auf seine eigene Antwort hin handeln
lässt. FastAPIs Voreinstellung für eine Dependency mit `yield` ist
`scope="request"`, was Schritt 2 und 4 andersherum setzt — und es hier tat bis
[#353][353], wo eine Annahme mit 204 antwortete, während die von ihr erzeugte
Mitgliedschaftszeile für die unmittelbar nächste Anfrage 21,7 ms lang unsichtbar
blieb, und ein Einladungstoken 34 ms verbraucht wurde, bevor die Transaktion, die
es ausgab, committet hatte.

Drei Konsequenzen, die man kennen sollte, bevor man eine Route schreibt:

- **Ein fehlgeschlagener Commit ist eine 500, keine Logzeile.** Die Antwort ist
  noch nicht geschrieben, ein zurückgestelltes Constraint oder eine verlorene
  Verbindung erreicht den Client also als Fehler, statt hinter einer bereits
  gesendeten 2xx entdeckt zu werden. Schritt 3 läuft ebenfalls nicht: Arbeit, die
  auf eine nicht stattgefundene Transaktion wartet, wird verworfen, mit einer
  Warnung, die sie benennt.
- **Alles, was einen Datenbankfehler schluckt, muss die Session zurücksetzen.** Ein
  Statement, das ausgelöst hat, hinterlässt seine Transaktion abgebrochen, und der
  Commit in Schritt 2 löst ebenfalls aus. Die Health-Probes
  (`app/services/health.py`) sind der Fall im Code: Sie weigern sich absichtlich
  weiterzugeben, also rollen sie vor der Rückkehr zurück.
- **Ein Body, der beim Senden der Antwort erzeugt wird, braucht eine andere
  Session.** Eine `StreamingResponse` über einen Generator wird während Schritt 3
  durchlaufen, und da ist die Session geschlossen. Diese Endpunkte nehmen
  `StreamingDBSession`, die FastAPIs voreingestellten Scope behält und daher nur
  lesend ist: Ihre Transaktion löst sich auf, nachdem der Client beantwortet
  wurde. Genau ein Endpunkt nutzt sie — der CSV-Export der Bewertungen — und
  `tests/api/test_db_session_scope.py` lehnt einen zweiten ab, ohne dass darüber
  entschieden wird.

Arbeit, die die Anfrage überdauert, nutzt diese Session gar nicht.
WebSocket-Handler und CLI-Kommandos öffnen `get_db_context()`, Worker-Tasks
`get_worker_db_context()`; alle drei laufen über dasselbe `_managed_session`, sie
committen also beim sauberen Verlassen ihres eigenen `async with` und starten ihre
aufgeschobene Arbeit an derselben Stelle — was mit einer Antwort nichts zu tun hat.

### Die zwei Commits des Run-Pfads { #the-run-paths-two-commits }

Ein Pfad committet absichtlich früher als „auf dem Weg hinaus“: **ein Agent-Run.**

Der Runner committet einmal _vor dem Aufruf des Modells_ und ein weiteres Mal im
abschließenden `finally` — `AgentRunnerService._run`, und `ChatAgentRunner.run`
für den Streaming-Chat.

Ein Modellaufruf dauert Sekunden bis Minuten, und eine darüber offen gelassene
Transaktion hält für diese Dauer eine Verbindung aus dem Pool `idle in
transaction`. Fünfzehn gleichzeitige Runs waren früher der ganze Pool ([#12][12]).

Zuerst zu committen bringt zwei weitere Dinge: Die Run-Zeile ist für die gesamte
Lebensdauer des Runs aus jeder anderen Session lesbar, und das Verlassen der
Freigabe-Warteschlange durch einen fortgesetzten Run ist dauerhaft, bevor der
freigegebene Aufruf wiederholt wird — sodass ein Absturz mitten in der
Wiederholung dieselbe Freigabe nicht zweimal ausgeben kann ([#3][3]).

Der abschließende Commit ist die andere Hälfte. Der Session-Kontext committet nur
bei einem sauberen Verlassen, was ein fehlgeschlagener, vom Budget gestoppter oder
abgebrochener Run nicht ist, und ein in der Historie fehlender Run ist ein Run, für
den niemand geradesteht.

Beide Grenzen sind gegen eine echte Datenbank in
`tests/integration/test_run_commit_boundary.py` nachgewiesen.

**Ein Sprachanruf ist ein Run und behält dieselben zwei Commits** ([#54][54]).
Ein Anruf hält sein Socket bis zu fünfzig Minuten, also committet er dort, wo
der Run-Pfad committet, und schreibt alles dazwischen über eigene Sessions:

- `VoiceCallStarter.start` committet einmal, sobald jede Ablehnung passiert ist,
  mit der Zeile in `voice_calls` und ihrem Run, beide geschrieben, bevor der
  Provider aufgerufen wird. Freigabezeilen, der Heartbeat und der Voice-Sweep
  brauchen diese Zeilen aus anderen Sessions lesbar; eine Ablehnung vor diesem
  Punkt rollt alles zurück, sodass ein abgelehnter Anruf nichts in **Aktivität** (Activity)
  hinterlässt.
- Jede gespeicherte Runde, jeder 15-Sekunden-Heartbeat und jedes Schreiben einer
  Freigabe öffnet eine eigene `get_db_context()`-Session und committet dort, nie
  auf der gemeinsamen Session des Runs - eine Runde ist dauerhaft, sobald sie
  gesagt ist, und ein Absturz verliert höchstens die laufende Runde.
- Der Abbau von `VoiceCallSession`, ein abgeschirmtes `finally`, hält das Ende
  in der Zeile des Anrufs fest, legt dann den Anruf beim Provider auf, schließt
  den Run ab und committet erneut - der abschließende Commit, auf jedem Weg
  hinaus. Das Ende kommt zuerst, damit eine Wiederverbindung, die darauf wartet,
  nicht auch noch das Auflegen abwartet.

`tests/test_voice_starter.py::test_a_started_call_is_committed_with_its_row_and_a_voice_run`
und `tests/test_voice_session.py::test_every_exit_path_hangs_up_the_provider_call`
halten die beiden Enden fest.

### Ein zweiter früher Commit { #the-one-other-early-commit }

`SessionService.detect_refresh_reuse` ist der zweite, und zwar aus dem
umgekehrten Grund: nicht weil die Transaktion zu lange gehalten würde, sondern
weil sie gleich verworfen wird. Ein Refresh-Token, der zu keiner lebenden Session
passte, kann die Wiedergabe eines Tokens sein, den eine Session weggedreht hat —
die Antwort ist, diese Kette zu beenden und das festzuhalten, und dann den Aufrufer
abzuweisen, was `AuthenticationError` durch den _Exception_-Zweig des
Session-Kontexts wirft und die Anfrage zurückrollt.

Ohne Commit ist das ein 401, eine weiterhin lebende kompromittierte Kette und kein
Eintrag darüber, dass etwas geschehen ist.
`test_the_response_survives_the_refusal_that_follows_it` rollt nach dem Aufruf
zurück und prüft, was geblieben ist
([#1519](https://github.com/vstorm-co/agenticos/issues/1519)).

### Ein Webhook, der auf seine Antwort wartet { #a-webhook-that-waits-for-its-answer }

`WorkflowExposureService._answer` ist der dritte. Ein Webhook, dessen Graph einen
Schritt Respond to webhook enthält, beantwortet seinen Absender mit dem, was dieser
Schritt festhält, also muss die Anfrage auf einen Run warten, der noch nicht
gestartet ist - und er kann nicht starten, bevor die Zulassung für den Worker
sichtbar ist. Der Service committet den Run und seinen Zustellungseintrag - der erste
Dispatch-Job des Runs wird mit ihnen committet, und das `NOTIFY`, das einen Worker
weckt, feuert bei diesem Commit - und fragt dann die Antwort des Runs ab. Nach dem
Commit wird nichts geschrieben, also hat der abschließende Commit nichts mehr zu
tun. `TestAnsweringTheSender` treibt den Run über einen echten Job-Claim an.

Sichtbarkeit schneidet in beide Richtungen. Alles, was früher schloss „die Zeile
eines laufenden Runs kann nicht gesehen werden“, schließt jetzt über eine Zeile,
die _gesehen wird_, und der Scheduler der Agent-Trigger ist die eine Stelle, die
das tat.

Sein Überschneidungsschutz blockiert bei jedem nicht-terminalen Run in der
Unterhaltung des Triggers — worunter jetzt auch der laufende Run eines
gleichzeitigen `run_now` oder Event-Auslösers fällt, ein Schutz, den die alte
Unsichtbarkeit nicht bieten konnte.

Ein Worker, der mitten im Run stirbt, hinterlässt derweil eine `running`-Zeile, die
im Prozess nichts je beenden wird. Der Prozess, der einen Run ausführt, stempelt
ihn alle `RUN_HEARTBEAT_SECONDS` als lebendig, und was eine Zeile ohne
Lebenszeichen begrenzt, ist der minütliche Durchlauf für abgestandene Runs, der
sie nach `STALE_RUN_REAPED_AFTER_SECONDS` als `failed` beendet. Das eigene Lebenszeichen einer geplanten Auslösung bleibt ihre
erneuerte Lease (`app/repositories/agent_trigger.py::claim_due`).

[Governance](governance.md#a-run-whose-process-died) sagt, was der Durchlauf klärt
und was er bewusst in Ruhe lässt.

### Hintergrundarbeit aus einer Anfrage heraus anstoßen { #dispatching-background-work-from-a-request }

**Arbeit, die geschehen muss, ist ein Job, eingereiht in der eigenen Transaktion
der Anfrage.** `app.services.background_jobs.enqueue` schreibt die Zeile des Jobs
auf die Session der Anfrage, sie wird also mit den Zeilen committet, die sie
nennt, oder gar nicht:

```python
from app.services.background_jobs import enqueue
from app.worker.jobs import FIRE_TRIGGER, FireTrigger

await enqueue(self.db, FIRE_TRIGGER, FireTrigger(trigger_id=trigger.id), organization_id=org_id)
```

Eine zurückgerollte Anfrage reiht nichts ein, und eine committete weckt die
Worker des Pools per `NOTIFY`, das PostgreSQL erst beim Commit zustellt - kein
Worker kann einen Job also abholen, bevor die Zeile existiert, die er liest. Das
schließt [#417][417] durch die Konstruktion: ein Upload, der mit
`{"status": "processing"}` antwortete und so blieb, weil die Arbeit vor dem
Commit begann und ihre eigene Session das Dokument nicht sehen konnte. Ein
Upload, ein Sync, den jemand gestartet hat, jedes Auslösen eines Triggers -
geplant, zugestellt oder „run now“ -, ein asynchroner API-Run und das Aufräumen
dessen, was außerhalb der Datenbank liegt, nach einem Löschen werden alle so
übergeben.

Das manuelle Auslösen eines Triggers steht hier aus einem zweiten Grund, der
einen Namen verdient: `POST /agents/{id}/triggers/{id}/run` hat früher auf den
gestarteten Run _gewartet_, sodass ein Agent, der langsamer war als das
Lese-Timeout eines Proxys, mit 504 antwortete, während der Run weiterlief und
committete - ein Fehler, gemeldet für etwas, das funktionierte, und eine
Einladung, den Knopf noch einmal zu drücken ([#658][658]). Die Route antwortet
mit `202`, sobald der Job eingereiht ist.

**Ein Löschen, dessen Eingabe der eigene Commit zerstört, reiht sein Aufräumen in
diesem Commit ein.** Das Löschen einer Organisation reicht die Speicherpfade und
Collection-Namen weiter, deren letzten Eintrag seine Transaktion entfernt, also
schreibt `queue_external_state_cleanup` sie in Aufräum-Jobs auf derselben
Session: Ein Absturz zwischen Commit und Aufräumen verliert nichts, und der Job
wird wiederholt, bis die Dateien und Vektor-Collections weg sind.

**Arbeit, die verloren gehen darf, nutzt `spawn_after_commit`**
(`app/core/background.py`): kurze Arbeit ohne Garantie auf der Loop der API
selbst, etwa die Erreichbarkeit eines MCP-Servers festzuhalten oder Dateien zu
löschen, auf die ein Artefakt nicht mehr verweist. Sie reiht die Coroutine an der
Session ein und startet sie zwei Anweisungen nach `commit()`; die Reihenfolge ist
gegen eine echte Datenbank in `tests/integration/test_flow_starts_after_commit.py`
belegt. Beim Herunterfahren wartet die Lifespan auf `background.drain()`, bevor
sie verwirft, was diese Tasks lesen.

Zwei Dinge folgen daraus, wo diese Queue liegt:

- **Sie gehört der Session, nicht der Anfrage.** Ein Service, der Arbeit
  übergibt, muss nicht wissen, ob er aus einer Route, einem WebSocket-Handler,
  der CLI oder einem Worker aufgerufen wurde - deshalb sind das nicht die
  `BackgroundTasks` von FastAPI, deren Zusicherung die Antwort betrifft und die
  jene drei anderen Aufrufer nicht haben.
- **Eine zurückgerollte Transaktion stößt nichts an.** Die eingereihten
  Coroutines werden geschlossen, denn Arbeit auszuführen, deren Zeile verworfen
  wurde, verschiebt den Fehler nur an eine schwerer erklärbare Stelle.

`spawn` bleibt richtig für Arbeit, die alles Nötige selbst besitzt und keine
Zeile berührt - die Kanal-Webhooks übergeben ihm jede Zustellung, damit der
Provider sofort eine Antwort bekommt. Nichts davon übersteht einen Neustart; ein
Job schon. Siehe [Einen Hintergrund-Task hinzufügen](howto/add-background-task.md).

[3]: https://github.com/vstorm-co/agenticos/issues/3
[12]: https://github.com/vstorm-co/agenticos/issues/12
[54]: https://github.com/vstorm-co/agenticos/issues/54
[353]: https://github.com/vstorm-co/agenticos/issues/353
[417]: https://github.com/vstorm-co/agenticos/issues/417
[658]: https://github.com/vstorm-co/agenticos/issues/658

## Workflow-Runs: eine Outbox und kurze Transaktionen { #workflow-runs-an-outbox-and-short-transactions }

Ein Workflow-Run kann Tage dauern - ein Knoten kann auf eine Freigabe warten -,
deshalb hält kein Prozess seine Position. Das tut Postgres: `dispatch_outbox`
nennt jeden Knoten, der bereit ist, und jeder `workflow-dispatch-node`-Job übernimmt eine
Zeile, führt einen Versuch aus und schließt ihn ab. Der Code liegt in
`app/services/workflow_execution/dispatcher.py`.

Jeder Versuch besteht aus drei kurzen Transaktionen um einen Aufruf, der keine
hält. Der Claim committet einen Lease auf die Zeile; die Versuchszeile wird als
`in_flight` committet, bevor der Handler aufgerufen wird, sodass ein Worker, der
mitten im Aufruf stirbt, etwas hinterlässt, das der Reconciler findet; und der
Abschluss committet das Ergebnis, die Kosten und die Outbox-Zeile des nächsten
Knotens gemeinsam, sodass kein Ergebnis ohne seinen nächsten Schritt dauerhaft
ist.

Der Job, der einen Worker für diesen nächsten Knoten weckt, wird mit seiner
Zeile in derselben Transaktion eingereiht, und ein später fälliger Knoten (ein
Timer, die Wartezeit eines Wiederholungsversuchs) hat sein `run_after` auf diesen
Zeitpunkt gesetzt; die Sweeps Poll und Reconcile sind nur die Absicherung für
einen Job, der abgebrochen wurde oder gestorben ist. Solange der Handler läuft,
erneuert der Worker seinen Lease in eigenen Transaktionen, und eine Erneuerung, die den Claim nicht mehr vorfindet, teilt
das dem Handler mit.

Jeder Statuswechsel nach dem Claim ist an das Token des Claims gebunden und
daran, dass die Zeile noch beansprucht ist, unter einer Sperre, die Dispatcher
und Reconciler in derselben Reihenfolge nehmen - Run, Knoten-Run, Outbox. Die
eine Ausnahme sind die Kosten: Was ein Aufruf ausgegeben hat, wird auch dann
verbucht, wenn sein Ergebnis zu spät kommt, um angenommen zu werden. Ein
unterbrochener Versuch gilt nie als gelungen oder gescheitert: Er wird
`uncertain`, und nur ein als idempotent deklarierter Knoten wird automatisch
erneut versucht.

## Agent-Runs: Eine Capability holt nie selbst { #agent-runs-a-capability-never-fetches }

Die obige Schichtung hat innerhalb eines Agent-Runs eine weitere Regel, und sie ist
der Grund, warum der Runner so groß ist. **Eine Capability fasst die Datenbank
nicht an.** Alles, was sie von dort braucht — die Collection-Namen, die ihr Spec
bindet, die Skills, die sie laden darf, den Workspace, in den sie schreibt, die
Delegierten, die sie aufrufen darf — löst der Service _vor_ dem Start des Runs auf
und übergibt es als `resources`, ein Dict, das die Capability lesen und dem sie
nichts hinzufügen kann. Das Modell fragt, _was_ zu suchen ist; es erfährt nie, _wo_.

Zwei Einträge in diesem Dict sind Nahtstellen zu anderen Subsystemen und keine
bloßen Daten:

| Resource                    | Hinterlassen vom Runner                                               | Gelesen von                                                     |
| --------------------------- | --------------------------------------------------------------------- | --------------------------------------------------------------- |
| `WORKSPACE_RESOURCE`        | dem geöffneten Workspace, den der Run auch als `ctx.workspace` erhält | `tool_output_limits` und einem Delegierten, der `sandbox` teilt |
| `SUBAGENT_RUNTIME_RESOURCE` | dem aufgelösten Delegationsbaum                                       | der `subagents`-Capability                                      |

Delegation ist der schärfste Fall für diese Regel. Ein Delegierter ist eine Zeile;
ebenso seine gepinnte Version, seine Collections, seine Skills und seine Secrets,
und jedes davon muss `resolve_access` passieren, bevor es gelesen wird. Also läuft
der Runner den ganzen Baum ab — die Verschachtelung, die Tiefengrenze, die
Ablehnung eines Delegierten, der im selben Run bereits darüber läuft —, solange er
noch eine Session und einen Auth-Kontext hält, und hinterlässt Closures, die einen
bereits aufgelösten Agent bauen, plus einen Recorder, der eine Zeile schreibt. Was
zur Laufzeit passiert, ist CPU-Arbeit und Pydantic AI.

Andersherum geht es nicht: Die `AsyncSession` der Anfrage wird von allem im Run
geteilt und ist nicht nebenläufigkeitssicher, ein zur Laufzeit abgelaufener Baum
wäre also eine Abfrage aus einem Tool-Aufruf heraus — und ein Fan-out wären mehrere
davon gleichzeitig, was die Session beschädigt, die der Rest der Anfrage nutzt,
statt bloß langsam zu sein.

Das Fehlen einer Resource ist nie ein Fehler. Eine Vorschau, ein Unit-Test oder ein
Agent, dessen Delegierte alle entfernt wurden, löst nichts auf, und die Capability
bietet dann keine Delegierten an, statt auszulösen — genau so, wie die
Workspace-Capability auf ein Backend im Speicher zurückfällt.

### Schema { #schema }

`0007_delegated_runs` fügt `agent_runs` zwei Spalten hinzu.

**`parent_run_id`** ist ein selbstbezüglicher Fremdschlüssel, der sagt, welcher Run
diesen delegiert hat, und er hält die Monatssumme der Organisation ehrlich — siehe
[Governance](governance.md#what-a-delegated-run-is-recorded-as).

Er ist `ON DELETE SET NULL`, aus derselben Arithmetik heraus: Den Elternteil zu
löschen entfernt die Zeile, die diese Kosten enthielt, eine Delegationszeile, die
oberste Ebene wird, ist also eine, die zu zählen beginnen _soll_. Ein Cascade würde
den Nachweis ausgegebenen Geldes löschen.

**`subagent_task_id`** ist die eigene Task-Id der Delegationsbibliothek, die die
Zeile mit dem Handle verbindet, das das Modell des Elternteils in seinem Transkript
sah. Weil ein Fremdschlüssel nur seine eigene Spalte nullen kann, überlebt dieses
Handle die Löschung und wird von `AgentRunRead` zurückgehalten — statt von einem
Trigger auf der am häufigsten beschriebenen Tabelle des Schemas genullt zu werden.

Der Index auf `parent_run_id` bedient `list_runs(parent_run_id=...)`, was
`GET /runs?parent_run_id=` anfragt. Siehe
[Governance](governance.md#what-run-history-shows) dazu, warum die Run-Historie die
beiden Arten von Zeile nie zusammen auflistet.

## Ein Mitglied oder einen Tenant löschen { #deleting-a-member-or-a-tenant }

Ein paar Fremdschlüssel würden beim Löschen genau den Schreibvorgang erzwingen, den
ein `CHECK`-Constraint verbietet — das vom Schema deklarierte Cascade und die
ebenfalls von ihm deklarierte Invariante widersprechen sich also, und die Löschung
löst innerhalb der Datenbank eine 500 aus, statt irgendetwas zu tun. Drei Paare
werden im Service in Einklang gebracht, bevor die Zeile geht, innerhalb der
Transaktion der Anfrage selbst:

- **Das private Secret einer ausscheidenden Person.**
  `organization_secrets.owner_user_id` ist `SET NULL`, doch
  `ck_secret_private_needs_owner` verbietet ein privates Secret ohne Besitzer.
  `UserService.delete` hebt die privaten Secrets der ausscheidenden Person zuerst
  auf Org-Sichtbarkeit, sodass das Null, das das Cascade schreibt, zulässig ist und
  der Schlüssel für die Organisation erreichbar bleibt, statt gestrandet zu sein.
- **Die Organisationen eines Erstellers.** `organizations.created_by_user_id` ist
  `RESTRICT`, und eine Registrierung erzeugt standardmäßig eine persönliche Org, ein blankes
  `DELETE users` hat für ein echtes Konto also nie funktioniert. Die persönliche Org
  wird mit ihrem Besitzer entfernt; eine geteilte wird an einen anderen Besitzer
  übergeben, oder die Löschung wird abgelehnt, wenn es niemanden gibt, dem sie zu
  übergeben wäre.
- **Eine org-gebundene Collection.** `knowledge_bases.organization_id` ist
  `SET NULL`, doch `ck_knowledge_bases_org_scope_has_org` verbietet eine
  org-gebundene Zeile ohne Org. `OrganizationService.delete` entfernt org-gebundene
  Collections ausdrücklich — samt Vektortabelle —, bevor die Org-Zeile geht; eine
  persönliche Collection, die bloß die Id der Org trägt, bleibt dem `SET NULL`
  überlassen, was ihr Scope erlaubt. Weil das Verwerfen der Vektortabelle den
  anfragegebundenen Store braucht, verdrahtet die Löschroute ihn über eine eigene
  Dependency; jede andere Org-Route nutzt den einfachen Service und baut keinen
  Store, den sie nie anfassen würde.

## Was ein Run seinem Modell übergeben hat, und warum das eine Tabelle ist { #what-a-run-handed-its-model-and-why-it-is-a-table }

`run_manifests` hält eine Zeile je Run: die Instruktionen so, wie sie
zusammengesetzt und gesendet wurden, jede Tool-Definition so, wie der Provider sie
bekam, die Einstellungen, einen Eintrag je Modellanfrage und die Nachrichtenliste
der letzten Anfrage. Sie wird von `AgentRunnerService.finish` auf jedem Weg aus
einem Run heraus geschrieben und von `GET /runs/{id}/manifest` gelesen — siehe
[Konzepte](concepts.md#a-run-and-what-it-handed-the-model) dazu, was festgehalten
wird und warum es sich nicht aus dem Spec rekonstruieren lässt.

Drei Schichtungsentscheidungen lohnen es, aufgeschrieben zu werden, denn jede ist
eine Stelle, an der die naheliegende Alternative falsch ist.

**Eine Tabelle, keine Spalte auf `agent_runs`.** Jene Tabelle wird im Produkt am
häufigsten aufgelistet — Run-Historie, der Ausgaben-Tab, die Dashboard-Zahlen, der
CSV-Export — und ein JSONB-Dokument mit dem JSON-Schema jedes Tools würde von all
diesen gelesen, um eine Frage zu beantworten, die keines von ihnen stellt. Eine
Zeile je Run, `ON DELETE CASCADE` sowohl vom Run als auch von der Organisation, nur
von der Detailansicht gelesen.

**Das Aufzeichnen geschieht in `app/agents/manifest.py`, nicht im Service.** Das
Modell, mit dem der Agent gebaut wird, wird umhüllt (`RecordingModel`, ein
`WrapperModel` — dieselbe Form, die `MeteredModel` nutzt, um die Ausgaben eines
Sub-Agents zu verbuchen), sodass festgehalten wird, was
`ModelRequestParameters` war, als der Provider sie empfing: nach jedem
`prepare`-Hook, nachdem die Tool-Suche verborgen hat, was sie verbirgt, nachdem das
Output-Tool hinzugefügt wurde. Der Service speichert, was der Wrapper gesammelt
hat, und entscheidet nichts über dessen Inhalt.

**Ein Anhang am Transkript wird über den Run gelesen, nicht über seinen
Hochladenden.**

`GET /files/{id}` ist auf `ChatFile.user_id` beschränkt, was der richtige Scope für
den Chat-Composer und der falsche für die Durchsicht eines Runs ist. Einen Run zu
lesen ist das Recht der Organisation und nicht das ihres Starters, also wurden die
Anhangskarten im Transkript einer Kollegin gerendert und jede Vorschau mit 404
beantwortet.

`GET /runs/{run_id}/files/{file_id}` autorisiert so, wie es das Transkript tut —
Organisation, dann `runs:view` — und lässt die Datei dann nur dort zu, wo ihre
`message_id` einen Zug der Unterhaltung des Runs selbst benennt. Genau so weit
reicht das Transkript ohnehin schon, und nicht weiter.

Beide Routen liefern die Bytes über `_chat_file_bytes.py` aus, sodass das, was ein
Browser _anzeigen_ darf, nicht davon abhängt, welche von beiden den Lesezugriff
autorisiert hat.

**Der Schreibvorgang ist abgesichert _und_ geschachtelt.** Er wird aus einem
`finally`-Block erreicht, eine beim Aufzeichnen eines fehlgeschlagenen Runs
ausgelöste Exception würde den Fehlschlag also durch sich selbst ersetzen. Sie zu
schlucken genügt für sich allein nicht: Ein fehlgeschlagener Flush lässt die
Session unbrauchbar zurück, der eigene abschließende Schreibvorgang des Runs ginge
also an eine Aufzeichnung verloren, die niemand verlangt hat. Er läuft aus demselben
Grund in `begin_nested()` wie bei `TranscriptService._attach` — ein SAVEPOINT ist
das, was „dieser Schreibvorgang darf harmlos fehlschlagen“ wahr statt bloß
wünschenswert macht.

## Eine Ablehnung, die ein Feld benennt { #a-refusal-that-names-a-field }

Jede Ablehnung verlässt das System in einer Hülle,
`{"error": {"code", "message", "details"}}`, und eine Ablehnung, die ein _Feld_
betrifft, benennt es in einer Form:

```json
{
  "details": {
    "fields": [
      {
        "field": "spec.name",
        "message": "String should have at most 128 characters"
      }
    ]
  }
}
```

`fieldProblems` in `frontend/src/lib/api-error.ts` liest genau das und sonst
nichts, und das ist es, was ein Formular die fehlerhafte Eingabe markieren lässt,
statt einen Satz zu zeigen, für den der Leser die Seite erneut absuchen muss.
`app/core/field_errors.py` ist die einzige Stelle, an der sie gebaut wird, und sie
hat drei Einstiegspunkte. Zwei davon lesen Pydantic, und **wer der Aufrufer ist,
entscheidet, was das erste Element von `loc` bedeutet**:

|                                            | Für                                                                                                                                                                                                      | `loc` beginnt mit                                                  |
| ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ |
| `request_field_problems`                   | `validation_exception_handler`, jeden `RequestValidationError`                                                                                                                                           | woher der Wert kam (`body`, `query`, …), was verworfen wird        |
| `field_problems(…, root=…)`                | einen Service, der ein Dokument validiert, das das Schema einer Route nicht kann — eine Ingestion-Übersteuerung je Upload, ein von Hand bearbeitetes Spec-YAML, den Config-Blob einer Capability         | einem Feld dieses Dokuments, gemeldet unterhalb von `root`         |
| `refused_field(field, message, **context)` | eine Regel, die ein Service in Prosa statt in einem Modell festhält — einen Endpunkt, der ein Passwort trägt, einen Mattermost-Bot, der seinen Server verliert, ein YAML-Dokument, das nie geparst wurde | — er antwortet mit dem `BadRequestError`, den der Aufrufer auslöst |

`refused_field` benennt den Satz einmal, denn das `message` der Hülle und das des
Feldes sind derselbe Satz; ein Auslöser, der einen anderen Status braucht, baut
dieselben `details` mit `field_details`. Achtzehn Aufrufstellen antworteten
stattdessen mit `details={"field": "<name>"}`, im Singular, mit dem Satz auf der
Hülle, und kein Formular hat es je gelesen — derselbe Defekt in einer dritten Form
([#891](https://github.com/vstorm-co/agenticos/issues/891)). Eine vierte
Schreibweise war `details={"<field>": <value>}`, wo der Schlüssel der Feldname war
und der Wert das, was der Aufrufer gerade gesendet hatte: `model_profile.py`
beantwortete eine abgelehnte Modell-Id mit der Id, im Body und in der Logzeile
daneben ([#898](https://github.com/vstorm-co/agenticos/issues/898)).

Nach der Zeichenkette zu entscheiden würde stattdessen ein Spec falsch lesen,
dessen verbotener Schlüssel auf oberster Ebene buchstäblich `body` heißt — eine
Form, die für zwei Dinge steht, und genau der Fehler, den das Modul beenden soll.

Zwei weitere Eigenschaften sollte man kennen, bevor man eine Aufrufstelle
hinzufügt. Sie liest nur `loc` und `msg`, der abgelehnte Wert kann also nicht neben
dem Feld zurückkommen, das er kaputtgemacht hat, und darum übergeben diese
Aufrufstellen ihr `exc.errors()` ungefiltert. Und `root` ist das, was das Formular
des Aufrufers das ganze Dokument nennt, jeder Pfad ist also relativ dazu: Das gibt
einem `model_validator(mode="after")` einen Landeplatz — er meldet `loc: ()`, weil
die von ihm gebrochene Regel zwei Felder zugleich betrifft — und es bringt die
Einstiegspunkte in Einklang, sodass eine beim Upload abgelehnte Übersteuerung genau
das benennt, was die 422 benennt, wenn dasselbe Paar als die eigenen Einstellungen
einer Collection ankommt.

Pydantics eigenes `exc.errors()` stattdessen durchzureichen war
[#882](https://github.com/vstorm-co/agenticos/issues/882) — eine zweite Form, die
`input`, `ctx` und `url` trug und die im Frontend nichts las.

**Eine gebündelte Ablehnung trägt beide Hälften.**

`validate_spec` meldet jedes Problem eines Specs auf einmal, und die meisten davon
sind kaputte Referenzen ohne eine Eingabe, die zu markieren wäre. Also antwortet es
mit `details.problems` — je eine Zeile, die der Builder auflistet — und mit
`details.fields` für die Teilmenge, die eines benennt.

Wenn sich ein Teil davon mit einem Klick beheben lässt, antwortet es zusätzlich mit
`details.fixes`, einer Liste von Codes: `use_state_workspace` verlegt den Workspace
auf das gespeicherte Backend `state`, das keine Sandbox-Verbindung braucht, und
`stop_sharing_unbound` streicht aus `share_with_delegates`, was der Agent nicht
teilen kann. Der Builder bietet für jeden bekannten Code eine Schaltfläche an und
übergeht den Rest. Eine Korrektur speichert den Entwurf und validiert ihn erneut;
sie veröffentlicht nie.

Die Konfiguration einer Capability ist der eine Teil eines Specs, der als
generiertes Formular gerendert wird, ihre Ablehnungen benennen die Eingabe also:
`capabilities.knowledge.config.default_top_k`, mit `specialists.researcher.` davor
bei einer Capability, die innerhalb eines Delegierten konfiguriert ist, denn der
Builder rendert ein Formular je Spezialist.

Nur den Satz zu behalten war die andere Hälfte von #882. Einen Entwurf zu speichern
validiert ein Config-Schema überhaupt nicht, die Publish-Validierung ist also die
einzige Stelle, an der eine vertippte Einstellung je abgelehnt wird.

**Zwei Arten von Ablehnung benennen bewusst kein Feld**, und die Grenze zwischen
ihnen und dem Rest ist es, was die eine Form davon abhält, wieder zweierlei zu
bedeuten:

- **Eine Ablehnung über einen Wert, den kein Aufrufer gesendet hat.** Der Name einer
  entfernten Datei wird von dem gewählt, der eine Datei in den synchronisierten
  Ordner legen kann, und beide Prüfungen in
  `app/services/rag/remote_names.py` laufen innerhalb einer Hintergrund-Synchronisation,
  wo der Leser ein Log ist und kein Formular. Dasselbe für eine Google-Drive-Quelle,
  die ohne ihre Zugangsdaten zurückgelesen wird: Die Zeile ist gespeichert, und
  `validate_config` des Connectors, abgeleitet aus seinem `CONFIG_MODEL`, ist das,
  was sie an der Route ablehnt.
- **Ein Konflikt.** `AlreadyExistsError` meldet eine Tatsache über eine Zeile, die
  bereits existiert, nicht über die Gestalt des Gesendeten — und welche der eigenen
  Eingaben eines Formulars den belegten Wert erzeugt hat, weiß nur das Formular,
  denn das Handle eines Agents leitet sich aus einem Namen ab, den niemand als
  Handle getippt hat. Das behauptet `identifiedBy` von `submitFailure` auf dem
  Client, eine 409 trägt also den belegten Wert und kein Feld.

## Wichtige Dateien { #key-files }

- Einstiegspunkt: `app/main.py`
- Konfiguration: `app/core/config.py`
- Dependencies: `app/api/deps.py`
- Auth-Hilfsfunktionen: `app/core/security.py`
- Exception-Handler: `app/api/exception_handlers.py`
- Ablehnungen auf Feldebene: `app/core/field_errors.py`

## Authentifizierung und Autorisierung { #authentication-authorization }

### Authentifizierungsverfahren { #authentication-methods }

Das Projekt unterstützt zwei Authentifizierungsverfahren, beide stets verfügbar:

1. **JWT (JSON Web Tokens)** -- Genutzt vom Frontend und von API-Clients.
   - Die Anmeldung über `POST /api/v1/auth/login` liefert `access_token` + `refresh_token`.
   - Access-Tokens laufen nach `ACCESS_TOKEN_EXPIRE_MINUTES` ab (voreingestellt 30 Min.).
   - Refresh-Tokens laufen nach `REFRESH_TOKEN_EXPIRE_MINUTES` ab (voreingestellt 7 Tage).
   - Das Frontend speichert Tokens als HTTP-only-Cookies.
   - Die WebSocket-Auth übergibt das JWT als Query-Parameter (`?token=<jwt>`) oder als Cookie.

2. **API-Key** -- Genutzt für Server-zu-Server- und programmatischen Zugriff.
   - Übergeben über den Header `X-API-Key` (konfigurierbar über `API_KEY_HEADER`).
   - Ein einzelner geteilter Schlüssel, gesetzt über die Umgebungsvariable `API_KEY`.
   - Nutzt einen zeitkonstanten Vergleich (`secrets.compare_digest`), um Timing-Angriffe zu verhindern.

### Wo eine frische Session landet { #where-a-fresh-session-lands }

Drei Türen begründen eine Session auf drei Wegen - das Passwortformular, der
OAuth-Callback und ein Magic Link - und genau eine von ihnen entscheidet, wo der
Besucher landet: `postSignInDestination` in
`frontend/src/lib/auth-landing.ts`, das einen Deep Link nur dann beachtet, wenn er
ein Pfad gleicher Herkunft ist, und sonst das Dashboard antwortet. Drei Antworten
an drei Stellen sind Drift, und die Drift war schon zweimal real: auf der
Rollen-Achse, wo die Landung sich nach Rolle verzweigte, und auf der
Provider-Achse, wo der OAuth-Umweg `?returnTo=` verlor.

Was sich je Tür unterscheidet, ist nur, wie der Pfad _reist_:

| Tür              | Wie der Pfad die Landung erreicht                                                                                                                                   |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Passwortformular | er hat den Tab nie verlassen - direkt aus `?returnTo=` gelesen                                                                                                      |
| OAuth-Callback   | `sessionStorage`, was zulässig ist, weil der Umweg im selben Tab auf dieser Herkunft beginnt und endet                                                              |
| Magic Link       | ein signierter Claim im Token, denn dem Link wird aus einer E-Mail gefolgt - ein anderer Tab, oft eine andere Anwendung, wo `sessionStorage` bauartbedingt leer ist |

Der Pfad des Magic Links wird bei der **Anfrage** abgelehnt statt an der Landung
gefiltert: `MagicLinkRequest.return_to` akzeptiert einen Pfad auf diesem Deployment
und nichts mit einem Schema, einem zweiten führenden Schrägstrich, einem
Backslash oder einem Steuerzeichen, sodass ein Token, das sich auf eine beliebige
Zeichenkette bringen ließe, nie existiert. Die Landung beurteilt ihn trotzdem
erneut - eine Prüfung, die einmal auf dem Server über einen Wert läuft, der danach
durch eine E-Mail reist, ist eine Prüfung, auf deren Stattfinden sich der Client
nicht verlassen kann.

`POST /auth/magic-link/verify` antwortet daher mit `MagicLinkToken` - dem
Token-Paar plus `return_to`, unangewendet. Ein eigenes Schema statt eines
nullbaren Feldes auf `Token`, denn die drei anderen Token-Antworten haben keinen
Rückweg zu tragen, und ein Feld, das bei den meisten von ihnen immer null ist, ist
eines, das ein Client zu ignorieren lernt.

### Autorisierung { #authorization }

Es gibt keine Rollenspalte auf dem Nutzer und keine rollenbasierte
Route-Dependency. Was ein Mitglied innerhalb einer Organisation tun darf, ist eine
Berechtigung aus dem Katalog in `app/core/permissions.py`, und welche Zeilen es
anfassen darf, wird je Zeile aufgelöst - siehe [Berechtigungen](permissions.md) für
das ganze Modell.

Zwei Dependencies, und nur zwei:

| Alias             | Bedeutet                                                                                                |
| ----------------- | ------------------------------------------------------------------------------------------------------- |
| `CurrentUser`     | jeden authentifizierten Nutzer                                                                          |
| `CurrentAppAdmin` | den Superadmin des Deployments (`users.is_app_admin`), für `/admin/*` und die Sammelrouten unter `/rag` |

Alles andere läuft über eines von:

```python
# A permission, on a collection route.
@router.post("/agents", dependencies=[Depends(require(Perm.AGENTS_EDIT))])
async def create_agent(...): ...

# A permission on one row, resolved in the service.
if not await resolve_access(db, ctx, agent, Perm.AGENTS_EDIT, resource_type=AGENT):
    raise AuthorizationError(...)

# A permission decided by a parameter, resolved in the service: scope=org
# demands runs:view, scope=own only a signed-in caller. See Permissions,
# "Where the gates go".
return await service.usage(ctx, scope=scope, ...)
```

!!! note "`require(...)` gehört nicht auf eine Route für eine einzelne Ressource"

    Ein Rollen-Tor kann die Grants auf einer Zeile nicht sehen, es würde einen
    Viewer mit einem ausdrücklichen `edit`-Grant also ablehnen, bevor
    `resolve_access` seinen Zugriff je erweitert hätte. Dieselbe Form gilt, wenn ein
    *Parameter* die Frage entscheidet - `GET /stats/usage?scope=own` muss für ein
    einfaches Mitglied erreichbar sein, sein Tor lebt also im Service.
    `tests/api/test_platform_routes.py` setzt das alles durch.

!!! note "Eine persönliche Einstellung trägt überhaupt kein Tor"

    Eine Zeile mit dem Scope `(user_id, organization_id)`, die nur ihr Besitzer
    liest und schreibt, sind keine Org-Daten, also schützt sie keine Berechtigung
    und es gibt keine Route, die an die von jemand anderem heranreicht.
    `GET`/`PUT`/`DELETE /me/dashboard-layout` (die gespeicherte
    Dashboard-Anordnung) und das `/presets`-Regal darunter (die benannten
    Anordnungen, zwischen denen eine Person wechselt) sind das Muster:
    `CurrentUser` + `ActiveOrg`, jede Abfrage auf **beide** Ids gefiltert. Der
    zusammengesetzte Schlüssel ist die ganze Tenant-Grenze — ein in einer
    Organisation gespeichertes Layout oder Preset ist in einer anderen unsichtbar,
    *sogar für seinen Besitzer*, was eine reine Prüfung je Nutzer durchwinken
    würde, also decken `tests/integration/test_dashboard_layout.py` und
    `tests/integration/test_dashboard_preset.py` genau das ab. Es gibt keine Route
    zum *Anwenden eines Presets*: Eines anzuwenden ist das `PUT` der Einträge des
    Presets als aktive Anordnung durch den Client, das Dashboard behält also einen
    Schreibpfad und eine Validierung für das, was es rendert.

    Eine Platzierung darf außerdem `options` tragen — das eigene Fenster der Karte
    (`period`), die Darstellung (`style`) und die Eingrenzung (`agent_id`,
    `user_id`). **Eine gespeicherte Option ist eine Anfrage, nie eine
    Autorisierung**: Sie erreicht `GET /stats/usage` als Query-Parameter und wird
    dort abgelehnt, wenn der Aufrufer nicht lesen darf, wonach sie fragt — genauso,
    als hätte er die URL getippt. Auf eine Kollegin einzugrenzen heißt, die Zeilen
    von jemand anderem zu lesen, also ist es `scope=org` und hinter `runs:view`;
    `scope=own` mit einer `user_id` ist eine 422 statt einer stillen Umdeutung.
    Beim Schreiben werden der Stil und das Fenster gegen die geschlossenen Mengen
    validiert, die die Frontend-Registry deklariert
    (`tests/test_dashboard_registry.py` hält die beiden Spiegel gleich); beim Lesen
    kommen die Optionen wortgetreu zurück, denn ein inzwischen gelöschter Agent darf
    nicht eine ganze Anordnung mit sich reißen.

`UserRole`, `User.has_role()`, `RoleChecker`, `CurrentAdmin` und
`CurrentSuperuser` waren das Modell des Templates und sind fort, zusammen mit der
Spalte `users.role`, die mit dem Zusammenfassen in `0001_baseline` ging. Sie waren
eine dritte Antwort auf eine Frage, die bereits zwei hatte.

### Schutz vor IDOR { #idor-protection }

Zwei Prädikate, und sie sind nicht austauschbar. **Die Organisation ist es, was
einen Lesezugriff begrenzt; der Nutzer ist es, was ihn weiter einengt.**

- Endpunkte für Unterhaltungen übergeben `organization_id=active_org.id`. Ohne das
  wird eine Unterhaltung allein über den Primärschlüssel gesucht, und jeder
  angemeldete Aufrufer, der eine UUID kennt, liest eine Unterhaltung in einem
  anderen Tenant — oder hängt an sie an.
- Sie übergeben außerdem `user_id=current_user.id`, was eine Zeile auf ihren
  Besitzer oder jemanden beschränkt, mit dem sie geteilt wurde. Die Tenant-Prüfung
  allein genügt nicht: Ohne das kann jedes Mitglied einer Organisation die
  Unterhaltung jedes anderen Mitglieds lesen und daran anhängen.
- **Eine Freigabe trägt das Schreiben nur bei `edit`.** Lesen und Schreiben sind
  zwei Fragen — `_may_read` und `_may_write` — und eine Freigabe beantwortete früher
  beide, welche Stufe sie auch hielt, sodass die beiden Stufen, die der
  Freigabe-Dialog anbietet, dasselbe bedeuteten: Eine zum _Ansehen_ geteilte
  Unterhaltung konnte umbenannt, archiviert, gelöscht oder um einen Zug mit
  `role: "assistant"` ergänzt werden, den in `/chat` alle lesen und den das Modell
  als seine eigenen Worte zurückbekommt. Die Stufe wird dem genannt, der sie
  gewährt, also ist sie die Stufe, die durchgesetzt wird (#931).
- Bei `transcript` erledigt dieses eine Argument zwei Aufgaben — es autorisiert
  _und_ reichert jede Nachricht mit der eigenen Bewertung des Aufrufers an. Diese
  Überladung ist der Grund, warum seine autorisierende Hälfte so lange fehlte: Die
  Route übergab es, das Argument stand im Review klar da, und es erledigte die
  andere Aufgabe.
- Datei-Downloads prüfen `chat_file.user_id == current_user.id`, und eine Datei an
  eine Nachricht anzuhängen trägt denselben Besitzer im `WHERE`: Ein Zug, der die
  Datei-Id eines anderen Nutzers benennt — oder eine Datei, die bereits an einer
  Nachricht hängt —, wird abgelehnt, nie still angewendet.

`ConversationService` macht es unmöglich, die Unterscheidung auszulassen:
`organization_id` ist ein **verpflichtendes** `UUID`-Keyword bei jedem Lesen und
Schreiben einer Unterhaltung. Früher war es auf `None` voreingestellt, `None` hieß
ungebunden, und eine Auslassung ist von einer Absicht nicht zu unterscheiden — zwei
Routen, die gewöhnliche Mitglieder bedienen, ließen es schlicht weg, und jeder
angemeldete Nutzer konnte jede Unterhaltung im Deployment lesen und daran anhängen.

### Ein Favorit gehört dem Leser, nicht dem Thread { #a-favourite-belongs-to-the-reader-not-to-the-thread }

`conversation_favourites` ist eine Zeile je `(user_id, conversation_id)` und kein
Boolean auf `conversations`, denn eine Unterhaltung kann geteilt werden und ein
Channel-Thread hat Teilnehmer statt eines Besitzers: Eine Spalte ließe den Stern
einer Person darüber entscheiden, wo der Thread für alle sitzt, die ihn sehen
können.

Vier Konsequenzen, die man kennen sollte:

- **`POST`/`DELETE /conversations/{id}/favourite` werden als _Lesezugriff_
  autorisiert.** Ein Stern sagt, wo ein Thread in der eigenen Seitenleiste des
  Sternvergebers sitzt, und ändert nichts am Thread, also darf jemand, mit dem eine
  Unterhaltung geteilt wurde, sie genauso mit einem Stern versehen wie ihr
  Besitzer. `for_write` würde dort genau dem Leser etwas verweigern, für den es
  das Feature gibt. Beide Routen tragen `Auth`, aus demselben Grund wie jeder andere
  Lesezugriff auf eine: Ohne Kontext antwortet `_may_read_trigger_log` mit falsch,
  und das Run-Log eines Triggers, das der Aufrufer über `runs:view` öffnen kann,
  wäre eines, das er nicht mit einem Stern versehen könnte (#1254).
- **`is_favourite` gehört dem Aufrufer, und es wird in `get_conversation`
  gestempelt** — dem einen Lesezugriff, durch den jeder leser-gebundene läuft,
  statt an jeder Route. Es erreichte zwei von acht Antworten, solange jede Route
  daran denken musste, sodass ein `GET` oder ein PATCH jemandem, der einen Thread
  mit einem Stern versehen hatte, sagte, er habe es nicht getan (#1254). Ein
  Lesezugriff ohne Leser — die Admin-Auflistung, der Run-Pfad, der einen Thread
  auflöst — fragt nach niemandes Sternen und zahlt keine Abfrage, um das zu sagen,
  und ein Lesezugriff, der nur _autorisiert_, schaltet es ausdrücklich mit
  `include_favourite=False` ab. Das sind die Lesezugriffe, deren Ergebnis verworfen
  wird oder keine Unterhaltung ist: `GET /conversations/{id}/messages`, das den
  Thread einmal über `transcript` auflöst, für die Seite und ihre Kosten; die drei
  Workspace-Routen; jeder Zug eines bestehenden Chats, über `agent._resolve_in_org`;
  und die Schreibzugriffe — `add_message`, `delete_conversation` und
  `set_favourite`, das das Flag selbst überschreibt. Standardmäßig an ist es, was
  eine Route, die eine Unterhaltung _tatsächlich_ serialisiert, vom Vergessen
  abhält; aus ist eine bewusste Handlung an der Aufrufstelle.
- **Einen Stern zu setzen ist unter Konkurrenz idempotent**, denn das Insert ist
  `ON CONFLICT DO NOTHING` und kein Lesen gefolgt von einem Insert. Zwei sich
  überlappende POSTs für dasselbe Paar sahen beide keine Zeile, und das zweite
  verletzte den Primärschlüssel; der Client serialisiert seinen eigenen ausstehenden
  Stern außerdem je Unterhaltung, sodass ein Doppelklick das DELETE nicht vor dem
  POST beantwortet bekommen kann, auf das es folgte.
- **Das Band ist ein `ORDER BY`, keine Gruppierung der Seite.** Die Seitenleiste ist
  seitenweise, ein nach Aktualität auf Seite zwei sortierter Favorit säße also unter
  fünfzig Threads, die keiner sind. Innerhalb jedes Bandes gilt die gewählte
  Sortierung weiterhin, und die Archivansicht ist gar nicht gebändert: Ein Stern
  überlebt das Archivieren, aber ein Band innerhalb des Archivs wäre eine zweite
  Stelle, an der man suchen müsste, was das Archivieren gerade verschoben hat.

Es gibt **keine Möglichkeit mehr, eine Unterhaltung über Tenants hinweg zu lesen.**
Das Sentinel, das das früher ausbuchstabierte (`UNSCOPED`), hatte genau einen
Aufrufer, `/admin/conversations/{id}`, und beide gingen mit dem deploymentweiten
Unterhaltungs-Browser — **Aktivität** (Activity) beantwortet „was ist passiert“ mit den Kosten, dem
Modell, dem Trace und dem, was dem Modell daneben übergeben wurde, und das ist die
Frage, für die jener Bildschirm genutzt wurde. Übrig ist davon
`GET /admin/conversations?user_id=`: die Threads eines benannten Kontos, für die
Admin-Nutzerlade aufgelistet und nie gelesen.

### Was die Admin-Nutzerlade anfragt { #what-the-admin-user-drawer-asks-for }

`GET /admin/users/{id}/detail` ist eine eigene Route statt Felder auf
`GET /admin/users/{id}`, denn es ist eine **Sicht**, aus drei Tabellen
zusammengesetzt - Mitgliedschaften, Sessions und die Nutzerzeile - und ein Nutzer
wird an einem Dutzend Stellen gelesen, die nichts davon brauchen.

Es gibt sie, weil die Lade keine der Fragen beantwortete, die eine Admin beim
Öffnen einer Zeile tatsächlich hat: Sie zeigte die Id, die E-Mail-Adresse, die
ohnehin in der Tabelle stand, den Anzeigenamen und ein Beitrittsdatum (#942). Was
sie jetzt beantwortet, ist, wo diese Person Zugang hat und mit welcher Autorität,
wann sie zuletzt hier war und ob noch irgendetwas von ihr angemeldet ist.
`last_seen_at` ist **null statt abwesend** für ein Konto, das sich nie angemeldet
hat, denn „angelegt und nie genutzt“ und „seit März untätig“ sind verschiedene
Entscheidungen.

Die ganze Route ist `CurrentAppAdmin`: Jedes Feld darauf handelt von jemand anderem.

Die vollständigen Berechtigungen auf Endpunktebene stehen in `docs/permissions.md`.

## Dateiverarbeitung im Chat { #file-processing-in-chat }

Wenn ein Nutzer im Chat eine Datei hochlädt, läuft die folgende Pipeline:

```
Upload (POST /files/upload)
  -> Validate (MIME type + size)
  -> Classify (image / pdf / docx / text)
  -> Parse (extract text content)
  -> Store (save to media/{user_id}/)
  -> Record (create ChatFile in DB)
  -> Link (attach to message when sent)
```

### Unterstützte Dateitypen { #supported-file-types }

| Kategorie | Endungen             | Verarbeitung                                                           |
| --------- | -------------------- | ---------------------------------------------------------------------- |
| Bilder    | JPEG, PNG, WebP, GIF | Unverändert gespeichert, als Binärdaten für Vision an das LLM gesendet |
| PDF       | .pdf                 | Text über den konfigurierten Parser extrahiert                         |
| Dokumente | .docx                | Text über python-docx extrahiert                                       |
| Text      | .txt, .md            | Direkt als UTF-8 dekodiert                                             |

### Wahl des Parsers { #parser-selection }

Chat-Anhänge werden mit PyMuPDF gelesen und sind nicht konfigurierbar: Ein Anhang
gehört zu keiner Collection, es gibt also keine gespeicherte Konfiguration, aus der
sich eine Parser-Wahl lesen ließe. Die Wahl des Parsers gilt für
Knowledge-Collections, wo sie eine Einstellung je Collection ist.

### Ablage { #storage }

Dateien werden über `FileStorageService` unter `media/{user_id}/` gespeichert. Das
Modell `ChatFile` speichert `storage_path`, `filename`, `mime_type`, `size`,
`file_type` und `parsed_content` (den extrahierten Text). Nur der Besitzer einer
Datei kann auf seine Dateien zugreifen.

### Größenbeschränkungen { #size-limits }

Es gibt zwei, weil es zwei Oberflächen gibt. `MAX_UPLOAD_SIZE_MB` (voreingestellt
50 MB) ist die Obergrenze für Dokumente der Knowledge Base;
`CHAT_MAX_UPLOAD_SIZE_MB` (voreingestellt 10 MB) ist das, was im Chat angehängt
werden darf. Es sind getrennte Einstellungen statt einer, denn ein Dokument wird
gechunkt und über Retrieval zurückgelesen, während ein Anhang an einen Agent ohne
Workspace ganz in den Prompt eingefügt wird — dieselbe Größe schlägt bei jedem
anders fehl. `GET /api/v1/health` veröffentlicht beide.

## Das RAG-System { #rag-system }

### Überblick über die Architektur { #architecture-overview }

Das RAG-System (Retrieval Augmented Generation) stellt eine Knowledge Base bereit,
die der KI-Agent während einer Unterhaltung durchsuchen kann. Es besteht aus:

```
Documents -> Parse -> Chunk -> Embed -> Vector Store
                                            |
User Query -> Embed -> Search -> Rerank? -> Results -> Agent Prompt
```

### Grundsatz: RAG ist global { #key-principle-rag-is-global }

**Collections werden von ALLEN Nutzern geteilt.** Es gibt keine Trennung der
Dokumente je Nutzer. Das heißt:

- Jeder authentifizierte Nutzer kann jede Collection **durchsuchen**.
- Nur **Admins** können Collections anlegen/löschen, Dokumente hochladen,
  Sync-Quellen konfigurieren und Sync-Logs ansehen.
- Die Knowledge Base dient als organisationsweit geteilte Ressource.

### Komponenten { #components }

| Komponente          | Datei                | Zweck                                                           |
| ------------------- | -------------------- | --------------------------------------------------------------- |
| `DocumentProcessor` | `rag/documents.py`   | Parst Dateien zu Text (PDF, DOCX, TXT, Bilder)                  |
| `IngestionService`  | `rag/ingestion.py`   | Orchestriert parse -> chunk -> embed -> store                   |
| `RetrievalService`  | `rag/retrieval.py`   | Behandelt Suchanfragen mit Filterung und Bewertung              |
| `EmbeddingService`  | `rag/embeddings.py`  | Erzeugt Embeddings über den konfigurierten Provider             |
| `BaseVectorStore`   | `rag/vectorstore.py` | Abstrakte Schnittstelle für Operationen auf der Vektordatenbank |
| `PgVectorStore`     | `rag/vectorstore.py` | Implementierung für pgvector (PostgreSQL)                       |

### Die Ingestion-Pipeline { #ingestion-pipeline }

Dokumente können auf diesen Wegen aufgenommen werden:

1. **CLI** -- `uv run agenticos cmd rag-ingest <path>`
2. **API** -- `POST /api/v1/rag/collections/{name}/ingest` (nur Admins, Datei-Upload)
3. **Sync-Quellen** -- Konfigurierte Connectors (Google Drive, S3, Websites), die
   Dokumente nach Zeitplan oder auf Abruf holen und entfernen, was ihre Quelle
   nicht mehr enthält.

Jedes aufgenommene Dokument wird:

- Zu Text geparst (Parser je Collection gewählt, je Upload übersteuerbar)
- In Chunks zerlegt (`chunk_size` / `chunk_overlap`, ebenfalls je Collection)
- Über den konfigurierten Embedding-Provider eingebettet
- In der Vektordatenbank gespeichert
- In SQL über das Modell `RAGDocument` mit Status verfolgt (`processing`, `done`, `error`)

### Sync-Modi { #sync-modes }

| Modus         | Verhalten                                                                                             |
| ------------- | ----------------------------------------------------------------------------------------------------- |
| `full`        | Alle Dokumente ersetzen (alles neu aufnehmen)                                                         |
| `new_only`    | Neue Dateien hinzufügen, Dateien mit geändertem Inhalts-Hash neu aufnehmen, unveränderte überspringen |
| `update_only` | Nur geänderte Dateien neu aufnehmen, neue Dateien vollständig überspringen                            |

### Sync-Connectors { #sync-connectors }

Entfernte Dokumentquellen nutzen einsteckbare Connectors in
`app/services/rag/connectors/`. Jeder Connector implementiert `BaseSyncConnector`
mit `list_files()` und `_fetch()`, deklariert ein `SECRET_KIND`, das das
Vault-Secret benennt, mit dem er sich authentifiziert, und deklariert ein
`CONFIG_MODEL` - ein Pydantic-Modell, das sagt, wie die Dokumente zu finden sind,
dem Assistenten als JSON Schema veröffentlicht. `download_file()` ist
konkret und entscheidet, wo eine Datei landen darf. Siehe `docs/patterns.md` dazu,
wie man einen hinzufügt, und `docs/howto/add-sync-connector.md` für ein
durchgearbeitetes Beispiel.

## Zusammenfassung { #recap }

- **Routes → Services → Repositories.** Eine Route importiert nie ein Repository.
- Ein Repository nutzt `db.flush()` und `db.refresh()`, **nie** `db.commit()`. Die
  Session der Anfrage committet einmal, bevor die Antwort geschrieben wird.
- Der Pfad eines Agent-Runs ist die wichtigste erlaubte Ausnahme: Er committet vor
  dem Modellaufruf und erneut im abschließenden `finally`. `MLService._record_failure`
  ist die andere, aus dem spiegelbildlichen Grund — ein Nutzungsdatensatz über eine
  _Ablehnung_ muss das Rollback überleben, das diese Ablehnung auslöst.
- Zwei erlaubte Ausnahmen. Der Pfad eines Agent-Runs committet vor dem
  Modellaufruf und erneut im abschließenden `finally`;
  `SessionService.detect_refresh_reuse` committet die soeben widerrufene Session
  und den Eintrag, der sagt warum — denn sein Aufrufer wirft unmittelbar danach
  einen 401, und das Zurückrollen würde beides rückgängig machen.
- Ein Webhook, dessen Graph seine Zustellungen beantwortet, committet die
  Zulassung in `WorkflowExposureService._answer`, damit der Run starten kann,
  während die Anfrage auf ihren Schritt Respond to webhook wartet.
- Hintergrundarbeit, die geschehen muss, ist ein **Job, eingereiht in der
  Transaktion der Anfrage** (`enqueue`); Arbeit ohne Garantie, die eine Zeile
  liest, die diese Anfrage geschrieben hat, wird mit **`spawn_after_commit`**
  übergeben, nie mit `spawn`.
- Eine dünne Domäne ist ein Modul; eine dicke ist ein Subpackage mit einer Fassade,
  und nichts außerhalb importiert deren Sub-Module.

Telefonaktionen benötigen ebenfalls eine dauerhafte Grenze: `telephony.runtime.dial`, Eingangsannahme, Weiterleitung und Auflegen bestätigen ihre Absicht vor dem Provideraufruf. Unklare Annahme bleibt für signierte Callbacks und Bereinigung gespeichert, ohne automatische Neuwahl. Die Medienannahme bestätigt ihren einmaligen Anspruch vor der Modellsitzung.