homelab-codex-ws/kb/audits/mail-sync-2026-08-06.md
oskar a62c36c0b6 docs(recon): przyrostowka IMAP gmail + fastmail — Krok 7 fazy mailowej
Read-only recon (kb/audits/mail-sync-2026-08-06.md, OKF type: audit).

Stan wyjsciowy zmierzony na zywo: korpus gmail urywa sie 2026-06-19, dziura
48 dni ~ 1800 maili przy tempie ~37/dobe; zero kodu IMAP/JMAP w repo (tylko
dokumenty), brak modelu stanu synca — w bazie 3 tabele, zadnej z UID.

Ustalenia blokujace, ktore latwo przeoczyc (kazde zawodzi cicho, bez bledu):
- koperty bez entities[type=headers] daja prefiks chunka "(brak tematu) | ?"
  (build_prefix), wiec poller musi pisac headers przy INSERCIE, nie backfillem
- DEFAULT_SUMMARYLESS_SOURCES = ("gmail",) — fastmail zembeduje sie i zniknie
  z /search, bo galaz summaryless filtruje po source
- etap mailowy dopiety do kb-ingest.timer (03:30) zapali KbEmbedBacklogGrowing
  na stale: SOLARIA wtedy spi (potwierdzone: kb_ingest_embed_skipped 1)
- envelope.id = goly Message-ID globalnie, wiec mail obecny na obu kontach
  trafia do bazy raz, z source konta ktore wygralo wyscig

Architektura: fetch na PIHA co godzine (24/7, archiwum kanoniczne, bez GPU),
indeksowanie osobno bramkowane probe'em Ollamy (embed z PIHA zmierzony:
HTTP 200 w 8 ms, ~60 chunkow/dobe — rsync na SOLARIE zbedny). Spoiwem jest
kolejka "koperty bez chunkow", nie --since (ts to naglowek nadawcy).

Decyzje operatora (a)-(g) z rekomendacjami. Dwie korekty zalozen:
- POSTGRES_PASSWORD NIE lezy plaintextem w repo — service.yaml wymienia tylko
  nazwy zmiennych, env.example ma placeholdery, skan sledzonych YAML: 0 trafien.
  Rekomendacja uzywa istniejacego /opt/homelab/kb/.env (root:root 600,
  czytany przez systemd przed zrzuceniem uprawnien)
- Fastmail przez IMAP, nie JMAP — domyka otwarta od czerwca decyzje
  "unifikacja adaptera" (kb-mail-pillar.md §9); wymaga korekty §2/§7 tamtego
  dokumentu po zatwierdzeniu

Zaleznosci z reconem multiagentowym: zadnych blokujacych. Dyspozytor to
subsystem B (osobny projekt); jawna zaleznosc to wiki-kompilat
(kb-m5-faza3.md:620 — "pelna wiki po przyrostowce").

Aktualizacja kb-m5-faza-mailowa.md: Krok 7 = WYKONANE + wskaznik do reconu.
Nic nie zaimplementowano, nie zdeployowano ani nie pobrano.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 14:02:23 +02:00

810 lines
43 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
okf: "0.1"
type: audit
visibility: private
status: active
updated: 2026-08-06
as_of: 2026-08-06
links: []
---
# Recon — przyrostówka IMAP (gmail + fastmail), 2026-08-06
Read-only recon. Realizuje **Krok 7** fazy mailowej
(`kb/phases/kb-m5-faza-mailowa.md` §10, §12 poz. 7 — „OTWARTE — NEXT",
odblokowane zamknięciem Etapu B). Zakres: co trzeba dobudować, żeby korpus
mailowy przestał być fotografią z Takeoutu i sam się dosypywał z dwóch żywych
skrzynek.
Źródło kodu: `task/mail-sync-recon` @ `b1b6692` (worktree cięty z mastera).
Dowody runtime zebrane 2026-08-06 ~13:50 UTC+2: SOLARIA = host tego reconu
(odczyty lokalne), PIHA po ssh (`docker exec kb-postgres psql`, `systemctl`,
`curl`). **Nic nie zostało zaimplementowane, zdeployowane ani pobrane — wynik
to wyłącznie ten dokument.** Twierdzenia o zewnętrznych API (limity Gmaila,
układ folderów Fastmaila) są oznaczone jako **[do weryfikacji na żywo]** —
nie sprawdzałem ich, bo wymagałoby to logowania na konta.
---
## 1. Jak powstał obecny korpus
### 1.1 Tor, którym przyszły dane — trzy joby, jeden kierunek
| # | Job | Co robi | Kiedy |
|---|---|---|---|
| 1 | `jobs/gmail-bulk-import` | Takeout `.mbox` → archiwum `.eml` + wiersze `envelope` (tylko manifest załączników) | jednorazowo, 2026-06/07 |
| 2 | `jobs/gmail-header-backfill` | UPDATE: dopisuje `entities[type=headers]` do istniejących kopert | jednorazowo, 2026-07 |
| 3 | `jobs/mail-body-ingest` | drugi pełny przebieg po archiwum: body → quote-strip → chunk → embed → `document_chunk` + `entities[type=threading]` | Etap A 2026-07-23, Etap B 2026-08-06 |
Wszystkie trzy są **one-shotami uruchamianymi ręcznie**. Żaden nie ma jednostki
systemd, żaden nie wie o istnieniu skrzynki — punktem wejścia jest plik na dysku
(`.mbox`, potem `.eml`). To jest dokładnie ta luka, którą przyrostówka wypełnia:
**nie ma niczego, co rozmawia z serwerem pocztowym.**
### 1.2 Archiwum `.eml` — struktura i format
Kanoniczne archiwum na PIHA, lustro (dla backfillu GPU) na SOLARII:
`/home/oskar/kb/mail/archive/`.
Układ katalogów wyznacza `kb_mail.archive.save_eml`
(`packages/kb-mail/src/kb_mail/archive.py:29`):
```
{archive_root}/{source}/{YYYY}/{MM}/{sanitized_message_id}.eml
```
- `sanitized_message_id` = `envelope.id` przepuszczony przez
`str.maketrans({"/": "_", "\\": "_", ":": "_", "<": "", ">": ""})`.
- `YYYY/MM` pochodzą z `ts` (nagłówek `Date`), **nie** z daty pobrania.
- **Append-only twardo**: `save_eml` robi `dest.exists()``FileExistsError`.
Nie ma trybu nadpisania. Wołający decyduje, czy to błąd, czy „już mam"
(bulk-import liczy to jako `skipped`).
- Format pliku: surowe bajty wiadomości, zapisane przez
`msg.as_bytes(policy=email.policy.compat32)` — czyli **bez** normalizacji,
z oryginalnymi 8-bitowymi bajtami w nagłówkach włącznie.
Stan na dziś (SOLARIA, odczyt lokalny): jedyny podkatalog to `gmail/`, roczniki
`1970` (epoch fallback) oraz `2002``2026`. **Katalogu `fastmail/` nie ma —
źródło `fastmail` jest greenfieldem, zero plików i zero wierszy w DB.**
### 1.3 Tabela `envelope` — schemat i klucze dedup
`services/kb-postgres/init/001_envelope.sql`:
```sql
CREATE TABLE envelope (
id TEXT PRIMARY KEY, -- goły Message-ID (bez < >), lub sha256-<32hex>
source TEXT NOT NULL, -- fastmail | gmail | paperless
ts TIMESTAMPTZ NOT NULL, -- data wiadomości, UTC
geo JSONB, -- null dla maili
raw_ref TEXT NOT NULL, -- ścieżka względna do .eml
entities JSONB NOT NULL DEFAULT '[]'
);
CREATE INDEX envelope_source_idx ON envelope (source);
CREATE INDEX envelope_ts_idx ON envelope (ts);
```
Kontrakt zamrożony, addytywny (`packages/kb-mail/src/kb_mail/envelope.py`,
`kb/subsystems/kb-mail-pillar.md` §3). Kolumn UID/folder/etykieta **nie ma**
i nie było w planie.
**Klucz dedup to `id`, czyli goły Message-ID.** Wyprowadza go
`gmail_bulk_import._message_id` (`importer.py:63`):
```python
mid = _sanitize(str(msg.get("Message-ID", ""))).strip().strip("<>")
return mid or ("sha256-" + hashlib.sha256(msg.as_bytes()).hexdigest()[:32])
```
Cała idempotencja stoi na `INSERT ... ON CONFLICT (id) DO NOTHING`
(`kb_mail.db.insert_envelope`, `importer._insert_batch`). Na żywej bazie:
**9 kopert** ma id z prefiksem `sha256-` (maile bez Message-ID), reszta to
prawdziwe Message-ID; średnia długość id = 60 znaków.
`entities` to lista otwartych obiektów tagowanych `type`. Dziś w użyciu trzy:
| `type` | Kto pisze | Zawartość |
|---|---|---|
| `attachment` | gmail-bulk-import | `filename`, `content_type`, `size`, `sha256` |
| `headers` | gmail-header-backfill | `from`, `to`, `cc`, `delivered_to`, `subject`, `date_raw` |
| `threading` | mail-body-ingest | `in_reply_to`, `references[]` |
Oba appendy (`headers`, `threading`) są idempotentne przez
`WHERE NOT EXISTS (SELECT 1 FROM jsonb_array_elements(entities) e WHERE e->>'type'='…')`.
### 1.4 Stan żywej bazy i archiwum (zmierzone 2026-08-06)
| Miara | Wartość | Skąd |
|---|---|---|
| `envelope` source='gmail' | 225 030 | `SELECT source, count(*) … GROUP BY source` |
| `envelope` source='paperless' | 191 | j.w. |
| `envelope` source='fastmail' | **0** | j.w. |
| `max(ts)` dla gmail | **2026-06-19 19:13:39+02** | j.w. |
| `document_chunk` (model bge-m3) | 389 012, w tym 201 849 bez wektora (newsletter) | `GROUP BY model` |
| Rozmiar `document_chunk` | 2 971 MB | `pg_total_relation_size` |
| Rozmiar `envelope` | 264 MB | j.w. |
| Rozmiar bazy `kb` | **3 248 MB** | `pg_database_size` |
| PIHA `/home` wolne | 136 GB (66% zajęte) | `df -h` |
| PIHA RAM | 8 GB, `available` 2,4 GB | `free -m` |
| Archiwum gmail 2024 / 2025 / 2026 | 13 207 / 13 575 / 6 213 plików | `find … -name '*.eml' \| wc -l` |
| Ostatni rocznik archiwum | `2026/06` (861 plików) | `ls` |
**Kluczowa liczba tego reconu: korpus urywa się 2026-06-19, dziś jest 2026-08-06
— dziura ma 48 dni.** Przy zmierzonym tempie ~37 maili/dobę (13 575 w 2025;
6 213 przez ~170 dni 2026) to **~1 800 kopert**, których w KB nie ma i nigdy nie
będzie, dopóki ktoś nie zrobi drugiego Takeoutu albo nie postawi przyrostówki.
Dziura rośnie o kolejny dzień każdego dnia.
### 1.5 Skąd `source='gmail'` i czym różni się ścieżka fastmail
`source` jest **wpisany na sztywno w kod importera**`Envelope(..., source="gmail", ...)`
(`importer.py:255`) i `save_eml(archive_root, envelope_id, "gmail", ts, raw)`
(`importer.py:248`). Nie ma parametru CLI, nie ma detekcji. Tak samo
`mail_body_ingest.fetch_envelopes` (`ingest.py:372`) ma zaszyte
`WHERE source = 'gmail'`.
Ścieżka fastmail **nie istnieje w żadnej postaci** — ani kodu, ani wierszy, ani
katalogu w archiwum. Jedyne, co jest, to zarezerwowane miejsce w kontrakcie
(komentarz `-- fastmail | gmail | ...` w DDL i w dataclassie). Różnice, które
realnie wystąpią, gdy się ją zbuduje:
1. **Brak Takeoutu** — nie ma bulk-importu do wykonania; fastmail zaczyna od
pustego zbioru i albo bierzemy tylko nowe maile, albo robimy jednorazowy
pełny zaciąg IMAP-em (Decyzja **(e)** w §4).
2. **Inny układ folderów** — Gmail ma jeden worek `All Mail`; Fastmail ma
klasyczne IMAP-owe foldery i trzeba je wyliczyć (§2.3).
3. **Ta sama przestrzeń id** — i to jest pułapka, patrz §2.4.
---
## 2. Czego brakuje do przyrostówki
### 2.1 Klient IMAP — nie ma go nigdzie
Grep po całym repo (`imaplib|aioimaplib|imapclient|IMAP|JMAP`) daje wyłącznie
**dokumenty**: `kb/subsystems/kb-mail-pillar.md`, `kb/subsystems/kb-overview.md`,
§10 planu fazy mailowej, kilka session-logów. **Zero linii kodu.**
`packages/kb-mail` ma dokładnie pięć modułów — `archive`, `chunking`, `db`,
`envelope`, `text` — i dwie zależności (`asyncpg`, `structlog`). Nie ma warstwy
transportu i nigdy nie było.
Katalogi `jobs/fastmail-poller/` i `jobs/gmail-imap-poller/`, zapowiadane w
`kb-overview.md` jako etapy 34, **nie istnieją**.
Co jest gotowe do reużycia i jest tego sporo:
| Element | Gdzie | Uwaga |
|---|---|---|
| `save_eml` | `kb_mail/archive.py` | append-only, `FileExistsError` = „już mam" |
| `insert_envelope` | `kb_mail/db.py` | `ON CONFLICT (id) DO NOTHING` |
| `parse_headers` + `parse_headers_fallback` | `gmail_header_backfill/backfill.py:67,131` | typed parse + compat32, przetestowane na 8-bitowych nagłówkach |
| `_message_id`, `_parse_date`, `_parse_attachments` | `gmail_bulk_import/importer.py:63,78,97` | wyprowadzenie id/ts/manifestu |
| `sanitize_surrogates`, `strip_nul` | `kb_mail/text.py` | oba obowiązkowe przed zapisem do jsonb/text |
| Cały pipeline body | `mail_body_ingest/ingest.py` | wymaga jednej zmiany, §3.2 |
| `embed_batch_resilient`, `check_ollama_health` | `kb_retrieval/embed.py:168,256` | retry + bisekcja + probe backendu |
`imaplib` jest w stdlib Pythona 3.11 (PIHA ma 3.11.2) — przyrostówka **nie musi
dokładać żadnej zależności**, jeśli zaakceptujemy synchroniczny klient
(uzasadnienie przy Decyzji (a)).
### 2.2 Model stanu synca — nie ma go, i to jest główna nowa rzecz do zaprojektowania
W bazie są **trzy tabele**: `envelope`, `document_chunk`, `document_summary`
(sprawdzone `\dt`). Nic nie przechowuje ani UIDVALIDITY, ani UIDNEXT, ani
last-seen-UID, ani daty ostatniego pollu.
Trzy warianty, uporządkowane:
**(A) Nowa tabela `mail_sync_state` w kb-postgres** — migracja `005`:
```sql
CREATE TABLE mail_sync_state (
account TEXT NOT NULL, -- 'gmail' | 'fastmail' (== envelope.source)
folder TEXT NOT NULL, -- nazwa IMAP-owa, jak zwrócona przez LIST
uidvalidity BIGINT NOT NULL,
last_uid BIGINT NOT NULL, -- najwyższy UID przetworzony do końca
last_sync_ts TIMESTAMPTZ NOT NULL,
PRIMARY KEY (account, folder)
);
```
**(B) Plik JSON w `/opt/homelab/state/`** — zgodne z konwencją runtime-state
z CLAUDE.md, ale rozjeżdża stan z danymi: przy odtworzeniu bazy z backupu plik
zostaje w przyszłości i przyrostówka cicho przeskakuje maile.
**(C) Wyprowadzanie z `max(envelope.ts)`** — bez stanu. Kuszące i **złe**:
`ts` to nagłówek `Date` nadawcy, nie moment dostarczenia. Mail z przekręconym
zegarem albo opóźniony w dostarczeniu wpadnie poniżej znacznika i zniknie na
zawsze. Znamy to z własnego korpusu — 2 559 kopert ma `ts` = epoch 1970.
**Rekomendacja: (A).** Stan synca to dane, nie konfiguracja — należy do tej
samej jednostki backupu co koperty, którym odpowiada. Kosztuje jedną migrację
(`services/kb-postgres/init/005_mail_sync_state.sql`), a płaci za to
najważniejszą właściwością: **stan i dane odtwarzają się razem albo wcale.**
Semantyka pętli (standardowa i jedyna poprawna dla IMAP):
1. `SELECT` folderu → serwer zwraca `UIDVALIDITY` i `UIDNEXT`.
2. Jeśli `UIDVALIDITY` ≠ zapamiętany → **cała numeracja UID jest unieważniona**;
zapamiętane `last_uid` nic nie znaczy. Reakcja: pełne przemiecenie folderu
(`UID SEARCH ALL`) i oparcie się wyłącznie na dedupie po Message-ID, po czym
zapis nowego `uidvalidity`. To zdarza się rzadko, ale gdy się zdarzy i nie
obsłużymy — tracimy maile bez śladu.
3. W przeciwnym razie `UID FETCH {last_uid+1}:*`.
4. Zapis `last_uid` **dopiero po** tym, jak koperta i plik `.eml` są trwale
zapisane — nigdy przed. Przerwanie w środku = powtórka partii przy następnym
ticku, którą dedup wyciszy.
Uwaga na wyścig: `UID FETCH n:*` zawsze zwraca co najmniej jedną wiadomość
(serwer zwraca ostatnią, gdy przedział jest pusty) — trzeba odfiltrować UID-y
`<= last_uid` po stronie klienta, inaczej licznik „nowych maili" nigdy nie
spadnie do zera i obserwowalność z §3.4 kłamie.
### 2.3 Mapowanie folderów
**Gmail.** Model etykietowy: jedna wiadomość, wiele etykiet, każda etykieta
widoczna jako osobny folder IMAP. Synchronizowanie kilku folderów oznacza
pobranie tej samej wiadomości wielokrotnie. Właściwy wybór to **jeden folder
`\All`** (`[Gmail]/All Mail`), który zawiera wszystko poza Spamem i Koszem —
i to jest dokładnie ta sama populacja co Takeout „All Mail", na której stoi
obecny korpus (plan §Decyzja 4: „Takeout »All Mail« nie zawiera folderu Spam").
Przyrostówka na `\All` jest więc **ciągła merytorycznie** z tym, co już jest
w bazie.
Nazwy folderów Gmaila są lokalizowane (przy polskim UI `[Gmail]/Wszystkie`), więc
**nie wolno ich zaszywać** — trzeba wybrać folder po atrybucie SPECIAL-USE
`\All` z odpowiedzi `LIST`. To samo dotyczy `\Sent`/`\Trash`.
**Fastmail.** Model klasyczny: `INBOX`, `Archive`, `Sent`, `Drafts`, `Trash`,
`Spam` + foldery użytkownika. Nie ma jednego worka odpowiadającego Gmailowemu
`\All` **[do weryfikacji na żywo — czy konto wystawia wirtualny folder
obejmujący całość]**. Zakres trzeba więc podać jawnie jako listę; rozsądny
domyślny zestaw to `INBOX` + `Archive` + `Sent` (Decyzja **(e)**).
Konsekwencja projektowa: konfiguracja folderów musi być **per konto listą**, a
nie pojedynczą nazwą, bo Gmail chce jednego wpisu, a Fastmail trzech. Tabela
z §2.2 jest już kluczowana `(account, folder)`, więc to obsługuje.
Czego **nie** zapisujemy: etykiet Gmaila. `entities` przyjęłoby
`{"type":"labels", …}` bez migracji, ale nic w retrievalu ich dziś nie czyta,
a `X-GM-LABELS` wymaga rozszerzenia `X-GM-EXT-1`, które przywiązuje kod do
Google — wprost wbrew zasadzie „protokół, nie provider"
(`kb-overview.md` §61). Odkładam.
### 2.4 Dedup nowych vs istniejące — działa, z jednym ostrym rogiem
Trzy warstwy, wszystkie już w kodzie:
1. **Archiwum**: `save_eml``FileExistsError` = ten `.eml` już leży.
2. **Koperty**: `ON CONFLICT (id) DO NOTHING` po Message-ID.
3. **Chunki**: `ON CONFLICT (envelope_id, chunk_index, model) DO NOTHING`
+ pre-fetch kluczy.
Dla ponownego pobrania tej samej wiadomości z tego samego konta to jest
komplet i nic nie trzeba dokładać. Ale:
> **Znalezisko — kolizja Message-ID między kontami.** `envelope.id` to goły
> Message-ID, **globalnie unikalny w całej tabeli**, bez prefiksu źródła
> (inaczej niż paperless, który używa `paperless:N`). Wiadomość obecna i w
> Gmailu, i w Fastmailu (ta sama lista dyskusyjna, przekierowanie, CC na oba
> adresy) ma **ten sam Message-ID**. Kto pierwszy wstawi, ten ustala `source`;
> drugi jest cicho pominięty przez `ON CONFLICT DO NOTHING`. Jednocześnie
> `save_eml` zapisze **dwa** pliki `.eml` (w `gmail/…` i `fastmail/…`, bo ścieżka
> zawiera `source`), a `raw_ref` będzie wskazywał tylko na jeden z nich.
To nie jest korupcja danych i nie blokuje niczego — treść trafia do indeksu raz,
co jest zachowaniem pożądanym. Ale ma dwa mierzalne skutki, o których trzeba
wiedzieć **zanim** ktoś zacznie się dziwić liczbom:
- statystyki „nowe maile fastmail" będą systematycznie zaniżone o część wspólną,
- filtrowanie po `source` w retrievalu (dziś: `DEFAULT_SUMMARYLESS_SOURCES`)
przypisze taki mail do konta, które akurat wygrało wyścig.
**Rekomendacja: zostawić zachowanie, dodać licznik.** Job niech raportuje
`envelopes_conflict_other_source` (konflikt na id, którego istniejący wiersz ma
inny `source`) — jedno zapytanie przy pominiętym wstawieniu, zero zmiany
semantyki, a zjawisko przestaje być niewidzialne. Zmiana klucza na
`(source, message_id)` jest odrzucona: łamie zamrożony kontrakt koperty,
wymagałaby przepisania 225 030 istniejących id i wszystkich `raw_ref`, a kupuje
duplikaty w indeksie — czyli dokładnie to, przed czym dedup chroni.
### 2.5 Czego jeszcze brakuje, a nie widać na pierwszy rzut oka
**(i) Nowe koperty muszą mieć `headers` od razu.** `mail_body_ingest.build_prefix`
(`ingest.py:344`) buduje prefiks chunka `Temat: … | Od: … | Data: …`
z `entities[type=headers]`, a przy ich braku podstawia `"(brak tematu)"` i `"?"`
**cicho, bez błędu**. Historyczne koperty mają headers tylko dlatego, że
przejechał po nich osobny backfill. Gdyby poller wstawiał koperty tak jak
bulk-import (sam manifest załączników), każdy nowy mail dostałby bezużyteczny
prefiks i nikt by tego nie zauważył poza spadkiem jakości retrievalu.
**Poller musi wstawiać `headers` i `attachment` w jednym `entities` przy
INSERCIE**, wołając `gmail_header_backfill.parse_headers` z jego fallbackiem.
**(ii) `mail_body_ingest` nie umie o źródle innym niż gmail.**
`fetch_envelopes` ma zaszyte `WHERE source = 'gmail'`. Potrzebny parametr
`--source` (lub `--sources` przyjmujące listę) — zmiana jednolinijkowa
w zapytaniu plus flaga CLI.
**(iii) `fastmail` jest niewidoczny w domyślnym trybie wyszukiwania.**
`packages/kb-retrieval/src/kb_retrieval/retrieval.py:43`:
```python
DEFAULT_SUMMARYLESS_SOURCES = ("gmail",)
```
`kb-query` nie nadpisuje tej wartości (`services/kb-query/app/main.py:128`
nie przekazuje `summaryless_sources`). Ponieważ maile nie mają streszczeń, do
wyniku wchodzą **wyłącznie** przez gałąź „summaryless" trybu hybrid — a ta
filtruje `WHERE e.source = ANY($2)`. **Koperty fastmail wpadłyby do bazy,
zembedowały się i nie pojawiły w żadnym wyniku `/search`.** Poprawka to jedno
słowo (`("gmail", "fastmail")`), ale bez niej cała gałąź fastmail jest niema.
To jest najbardziej „cicha" pułapka w całym przedsięwzięciu.
**(iv) Pre-fetch kluczy chunków nie skaluje się do trybu cyklicznego.**
`fetch_existing_chunk_keys` (`ingest.py:387`) robi
`SELECT envelope_id, chunk_index FROM document_chunk WHERE model = $1`
**bez ograniczenia** — dziś 389 012 wierszy. Zmierzone na żywo: ~1,0 s po
stronie serwera, ~26 MB na drucie (średnia długość id = 60 B), plus set 389 tys.
krotek w Pythonie (rzędu 6090 MB RSS) — na PIHA, gdzie `free` pokazuje 2,4 GB
`available`. Dla jednorazowego plastra 50k to nic; dla joba odpalanego co godzinę
to marnotrawstwo rosnące razem z korpusem. Poprawka: zawęzić pre-fetch do kopert
z bieżącego zbioru roboczego (`WHERE model = $1 AND envelope_id = ANY($2)`).
Nieblokujące — `ON CONFLICT` i tak stanowi drugą linię obrony — ale należy
zrobić to razem z (ii), bo dotyka tej samej funkcji.
---
## 3. Proponowana architektura
### 3.1 Gdzie biegnie fetcher: **PIHA**, i to nie jest bliska decyzja
| Kryterium | PIHA | SOLARIA |
|---|---|---|
| Dostępność | 24/7 | **wyłączana ~16 h/dobę z założenia** (`kb/decisions/architektura-2026-07-28.md`) |
| Archiwum kanoniczne | tak | lustro do backfillu, może dryfować |
| kb-postgres | lokalnie | przez Tailscale |
| GPU / Ollama | nie | tak |
| Precedens (`kb-ingest.timer`) | tak | — |
Przyrostówka jest zadaniem **sieciowo-, nie obliczeniowo-zależnym**: pobrać
~37 maili, zapisać ~4,6 MB, wstawić 37 wierszy. Umieszczenie jej na hoście
wyłączanym na noc oznaczałoby, że stan skrzynki jest odczytywany tylko wtedy,
gdy ktoś akurat włączył desktop — i że archiwum kanoniczne żyje po drugiej
stronie sieci od procesu, który je zapisuje. Decyzja architektoniczna repo mówi
o SOLARII wprost: „nic wymagającego 24/7 nie może tu mieszkać".
**Embedding zostaje na SOLARII, ale tylko jako wywołanie HTTP.** Zweryfikowane
na żywo z PIHA (2026-08-06 13:56): `getent hosts solaria`
`100.100.231.104`, `GET http://solaria:11434/api/tags`**HTTP 200 w 8,2 ms**,
RTT ICMP 1,26 ms. Przy dobowym przyroście rzędu 60 chunków (37 maili × 1,73
chunka/mail, zmierzone: 389 012 / 225 030) i ~818 ms/chunk to **poniżej sekundy
pracy GPU na dobę**. Argument z planu §Decyzja 8 przeciwko embedowaniu z PIHA
dotyczył 271 tys. chunków backfillu i **nie przenosi się** na tę skalę.
**Wniosek: rsync archiwum PIHA→SOLARIA nie jest przyrostówce do niczego
potrzebny.** Lustro na SOLARII pozostaje artefaktem backfillu; niech dryfuje
albo zniknie.
### 3.2 Jak nowe koperty wchodzą w istniejący tor — dwa etapy, nie jeden
**Odrzucam „tryb `--since`" jako mechanizm spinający.** `--since` filtruje po
`envelope.ts`, czyli po dacie z nagłówka nadawcy. Mail dostarczony dziś z datą
sprzed tygodnia wypadnie poza okno i nie zostanie nigdy zchunkowany — ta sama
wada, co wariant (C) w §2.2, tylko przesunięta o jeden krok dalej w potoku.
`--since` zostaje tym, czym był: narzędziem do etapowania ręcznych runów.
Właściwym spoiwem jest **kolejka wynikająca z danych**: koperta bez chunków
*jest* elementem kolejki. Zapytanie:
```sql
SELECT e.id FROM envelope e
WHERE e.source = ANY($1)
AND NOT EXISTS (SELECT 1 FROM document_chunk c WHERE c.envelope_id = e.id)
```
Jest samonaprawiające się (przerwany run, koperta pominięta przy padniętej
Ollamie, mail wstawiony z datą wsteczną — wszystko wraca do kolejki samo)
i nie wymaga żadnego dodatkowego stanu. To wprost wzorzec, którym `kb-ingest`
liczy `kb_ingest_embed_backlog`.
Stąd **dwa niezależne kroki**, celowo rozdzielone po linii „potrzebuje GPU":
```
kb-mail-sync.timer (PIHA, co godzinę, bez GPU)
└─ jobs/mail-imap-sync
IMAP LOGIN → SELECT folder → UID FETCH last_uid+1:*
→ save_eml() (append-only, FileExistsError = skip)
→ insert_envelope() (entities = [headers…, attachment…])
→ UPDATE mail_sync_state.last_uid
→ .prom
kb-ingest.timer (PIHA, istniejący, bramkowany probe'em Ollamy)
└─ + nowy etap: mail_body_ingest.run(sources=[...], only_unchunked=True)
→ chunk → embed (http://solaria:11434) → document_chunk + threading
```
Zaletą tego podziału jest to, że **pobieranie nigdy nie czeka na SOLARIĘ**.
Maile lądują w archiwum i w kopertach co godzinę niezależnie od tego, czy
desktop jest włączony; indeksowanie dogania, gdy GPU jest dostępne — dokładnie
tak, jak dziś działa paperless. Nowy kod ogranicza się do klienta IMAP; reszta
to wpięcie istniejących funkcji.
### 3.3 Harmonogram — i pułapka odziedziczona po `kb-ingest.timer`
Wzorzec jednostek: `jobs/documents-ingest/systemd/``.service` (`Type=oneshot`,
`User=oskar`, `EnvironmentFile=/opt/homelab/kb/.env`) + `.timer`
(`Persistent=true`) + cienki `*-run.sh`, którego jedynym zadaniem jest
przekierowanie do `/opt/homelab/logs/<job>/run-YYYYMMDD.log` (nigdy sam
journal — lekcja z runu, który stracił 4999 wierszy do zamkniętego tmuxa).
Proponowany takt: **`OnCalendar=hourly`** dla `kb-mail-sync`. Uzasadnienie:
przy ~37 mailach na dobę godzinny tick pobiera 12 wiadomości, czyli jest
praktycznie darmowy, a jednocześnie utrzymuje świeżość KB w granicach godziny —
co ma znaczenie dla przyszłego dyspozytora (§5). `Persistent=true` nadgania po
reboocie PIHA.
> **Znalezisko — `kb-ingest.timer` chodzi o 03:30, kiedy SOLARIA prawie na pewno
> śpi.** Dowód z żywego systemu (PIHA, 2026-08-06):
> `NEXT Fri 2026-08-07 03:30, LAST Thu 2026-08-06 03:30:03` oraz zawartość
> `/opt/homelab/state/node-exporter/kb-ingest.prom`:
> `kb_ingest_embed_skipped 1`, `kb_ingest_embed_backlog 0`.
> Dzisiejszy tick **pominął oba etapy embedujące**, bo probe Ollamy nie
> odpowiedział. Dziś to nieszkodliwe: paperless nie generuje nowych chunków, więc
> backlog stoi na zerze i `KbEmbedBacklogGrowing` (próg: `> 0` przez `for: 72h`)
> nigdy nie ma czego mierzyć.
>
> **Dopięcie etapu mailowego do tego samego ticku zmienia to jakościowo.**
> Wpłynie ~60 nowych chunków na dobę, embed będzie pomijany każdej nocy,
> backlog zacznie rosnąć monotonicznie i po 72 h alert zapali się **na stałe** —
> nie sygnalizując żadnej awarii, tylko rozjazd harmonogramu z dobowym cyklem
> SOLARII. Alert, który świeci zawsze, przestaje być alertem.
Do rozstrzygnięcia razem z Decyzją **(d)**. Najtańsza poprawka: przesunąć etap
mailowy (albo cały `kb-ingest`) na godzinę, o której SOLARIA realnie pracuje —
w chwili tego reconu jest włączona od ~10:40, `uptime` 3 h 12 min. Alternatywa
bez zgadywania: uruchamiać etap indeksujący częściej (np. co 2 h) i pozwolić,
by probe Ollamy sam wybrał okno, w którym GPU odpowiada. Ta druga opcja jest
odporna na zmianę nawyków operatora i nie wymaga zgadywania, o której SOLARIA
wstaje — **rekomenduję ją.**
### 3.4 Obserwowalność — istniejący tor, bez nowych bytów
Tor jest gotowy i sprawdzony: job pisze plik `.prom` atomowo (tmp + rename) do
`/opt/homelab/state/node-exporter/`, node_exporter@PIHA go zbiera przez
textfile collector (mount `/:/host:ro` już to pokrywa), reguły idą do
`services/fleet-prometheus/rules/`, a dostawę do Telegrama robi brain-watchdog
odpytujący `/api/v1/alerts`. **Bez Alertmanagera** — tak jak `kb-ingest.yml`
i `liveness.yml`.
Proponowane metryki w `/opt/homelab/state/node-exporter/kb-mail-sync.prom`:
| Metryka | Typ | Znaczenie |
|---|---|---|
| `kb_mail_sync_last_run_timestamp` | gauge | ostatni tick (sukces lub nie) |
| `kb_mail_sync_last_success_timestamp` | gauge | ostatni tick bez twardego błędu |
| `kb_mail_sync_last_exit_code` | gauge | kod wyjścia |
| `kb_mail_sync_envelopes_inserted{account}` | gauge | nowe koperty w tym ticku |
| `kb_mail_sync_envelopes_skipped_dup{account}` | gauge | dedup po Message-ID |
| `kb_mail_sync_conflict_other_source{account}` | gauge | kolizja międzykontowa z §2.4 |
| `kb_mail_sync_last_message_ts{account}` | gauge | `max(envelope.ts)` dla konta |
| `kb_mail_sync_uidvalidity_resets{account}` | counter | ile razy serwer unieważnił numerację |
Reguły alertowe — i tu jest realna subtelność:
- `KbMailSyncStale`: `time() - kb_mail_sync_last_success_timestamp > 21600`
(6 h = 6 nieudanych godzinnych ticków), severity critical. **To jest właściwy
alert.**
- **„Zero nowych maili przez X dni" odradzam jako alert.** Zero nowych maili jest
legalnym stanem skrzynki — urlop, weekend, przeniesienie ruchu na drugie konto.
Alert na ciszę zapali się przy zdrowym systemie i zostanie wyciszony, po czym
przestanie działać wtedy, gdy będzie potrzebny. Ten sam skutek daje sygnał,
który **nie ma fałszywych trafień**: `kb_mail_sync_last_success_timestamp`
odpowiada na pytanie „czy poller w ogóle działa", i to niezależnie od tego,
czy cokolwiek przyszło.
- Jeśli mimo to operator chce ostrzeżenie o „martwej skrzynce", właściwą metryką
jest `kb_mail_sync_last_message_ts` (data najnowszego maila, nie licznik ticku)
z progiem **per konto** i severity `warning` — gmail jest kontem śmieciowym,
więc dla niego kilkudniowa cisza to anomalia, a dla fastmaila niekoniecznie.
Sugerowany próg gmail: 7 dni. Do rozstrzygnięcia po miesiącu obserwacji, nie
z góry.
---
## 4. Decyzje operatora
### (a) Gmail: IMAP + hasło aplikacji **czy** Gmail API / OAuth2
**Rekomendacja: IMAP + hasło aplikacji.**
- Zasada z `kb-overview.md` §61 mówi wprost: *„Protokół, nie provider. Ingest
pisany przeciw standardom (JMAP, IMAP, WebDAV), nie przeciw firmie →
przenośność"*. `kb-mail-pillar.md` §2 powtarza to dla Gmaila: *„adapter IMAP
(protokół, nie Gmail API → przenośność)*". Ta decyzja jest w repo już podjęta;
recon jej nie zmienia, tylko potwierdza, że nic nowego nie każe jej rewidować.
- Jeden adapter obsłuży oba konta. Gmail API dałby drugi, nieprzenośny tor kodu
dla konta, które sam operator opisuje jako **śmieciowe** (loginy, 2FA,
newslettery) — najgorszy możliwy stosunek nakładu do wartości.
- OAuth2 wymaga projektu w Google Cloud, ekranu zgody, cyklu odświeżania tokenu
i obsługi jego wygaśnięcia w jobie bezobsługowym. Hasło aplikacji to jeden
ciąg znaków w pliku `.env`, który już istnieje i już trzyma inne sekrety.
- **[do weryfikacji na żywo]** Hasła aplikacji wymagają włączonego 2FA na koncie
i bywają wyłączane politykami Workspace; przy koncie prywatnym powinny być
dostępne. Warto sprawdzić przy okazji, czy Google nie ogłosiło wygaszenia tej
metody — mój stan wiedzy sięga maja 2026 i **nie jest wiarygodnym źródłem dla
polityki dostawcy z sierpnia 2026**. Gdyby okazało się, że hasła aplikacji
odpadły, OAuth2 staje się przymusem, nie wyborem — reszta architektury się nie
zmienia, wymianie podlega wyłącznie sposób uwierzytelnienia w kliencie.
- **[do weryfikacji na żywo]** Gmail limituje dobowy transfer IMAP (rzędu
kilku GB). Przy ~1 800 zaległych maili (~225 MB) i ~4,6 MB/dobę bieżąco nie
ma to znaczenia, ale gdyby ktoś kiedyś chciał zaciągnąć IMAP-em pełną
historię — miałby.
### (b) Fastmail: IMAP + hasło aplikacji
**Rekomendacja: tak, IMAP + hasło aplikacji — i tym samym zamykamy otwartą
decyzję „unifikacja adaptera".**
To nie jest formalność, bo **stoi w sprzeczności z tym, co repo dziś zapisuje**:
`kb-mail-pillar.md` §2 i §7 oraz §10 planu fazy mailowej przewidują dla
Fastmaila **JMAP** (`jobs/fastmail-poller`), a `kb-mail-pillar.md` §9 trzyma to
jako decyzję otwartą: *„jeden wspólny IMAP dla Fastmail + Gmail (mniej kodu) vs
JMAP dla Fastmail + IMAP dla Gmail (JMAP bogatszy)?"*. Zlecenie tego reconu
mówi „IMAP (gmail + fastmail)", czyli rozstrzyga tę decyzję na rzecz unifikacji.
Recon się z tym zgadza:
- JMAP daje synchronizację po `state` — elegancką i **niepotrzebną przy dwóch
ticku na godzinę i 37 mailach na dobę**. UIDVALIDITY/UIDNEXT rozwiązuje ten
sam problem, a jest jedyną rzeczą, którą trzeba i tak zaimplementować dla
Gmaila.
- Jeden adapter = jeden zestaw testów, jedna klasa błędów, jedna ścieżka
hardeningu 8-bitowych nagłówków. Dwa protokoły to dwa razy tyle powierzchni
przy identycznym wyniku w bazie.
- Fastmail wystawia pełny IMAP i hasła aplikacji z ograniczonym zakresem
(można wydać poświadczenie tylko-IMAP, bez dostępu do panelu) — **[do
weryfikacji na żywo]**, ale jeśli tak, jest to *ściślejsze* uprawnienie niż
token JMAP.
- JMAP nie jest zamknięty na zawsze: koperta i archiwum są protokołowo obojętne,
więc wymiana transportu w przyszłości nie dotyka danych.
**Konsekwencja dokumentacyjna:** po zatwierdzeniu trzeba poprawić
`kb-mail-pillar.md` §2/§7/§9 i §10 planu fazy mailowej, żeby nie zostawiać
w KB dwóch sprzecznych zapisów. Nazwy jobów z tamtych dokumentów
(`jobs/fastmail-poller` + `jobs/gmail-imap-poller`) tracą sens przy jednym
adapterze — proponuję **jeden `jobs/mail-imap-sync`** sparametryzowany kontem.
### (c) Sekrety — gdzie trzymać hasła
> **Sprostowanie założenia.** Zlecenie mówi, że `POSTGRES_PASSWORD` leży dziś
> plaintextem w `services/*/service.yaml`. **Tak nie jest.** `service.yaml`
> wymienia wyłącznie *nazwy* zmiennych (`runtime.env_vars: [POSTGRES_PASSWORD]`
> — `services/kb-postgres/service.yaml:22`), pliki `env.example` zawierają
> placeholdery (`POSTGRES_PASSWORD=change-me-strong-password`), a `.gitignore`
> blokuje `.env` i `*.env`. Skan wszystkich śledzonych plików YAML pod kątem
> wartości wyglądających na sekrety (≥12 znaków, po odsianiu placeholderów)
> zwrócił **zero trafień**. Wzorzec repo jest poprawny i to jego należy użyć,
> a nie zastępować.
**Rekomendacja: rozszerzyć istniejący `/opt/homelab/kb/.env` na PIHA.**
Zweryfikowane na żywo: plik istnieje, ma uprawnienia **`-rw------- root root`**,
262 bajty, i trzyma już `KB_DSN`, `PAPERLESS_API_TOKEN` oraz
`ANTHROPIC_API_KEY`. `kb-ingest.service` czyta go przez
`EnvironmentFile=/opt/homelab/kb/.env`, mimo że sam biegnie jako `User=oskar`
systemd wczytuje `EnvironmentFile` **jako root, przed zrzuceniem uprawnień**.
Efekt: sekret jest wstrzykiwany do procesu, ale **nie jest czytelny dla
użytkownika `oskar` w spoczynku**. To jest lepsza własność, niż dałby plik
`600 oskar:oskar`, i warto ją zachować świadomie, a nie przypadkiem.
Dokładamy cztery klucze:
```
MAIL_GMAIL_USER=…
MAIL_GMAIL_APP_PASSWORD=…
MAIL_FASTMAIL_USER=…
MAIL_FASTMAIL_APP_PASSWORD=…
```
Repo dostaje **wyłącznie** `jobs/mail-imap-sync/env.example` z placeholderami —
dokładnie jak `services/*/env.example`.
Zasady wykonawcze, wszystkie wyprowadzone z tego, co już się w tym repo
wydarzyło:
1. **Nigdy w argv.** `--dsn <hasło>` trafia do `ps` i do historii powłoki.
Istniejące joby dopuszczają `--dsn`, ale honorują też `KB_DSN` z env —
nowy job niech przyjmuje poświadczenia **tylko** ze środowiska, bez
odpowiednika `--password`.
2. **Nigdy do transkryptu.** W repo są **dwa** udokumentowane przypadki rotacji
po wycieku do transkryptu sesji: token Paperless (`docs/sessions/2026-07-15.md`
§Krok 3) i klucz Anthropic (`docs/sessions/2026-07-21.md`, incydent 3).
To nie jest hipotetyczne ryzyko — to jedyny sposób, w jaki sekrety w tym
homelabie dotąd wyciekały. Instalacja hasła musi się odbyć bez wypisywania go
na ekran.
3. **Hasła aplikacji, nie hasła kont** — odwoływalne pojedynczo, bez dostępu do
ustawień konta i bez omijania 2FA.
4. **Bez własnego szyfrowania.** SOPS/age/vault to nowy byt operacyjny (klucze,
dystrybucja, odtwarzanie po awarii) dla jednego pliku na jednym hoście.
Nieproporcjonalne, i wprost sprzeczne z „użyj istniejących mechanizmów".
Odrzucone: sekrety w `hosts/<node>/runtime/…` (śledzone w Gicie);
`docker secret` (wymaga Swarma, którego repo świadomie nie używa —
patrz konwencja `mem_limit` w CLAUDE.md); osobny plik per job (rozdrabnia to,
co już jest scentralizowane i poprawnie uprawnione).
### (d) Host schedulera
**Rekomendacja: PIHA** — pełne uzasadnienie w §3.1 (24/7, archiwum kanoniczne,
lokalny kb-postgres, istniejący precedens `kb-ingest.timer`, decyzja
architektoniczna zakazująca stawiania na SOLARII czegokolwiek, co wymaga 24/7).
**Do rozstrzygnięcia razem z tym: takt indeksowania wobec dobowego cyklu
SOLARII** (§3.3). Rekomendacja: pobieranie co godzinę (`kb-mail-sync`, bez GPU),
indeksowanie częściej niż raz na dobę i bramkowane probe'em Ollamy, żeby
któryś tick trafił w okno pracy desktopu. Bez tego backlog embedów rośnie
każdej nocy i `KbEmbedBacklogGrowing` zapala się na stałe.
### (e) Zakres folderów per konto
**Rekomendacja:**
| Konto | Foldery | Uzasadnienie |
|---|---|---|
| gmail | **wyłącznie `\All`** (SPECIAL-USE, nie nazwa) | jedna wiadomość = jeden fetch mimo wielu etykiet; ta sama populacja co Takeout, na którym stoi 225 030 istniejących kopert → ciągłość korpusu |
| fastmail | `INBOX` + `Archive` + `Sent` | brak odpowiednika `\All`; te trzy pokrywają korespondencję prowadzoną i zarchiwizowaną |
Wyłączone z obu kont: `Spam`, `Trash`, `Drafts`. Spam i Trash to zdefiniowany
szum, a ich włączenie zmieniłoby populację względem tego, co już jest w bazie
(plan §Decyzja 4 odnotowuje, że Takeout „All Mail" nie zawiera Spamu). Drafty
nie są korespondencją — nie mają stabilnego Message-ID i mutują.
Zakres **musi być konfigurowalny listą per konto** (`MAIL_<ACCOUNT>_FOLDERS`),
bo tabela stanu jest kluczowana `(account, folder)`. Rozszerzenie o kolejny
folder jest wtedy zmianą konfiguracji, nie kodu, a dedup po Message-ID
gwarantuje, że dołożenie folderu z częściowo pokrywającą się zawartością niczego
nie zduplikuje.
**Osobne pytanie, którego nie rozstrzygam za operatora: historia Fastmaila.**
Konto ma zawartość sprzed dziś, a przyrostówka domyślnie zaczyna od
„od teraz" (`last_uid` = `UIDNEXT-1` przy pierwszym `SELECT`). Dwie opcje:
- **(e1) Tylko nowe.** Pierwszy tick zapisuje `last_uid` i nie pobiera nic
wstecz. Najprostsze, natychmiastowe, historia zostaje poza KB.
- **(e2) Pełny zaciąg.** Pierwszy przebieg z `UID SEARCH ALL` ściąga całą
zawartość wybranych folderów. Koszt zależy od rozmiaru skrzynki, którego
**nie znam** — to jest dokładnie ta „decyzja otwarta: sizing", którą
`kb-mail-pillar.md` §9 trzyma niezamkniętą od czerwca.
**Rekomendacja: (e2), ale dopiero po pomiarze.** Historia Fastmaila to
prawdopodobnie ta „sensowna poczta", o którą operatorowi chodziło bardziej niż
o gmailowe newslettery — a mechanizm jest ten sam co dla przyrostu, więc nie
kosztuje osobnego kodu. Warunek: najpierw jedno bezpieczne zapytanie
(`SELECT` folderu + `STATUS (MESSAGES)`), które poda liczbę wiadomości, i
dopiero na tej liczbie decyzja. Wykonalne w minutę **po** postawieniu klienta —
nie ma sensu zgadywać teraz.
### (f) Nowa decyzja, której zlecenie nie wymieniało: model stanu synca
Wypływa z §2.2 i wymaga zgody, bo dokłada **migrację `005`** do bazy, o której
faza mailowa deklarowała „zero migracji".
**Rekomendacja: tabela `mail_sync_state` w kb-postgres** (wariant A), nie plik
w `/opt/homelab/state/`. Powód rozstrzygający: stan synca i dane, którym
odpowiada, muszą się odtwarzać razem. Plik stanu przeżywający restore bazy
sprawia, że przyrostówka **cicho przeskakuje** wszystko między odtworzonym
stanem a bieżącym `last_uid` — awaria bez objawów, wykrywalna dopiero przy
zauważeniu brakujących maili miesiące później.
### (g) Nowa decyzja: `fastmail` w domyślnym trybie wyszukiwania
Z §2.5 (iii). `DEFAULT_SUMMARYLESS_SOURCES = ("gmail",)` sprawia, że koperty
fastmail byłyby niewidoczne w `/search` mimo poprawnego zembedowania.
**Rekomendacja: rozszerzyć do `("gmail", "fastmail")` w tym samym commicie,
który wprowadza źródło `fastmail`** — nie później. Rozdzielenie tych zmian tworzy
okno, w którym system wygląda na działający, a wyszukiwarka po cichu gubi całe
źródło. Zmiana jest jednowierszowa i nie ma wpływu na paperless (idzie kaskadą)
ani na inwariant startowy `kb-query` (sprawdza model embeddera, nie źródła).
---
## 5. Zależności z reconem multiagentowym i planem dyspozytora
Sprawdzony dokument: `docs/architecture/RECON-multiagent-2026-07-27.md`
**już nie istnieje pod tą ścieżką** — migracja OKF przeniosła go commitem
`00de810` do `kb/subsystems/recon-multiagent.md` (`type: subsystem`).
Analogicznie recony-audyty poszły commitem `9f77a72` do `kb/audits/`
(`type: audit`, wymagane pole `as_of`) — dlatego ten dokument leży
w `kb/audits/`, nie w `docs/architecture/`.
**Wprost: planu dyspozytora w tym reconie nie ma.** Dokument dotyczy w całości
subsystemu A (control-plane, node-agenty, MQTT, event pipeline, topologia).
`kb-query` pojawia się w nim wyłącznie jako wiersz inwentarza („kb-query | piha",
linia 129) i jako rozjazd topologii (linia 463: `topology.yaml` pomija
kb-query, choć `hosts/piha` go ma). Fraza „dyspozytor" nie występuje.
Dyspozytor jest zdefiniowany gdzie indziej —
`kb/decisions/architektura-2026-07-28.md`: *„B — do-the-work: dyspozytor zadań
(agent) + nogi KB / Home Assistant / homelab-ops. **Osobny wysiłek, osobny
projekt**"*, z PIHA jako *„przyszłym domem dyspozytora subsystemu B"*.
**Zależności twardych (blokujących) nie ma w żadną stronę.** Przyrostówka pisze
do `envelope`/`document_chunk`; kb-query czyta. Kontrakt między nimi to schemat
bazy, który się nie zmienia. Są natomiast **cztery realne styki**, które warto
mieć zapisane:
1. **Wybór hosta jest zgodny z docelową architekturą.** Dyspozytor ma zamieszkać
na PIHA; przyrostówka też tam trafia. Świeżość korpusu i jego konsument będą
w tym samym miejscu, bez nowych przeskoków sieciowych.
2. **Świeżość jest warunkiem sensowności dyspozytora.** Agent odpowiadający na
„co pisał X w zeszłym tygodniu" na korpusie urwanym 2026-06-19 zwróci
pewną siebie i nieprawdziwą odpowiedź. Przyrostówka jest tym, co zamienia
KB z archiwum w źródło bieżące — i to jest jej główny związek z subsystemem B.
3. **Wiki-kompilat jest zablokowany wprost.** `kb/phases/kb-m5-faza3.md:620`:
*„Pełna wiki po fazie mailowej (przyrostówka) — wcześniej kompilat byłby
fotografią przeszłości"*. To jedyna znaleziona **jawna** zależność „X czeka na
przyrostówkę" w całym KB.
4. **Nowy timer powiększa „shadow set" z otwartego pytania nr 5 tamtego reconu.**
Recon multiagentowy wymienia `kb-ingest` wśród bytów instalowanych **poza**
GitOps-owym wykrywaniem dryfu (*„shadow-deploy family: stability-agent,
agent-system, frigate, kb-ingest, HA config push"*) i stawia decyzję: co jest
w zakresie detekcji dryfu. `kb-mail-sync.timer` dołoży się do tej samej listy.
Nie blokuje to niczego i nie jest powodem do zmiany planu — ale przy
rozstrzyganiu pytania 5 lista będzie o jedną pozycję dłuższa i lepiej, żeby
trafiła tam świadomie niż przez przeoczenie.
Dla `kb-query` „API bez UI" przyrostówka nie zmienia **nic** po stronie
kontraktu HTTP: `/search` przyjmuje `q` i `mode`, a źródła są wewnętrznym
szczegółem retrievalu. Jedyny punkt styku to Decyzja **(g)** — bez niej nowe
źródło nie pojawi się w odpowiedziach ani przez UI, ani przez API.
---
## 6. Podsumowanie i zakres pracy
**Stan wyjściowy.** Korpus mailowy jest kompletny i zaindeksowany — do
**2026-06-19**. Od 48 dni nie przyrasta, i nie przyrośnie, bo w repo nie ma ani
jednej linii kodu rozmawiającej z serwerem pocztowym. Przy zmierzonym tempie
~37 maili/dobę poza KB jest już ~1 800 wiadomości, a licznik bije dalej.
**Co trzeba zbudować** (szacunek: 2 sesje, w tym testy):
| # | Element | Rodzaj |
|---|---|---|
| 1 | `jobs/mail-imap-sync` — klient IMAP (stdlib `imaplib`), pętla UIDVALIDITY/UIDNEXT, wybór folderu po SPECIAL-USE | nowy kod |
| 2 | Migracja `005_mail_sync_state.sql` | nowy plik |
| 3 | Wstawianie kopert z `entities = [headers, attachment]` w jednym kroku (reuse `parse_headers` + `parse_headers_fallback`) | reuse |
| 4 | `--source` / `--sources` w `mail_body_ingest.fetch_envelopes` + tryb „koperty bez chunków" | 2 małe zmiany |
| 5 | Zawężenie `fetch_existing_chunk_keys` do zbioru roboczego | 1 mała zmiana |
| 6 | `DEFAULT_SUMMARYLESS_SOURCES` += `"fastmail"` | 1 wiersz |
| 7 | Licznik `envelopes_conflict_other_source` | 1 mała zmiana |
| 8 | `systemd/kb-mail-sync.{service,timer}` + `kb-mail-sync-run.sh` | wzorzec 1:1 z `documents-ingest/systemd/` |
| 9 | Metryki `.prom` + reguła `KbMailSyncStale` w `services/fleet-prometheus/rules/` | wzorzec 1:1 z `kb-ingest.yml` |
| 10 | `env.example` + rozszerzenie `/opt/homelab/kb/.env` na PIHA | placeholdery w repo, wartości poza |
Punkty 47 to cztery drobne zmiany w istniejącym kodzie; **cały właściwy nowy
kod to pozycja 1**. Reszta pipeline'u — archiwum, koperta, parse z hardeningiem
8-bitowym, quote-strip, chunker, batch embed z breakerem i bisekcją, tor
Prometheus — jest zbudowana, przetestowana i przećwiczona na 225 tysiącach
wiadomości.
**Trzy rzeczy, które łatwo przeoczyć, a każda kosztuje cicho:**
1. Koperty bez `entities[type=headers]` dostają prefiks
`Temat: (brak tematu) | Od: ?`**bez błędu**, tylko z gorszym retrievalem
(§2.5 i).
2. `fastmail` nieujęty w `DEFAULT_SUMMARYLESS_SOURCES` znika z wyników mimo
poprawnego zembedowania — **bez błędu** (§2.5 iii).
3. Etap mailowy dopięty do ticku 03:30 zapali `KbEmbedBacklogGrowing` na stałe,
bo o tej porze SOLARIA śpi — potwierdzone dziś odczytem
`kb_ingest_embed_skipped 1` (§3.3).
**Do decyzji operatora przed startem:** (a) Gmail IMAP+app password,
(b) Fastmail IMAP+app password — co domyka otwartą od czerwca decyzję
„unifikacja adaptera" i **wymaga korekty** zapisów o JMAP w
`kb-mail-pillar.md` i §10 planu, (c) sekrety w istniejącym
`/opt/homelab/kb/.env` (**założenie zlecenia o plaintextowych hasłach w repo
jest nieprawdziwe** — obecny wzorzec jest poprawny), (d) PIHA + korekta taktu
indeksowania, (e) `\All` dla Gmaila i `INBOX`+`Archive`+`Sent` dla Fastmaila,
plus osobno historia Fastmaila po pomiarze rozmiaru skrzynki, (f) stan synca
jako tabela w bazie, (g) `fastmail` w domyślnym trybie wyszukiwania.