Ctrl K
ComunidadGuías…

docs/architecture.pl.md

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

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

# Architektura { #architecture }

Ten projekt trzyma się architektury warstwowej **Repository + Service**. Każda
funkcja — użytkownicy, rozmowy, pliki, dokumenty RAG, źródła synchronizacji —
korzysta z tego samego wzorca:
**Models → Schemas → Repositories → Services → Endpoints**.

## Przepływ żądania { #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])
```

Route'y nigdy nie zawierają bezpośrednich wywołań bazy danych. Cały dostęp do
danych idzie przez serwisy, które z kolei delegują do repozytoriów.

!!! info "To test, a nie konwencja"

    `backend/tests/test_route_layering.py` nie przechodzi, jeśli route importuje
    repozytorium — i nie przechodzi tak samo głośno, jeśli jego lista dozwolonych
    trzyma wyjątek, który już nie obowiązuje.

Zasada rozjechała się w pięciu modułach, zanim cokolwiek zaczęło jej pilnować —
w żadnym nie był to wyciek, bo każdy handler przekazywał ten zakres, który
akurat znał. I to jest właśnie koszt: zakres, którego właścicielem jest route, to zakres,
którego nie widzi żaden test serwisu, a następny czytelnik encji musi wiedzieć, że ma
przekazać to samo. Jedynym wyjątkiem jest `Literal` z porządkami sortowania,
importowany jako typ, a nie jako dostęp do danych.

## Struktura katalogów (`backend/app/`) { #directory-structure-backendapp }

| Katalog / plik        | Do czego służy                                                  |
| --------------------- | --------------------------------------------------------------- |
| `api/routes/v1/`      | Endpointy HTTP, walidacja żądań, uwierzytelnianie               |
| `api/deps.py`         | Wstrzykiwanie zależności (sesja bazy, bieżący użytkownik)       |
| **`services/`**       | **Logika biznesowa, orkiestracja**                              |
| ↳ `user.py`           | CRUD użytkownika, aktualizacje profilu                          |
| ↳ `conversation.py`   | Zarządzanie rozmowami i wiadomościami                           |
| ↳ `message_rating.py` | CRUD ocen wiadomości, statystyki, eksport                       |
| ↳ `file_upload.py`    | Obsługa wgrywania plików w czacie                               |
| ↳ `file_storage.py`   | Abstrakcja przechowywania plików (lokalnie / S3)                |
| ↳ `rag_document.py`   | Cykl życia dokumentu RAG                                        |
| ↳ `rag_sync.py`       | Orkiestracja synchronizacji ze zdalnym źródłem                  |
| ↳ `sync_source.py`    | CRUD źródeł synchronizacji i historia przebiegów jednego źródła |
| ↳ `audit.py`          | Odczyt śladu audytowego własnej organizacji wywołującego        |
| **`repositories/`**   | **Warstwa dostępu do danych, zapytania do bazy**                |
| ↳ `user.py`           | Zapytania o użytkowników                                        |
| ↳ `conversation.py`   | Zapytania o rozmowy                                             |
| ↳ `chat_file.py`      | Zapytania o pliki czatu                                         |
| ↳ `message_rating.py` | Zapytania o oceny wiadomości                                    |
| ↳ `rag_document.py`   | Zapytania o dokumenty RAG                                       |
| ↳ `sync_log.py`       | Zapytania o logi synchronizacji                                 |
| ↳ `sync_source.py`    | Zapytania o źródła synchronizacji                               |
| **`schemas/`**        | **Modele żądań/odpowiedzi Pydantic**                            |
| ↳ `user.py`           | Schematy użytkownika                                            |
| ↳ `conversation.py`   | Schematy rozmów i wiadomości                                    |
| ↳ `file.py`           | Schematy wgrywania plików                                       |
| ↳ `message_rating.py` | Schematy ocen wiadomości                                        |
| ↳ `rag.py`            | Schematy zapytań/odpowiedzi RAG                                 |
| ↳ `sync_source.py`    | Schematy źródeł synchronizacji                                  |
| **`db/models/`**      | **Modele SQLAlchemy 2.1**                                       |
| ↳ `user.py`           | Model użytkownika                                               |
| ↳ `conversation.py`   | Modele rozmów i wiadomości                                      |
| ↳ `chat_file.py`      | Model pliku czatu                                               |
| ↳ `message_rating.py` | Model oceny wiadomości                                          |
| ↳ `webhook.py`        | Model webhooka                                                  |
| ↳ `rag_document.py`   | Model dokumentu RAG                                             |
| ↳ `sync_log.py`       | Model logu synchronizacji                                       |
| ↳ `sync_source.py`    | Model źródła synchronizacji                                     |
| `core/config.py`      | Ustawienia przez pydantic-settings                              |
| `core/security.py`    | Narzędzia do JWT / kluczy API                                   |
| `agents/`             | Agenci AI i narzędzia                                           |
| `rag/`                | Moduł RAG (embeddingi, magazyn wektorów, retrieval)             |
| `rag/connectors/`     | Konektory synchronizacji (Google Drive, S3, strony internetowe) |
| `commands/`           | Komendy CLI w stylu Django                                      |

## Odpowiedzialności warstw { #layer-responsibilities }

### Route'y API (`api/routes/v1/`) { #api-routes-apiroutesv1 }

- Obsługa żądania i odpowiedzi HTTP
- Walidacja wejścia przez schematy Pydantic
- Sprawdzenia uwierzytelnienia i autoryzacji
- **Nigdy** nie zawierają bezpośrednich wywołań bazy — zawsze delegują do serwisu
- **Nigdy** nie parsują niezaufanego wejścia w wyrażeniu route'a. `ValidationError`
  podniesiony tam jest `ValueError`, ale nie `RequestValidationError`, więc żaden
  handler go nie mapuje i wywołujący dostaje 500 z `details: null` — i tak
  właśnie każdy błąd w ręcznie edytowanym YAML-u speca był raportowany jako
  awaria (#873). Parsowanie należy do serwisu, który jest właścicielem, i tak
  samo odmowa: `import_spec` na `AgentRegistryService` odpowiada na zepsuty
  dokument błędem 400, który nazywa pole, i nigdy nie cytuje wywołującemu tego,
  co ten przysłał.

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

- Logika biznesowa i walidacja
- Orkiestrują jedno lub więcej wywołań repozytorium
- Podnoszą wyjątki domenowe (`NotFoundError`, `AlreadyExistsError` itd.)
- Zarządzają granicami transakcji

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

- Wyłącznie operacje na bazie danych
- Żadnej logiki biznesowej
- Używają `db.flush()`, a nie `commit()` — transakcja należy do sesji żądania,
  która [commituje ją przed wysłaniem odpowiedzi](#the-requests-transaction)
- Zwracają modele domenowe

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

- Osobne modele `Create`, `Update` i `Response` na encję
- Schematy `Response` używają `model_config = ConfigDict(from_attributes=True)` do konwersji z ORM

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

- Definicje modeli SQLAlchemy 2.1
- Tu mieszkają relacje, indeksy i domyślne wartości kolumn

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

- Wymienne adaptery synchronizacji implementujące `BaseSyncConnector`
- Każdy konektor dostarcza `list_files()` i `download_file()`
- Rejestrowane w `CONNECTOR_REGISTRY`, żeby dało się je znaleźć w czasie działania

## Transakcja żądania { #the-requests-transaction }

Jedno żądanie, jedna sesja, jedna transakcja, commitowana w jednym miejscu — a to
miejsce liczy się tak samo jak sam fakt.

Route prosi o `DBSession` (`app/api/deps.py`), co rozwiązuje się do
`get_db_session` (`app/db/session.py`). Wszystko poniżej route'a dzieli tę jedną
sesję: serwisy przyjmują ją w konstruktorze, repozytoria jako pierwszy argument,
i ani jedne, ani drugie nigdy nie wołają `commit()` — z jednym celowym wyjątkiem,
ścieżką runu agenta i rozmową głosową, która też jest runem, opisanymi
[niżej](#the-run-paths-two-commits). `flush()`
wysyła instrukcje, żeby wiersz miał id, a ograniczenia zostały sprawdzone; commit
następuje raz, po drodze na zewnątrz.

**Po drodze na zewnątrz znaczy: zanim odpowiedź zostanie zapisana.** Alias
deklaruje `Depends(get_db_session, scope="function")`, co rejestruje kod wyjścia
sesji na stosie wyjścia, który FastAPI rozwija między powrotem z operacji ścieżki
a `await response(scope, receive, send)`. Kolejność dla żądania jest więc taka:

1. route zwraca sterowanie, a `response_model` serializuje to, co zwrócił;
2. transakcja się commituje — albo, jeśli cokolwiek podniosło wyjątek, wycofuje;
3. startuje praca w tle odroczona przez żądanie (niżej);
4. odpowiedź zostaje zapisana do gniazda;
5. sesja zostaje zamknięta.

```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 "2xx znaczy, że zapis jest do odczytania, a nie tylko przyjęty"

    Ta kolejność to cały kontrakt. Gołe `Depends(get_db_session)` gdziekolwiek
    przywraca domyślne zachowanie FastAPI i zamienia kroki 2 i 4 miejscami —
    `tests/api/test_db_session_scope.py` nie przechodzi, gdy takie znajdzie.

Ta kolejność jest tym, co pozwala klientowi działać na podstawie własnej
odpowiedzi. Domyślnym zakresem FastAPI dla zależności z `yield` jest
`scope="request"`, który ustawia kroki 2 i 4 na odwrót — i tak było tutaj do
[#353][353], gdzie przyjęcie zaproszenia odpowiadało 204, podczas gdy utworzony
przez nie wiersz członkostwa pozostawał niewidoczny dla kolejnego żądania przez
21,7 ms, a token zaproszenia był zużywany 34 ms przed commitem transakcji, która
go wybiła.

Trzy konsekwencje, które warto znać, zanim napiszesz route:

- **Commit, który się nie powiedzie, to 500, a nie wiersz w logu.** Odpowiedź nie
  została jeszcze zapisana, więc odroczone ograniczenie albo utracone połączenie
  dociera do klienta jako błąd, zamiast być odkryte za wysłanym już 2xx. Krok 3
  też się nie wykonuje: praca czekająca na transakcję, której nie było, zostaje
  porzucona, z ostrzeżeniem, które ją nazywa.
- **Cokolwiek połyka błąd bazy danych, musi zresetować sesję.** Instrukcja, która
  podniosła wyjątek, zostawia swoją transakcję w stanie przerwanym, a commit
  w kroku 2 też podnosi wyjątek. Sondy zdrowia (`app/services/health.py`) to ten
  przypadek w kodzie: celowo nie propagują błędu, więc wycofują transakcję przed
  zwróceniem wyniku.
- **Ciało odpowiedzi wytwarzane w trakcie jej wysyłania potrzebuje innej sesji.**
  `StreamingResponse` nad generatorem jest iterowana w kroku 3, a wtedy sesja jest
  już zamknięta. Te endpointy biorą `StreamingDBSession`, która zachowuje domyślny
  zakres FastAPI i dlatego jest tylko do odczytu: jej transakcja rozstrzyga się po
  tym, jak klient dostał odpowiedź. Używa jej dokładnie jeden endpoint — eksport
  ocen do CSV — a `tests/api/test_db_session_scope.py` odrzuca drugi, dopóki nie
  zapadnie co do niego decyzja.

Praca, która przeżywa żądanie, w ogóle nie używa tej sesji. Handlery WebSocketów
i komendy CLI otwierają `get_db_context()`, a zadania workera
`get_worker_db_context()`; wszystkie trzy idą przez to samo `_managed_session`,
więc commitują przy czystym wyjściu z własnego `async with` i tam samo startują
swoją odroczoną pracę — co nie ma nic wspólnego z odpowiedzią.

### Dwa commity ścieżki runu { #the-run-paths-two-commits }

Jedna ścieżka celowo commituje wcześniej niż „po drodze na zewnątrz”:
**run agenta.**

Runner commituje raz _przed wywołaniem modelu_ i jeszcze raz w końcowym
`finally` — `AgentRunnerService._run` oraz `ChatAgentRunner.run` dla
strumieniowanego czatu.

Wywołanie modelu trwa od sekund do minut, a transakcja zostawiona na ten czas
otwarta trzyma połączenie z puli w stanie `idle in transaction` przez cały ten
okres. Piętnaście równoległych runów potrafiło zająć całą pulę ([#12][12]).

Wcześniejszy commit kupuje jeszcze dwie rzeczy: wiersz runa jest do odczytania
z każdej innej sesji przez całe życie runa, a wyjście wznowionego runa z kolejki
zatwierdzeń jest trwałe, zanim zatwierdzone wywołanie zostanie odtworzone — więc
awaria w trakcie odtwarzania nie może wydać tego samego zatwierdzenia dwa razy
([#3][3]).

Końcowy commit to druga połowa. Kontekst sesji commituje tylko przy czystym
wyjściu, a run zakończony błędem, zatrzymany przez budżet albo anulowany czystym
wyjściem nie jest — a run, którego brakuje w historii, to run, za który nikt nie
odpowiada.

Obie granice są dowodzone na prawdziwej bazie danych w
`tests/integration/test_run_commit_boundary.py`.

**Rozmowa głosowa jest runem i zachowuje te same dwa commity** ([#54][54]).
Rozmowa trzyma swój socket do pięćdziesięciu minut, więc commituje tam, gdzie
ścieżka runu, a wszystko pomiędzy zapisuje przez własne sesje:

- `VoiceCallStarter.start` commituje raz, gdy wszystkie odmowy zostały już
  sprawdzone, z zapisanym wierszem `voice_calls` i jego runem, zanim provider
  zostanie wywołany. Wiersze zatwierdzeń, heartbeat i sprzątanie rozmów
  głosowych potrzebują tych wierszy czytelnych z innych sesji; odmowa przed tym
  punktem wycofuje wszystko, więc odrzucona rozmowa nie zostawia niczego
  w **Aktywności** (Activity).
- Każda zapisana tura, każdy 15-sekundowy heartbeat i każdy zapis zatwierdzenia
  otwiera własną sesję `get_db_context()` i tam commituje, nigdy na wspólnej
  sesji runu — tura jest trwała w chwili, gdy zostanie wypowiedziana, a awaria
  traci co najwyżej turę w toku.
- Sprzątanie `VoiceCallSession`, osłonięte `finally`, zapisuje koniec w
  wierszu rozmowy, potem rozłącza wywołanie providera, kończy run i commituje
  ponownie — końcowy commit, na każdej drodze wyjścia. Koniec idzie pierwszy,
  żeby ponowne połączenie, które na niego czeka, nie czekało też na
  rozłączenie.

`tests/test_voice_starter.py::test_a_started_call_is_committed_with_its_row_and_a_voice_run`
i `tests/test_voice_session.py::test_every_exit_path_hangs_up_the_provider_call`
pilnują obu końców.

### Drugi wczesny commit { #the-one-other-early-commit }

`SessionService.detect_refresh_reuse` jest drugim, i to z odwrotnego powodu: nie
dlatego, że transakcja byłaby trzymana zbyt długo, ale dlatego, że zaraz zostanie
wyrzucona. Refresh token, który nie pasował do żadnej żywej sesji, może być
odtworzeniem tego, który sesja właśnie zrotowała — odpowiedzią jest zakończenie
tego łańcucha i zapisanie tego, a potem odmowa wywołującemu, która podnosi
`AuthenticationError` przez gałąź _wyjątku_ kontekstu sesji i wycofuje żądanie.

Bez commitu jest to 401, wciąż żywy skompromitowany łańcuch i żaden ślad, że
cokolwiek się wydarzyło. `test_the_response_survives_the_refusal_that_follows_it`
wycofuje transakcję po wywołaniu i sprawdza, co zostało
([#1519](https://github.com/vstorm-co/agenticos/issues/1519)).

### Webhook, który czeka na swoją odpowiedź { #a-webhook-that-waits-for-its-answer }

`WorkflowExposureService._answer` jest trzeci. Webhook, którego graf zawiera krok
Respond to webhook, odpowiada nadawcy tym, co ten krok zapisze, więc żądanie musi
czekać na run, który jeszcze nie wystartował - a nie wystartuje, dopóki przyjęcie
nie będzie widoczne dla workera. Serwis zatwierdza run i rekord dostarczenia - pierwszy job dispatchu runu jest
zatwierdzany razem z nimi, a `NOTIFY` budzący workera odpala się przy tym commicie

- a potem odpytuje odpowiedź runa. Po commicie nic nie jest zapisywane, więc
  zamykający commit nie ma już nic do zrobienia. `TestAnsweringTheSender` prowadzi
  run przez prawdziwy claim joba.

Widoczność tnie w obie strony. Cokolwiek rozumowało wcześniej „wiersza
wykonującego się runa nie da się zobaczyć”, rozumuje teraz o wierszu, który
_jest_ widziany, a scheduler triggerów agenta to jedyne miejsce, które tak
rozumowało.

Jego strażnik braku nakładania blokuje na każdym nieterminalnym runie w rozmowie
triggera — co teraz obejmuje też żywy run równoległego `run_now` albo odpalenia
zdarzeniem, czyli ochronę, której dawna niewidoczność nie mogła dać.

Tymczasem worker, który umiera w trakcie runu, zostawia wiersz `running`, którego
nic w procesie już nie dokończy. Proces wykonujący run oznacza go jako żywy co
`RUN_HEARTBEAT_SECONDS`, a wiersz, który przestał dawać znak życia, ogranicza
zamiatanie przeterminowanych runów co minutę, które kończy go jako `failed` po
przekroczeniu `STALE_RUN_REAPED_AFTER_SECONDS`. Własnym sygnałem życia zaplanowanego odpalenia
pozostaje odnawiana dzierżawa (`app/repositories/agent_trigger.py::claim_due`).

[Governance](governance.md#a-run-whose-process-died) mówi, co to zamiatanie
rozstrzyga, a czego celowo nie rusza.

### Zlecanie pracy w tle z żądania { #dispatching-background-work-from-a-request }

**Praca, która musi się wydarzyć, jest zadaniem kolejkowanym we własnej
transakcji żądania.** `app.services.background_jobs.enqueue` zapisuje wiersz
zadania w sesji żądania, więc zostaje zatwierdzony razem z wierszami, które
wskazuje, albo wcale:

```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)
```

Wycofane żądanie niczego nie kolejkuje, a zatwierdzone budzi workery puli przez
`NOTIFY`, które PostgreSQL dostarcza dopiero przy commicie - więc żaden worker
nie pobierze zadania, zanim istnieje wiersz, który ono czyta. To zamyka
[#417][417] przez samą konstrukcję: wgranie odpowiadające
`{"status": "processing"}`, które już takie zostawało, bo praca ruszała przed
commitem i jej własna sesja nie widziała dokumentu. Wgranie, synchronizacja
uruchomiona przez kogoś, każde odpalenie triggera - z harmonogramu, z
dostarczenia albo „run now” - asynchroniczny run przez API i sprzątanie tego, co
leży poza bazą, po usunięciu, są przekazywane właśnie tak.

Ręczne odpalenie triggera jest tu z drugiego powodu, który warto nazwać: `POST
/agents/{id}/triggers/{id}/run` kiedyś _czekał_ na run, który uruchomił, więc
agent wolniejszy niż limit czasu odczytu proxy odpowiadał 504, podczas gdy run
trwał dalej i się zatwierdzał - błąd zgłoszony dla czegoś, co działało, i
zaproszenie, żeby nacisnąć przycisk jeszcze raz ([#658][658]). Trasa odpowiada
`202`, gdy zadanie jest już w kolejce.

**Usunięcie, którego dane wejściowe niszczy jego własny commit, kolejkuje
sprzątanie w tym commicie.** Usunięcie organizacji przekazuje dalej ścieżki w
magazynie i nazwy kolekcji, których ostatni zapis usuwa jego transakcja, więc
`queue_external_state_cleanup` wpisuje je do zadań sprzątających w tej samej
sesji: awaria między commitem a sprzątaniem niczego nie gubi, a zadanie jest
ponawiane, dopóki pliki i kolekcje wektorowe nie znikną.

**Praca, którą można stracić, idzie przez `spawn_after_commit`**
(`app/core/background.py`): krótka praca bez gwarancji w pętli samego API, jak
zapisanie osiągalności serwera MCP albo usunięcie plików, do których artefakt już
się nie odwołuje. Kolejkuje korutynę na sesji i uruchamia ją dwie instrukcje po
`commit()`; kolejność jest udowodniona na prawdziwej bazie w
`tests/integration/test_flow_starts_after_commit.py`. Przy zamknięciu lifespan
czeka na `background.drain()`, zanim zlikwiduje to, co te taski czytają.

Z tego, gdzie leży ta kolejka, wynikają dwie rzeczy:

- **Należy do sesji, nie do żądania.** Serwis, który przekazuje pracę, nie musi
  wiedzieć, czy wywołano go z trasy, handlera WebSocketu, CLI czy workera - i
  dlatego to nie są `BackgroundTasks` z FastAPI, których gwarancja dotyczy
  odpowiedzi, a tamte trzy miejsca jej nie mają.
- **Wycofana transakcja niczego nie zleca.** Zakolejkowane korutyny są
  zamykane, bo wykonanie pracy, której wiersz wyrzucono, tylko przenosi błąd w
  miejsce trudniejsze do wyjaśnienia.

`spawn` zostaje właściwy dla pracy, która ma wszystko, czego potrzebuje, i nie
dotyka żadnego wiersza - webhooki kanałów przekazują mu każde dostarczenie, żeby
provider dostał odpowiedź od razu. Żadne z nich nie przetrwa restartu; zadanie
tak. Zob. [Dodaj zadanie w tle](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

## Runy workflowów: outbox i krótkie transakcje { #workflow-runs-an-outbox-and-short-transactions }

Run workflowu może trwać dni - węzeł może czekać na zatwierdzenie - więc żaden
proces nie trzyma jego pozycji. Trzyma ją Postgres: `dispatch_outbox` wskazuje
każdy gotowy węzeł, a każdy job `workflow-dispatch-node` przejmuje jeden wiersz, wykonuje
jedną próbę i ją rozlicza. Kod jest w `app/services/workflow_execution/dispatcher.py`.

Każda próba to trzy krótkie transakcje wokół wywołania, które nie trzyma
żadnej. Claim commituje lease na wierszu; wiersz próby jest commitowany jako
`in_flight`, zanim handler zostanie wywołany, więc worker, który umrze w trakcie,
zostawia coś, co reconciler może znaleźć; a rozliczenie commituje wynik, koszt i
wiersz outboxa następnego węzła razem, więc wynik nigdy nie jest trwały bez
kolejnego kroku.

Job, który budzi workera dla tego następnego węzła, jest
kolejkowany z jego wierszem w tej samej transakcji, a węzeł gotowy później
(timer, opóźnienie ponowienia) ma `run_after` ustawione na ten czas; sweepy poll i
reconcile są tylko zabezpieczeniem na wypadek joba, który został anulowany albo
padł. Dopóki handler działa, worker odnawia lease we własnych transakcjach, a
odnowienie, które nie znajduje już claimu, informuje o tym handler.

Każda zmiana statusu po claimie jest chroniona tokenem claimu i tym, że wiersz
nadal jest przejęty, pod blokadą braną w jednej kolejności - run, run węzła,
outbox - przez dispatcher i reconciler tak samo. Jedynym zapisem, który nie jest
chroniony, jest koszt: to, co wywołanie wydało, jest księgowane nawet wtedy, gdy
jego wynik przychodzi za późno, by zostać przyjęty. Przerwana próba nigdy nie jest uznawana za
udaną ani nieudaną: staje się `uncertain`, a automatycznie ponawiany jest tylko
węzeł zadeklarowany jako idempotentny.

## Runy agenta: capability nigdy nie pobiera { #agent-runs-a-capability-never-fetches }

Powyższy podział na warstwy ma wewnątrz runu agenta jeszcze jedną zasadę i to
przez nią runner jest tak duży, jak jest. **Capability nie dotyka bazy danych.**
Wszystko, czego z niej potrzebuje — nazwy kolekcji, które wiąże jej spec, skille,
które może wczytać, workspace, do którego pisze, delegaci, których może wywołać —
jest rozwiązywane przez serwis _przed_ startem runu i przekazywane jako
`resources`, słownik, który capability może czytać i do którego nie może nic
dodać. Model pyta o to, _czego_ szukać; nigdy nie dowiaduje się, _gdzie_.

Dwa wpisy w tym słowniku są szwami do innych podsystemów, a nie zwykłymi danymi:

| Zasób                       | Zostawiony przez runner                                       | Czytany przez                                                  |
| --------------------------- | ------------------------------------------------------------- | -------------------------------------------------------------- |
| `WORKSPACE_RESOURCE`        | otwarty workspace, który run dostaje też jako `ctx.workspace` | `tool_output_limits` oraz delegat, który współdzieli `sandbox` |
| `SUBAGENT_RUNTIME_RESOURCE` | rozwiązane drzewo delegacji                                   | capability `subagents`                                         |

Delegacja jest najostrzejszym przypadkiem tej zasady. Delegat jest wierszem; tak
samo jego przypięta wersja, jego kolekcje, jego skille i jego sekrety, a każde
z nich musi przejść przez `resolve_access`, zanim zostanie odczytane. Runner
obchodzi więc całe drzewo — zagnieżdżenie, ograniczenie głębokości, odmowę dla
delegata już działającego wyżej w tym samym runie — póki wciąż trzyma sesję
i kontekst uwierzytelnienia, i zostawia po sobie domknięcia budujące już
rozwiązanego agenta oraz rejestrator zapisujący jeden wiersz. W czasie działania
dzieje się praca procesora i Pydantic AI.

Odwrotnie być nie może: `AsyncSession` żądania jest dzielona przez wszystko
w runie i nie jest bezpieczna przy współbieżności, więc drzewo obchodzone
w czasie działania byłoby zapytaniem z wnętrza wywołania narzędzia —
a rozgałęzienie byłoby kilkoma takimi naraz, co nie tyle spowalnia, ile psuje
sesję używaną przez resztę żądania.

Brak zasobu nigdy nie jest błędem. Podgląd, test jednostkowy albo agent, któremu
usunięto wszystkich delegatów, nie rozwiązuje niczego, a capability nie oferuje
wtedy żadnych delegatów, zamiast podnosić wyjątek — dokładnie tak, jak capability
workspace'u spada do backendu trzymanego w pamięci.

### Schemat { #schema }

`0007_delegated_runs` dodaje dwie kolumny do `agent_runs`.

**`parent_run_id`** to samoodwołujący się klucz obcy mówiący, który run
oddelegował ten, i to on pilnuje uczciwości miesięcznej sumy organizacji — zobacz
[Governance](governance.md#what-a-delegated-run-is-recorded-as).

Jest `ON DELETE SET NULL` z tej samej arytmetyki: usunięcie rodzica kasuje
wiersz, który zawierał ten koszt, więc wiersz delegacji, który staje się wierszem
najwyższego poziomu, _powinien_ zacząć się liczyć. Kaskada skasowałaby zapis
wydanych pieniędzy.

**`subagent_task_id`** to własne id zadania z biblioteki delegacji, które łączy
wiersz z uchwytem, jaki model rodzica widział w swoim transkrypcie. Ponieważ
klucz obcy może wyzerować tylko własną kolumnę, ten uchwyt przeżywa usunięcie
i jest wstrzymywany przez `AgentRunRead` — zamiast być zerowany przez trigger na
najgorętszej pod względem wstawień tabeli w schemacie.

Indeks na `parent_run_id` obsługuje `list_runs(parent_run_id=...)`, czyli to,
o co pyta `GET /runs?parent_run_id=`. Zobacz
[Governance](governance.md#what-run-history-shows), żeby dowiedzieć się, dlaczego
historia runów nigdy nie wypisuje obu rodzajów wierszy razem.

## Usuwanie członka albo tenanta { #deleting-a-member-or-a-tenant }

Kilka kluczy obcych wymusiłoby przy usuwaniu dokładnie ten zapis, którego
zabrania ograniczenie `CHECK` — więc kaskada deklarowana przez schemat
i deklarowany przez ten sam schemat niezmiennik są ze sobą sprzeczne, a usunięcie
podnosi wyjątek wewnątrz bazy jako 500, zamiast cokolwiek zrobić. Trzy takie pary
są godzone w serwisie, zanim wiersz zniknie, wewnątrz własnej transakcji żądania:

- **Prywatny sekret osoby odchodzącej.** `organization_secrets.owner_user_id` jest
  `SET NULL`, ale `ck_secret_private_needs_owner` zabrania prywatnego sekretu bez
  właściciela. `UserService.delete` najpierw podnosi prywatne sekrety
  odchodzącego do widoczności organizacji, więc null zapisywany przez kaskadę
  jest legalny, a klucz pozostaje osiągalny dla organizacji, zamiast zostać
  osierocony.
- **Organizacje twórcy.** `organizations.created_by_user_id` jest `RESTRICT`,
  a rejestracja domyślnie tworzy organizację osobistą, więc gołe `DELETE users` nigdy
  nie zadziałało dla prawdziwego konta. Organizacja osobista jest usuwana razem
  z właścicielem; współdzielona jest przekazywana innemu właścicielowi, a gdy nie
  ma komu jej przekazać, usunięcie zostaje odrzucone.
- **Kolekcja o zasięgu organizacji.** `knowledge_bases.organization_id` jest
  `SET NULL`, ale `ck_knowledge_bases_org_scope_has_org` zabrania wiersza
  o zasięgu organizacji bez organizacji. `OrganizationService.delete` usuwa
  kolekcje o zasięgu organizacji jawnie — razem z tabelą wektorów — zanim zniknie
  wiersz organizacji; kolekcja osobista, która jedynie nosi id organizacji,
  zostaje oddana `SET NULL`, na co pozwala jej zasięg. Ponieważ usunięcie tabeli
  wektorów potrzebuje magazynu o zasięgu żądania, route usuwania podłącza go
  przez dedykowaną zależność; każdy inny route organizacji używa zwykłego serwisu
  i nie buduje magazynu, którego nigdy by nie dotknął.

## Co run przekazał swojemu modelowi i dlaczego jest to tabela { #what-a-run-handed-its-model-and-why-it-is-a-table }

`run_manifests` trzyma po jednym wierszu na run: instrukcje w postaci, w jakiej
je złożono i wysłano, każdą definicję narzędzia w postaci, w jakiej dostał ją
provider, ustawienia, po jednym wpisie na żądanie do modelu oraz listę wiadomości
ostatniego żądania. Zapisuje go `AgentRunnerService.finish` na każdej drodze
wyjścia z runu, a czyta `GET /runs/{id}/manifest` — zobacz
[Koncepcje](concepts.md#a-run-and-what-it-handed-the-model), żeby dowiedzieć się,
co jest zapisywane i dlaczego nie da się tego odtworzyć ze speca.

Trzy decyzje o warstwach warto zapisać, bo każda z nich to miejsce, w którym
oczywista alternatywa jest błędna.

**Tabela, a nie kolumna w `agent_runs`.** Ta tabela jest najczęściej wypisywaną
w produkcie — historia runów, zakładka wydatków, liczby na dashboardzie, eksport
do CSV — a dokument JSONB trzymający schemat JSON każdego narzędzia byłby przez
nie wszystkie czytany po to, by odpowiedzieć na pytanie, którego żadna z nich nie
zadaje. Jeden wiersz na run, `ON DELETE CASCADE` i od runu, i od organizacji,
czytany wyłącznie przez widok szczegółów.

**Zapisywanie dzieje się w `app/agents/manifest.py`, a nie w serwisie.** Model,
na którym zbudowany jest agent, jest opakowany (`RecordingModel`, czyli
`WrapperModel` — ten sam kształt, którego `MeteredModel` używa do księgowania
wydatków subagenta), więc zapisywane jest `ModelRequestParameters` w postaci,
w jakiej otrzymał je provider: po każdym hooku `prepare`, po tym, jak
wyszukiwanie narzędzi ukryło to, co ukrywa, po dodaniu narzędzia wyjściowego.
Serwis utrwala to, co zebrał wrapper, i nie decyduje o zawartości.

**Załącznik w transkrypcie odczytuje się przez run, a nie przez tego, kto go
wgrał.**

`GET /files/{id}` jest ograniczone do `ChatFile.user_id`, co jest właściwym
zasięgiem dla okna pisania wiadomości i niewłaściwym dla przeglądu runu. Odczyt
runu jest prawem organizacji, a nie tego, kto go uruchomił, więc karty
załączników w transkrypcie kolegi renderowały się, a każdy podgląd odpowiadał 404.

`GET /runs/{run_id}/files/{file_id}` autoryzuje tak jak transkrypt —
organizacja, potem `runs:view` — a następnie dopuszcza plik tylko tam, gdzie jego
`message_id` wskazuje turę rozmowy należącej do tego runu. Dokładnie tyle
zasięgu, ile daje już transkrypt, i ani trochę więcej.

Oba route'y podają bajty przez `_chat_file_bytes.py`, więc to, co przeglądarka
może _wyświetlić_, nie zależy od tego, który z nich autoryzował odczyt.

**Zapis jest osłonięty _i_ zagnieżdżony.** Dochodzi się do niego z bloku
`finally`, więc wyjątek podniesiony podczas zapisywania nieudanego runu
zastąpiłby tę awarię sobą. Samo połknięcie go nie wystarczy: nieudany flush
zostawia sesję nie do użytku, więc własny końcowy zapis runu przepadłby na rzecz
zapisu, o który nikt nie prosił. Wykonuje się wewnątrz `begin_nested()` z tego
samego powodu co `TranscriptService._attach` — to SAVEPOINT sprawia, że „ten
zapis może się nieszkodliwie nie powieść” jest prawdą, a nie pobożnym życzeniem.

## Odmowa, która nazywa pole { #a-refusal-that-names-a-field }

Każda odmowa wychodzi w jednej kopercie,
`{"error": {"code", "message", "details"}}`, a odmowa dotycząca _pola_ nazywa je
w jednym kształcie:

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

`fieldProblems` w `frontend/src/lib/api-error.ts` czyta właśnie to i nic więcej,
i to dzięki temu formularz może oznaczyć wadliwe pole, zamiast pokazywać zdanie,
którego czytelnik musi szukać, przeglądając stronę od nowa.
`app/core/field_errors.py` to jedyne miejsce, w którym się ją buduje, i ma trzy
punkty wejścia. Dwa z nich czytają Pydantica, a **to, którym wywołującym jesteś,
decyduje, co znaczy pierwszy element `loc`**:

|                                            | Dla                                                                                                                                                                            | `loc` zaczyna się od                                                           |
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------ |
| `request_field_problems`                   | `validation_exception_handler`, każdy `RequestValidationError`                                                                                                                 | miejsca, z którego przyszła wartość (`body`, `query`, …) — i to jest odrzucane |
| `field_problems(…, root=…)`                | serwis walidujący dokument, którego nie potrafi schemat route'a — nadpisanie ustawień ingestii dla jednego wgrania, ręcznie edytowany YAML speca, blob konfiguracji capability | pola tego dokumentu, raportowanego pod `root`                                  |
| `refused_field(field, message, **context)` | zasada, którą serwis wyraża prozą, a nie modelem — endpoint niosący hasło, bot Mattermosta tracący swój serwer, dokument YAML, który nigdy się nie sparsował                   | — zwraca `BadRequestError`, który wywołujący ma podnieść                       |

`refused_field` nazywa zdanie raz, bo `message` koperty i zdanie pola to jedno
i to samo zdanie; podnoszący, który potrzebuje innego statusu, buduje te same
`details` przez `field_details`. Osiemnaście miejsc wywołania odpowiadało zamiast
tego `details={"field": "<name>"}`, w liczbie pojedynczej, ze zdaniem w kopercie,
i żaden formularz nigdy tego nie przeczytał — ta sama wada w trzecim kształcie
([#891](https://github.com/vstorm-co/agenticos/issues/891)). Czwartym zapisem
było `details={"<field>": <value>}`, gdzie kluczem była nazwa pola, a wartością
to, co wywołujący właśnie przysłał: `model_profile.py` odpowiadał na odrzucone id
modelu tym właśnie id, w ciele odpowiedzi i w wierszu logu obok
([#898](https://github.com/vstorm-co/agenticos/issues/898)).

Decydowanie na podstawie samego łańcucha znaków źle odczytałoby speca, którego
zakazany klucz najwyższego poziomu nazywa się dosłownie `body`, czyli jeden
kształt stojący za dwa — dokładnie ten błąd, z którym ten moduł ma skończyć.

Zanim dodasz miejsce wywołania, warto znać jeszcze dwie własności. Czyta
wyłącznie `loc` i `msg`, więc odrzucona wartość nie może wrócić obok pola, które
zepsuła — i dlatego te miejsca wywołania podają jej `exc.errors()` bez
filtrowania. A `root` to nazwa, którą formularz wywołującego daje całemu
dokumentowi, więc każda ścieżka jest względem niego: to daje
`model_validator(mode="after")` miejsce, w którym może wylądować — zgłasza
`loc: ()`, bo zasada, którą złamał, dotyczy dwóch pól naraz — i sprawia, że
punkty wejścia się zgadzają, a nadpisanie odrzucone przy wgrywaniu nazywa
dokładnie to, co nazywa 422, gdy ta sama para przychodzi jako własne ustawienia
kolekcji.

Przepuszczanie zamiast tego własnego `exc.errors()` Pydantica było
[#882](https://github.com/vstorm-co/agenticos/issues/882) — drugim kształtem,
niosącym `input`, `ctx` i `url`, którego nic na frontendzie nie czytało.

**Zagregowana odmowa niesie obie połowy.**

`validate_spec` zgłasza wszystkie problemy speca naraz, a większość z nich to
zepsute odwołania, przy których nie ma czego oznaczyć. Odpowiada więc
`details.problems` — po wierszu na każdy, które Builder wypisuje — oraz
`details.fields` dla tego podzbioru, który pole nazywa.

Gdy część z nich da się usunąć jednym kliknięciem, odpowiada też `details.fixes`,
listą kodów: `use_state_workspace` przenosi workspace na przechowywany backend
`state`, który nie potrzebuje połączenia z sandboxem, a `stop_sharing_unbound`
usuwa z `share_with_delegates` to, czego agent nie może udostępnić. Builder
pokazuje przycisk dla każdego znanego kodu i pomija resztę. Poprawka zapisuje
wersję roboczą i waliduje ją ponownie; nigdy nie publikuje.

Konfiguracja capability to jedyna część speca renderowana jako generowany
formularz, więc jej odmowy nazywają pole:
`capabilities.knowledge.config.default_top_k`, z `specialists.researcher.`
z przodu dla capability skonfigurowanej wewnątrz delegata, bo Builder renderuje
po jednym formularzu na specjalistę.

Zachowanie samego zdania było drugą połową #882. Zapis szkicu w ogóle nie
waliduje schematu konfiguracji, więc walidacja przy publikacji to jedyne miejsce,
w którym błędnie wpisane ustawienie zostaje kiedykolwiek odrzucone.

**Dwa rodzaje odmowy celowo nie nazywają żadnego pola**, a granica między nimi
a resztą jest tym, co powstrzymuje jeden kształt przed ponownym znaczeniem dwóch
rzeczy:

- **Odmowa dotycząca wartości, której nie przysłał żaden wywołujący.** Nazwę
  zdalnego pliku wybiera ten, kto może wrzucić plik do synchronizowanego folderu,
  a oba sprawdzenia w `app/services/rag/remote_names.py` wykonują się wewnątrz
  synchronizacji w tle, gdzie czytelnikiem jest log, a nie formularz. Tak samo
  przy źródle Google Drive odczytanym bez swojego poświadczenia: wiersz jest
  zapisany, a odmawia go przy route'cie `validate_config` konektora, wyprowadzone
  z jego `CONFIG_MODEL`.
- **Konflikt.** `AlreadyExistsError` zgłasza fakt o wierszu, który już istnieje,
  a nie o kształcie tego, co przysłano — a to, które z pól formularza
  wyprodukowało zajętą wartość, wie tylko formularz, bo uchwyt agenta wyprowadza
  się z nazwy, której nikt nie wpisał jako uchwytu. Bierze to na siebie
  `identifiedBy` z `submitFailure` po stronie klienta, więc 409 niesie zajętą
  wartość i żadnego pola.

## Kluczowe pliki { #key-files }

- Punkt wejścia: `app/main.py`
- Konfiguracja: `app/core/config.py`
- Zależności: `app/api/deps.py`
- Narzędzia uwierzytelniania: `app/core/security.py`
- Handlery wyjątków: `app/api/exception_handlers.py`
- Odmowy na poziomie pola: `app/core/field_errors.py`

## Uwierzytelnianie i autoryzacja { #authentication-authorization }

### Metody uwierzytelniania { #authentication-methods }

Projekt wspiera dwie metody uwierzytelniania, obie zawsze dostępne:

1. **JWT (JSON Web Tokens)** -- Używane przez frontend i klientów API.
   - Logowanie przez `POST /api/v1/auth/login` zwraca `access_token` + `refresh_token`.
   - Tokeny dostępu wygasają po `ACCESS_TOKEN_EXPIRE_MINUTES` (domyślnie 30 min).
   - Tokeny odświeżające wygasają po `REFRESH_TOKEN_EXPIRE_MINUTES` (domyślnie 7 dni).
   - Frontend trzyma tokeny jako ciasteczka HTTP-only.
   - Uwierzytelnienie WebSocketu przekazuje JWT jako parametr zapytania (`?token=<jwt>`) albo w ciasteczku.

2. **Klucz API** -- Używany do dostępu serwer-serwer i programistycznego.
   - Przekazywany nagłówkiem `X-API-Key` (konfigurowalnym przez `API_KEY_HEADER`).
   - Jeden współdzielony klucz ustawiany zmienną środowiskową `API_KEY`.
   - Używa porównania w stałym czasie (`secrets.compare_digest`), żeby zapobiec atakom czasowym.

### Gdzie ląduje świeża sesja { #where-a-fresh-session-lands }

Sesję ustanawiają trzy drzwi, trzema drogami — formularz z hasłem, callback OAuth
i magic link — a o tym, gdzie odwiedzający wyląduje, decyduje dokładnie jedno
miejsce: `postSignInDestination` w `frontend/src/lib/auth-landing.ts`, które
respektuje deep link tylko wtedy, gdy jest ścieżką w tym samym origin, a poza tym
odpowiada dashboardem. Trzy odpowiedzi w trzech miejscach to rozjazd, a ten
rozjazd zdarzył się naprawdę dwa razy: na osi ról, gdzie lądowanie rozwidlało się
według roli, i na osi providerów, gdzie podróż w obie strony przez OAuth gubiła
`?returnTo=`.

Między drzwiami różni się tylko to, jak ścieżka _podróżuje_:

| Drzwi              | Jak ścieżka dociera do lądowania                                                                                                                        |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Formularz z hasłem | nigdy nie opuściła karty — czytana wprost z `?returnTo=`                                                                                                |
| Callback OAuth     | `sessionStorage`, co jest dozwolone, bo podróż w obie strony zaczyna się i kończy w tej samej karcie w tym samym origin                                 |
| Magic link         | podpisane oświadczenie w tokenie, bo w link klika się z maila — w innej karcie, często w innej aplikacji, gdzie `sessionStorage` jest pusty z definicji |

Ścieżka magic linku zostaje odrzucona już przy **żądaniu**, a nie odfiltrowana
przy lądowaniu: `MagicLinkRequest.return_to` przyjmuje ścieżkę na tym
deploymencie i nic ze schematem, z drugim wiodącym ukośnikiem, z odwrotnym
ukośnikiem ani ze znakiem sterującym, więc token, który dałoby się zmusić do
przeniesienia dowolnego łańcucha znaków, po prostu nie powstaje. Lądowanie i tak
ocenia ją ponownie — sprawdzenie, które wykonuje się raz, na serwerze, na
wartości wędrującej potem przez maila, to sprawdzenie, co do którego klient nie
może zakładać, że się odbyło.

`POST /auth/magic-link/verify` odpowiada więc przez `MagicLinkToken` — parę
tokenów plus `return_to`, niezastosowane. Własny schemat, a nie nullowalne pole
w `Token`, bo pozostałe trzy odpowiedzi z tokenami nie mają ścieżki powrotnej do
przeniesienia, a pole, które w większości z nich jest zawsze nullem, klient
szybko uczy się ignorować.

### Autoryzacja { #authorization }

Na użytkowniku nie ma kolumny z rolą ani zależności route'a opartej na roli. To,
co członek może robić wewnątrz organizacji, jest uprawnieniem z katalogu
w `app/core/permissions.py`, a to, których wierszy może dotknąć, rozstrzyga się
per wiersz — zobacz [Uprawnienia](permissions.md), żeby poznać cały model.

Dwie zależności, i tylko dwie:

| Alias             | Znaczy                                                                                     |
| ----------------- | ------------------------------------------------------------------------------------------ |
| `CurrentUser`     | każdy uwierzytelniony użytkownik                                                           |
| `CurrentAppAdmin` | superadmin deploymentu (`users.is_app_admin`), dla `/admin/*` i zbiorczych route'ów `/rag` |

Wszystko inne idzie przez jedno z:

```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(...)` nie należy do route'a działającego na jednym zasobie"

    Bramka rolowa nie widzi grantów na wierszu, więc odrzuciłaby Viewera
    z jawnym grantem `edit`, zanim `resolve_access` zdążyłoby poszerzyć jego
    dostęp. Ten sam kształt obowiązuje, gdy o pytaniu decyduje *parametr* —
    `GET /stats/usage?scope=own` musi być osiągalne dla zwykłego członka, więc
    jego bramka mieszka w serwisie. `tests/api/test_platform_routes.py` pilnuje
    całości.

!!! note "Osobista preferencja nie niesie żadnej bramki"

    Wiersz o zasięgu `(user_id, organization_id)`, który czyta i zapisuje
    wyłącznie jego właściciel, nie jest danymi organizacji, więc nie bramkuje go
    żadne uprawnienie i nie ma route'a sięgającego do cudzego.
    `GET`/`PUT`/`DELETE /me/dashboard-layout` (zapisany układ dashboardu)
    i leżąca pod nim półka `/presets` (nazwane układy, między którymi ktoś się
    przełącza) są tym wzorcem: `CurrentUser` + `ActiveOrg`, każde zapytanie
    filtrowane po **obu** id. Klucz złożony jest całą granicą tenanta — układ albo
    preset zapisany w jednej organizacji jest niewidoczny w innej *nawet dla
    swojego właściciela*, co sama kontrola per użytkownik by przepuściła, więc
    `tests/integration/test_dashboard_layout.py`
    i `tests/integration/test_dashboard_preset.py` pokrywają dokładnie to. Nie ma
    route'a *zastosuj preset*: zastosowanie go to `PUT` klienta wpisujący wpisy
    presetu jako aktywny układ, dzięki czemu dashboard zachowuje jedną ścieżkę
    zapisu i jedną walidację tego, co renderuje.

    Umieszczenie karty może też nieść `options` — jej własne okno czasowe
    (`period`), sposób prezentacji (`style`) i zawężenie (`agent_id`, `user_id`).
    **Zapisana opcja jest prośbą, nigdy autoryzacją**: dociera do
    `GET /stats/usage` jako parametr zapytania i tam zostaje odrzucona, jeśli
    wywołujący nie może czytać tego, o co prosi — dokładnie tak, jak gdyby sam
    wpisał ten URL. Zawężenie do kolegi to czytanie cudzych wierszy, więc jest to
    `scope=org` i stoi za `runs:view`; `scope=own` z `user_id` daje 422, a nie
    ciche przeinterpretowanie. Przy zapisie styl i okno są walidowane wobec
    zamkniętych zbiorów deklarowanych przez rejestr frontendu
    (`tests/test_dashboard_registry.py` pilnuje równości obu odbić); przy odczycie
    opcje wracają dosłownie, bo agent, który został od tamtej pory usunięty, nie
    może pociągnąć za sobą całego układu.

`UserRole`, `User.has_role()`, `RoleChecker`, `CurrentAdmin` i
`CurrentSuperuser` były modelem z szablonu i zniknęły, razem z kolumną
`users.role`, która odeszła wraz ze zgnieceniem migracji w `0001_baseline`. Były trzecią odpowiedzią na pytanie,
które miało już dwie.

### Ochrona przed IDOR { #idor-protection }

Dwa predykaty i nie są wymienne. **Organizacja ogranicza odczyt; użytkownik
zawęża go dalej.**

- Endpointy rozmów przekazują `organization_id=active_org.id`. Bez tego rozmowa
  jest wyszukiwana wyłącznie po kluczu głównym, a każdy zalogowany wywołujący,
  który zna UUID, czyta rozmowę w innym tenancie — albo do niej dopisuje.
- Przekazują też `user_id=current_user.id`, co ogranicza wiersz do jego
  właściciela albo do kogoś, komu go udostępniono. Sama kontrola tenanta nie
  wystarcza: bez tego każdy członek organizacji może czytać rozmowy każdego
  innego członka i do nich dopisywać.
- **Udostępnienie niesie zapis dopiero na poziomie `edit`.** Odczyt i zapis to dwa
  pytania — `_may_read` i `_may_write` — a udostępnienie odpowiadało kiedyś na
  oba, niezależnie od poziomu, który niosło, więc dwa poziomy oferowane w oknie
  udostępniania znaczyły to samo: rozmowę udostępnioną do _podglądu_ można było
  przemianować, zarchiwizować, usunąć albo dopisać do niej turę
  `role: "assistant"`, którą wszyscy czytają w `/chat`, a model dostaje z powrotem
  jako własne słowa. Poziom jest komunikowany temu, kto go nadaje, więc
  egzekwowany jest właśnie ten poziom (#931).
- W `transcript` ten jeden argument robi dwie rzeczy — autoryzuje _i_ wzbogaca
  każdą wiadomość o własną ocenę wywołującego. To przeciążenie jest powodem, dla
  którego jego autoryzująca połowa tak długo była nieobecna: route go
  przekazywał, argument był w przeglądzie kodu wyraźnie widoczny, a robił tę
  drugą robotę.
- Pobieranie plików sprawdza `chat_file.user_id == current_user.id`, a dołączenie
  pliku do wiadomości niesie tego samego właściciela w `WHERE`: tura wskazująca id
  pliku innego użytkownika — albo plik już przypięty do wiadomości — zostaje
  odrzucona, nigdy po cichu zastosowana.

`ConversationService` sprawia, że tego rozróżnienia nie da się pominąć:
`organization_id` jest **wymaganym** argumentem nazwanym typu `UUID` przy każdym
odczycie i zapisie rozmowy. Kiedyś miał wartość domyślną `None`, `None` znaczyło
„bez zasięgu”, a pominięcia nie da się odróżnić od zamiaru — dwa route'y
obsługujące zwykłych członków po prostu go nie podawały i każdy zalogowany
użytkownik mógł czytać dowolną rozmowę w deploymencie i do niej dopisywać.

### Ulubione należy do czytelnika, a nie do wątku { #a-favourite-belongs-to-the-reader-not-to-the-thread }

`conversation_favourites` to wiersz na parę `(user_id, conversation_id)`, a nie
wartość logiczna w `conversations`, bo rozmowę można udostępnić, a wątek w kanale
ma uczestników, a nie właściciela: kolumna pozwoliłaby gwiazdce jednej osoby
decydować o tym, gdzie wątek siedzi u wszystkich, którzy go widzą.

Cztery konsekwencje, które warto znać:

- **`POST`/`DELETE /conversations/{id}/favourite` są autoryzowane jak _odczyt_.**
  Gwiazdka mówi, gdzie wątek siedzi we własnym panelu bocznym osoby, która ją
  postawiła, i nie zmienia w wątku nic, więc ktoś, komu rozmowę udostępniono, może
  ją oznaczyć dokładnie tak jak jej właściciel. `for_write` odmówiłoby tu właśnie
  temu czytelnikowi, dla którego ta funkcja istnieje. Oba route'y niosą `Auth`
  z tego samego powodu co każdy inny odczyt rozmowy: bez kontekstu
  `_may_read_trigger_log` odpowiada fałszem, a log przebiegów triggera, który
  wywołujący może otworzyć dzięki `runs:view`, byłby czymś, czego nie mógłby
  oznaczyć gwiazdką (#1254).
- **`is_favourite` należy do wywołującego i jest stemplowane w
  `get_conversation`** — jedynym odczycie, przez który przechodzi każdy odczyt
  o zasięgu czytelnika, a nie przy każdym route'cie. Docierało do dwóch
  odpowiedzi na osiem, dopóki każdy route musiał o tym pamiętać, więc `GET` albo
  PATCH mówił komuś, kto oznaczył wątek gwiazdką, że tego nie zrobił (#1254).
  Odczyt bez czytelnika — listowanie dla admina, ścieżka runu rozwiązująca wątek —
  nie pyta o niczyje gwiazdki i nie płaci za to zapytaniem, a odczyt, który
  wyłącznie _autoryzuje_, wyłącza to jawnie przez `include_favourite=False`. To są
  te odczyty, których wynik zostaje odrzucony albo nie jest rozmową:
  `GET /conversations/{id}/messages`, który rozwiązuje wątek raz, przez
  `transcript`, dla strony i jej kosztu; trzy route'y workspace'u; każda tura
  istniejącego czatu, przez `agent._resolve_in_org`; oraz zapisy — `add_message`,
  `delete_conversation` i `set_favourite`, który sam nadpisuje tę flagę. Domyślne
  włączenie jest tym, co powstrzymuje route, który _faktycznie_ serializuje
  rozmowę, przed zapomnieniem; wyłączenie jest świadomym aktem w miejscu
  wywołania.
- **Oznaczanie gwiazdką jest idempotentne przy rywalizacji**, bo wstawienie jest
  `ON CONFLICT DO NOTHING`, a nie odczytem i wstawieniem po nim. Dwa nakładające
  się POST-y dla tej samej pary nie widziały wiersza i drugi naruszał klucz
  główny; klient dodatkowo szereguje własne oczekujące oznaczenie per rozmowa,
  więc podwójne kliknięcie nie może doprowadzić do odpowiedzi na DELETE przed
  POST-em, po którym nastąpił.
- **Pasmo to `ORDER BY`, a nie grupowanie strony.** Panel boczny jest
  stronicowany, więc ulubiona rozmowa wysortowana świeżością na drugą stronę
  siedziałaby pod pięćdziesięcioma wątkami, które ulubione nie są. Wewnątrz
  każdego pasma wybrane sortowanie nadal obowiązuje, a widok archiwum nie jest
  podzielony na pasma w ogóle: gwiazdka przeżywa archiwizację, ale pasmo wewnątrz
  archiwum byłoby drugim miejscem, w którym trzeba szukać tego, co archiwizacja
  właśnie przeniosła.

**Nie ma już sposobu, by przeczytać rozmowę w poprzek tenantów.** Wartownik, który
kiedyś to zapisywał (`UNSCOPED`), miał dokładnie jednego wywołującego,
`/admin/conversations/{id}`, i oba odeszły razem z przeglądarką rozmów
obejmującą cały deployment — Aktywność odpowiada na „co się stało” kosztem,
modelem, trace'em i tym, co obok podano modelowi, a to jest pytanie, do którego
tamten ekran był używany. To, co z niego zostało, to
`GET /admin/conversations?user_id=`: wątki jednego wskazanego konta, wypisywane
dla szuflady użytkownika w panelu admina i nigdy nieczytane.

### O co pyta szuflada użytkownika w panelu admina { #what-the-admin-user-drawer-asks-for }

`GET /admin/users/{id}/detail` jest osobnym route'em, a nie polami na
`GET /admin/users/{id}`, bo jest **widokiem** złożonym z trzech tabel — członkostw,
sesji i wiersza użytkownika — a użytkownika czyta się w kilkunastu miejscach,
z których żadne tego nie potrzebuje.

Istnieje, bo szuflada nie odpowiadała na żadne z pytań, jakie naprawdę ma admin
otwierający wiersz: pokazywała id, adres e-mail już obecny w tabeli, nazwę
wyświetlaną i datę dołączenia (#942). Teraz odpowiada na to, gdzie ta osoba ma
dostęp i z jaką władzą, kiedy ostatnio tu była i czy cokolwiek jej jest wciąż
zalogowane. `last_seen_at` jest **nullem, a nie polem nieobecnym** dla konta,
które nigdy się nie zalogowało, bo „utworzone i nigdy nieużyte” oraz „uśpione od
marca” to różne decyzje.

Cały route jest `CurrentAppAdmin`: każde pole w nim dotyczy kogoś innego.

Pełne uprawnienia na poziomie endpointów znajdziesz w `docs/permissions.md`.

## Przetwarzanie plików w czacie { #file-processing-in-chat }

Gdy użytkownik wgra plik w interfejsie czatu, wykonuje się następujący 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)
```

### Wspierane typy plików { #supported-file-types }

| Kategoria | Rozszerzenia         | Przetwarzanie                                                             |
| --------- | -------------------- | ------------------------------------------------------------------------- |
| Obrazy    | JPEG, PNG, WebP, GIF | Zapisywane bez zmian, wysyłane do LLM jako dane binarne do analizy obrazu |
| PDF       | .pdf                 | Tekst wyciągany skonfigurowanym parserem                                  |
| Dokumenty | .docx                | Tekst wyciągany przez python-docx                                         |
| Tekst     | .txt, .md            | Dekodowane wprost jako UTF-8                                              |

### Wybór parsera { #parser-selection }

Załączniki czatu są czytane przez PyMuPDF i nie da się tego skonfigurować:
załącznik nie należy do żadnej kolekcji, więc nie ma zapisanej konfiguracji,
z której można by odczytać wybór parsera. Wybór parsera dotyczy kolekcji wiedzy,
gdzie jest ustawieniem per kolekcja.

### Przechowywanie { #storage }

Pliki są zapisywane do `media/{user_id}/` przez `FileStorageService`. Model
`ChatFile` przechowuje `storage_path`, `filename`, `mime_type`, `size`,
`file_type` oraz `parsed_content` (wyciągnięty tekst). Do swoich plików ma dostęp
wyłącznie ich właściciel.

### Limity rozmiaru { #size-limits }

Są dwa, bo są dwie powierzchnie. `MAX_UPLOAD_SIZE_MB` (domyślnie 50 MB) to limit
dokumentu w bazie wiedzy; `CHAT_MAX_UPLOAD_SIZE_MB` (domyślnie 10 MB) to tyle,
ile można załączyć w czacie. Są osobnymi ustawieniami, a nie jednym, bo dokument
jest dzielony na fragmenty i odczytywany z powrotem przez retrieval, podczas gdy
załącznik do agenta bez workspace'u jest wklejany w całości do promptu — ten sam
rozmiar zawodzi w każdym z tych przypadków inaczej. `GET /api/v1/health`
publikuje oba.

## System RAG { #rag-system }

### Przegląd architektury { #architecture-overview }

System RAG (Retrieval Augmented Generation) dostarcza bazę wiedzy, którą agent AI
może przeszukiwać w trakcie rozmów. Składa się z:

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

### Zasada kluczowa: RAG jest globalny { #key-principle-rag-is-global }

**Kolekcje są współdzielone przez WSZYSTKICH użytkowników.** Nie ma izolacji
dokumentów per użytkownik. Oznacza to, że:

- Każdy uwierzytelniony użytkownik może **przeszukiwać** dowolną kolekcję.
- Tylko **administratorzy** mogą tworzyć i usuwać kolekcje, wgrywać dokumenty,
  konfigurować źródła synchronizacji i oglądać logi synchronizacji.
- Baza wiedzy służy jako zasób współdzielony w obrębie całej organizacji.

### Komponenty { #components }

| Komponent           | Plik                 | Do czego służy                                              |
| ------------------- | -------------------- | ----------------------------------------------------------- |
| `DocumentProcessor` | `rag/documents.py`   | Parsuje pliki do tekstu (PDF, DOCX, TXT, obrazy)            |
| `IngestionService`  | `rag/ingestion.py`   | Orkiestruje parse -> chunk -> embed -> store                |
| `RetrievalService`  | `rag/retrieval.py`   | Obsługuje zapytania wyszukiwania z filtrowaniem i punktacją |
| `EmbeddingService`  | `rag/embeddings.py`  | Generuje embeddingi przez skonfigurowanego providera        |
| `BaseVectorStore`   | `rag/vectorstore.py` | Abstrakcyjny interfejs operacji na bazie wektorowej         |
| `PgVectorStore`     | `rag/vectorstore.py` | Implementacja na pgvector (PostgreSQL)                      |

### Pipeline ingestii { #ingestion-pipeline }

Dokumenty można wciągnąć przez:

1. **CLI** -- `uv run agenticos cmd rag-ingest <path>`
2. **API** -- `POST /api/v1/rag/collections/{name}/ingest` (tylko admin, wgranie pliku)
3. **Źródła synchronizacji** -- Skonfigurowane konektory (Google Drive, S3, strony
   internetowe), które pobierają dokumenty według harmonogramu albo na żądanie i
   usuwają to, czego ich źródło już nie zawiera.

Każdy wciągnięty dokument:

- Jest parsowany do tekstu (parser wybierany per kolekcja, do nadpisania per wgranie)
- Jest dzielony na fragmenty (`chunk_size` / `chunk_overlap`, też per kolekcja)
- Jest embedowany przez skonfigurowanego providera embeddingów
- Jest zapisywany w bazie wektorowej
- Jest śledzony w SQL przez model `RAGDocument` ze statusem (`processing`, `done`, `error`)

### Tryby synchronizacji { #sync-modes }

| Tryb          | Zachowanie                                                                                    |
| ------------- | --------------------------------------------------------------------------------------------- |
| `full`        | Zastępuje wszystkie dokumenty (wciąga wszystko od nowa)                                       |
| `new_only`    | Dodaje nowe pliki, wciąga od nowa pliki, których hasz treści się zmienił, pomija niezmienione |
| `update_only` | Wciąga od nowa wyłącznie zmienione pliki, nowe pomija całkowicie                              |

### Konektory synchronizacji { #sync-connectors }

Zdalne źródła dokumentów korzystają z wymiennych konektorów w
`app/services/rag/connectors/`. Każdy konektor implementuje `BaseSyncConnector`
z `list_files()` i `_fetch()`, deklaruje `SECRET_KIND` nazywające sekret
w vaulcie, który go uwierzytelnia, oraz deklaruje `CONFIG_MODEL` — model
Pydantic mówiący, jak znaleźć dokumenty, publikowany do kreatora jako JSON
Schema. `download_file()` jest
konkretne i decyduje, gdzie plik może wylądować. Zobacz `docs/patterns.md`, żeby
dowiedzieć się, jak dodać konektor, a `docs/howto/add-sync-connector.md` po
opracowany przykład.

## Podsumowanie { #recap }

- **Route'y → serwisy → repozytoria.** Route nigdy nie importuje repozytorium.
- Repozytorium używa `db.flush()` i `db.refresh()`, **nigdy** `db.commit()`.
  Sesja żądania commituje raz, zanim odpowiedź zostanie zapisana.
- Ścieżka runu agenta to główny usankcjonowany wyjątek: commituje przed
  wywołaniem modelu i jeszcze raz w końcowym `finally`. `MLService._record_failure`
  to ten drugi, z lustrzanego powodu — wiersz zużycia opisujący _odmowę_ musi
  przetrwać wycofanie, które ta odmowa powoduje.
- Dwa usankcjonowane wyjątki. Ścieżka runu agenta commituje przed wywołaniem
  modelu i jeszcze raz w końcowym `finally`;
  `SessionService.detect_refresh_reuse` commituje sesję, którą właśnie unieważnił,
  i wpis mówiący dlaczego — bo jego wywołujący natychmiast podnosi 401, a wycofanie
  cofnęłoby oba.
- Webhook, którego graf odpowiada na dostarczenia, zatwierdza przyjęcie w
  `WorkflowExposureService._answer`, żeby run mógł wystartować, gdy żądanie czeka
  na krok Respond to webhook.
- Praca w tle, która musi się wydarzyć, jest **zadaniem kolejkowanym w
  transakcji żądania** (`enqueue`); praca bez gwarancji, która czyta wiersz
  zapisany przez to żądanie, jest przekazywana przez **`spawn_after_commit`**,
  nigdy przez `spawn`.
- Cienka domena to moduł; gruba to podpakiet z fasadą, a nic spoza niego nie
  importuje jego podmodułów.

Operacje telefoniczne również wymagają trwałej granicy: `telephony.runtime.dial`, przyjęcie połączenia, przekierowanie i zakończenie zatwierdzają zamiar przed wywołaniem operatora. Niepewny wynik zachowuje zamiar dla podpisanych callbacków i sprzątania, bez ponownego wybierania numeru. Przyjęcie strumienia zatwierdza jednorazową rezerwację przed otwarciem sesji modelu.