# Prometheus liveness cutover — recon starego toru (2026-07-06)

Read-only recon przed cutoverem liveności floty na Prometheus `up{}`. Mapa obecnego
toru: node-agent → eventy → rsync → observer → world_state → panel/supervisor,
z dokładnymi punktami przełączenia i etapowym planem. Zero zmian w kodzie —
tylko ten dokument.

**Korekta ścieżki względem założeń taska:** observer NIE żyje w
`services/control-plane/src/observer/` — to pojedynczy moduł
`scripts/observer/observer.py` (656 linii), a maszyna stanów liveności jest
wydzielona do `services/control-plane/src/liveness.py` (152 linie), importowanego
przez observer i oba panele.

**Trzy rzeczy, które wozi stara rura** (cutover dotyczy tylko #1):

1. **Liveność węzła** — heartbeat `node_health` → `last_seen` → TTL →
   `online/stale/offline`. To zastępuje Prometheus `up{}`.
2. **Bogate eventy aplikacyjne** — `containers_not_running`, `healthcheck_failed`,
   `disk_pressure`, `service_unhealthy`… Prometheus tego NIE wozi. Muszą zostać.
3. **world_state / panel / incydenty** — observer buduje je z eventów; po cutoverze
   panel musi brać liveność z nowego źródła, ale incydenty/serwisy dalej z eventów.

Kluczowe odkrycie recon: **heartbeat `node_health` jest jednocześnie nośnikiem
metryk zasobów (disk/mem/cpu) do panelu i supervisora** — więc cutover to zmiana
*źródła klasyfikacji liveności*, a NIE wyłączenie heartbeatu ani rsynca.
Prawie nic nie jest do „odłączenia"; stara rura zostaje fizycznie w całości.

---

## A. NODE-AGENT (`services/node-agent/`)

### A.1 Emitowane typy eventów

Wszystko przez `NodeAgent.emit_event()` — `services/node-agent/src/node_agent.py:154-177`.
Koperta: `id, timestamp (int epoch), date, type, severity, node, service, message, payload`
(`node_agent.py:162-172`); `id = evt-<node>-<unixts>-<type>-<svc_slug>` (`:161`).

| typ | severity | warunek emisji | miejsce | klasa |
|---|---|---|---|---|
| `node_health` | info | **co cykl, bezwarunkowo** (payload: `disk_pct, mem_pct, cpu_pct`) | `node_agent.py:636-640` | **#1 czysty heartbeat + nośnik metryk** |
| `disk_pressure` | high/medium | disk ≥ 85% / ≥ 75% | `:201-205`, `:208-212` | #2 bogaty |
| `high_memory` | high/medium | mem ≥ 95% / ≥ 85% | `:236-240` | #2 bogaty |
| `high_cpu` | medium | CPU ≥ 90% | `:266-267` | #2 bogaty |
| `containers_not_running` | high | kontener `exited/dead` z restart policy | `:338-343` | #2 bogaty |
| `healthcheck_failed` | high | kontener running, docker health `unhealthy` | `:348-352` | #2 bogaty |
| `service_healthy` | info | kontener running (per kontener, co cykl) | `:359-364` | #2 (potwierdzenie per-serwis) |
| `service_healthy/unhealthy` (control-plane) | info/high | tylko VPS: sonda `http://localhost:18180/summary` | `:588-604` | #2 bogaty |

node-agent **NIE** emituje `docker_api_error`, `mqtt_unreachable` ani eventów
tailscale — to repertuar stability-agenta (osobna rura, patrz A.4). Błąd Docker API
w node-agent to tylko `logger.error`, bez eventu (`:311`, `:367`).

### A.2 Zapis eventów

- `RUNTIME_PATH` default `/opt/homelab` (`node_agent.py:57`), `EVENTS_DIR = /opt/homelab/events` (`:59`).
- Zapis do `EVENTS_DIR/<node>/<event_id>.json` — **płaski katalog per-node, jeden
  plik JSON na event, bez partycji datą, nie JSONL** (`_node_events_dir` `:147-148`,
  zapis `:173-175`). Odmienna konwencja niż `scripts/lib/events.py:7,21,25` /
  `events.sh` (date-partitioned `events/YYYY-MM-DD/<node>/…`), które też lądują
  w tym samym drzewie — observer czyta oba (glob rekurencyjny, sekcja B.1).

### A.3 Transport na VPS

`_ship_events_to_vps()` — `node_agent.py:530-566`, wołane na końcu **każdego cyklu**
(`:642`), czyli co `CHECK_INTERVAL` (default 60 s, `docker-compose.yml:29`;
wszystkie overridy hostów ustawiają 60):

```
rsync -az --remove-source-files -e "ssh -F /dev/null -o StrictHostKeyChecking=no ..." \
  /opt/homelab/events/<node>/  oskar@100.95.58.48:/opt/homelab/events/<node>/
```

- **Push z węzła na VPS**, nie pull; VPS jest sinkiem i nie shipuje (guard `:538-539`).
- `--remove-source-files` (`:546`) — po udanym pushu eventy znikają lokalnie;
  bufor lokalny istnieje tylko przez okres braku łączności (offline-first dla LTE).
- Klucz SSH montowany z hosta (np. `hosts/piha/runtime/node-agent/docker-compose.override.yml`,
  wolumen `~/.ssh → /home/homelab/.ssh:ro`); timeout 30 s, błąd nie-fatalny (`:560-566`).

**Konsekwencja dla liveności:** `last_seen` węzła w world_state to w praktyce
„ostatni event, który *fizycznie dojechał* na VPS" — mierzy łącznie: node żyje +
Docker żyje + node-agent żyje + SSH/Tailscale do VPS działa. Prometheus `up{}`
mierzy węższą rzecz: node_exporter odpowiada na scrape z VPS. Różnica semantyk
jest realna (np. padnięty node-agent przy żywym node = stary tor mówi „dead",
Prometheus mówi „up") i musi wyjść w parallel-run jako *znana* klasa rozbieżności.

### A.4 stability-agent — osobna rura (nie dotyczy cutoveru)

`services/stability-agent/src/stability_agent.py`: pisze `events/YYYY-MM-DD/<node>/events.jsonl`
(append, `:22,47-53`) i publikuje do **Redis na piha** (`100.108.208.3`, `:57-70,289-344`).
Observer i supervisor globują tylko `*.json` (`observer.py:596`, `supervisor.py:538`),
więc **`events.jsonl` stability-agenta w ogóle nie zasila world_state** — jego eventy
(`docker_api_error`, `mqtt_unreachable`…) żyją w torze Redis. Cutover liveności go nie dotyka.
Nie występuje w żadnym `hosts/*/services.yaml` (tylko node-agent tam jest);
`service.yaml:3` mówi `owner_node: chelsty`, a deploy-skrypt obsługuje piha/chelsty/solaria/vps.

---

## B. OBSERVER (`scripts/observer/observer.py` + `services/control-plane/src/liveness.py`)

### B.1 Czytanie eventów

- `EVENTS_DIR = RUNTIME_PATH/events` (`observer.py:57-58`); `WORLD_DIR = /opt/homelab/world` (`:61`).
- `run_once`: rekurencyjny glob `EVENTS_DIR/**/*.json`, posortowany (`observer.py:596`).
- Checkpoint per-katalog-węzła: `/opt/homelab/state/observer_checkpoint.json`
  (`:62`, format `{"node_checkpoints": {...}}` `:77`, save `:193-197`); plik „nowy"
  gdy ścieżka > checkpoint danego katalogu (porównanie stringów, `:604-606`, awans
  tylko do przodu `:631-632`). Uszkodzone eventy → kwarantanna
  `state/observer_failed_events/<node>/` (`:100-118`, `:638`).
- Pętla co **5 s**, hardcoded (`:644-648`). Heartbeat `state/observer.heartbeat`
  co cykl (`:586-590`) — na nim wisi compose healthcheck (`services/control-plane/docker-compose.yml:30-35`).
- Uruchomienie: kontener `observer` na VPS, `python /repo/scripts/observer/observer.py`
  (`services/control-plane/docker-compose.yml:18-35`; `hosts/vps/services.yaml:17-34`).

### B.2 Wyprowadzanie liveności z eventów — DOKŁADNE punkty przełączenia

Dwie warstwy zapisu + jedna warstwa read-time:

**(1) Event-driven, `process_event` (`observer.py:420-470`):**

- **`observer.py:436`** — `world_state["nodes"][node]["last_seen"] = timestamp`
  dla **KAŻDEGO** eventu z węzła, bezwarunkowo. Jedyne miejsce zapisu `last_seen`
  z ingestu. ⟵ *switch point #1*
- `observer.py:438-441` — `node_online`→`status="online"`, `node_offline`→`"offline"`.
- `observer.py:443-453` — `node_health`: `status="online"` (`:446`) + metryki
  `disk_usage_pct/mem_usage_pct/cpu_usage_pct` (`:447-451`) + kasowanie
  `disk_pressure` gdy disk < 75 (`:452-453`). ⟵ *switch point #2 (część statusowa);
  część metryczna MUSI zostać*

**(2) Autorytatywna klasyfikacja TTL, `_prune_stale_world` (`observer.py:300-326`)** —
działa **co cykl, także bez nowych eventów** (`run_once:608-611`):

- `observer.py:312-314` — `compute_liveness(last_seen, now, ttls_for(node, roles))`.
  ⟵ *switch point #3 — GŁÓWNE miejsce podmiany źródła*
- `:315-317` — `UNKNOWN` (brak `last_seen`) → nie dotykaj statusu.
- `:319-322` — zapis `node_info["liveness"]` i `node_info["status"] = liveness_to_status(...)`.
- `:325-326` — na przejściu tieru → `_emit_node_transition`.

**Maszyna stanów — `services/control-plane/src/liveness.py`** (single source of truth,
importowana przez observer + `operator_ui.py` + `webui/web.py`, patrz docstring `:1-18`):

- Tiery `FRESH/STALE/DEAD/UNKNOWN` (`liveness.py:27-30`).
- TTL: default fresh ≤ 180 s / dead > 600 s (`:36-39`); remote (chelsty-*) 900/3600 s
  (`:43-46`); wybór w `ttls_for` (`:80-85`, `REMOTE_NODES={"chelsty-infra","chelsty-ha"}`,
  `REMOTE_ROLES={"remote"}` `:49-50`). Env: `LIVENESS_TTL_FRESH/DEAD`,
  `LIVENESS_REMOTE_TTL_FRESH/DEAD`.
- `compute_liveness` (`:88-103`): wiek `now - parse_ts(last_seen)` → tier.
- `liveness_to_status` (`:106-108`): fresh→online, stale→stale, dead→offline,
  UNKNOWN→None (nigdy nie zgaduje).
- `parse_ts` (`:64-77`): epoch int/float (node-agent) i ISO string (events.py) —
  oba formaty realnie występują w `last_seen`.

**(3) Syntetyczne eventy przejść — `_emit_node_transition` (`observer.py:199-254`):**
DEAD→`node_offline` (high), STALE→`node_stale` (warning), powrót do FRESH→`node_online`
(info) (`:217-224`); tag `source:"observer"` (`:237`), zapis do `EVENTS_DIR/<node>/` (`:248-250`).
Guard re-ingestu: `run_once` pomija eventy `source=="observer"` (awansuje checkpoint,
nie woła `process_event`), żeby nie wskrzeszać martwego węzła (`observer.py:620-628`).
**Po cutoverze ten mechanizm zostaje — zmienia się tylko to, co zasila klasyfikację
przejść.** Konsumentem jest supervisor (sekcja C.1).

### B.3 Pochodzenie pól world_state (`_save_world`, `observer.py:392-418`)

| plik | pola z liveności (#1) | pola z bogatych eventów (#2) | inne |
|---|---|---|---|
| `nodes.json` | `last_seen` (`:436`), `status` (`:438-446` + `:320-322`), `liveness` (`:319`) | `disk_usage_pct/mem_usage_pct/cpu_usage_pct` (`:447-451,459,465,470`), `disk_pressure` (`:458`), `memory_pressure` (`:464`), `cpu_pressure` (`:469`) | `roles` z `inventory/topology.yaml` (`:434`, `:126-130`) |
| `services.json` | — | `status` healthy/unhealthy, `last_check`, `incident_id` z `service_*`/`healthcheck_failed` (`:474-499`, `:560`) | |
| `deployments.json` | — | `deployment_*` po `correlation_id` (`:502-525`) | |
| `incidents.json` | — (**liveność węzła NIE tworzy incydentów** — tylko syntetyczne eventy → supervisor alert) | `service_unhealthy`/`healthcheck_failed`/`deployment_failed` → `_handle_incident` (`:529-560`, `:570-582`) | auto-resolve (`:342-377`), prune 7 d (`:384-390`) |
| `runtime-summary.json` | pośrednio (`node_count`) | `active_incidents_count`, `status` nominal/degraded (`:393-404`) | `last_update` — na tym wisi brain-watchdog |

Prune: węzły spoza `topology.yaml` usuwane (`:272-276`) — ważne przy dodawaniu
węzłów do Prometheusa: nazwa z labelki `node:` musi istnieć w topology.

### B.4 Świadomość Prometheusa w observerze

**Zero.** Grep `prometheus|up\{|api/v1|9090|node_exporter` po `scripts/observer/`,
`services/control-plane/src/` i testach — brak trafień (poza niepowiązanym
`index.html:688`). Liveność liczona wyłącznie z freshness eventów.

---

## C. KONSUMENCI world_state / liveności

### C.1 Supervisor (`services/control-plane/src/supervisor.py`)

- Czyta `world/{services,nodes,incidents}.json` w `_load_actual_state` (`:197-248`);
  pusty/ucięty plik → cały cykl reconcile pomijany (`:282-283`).
- **Z `nodes.json` używa TYLKO `disk_pressure`** (`reconcile:315-319` →
  `disk_cleanup` recommendation; `NO_DISK_CLEANUP_NODES` = chelsty-*, `:44`).
  **Żadna decyzja nie czyta `status`/`liveness`/`last_seen`.**
- **Pętla driftu/remediacji (`:288-308`) nie gate'uje na liveność węzła** — porównuje
  desired vs `services.json` i generuje redeploy/container_restart niezależnie od tego,
  czy węzeł żyje. (Jedyny hamulec: `monitor: false` w `services.yaml`, `:182-186`.)
- Liveność węzłów konsumuje **event-driven**: `NODE_ALERT_EVENTS = {node_offline,
  node_stale, node_online}` (`:94`) → `_route_node_event` (`:742-783`) → akcja
  `alert_only` z dedupem po `action_id` i cooldownem 1 h (`NODE_ALERT_COOLDOWN` `:95`).
  Komentarz `:88-94`: offline node = alert-only, bo nie da się go zrestartować.
- **Wniosek cutoverowy:** supervisor przeżywa zniknięcie pól liveności z `nodes.json`
  bez zmian; łamie się dopiero, gdy przestaną powstawać syntetyczne eventy przejść
  (cisza zamiast alertu node-down). `disk_pressure` i cały tor remediacji
  service-level = bogate eventy, nietykalne.

### C.2 Panel operator-ui (agents.okit.pl) — `services/control-plane/src/operator_ui.py`

- `/nodes` → `current_nodes` (`:73-99`): czyta `nodes.json`, per węzeł liczy `health`
  przez `liveness.node_health` (`_node_health` `:62-70`) — **read-time safety net**:
  worse(persisted `status`, freshness z `last_seen`) (`liveness.py:116-138`).
  Chroni przed zamrożonym observerem (frozen `nodes.json`) — freshness dalej się starzeje.
- `/services` → `current_services` (`:102-147`): czyta `services.json` **i** `nodes.json`,
  liczy `compute_liveness` per węzeł (`:116-123`) i kaskadę `degrade_for_node`
  (`:132-133`; `liveness.py:141-151`) — serwis na martwym węźle nigdy nie jest nominal.
- `/incidents` (`:171-192`), `/summary` (`:199-216`, `stale` gdy `last_update` > 60 s),
  `/events` (`:225-256`, czyta wprost `EVENTS_DIR`, nie world).
- Frontend `index.html`: badge `node.health` + `last_seen` (`:539,545`), karty topologii
  kolorowane po `health` (`:626-641`).
- **Wniosek cutoverowy:** panel jest NAJWIĘKSZYM konsumentem pól liveności.
  Bez `status`/`last_seen` w `nodes.json`: health „unknown", pusta kolumna last_seen,
  utrata safety netu i kaskady serwisowej. **Pola muszą być dalej pisane** —
  tylko zasilane z Prometheusa. Uwaga: safety net liczy z `last_seen`, więc dopóki
  heartbeat eventowy płynie, `last_seen` jest świeży i nie kłóci się z prom-statusem;
  gdyby heartbeat kiedyś wyłączyć, `node_health()` w `liveness.py` musi najpierw
  nauczyć się nowego źródła freshness (pułapka opisana w F, etap 4).

### C.3 Drugi panel — `services/agent-system/webui/web.py` (piha, :18180)

To samo co C.2, z degradacją: `liveness.py` bind-mountowane do `/app/liveness.py`
(`agent-system/docker-compose.yml:19`); przy braku modułu `_LIVENESS_OK=False`
i fallback do samego `status` (`web.py:14-18, 89-92`). Dane lustrzane przez
`runtime-materializer` (`materializer.py:72-107` — mirror endpointów HTTP control-plane
do lokalnych `world/*.json` na piha). Ten sam wymóg: pola `status`/`last_seen` muszą żyć.

### C.4 Telegram bot (`services/agent-system/telegram-bot/bot.py`)

Nie czyta `world/*.json` bezpośrednio. Dwa tory: (1) skan `/opt/homelab/actions/pending/`
(`:95-115`) — tędy przychodzi node-down alert (akcja `alert_only` supervisora);
(2) komendy przez HTTP control-plane (`fetch_api` `:27-40`): `/nodes` (`:258-275`,
renderuje `health`/`status`/`last_seen`), `/unhealthy` (`:302-334`), `/summary`, `/incidents`.
Łamie się dokładnie tam, gdzie panel.

### C.5 brain-watchdog (`services/brain-watchdog/src/brain_watchdog/main.py`, na PIHA)

**W pełni niezależny od world_state-liveności** — to już działający „nowy tor":

- Tor 1: `check()` (`:141-170`) — GET `{CONTROL_PLANE_URL}/summary`, staleness liczona
  lokalnie z `last_update` vs 600 s; wykrywa martwy mózg (observer/world_state).
- Tor 2: `check_prometheus_alerts()` (`:78-105`) — GET `{PROMETHEUS_URL}/api/v1/alerts`,
  filtruje `state=="firing"`, klucz `alertname:node`; `handle_prometheus_alerts`
  (`:108-138`) → Telegram raz na firing + recovery. Tylko `/api/v1/alerts`,
  **nie** `/api/v1/query`.
- `PROMETHEUS_URL` opcjonalne (`env.example:8-11`, przykład `http://100.95.58.48:9090`).

### C.6 Pozostali

- **Executor** (`executor.py`): zero zależności od world — czyta tylko
  `actions/approved/` (`:45-57`) i wykonuje po SSH (`:59-137`). Uwaga poboczna:
  wykona restart nawet na węźle, który observer ma za offline (brak gate'u).
- **Onboarding** `scripts/onboard/steps/50-verify.sh:110-142` — **twardy gate na
  `nodes.json` `status` ∈ {online, offline}** (krok 4/4). Musi przeżyć cutover
  (przeżyje, jeśli pole `status` dalej pisane).
- `scripts/deploy/verify-agent-fleet.sh:23-59` — liveness z Redisa (tor stability-agenta)
  + curl `/summary`,`/nodes`.

### C.7 Macierz: co się łamie

| konsument | pola liveness w nodes.json | syntetyczne node_offline/stale/online | brain-watchdog/Prometheus |
|---|---|---|---|
| supervisor remediacja | — (tylko `disk_pressure`) | — | — |
| supervisor node-alert → Telegram | — | **TAK — jedyny konsument** | — |
| operator-ui `/nodes`,`/services` | **TAK** (status, last_seen, kaskada) | tylko feed `/events` | — |
| webui (piha) | **TAK** (z fallbackiem) | feed eventów | — |
| telegram bot `/nodes`,`/unhealthy` | TAK (przez HTTP) | przez actions/pending | — |
| brain-watchdog | — | — | **TAK** (już działa) |
| executor | — | — | — |
| onboard 50-verify | **TAK** (`status` gate) | — | — |

---

## D. PROMETHEUS (`services/fleet-prometheus/`) — co jest, czego brakuje

### D.1 Co JUŻ jest

- Instancja na **VPS** (`service.yaml:3`), `prom/prometheus:v3.5.0`, port
  **tylko na Tailscale IP**: `${TAILSCALE_BIND_IP}:9090:9090`
  (`docker-compose.yml:31`; `env.example:10` = `100.95.58.48`). `mem_limit: 512m`,
  `oom_score_adj: 200` (`:40-41`) — **ubijalny przed control-plane (−900)**.
  Retencja 15 d / 2 GB (`:14-15`).
- Scrape (`prometheus.yml`): global 15 s (`:10`), external label `fleet: homelab-codex`
  (`:11-12`). Job `fleet-node` (`:36-52`) — po jednej labelce `node:` na target:
  `vps` (host.docker.internal:9100), `piha` (100.108.208.3), `solaria` (100.100.231.104),
  `lustro` (100.99.85.73). Świadomie NIE scrape'owane (`:54-60`): saturn (laptop),
  chelsty/chelsty-infra (exporter DOWN po LTE, 2026-06-26).
- Reguła `NodeDown` (`rules/liveness.yml:21-28`): `up{node=~"vps|piha"} == 0`,
  `for: 5m`, severity critical. solaria/lustro świadomie wykluczone (`:12-16` —
  planned power-off; docelowo anomaly detection, `docs/backlog.md:390-406`).
  Bez Alertmanagera by design (`:3-7`) — delivery = brain-watchdog poll `/api/v1/alerts`.
- node_exporter w repo tylko dla VPS (`services/node_exporter/`, network_mode: host,
  `hosts/vps/services.yaml:36-43`). Exportery na piha/solaria/lustro **nie mają
  definicji w repo** — istnieją poza GitOps. *(do weryfikacji na żywo: jak są
  postawione i czy wstaną po reboocie)*

### D.2 Czego brakuje, żeby observer pytał Prometheus o liveność

1. **Klient HTTP w observerze** — dziś zero (B.4). Potrzebne:
   `GET http://100.95.58.48:9090/api/v1/query?query=up{job="fleet-node"}` (stdlib
   `urllib`, jak w brain-watchdog). Observer to bridged kontener na VPS bez wspólnej
   sieci docker z fleet-prometheus — musi iść przez Tailscale IP hosta, bo port
   nie jest na `0.0.0.0`. *(routing bridged→tailscale-IP-hosta do weryfikacji na
   żywo; brain-watchdog z pihy działa przez mesh — sesja 2026-06-30)*
2. **Konfiguracja**: env `PROMETHEUS_URL` (opcjonalny → fail-open) w serwisie
   `observer` w `services/control-plane/docker-compose.yml:18-35`.
3. **Mapowanie zbiorów węzłów**: Prometheus zna {vps, piha, solaria, lustro};
   topology (`inventory/topology.yaml:34-116`) zna 7: + saturn, chelsty-infra,
   chelsty-ha. Observer musi wiedzieć, dla których węzłów `up{}` jest autorytatywne,
   a dla których zostaje freshness eventowa → **liveność hybrydowa per-node**
   (lista w konfigu lub dynamicznie: „węzeł ma serię `up`→Prometheus, nie ma→eventy").
4. **Mapowanie semantyki**: `up` jest binarne (1/0/brak serii), tier `stale` nie ma
   bezpośredniego odpowiednika. Propozycja: `up==1`→fresh; `up==0` przez < N s→stale,
   ≥ N s→dead (N z obecnych TTL); brak serii→UNKNOWN (nie dotykaj — dokładnie
   dzisiejsza semantyka `liveness.py:96-97`). Alternatywa odporniejsza na restart
   Prometheusa: `time() - timestamp(up)` jako „prom-last_seen" wpuszczone w istniejące
   `compute_liveness` — minimalna zmiana, te same TTL-e i te same przejścia.
5. **Watchdog na sam Prometheus**: po cutoverze Prometheus staje się źródłem prawdy,
   a nikt go nie pilnuje (brain-watchdog pilnuje mózgu i *konsumuje* alerty, ale nie
   alarmuje, gdy Prometheus umrze — poll po prostu cichnie). Potrzebny alert/telegram
   na błąd pollingu lub na `up{job="prometheus"}`.

---

## E. LUKA POKRYCIA

Węzły z `inventory/topology.yaml:34-116` (7): saturn, piha, solaria, vps,
chelsty-infra, chelsty-ha, lustro.

| węzeł | Prometheus scrape | NodeDown alert | node-agent (stary tor) | wniosek cutoverowy |
|---|---|---|---|---|
| vps | TAK (`prometheus.yml:40-42`) | TAK | TAK | pełne pokrycie oboma — kandydat na cutover |
| piha | TAK (`:44-46`) | TAK | TAK | jw. |
| solaria | TAK (`:47-49`) | NIE (`liveness.yml:12-16`) | TAK | cutover statusu OK; alert dalej brak (świadomie, anomaly detection later) |
| lustro | TAK (`:50-52`) | NIE | TAK (bez stability-agenta) | jw. |
| **chelsty-infra** | **NIE** (`:57-60`, exporter DOWN po LTE) | NIE | **TAK** (remote TTL 900/3600, `liveness.py:43-50`) | **tylko stary tor — cutover totalny zostawiłby go bez liveności** |
| chelsty-ha | NIE | NIE | NIE (`hosts/chelsty-ha/services.yaml:6-12`, `monitor: false`) | już dziś bez liveności (pośrednio przez MQTT chelsty-infra) — cutover nic nie zmienia |
| saturn | NIE (`:55`, laptop) | NIE | NIE (brak `hosts/saturn/services.yaml`, `docs/backlog.md:423`) | już dziś bez liveności — cutover nic nie zmienia |

**Chelsty offline ~34 dni — jak traktuje go stara rura:** eventy buforują się lokalnie
(rsync fail = non-fatal, `node_agent.py:560-566`), `last_seen` na VPS zamrożone sprzed
outage'u → `compute_liveness` z remote TTL (dead > 3600 s) klasyfikuje DEAD →
`status="offline"` w `nodes.json`, panel pokazuje error; syntetyczny `node_offline`
poszedł raz przy przejściu (potem cooldown/dedup). Po powrocie LTE: zaległe eventy
dojeżdżają rsynciem, `last_seen` skacze do przodu, tier wraca, `node_online` się
emituje. **Prometheus tego węzła w ogóle nie zna** (brak serii `up`) — po cutoverze
totalnym chelsty-infra nie miałby żadnej liveności i żadnego przejścia offline→online.
Dodatkowo docs sygnalizują konflikt IP w komentarzach `prometheus.yml:57` vs
`hosts/chelsty-infra/host.yaml:12` — do wyjaśnienia przy ewentualnym dodawaniu scrape.
*(rzeczywisty bieżący stan chelsty — do weryfikacji na żywo; ostatni zapis:
UNREACHABLE, `docs/infra/inventory-verify-2026-07-02.md:17,151`)*

**Wniosek twardy:** cutover NIE może być globalny. Docelowa architektura to
**hybryda per-node**: `up{}` dla scrape'owanych (vps, piha, solaria, lustro),
freshness eventowa dla chelsty-infra (i każdego przyszłego węzła bez exportera).
To zresztą wymusza zachowanie całej ścieżki eventowej liveności w kodzie — czyli
naturalnie wychodzi „source per node", nie „wyrwanie starego kodu".

---

## F. REKOMENDACJA CUTOVERU — plan etapowy

### Zasada nadrzędna

Cutover = **podmiana źródła klasyfikacji liveności w jednym miejscu**
(`observer.py:312-314` + neutralizacja event-driven flipów `:438-446` dla węzłów
prom-sourced), przy zachowaniu w całości: rsynca, bogatych eventów, heartbeatu
`node_health` (nośnik metryk disk/mem/cpu + `last_seen` dla safety netu paneli),
syntetycznych eventów przejść (tor alertowy supervisora) i pól `status`/`liveness`/
`last_seen` w `nodes.json` (kontrakt paneli, bota i onboardingu). **Nic nie jest
fizycznie odłączane.**

### Etap 0 — dowód bojowy istniejącego toru Prometheus (bez zmian w repo)

- Zweryfikować na żywo: `PROMETHEUS_URL` ustawione w `/opt/homelab/config/brain-watchdog/.env`
  na piha; poll działa; symulowany NodeDown (stop node_exporter na piha na > 5 m)
  dociera na Telegram i wysyła recovery. Sesja `docs/sessions/2026-06-30-prometheus-liveness.md:84-91`
  wprost mówi, że to *nie było jeszcze proven end-to-end*.
- Zweryfikować, że observer-kontener (bridged, VPS) dosięga `http://100.95.58.48:9090`
  (curl z wnętrza kontenera).
- **Weryfikacja:** alert + recovery na Telegramie. **Rollback:** n/d (nic nie zmieniamy).

### Etap 1 — PARALLEL-RUN (shadow read w observerze)

- Zmiana tylko w `scripts/observer/observer.py` (+ env w compose): opcjonalny
  `PROMETHEUS_URL`; co cykl (z wewnętrznym cache ~30–60 s, żeby nie odpytywać co 5 s)
  `GET /api/v1/query?query=up{job="fleet-node"}`; wynik → **nowe, nienaruszające pola**
  w `nodes.json`: np. `prom_up` (0/1/null), `prom_scrape_age_s`, `prom_liveness`
  (tier liczony przez ten sam `compute_liveness` z prom-last_seen).
- **Żadnej zmiany `status`/`liveness`/`last_seen`.** Rozbieżność
  (`prom_liveness != liveness` u węzła znanego obu źródłom) → log WARN +
  (opcjonalnie) event `liveness_source_mismatch` info — widoczny w feedzie `/events`,
  nieroutowany przez supervisor.
- Fail-open: timeout/błąd/brak env → pomiń, stary tor bez zmian.
- Testy: jednostkowe na mapowanie odpowiedzi API → tiery (mock HTTP), plus
  istniejące `tests/test_liveness.py` bez regresji. DoD repo: build + smoke run + pytest.
- **Weryfikacja:** ≥ 7 dni obserwacji logów mismatch; sprawdzić znane scenariusze
  (restart węzła, restart node-agenta przy żywym węźle — spodziewana *znana*
  rozbieżność semantyk z A.3, restart Prometheusa).
- **Rollback:** usunąć `PROMETHEUS_URL` z env observera (restart kontenera).

### Etap 2 — analiza zgodności + decyzja mappingu

- Przegląd tygodnia rozbieżności; sklasyfikować każdą (semantyka vs bug).
- Ustalić finalny mapping (rekomendacja: `prom-last_seen = timestamp(up)` przez
  istniejące `compute_liveness` i istniejące TTL-e — najmniej nowej semantyki).
- Kryterium wyjścia: zero niewyjaśnionych rozbieżności dla vps/piha/solaria/lustro
  przez ≥ 7 kolejnych dni.
- **Rollback:** n/d (etap analityczny).

### Etap 3 — przełączenie per-node za flagą

- Env np. `PROM_LIVENESS_NODES="vps,piha,solaria,lustro"` (pusta = zachowanie dziś).
  Dla węzłów z listy:
  - `_prune_stale_world` (`observer.py:312-314`) liczy tier z prom-last_seen
    zamiast event-last_seen (fallback na eventy, gdy Prometheus nie odpowiada
    przez > TTL-fresh — fail-open na stary tor, nigdy „brak danych");
  - event-driven flip `status="online"` przy `node_health` (`observer.py:446`)
    dla tych węzłów przestaje nadpisywać prom-status (albo zostaje — i tak
    autorytatywna klasyfikacja co 5 s go koryguje; do decyzji w implementacji);
  - `last_seen` w `nodes.json` **nadal z eventów** (`observer.py:436` bez zmian) —
    panele i safety net (`liveness.py:116-138`) działają bez modyfikacji;
  - `_emit_node_transition` działa jak dziś — przejścia z nowego źródła emitują
    te same `node_offline/stale/online`, supervisor alert path bez zmian.
- Kolejność włączania: najpierw `solaria,lustro` (najmniejsza szkoda przy pomyłce,
  brak NodeDown), po tygodniu `vps,piha`.
- **Weryfikacja:** panel pokazuje spójne statusy; kontrolowany test — stop
  node_exporter na solaria → panel stale→offline + `node_stale`/`node_offline`
  w akcjach/Telegramie; stop node-agenta przy żywym exporterze → status ZOSTAJE
  online (nowa, pożądana semantyka), a brak metryk disk/mem widoczny osobno.
- **Rollback:** wyczyścić `PROM_LIVENESS_NODES` (restart observera) — natychmiastowy
  powrót do TTL eventowego.

### Etap 4 — (opcjonalnie, osobna decyzja) odchudzenie starej rury

Dopiero po długim stabilnym etapie 3. Kandydat: wydłużenie `CHECK_INTERVAL`
node-agenta na prom-węzłach (mniej rsynca). **Pułapki, przez które NIE robić tego
pochopnie:** (a) `node_health` wozi disk/mem/cpu do `nodes.json` i `disk_pressure`
do supervisora (`supervisor.py:315-319`); (b) read-time safety net paneli liczy
z `last_seen` — rzadszy heartbeat przy niezmienionych TTL-ach = fałszywe degraded
(`liveness.py:16-18` wprost wiąże TTL z interwałem 60 s); (c) onboarding gate
50-verify. Realnie: zostawić 60 s i uznać, że stara rura po prostu przestaje być
*źródłem klasyfikacji*, a zostaje nośnikiem metryk i bogatych eventów.

### Etap 5 — domknięcie luk pokrycia (osobne taski, poza cutoverem)

- chelsty-infra: zbadać exporter-over-LTE (`prometheus.yml:57-60` + konflikt IP
  z `hosts/chelsty-infra/host.yaml:12`); do tego czasu zostaje na torze eventowym.
- NodeDown dla solaria/lustro: świadomie odroczone do anomaly detection
  (`docs/backlog.md:390-406`) — nie wciągać do cutoveru.
- Watchdog na sam Prometheus (D.2 pkt 5) — mały task przy etapie 3.
- saturn / chelsty-ha: świadomie poza monitoringiem — status quo.

### Jeden task czy seria?

**Seria.** Minimalnie trzy taski implementacyjne + weryfikacje między nimi:
(1) etap 1 shadow-read; (2) etap 3 flaga per-node (po tygodniu etapu 2);
(3) watchdog-na-Prometheusa + aktualizacja `docs/observer-runtime.md`.
Etap 0 to czynność operatorska (runtime, nie repo). Etap 5 to niezależny backlog.

---

## TL;DR

**Stary tor:** node-agent co 60 s emituje m.in. bezwarunkowy heartbeat `node_health`
(`node_agent.py:636-640`) i rsyncuje eventy na VPS (`:530-566`); observer
(`scripts/observer/observer.py`) z każdego eventu podbija `last_seen` (`:436`),
a co 5 s klasyfikuje tier TTL-owo w `_prune_stale_world` (`:312-314`,
`liveness.py:88-103`; 180/600 s, remote 900/3600 s), pisze `status/liveness` do
`nodes.json` i na przejściach emituje syntetyczne `node_offline/stale/online`
(`:199-254`), które supervisor zamienia na alert_only→Telegram (`supervisor.py:742-783`).
Panele + bot czytają pola liveności z `nodes.json` (z read-time safety netem
z `last_seen`), supervisor-remediacja i executor są od liveności niezależne,
brain-watchdog już dziś chodzi po torze Prometheus (`/api/v1/alerts`, NodeDown
`up{node=~"vps|piha"}==0 for 5m`).

**Cutover = podmiana źródła klasyfikacji w `observer.py:312-314` (+ neutralizacja
`:438-446` dla prom-węzłów), hybrydowo per-node.** Fizycznie nic nie odłączamy:
rsync, bogate eventy, heartbeat (nośnik disk/mem/cpu i `last_seen`), syntetyczne
eventy przejść i pola w `nodes.json` zostają.

**Kolejność etapów:** 0) dowód bojowy toru Prometheus→watchdog→Telegram +
reachability observer→9090; 1) shadow-read w observerze (nowe pola `prom_*`,
log mismatch, fail-open); 2) ≥ 7 dni zgodności + decyzja mappingu
(rekomendacja: `timestamp(up)` jako prom-last_seen przez istniejące
`compute_liveness`); 3) flaga `PROM_LIVENESS_NODES`, najpierw solaria+lustro,
potem vps+piha; rollback = wyczyszczenie flagi; 4) ewentualne odchudzenie —
osobna decyzja, domyślnie nie; 5) luki pokrycia (chelsty exporter, watchdog na
Prometheus) jako osobne taski. **Seria 3 tasków, nie jeden.**

**Ryzyka:**
1. **Prometheus jako SPOF liveności** — `mem_limit 512m`, `oom_score_adj 200`
   (ubijalny przed control-plane) i nikt go nie pilnuje; wymagany fail-open na
   stary tor + watchdog na sam Prometheus.
2. **chelsty-infra tylko na starym torze** — globalny cutover zostawiłby go bez
   liveności; hybryda per-node obowiązkowa.
3. **Różnica semantyk** — event-freshness mierzy node+agent+SSH-łańcuch, `up{}`
   mierzy exporter; padnięty node-agent przy żywym węźle to *zamierzona* zmiana
   werdyktu po cutoverze — musi być świadomie zaakceptowana w etapie 2.
4. **Read-time safety net paneli liczy z `last_seen`** — każde majstrowanie przy
   heartbeacie bez zmiany `liveness.node_health()` = fałszywe degraded; dlatego
   heartbeat zostaje.
5. **Kontrakty na `nodes.json.status`** — onboarding `50-verify.sh:110-142`,
   telegram `/nodes`, webui-fallback; pole musi być pisane dalej.
6. **Exportery piha/solaria/lustro poza GitOps** (brak definicji w repo) — reboot
   węzła może ubić źródło prawdy; do inwentaryzacji przy etapie 1.
7. Syntetyczne eventy przejść to jedyny nośnik node-down→Telegram (poza NodeDown
   dla vps/piha) — implementacja etapu 3 nie może zgubić `_emit_node_transition`.

**Do weryfikacji na żywo** (nie do ustalenia z repo): stan `.env` brain-watchdoga
na piha i dowód toru alertowego; reachability `100.95.58.48:9090` z kontenera
observera; sposób deployu node_exporterów na piha/solaria/lustro; bieżący stan
chelsty-infra (ostatni zapis: UNREACHABLE 2026-07-02); wolumen i stan
`/opt/homelab/events` + checkpoint na VPS.
