# Tech-debt backlog

Centralny tracker tech-długu i znanych usterek. Wpisy ze sesji — dodawaj z datą i kontekstem.

---

## M1 aktywna na SOLARII — cleanup node-agenta wyłączony do czasu R1 (2026-08-04)

**Data**: 2026-08-04
**Źródło**: `docs/incidents/2026-07-30-ollama-solaria-vanish.md` (§7, M1).
**Stan**: w `hosts/solaria/runtime/node-agent/docker-compose.override.yml` ustawiono
`NODE_TYPE=lte_node` (było `ai_node`) na czas backfillu embed (faza mailowa KB).
`lte_node` powoduje wczesny return w `run_safe_cleanup()`, więc na SOLARII nie działa
niefiltrowany `docker container prune` kasujący zatrzymane kontenery w ≤60 s —
łącznie z tymi, które mają `restart: unless-stopped` i zostały zatrzymane świadomie.
`self.node_type` jest czytane wyłącznie w `run_safe_cleanup()` i dwóch liniach logu,
więc monitoring, eventy i dispatch akcji działają bez zmian.
**Skutek uboczny**: `lte_node` pomija CAŁY cleanup — na czas mitygacji nie są sprzątane
dangling images ani build cache. Pilnować miejsca na dysku SOLARII.
**Nie zdeployowane** — zmiana jest tylko w repo (branch `task/m1-prune-mitigation`);
wchodzi przy najbliższym redeployu node-agenta na SOLARII.
**Zdjąć po**: wdrożeniu R1 (filtrowanie prune po restart policy / labelu compose) na tym
nodzie — wtedy przywrócić `NODE_TYPE=ai_node`. R1–R3 w toku po stronie subsystemu A.

---

## deploy-runner: instalacja na węzłach + E2E redeployu (2026-08-03)

**Data**: 2026-08-03
**Źródło**: sesja `task/redeploy-fix` — recon D14/D15 + OPEN QUESTION 4 (nieopróżniona
kolejka akcji).
**Było**: `redeploy` nie mógł się wykonać nigdy. Executor odpalał
`scripts/deploy/deploy-node.sh <node> <service>` **wewnątrz swojego kontenera** —
skrypt ignoruje oba argumenty i wymaga repo w `${HOME}/homelab-codex-ws`
(w kontenerze `HOME=/home/homelab`, katalog nie istnieje) → `exit 1` w 18. linii.
Za tym stały jeszcze trzy blokady: brak `git`, brak klienta `docker` w obrazie oraz
— gdyby przeszedł — deploy **całego zestawu usług hosta executora**, nie węzła
z akcji. Stąd `healthcheck_failed` przekierowany 2026-07-29 na `container_restart`
i 18 pending / 0 completed.
**Zrobione w repo (ta sesja)**: `scripts/deploy/deploy-service.sh` (deploy jednej
usługi, wspólny z `deploy-node.sh` — ta sama inwokacja compose, więc ta sama nazwa
projektu), `jobs/deploy-runner/` (systemd na hoście: rsync-pull akcji, walidacja,
deploy, `action_result` z powrotem), executor dispatchuje `redeploy` do
`actions/deploy/<node>/` i rozlicza je jak `container_restart`
(`REDEPLOY_TIMEOUT_SECS=900`). 248 testów zielonych.
**Do zrobienia (runtime, wymaga operatora)**:
1. Deploy control-plane na VPS (nowy executor + `../..:/repo:ro`).
2. Instalacja `jobs/deploy-runner/` na vps, piha, solaria — patrz README
   („Install (per node)"). Na VPS `VPS_EVENTS_HOST` musi zostać puste.
3. E2E na benignej usłudze na PIHA (wzorzec `test-e2e-b` z 2026-07-23), potem
   opróżnienie kolejki 18 pending — w tym `redeploy-vps-gokapi`.
4. SOLARIA: `group_add: "996"` dla node-agenta wciąż niewdrożony (recon 641-649) —
   dopóki nie wejdzie, `container_restart` tam nie działa (redeploy działa, bo nie
   idzie przez node-agenta).

---

## disk_cleanup w executorze jest zepsuty tak samo jak był redeploy (2026-08-03)

**Data**: 2026-08-03
**Źródło**: sesja `task/redeploy-fix` (znalezione przy okazji, poza zakresem zadania).
**Problem**: `executor._execute_disk_cleanup()` odpala `ssh oskar@<node> …`, a obraz
control-plane (`python:3.11-slim` + `pip install pyyaml`) **nie ma klienta ssh** —
`subprocess.run(["ssh", …])` leci `FileNotFoundError`, akcja ląduje w `failed/`.
To ta sama klasa błędu co redeploy i sprzeczne z decyzją „bez SSH w executorze".
**Fix**: przenieść `disk_cleanup` na model dispatch (node-agent albo deploy-runner —
runner ma już hosta i uprawnienia) albo usunąć typ akcji. Do decyzji przy okazji
opróżniania kolejki.

---

## `scripts/deploy/deploy-host.sh` to pusty plik (2026-08-03)

**Data**: 2026-08-03
**Źródło**: sesja `task/redeploy-fix`.
**Problem**: 0-bajtowy, wykonywalny stub w `scripts/deploy/` — wygląda jak
entrypoint, nie robi nic. Kandydat do skasowania (jak `deploy-role.sh` w etapie 0).

---

## Cutover HA "ken": kontener piha to legacy, prawdziwy dom to RPi4/HAOS (2026-07-22)

**Data**: 2026-07-22
**Źródło**: recon — dwie instancje HA równolegle sterowały domem (kontener
`homeassistant5` na piha + RPi4 HAOS 192.168.31.7), patrz
`services/home-assistant/DESIGN.md` sekcja "Incident log". `instances.yaml`
naprawiony w tej samej sesji: `ken` = 192.168.31.7 (api), `ken-legacy` =
dawny kontener piha (docker-exec, archived).

**Do zrobienia**:
1. **ha-diag-agent na piha**: przepiąć z `http://localhost:8123` (celuje w
   legacy!) na `http://192.168.31.7:8123` — wymaga nowego tokenu
   `diag_agent` wystawionego na instancji 31.7 (obecny token jest dla
   kontenera piha i nie zadziała na nowym targecie).
2. **Wygaszenie `homeassistant5`**: import archiwalny do
   `services/home-assistant/config/ken-legacy/` → `docker stop` (BEZ `rm`)
   → 7 dni obserwacji (upewnić się, że nic w domu nie polega na tym
   kontenerze) → decyzja o `docker rm`.
3. ✅ ZROBIONE (2026-07-22) — **Adapter `api` w `import.sh` dla `ken`**:
   automatyzacje/skrypty/sceny przez `/api/config/<domain>/config/<id>`
   (REST), dashboardy/area+entity registry/`input_*` helpery przez websocket
   API (`scripts/ha/lib/ha_api.py`, `ha_ws.py`, `import_api.py`). Pierwszy
   realny import `ken` zaimportował 118 automatyzacji, 5 skryptów, 3 sceny,
   7 dashboardów (default + 6 named; jeden zarejestrowany dashboard nigdy
   nie skonfigurowany — `config_not_found`, odnotowany w raporcie, nie
   twardy błąd). Pełny import `/config` pozostaje poza zasięgiem (HAOS bez
   SSH) — patrz DESIGN.md.
4. ✅ ZROBIONE (2026-07-22) — **`scripts/ha/deploy.sh`, adapter `api`, zakres
   automations/scripts/scenes**: drift-check (świeży re-import vs. `HEAD`,
   dowolna różnica poza plikami z tego deployu = abort z diffem) →
   walidacja (lokalny sanity check + `check_config` na instancji) → zapis
   per obiekt (`POST /api/config/<domain>/config/<id>`) → verify (GET +
   porównanie, bez auto-rollbacku). `--dry-run` zweryfikowany na żywym
   `ken` (read-only, bez różnic). Testy offline:
   `scripts/ha/tests/test_deploy_api_offline.sh`. Poza zakresem: dashboardy/
   helpery (WS, brak mutującej komendy), adapter `docker-exec`, DELETE
   obiektów usuniętych z repo (tylko ostrzeżenie).

---

## Nowy podprojekt: Home Assistant configs-as-code (szkielet)

**Data**: 2026-07-21
**Branch**: `task/ha-skeleton`

Szkielet struktury dla `services/home-assistant/` — configs-as-code dla
instancji HA (`ken` na PIHA, `chelsty-ha`). Na razie tylko struktura +
read-only import (`scripts/ha/import.sh`), bez deployu. Fazowanie, wybór
adaptera per instancja, model sync i otwarte pytania —
`services/home-assistant/DESIGN.md`.

---

## Plan: Monitoring floty — Prometheus jako źródło prawdy

**Data**: 2026-06-22
**Źródło**: sesja 2026-06-22 (`docs/sessions/2026-06-22.md`)
**Decyzja**: Prometheus (pull, `up{}`) zastępuje warstwę WYKRYWANIA liveness
(node-agent shipper + rsync/ssh + event-store + observer prune/checkpoint + ręczne TTL)
— przyczynę nawracających awarii (uid≠1000, ślepy ssh-mount, bloat ~242k eventów,
race prune↔checkpoint, NOMINAL-bez-TTL). Osobny fleet-Prometheus pod GitOps, **nie**
adopcja domowego instance PIHA. **BEZ** Alertmanagera — alert przez brain-watchdog.
Placement: VPS. Granica: zostają supervisor (remediacja), observer/panel, ha-diag-agent,
historia incydentów, out-of-band watchdog.
> Zastępuje wcześniejszy szkic (blackbox + Alertmanager) z sesji 2026-06-17.

**Kroki (priorytetowo)**:
1. Scaffold serwisu `fleet-prometheus` pod GitOps (worktree `task/fleet-prometheus`,
   wzorzec `services/vikunja/`): compose + `env.example` + `service.yaml` + README +
   `healthcheck.sh`; rejestracja w `hosts/vps/services.yaml` + `inventory/topology.yaml`;
   exposure `tailscale-internal`; pusty scrape na start (self + lokalny `node_exporter` VPS).
2. ✅ ZROBIONE (2026-06-26, commit `7d4014e`) — Inwentaryzacja nodów floty `100.x` do
   scrape. Dodane: piha/solaria/lustro (`node:` label), vps zachowany. Saturn pominięty
   (workstation), chelsty/chelsty-infra pominięte (node_exporter down z VPS → osobny wpis).
3. Container-layer exporter (cAdvisor lub lekki docker-state) — `node_exporter` nie widzi
   kontenerów.
4. ✅ ZROBIONE (2026-06-30, commit `d417000`) — Reguły liveness (`up==0 for: 5m`).
   `rules/liveness.yml`: `NodeDown expr up{node=~"vps|piha"}==0 for 5m severity critical`.
   Only always-on (vps, piha); solaria/lustro świadomie wykluczone (intermittent → anomaly
   detection). Deploy: fleet-prometheus Recreated (zmiana compose), reguła inactive=poprawnie.
5. ✅ ZROBIONE (2026-06-30, commit `62d6fc0`) — brain-watchdog: drugie wejście — poll
   Prometheus `/api/v1/alerts` (`firing`) → Telegram. Architektura A: dwa niezależne tory,
   mózg NIETKNIĘTY, debounce per-alert (klucz alertname:node w state.json). 12 testów pass.
   PENDING zamknięty 2026-07-02: poll POTWIERDZONY reconem (obraz zbudowany po `62d6fc0`,
   `PROMETHEUS_URL` w `.env` i w env kontenera, zero poll failed) — patrz
   `docs/infra/inventory-verify-2026-07-02.md`.
   ✅ END-TO-END UDOWODNIONE 2026-07-06 (Etap 0 cutoveru): tor Prometheus →
   watchdog → Telegram potwierdzony w produkcji testem `AlertTestEtap0`
   (firing → log polla → Telegram → rollback) — patrz `docs/sessions/2026-07-06.md`.
6. Rotacja tokenu HAOS w domowym prom (plaintext).
7. Przepięcie observer / panel `agents.okit.pl` na Prometheus jako źródło — największy
   znak zapytania przy cutoverze.
8. Parallel-run obok rury eventowej; cutover dopiero gdy Prometheus-truth się udowodni.

---

## Aktywne

### Bug upstreamu `GoogleCloudPlatform/knowledge-catalog`: generator grafu pomija linki od `/`

**Data**: 2026-07-31
**Źródło**: pilot fazy 5 — narty27 (`docs/sessions/2026-07-31-kb-f4-final-narty27.md`,
sekcja „Wnioski do przeniesienia na fazę 5", pkt 3)
**Problem**: generator grafu z `knowledge-catalog` pomija linki zaczynające się od `/`
(ścieżki absolutne), wbrew §5.1 **własnej spec** OKF, która je dopuszcza. Efekt: część
krawędzi grafu po prostu nie powstaje — cicho, bez ostrzeżenia. Rozbieżność
implementacja↔spec po stronie upstreamu, nie naszej konfiguracji.
**Obejście (zastosowane)**: własny generator grafu (cytoscape) w pilocie narty27 —
nie używamy generatora upstreamu.
**Fix**: zgłosić issue/PR do `GoogleCloudPlatform/knowledge-catalog` (minimalny repro:
dokument z linkiem `/foo` → brak krawędzi w wyjściu grafu, mimo §5.1). Jeśli upstream
naprawi — rozważyć powrót z własnego generatora przy fazie 5 (wiki-kompilat), żeby nie
utrzymywać własnego kodu bez potrzeby.

---

### `hosts/solaria/runtime/ollama/docker-compose.override.yml` — brak w repo (rozjazd repo↔runtime)

**Data**: 2026-07-31
**Źródło**: sesja 2026-07-31 (`docs/sessions/2026-07-31-kb-f4-final-narty27.md`,
sekcja „Otwarte po sesji" pkt 2)
**Problem**: `ollama` jest zadeklarowana w `hosts/solaria/services.yaml` (rola
`llm-inference`, exposure `private` = bind na `TAILSCALE_BIND_IP` + loopback), ale
`hosts/solaria/runtime/` zawiera wyłącznie `node-agent` i `stability-agent` — override
dla `ollama` **nie istnieje w repo**. Host-specific konfiguracja żywego kontenera
(rezerwacja GPU, bind, env) nie jest zatem wersjonowana: repo nie opisuje tego, co
faktycznie biega na SOLARII. Ta sama klasa rozjazdu repo↔runtime co przy węzłach
repo-less — dowolny redeploy z repo może wystawić serwis inaczej, niż działa dziś.
Wzorzec docelowy istnieje obok: `hosts/piha/runtime/ollama-piha/docker-compose.override.yml`.
**Fix**: zrekonstruować override z żywego kontenera na SOLARII (`docker inspect` →
bind/porty/GPU/env/limity) i zacommitować jako
`hosts/solaria/runtime/ollama/docker-compose.override.yml`; zweryfikować, że deploy
z repo daje kontener identyczny z obecnym **zanim** ktokolwiek zrobi redeploy.

---

### `services/narty27`: `exposure: private` w `service.yaml` + README vs faktyczna publiczna ekspozycja (NPM VPS + LE)

**Data**: 2026-07-31
**Źródło**: pilot fazy 5 — narty27 (`docs/sessions/2026-07-31-kb-f4-final-narty27.md`);
znalezione przy porządkowaniu backlogu po tej sesji
**Problem**: kontrakt serwisu deklaruje `private` — `services/narty27/service.yaml:5`
(`exposure: private # LAN/Tailscale only; no npm vhost, no public ingress`) i to samo
w `services/narty27/README.md` — podczas gdy `narty27.kapala.org` jest **publiczne**
(NPM na VPS + cert Let's Encrypt). Pole `exposure` steruje traktowaniem ekspozycji
przez agentów (patrz „Discovery Entry Points for Agents" w CLAUDE.md — `service.yaml`
jest kontraktem operacyjnym, z którego agent czyta, jak zarządzać serwisem), więc
rozjazd kontrakt↔rzeczywistość jest tu groźniejszy niż zwykła nieaktualna
dokumentacja: agent podejmie decyzję na podstawie pola, które kłamie.
**Fix (osobny task)**: (1) **najpierw** zweryfikować, co realnie konsumuje pole
`exposure` (observer / supervisor / ścieżka deployu) — dopiero to pokaże, czy poza
dokumentacją zmiana coś przestawia; (2) potem poprawić `service.yaml` + README na
`public`, z komentarzem wskazującym NPM VPS host **#14** i cert LE **#38**.

---

### `scripts/ha/deploy.sh --delete`: brak wspólnego toru kasacji automatyzacji

**Data**: 2026-07-27
**Źródło**: sesja porządków po audycie (`task/ha-porzadki`); kontekst bezpośredni:
kasacja pkt 10 (`36b43e5`, para Tymka/powitanie-test/notify-router/para-prototyp)
zrobiona pętlą ręcznych `curl DELETE` po API zamiast przez repo tooling.
**Problem**: `deploy.sh` ma tylko WRITE (`POST /api/config/<domain>/config/<id>`) —
zgodnie z DESIGN.md ("Deploy path") i istniejącym wpisem w tym backlogu (sekcja
"Cutover HA ken", krok 4) DELETE obiektów usuniętych z repo jest poza zakresem,
drift-check tylko ostrzega (`warnings`), nigdy nie kasuje. Efekt: jedyna droga
usunięcia automatyzacji z żywej instancji to ręczne wywołanie API, bez
drift-check/`check_config`/verify, czyli bez żadnej z gwarancji, które deploy.sh
daje dla write.
**Fix**: `deploy.sh <instance> --delete <plik...>` (albo `--delete` jako tryb pracy
na plikach usuniętych z repo, wykrytych przez `drift_warnings`) z tym samym rytmem
co write: dry-run domyślny, `--dry-run`/LIVE jak dziś, `DELETE
/api/config/<domain>/config/<id>` per obiekt, verify (GET → oczekiwane 404) zamiast
porównania treści. Scope jak przy write: automations/scripts/scenes.

---

### HA ken: `input_number.klima_salon_tolerancja` nie ma triggera — zmiana nie przelicza progu

**Data**: 2026-07-27
**Źródło**: sesja porządków po audycie (`task/ha-porzadki`), przy okazji przeglądu
`1784804667795` dla konwencji automatyzacji (pkt 17)
**Problem**: `"Klima salon: włącz chłodzenie i synchronizuj cel"` (`1784804667795`)
ma trigger `id: sync` na `input_number.klima_salon_temp_docelowa` (zmiana celu od
razu przelicza próg), ale brak odpowiednika dla `input_number.klima_salon_tolerancja`
— to ta sama klasa buga co "trigger brzegowy bez lustrzanego warunku" z audytu
(sekcja 3, wzorzec `klima_salon` z fixu `faa2e2a`), tylko po stronie triggerów, nie
warunków: zmiana tolerancji na dashboardzie nic nie przelicza do najbliższej
naturalnej zmiany `sensor.thsalon_temperature`.
**Fix**: dodać trigger `state` na `input_number.klima_salon_tolerancja` do gałęzi
`sync` (albo do analogicznej gałęzi w automatyzacji OFF, jeśli tolerancja wpływa też
na próg wyłączenia) w `1784804667795`, tak samo jak istniejący trigger na
`_temp_docelowa`.

---

### HA ken: guard TRV kalibracji przed sezonem grzewczym (~2026-09)

**Data**: 2026-07-23
**Źródło**: `services/home-assistant/docs/audyt-automatyzacji-2026-07-23.md` sekcja 1.4
(pkt 2 checklisty operatora)
**Problem**: kalibracje TRV sypialnia (`1764751049013`) i Tymek (`1765817937658`) liczą
`room_temp` jako średnią z czujnika zhimi, który jest `unavailable` od 2026-07-17 →
`float(0)` w formule zaniża temperaturę o połowę → kalibracja dojechała do −5.0
(potwierdzone w fixtures). Latem (TRV `off`) nieszkodliwe; w sezonie grzewczym =
trwałe przegrzewanie obu pokoi. Dodatkowo wszystkie 6 automatyzacji TRV pollują co
40–50s (baterie 20–38%), a SalonPrawy ma clamp `diff` ±5 zamiast ±1.5 jak reszta.
**Fix (przed sezonem)**: (1) guard `is not unavailable` na czujnikach zhimi zamiast
ślepego uśredniania; (2) ręczny reset kalibracji sypialnia/Tymek po naprawie; (3)
zwolnić pętle do ≥5 min lub trigger na zmianę stanu; (4) ujednolicić clamp SalonPrawy;
(5) reanimować albo wyłączyć TRV łazienki (`number.*_calibration` unavailable —
urządzenie zniknęło z sieci).

---

### HA ken: przycisk graceful shutdown klimy salonowej na kartę dashboardu

**Data**: 2026-07-23
**Źródło**: `services/home-assistant/docs/audyt-automatyzacji-2026-07-23.md` sekcja 3.1,
fix-pack 1 (`task/ha-fix-pack-1`, DESIGN.md „Decyzje operatora po audycie 2026-07-23")
**Kontekst**: po fix-packu 1 „Klima salon: wyłącz…" (`1784804668795`) respektuje
`klima_salon_auto = on` dla gałęzi sunset/balkon; suszenie parownika przy ręcznym
zgaszeniu `klima_salon_auto` działa już tylko jako efekt uboczny przełącznika trybu
auto (osobna gałąź `choose` na trigger `auto_off`). Brakuje wygodnego jednego
przycisku „wyłącz klimę i osusz teraz" niezależnego od przełącznika auto.
**Fix**: dodać na dashboard przycisk/skrypt wywołujący `script.klima_salon_dry_off`
bezpośrednio (bez przełączania `klima_salon_auto`), żeby ręczne graceful shutdown nie
wymagało znajomości wewnętrznej logiki automatyzacji.

---

### HA ken: diagnoza wspólnej awarii sprzętowej 2026-07-17 (czujniki ruchu, pilot 4button, xiaomi_miot)

**Data**: 2026-07-23
**Źródło**: `services/home-assistant/docs/audyt-automatyzacji-2026-07-23.md` sekcje 1.2,
1.6, 4.5 (pkt 1, 3, 15 checklisty operatora)
**Problem**: restart HA / update Supervisora 2026-07-17 15:07 zbiega się z
`unavailable` na: klaster czujników ruchu/obecności (mdwejscie, mdsypialnia, mdheli —
przynajmniej od restartu; occusalon/mdtymka/mdubikacja gasną w kolejnych dniach —
obraz siadających baterii), całej integracji xiaomi_miot (fan.zhimi_mb3/mc2, czujniki
temperatury zhimi używane w kalibracjach TRV) i `sensor.4button_battery` (mimo że pilot
4button strzelał jeszcze 2026-07-12 — 8 automatyzacji salonu na tym urządzeniu). Nie
jest jasne, czy to jedna awaria (Zigbee coordinator/integracja) czy zbieg kilku.
**Fix**: (1) sprawdzić fizycznie baterie/re-pairing 6 czujników ruchu + czujnik
wycieku WC (pkt 1 checklisty); (2) zdiagnozować integrację xiaomi_miot po stronie HA
zamiast łatać pojedyncze automatyzacje (pkt 3); (3) sprawdzić fizycznie baterię pilota
4button (pkt 15). Reanimacja czujników ruchu odblokuje też ~15 cicho martwych
automatyzacji (alerty on-leave, nocne gaszenie, `Poranek start`) — patrz audyt sekcja
1.2 dla pełnej listy skutków.

---

### HA ken: projekt „architektura night_mode" (konsolidacja sleep/night mode)

**Data**: 2026-07-23
**Źródło**: `services/home-assistant/docs/audyt-automatyzacji-2026-07-23.md` sekcje 2.2,
2.4, 6 (pkt 7, 9 checklisty operatora — świadomie NIE załatane w fix-packu 1, patrz
DESIGN.md „Decyzje operatora po audycie 2026-07-23")
**Problem**: cztery flagi trybu (`sleep_mode`, `night_mode`, `passive_mode`,
`movie_mode`) o częściowo pokrywającej się semantyce, sprawdzane niespójnie (raz jedna
flaga, raz druga, raz OR obu); `automation.turn_off_sleep_mode_at_sunrise` steruje w
rzeczywistości `night_mode`. Cztery nakładające się automatyzacje gaszą ten sam zestaw
świateł nocą (`1702844937214` martwa, `1763384250145` martwa, `1768946230585` enforcer
co ~10 min całą noc, `1700832676138` o 3:00) — po reanimacji martwych czujników (patrz
wpis wyżej) trzy z nich ożyją naraz i zaczną się ścigać. `1768946230585` ma dodatkowo
trigger `time_pattern /5` obok krawędziowego `off→on`, więc mimo aliasu „5 minut po
aktywacji" gasi światła cyklicznie całą noc — do decyzji, czy to zamierzone.
**Fix (projekt, nie one-liner)**: skonsolidować do jednego `input_select.tryb_domu` +
jednej automatyzacji „nocne domknięcie" z jasnym priorytetem trybów i enforcerem
(cykliczny dozorca vs. jednorazowe zadziałanie po aktywacji — decyzja pkt 7), plus
finalny/ostateczny wyłącznik nocny zastępujący dzisiejsze cztery. Zakres większy niż
fix-pack — osobny task.

---

### Executor: zepsuty JSON w `approved/` retry'owany w nieskończoność co 10s zamiast trafić do `failed/`/`rejected/`

**Data**: 2026-07-23
**Źródło**: sesja 2026-07-22/23 (`docs/sessions/2026-07-23-control-plane-remediation-e2e.md`)
**Problem**: podczas testów E2E remediacji bez SSH zepsuty JSON akcji w `approved/`
(przyczyna: podstawienie zmiennej lokalnej w cudzysłowie w zagnieżdżonym heredoc przez
ssh) powodował, że executor próbował go sparsować co cykl (10s), logując "Failed to
move ... to running: Expecting value" bez końca — plik nigdy nie trafiał do
`failed/`/`rejected/`.
**Fix**: executor powinien przenosić nieparsowalny JSON do `failed/` (albo dedykowanego
`malformed/`) po pierwszej próbie, nie zostawiać go do nieskończonego retry w `approved/`.

---

### Uprawnienia `actions/` naprawione TYLKO na PIHA (ręcznie) — SOLARIA i lustro niezweryfikowane

**Data**: 2026-07-23
**Źródło**: sesja 2026-07-22/23 (`docs/sessions/2026-07-23-control-plane-remediation-e2e.md`)
**Problem**: fix uprawnień `/opt/homelab/actions` (grupa `pi` + setgid, patrz Zamknięte)
zastosowany ręcznie tylko na PIHA. SOLARIA (oskar tam podobno uid 1000 — do sprawdzenia
czy problem w ogóle występuje) i lustro (repo-less, patrz osobny wpis niżej)
niezweryfikowane — remediacja na tych węzłach może paść identycznie przy pierwszej
próbie.
**Fix docelowy**: node-agent powinien sam tworzyć `dispatch/<node>/` z właściwymi
prawami (grupa współdzielona + setgid) przy starcie, żeby to nie wracało za każdym
razem jako ręczna interwencja — patrz też „Tech-debt: globalny porządek uid/gid" niżej.

---

### `deploy-local.sh` (control-plane): brak twardego checka, że `.env` istnieje i `TAILSCALE_BIND_IP` jest ustawione

**Data**: 2026-07-22
**Źródło**: sesja 2026-07-22/23 (`docs/sessions/2026-07-23-control-plane-remediation-e2e.md`)
**Problem**: przy fixie bindu `operator_ui.py:18180` odkryto footgun: bez `.env`,
`docker compose` tylko OSTRZEGA i po cichu wraca do bindu `0.0.0.0` zamiast failować —
dokładnie ten sam wzorzec błędu jak historyczny bug fleet-prometheus/deploy-node.sh
(brak `--env-file`, patrz Zamknięte, commit `686aca7`), tym razem w
`deploy-local.sh`/control-plane.
**Fix**: dodać twardy check w `deploy-local.sh` — `.env` musi istnieć i
`TAILSCALE_BIND_IP` musi być ustawiony, inaczej abort przed `docker compose up`.

---

### `operator_ui.py` nie ma ŻADNEJ autoryzacji

**Data**: 2026-07-22
**Źródło**: sesja 2026-07-22/23 (`docs/sessions/2026-07-23-control-plane-remediation-e2e.md`)
**Problem**: po zamknięciu publicznego bindu 18180 (patrz Zamknięte, commit `9a5c160`)
`operator_ui.py` nadal przyjmuje `/action/mutate` (w tym przejście do `approved`) bez
żadnego uwierzytelnienia — chroni tylko granica Tailscale mesh, nie autoryzacja per
operator.
**Fix**: osobny temat — do zaprojektowania (token/basic auth/mTLS w mesh), poza
zakresem fixu bindu.

---

### `alert_only` zapycha approval queue

**Data**: 2026-07-22
**Źródło**: sesja 2026-07-22/23 (`docs/sessions/2026-07-23-control-plane-remediation-e2e.md`)
**Problem**: przy reconie stanu wyjściowego 16 z 18 pending actions to były
liveness-transitions typu `alert_only`, nie realne decyzje do klikania — operator musi
przewijać szum, żeby znaleźć akcje, które faktycznie wymagają Approve/Reject.
**Fix**: rozważyć osobny widok/filtr dla `alert_only` w operator UI, albo
auto-acknowledge bez wejścia do tej samej kolejki co `container_restart`/`redeploy`.

---

### Crash-loop nie generuje `container_restart` — gap detekcja→akcja

**Data**: 2026-07-22
**Źródło**: sesja 2026-07-22/23 (`docs/sessions/2026-07-23-control-plane-remediation-e2e.md`)
**Problem**: `node_exporter` na PIHA miał 1048 restartów i event `[high]`, ale
supervisor nie wygenerował żadnej akcji `container_restart` — crash-loop widoczny w
evencie nie przekłada się na akcję remediacyjną.
**Fix**: zbadać routing supervisora dla crash-loop sygnałów (`disk_pressure` i
`containers_not_running` mają jasne mapowanie na akcje w CLAUDE.md — crash-loop
najwyraźniej nie).

---

### Telegram yes/no dla pending actions brakuje

**Data**: 2026-07-22
**Źródło**: sesja 2026-07-22/23 (`docs/sessions/2026-07-23-control-plane-remediation-e2e.md`)
**Problem**: brain-watchdog ma `send_telegram` (używane dla alertów Prometheus), ale
nic nie łączy go z `actions/pending` — operator musi wejść do operator UI, żeby
zatwierdzić/odrzucić akcję, zamiast dostać yes/no bezpośrednio na Telegramie.
**Fix**: rozszerzyć brain-watchdog (albo osobny konsument) o powiadomienie z
przyciskami approve/reject per pending action.

---

### `homeassistant5` na PIHA: `Exited (0)`, nie podnosi się mimo `restart: unless-stopped`

**Data**: 2026-07-22
**Źródło**: sesja 2026-07-22/23 (`docs/sessions/2026-07-23-control-plane-remediation-e2e.md`)
**Problem**: kontener `homeassistant5` (legacy HA "ken", patrz cutover
`docs/backlog.md` sekcja "Cutover HA ken") zaobserwowany w stanie `Exited (0)` — exit
code 0 = czyste zatrzymanie, więc Docker `restart: unless-stopped` świadomie go nie
podnosi (to nie crash). Do zweryfikowania, czy to zamierzone wygaszenie z cutoveru czy
coś zatrzymało kontener niezamierzenie.
**Fix**: sprawdzić czy to spójne z planem wygaszenia `homeassistant5` (patrz cutover
HA ken, krok 2 „Wygaszenie homeassistant5") — jeśli tak, brak akcji; jeśli nie,
zbadać czemu wyszedł z kodem 0.

---

### lustro: `node_agent.py` wymaga ręcznego wypchnięcia do `/opt/homelab/deploy/node-agent` (repo-less)

**Data**: 2026-07-22
**Źródło**: sesja 2026-07-22/23 (`docs/sessions/2026-07-23-control-plane-remediation-e2e.md`)
**Problem**: węzeł lustro nie ma repo/gita — `node_agent.py` (z fixami remediacji bez
SSH, commit `2dac154`) musi być ręcznie skopiowany do
`/opt/homelab/deploy/node-agent`, żeby dotrzeć na ten węzeł. Ryzyko rozjazdu
repo↔runtime identyczne jak przy innych repo-less węzłach.
**Fix**: ustalić mechanizm dystrybucji kodu node-agenta na węzły repo-less (rsync przy
deployu / paczka artefaktu / inny kanał) zamiast ręcznego kopiowania.

---

### 🔴 npm@VPS panel admina :81 publicznie osiągalny z internetu

**Data**: 2026-07-10
**Źródło**: sesja `scripts/npm/npm_api.py` (skrypt do zarządzania NPM przez REST API)
**Problem**: `services/npm/docker-compose.yml` mapuje `81:81` bez ograniczenia do
interfejsu — Docker bindem domyślnym wystawia to na `0.0.0.0`, czyli panel admina
NPM@VPS jest osiągalny z publicznego IP (`135.181.153.108:81`), nie tylko przez
Tailscale mesh (`100.95.58.48:81`). Panel admina (login+hasło, bez 2FA wymuszonego)
nie powinien być publiczny. Brak `hosts/vps/runtime/npm/docker-compose.override.yml`
ograniczającego bind.
**Fix**: dodać override z bindem `127.0.0.1:81:81` (dostęp tylko przez Tailscale/SSH
tunnel) albo `<tailscale_ip>:81:81`, zachowując `80`/`443` publiczne (to jest ich rola).
Zweryfikować po zmianie, że `npm_api.py --npm vps` nadal łączy się przez
`100.95.58.48:81`.

---

### Ghosty B WRÓCIŁY — hash-prefixed control-plane na VPS nieposprzątane

**Data**: 2026-07-06
**Źródło**: sesja 2026-07-06 (`docs/sessions/2026-07-06.md`) — snapshot panelu agents.okit.pl
**Problem**: hash-prefixed kontenery control-plane znów widoczne na VPS —
wpis „ZNIKNĘŁY (potwierdzone reconem 2026-07-02)" w Zamkniętych zdezaktualizowany.
**Fix**: ręczny `docker rm` hash-prefixed kontenerów control-plane na VPS;
przy okazji sprawdzić, skąd wróciły (fix A `3b71707` miał blokować źródło divergence).
**Update 2026-07-16** (sesja `docs/sessions/2026-07-16.md`): po odetkaniu supervisora
(event flood + pętla zamrożona, oba naprawione dziś) potwierdzone, że wpisy `error`
w topologii panelu to dokładnie te ghosty — hash-prefixed w world_state observera,
NIE żywe kontenery (`docker ps -a exited=0` na VPS). Observer nie prune'uje wpisów
po zniknięciu kontenerów spod tych nazw. To trzyma `System Status: ERROR` fałszywie.
**Fix (do zrobienia)**: observer powinien weryfikować faktyczny stan kontenera przy
budowaniu world_state i prune'ować wpisy dla kontenerów, których już nie ma (ta sama
klasa błędu co „Rozjazd world-state observera: NOMINAL przed istnieniem" niżej).

---

### shadow_mode → remediacja: decyzja o auto-restart

**Data**: 2026-07-16
**Źródło**: sesja 2026-07-16 (`docs/sessions/2026-07-16.md`) — wątek control-plane
**Problem**: po odetkaniu supervisora (event flood + pętla zamrożona, oba naprawione
dziś) kolejka akcji nadal pusta częściowo dlatego, że `shadow_mode=True` downgrade'uje
HA `container_restart` do `alert_only` — supervisor widzi problem, ale świadomie nie
enqueue'uje akcji restartu.
**Do decyzji**: czy i kiedy włączyć auto-restart padłych kontenerów — wymaga
architektury (guardraile, cooldowny, blast radius per serwis) zanim `shadow_mode=false`.
Patrz też istniejący wpis „ha-diag-agent deploy ZABLOKOWANY" niżej — przed
`shadow_mode=false` tam wymieniony konkretny target (`homeassistant5`).

---

### gokapi: deploy-node VPS rzuca błąd — brakujący `.env`

**Data**: 2026-07-16
**Źródło**: sesja 2026-07-16 (`docs/sessions/2026-07-16.md`) — wątek control-plane
**Problem**: `deploy-node.sh` na VPS rzuca błąd na serwisie gokapi z powodu brakującego
`.env` (`/opt/homelab/config/gokapi/.env` nieutworzony lub niepełny — wzorzec z sesji
2026-07-09 `docs/sessions/2026-07-09-kb-configi-gokapi.md`).
**Fix**: sprawdzić `services/gokapi/env.example`, utworzyć/uzupełnić `.env` na VPS wg
konwencji `env.example` → `/opt/homelab/config/<service>/.env`.

---

### elasticsearch + diskover w stanie error na PIHA — observer zna usunięte serwisy

**Data**: 2026-07-06
**Źródło**: sesja 2026-07-06 (`docs/sessions/2026-07-06.md`) — snapshot panelu agents.okit.pl
**Problem**: elasticsearch i diskover usunięte z PIHA w module 0 (2026-07-02),
ale observer wciąż je zna i raportuje `error` w panelu. World-state nie zapomina
serwisów, które przestały istnieć — ta sama klasa błędu co „NOMINAL przed
istnieniem" (patrz wpis z 2026-06-25).
**Fix**: wyczyścić martwe wpisy z world_state / dodać wygaszanie serwisów
nieobecnych w desired state i w dockerze.

---

### Gotchas (z 2026-06-30 — migracja kapala.org → Cloudflare/wildcard)

**Data**: 2026-06-30
**Źródło**: sesja 2026-06-30 (`docs/sessions/2026-06-30-kapala-cloudflare-wildcard-mesh.md`)

- **NPM custom WS config + Websockets Support:** NIE wklejać
  `proxy_http_version 1.1;` do Advanced gdy Websockets Support = ON.
  NPM dodaje tę dyrektywę sam → duplikat → nginx -t failuje → plik
  proxy_host/<id>.conf się NIE generuje → "unrecognized name" mimo
  dobrego certu. Objaw mylący (wygląda jak problem certu/DNS).
  Diagnoza: `strings /data/database.sqlite | grep <domena>` → pole nginx_err.

- **Cloudflare auto-proxy na import:** CF proxuje A/CNAME przy dodaniu strefy.
  DKIM CNAME (fm1/2/3._domainkey) proxied = zepsuty podpis maila.
  Zawsze przełączyć na DNS only (szara chmurka) przed aktywacją.
  Reserved/CGNAT IP (Tailscale 100.x) CF wymusza DNS only automatycznie.

---

### Migracja okit.pl → Cloudflare (większy projekt, firmowa domena)

**Data**: 2026-06-30
**Źródło**: sesja 2026-06-30 (`docs/sessions/2026-06-30-kapala-cloudflare-wildcard-mesh.md`)
Naprawi wszystkie wygasłe certy okit.pl naraz (HTTP-01 failuje przy mesh DNS):
ap, audiobooks, code-server, dysk, forgejo, ha-embed, hagc, ngpm, node-red,
okit.pl, pihole, ha.okit.pl. Wzorzec jak kapala.org: NS na CF, wildcard *.okit.pl
przez DNS-01, przepiąć hosty. UWAGA: okit.pl ma usługi publiczne (foty) —
rozdzielić mesh-only od publicznych. Ostrożnie — firmowa domena.

---

### foty.kapala.org renew failuje (#47, expired 2026-06-19, HTTP-01)

**Data**: 2026-06-30
**Źródło**: sesja 2026-06-30 (`docs/sessions/2026-06-30-kapala-cloudflare-wildcard-mesh.md`)
Foty są PUBLICZNE celowo (zewn. dostęp za hasłem NPM) — port 80 powinien
być dostępny, więc HTTP-01 powinno działać. Sprawdzić czemu failuje
(DNS foty wskazuje na zły IP? port 80 zablokowany?). NIE przenosić na mesh.

---

### Cleanup po błędnej ścieżce HA-Tailscale-addon (z 2026-06-29)

**Data**: 2026-06-30
**Źródło**: sesja 2026-06-30 (`docs/sessions/2026-06-30-kapala-cloudflare-wildcard-mesh.md`)
- ha-ken: Stop + Uninstall add-on Tailscale
- Tailscale admin: usunąć node ha-ken (100.98.128.40)
- 42.pl/okit.pl: usunąć rekord ha-ken → 87.205.110.38 (jeśli jest)

---

### Stary ha.okit.pl (cert wygasł 6/28, teraz "Not Used")

**Data**: 2026-06-30
**Źródło**: sesja 2026-06-30 (`docs/sessions/2026-06-30-kapala-cloudflare-wildcard-mesh.md`)
Zostawić lub usunąć proxy host + DNS. Niepilne (martwy, nie szkodzi).

---

### Stopniowa migracja usług domowych okit.pl → kapala.org (mesh-only)

**Data**: 2026-06-30
**Źródło**: sesja 2026-06-30 (`docs/sessions/2026-06-30-kapala-cloudflare-wildcard-mesh.md`)
dysk, audiobooks, budget, code-server, home, ha-embed, node-red... wg wzorca
migracji usługi na kapala.org (patrz sesja: CF rekord A → 100.108.208.3 DNS only,
NPM proxy host z wildcard *.kapala.org, Advanced PUSTE, weryfikacja grep+curl).

---

### `deploy-node.sh` nie reloaduje config-driven serwisów (cicha rozbieżność deploy↔config)

**Data**: 2026-06-26
**Źródło**: sesja 2026-06-26 (`docs/sessions/2026-06-26.md`)
**Problem**: po zmianie `prometheus.yml` (bez zmiany obrazu) `deploy-node.sh` NIE
recreate'uje kontenera. Compose widzi "kontener działa, obraz ten sam" → zostawia
Running, NIE podmienia configu → Prometheus trzyma stary config w pamięci. **Deploy
raportuje green, a zmiana configu nie wchodzi w życie.** Dziś wymagało ręcznego
`docker compose ... up -d --force-recreate`. Dotyczy KAŻDEGO serwisu config-driven bez
zmiany obrazu (nie tylko Prometheus).
**Fix**: po zmianie configu serwisu albo `--force-recreate`, albo POST `/-/reload` dla
serwisów z lifecycle API (fleet-prometheus ma `--web.enable-lifecycle`). Rozważyć
wykrywanie zmiany plików config w deploy i wymuszanie recreate.

---

### Zbadać: chelsty + chelsty-infra `node_exporter` DOWN z VPS

**Data**: 2026-06-26
**Źródło**: sesja 2026-06-26 (`docs/sessions/2026-06-26.md`)
**Problem**: przy inwentaryzacji targetów floty do fleet-prometheusa, `node_exporter`
na chelsty i chelsty-infra był nieosiągalny z VPS — pominięte w scrape. To LTE edge
(intermittent uplink), więc DOWN może być normą, ale wymaga rozróżnienia: brak
node_exportera vs odcięty uplink vs zablokowany port.
**Fix**: ustalić, czy node_exporter w ogóle działa na obu chelsty (compose/proces),
czy jest osiągalny po Tailscale z VPS, i czy ma sens go scrape'ować mimo LTE
(prawdopodobnie tak — `up==0` na LTE = sygnał dla anomaly detection, nie fałszywy alarm).

---

### Supervisor nie enqueue'uje akcji remediacji przy `error`-state

**Data**: 2026-06-25 (powtórka sygnału z 2026-06-19)
**Źródło**: sesja 2026-06-25 (`docs/sessions/2026-06-25.md`)
**Problem**: Action Queue pusta mimo `System Status ERROR` widocznego w panelu.
Supervisor nie generuje `container_restart` / `redeploy` dla serwisów w stanie `error`.
Objaw zaobserwowany co najmniej dwukrotnie — wymaga izolowanego dochodzenia.
Podejrzane: supervisor może nie reagować na error-state jeśli źródłem są ghost kontenery
(błędne project-name), nie realne health-check failures.
**Fix**: zbadać osobno — sprawdzić, czy supervisor otrzymuje właściwe eventy od observera,
czy ma własną logikę de-duplifikacji blokującą enqueue.
**Update 2026-07-02**: ghost kontenery (bug B) zniknęły z VPS — jeśli objaw wróci,
hipoteza "źródłem są ghosty" jest już nieaktualna. UWAGA: ślepy supervisor na SATURN
(brak mountu repo, patrz sesja 2026-07-02) to INNY przypadek — nie mylić z tym bugiem.
**Update 2026-07-16** (sesja `docs/sessions/2026-07-16.md`, druga połowa dnia): dwie
głębsze przyczyny pustej kolejki znalezione i naprawione (petla supervisora zamrożona
~24h — patrz „Supervisor: pętla zamrożona…" w Zamkniętych; event flood 358k plików
paraliżujący reconcile — patrz „Event flood…" w Zamkniętych). Po obu fixach supervisor
tika i reconcile się kończy, ALE objaw z tego wpisu (brak `redeploy` mimo widocznego
`error` — elasticsearch/diskover na piha, ollama solaria) **nadal aktualny** — drift→action
nie domyka się mimo odetkanego mózgu. Zostaje otwarte jako osobne dochodzenie.

---

### Rozjazd world-state observera: panel pokazuje serwis NOMINAL przed jego istnieniem

**Data**: 2026-06-25
**Źródło**: sesja 2026-06-25 (`docs/sessions/2026-06-25.md`)
**Problem**: panel wykazał `fleet-prometheus` jako nominal na SOLARII zanim kontener
w ogóle istniał — observer `world_state` rozjechany z dockerem. Artefakt rejestracji
w manifeście bez realnego kontenera. Podobna klasa błędu jak ghost kontenery.
**Fix**: observer powinien weryfikować faktyczny stan kontenera przy budowaniu world_state
zamiast opierać się wyłącznie na zarejestrowanych serwisach.

---

### 🔴 BLOKUJĄCE — FLOTA-BOMBA: node-agent SSH mount ślepy po recreate

**Data**: 2026-06-11
**Źródło**: sesja lustro ssh shipping fix
**Problem**: solaria/piha/chelsty to stare **root** kontenery node-agenta (piha Created
2026-05-27, uid 0) — sprzed dodania `user: "1000:1000"` do bazowego compose. Ich override
montuje klucz SSH w `/root/.ssh`, co działa tylko dla uid 0. Pierwszy `--force-recreate` /
reboot hosta / update obrazu przełączy kontener na uid 1000 (`homelab`, HOME=/home/homelab)
i shipping eventów na VPS padnie z "Permission denied" — dokładnie jak na lustrze
(naprawione `a5a1352`). `ssh` w `_ship_events_to_vps()` nie ma `-i` i szuka klucza
w `$HOME/.ssh`.
**⚠️ NIE RECREATE node-agenta na solaria/piha/chelsty przed fixem.**
**Fix**: ujednolicić mount → `/home/homelab/.ssh` we wszystkich
`hosts/*/runtime/node-agent/docker-compose.override.yml` (wzór: `hosts/lustro/`)
ALBO dodać `-i $HOME/.ssh/id_rsa` w `_ship_events_to_vps()`.

---

### ha-diag-agent deploy ZABLOKOWANY (placeholder token)

**Data**: 2026-06-11
**Źródło**: sesja — deploy config merged (`5e9db5c`), `.env` na piha utworzony
(`/opt/homelab/config/ha-diag-agent/.env`, chmod 600) ale token = PLACEHOLDER.
**Blokada**: chelsty-ha offline → brak tokenu i połączenia.
**Do decyzji**: cel HA — chelsty-ha vs HA Ken (`homeassistant5` na piha; z kontenera
NIE `localhost`).
**Przed `shadow_mode=false`**: target restartu w supervisorze = nazwa kontenera
`homeassistant5`; curl endpointu HA z tokenem = HTTP 200.

---

### observer-poison-quarantine — review brancha (`78c9e4a`)

**Data**: 2026-06-11
**Źródło**: sesja — patch Codexa zachowany na `task/observer-poison-quarantine`, NIE w master.
**Do zrobienia**: zweryfikować, czy observer realnie wiesza się na malformed evencie
(poison NIE był przyczyną awarii lustra — hipoteza niezweryfikowana, obalona przez
verify-before-fix). Realny bug → merge; inaczej → drop brancha i worktree.

---

### node_agent.py — drobne sprzątanie shippingu

**Data**: 2026-06-11
**Źródło**: sesja lustro ssh shipping fix
1. **Stale komentarz** `node_agent.py:546-548` — twierdzi, że kontener "runs as root";
   nieaktualne od `user: "1000:1000"`.
2. **Sukces shippingu na `logger.debug`** → podnieść do `info` lub dodać licznik —
   działający shipping jest niewidoczny w logach przy INFO, co utrudniało diagnozę
   (cicha awaria wyglądała identycznie jak ciche działanie).

---

### event-bloat: wyczyścić spłynięty backlog lustro na VPS

**Data**: 2026-06-11
**Źródło**: sesja — po fixie shippingu 7600+ plików backlogu spłynęło do
`/opt/homelab/events/lustro/` na VPS.
**Fix**: wyczyścić stare pliki (observer już je przetworzył); docelowo polityka retencji
w event-store.

---

### rsync `--omit-dir-times` (node-agent)

**Data**: 2026-06-09
**Źródło**: flota recovery session
**Objaw**: rsync exit code 23 po każdym push — `set-times` na katalogu `/opt/homelab/events/`
zwraca EPERM (oskar nie jest właścicielem katalogu; aerbot jest). Pliki są kopiowane poprawnie,
ale exit 23 zaśmieca logi i może maskować prawdziwe błędy.
**Fix**: dodać `--omit-dir-times` do wywołania `rsync` w `node-agent.py`.
**Lokalizacja**: `services/node-agent/src/node_agent.py` — wywołanie rsync w pętli push.
**Update 2026-06-11**: potwierdzone flotowo — każdy node loguje fałszywe
"Event shipping failed" (rsync code 23) co cykl, mimo że pliki przechodzą; katalogi
`/opt/homelab/events/*` na VPS należą do `aerbot`, klient nie ustawi na nich czasów.

---

### Deklaratywny zapis `oskar ∈ aerbot` w manifeście VPS

**Data**: 2026-06-09
**Źródło**: flota recovery — root cause: oskar spoza grupy aerbot(1000) → rsync Permission denied
**Problem**: przynależność do grupy jest zarządzana ręcznie (`usermod -aG 1000 oskar` ad-hoc).
Brak gwarancji po przeinstalowaniu VPS lub zmianie usera.
**Fix**: dodać do `hosts/vps/host.yaml` lub `hosts/vps/capabilities.yaml` sekcję
`users: oskar: groups: [aerbot]` — i wyegzekwować w deploy/bootstrap skrypcie VPS.
Alternatywa: zmienić właściciela `/opt/homelab/events/` na `oskar:oskar` i zaktualizować
node-agent deploy skrypty.

---

### Rozdzielenie worktree per task (agent.sh)

**Data**: 2026-06-09
**Źródło**: sesja — `homelab-codex-ws-node-onboarding` używany raz dla `task/node-onboarding`,
raz dla `task/fix-event-bloat` przez ręczne `git checkout`.
**Problem**: jeden worktree współdzielony przez dwa branche = anty-wzorzec. `git branch`
mogło wskazywać zły branch; `+` w listingu = pozornie "w innym worktree" ale nieprawda.
Prowadzi do commitowania na złej gałęzi.
**Fix**: egzekwować — jeden task = jeden worktree (`agent.sh new <task-name>`). Przy wejściu
do worktree zawsze `git branch --show-current` i weryfikacja `.agent-task`.
Długoterminowo: `agent.sh new` powinien odmawiać jeśli żądana gałąź jest już sprawdzona.

---

## Zamknięte

### Publiczny bind `operator_ui.py:18180` na VPS bez autoryzacji — NAPRAWIONE (2026-07-22, commit `9a5c160`)

**Data**: 2026-07-22
**Źródło**: sesja 2026-07-22/23 (`docs/sessions/2026-07-23-control-plane-remediation-e2e.md`)
**Było**: `operator_ui.py` (port 18180) nasłuchiwał na `0.0.0.0` na publicznym VPS
(`135.181.153.108`) bez żadnej autoryzacji. `curl` z zewnątrz do `/actions` zwracał
HTTP 200. `do_POST /action/mutate` przenosi akcje między stanami WŁĄCZNIE z
`"approved"` — dowolna osoba z internetu mogła zatwierdzić akcję remediacyjną.
Jedyne co chroniło do tej pory: executor nie umiał jeszcze wykonać akcji (brak SSH,
patrz wpis niżej) — przypadek, nie zabezpieczenie.
**Naprawione**: dual-bind wg wzorca fleet-prometheus — `127.0.0.1:18180:8080` +
`${TAILSCALE_BIND_IP}:18180:8080`, nowy `services/control-plane/env.example`.
Zweryfikowane po deployu: publiczny IP → HTTP 000, Tailscale → HTTP 200. Konsumenty
(node-agent VPS, materializer PIHA) nietknięte.
**Footgun zapamiętany**: brak `.env` → `docker compose` tylko OSTRZEGA i po cichu
wraca do bindu `0.0.0.0`, nie failuje — patrz wpis „deploy-local.sh: brak twardego
checka .env" w Aktywnych.
**Pozostaje osobno**: `operator_ui.py` nadal bez żadnej autoryzacji (bind zamknięty
chroni przed internetem, nie przed kimkolwiek w mesh Tailscale) — patrz Aktywne.

---

### Remediacja floty bez SSH: executor→node-agent pull przez rsync — ZROBIONE (2026-07-22, commit `2dac154`), E2E potwierdzone 2026-07-23

**Data**: 2026-07-22 (implementacja), 2026-07-23 (pierwszy udany cykl E2E)
**Źródło**: sesja 2026-07-22/23 (`docs/sessions/2026-07-23-control-plane-remediation-e2e.md`)
**Było**: łańcuch supervisor→pending→approved→running działał, ale executor próbował
`ssh oskar@{node} docker restart {container}` — kontener control-plane nie ma klienta
ssh, klucza, ani rozwiązywalnych nazw węzłów. Wykonanie zawsze failowało.
**Decyzja architektoniczna**: bez SSH w executorze (kontener na publicznym VPS z
powłoką na całą flotę = zły blast radius). Kierunek PULL: executor zleca (zapis
`actions/dispatch/<node>/<action_id>.json`), node-agent na docelowym węźle wykonuje
lokalnie przez własny `docker.sock`, wynik wraca eventem `action_result` przez
istniejący rsync-push. VPS nigdy nie inicjuje połączenia do węzła.
**Zabezpieczenia**: walidacja `node == self.node_name`; whitelist typów akcji =
`{container_restart}`; guard przed restartem samego node-agenta; brak wykonywania
dowolnych poleceń z payloadu; idempotencja (ponowne zlecenie = no-op). 183 testy.
**Potwierdzone w boju (2026-07-23)**: cykl `test-e2e-b` — Executing → Dispatched →
Completed w 31 sekund, `node_exporter` na PIHA realnie zrestartowany (uptime 30h→
minuty), zero połączeń SSH. Pierwszy w historii systemu pełny cykl remediacji.

---

### Uprawnienia `actions/` na PIHA (uid/gid oskar 1004 vs kontener 1000) — NAPRAWIONE ręcznie (2026-07-23, poza repo)

**Data**: 2026-07-23
**Źródło**: sesja 2026-07-22/23 (`docs/sessions/2026-07-23-control-plane-remediation-e2e.md`)
**Było**: pierwszy test E2E remediacji bez SSH padał — agent logował `[Errno 13]
Permission denied: /opt/homelab/actions/dispatch` co cykl. `/opt/homelab/actions`
było `oskar:oskar drwxr-xr-x` (utworzone w maju), a działający wzorzec to
`/opt/homelab/events` = `oskar:pi drwxrwsr-x` (grupa `pi`, zapis grupowy, setgid).
Ten sam motyw uid/gid (host oskar 1004 vs kontener 1000) uderzył już czwarty raz —
patrz sekcja „Tech-debt: globalny porządek uid/gid/uprawnień we flocie".
**Naprawione (ręcznie, tylko na PIHA)**: `chgrp -R pi` + `chmod -R g+w` + `chmod g+s`
na `/opt/homelab/actions`. Executor zachował się poprawnie podczas awarii: po 300s
timeoutu przeniósł akcję do `failed` z czytelnym powodem, nic nie zawisło.
**Pozostaje osobno**: fix zastosowany TYLKO na PIHA — SOLARIA i lustro
niezweryfikowane; docelowy fix systemowy to node-agent tworzący
`dispatch/<node>/` z właściwymi prawami przy starcie — patrz Aktywne.

---

### Supervisor: pętla zamrożona ~24h, healthy ale nie tika — NAPRAWIONE (2026-07-16, commit `409b583`)

**Data**: 2026-07-16
**Źródło**: sesja 2026-07-16 (`docs/sessions/2026-07-16.md`) — wątek control-plane
**Było**: kontener supervisora `healthy`, ale pętla `reconcile()` nie tikała od ~24h,
zero logów. Root cause zweryfikowany na `/proc` (`State:S hrtimer_nanosleep`, NIE
deadlock): `glob` po `EVENTS_DIR` co cykl przy 358k plikach → cykl przekracza brak
timeoutu → nigdy się nie kończy. Logi na DEBUG maskowały objaw.
**Naprawione**: każdy cykl w `ThreadPoolExecutor` z `future.result(timeout=90s,
env SUPERVISOR_RECONCILE_TIMEOUT)`; try/except owija cykl (wyjątek nie zabija pętli);
tick-log co 10 cykli (`SUPERVISOR_TICK_LOG_EVERY`) na INFO; healthcheck sprawdza
świeżość heartbeat, nie tylko czy proces żyje. Zweryfikowane w boju: pętla tika
(cycle #340→#480), cykl #1 timeoutował ale pętla kontynuowała = odporność działa.
**Lekcja**: „healthy kontener ≠ tikająca pętla" — healthcheck musi sprawdzać
świeżość ostatniego cyklu, nie samo czy proces odpowiada.

---

### Event flood 358k plików + retencja martwa od fixu checkpointu — NAPRAWIONE (2026-07-16, commit `dff76ec`)

**Data**: 2026-07-16
**Źródło**: sesja 2026-07-16 (`docs/sessions/2026-07-16.md`) — wątek control-plane
**Było**: `EVENTS_DIR` = 358k plików, 91% to `service_healthy` emitowany co cykl per
serwis (stan-jako-zdarzenie, antywzorzec). Retencja `_cleanup_control_plane_fs()`
była martwa od fixu checkpointu `d5139c9` (2026-07-15, patrz „Bug: checkpoint
observera po ścieżce leksykalnej" niżej) — porównanie `str(ścieżka) <= checkpoint_int`
rzucało `TypeError` cicho łapany przez szeroki `except` → backlog rósł bez ograniczenia.
Naprawiając checkpoint wczoraj, złamaliśmy retencję, która na nim polegała.
**Naprawione**: (a) node-agent emituje `service_healthy` tylko na transition
unhealthy→healthy (funkcja dla `observer.process_event` zachowana); (b) retencja
naprawiona epoch-do-epoch; (c) `scripts/maintenance/cleanup_event_backlog.py`
(dry-run + `--apply`). 150 testów pass. Cleanup wykonany na PIHA+VPS: 272 232 pliki
usunięte, backlog 358k→12,7k. **Wynik**: reconcile supervisora przestał timeoutować.
**Lekcja**: migracja typu pola (ścieżka→epoch int) musi audytować WSZYSTKICH
konsumentów tego pola — szeroki `except` maskował dokładnie tę klasę regresji.

---

### Ghost kontenery w panelu (Problem B) — ZNIKNĘŁY (potwierdzone reconem 2026-07-02)

> ⚠️ ZDEZAKTUALIZOWANE 2026-07-06: ghosty znów widoczne na VPS — patrz wpis
> „Ghosty B WRÓCIŁY" w Aktywnych (`docs/sessions/2026-07-06.md`).

**Data**: 2026-06-24 (wykryte), 2026-07-02 (zamknięte)
**Źródło**: sesje 2026-06-24/25/26; recon `docs/infra/inventory-verify-2026-07-02.md`
**Było**: martwe kontenery ze starych project-name'ów
(`8547b46c0317_control-plane-supervisor` itp.) raportowane przez observera jako
`error` → `System Status ERROR` w panelu mimo zdrowego mózgu.
**Zamknięte**: recon 2026-07-02 — 24 kontenery na VPS, ZERO hash-prefixed.
Prawdopodobnie recreate'y z kolejnych deployów je zmiotły. Zero akcji ręcznej.
Rozważenie czyszczenia obcych project-name przy deployu — już nieaktualne
(fix A `3b71707` blokuje źródło divergence).

---

### brain-watchdog: poll Prometheus — POTWIERDZONY (recon 2026-07-02)

**Data**: 2026-06-30 (pending), 2026-07-02 (zamknięte)
**Źródło**: recon `docs/infra/inventory-verify-2026-07-02.md`
**Było**: log startowy nie wypisuje `PROMETHEUS_URL` → brak pewności, że polling
aktywny; diagnoza opierała się na `.env` i braku błędów.
**Zamknięte**: recon potwierdził — obraz zbudowany po `62d6fc0`, `PROMETHEUS_URL`
w `.env` I w env kontenera, zero `poll failed` w logach. Poll aktywny.
Jednolinijkowy log startowy (`polling enabled/disabled`) pozostaje opcjonalną
kosmetyką. ✅ Pełne end-to-end (firing → Telegram) POTWIERDZONE 2026-07-06
testem `AlertTestEtap0` (Etap 0 cutoveru, `docs/sessions/2026-07-06.md`) —
bez czekania na realną awarię.

---

### Miny #1/#2/#3 z weryfikacji inwentaryzacji — ROZBROJONE (2026-07-02)

**Źródło**: `docs/infra/inventory-verify-2026-07-02.md`, sesja
`docs/sessions/2026-07-02.md` (tam szczegóły i lekcje).
- **#1 PIHA checkout**: gałąź wciąż `task/kb-gmail-import` po resecie z 2026-06-30
  (reset --hard przesuwa gałąź, nie przełącza) → `checkout master && pull`,
  30 commitów nadrobione.
- **#2 control-plane na SATURN**: supervisor ślepy (brak mountu repo) →
  `docker compose down`, wolumeny zachowane. Jedyny mózg = VPS.
- **#3 owner_node**: forgejo→piha, mosquitto→vps (commit `886bc85`).

---

### 🔴 KRYTYCZNY — `deploy.sh vps` niszczył control-plane — NAPRAWIONE (commit `3b71707`)

**Data**: 2026-06-24 (wykryte), 2026-06-25 (incydent w produkcji), 2026-06-26 (naprawione)
**Źródło**: sesja 2026-06-24 + 2026-06-25 + 2026-06-26 (`docs/sessions/2026-06-26.md`)
**Było (Problem A)**: `control-plane` w `hosts/vps/services.yaml` jako zwykły serwis
pętli `deploy-node.sh`. Pętla używała innego `COMPOSE_PROJECT_NAME` niż `deploy-local.sh`
(cwd=`services/control-plane`). Niezgodność → Recreate → `No such container:
<hash>_control-plane-observer` → `set -e` przerywa pętlę → observer/supervisor/executor/ui
znikają. Każdy `deploy.sh vps` rozkładał mózg (potwierdzone w produkcji 2026-06-25).
**Naprawione**: guard w `deploy-node.sh` pomijający serwisy z własnym
`services/<svc>/deploy-local.sh`. `control-plane` ZOSTAJE w `services.yaml` (gate
pytest+build nadal go testuje), pomijana jest tylko destrukcyjna pętla deployu.
**Potwierdzone w boju**: `deploy.sh vps` wypisał `Skipping control-plane: ma własną
ścieżkę deployu`, mózg `Up 25h healthy`, nietknięty.
**Pozostało osobno**: ghosty z poprzednich rozjazdów (Problem B — patrz Aktywne).

---

### Flaky testy control-plane — state-leak w pytest — NAPRAWIONE (commit `992ff7c`)

**Data**: 2026-06-24 (zgłoszone), 2026-06-25 (naprawione)
**Źródło**: sesja 2026-06-24 + sesja 2026-06-25 (`docs/sessions/2026-06-25.md`)
**Było**: `test_incident_lifecycle.py` flaky przez state-leak — `OBSERVER_STATE_FILE`
wyprowadzany przy imporcie, helpery patchowały `STATE_DIR` ale nie `OBSERVER_STATE_FILE` →
checkpointy pisane na realny dysk `/opt/homelab/state/` z ścieżkami otagowanymi numerem
przebiegu pytest. Gate deploy.sh czerwony przy zdrowym kodzie.
**Naprawione**: autouse monkeypatch fixture redirectujący WSZYSTKIE ścieżki stanu w tym
`OBSERVER_STATE_FILE`; usunięto buggy `_make_observer`; posprzątano zatruty realny checkpoint.
Weryfikacja: 6/6 przebiegów → 28 passed. Gate rzetelny.

---

### deploy-node.sh — brak `--env-file` (bind 0.0.0.0) — NAPRAWIONE (commit `686aca7`)

**Data**: 2026-06-25
**Źródło**: sesja 2026-06-25 (`docs/sessions/2026-06-25.md`)
**Było**: `deploy-node.sh` nie przekazywał `--env-file` do `compose up` → zmienne `.env`
(jak `TAILSCALE_BIND_IP`) interpolowały się do pustego stringa → bind `0.0.0.0` zamiast
Tailscale IP. Potencjalna dziura na publicznym VPS.
**Naprawione**: guard `if [ -f "services/<svc>/.env" ]` + `--env-file` per-serwis przed
`docker compose up`. Worktree `task/deploy-envfile-fix`, merge ff-only.

---

### Swap 2–4 GB na VPS — ZROBIONE

**Data**: 2026-06-22 (zgłoszone PENDING w sesji 2026-06-09 flota-recovery)
**Źródło**: sesja 2026-06-22 (`docs/sessions/2026-06-22.md`)
**Było**: VPS (3.7 GB RAM) z `swap=0` → OOM 2026-06-01.
**Zrobione**: `/swapfile` 4 GB aktywny i trwały (wpis w `/etc/fstab`); `vm.swappiness=10`
na żywo i w `/etc/sysctl.conf`. Weryfikacja: `free -h` → `Swap: 4.0Gi (0B used)`,
`/proc/swaps` zawiera `/swapfile`.
**Uwaga**: host-level one-off (nie czysto GitOps), udokumentowany jako celowy host-state
w `hosts/vps/host.yaml`. Brak commitu kodu — zmiana host-side gotowcem.

---

### Observer staleness — martwy node pokazywany NOMINAL

**Data**: 2026-06-08 (złapane), status: OTWARTY w sensie implementacji
**Problem**: observer/supervisor trzyma ostatni znany stan; brak heartbeat TTL.
Chelsty-infra milczy, ale status NOMINAL podważa zaufanie do panelu.
**Fix**: heartbeat TTL → po przekroczeniu oznacz status `stale` lub `down`.
**Powiązane**: brain-watchdog ślepy na per-node freshness.
*(Otwarty jako TODO implementacyjny — przeniesiony z sesji 2026-06-08)*

## Anomaly detection liveness — mózg uczy się wzorca dobowego per node (pomysł 2026-06-26)

**Idea**: zamiast statycznych okien czasowych w regułach alertowych (np. "lustro 7-23"),
mózg (supervisor/observer) czyta historię metryk z Prometheus (range queries / Grafana)
i SAM wykrywa wzorzec dobowy każdego węzła. `up==0` zgodne z nauczonym wzorcem offline
(lustro zwykle off nocą, solaria nieregularnie) = NIE anomalia, nie alarmuj. `up==0`
odbiegające od wzorca = realna awaria → alert. Inteligencja w mózgu + dane jako źródło
wzorca, nie sztywne godziny wpisywane ręcznie.

**Warunek**: wymaga TYGODNI historii metryk. fleet-prometheus postawiony 2026-06-25 →
realne dopiero za ~2-4 tygodnie, gdy uzbiera się wzorzec dobowy.

**Pułapka**: uczący się system może przeoczyć realną awarię pokrywającą się z typowym
oknem offline (statyczna reguła jest głupia, ale przewidywalna). Uwzględnić przy projektowaniu.

**Na teraz**: targety scrape'owane BEZ polityki alertowej, label tylko `node:`. Prometheus
gromadzi historię. Anomaly detection = osobny świadomy projekt później (CC, z testami).

---
## Rozjazdy repo<->rzeczywistosc (z inwentaryzacji 2026-06-30)
**Zrodlo**: `docs/infra/inventory-2026-06-30.md` (23 rozjazdy, pelna tabela tam).
**Weryfikacja 2026-07-02**: `docs/infra/inventory-verify-2026-07-02.md` — bilans:
20 wciaz aktualnych, 2 zmienione, 1 wyjasniony (storage SOLARIA = partycja Windows
dual-boot, NIE rozjazd — zdjety z listy).
Ponizej te wymagajace akcji, pogrupowane wg ryzyka. Naprawa = osobny task/kilka.

### Grupa A — czyste docs, zero ryzyka
- ✅ ZROBIONE (2026-07-02, commit `886bc85`) — **forgejo** `service.yaml owner_node`:
  saturn -> piha (biega na PIHA always-on)
- ✅ ZROBIONE (2026-07-02, commit `886bc85`) — **mosquitto** `service.yaml owner_node`:
  piha -> vps (biega na VPS, nie na PIHA)
- **capabilities SATURN**: RAM 8 -> 14GiB; dysk sd-card 64GB -> /dev/sda 159GB
- **capabilities SOLARIA**: CPU 24 -> 32 nproc
- **`hosts/saturn/services.yaml`** nie istnieje — 5 kontenerow bez deklaracji
- **`hosts/vps/services.yaml`** niekompletne (4 z 9 z topology); **solaria** tez (brak planner-agent)

### Grupa B — wymaga decyzji
- **npm x2**: PIHA (LAN ingress :80/:443) + VPS (public). Repo zna jedna (owner=vps).
  Decyzja: zostawic oba (intentional, wildcard cert via NPM@PIHA) czy usunac PIHA?
  Jesli oba zamierzone -> dodac piha do service.yaml + hosts/piha.
- ✅ ZROBIONE (2026-07-02) — **control-plane na SATURN**: `docker compose down`
  (wolumeny zachowane). Supervisor byl SLEPY (brak mountu repo, WARNING loop
  "Hosts directory /repo/hosts does not exist" co 30s) — zero ryzyka zdublowanych
  remediacji przez te 3 dni. Jedyny control-plane = produkcyjny na VPS.
- **ollama**: `service.yaml owner=solaria` ale NIE biega. Wdrozyc czy wyrzucic z repo?

### Grupa C — sprzatanie
- **control-plane-ui healthcheck**: uzywa `curl` ktorego NIE MA w obrazie -> failuje w
  kolko -> UNHEALTHY + log spam (4.2G syslog na SATURN). Fix: wget/nc w healthcheck
  albo curl w Dockerfile. (Przyczyna rozjazdu #6 znaleziona przy gaszeniu dysku.)
- **homeassistant5 na PIHA** (HA "ken" :8123) niedeklarowany -> dodac do hosts/piha + topology
- **VPS**: outline-postgres-1 anonimowy image (4e6e670bb069) -> named tag;
  humanai-landing/mailer/umami do repo. ~~joplin-db postgres:18 -> 17/16~~
  (ocena zdezaktualizowana 2026-07-02: PG18 GA od 09/2025, nie pre-release — bez akcji)
- **PIHA: 33 shadow kontenery** poza GitOps (immich, vaultwarden, wikijs, actual,
  audiobookshelf, elasticsearch, grafana, prom, portainer, code-server, diskover...)
  -> audyt + stopniowo do hosts/piha/services.yaml
- **zigbee2mqtt** topology mowi chelsty-infra, biega na PIHA -> poprawic topology
- **stability-agent / node_exporter** owner_node single, biegaja wielomiejscowo -> per-host

### Followupy z weryfikacji + rozbrajania min (2026-07-02)
**Zrodlo**: `docs/infra/inventory-verify-2026-07-02.md` + sesja 2026-07-02.
Zgloszone przy fixie owner_node (`886bc85`), swiadomie NIE ruszone — osobne decyzje.

- **forgejo** brak wpisu w `hosts/piha/services.yaml`; **mosquitto** brak
  w `hosts/vps/services.yaml` — schemat hostowy wymaga role/exposure/depends_on
  (miny #2/#3/#16 z audytu).
- **mosquitto na VPS bez mem_limit override** w `hosts/vps/runtime/` —
  narusza konwencje CLAUDE.md (kazdy serwis VPS deklaruje mem_limit).
- **drugi mosquitto na chelsty-infra** (offline'owa instancja) — pojedyncze
  `owner_node` jej nie opisuje; wzorzec per-host jak stability-agent /
  node_exporter (miny #17/#18).
- **topology.yaml:75**: mosquitto zadeklarowany tez jako komponent ai-cluster —
  rozstrzygnac, czyj jest broker :1883.
- **pi-watchtower-1 na LUSTRO w restart-loopie** (nowe z reconu; node-agent healthy).
- **alias `lustro` nie rezolwuje z SOLARII** (nowe z reconu).
- **fleet-prometheus bez formalnego override mem_limit** w `hosts/vps/runtime/` —
  limit siedzi w bazowym compose (kosmetyka).

### Po odchudzaniu PIHA (2026-07-02, faza 2 modulu 0)
- **llm-gateway: zlokalizowac/zarchiwizowac zrodlo** — kod (wlasny FastAPI router ->
  Ollama@SOLARIA) moze zyc TYLKO w `/opt/llm-gateway` na PIHA, bez gita; przeszukanie
  PIHA i repo nie znalazlo innej kopii. Zarchiwizowac do repo/Forgejo zanim padnie nosnik.
- **Prometheus@PIHA: target llm-gateway blednie nazwany `watchtower`** — celuje w :8080
  i odpytuje `/v1/metrics`, dostaje wieczne 404 (llm-gateway nie serwuje metryk).
  Naprawic nazwe/endpoint albo usunac target.

### Tech debt SATURN (z gaszenia dysku 2026-06-30)
- **`/opt/anaconda3` 16G** — najwiekszy pojedynczy zjadacz dysku (env-y Pythona). Decyzja Oskara kiedy/czy czyscic.
- Dysk 91% -> 83% ugaszone (docker prune + journal + syslog), ale `/home` zaszyfrowany
  i ciasny strukturalnie. SATURN dzwiga dev + drugi control-plane + agent-webui — napiecie.

## Tech-debt: globalny porządek uid/gid/uprawnień we flocie (2026-07-10)

**Diagnoza.** Flota NIE ma spójnej mapy uid/gid. "oskar" ma różne uid per host
(PIHA: 1004, inne hosty: prawdopodobnie 1000/inne). Kontenery agentów zakładają
uid 1000 (user "homelab"). Bind-mounty przenoszą SUROWE uid (nie nazwy) między
hostem a kontenerem → gdy uid hosta ≠ uid zakładany przez kontener, pliki stają
się "cudze" i wybucha cicha awaria (klucz nieczytelny, rsync nie tworzy plików,
socket permission denied). To NIE są przypadki — to systemowy brak kanonicznej
mapy uid/gid.

**Historia incydentów (dowód że systemowe):**
- 2026-07-10: node-agent PIHA (uid 1000 homelab) montował /home/oskar/.ssh (pliki
  uid 1004) → "Load key id_rsa: Permission denied" → rsync padał → 21 dni bez
  eventów (wykryte przez shadow-read). Fix: dedykowany /opt/homelab/agent-ssh
  chown 1000.
- Wcześniej: oskar spoza grupy `aerbot` na VPS → rsync push nie tworzył plików →
  brak cleanup → 8-dniowa cicha awaria floty. Fix: usermod -aG aerbot oskar.
- 2026-07-10 (świeże, PENDING): node-agent PIHA "Docker unavailable: PermissionError(13)"
  po recreate — agent nie czyta /var/run/docker.sock (grupa docker/uid). Osobny od
  shippingu (nie blokuje eventów), ale ten sam rodzaj problemu — do naprawy
  (grupa docker w kontenerze / gid socketu).
- LUSTRO uid pi=1000 vs PIHA oskar=1004 — różne uid "pierwszego usera" per host.

**Kierunek naprawy (do rozważenia, osobny projekt):**
- Ustalić KANONICZNE uid/gid per rola: agent=1000 wszędzie; dedykowane grupy dla
  współdzielonych zasobów (aerbot dla events/rsync-sink na VPS, docker dla socketu).
- Audyt `id <user>` na KAŻDYM hoście floty (saturn/solaria/piha/vps/lustro) —
  zmapować realne uid/gid, udokumentować rozjazdy.
- Rozważyć deklaratywny zapis oczekiwanych uid/gid w hosts/*/host.yaml lub
  capabilities.yaml (żeby deploy mógł weryfikować/wymuszać spójność).
- Agenci NIE powinni montować prywatnego .ssh użytkownika — zawsze dedykowany
  katalog z własnym kluczem pod właściwym uid (wzorzec z fixa 2026-07-10).

## Bug: checkpoint observera po ścieżce leksykalnej — kruchy, zatruwa węzeł na zawsze (2026-07-12)

**Objaw.** PIHA była "martwa" dla observera ~34 dni mimo działającego node-agenta.
Eventy dojeżdżały na VPS (7344 plików w /opt/homelab/events/piha/), ale observer
ich NIE konsumował — `last_seen` nie drgnął, shadow-read logował
`SHADOW_LIVENESS_MISMATCH node=piha event=dead prom=up` z rosnącym wiekiem.

**Root cause.** `observer_checkpoint.json` trzyma per-węzeł ostatnio przetworzoną
ŚCIEŻKĘ i porównuje ją LEKSYKALNIE (stringowo), awansując tylko "do przodu".
Checkpoint PIHA utknął na `evt-unknown-1781254800-ha_update_available-homeassistant-951.json`
(event z HA, który wpadł do katalogu piha/ z node="unknown"). Nowe eventy nazywają się
`evt-piha-<ts>-...`, a leksykalnie **"evt-piha-…" < "evt-unknown-…"** (bo `p` < `u`),
więc KAŻDY nowy event był uznawany za starszy niż checkpoint i pomijany.

**Fix doraźny (zastosowany).** Usunięcie wpisu `piha` z node_checkpoints + restart
observera → 7344 eventy przetworzone, `last_seen_age` spadł z 2 082 036 s (~24 dni)
do 19 s, status=online/fresh, mismatch zniknął.

**Fix systemowy (ZROBIONE 2026-07-14, `task/fix-observer-checkpoint`).** Checkpoint
per-węzeł trzyma teraz **TIMESTAMP** (int epoch), nie ścieżkę. „Nowy event" =
`ts_z_nazwy_pliku > checkpoint_ts_węzła`; kolejność przetwarzania sortowana po
timestampie, nie leksykalnie. Timestamp parsowany z nazwy `evt-<node>-<unixts>-…`
(regex `-(\d{9,11})-`, ten sam co `operator_ui._event_file_ts`); **fallback na
mtime** gdy nazwa nie pasuje — nieparsowalna nazwa NIGDY nie zwraca 0 (0 = leksykalne
„starszy niż checkpoint" = dokładnie ten poison). Migracja starych checkpointów
(ścieżka→ts) przy starcie; nieparsowalna wartość → 0 (reprocess wszystkiego —
bezpieczne, `process_event` jest idempotentne na `last_seen`/`world_state`; lepiej
przetworzyć duplikaty niż zgubić węzeł). Testy regresyjne w
`test_incident_lifecycle.py` (sekcja 9). Znany, akceptowalny warunek brzegowy:
strict `>` może pominąć event o `ts == checkpoint` dostarczony w PÓŹNIEJSZYM cyklu
niż inne eventy z tej samej sekundy — nierealne przy cadence shippingu (rsync co
60 s wysyła całą partię danej sekundy razem; kolejne partie są ~60 s od siebie).

**Uwaga do idempotencji (zbadane).** Reprocess tego samego eventu NIE psuje
world_state (status/last_seen deterministyczne, resolve incydentu guardowany na
`status=="active"`), ALE `_handle_incident`/`deployment_*` inkrementują
`occurrence_count` i dopisują do `events[]` przy każdym przetworzeniu — reprocess
(np. jednorazowo po migracji) zawyża te liczniki. To kosmetyka, nie korupcja stanu.
Docelowo można dedupować po `event.id` w `events[]` — osobny, drobny task.

## Bug: ha-diag-agent emituje eventy z node="unknown" do katalogu innego węzła (2026-07-14) — ZROBIONE (2026-07-15, `f2ba81b`)

**Kontekst.** To był plik-truciciel z buga checkpointu wyżej:
`evt-unknown-1781254800-ha_update_available-homeassistant-951.json` w
`events/piha/`. Node w evencie = `unknown`, ale plik wylądował w katalogu `piha/`.
Sufiks `-951` to `_seq` emittera → agent nachodził długo, wyemitował 951 eventów,
wszystkie jako `node="unknown"`.

**Root cause (config-wiring).** Tożsamość agenta (`node_name`) i KATALOG eventów
pochodzą z DWÓCH niezależnych źródeł:
- `services/ha-diag-agent/src/ha_diag/config.py:20` → `node_name: str = "unknown"`
  (domyślne, gdy env `NODE_NAME` nie dotrze do procesu w kontenerze).
- `services/ha-diag-agent/docker-compose.yml:12` → wolumen
  `/opt/homelab/events/${NODE_NAME:-ha-diag}:/events` — `${NODE_NAME}` jest
  interpolowane po stronie HOSTA (compose), a katalog jest dodatkowo twardo
  przypięty do `piha` w `hosts/piha/runtime/ha-diag-agent/docker-compose.override.yml`.

Jeśli `NODE_NAME` trafi do interpolacji wolumenu/override (→ `piha`), ale NIE do
`environment:` procesu (albo `Settings.load()` przez `os.environ.setdefault` go nie
nadpisze), aplikacja czyta `node_name="unknown"` i pisze eventy `node="unknown"`
do katalogu `events/piha/`. Rozjazd między nazwą w evencie a katalogiem docelowym.

**Skutek.** Poza zatruciem checkpointu (już naprawione osobno): eventy `node="unknown"`
są bezużyteczne dla world_state (observer tworzy węzeł-widmo `unknown`, potem prune go
kasuje bo nie ma go w topologii) — realny sygnał z ha-diag na piha przepada.

**Fix — ZROBIONE (2026-07-15, `f2ba81b`, `docs/sessions/2026-07-15.md`).** Wariant (a):
`node_name` NIGDY nie może być `"unknown"` w produkcji. `config.py`
`Field(default="unknown", validate_default=True)` + validator odrzuca `""`/`"unknown"`;
`main.py` → `SystemExit(1)` FATAL przy braku `NODE_NAME`; `EventEmitter.__init__` jako
ostatnia bramka przed nazwą pliku eventu. +18 testów, 0 regresji. Zmergowany i
zdeployowany na PIHA (rebuild, `NODE_NAME=piha` dochodzi do procesu). Pliki
`evt-unknown-*` na VPS/PIHA: 0 (potwierdzone).
**Pozostaje osobno (druga warstwa obrony, nadal TODO):** observer/emitter powinien
docelowo odrzucać/kwarantannować event, którego `node` w treści != katalog docelowy —
dzisiejszy fix zamyka źródło (`unknown` nie powstaje), ale nie waliduje spójności
node↔katalog dla innych, przyszłych źródeł eventów.

## Bug: deploy-local.sh control-plane pada na ghost-kontenerach i zostawia mózg rozłożony (2026-07-12)

**Objaw.** `bash deploy-local.sh` przerwał się w połowie:
`Error response from daemon: No such container: 5ee6fec5455d_control-plane-ui` →
deploy abort → **ZERO kontenerów control-plane** (`docker ps -a` pusty). Mózg całkowicie
down. Zdarzyło się DWA RAZY tego samego dnia. Ratunek: ponowny `deploy-local.sh`
(gdy ghost już zniknął, compose stawia czysto).

**Root cause (podejrzenie).** Ten sam COMPOSE_PROJECT_NAME divergence / ghost
hash-prefixed containers, co przy `deploy.sh vps` (naprawione guardem 3b71707).
Compose próbuje Recreate kontenera pod nazwą `<hash>_control-plane-ui`, której Docker
już nie zna → błąd → `set -e` przerywa deploy w połowie.

**Fix (DO ZROBIENIA).** deploy-local.sh powinien być odporny: cleanup ghostów przed
recreate (`docker compose down --remove-orphans` albo jawne usunięcie hash-prefixed
kontenerów), ewentualnie jawny `-p` (COMPOSE_PROJECT_NAME) żeby nazwy były deterministyczne.
Deploy mózgu NIE MOŻE zostawiać control-plane w stanie zero-kontenerów.

## Fix: paperless-worker@SOLARIA — dwa bugi configu, naprawione i zweryfikowane na żywo (2026-07-12)

**Kontekst.** Moduł 3 (`services/paperless-worker/`, split-host OCR worker) był
zdeployowany i brał zadania z kolejki, ale miał dwa bugi w compose:

1. **`command: celery ...` nie odpalał celery.** Obraz paperless-ngx
   (`/sbin/docker-entrypoint.sh`) routuje każdy argument NIE zaczynający się
   od `/` do `manage.py` — więc `celery` lądował jako nieznana subkomenda
   Django, nie jako program. Fix: `command: /usr/sbin/gosu paperless
   /usr/local/bin/celery ...` (ścieżka absolutna wchodzi w gałąź `exec "$@"`
   entrypointu; `gosu paperless` z przodu bo ta gałąź nie dostaje automatycznego
   gosu, inaczej proces poszedłby jako root i zepsuł właściciela plików na NFS).
2. **Brak współdzielonego `SCRATCH_DIR`.** Paperless@PIHA staguje wgrywany
   plik w `/tmp/paperless` (domyślny `SCRATCH_DIR`) i niesie tę ścieżkę w
   payloadzie zadania celery jako ścieżkę absolutną. Worker@SOLARIA miał
   własny, lokalny `/tmp/paperless` — gdy odbierał zadanie zamiast workera
   PIHA, padał `Cannot consume ...: File not found`. Fix: NFS volume
   `paperless_scratch` (ten sam wzorzec co `data/media/consume`) na
   `/opt/homelab/data/paperless/scratch` (już istniał na PIHA, `chown 1000:1000`),
   mount na `/tmp/paperless` po obu stronach.

Oba fixy + uzasadnienie: `services/paperless/docker-compose.yml`,
`services/paperless-worker/docker-compose.yml`, `services/paperless-worker/README.md`.
Zweryfikowane end-to-end na żywo (branch `task/paperless-worker-fix`, jeszcze
niezmergowany do master w momencie pisania tego wpisu): 3 dokumenty testowe
wrzucone do `consume/` na PIHA, jeden odebrany i dokończony przez worker@SOLARIA
(log: `ocrmypdf`/`tesseract` → `ConsumeTaskPlugin completed with: Success`),
zero `File not found`. Dokumenty testowe usunięte po teście (`document.delete()`
+ ręczny cleanup plików) — produkcyjne 6 dokumentów nietknięte.

**Otwarte (świadomie odłożone, nie blokuje działania):**
- `hosts/solaria/services.yaml` i `inventory/topology.yaml` nie mają wpisu
  `paperless-worker` (SOLARIA ma tam tylko `node-agent`) — było zaplanowane w
  cutover checkliście README jako krok "przy deployu", ale nigdy nie zrobione.
  Bez tego wpisu supervisor/observer nie widzą tego serwisu w desired-state —
  drift (np. worker padnie i nie wstanie) nie zostanie automatycznie wykryty
  przez agent system, tylko przez brak przetwarzania kolejki.
- Test formalnego fallbacku (stop worker@SOLARIA → kolejka mieli na PIHA →
  start → drenaż) nie był wykonany w tej sesji — mechanizm nie zmienił się
  tym fixem (był już OK), ale warto zweryfikować przy okazji.

## Nowe serwisy KB nie sa w monitoringu (desired-state)

**Data:** 2026-07-12

**Problem:** Zdeployowane serwisy filaru dokumentow nie maja wpisow w `hosts/*/services.yaml`
i `inventory/topology.yaml`, wiec supervisor/observer ich NIE WIDZA w desired-state:
- `paperless` + `paperless-db` + `paperless-broker` (PIHA) — Deploy 1, 2026-07-10
- `paperless-worker` (SOLARIA) — Deploy 2, 2026-07-12 (SOLARIA ma tam tylko `node-agent`)

**Skutek:** drift nie jest wykrywany. Jesli worker padnie i nie wstanie, albo paperless
przestanie dzialac — agent system tego nie zglosi. Dowiesz sie dopiero po tym, ze kolejka
nie jest przetwarzana / strona nie odpowiada.

**Fix:** dodac wpisy do `hosts/piha/services.yaml`, `hosts/solaria/services.yaml`,
`inventory/topology.yaml`. Zweryfikowac ze observer/supervisor je widza (healthcheck,
liveness). Dotyczy tez przyszlych: nextcloud, gokapi.

**Zasada na przyszlosc:** rejestracja w services.yaml/topology to CZESC deployu, nie osobny
krok "kiedys" — inaczej kazdy nowy serwis to slepy punkt monitoringu.

## Bug: deploy-node.sh nie przebudowuje obrazu — deploy "OK" ale nowy kod nie wchodzi (2026-07-15) — ✅ ZROBIONE (2026-07-16, commit `77defff`)

**Objaw.** `deploy-node.sh <serwis>` robi `docker compose up -d` BEZ `--build`. Dla
serwisów z Dockerfile (ha-diag-agent, node-agent, llm-gateway, brain-watchdog, itd.),
gdy zmienia się TYLKO kod (nie compose/env), compose widzi "kontener działa, obraz ten
sam" → status `Running` (0.0s), NIE przebudowuje i NIE recreatuje. Nowy kod z repo NIE
wchodzi w życie mimo `git pull` i "Deployment Complete".

**Skutek — cicha rozbieżność repo↔runtime.** Deploy raportuje sukces, a kontener biega
na starym obrazie. Ugryzło DWA razy: fleet-prometheus (config nie wchodził bez
force-recreate) i ha-diag-agent 2026-07-15 (fix node_name był w repo `f2ba81b`, ale
`Running` zamiast rebuild — trzeba było ręcznego `docker compose up -d --build
--force-recreate`).

**Root cause.** deploy-node.sh (~linia 110) w pętli deployu: brak `--build` w wywołaniu
compose. Docker cache'uje obraz po tagu, nie po zawartości src/.
compose. Docker cache'uje obraz po tagu, nie po zawartości src/.

**Fix — ZROBIONE (2026-07-16, `77defff`).** deploy-node.sh wywołuje teraz `--build`
warunkowo, gdy serwis ma top-level `Dockerfile` (`test -f services/<svc>/Dockerfile`);
prebuilt serwisy bez `--build` (no-op). Zweryfikowane w boju na PIHA: 6 serwisów
(node-agent/ha-diag/brain-watchdog/llm-gateway → Building; vikunja/kb-postgres → prebuilt).
**Follow-up pozostawiony**: `agent-system` ma build w podkatalogach bez top-level
Dockerfile — niezarejestrowany przez tę detekcję, osobny task.

## Ollama SOLARIA: brak sterownika NVIDII — ZAMKNIĘTE (2026-07-16)

**Kontekst.** Cutover 2026-07-15 (`docs/infra/ollama-solaria-cutover-2026-07-15.md`)
odkrył, że SOLARIA nie miała zainstalowanego żadnego sterownika NVIDII —
`nvidia-smi` nie istniał na hoście. `hosts/solaria/services.yaml` opisywał
ollama jako "GPU-backed" od dawna, ale to było aspiracyjne — Ollama zawsze
szła CPU-only. GPU reservation zakomentowana w
`services/ollama/docker-compose.yml` (`f57a01a`); item trafił do backlogu
jako blokujący fazę mailową embeddingów (moduł 5).

**Fix (2026-07-16).** Zainstalowany `nvidia-driver-595-open` z repo dystrybucji
(nie stary PPA `graphics-drivers` dla jammy — zdezaktywowany przez rename na
`.disabled`). RTX 4070 Ti SUPER 16GB, CUDA 13.2, `nvidia-smi` działa na
hoście. `nvidia-container-toolkit` był już obecny (doinstalowany jako
prerequisite przy cutoverze 07-15). GPU reservation przywrócona w compose.
Pomiar throughput GPU vs CPU baseline (0.79s/chunk) — patrz
`jobs/documents-ingest/README.md`, sekcja timing.

**Status:** ZAMKNIĘTE.

**Follow-upy pozostawione (osobne taski):**
- **Batching wywołań Ollamy** — przed fazą mailową (225k kopert). Sekwencyjne
  wywołania `/api/embeddings` (nawet na GPU) będą wąskim gardłem przy takiej
  skali; ocenić równoległość/batch API Ollamy.
- **`UNIQUE(envelope_id, chunk_index)` bez `model`** w `document_chunk`
  (`services/kb-postgres/init/002_chunks.sql`) — re-embedding innym modelem
  cicho no-opuje się przez istniejący constraint. Schema change do zrobienia
  przy fazie 3 (patrz `jobs/documents-ingest/README.md`, sekcja "Idempotency"
  kroku 6 embed).

## Follow-upy z etapu 0 (truth cleanup, 2026-07-29)

**Źródło**: sesja merge `task/etap0-truth` (topologia: status active|dormant,
listy serwisów usunięte z topology.yaml — node-level truth only).

- **40-register.sh emituje stary schemat topologii** — szablon bloku node'a
  w `scripts/onboard/steps/40-register.sh` nadal zawiera listę `services:`
  i nie ma pola `status:`; wyrównać z nowym schematem node-level-only
  (`inventory/topology.yaml`).
- **Historyczny komentarz mqtt_unreachable w observerze** — `scripts/observer/
  observer.py:866` wspomina routing `mqtt_unreachable -> container_restart`
  usunięty z supervisora (recon D15); sprzątnąć przy najbliższej edycji pliku.
