Compare commits

..

17 commits

Author SHA1 Message Date
oskar a6d35ba034 docs(kb): session log 2026-08-27 — faza5 Etap 1 + rekoncyliacja kb-wiki 2026-08-27 20:17:21 +02:00
oskar 308ae6a9f4 docs(kb): faza5-wiki §6 — rozliczenie rekoncyliacji kb-wiki (2026-08-27)
Operator zdecydował: opcja 1 z §4 (scalić). Wykonane w klonie roboczym
~/kb-wiki (repo prawdziwe, master, poza tym worktree) — cztery commity
lokalne, świadomie nie pushnięte, czekają na review operatora:

1. sprawy/fll-2025-26.md — scalone przed tą sesją (commit c4127b9 w
   kb-wiki, już pushnięty), ta sesja tylko zweryfikowała ponownie.
2. podmioty/mbank.md i osoby/pawel-cesar-sanjuan-szklarz.md przeniesione z
   ~/kb-wiki-etap1-sesja-2026-08-27/ bez kolizji, linki [[...]] przepisane
   na rzeczywistą konwencję repo (bez prefiksu katalogu).
3. check_okf.py (walidator sesyjny) przeniesiony do kb-wiki, zaadaptowany
   do layoutu repo (type=meta, wyjątek README/INDEX/lint-reports).
4. Decyzje (d)-(f) audytu 08-26 (fallback chunk-id, sekcja "Niepewne/
   sprzeczne" + polityka aktualizacji, inwariant 7) scalone do
   _meta/conventions.md bez duplikowania tego, co repo już miało.

Stan końcowy: 7 stron w repo, 74/74 sources + 150/150 przypisów inline
zweryfikowanych wprost w bazie (KB_DSN, SELECT-only), zero wiszących
linków. Pełny raport: _meta/lint-reports/2026-08-27-rekoncyliacja.md w
kb-wiki. ~/kb-wiki-etap1-sesja-2026-08-27/ pozostawione nietknięte na
żądanie operatora.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W7AnxL6pbgySpEgcCwAfww
2026-08-27 19:57:39 +02:00
oskar efd9ad9443 docs(kb): faza5-wiki Etap 1 + odkrycie: kb-wiki proof-of-concept już istniał od 2026-07-21 na Forgejo, audyt 08-26 tego nie wykrył
kb/phases/kb-m5-faza5-wiki.md dokumentuje: (1) tę sesję (2026-08-27) budującą
niezależny lokalny bootstrap kb-wiki (3 strony: fll-2025-26, mbank,
pawel-cesar-sanjuan-szklarz) nie wiedząc o realnym repo oskar/kb-wiki na
Forgejo (5 stron, od 2026-07-21, dokumentowanym już w kb-m5-faza4.md i
docs/sessions/2026-07-21.md); (2) jak doszło do odkrycia (audyt 08-26 nie
sprawdził Forgejo, tylko repo lokalne + PIHA); (3) porównanie obu kompilacji
fll-2025-26 (komplementarne, nie sprzeczne); (4) rekoncyliację jako decyzję
operatora, nie tej sesji — lokalny bootstrap nigdy nie pushowany do żadnego
remote.

Jednolinijkowa notka przy audycie §9/Etap 1 wskazuje na pełne rozliczenie.
2026-08-27 16:59:08 +02:00
oskar 2df4f1dbc3 fix(kb): check_okf.py flags control bytes (NUL etc.) outside \t\n\r
The validator only ever parsed frontmatter fields — it never scanned body
content, so a file with literal NUL bytes pasted in from a psql SELECT
(kb/audits/wiki-kompilat-recon-2026-08-26.md) passed silently. Adds a
whole-file scan for control bytes other than tab/newline/CR, one error per
offending line. Covered by scripts/kb/tests/test_check_okf.py.
2026-08-27 15:53:35 +02:00
oskar e20845ae1b docs(kb): dopisz inwariant 7 (izolacja retrievalu kompilacji od source=wiki) do faza3 §8.1
Decyzja (f) z kb/audits/wiki-kompilat-recon-2026-08-26.md §10, zatwierdzona
przez operatora w całości 2026-08-27. Kompilacja strony wiki nigdy nie czyta
source='wiki' jako dowodu (exclude_sources=('wiki',)); tylko /search
(warstwa użytkownika, po syntezie odpowiedzi) widzi wiki w kaskadzie —
mitygacja self-citation/citogenesis przy źródle retrievalu.

Dopisane jako rozszerzenie po inwariantach 1-6, które pozostają nietknięte
(szkic operatora 2026-07-15 nadal "NIE podlega zmianie").
2026-08-27 15:53:35 +02:00
oskar d8ff94ca1b docs(kb): oznacz decyzje (a)-(h) wiki-kompilat-recon jako zatwierdzone, usuń wklejone bajty NUL
Operator zatwierdził pakiet (a)-(h) z §10 w całości bez zmian 2026-08-27.
Plik zawierał 4 dosłowne bajty NUL (offsety ~37383/37891/41544/47333) —
wklejki z SELECT-ów po korpusie mailowym — które sprawiały, że narzędzia
tekstowe (grep) traktowały plik jako binarny; usunięte, treść w tych
miejscach pozostaje czytelna (puste `` obok `�` — oba demonstrują literalnie
bajt, którego dotyczą).
2026-08-27 15:53:35 +02:00
oskar 17e7cb0aea docs: session 2026-08-27 13:49 2026-08-27 15:49:48 +02:00
oskar a97cec0819 merge: task/drobne-fixy (resolve-requests 775, prune out of health-monitor, events doc, redeploy action_id) 2026-08-27 15:45:17 +02:00
oskar 1dca438015 fix(supervisor): apply started_at suffix to redeploy action_id too
Domknięcie COMMIT 2 z task/incident-resolve-fix (2026-08-26):
redeploy-<node>-<service> carried the same latent collision risk as
container-restart-<node>-<service> before that fix — two different
incidents for the same node+service can still overwrite each other's
cancelled/completed/failed history.

Verified before applying the same fix: no code anywhere reconstructs
`redeploy-{node}-{service}` for an exact-match lookup.
_cancel_resolved_pending_actions matches on the node/service *fields*
inside each pending file, not the id string; executor.py, node_agent.py
and deploy-runner.sh all treat action_id as an opaque string read back
from the action's own JSON/marker file. Safe to extend the suffix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VMn76yx2CNuHKFVMrYcKWA
2026-08-27 14:48:16 +02:00
oskar 40d78ce60d docs: fix events layout drift in CLAUDE.md
CLAUDE.md described events as JSON-lines files at
events/YYYY-MM-DD/<node>/events.jsonl. Actual runtime layout (node-agent,
executor, observer, health-monitor, stability-agent) is flat: one JSON
file per event at events/<node>/evt-<node>-<unixts>-<type>[-<service>].json,
no date subdirectory, no JSON-lines aggregation.

grepped docs/ for the same drifted description: the only other hit is
docs/sessions/2026-08-26.md, which is a historical session log recording
this exact finding as a follow-up — left untouched. The same drift also
appears in several kb/ files; out of scope here (docs/ only, kb/ has its
own authoring conventions) — flagged as a follow-up.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VMn76yx2CNuHKFVMrYcKWA
2026-08-27 14:46:34 +02:00
oskar 9a86843ddd fix(monitor): remove unfiltered docker container prune from health-monitor.sh
health-monitor.sh still carried the docker prune mechanism behind the
2026-07-30 ollama/SOLARIA incident (see kb/incidents/2026-07-30-ollama-
solaria-vanish.md, root cause R1). Verified 2026-08-06 it is not wired
into cron/systemd on any node — node_agent.py's R1-filtered prune is
the only cleanup path actually running in the fleet.

Remove the cleanup section entirely rather than backporting the R1
filter here too: node-agent is the sole owner of Docker cleanup, and a
second copy of the filter logic would just be a future drift risk.
Health checks (disk/RAM/CPU/container status) and the VPS control-
plane filesystem rotation are untouched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VMn76yx2CNuHKFVMrYcKWA
2026-08-27 14:45:30 +02:00
oskar 89f75c34b0 fix(observer): make world/resolve-requests/ group-writable
world/resolve-requests/ was created via plain mkdir (0o755, masked by
umask), so the SSH operator (group aerbot) could not drop a resolve
flag there — the manual incident-resolve path required docker exec,
defeating its "operator drops a file" design.

Same defect and fix as executor.py's INBOX_DIR_MODE (2026-08-06): an
explicit, idempotent os.chmod(0o775) after mkdir, applied at the same
site the directory is created/used.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VMn76yx2CNuHKFVMrYcKWA
2026-08-27 14:43:49 +02:00
oskar 419df70e29 docs: session 2026-08-26 23:06 2026-08-26 23:07:08 +02:00
oskar 3af6752461 docs: session log 2026-08-26 23:00 — deploy control-plane VPS (stale-resolve + unique action_id + gokapi out) + test ścieżki flagi na LUSTRO
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012pjmfPfrF5UYHqki2YwvdG
2026-08-26 23:04:36 +02:00
oskar 03441a16c0 feat(kb-publish): pytaj o publikacje przy zamknieciu sesji zamiast biernego przypomnienia
Zmiana triggera z "kazda odpowiedz dotykajaca kb/" na "moment zamkniecia
sesji" (save-session, "konczymy sesje", naturalny koniec zadania). Zamiast
jednego zdania przypomnienia, CC wprost pyta operatora tak/nie o publikacje;
przy potwierdzeniu pokazuje gotowiec bash scripts/kb/publish.sh do wklejenia
(nadal nigdy nie uruchamia go sam). Brak odpowiedzi = pytanie zadane raz na
sesje, bez ponawiania.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017q2vHW4uXD8wEr7C8x87oc
2026-08-26 22:37:17 +02:00
oskar f1559991c1 merge: task/incident-resolve-fix (stale-resolve + unique action_id + gokapi out) 2026-08-26 22:35:26 +02:00
oskar 99790438b6 docs(kb): recon wiki-kompilat (faza 5) — lokalizacja, bootstrap encji, format dowodowy
Read-only recon pod fazę 5. Ustala że architektura jest już w dużej mierze
zatwierdzona (kb-m5-faza3.md §8, 2026-07-15/17) i że blokada "pełna wiki po
fazie mailowej" jest dziś zdjęta (korpus sięga 2026-08-26 po naprawie
incydentu 20-dni-ciszy). Dociąga trzy luki nieadresowane w tamtym szkicu:
fallback chunk-id→envelope_id przy re-chunkingu, inwariant 7 (rozdział
retrievalu kompilacji od retrievalu zapytań, mitygacja self-citation) i
sekcję "niepewne/sprzeczne". Lista 17 encji zalążkowych z SELECT-ów na
żywym korpusie (top nadawcy/domeny wśród zaembedowanych kopert, streszczenia
paperless). Plan iteracji: Etap 1 = 3 strony proof (1 sesja), Etap 2 = lint
+ skala do 17. Decyzje operatora (a)-(h).

Co: kb/audits/wiki-kompilat-recon-2026-08-26.md
Co nie: implementacja — nic zdeployowane ani skommitowane poza tym dokumentem.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AWrHr2z1tGhFrGUvPSx2vU
2026-08-26 22:27:44 +02:00
15 changed files with 1617 additions and 131 deletions

View file

@ -1,18 +1,34 @@
--- ---
name: kb-publish name: kb-publish
description: Reminds the operator to publish the KB site after changes to kb/**/*.md. Trigger whenever this session's own edits, or a merge/diff visible in git, touch files under kb/. description: At session close, asks the operator whether to publish the KB site if kb/**/*.md changed this session. Trigger whenever the session is wrapping up (save-session, "kończymy sesję", natural end of task) and this session's own edits, or a merge/diff visible in git, touched files under kb/.
--- ---
## What this skill does ## What this skill does
At the end of your response, if this session touched `kb/**/*.md` — either Trigger point: **session close**, not every response. Session close means the
through your own edits or through a merge/diff you observed in git — append operator signals they're wrapping up — e.g. invoking `save-session`, saying
exactly one reminder sentence: something like "kończymy sesję" / "koniec na dziś" / "wrap up", or the task
reaching its natural end with no further work queued. Don't act on this skill
mid-session just because a `kb/` file was touched.
> Zmiany w kb/ — pamiętaj odpalić `bash scripts/kb/publish.sh` żeby opublikować. At that moment, if this session touched `kb/**/*.md` — either through your
own edits or through a merge/diff you observed in git — **ask the operator
directly**, as a question requiring a yes/no answer, not a passive reminder:
One sentence, no more. Don't repeat it more than once per response, and don't > Sesja dotknęła kb/. Zregenerować i zasugerować publikację (`bash scripts/kb/publish.sh`)?
add it if nothing under `kb/` changed.
- If the operator confirms (any affirmative reply): print the ready-to-paste
command block so they can copy it straight into their own shell:
```bash
bash scripts/kb/publish.sh
```
Do not run it yourself — see below.
- If the operator declines: drop it, nothing more to do.
- If the operator doesn't respond or ignores the question (moves on to
something else, ends the conversation): do **not** ask again in this
session. One ask per session, max.
## What this skill does NOT do ## What this skill does NOT do
@ -23,4 +39,4 @@ production — that is out of scope for a worktree agent and requires an
explicit, direct instruction from the operator in the current turn. explicit, direct instruction from the operator in the current turn.
If the operator explicitly asks you to run it, that instruction stands on its If the operator explicitly asks you to run it, that instruction stands on its
own — this skill only governs the passive reminder, not that request. own — this skill only governs the session-close question, not that request.

View file

@ -109,7 +109,7 @@ Agents must never execute destructive actions (restarts, deploys, config changes
## Event System ## Event System
Events are append-only JSON lines at `/opt/homelab/events/YYYY-MM-DD/<node>/events.jsonl`. Events are append-only, one JSON file per event, flat under `/opt/homelab/events/<node>/evt-<node>-<unixts>-<type>[-<service>].json`.
Emit via `scripts/lib/events.sh` (shell) or `scripts/lib/events.py` (Python). Emit via `scripts/lib/events.sh` (shell) or `scripts/lib/events.py` (Python).

View file

@ -69,3 +69,139 @@ Brak zmian w repo.
### Narrative ### Narrative
> _user-provided summary_ > _user-provided summary_
## Session 23:00
Deploy control-plane (observer + supervisor) na VPS: stale-resolve 24h +
flagi `resolve-requests` (commit `71a7af5`), unikalny `action_id`
`container_restart` z bare-id fallbackiem (commit `91db682`), usunięcie
gokapi z desired state (commit `74ff3ee`) — zmerdowane do `master` jako
`f155999` przez operatora tuż przed sesją. SUPERVISED — checkpoint A po
weryfikacji deployu, checkpoint B po teście ścieżki flagi.
### Commits
```
(brak commitów kodu tej sesji — wyłącznie deploy + test na produkcji;
log sesji poniżej dopisany bez pusha)
```
### Files changed
Brak zmian w repo poza niniejszym logiem.
### Deploys / operacje na nodach
**KROK 0 — sanity:** `git pull` na `~/homelab-codex-ws` (SOLARIA, główny
checkout) — już aktualny na `03441a1` (na wierzchu mergu `f155999`).
`git status`/`diff HEAD` czyste. Wcześniej w tej samej sesji (przed
mergem) `git log -1` pokazywał `4fa10f0` — merge jeszcze nie istniał;
zatrzymano się i poczekano na operatora zamiast mergować samodzielnie
(worktree-aware: merge to wyłącznie krok człowieka).
**KROK 1 — deploy control-plane (checkpoint A, zatwierdzony):**
- Rollback tagi: `control-plane-{executor,observer,operator-ui,supervisor}
:rollback-pre-resolvefix` — ten sam wzorzec co `:rollback-pre-dispatchfix`
z 08-06.
- `git pull origin master` na VPS (`003f83d` → `03441a1`, fast-forward),
`docker compose up -d --build --force-recreate`.
- `deploy-local.sh`'s auto-chown krok padł: brak TTY dla hasła sudo,
`/opt/homelab/backups` i `/opt/homelab/events/solaria/*``oskar:oskar`
zamiast `aerbot:aerbot` (1000). Sprawdzone: `actions/`, `world/`,
`state/`, `config/` (realna ścieżka zapisu control-plane) już poprawnie
`aerbot:aerbot 775` — ominięto self-heal, `docker compose` odpalony
bezpośrednio bez sudo. Mismatch na `backups/`/`events/solaria/*`
pozostawiony nietknięty (follow-up niżej).
- Weryfikacja: 4/4 kontenery `healthy`, 0 linii error/traceback/exception
od restartu. sha256 `observer.py` (mount `/repo`, żywy) i `supervisor.py`
(wypieczony `/app/src`, wymaga `--build`) == repo HEAD, potwierdzone
osobno przez `docker exec` w obu kontenerach.
- Po 3 cyklach reconcile: `active_incidents: 0`, `world/resolve-requests/`
utworzony przez observera (pusty).
- `redeploy-vps-gokapi` (pending od 07-09) auto-cancelled po pierwszym
cyklu: `cancelled_reason: "service_removed_from_desired_state"` — bez
ponownego wygenerowania. `pending/` pozostał czysty (tylko niezwiązane
alerty HA z piha).
**KROK 2 — test ścieżki flagi na LUSTRO (checkpoint B, zatwierdzony):**
- `docker stop node-exporter` na LUSTRO → observer otworzył
`inc-1787777894-lustro-node-exporter`.
- Flaga: `touch world/resolve-requests/<id>` przez zwykłego SSH
usera **odrzucony permission denied** — katalog `755 aerbot:aerbot`,
brak zapisu grupowego mimo że `oskar` jest w grupie `aerbot`. Obejście:
`docker exec control-plane-observer touch ...` (proces w kontenerze
działa jako uid 1000 = właściciel katalogu). Follow-up niżej.
- Resolve w **1.01 s** od touch (limit ≤10s), `resolved_reason:
manual_operator`, flaga skasowana, `service.incident_id` wyczyszczony,
log INFO `"Manually resolving incident ... via resolve-request flag"`.
- Drift trwał dalej (kontener wciąż stopped) → supervisor wygenerował
`container-restart-lustro-node-exporter-1787777887` — **nowy format
id z COMMIT 2 potwierdzony na produkcji** — oraz równolegle
`redeploy-lustro-node-exporter` (bare id, ścieżka `unhealthy_service`
po wyczyszczeniu `incident_id`).
- Za decyzją operatora: `POST /action/mutate` na `127.0.0.1:18180`
(ten sam endpoint co UI/Telegram) — zatwierdzono restart, odrzucono
redeploy jako nadmiarowy.
- Executor zdispatchował realnie do LUSTRO; node-agent wykonał
`docker restart node-exporter` (log: `"Restarted container
'node-exporter' for action container-restart-lustro-node-exporter
-1787777887"`), akcja `completed`.
- Drift utrzymał się jeszcze chwilę po zatwierdzeniu → drugi, nowy
incydent (`inc-1787777955-...`) i druga, odrębna pending akcja
(`...-1787777950`, inny suffix `started_at`) — dokładnie oczekiwane
zachowanie "różny id przy nowym incydencie". Po powrocie zdrowia
auto-cancelled: `cancelled_reason: "drift_resolved_auto"`, bez
interwencji.
- Stan końcowy: `node-exporter` na LUSTRO `Up`, oba incydenty
`resolved`, `active_incidents: 0`, `pending/` czysty, 4/4 kontenery
control-plane nadal `healthy`, 0 error-ish linii w logach.
### Follow-upy
- **pytest env zepsuty na SOLARII**: `~/.local/bin/pytest` (brak
`_pytest`) i `homelab-codex-ws/.venv` (brak `pytest` w ogóle) oba
niedziałające; działa wyłącznie `/home/oskar/anaconda3/bin/pytest`
(7.4.4). Użyty do pełnego runu przed force-pushem poprawki COMMIT 2
(184 passed control-plane, 70 passed node-agent).
- **`world/resolve-requests/` permissions**: `755 aerbot:aerbot` zamiast
konwencji `775` używanej w `actions/`/`world/`/`state/`/`config/`.
Blokuje operatora SSH przed bezpośrednim `touch` flagi resolve —
manualna ścieżka z 71a7af5 ("operator drops a file") w praktyni wymaga
`docker exec`. Poprawić `chmod 775` / mode przy `os.makedirs` w
observer.py.
- **`backups/` i `events/solaria/*` ownership**: `oskar:oskar` zamiast
`aerbot:aerbot` — nie blokuje funkcjonalnie (czytelne dla "other"), ale
psuje self-heal chown w `deploy-local.sh` (próbuje rekurencyjnego sudo
chown całego `/opt/homelab` bez TTY/hasła). Do ręcznego wyczyszczenia
z hasłem sudo albo do zmiany self-heal na scoped (tylko katalogi
control-plane realnie potrzebuje) zamiast całego drzewa.
- **CLAUDE.md doc drift**: opisuje `events/YYYY-MM-DD/<node>/events.jsonl`,
rzeczywisty layout na VPS to płaskie `events/<node>/evt-*.json` (jeden
plik na zdarzenie, bez partycjonowania po dacie). Do poprawienia przy
najbliższej okazji.
- `redeploy-vps-gokapi` — zamknięty tym deployem (auto-cancelled), nie
wymaga już dalszego follow-upu z sesji 16:00.
### Narrative
> _user-provided summary_
## Session 23:06
### Commits
```
(brak commitów — sesja bez żadnej pracy poza natychmiastowym zamknięciem)
```
### Files changed
Brak zmian w repo.
### Deploys
None recorded
### Narrative
> _user-provided summary_

View file

@ -0,0 +1,59 @@
---
okf: "0.1"
type: session-log
visibility: private
status: active
updated: 2026-08-27
links:
- ../../kb/phases/kb-m5-faza5-wiki.md
- ../../kb/phases/kb-m5-faza3.md
- ../../kb/audits/wiki-kompilat-recon-2026-08-26.md
---
# Sesja 2026-08-27 — Faza 5: decyzje + Etap 1 + rekonсyliacja (ZAMKNIĘTY)
## Timeline
- **Sanity** — automat mailowy zdrowy po nocy (last_success świeży, gmail
max(ts) bieżący).
- **Formalizacja decyzji** (2df4f1d i wcześniejsze) — pakiet (a)-(h) audytu
`wiki-kompilat-recon-2026-08-26.md` ZATWIERDZONY w całości; inwariant 7
dopisany do faza3 §8.1 (izolacja retrievalu kompilacji od `source='wiki'`);
`check_okf.py` łapie bajty kontrolne (nowy check #12 + testy) — 4 NUL-e
usunięte z samego audytu.
- **Etap 1** (`task/wiki-etap1`, autonomiczny run CC ~25 min) — bootstrap +
3 strony proof (`sprawy/fll-2025-26`, `podmioty/mbank`,
`osoby/pawel-cesar-sanjuan-szklarz`). 40 sources + 47 przypisów
zweryfikowanych byte-for-byte, zero degradacji. Alias-resolution: 6 er
życia Pawła spójnie + 2 fałszywe pozytywy pod `paweld2.eu` wykryte i
udokumentowane jako wynik negatywny (kluczowy test mechanizmu PASS).
- **ODKRYCIE** — kb-wiki istniało na Forgejo od 2026-07-21 (5 stron: `pzu`,
`warta`, `ubezpieczenie-auto`, `fll-2025-26`, `wspólnota`) — proof
wykonany w fazie 3 (wątek "Kontynuacja wątku o KB", decyzja D7),
nieodnotowany w `kb/` ani w audycie. Recon 08-26 błędnie stwierdził
"remote nie istnieje" — sprawdził repo/pilota/bazę, nie odpytał Forgejo
bezpośrednio.
- **LEKCJA 1**: fakty wykonania muszą lądować w repo (lekcja 6 narty27 w
praktyce — zgubiliśmy całe repo na 5 tygodni).
- **LEKCJA 2**: recon zasobów zewnętrznych odpytuje źródło wprost, nie
wnioskuje z planów.
- **Rekonсyliacja** — porównanie dwóch niezależnych kompilacji `fll-2025-26`
(lipiec paperless-only vs sierpień paperless+gmail): komplementarne, zero
sprzeczności w faktach wspólnych; konwergencja metodologiczna (obie sesje
ten sam chunk 277/278, obie odmówiły potwierdzenia nieczytelnego OCR).
Scalenie: 21 envelope, 38 par sources, 45 przypisów re-zweryfikowanych;
naprawiony wadliwy lipcowy przypis; konwencja "Brak danych w KB"
sformalizowana. Potem mbank+paweł przeniesione do kanonicznego repo,
konwencje (d)-(f) scalone, walidator w repo. Stan końcowy kb-wiki@Forgejo
`a540a99`: 7 stron, lint 74/74 sources + 150/150 inline zielono.
- **kb-wiki remote** przepięty HTTPS→SSH (port 222).
- **Kandydat Etapu 2** wykryty SQL-em: `podmioty/future-minds` (organizator
FLL, 8+ dokumentów).
- **Follow-upy bez zmian**: rotacja sekretów (kb-postgres + `TG_TOKEN`
NADAL WISI), fix UID SEARCH, PDF-y, charset, R1/R2 z incydentu 26.08.
## Stan Fazy 5
- Etap 1 ✓ (2026-08-27). Next: Etap 2 — skala do 17+1 encji, lint w kodzie,
skan charset/NUL, decyzja o integracji `source='wiki'` w retrievalu
(inwariant 7 obowiązuje).

View file

@ -0,0 +1,23 @@
## Session 13:49
### Commits
a97cec0 merge: task/drobne-fixy (resolve-requests 775, prune out of health-monitor, events doc, redeploy action_id)
1dca438 fix(supervisor): apply started_at suffix to redeploy action_id too
40d78ce docs: fix events layout drift in CLAUDE.md
9a86843 fix(monitor): remove unfiltered docker container prune from health-monitor.sh
89f75c3 fix(observer): make world/resolve-requests/ group-writable
### Files changed
CLAUDE.md | 2 +-
scripts/monitor/health-monitor.sh | 110 +++------------------
scripts/observer/observer.py | 12 +++
services/control-plane/src/supervisor.py | 40 ++++----
.../control-plane/tests/test_incident_lifecycle.py | 17 ++++
.../tests/test_supervisor_action_id_uniqueness.py | 50 +++++++++-
6 files changed, 109 insertions(+), 122 deletions(-)
### Deploys
- control-plane → VPS: tagged 4/4 images `:rollback-pre-drobnefixy`, `git pull` (03441a1→a97cec0) + `docker compose up -d --build --force-recreate` (direct, no deploy-local.sh, per 26.08 precedent). Result: 4/4 healthy, zero error/traceback in logs since restart, sha256 of observer.py and supervisor.py match HEAD, `world/resolve-requests/` mode 775 confirmed, incidents.json stable (md5 unchanged) over 3 observer cycles. 30s permission test: flag `test-perms-123` dropped via plain `ssh` as `oskar` (no docker exec) was picked up and deleted in ~5s with `WARNING - Resolve-request flag for unknown incident test-perms-123 — removing flag` — 775 confirmed working in practice.
### Narrative
> _user-provided summary_

View file

@ -0,0 +1,856 @@
---
okf: "0.1"
type: audit
visibility: private
status: active
updated: 2026-08-27
as_of: 2026-08-26
links:
- ../phases/kb-m5-faza3.md
- ../phases/kb-m5-faza-mailowa.md
- ../services/narty27.md
- ../services/kb-query.md
- ../services/kb-site.md
- ../subsystems/kb-mail-pillar.md
- ../subsystems/kb-overview.md
- ../subsystems/kb-documents-pillar.md
- ../subsystems/recon-multiagent.md
- ../decisions/architektura-2026-07-28.md
- ../decisions/backlog-aktywne.md
- ../incidents/2026-08-26-mail-sync-20-dni-ciszy.md
- ../audits/mail-sync-2026-08-06.md
- ../runbooks/mail-sync-run.md
- ../services/job-mail-imap-sync.md
- ../services/pkg-kb-retrieval.md
---
# Recon — wiki-kompilat (faza 5), 2026-08-26
Read-only recon. Zakres: przygotowanie fazy 5 subsystemu B (KB) — syntezy wiki nad
warstwą dowodową RAG/pgvector. Zlecenie: zbadać 9 punktów (lokalizacja, bootstrap
encji, tryb kompilatora, format linku dowodowego, anty-propagacja, integracja z
kb-query, zależności/kolizje, plan iteracji, decyzje operatora) i wynieść wniosek
per punkt. **Nic nie zostało zaimplementowane, zdeployowane ani skommitowane poza
tym dokumentem.**
Dowody: repo (worktree `task/wiki-kompilat-recon`, cięty z mastera @ `4fa10f0`),
pilot `~/narty-2027/saalbach-kb` na SOLARII (lokalny odczyt), żywa baza `kb` na
PIHA przez `ssh piha 'docker exec kb-postgres psql ...'` (SELECT-y, zero zapisów),
`systemctl status` na PIHA dla stanu timerów.
**Znalezisko nadrzędne, zanim cokolwiek innego:** to nie jest recon od zera.
`kb/phases/kb-m5-faza3.md` §8 zawiera **już zatwierdzony przez operatora szkic
architektoniczny** wiki-kompilatu (decyzja 2026-07-15, opisana jako „NIE podlega
zmianie") plus rozwinięcie wykonawcze (§8.2, 2026-07-17) z konkretnymi
rozstrzygnięciami: osobne repo `kb-wiki`, struktura katalogów, format frontmattera
z blokiem `sources`, inline-przypisy `[^envelope_id#chunk_id]`, trzypoziomowa
kaskada retrievalu `wiki → summary → chunk`, kompilacja przez CC/API, lint
okresowy, pętla zwrotna. Zadanie tego reconu — tak jak je czytam — to **zweryfikować
te ustalenia w świetle tego, co się wydarzyło od 07-17** (pilot narty27, cała faza
mailowa, incydent 20 dni ciszy), dociągnąć luki, których tamten szkic nie
rozstrzygał (chunk-id vs re-chunking, self-citation, konkretna lista encji z
prawdziwych danych), i złożyć to w plan pierwszego etapu. Poniżej cytuję zamiast
odtwarzać, i mówię wprost, gdzie się zgadzam, a gdzie proponuję dociągnięcie.
---
## 1. Stan korpusu na dziś (2026-08-26) — czy blokada z faza3 jest zdjęta
`kb/phases/kb-m5-faza3.md:628`, cytat ze szkicu operatora: *„Pełna wiki po fazie
mailowej (przyrostówka) — wcześniej kompilat byłby fotografią przeszłości."*
`kb/audits/mail-sync-2026-08-06.md` §7 nazywa to *jedyną znalezioną jawną
zależność „X czeka na przyrostówkę" w całym KB*.
Zmierzone dziś na żywej bazie:
```sql
SELECT source, max(ts)::date AS latest, count(*) FROM envelope GROUP BY 1;
```
| source | latest | count |
|---|---|---|
| gmail | **2026-08-26** | 227 280 |
| fastmail | **2026-08-26** | 105 |
| paperless | 2026-07-13 | 191 |
```sql
SELECT count(*) FROM document_chunk WHERE embedding IS NULL AND excluded_reason IS NULL;
-- → 0
```
Korpus mailowy sięga **dziś**, nie 2026-06-19 jak w audycie 08-06 — incydent
[20 dni ciszy](../incidents/2026-08-26-mail-sync-20-dni-ciszy.md) naprawiony tego
samego dnia co to zlecenie domknął przyrostówkę: kursor dogonił IMAP, 548
zaległych kopert wchłonięte, zero błędów. `kb-mail-sync.timer` (co godzinę) i
`kb-ingest.timer` (co 2h, embed) oba `active (waiting)` na PIHA, żywe od
2026-08-06, bez przerwy odkąd monitoring realnie działa (od tego samego
incydentu). Embed backlog = **0** — nic nie czeka na wektor.
**Wniosek: blokada z faza3 jest zdjęta dziś, nie wcześniej.** To jest właściwy
moment na ten recon — zrobiony tydzień wcześniej trafiłby na korpus wciąż
urwany, a wiki-kompilat skompilowałby na trwałe fotografię z czerwca. Warto
odnotować kruchość tego stanu: przyrostówka działa dzięki jednemu `chown` i
jednemu `/-/reload`, oba ręczne, oba bez automatyzacji (R1/R2 z incydentu —
poprawna własność katalogów przy `sudo`-runach i reload reguł Prometheusa w
deployu — zostają otwarte). Faza 5 dziedziczy to ryzyko: gdyby przyrostówka
znów ucichła cicho, wiki-kompilat kompilowałby dalej z coraz starszej wody, bez
sygnału. Patrz §7 niżej (interakcja z monitoringiem `KbMailSyncStale`).
**191 kopert paperless** nie rosną od 2026-07-13 — to osobny fakt: paperless nie
ma przyrostówki (dokumenty trafiają tam ręcznie), niezwiązany z blokadą mailową.
Nie wstrzymuje fazy 5.
---
## 2. Punkt 1 — Lokalizacja wiki: `kb/wiki/` w repo vs osobne repo
### Co już zdecydowano
`kb-m5-faza3.md` §8.2: *„Repozytorium (rozstrzygnięcie punktu 6 szkicu): osobne
repo `kb-wiki` — decyzja 7, uzasadnienie w §2. Klon roboczy na PIHA:
`/opt/homelab/data/kb-wiki/`."* — i dalej: *„D7 — rekomendacja: osobne
`kb-wiki`"* w podsumowaniu (§12). To jest już podjęta decyzja, nie propozycja.
### Weryfikacja na wzorcu pilota
narty27 (`~/narty-2027/saalbach-kb`) **potwierdza ten wzorzec empirycznie**:
`kb/services/narty27.md` — *„Content is personal and lives outside the repo… It
is never committed — not to this repo, not to any other."* Źródło leży w osobnym
lokalnym gicie na SOLARII, poza `homelab-codex`, infra (`services/narty27`)
zostaje w `homelab-codex`. Dokładnie ten sam split, jaki faza3 projektuje dla
`kb-wiki`: **treść osobno, infrastruktura hostująca w repo.**
### Rekomendacja: potwierdzam D7 (osobne repo), z jednym doprecyzowaniem
Zgadzam się z decyzją bez zmian co do kierunku. Powody, które faza3 już podała
i które podtrzymuję:
1. **Cykl życia inny niż kodu infry.** Commity do `kb-wiki` będą częste,
drobne, generowane maszynowo (`compile: <strona><envelope_ids>`) —
zalałyby historię `homelab-codex-ws`, gdzie commituje operator ręcznie i
rzadko (`CLAUDE.md`: *„Primary control node — only node where commits are
made"*).
2. **Widoczność `visibility` per strona nie mapuje się na model `homelab-codex`.**
`check_okf.py` tego repo ma zamkniętą listę `TYPES` i regułę `POINTER_GLOBS`/
`EXCLUDE_DIRS` dopasowaną do tego, co tu jest — dodanie setek stron-encji
(firmy, osoby, sprawy) do `kb/` zaszumiłoby indeks tego repo i pomieszałoby
dwa różne reżimy publikacji (kb-site publikuje `visibility: public` z `kb/`;
`kb-wiki` potrzebuje **własnego**, bo część stron — dane osobowe, umowy,
ludzie — nigdy nie powinna nawet teoretycznie wpaść w tę samą ścieżkę co
dokumentacja infrastruktury).
3. **Rozmiar.** Korpus mailowy to 227k+ kopert; nawet skromna kompilacja
(setki stron) to inny rząd wielkości commitów niż `homelab-codex-ws` widział
kiedykolwiek. Osobne repo izoluje ten wzrost od repo, które operator
przegląda i na którym pracują agenty infrastrukturalne.
**Doprecyzowanie względem faza3:** szkic nie rozstrzyga *dostępu CC w
worktree*. `homelab-codex-ws` ma wzorzec `agent.sh new` → worktree per task,
każdy dostaje pełne repo `homelab-codex-ws` sklonowane z gita. `kb-wiki` **nie
jest** tym repo — sesja kompilująca stronę potrzebuje **dwóch** klonów
jednocześnie: `homelab-codex-ws` (żeby czytać `packages/kb-retrieval`, DSN,
`.env` na PIHA) i `kb-wiki` (żeby pisać stronę). To nie jest problem dla
worktree pojedynczej sesji CC odpalonej ręcznie na PIHA/SOLARII z dwoma
katalogami obok siebie — **jest** problemem dla wzorca `scripts/dev/agent.sh
new`, który zna tylko jedno repo. Rekomendacja: kompilacja wiki **nie** idzie
przez `agent.sh`-owe worktree `homelab-codex-ws` — to osobny tryb pracy
(sesja CC z dwoma katalogami roboczymi, jak dziś narty27 na SOLARII), do
zapisania w `kb-wiki/_meta/conventions.md`, nie w tym repo.
### Publish.sh i public/private per strona
`kb-site` (`services/kb-site/`) publikuje wyłącznie `kb/**/*.md` z
`visibility: public`, fail-closed, przez token w URL (`kb-e2a24af3.okit.pl`).
`kb-wiki` leżący poza `kb/` **nie wejdzie** do tego pipeline'u automatycznie —
i to dobrze, bo strony wiki (dane o ludziach, umowach, finansach) mają zupełnie
inny profil ryzyka publikacji niż dokumentacja infrastruktury. Jeśli/kiedy
pojawi się potrzeba publicznej (lub choćby LAN-owej) wystawki `kb-wiki`, to
osobny generator + osobny serwis (wzorzec `narty27`: nginx + named volume,
`exposure: private` domyślnie), **nie** rozszerzenie `gen_pages.py`. Na tym
etapie (proof-of-concept, 310 stron) nie ma potrzeby stawiać hostingu w ogóle
`git log`/`cat` na PIHA wystarczy do przeglądu.
---
## 3. Punkt 2 — Bootstrap: analiza korpusu i lista encji zalążkowych
### Zapytania i wyniki (żywa baza, 2026-08-26)
**Wolumen per rok/źródło** (`envelope`, pełne dane w §1 wyżej dla świeżości;
poniżej rozkład historyczny):
```sql
SELECT source, date_trunc('year', ts) AS yr, count(*) FROM envelope GROUP BY 1,2 ORDER BY 1,2;
```
Szczyt 20112012 (~23k/rok — dawny pracodawcy przez `outbox.pl`), potem
stabilne 913k/rok 20132025. 2 559 kopert `ts`=epoch 1970 (daty nieodzyskane,
opisane w `kb-overview.md` backlog).
**Top nadawcy wśród kopert, które realnie mają zaembedowany, nie-wykluczony
chunk** (czyli faktycznie trafiają w wyniki `/search` — to jest właściwe sito
pod encje, nie surowy wolumen, który zdominowany jest przez newslettery
wykluczone z indeksu):
```sql
SELECT h->'from'->>'name', h->'from'->>'address', count(DISTINCT e.id),
min(e.ts)::date, max(e.ts)::date
FROM envelope e
JOIN jsonb_array_elements(e.entities) h ON h->>'type' = 'headers'
WHERE EXISTS (SELECT 1 FROM document_chunk c
WHERE c.envelope_id = e.id AND c.embedding IS NOT NULL AND c.excluded_reason IS NULL)
GROUP BY 1,2 ORDER BY 3 DESC LIMIT 40;
```
| Nadawca | Adres | Koperty | Zakres |
|---|---|---|---|
| oskar kapala | oskar.kapala@gmail.com | 10 021 | 20052020 |
| Oskar Kapala | oskar.kapala@outbox.pl | 6 399 | (epoch)2014 |
| Kasia Lorenc-Kapala | katalia@gmail.com | 3 385 | 20072026 |
| Oskar Kapala | oskar.kapala@gmail.com | 2 910 | 20102026 |
| System Synergia | robot2@robot.librus.pl | 2 818 | 20182026 |
| Allegro | powiadomienia@allegro.pl | 2 170 | 20082026 |
| Tomasz Anuszewski | tomasz.anuszewski@outbox.pl | 1 913 | 20112012 |
| Dziennik Bankier.pl (×2 adresy) | …bankier.pl | 1 736 + 1 676 | 20072014 |
| RSW_TECH_TEAM | rsw_tech_team@outbox.pl | 1 628 | 20112012 |
| Groupon | info@news.groupon.pl | 1 447 | 20112012 |
| Miron Mironiuk | m@cosmose.co | 1 126 | 20152016 |
| kontakt@mbank.pl | | 1 099 | 20052026 |
| Jan Boboli | jan.boboli@outbox.pl | 969 | 20112013 |
| Paweł Cesar Sanjuan Szklarz | paweld2@gmail.com, pawel@cosmose.co | 757+555+418+376 | 20052024 |
| InPost | info@paczkomaty.pl | 722 | 20172026 |
| Bogumil Jakubiak | B.Jakubiak@icm.edu.pl | 686 | 20042009 |
| sOKratis Liliana Banaszak | liliana.banaszak@sokratis.pl | 679 | 20092026 |
| Strava | update.strava.com / strava.com | 591+395 | 20172026 |
**Domeny** (§2, po odsianiu marketingu/newsletterów po heurystyce
`noreply|notification|robot|mailer`): `outbox.pl` (39,4k — domena prywatna
operatora, alias `oskar.kapala@outbox.pl` + koledzy z tej samej domeny =
prawdopodobnie dawny pracodawca/hosting), `gmail.com`, `cosmose.co` (2 982,
firma — Miron Mironiuk + Paweł Szklarz), `icm.edu.pl`/`students.mimuw.edu.pl`
(uczelnia/ICM UW), `mbank.pl`/`bankier.pl` (finanse), `sokratis.pl`.
**Paperless — 191 kopert, w większości streszczone** (`document_summary`,
162 `claude-haiku-4-5` + 155 `gemma3:12b`, prawie 1:1 pokrycie). Próbka
streszczeń pokazuje wyraźne klastry tematyczne:
| Klaster | Liczba (próbka 40 najnowszych) | Przykład |
|---|---|---|
| FIRST LEGO League / Fundacja Future Minds | 8 | zgody na wizerunek, karty wyników, harmonogramy |
| Regulaminy OLX (pakiety, ochrona kupującego, ads) | 5 | umowa serwisowa |
| Revolut (krypto, regulaminy, cennik) | 4 | umowa finansowa |
| Spółdzielnia Mieszkaniowa „Przy Metrze" | 2 | RODO, dane kontaktowe |
| Ubezpieczenia (PZU OC, itp.) | 2 | polisa |
| Wynajem auta (Dollar, Madryt) | 2 | rezerwacja |
| Faktura (ZigBee, Oskar Kapała Rozwiązania IT — działalność gospodarcza) | 1 | dowód zakupu |
### Kryterium kolejności
Trzy sygnały, w tej kolejności ważności:
1. **Obecność w indeksowanym (nie-wykluczonym) korpusie** — encja musi mieć
realne pokrycie w `document_chunk` z wektorem, inaczej strona kompiluje się
z pustki. To jedyny twardy filtr (zastosowany w zapytaniu wyżej).
2. **Wielowątkowość źródeł** — encje, które łączą paperless (dokument formalny:
umowa, polisa, regulamin) *i* gmail (korespondencja o tej samej sprawie),
są lepszymi kandydatami na `sprawy`/`umowy` niż encje widoczne tylko w
jednym źródle — bo demonstrują dokładnie to, co ma udowodnić wiki-kompilat
(Karpathy-wzorzec: strona syntezuje, nie kopiuje jednego dokumentu).
3. **Rozstrzygalność, nie wolumen.** `outbox.pl`/`Tomasz Anuszewski`/`Jan
Boboli`/`RSW_TECH_TEAM` mają duży wolumen (20112012), ale to zamknięty w
czasie epizod (prawdopodobnie dawny pracodawca) — dobry kandydat na
`podmiot`, zły na pierwszy PoC, bo wymaga ustalenia kontekstu (kim była ta
firma), którego recon nie ma. Odłożone na drugą turę, nie na pierwszą.
### Proponowana lista 1020 encji zalążkowych
| # | Strona | Typ | Uzasadnienie (z danych) |
|---|---|---|---|
| 1 | `fll-2025-26` | sprawa | 8/40 streszczeń paperless; żywa sprawa (2026), krzyżuje szkołę + fundację + dziecko |
| 2 | `future-minds` (fundacja) | podmiot | organizator FLL, NIP w danych, kontakt jawny w treści dokumentów |
| 3 | `spoldzielnia-przy-metrze` | podmiot | nieruchomość, RODO + dane kontaktowe, aktywna (bieżąca korespondencja o pożarówce 2026) |
| 4 | `revolut` | podmiot | 4 dokumenty regulaminowe w próbce, konto aktywne (kontekst kryptowalut) |
| 5 | `mbank` | podmiot | 1 099 kopert 20052026, ciągłość 20 lat, kontakt@mbank.pl + dziennik bankier.pl (powiązana marka) |
| 6 | `pzu` | podmiot | kandydat wymieniony wprost w szkicu operatora (§8.1 przykład), polisa OC w paperless |
| 7 | `cosmose` (firma) | podmiot | 2 982 kopert, dwóch nazwanych korespondentów (Mironiuk, Szklarz) — test cross-osoba w jednym podmiocie |
| 8 | `miron-mironiuk` | osoba | 1 126 kopert, jeden zidentyfikowany adres, powiązanie z `cosmose` |
| 9 | `pawel-cesar-sanjuan-szklarz` | osoba | 4 warianty adresu/nazwy (757+555+418+376 kopert) — dobry test entity resolution na aliasach tej samej osoby |
| 10 | `librus-synergia` | temat | 2 818 kopert 20182026, szkoła dzieci, ciągły strumień — test strony-tematu o dużym, jednorodnym źródle |
| 11 | `inpost-paczkomaty` | temat | 722+681 (dwa nadawcy tej samej usługi) — test scalania dwóch adresów jednej usługi w jedną stronę |
| 12 | `strava` | temat | 591+395, dwa adresy tej samej usługi, 20172026 ciągłe |
| 13 | `bogumil-jakubiak` | osoba | ICM UW, 20042009 — zamknięty epizod, test strony `status: archived` |
| 14 | `wynajem-samochodow` (Dollar/Madryt) | temat | rezerwacja 2026, dwa powiązane dokumenty paperless (potwierdzenie różnymi kanałami tej samej rezerwacji) — test dedupu treści |
| 15 | `dzialalnosc-gospodarcza` (Oskar Kapała Rozwiązania IT) | podmiot | faktura w paperless, własna działalność — inny rejestr niż korespondencja prywatna |
| 16 | `sokratis` | podmiot | 679 kopert 20092026, ciągłość 17 lat, nazwany kontakt (Liliana Banaszak) |
| 17 | `allegro` | temat | 2 170 kopert 20082026 — najdłuższa ciągła relacja handlowa w korpusie |
| 1820 | (druga tura, po pierwszej weryfikacji) | — | `outbox.pl`/dawny pracodawca po ustaleniu kontekstu; `PZU`↔`mbank` cross-link jako pierwszy test grafu `sprawy` łączących dwa `podmioty` |
17 pozycji zamiast równych 20 — reszta świadomie zostawiona na drugą turę
zamiast dopełniana sztucznie (Bogumil Jakubiak/ICM to jedyny archiwalny/
zamknięty przypadek na liście — reszta to żywe encje; warto mieć chociaż jeden
przykład `status: archived` w PoC, żeby lint (inwariant 3) miał co sprawdzać
na obu stanach od pierwszego dnia).
---
## 4. Punkt 3 — Tryb kompilatora: batch / pipeline / hybryda
### Co już zdecydowano
Faza3 §8.2, inwariant 2: *„Kompilację i lint robi CC/zewnętrzne API… Sesja
CC/API dostaje wyniki retrievalu dla encji… pisze/aktualizuje stronę,
commituje do kb-wiki."* To opisuje **batch przez sesje CC** — potwierdzone też
przez operatora w zleceniu tego reconu („Utrzymanie: CC/API — lokalny model za
słaby — rozstrzygnięte"). Nie podważam tego kierunku; on jest już przesądzony.
Pytanie, które faza3 zostawia otwarte, to **rytm**: czy kompilacja jest
wyłącznie ręcznie wyzwalanym batchem, czy dostaje automatyczny trigger, i jak
zamyka się pętla zwrotna (inwariant 4: *„dobre odpowiedzi z zapytań wracają do
wiki jako nowe strony"*).
### Trzy tryby, ocenione pod kątem tego, co ta faza faktycznie robi
**(A) Czysty batch (sesja CC, ręcznie wyzwalana).** Operator albo agent
odpala sesję z listą encji, sesja robi N zapytań do `hybrid_retrieve`/
`cascade_retrieve`, pisze/aktualizuje strony, commituje. Koszt: **przewidywalny
i widoczny w moment wywołania** — tyle, ile ta jedna sesja zużyje tokenów API.
Częstotliwość: kiedy operator zdecyduje (np. raz na kilka sesji roboczych KB).
**(B) Job z pipeline'em eskalacji (per-query, automatyczny).** Każde
zapytanie do `kb-query` /search, które trafia próg (`dist`, heurystyki),
eskaluje do API i **automatycznie** zapisuje/aktualizuje stronę wiki z
odpowiedzi. To bezpośrednio realizuje pętlę zwrotną (inwariant 4) bez udziału
człowieka w każdym pojedynczym zapisie.
**(C) Hybryda: bootstrap batch, utrzymanie pipeline.** Pierwsza fala stron
(PoC, potem szersza kompilacja korpusu) idzie trybem (A) — bo trzeba pokryć
setki/tysiące encji z pustego stanu, co jest z natury wsadowe i wymaga
przeglądu jakości. Po ustabilizowaniu wiki, dopisywanie/aktualizacja idzie
trybem (B), zdarzeniowo, przy okazji zapytań `/search`, które i tak przechodzą
przez `kb-query`.
### Rekomendacja: (C), z (B) odłożonym poza zakres tej fazy
Uzasadnienie:
1. **Skala nie pozwala na (B) od startu.** `kb-query` dziś **nie robi syntezy
odpowiedzi** — to jawnie zapisane w `kb/services/kb-query.md` (*„This is a
search API, not chat: no answer synthesis over results, that's phase 5"*).
Automatyczny zapis strony z „dobrej odpowiedzi" wymaga najpierw tej
syntezy (LLM nad wynikami retrievalu) — a to jest osobny, jeszcze
niezbudowany komponent, w planie `kb-m5-faza-mailowa.md` §11 zapisany jako
*„Synteza odpowiedzi + polityka eskalacji | faza 5 | po fazie 3/4"*.
Innymi słowy: (B) zakłada istnienie warstwy, którą dopiero ta faza ma
zbudować. Zaczynanie od (B) to budowanie pipeline'u nad nieistniejącym
sygnałem.
2. **Bootstrap wymaga przeglądu, nie automatyzacji.** 17 stron z §3 to
pierwsza próba entity resolution na korpusie, który nigdy wcześniej nie był
tak czytany — pierwsze przebiegi *będą* mylić się (aliasy tej samej osoby
pod różnymi adresami, jak `paweld2@gmail.com` vs `pawel@cosmose.co`, patrz
#9). Batch z operatorem czytającym diff przed commitem jest tu właściwym
trybem; automatyczny zapis bez przeglądu ryzykuje właśnie to, co inwariant
3 (lint) ma łapać *po fakcie* — lepiej łapać *przed* pierwszym zapisem.
3. **Koszt.** Batch: koszt jednej sesji, znany z góry (faza3 §11 szacuje
kompilację 35 stron proof jako „pomijalna" wobec ~1.54 USD pilota
streszczeń). Pipeline per-query: koszt proporcjonalny do ruchu na
`kb-query`, dziś zerowego (serwis bez UI logowania, brak ruchu
produkcyjnego opisanego w żadnym audycie) — budowanie licznika kosztów pod
ruch, którego jeszcze nie ma, jest przedwczesne.
**Wniosek: w tej fazie — wyłącznie (A)/(C)-batch.** Pipeline eskalacji (B) to
konsekwencja *syntezy odpowiedzi*, która sama jest poza zakresem tego reconu
(`kb-m5-faza-mailowa.md` §11) — wpisuję go do §8 (plan iteracji) jako **osobny,
późniejszy etap**, nie część pierwszego kroku.
### Trigger i częstotliwość dla batcha
Rekomendacja: **ręczny**, przywiązany do sesji roboczych KB, nie do ticku
ingestu. Uzasadnienie: `kb-ingest`/`kb-mail-sync` tykają bez nadzoru
(systemd timer) właśnie dlatego, że są deterministyczne i tanie (lokalny
embed). Kompilacja wiki zużywa API i wymaga jakościowego osądu przy każdym
zapisie na tym etapie — sprzeczne z „bez nadzoru". Automatyczny trigger „po
ticku ingestu" dodałby niekontrolowany koszt API do procesu, który dziś nie ma
żadnego budżetu ani limitu (`packages/kb-retrieval`/`kb-query` nie mają
pojęcia o kosztach API — nigdzie w repo nie ma licznika wydatków Anthropica).
---
## 5. Punkt 4 — Format linku dowodowego i odporność na re-chunking
### Co już zdecydowano
Faza3 §8.2 daje **dokładną składnię**, cytuję w całości bo to bezpośrednia
odpowiedź na pytanie zlecenia:
```markdown
---
sources:
- envelope_id: paperless:119
chunks: [4211, 4213] # document_chunk.id
- envelope_id: "CABtrY-...@mail.gmail.com"
chunks: [] # koperta bez chunków (np. sam manifest/headers)
---
Treść… składka 1 234 zł/rok [^paperless:119#4212].
```
Dwa poziomy: `sources:` we frontmatterze (agregat, dla lintu — sprawdza czy
*strona* ma dowody) i inline `[^envelope_id#chunk_id]` przy każdym fakcie
liczbowym (dla spot-checku — sprawdza czy *zdanie* ma dowód). To jest już
odpowiedź na „envelope_id vs chunk-id": **oba, na dwóch poziomach
granularności**, nie albo-albo.
### Luka, którą faza3 zostawia: `document_chunk.id` (bigint PK) nie jest stabilny
To jest realny problem, którego szkic nie adresuje wprost. `document_chunk.id`
to `bigint GENERATED … nextval(...)` — PK generowany przy insercie. Re-chunking
(zmiana `chunk_size`/`chunk_overlap`, zmiana modelu embeddingu, poprawka
chunkera) w obecnym pipeline'u (`jobs/mail-body-ingest`, `kb/phases/kb-m5-faza3.md`
§1.6, Decyzja 1) **usuwa i wstawia na nowo** wiersze `document_chunk` dla
przetwarzanej koperty — nowe `id`. Strona wiki napisana dziś z przypisem
`[^paperless:119#4212]` po re-chunkowaniu paperless wskazuje na `id`, który już
nie istnieje.
Sprawdziłem: nie ma w repo żadnego mechanizmu "kotwiczenia" chunka niezależnego
od PK (np. hash treści, offset w dokumencie źródłowym). `chunk_index` (kolumna
w `document_chunk`) jest stabilniejszy semantycznie (n-ty fragment tej koperty)
ale też **nie** jest gwarantowany stabilny — zmiana `chunk_size` przesuwa
granice i renumeruje wszystkie chunki tej koperty.
### Ocena: to nie jest luka krytyczna, bo lint już ją łapie — ale warto dociągnąć degradację
Inwariant 3 (lint) faza3 §8.2 już projektuje mechanizm wykrywania: *„spot-check
N losowych przypisów `[^...]` vs treść chunka w bazie"*. Martwy `chunk_id` po
re-chunkingu to dokładnie to, co ten spot-check złapie — `SELECT text FROM
document_chunk WHERE id = 4212` zwróci zero wierszy, lint zgłasza to jako
znalezisko w `_meta/lint-reports/`. **Mechanizm wykrywający już istnieje w
projekcie**, więc rekomendacja to nie „zbuduj nowy system kotwiczenia" tylko
**doprecyzowanie zachowania przy trafieniu martwego linku**:
**Rekomendacja — dwupoziomowy fallback, nie twardy błąd:**
1. Link **prymarny** to `[^envelope_id#chunk_id]` — precyzyjny, ale kruchy.
2. Gdy `chunk_id` nie istnieje (po re-chunkingu), **fallback do
`envelope_id`** — dowód wskazuje na kopertę jako całość ("ten fakt pochodzi
z tej wiadomości", zamiast "z tego dokładnie fragmentu"). Zdegradowane, ale
wciąż audytowalne, bo `envelope_id` **jest** stabilny (`envelope.id` to
Message-ID albo `sha256-…`, nigdy nie zmienia się po insercie —
`kb-mail-pillar.md` §3, kontrakt zamrożony).
3. Lint raportuje **liczbę zdegradowanych przypisów** jako osobną metrykę w
`_meta/lint-reports/` (nie tylko listę zerwanych linków) — to jest wczesny
sygnał "re-chunking coś ruszył, strony trzeba przejrzeć", zanim ktokolwiek
zauważy to ręcznie.
4. **Re-chunking = trigger do przeglądu, nie do automatycznej naprawy.**
Automatyczne przepisanie `chunk_id` na nowy najbliższy semantycznie chunk
wymagałoby ponownego embeddingu i porównania — kosztowne i ryzykowne (może
przypisać fakt do złego fragmentu po cichu). Degradacja do `envelope_id` +
ręczny przegląd przy najbliższej kompilacji tej strony jest tańsza i
bezpieczniejsza.
Konsekwencja praktyczna: re-chunking całego korpusu mailowego (227k kopert)
jest zdarzeniem rzadkim i świadomym (widziane już raz — Decyzja 1 fazy3,
`UNIQUE+model`), nie czymś co dzieje się przy okazji. Skala degradacji przy
takim zdarzeniu jest więc *skokowa i rzadka*, nie ciągła — fallback +
metryka w locie wystarczą, nie trzeba temu poświęcać osobnej infrastruktury.
---
## 6. Punkt 5 — Anty-propagacja: datowanie, sprzeczności, aktualizacja
### Co już zdecydowano
Frontmatter niesie `compiled_at`/`updated_at` (datowanie na poziomie strony).
Inwariant 3: lint okresowy łapie *„sprzeczności między stronami (fakty o tej
samej encji)"*. Inwariant 4: pętla zwrotna dopisuje nowe strony/aktualizacje z
dobrych odpowiedzi zapytań.
### Luki, które faza3 nie precyzuje i które zlecenie wprost pyta
**(1) Sekcja „niepewne/sprzeczne" na poziomie strony.** Dziś nic w
projektowanym frontmatterze/strukturze nie ma miejsca na "wiem, że dwa źródła
mówią co innego, i to jest w rękopisie widoczne". Lint wykrywa sprzeczność
*między stronami* — ale co z sprzecznością *wewnątrz* jednej kompilacji (np.
paperless:119 podaje jedną kwotę składki, wcześniejszy mail podaje inną —
podwyżka, błąd, czy różne polisy)?
**Rekomendacja:** sekcja `## Niepewne / sprzeczne` jako **konwencja treści**
strony (nie pole frontmattera — to jest proza, nie metadane), umieszczana
zawsze na końcu strony, gdy występuje. Format:
```markdown
## Niepewne / sprzeczne
- Składka OC: [^paperless:24#N] podaje kwotę roczną bez liczby, [^<msg-id>#M]
(mail 2025-03) wspomina "1 450 zł" — nie jest jasne, czy to ta sama polisa
czy poprzedni rok. **Nie rozstrzygane automatycznie** — do potwierdzenia
przy następnej kompilacji tej strony.
```
Lint (spot-check) traktuje tę sekcję jako **oczekiwaną**, nie jako defekt —
strona bez tej sekcji nie jest "lepsza", strona z niewykrytą sprzecznością w
treści głównej (fakt podany jako pewny, gdy dowody się rozjeżdżają) jest
gorsza. To jest różnica między *ukrywaniem* niepewności (zły kompilat) a jej
*nazwaniem* (dobry kompilat) — i to jest właśnie mechanizm, który ma
zapobiegać propagacji błędu: LLM kompilujący wprost pisze "nie wiem", zamiast
zgadywać i zamrażać zgadywankę jako fakt.
**(2) Polityka aktualizacji przy nowym, sprzecznym dowodzie.** Scenariusz ze
zlecenia: nowy mail przeczy istniejącej stronie. Dwie opcje:
- **Nadpisać** stronę nowym stanem (traktować najnowszy dowód jako
prawdziwy) — ryzyko: jeśli nowy mail jest błędny/nieaktualny/o innej
sprawie, kasuje się poprawną wcześniejszą treść bez śladu.
- **Dopisać do „Niepewne/sprzeczne"** i **nie nadpisywać** faktu w treści
głównej, dopóki sprzeczność nie zostanie rozstrzygnięta (przez kolejną
kompilację z jeszcze jednym dowodem, albo ręcznie przez operatora).
**Rekomendacja: druga opcja, z jednym wyjątkiem.** Domyślnie *dopisz, nie
nadpisuj* — bo strona ma być audytowalnym destylatem, nie najświeższą
migawką, a "nowszy mail" nie zawsze znaczy "poprawniejszy" (może być o innej
polisie, literówka nadawcy, spam). Wyjątek: gdy nowy dowód jest **tego samego
typu i wprost aktualizuje poprzedni** (np. "nowy cennik od 1.09" jawnie
zastępujący poprzedni, nie sprzeczny z nim) — to nie jest sprzeczność, to
aktualizacja, i idzie do treści głównej z nową datą `updated_at`, a stary fakt
przechodzi do sekcji „Historia" (jeśli strona jej potrzebuje) albo po prostu
znika, zastąpiony — to jest zwykła kompilacja, nie przypadek antypropagacji.
Rozróżnienie "to jest aktualizacja" vs "to jest sprzeczność" robi **sesja
kompilująca (CC/API)**, nie automat — zgodnie z inwariantem 2 (kompilacja to
zadanie dla modelu, nie dla reguły).
**(3) Datowanie twierdzeń w treści, nie tylko w frontmatterze.** Rekomendacja
dodatkowa: każdy fakt z konkretną datą ważności (cena, status sprawy, "aktualnie
w toku") niesie datę **przy fakcie**, nie tylko w `updated_at` strony — bo
strona może zbierać fakty z różnych momentów (mail z 2019 o starej umowie +
mail z 2026 o nowej), a jedna data na całą stronę zaciera to rozróżnienie.
Przykład: *„Składka 1 234 zł/rok (stan na 07.2026) [^paperless:119#4212]."*
To jest rozszerzenie konwencji przypisu z §5, nie nowy mechanizm.
---
## 7. Punkt 6 — Integracja z kb-query: retrieval, ryzyko sprzężenia
### Co już zdecydowano
Inwariant 5 (faza3 §8.1): *„Wiki wchodzi do retrievalu jako dodatkowe źródło…
kaskada: wiki → summary → chunk."* §8.2 rozwija: strona wiki = koperta
`source='wiki'`, `id='wiki:<ścieżka>'`, chunkowana i embedowana tym samym
jobem, **bez nowego schematu bazy** — reużywa istniejący kontrakt koperty.
### Co to znaczy konkretnie w kodzie dzisiejszego `packages/kb-retrieval`
Sprawdziłem `retrieval.py`: dziś są trzy tory (`flat`/`cascade`/`hybrid`), z
`cascade_retrieve` jako **dwupoziomowym** (`document_summary` → `document_chunk`)
i `hybrid_retrieve` jako cascade + równoległy skan `summaryless_sources`
(`gmail`, `fastmail`) scalany po `dist`. **Nie ma dziś trzeciego poziomu.**
Realizacja inwariantu 5 wymaga:
1. Nowej tabeli/kolekcji `document_summary`-podobnej dla wiki, **albo**
traktowania stron wiki jak paperless — envelope z summary (skoro strony
są krótkie, 13 chunki, `document_summary` dla całej strony ma sens
semantyczny inaczej niż dla 187k-wierszowego newslettera).
2. Nowej funkcji `wiki_retrieve` w `kb_retrieval/retrieval.py`, wołanej
**przed** `cascade_retrieve`, z wynikiem scalanym analogicznie do
`hybrid_retrieve` (ten sam embedder, ten sam cosine space — merge to sort,
nie renormalizacja, dokładnie jak dziś).
3. Zmiany w `search.py`/`app/main.py` `kb-query`, żeby nowy tryb (`mode`)
albo rozszerzenie `hybrid` obejmował wiki — i w `app/links.py`
(`build_result`) żeby wynik ze źródła `wiki` miał sensowny `link`
(`raw_ref` do pliku w `kb-wiki`, nie URL — bo `kb-wiki` nie jest hostowane
publicznie, patrz §2).
To jest **realna zmiana kodu** w serwisie produkcyjnym (`kb-query`), nie tylko
w nowym repo `kb-wiki` — warto to mieć jawnie w planie iteracji (§8), bo
inwariant 5 bez tego pozostaje deklaracją, nie działaniem.
### Ryzyko sprzężenia (self-citation / citogenesis) — luka w szkicu faza3
Zlecenie pyta wprost o to ryzyko i faza3 **go nie adresuje**. Mechanizm
ryzyka: kompilacja strony X czyta wyniki retrievalu (inwariant 2) → jeśli
retrieval w tym momencie już przeszukuje `source='wiki'` (inwariant 5, w pełni
wdrożony) → strona X może dostać jako "dowód" **inną stronę wiki**, która sama
została skompilowana z niepewnych/błędnych przesłanek → błąd się utrwala i
**wzmacnia** zamiast zanikać, bo kolejna kompilacja cytuje już nie surowy
mail, tylko wcześniejszą (błędną) syntezę, z pozorem niezależnego
potwierdzenia. To jest dokładnie mechanizm Wikipedia-citogenesis, przeniesiony
na kompilator jednoosobowy.
**Rekomendacja — rozdzielić dwie ścieżki retrievalu, które dziś inwariant 5
zlewa w jedną:**
1. **Retrieval na potrzeby `/search` (użytkownik, faza 5 synteza
odpowiedzi)** — tu wiki **powinna** wchodzić do kaskady (inwariant 5 ma
rację: skompilowana wiedza jest cenniejsza niż surowy chunk dla kogoś, kto
pyta "co wiem o PZU").
2. **Retrieval na potrzeby *kompilacji* nowej/aktualizowanej strony (sesja
CC/API pisząca `kb-wiki`)** — tu retrieval **musi** wykluczać
`source='wiki'` (parametr `exclude_sources=('wiki',)` w
`cascade_retrieve`/`hybrid_retrieve`, jedna linia zmiany analogiczna do
dzisiejszego `summaryless_sources`). Kompilacja zawsze czyta wyłącznie
warstwę dowodową (mail, paperless — evidence layer z ustaleń wyjściowych
operatora), nigdy inne strony wiki jako źródło faktów. Strony **mogą**
linkować się nawzajem przez `[[...]]` (graf, nawigacja) — ale to nie jest
to samo co "cytowanie jako dowód faktu"; link `[[pzu]]` w treści strony
`polisa-oc-auto` to odsyłacz do powiązanej strony, nie przypis źródłowy —
przypisy źródłowe (`[^...]`) zawsze wskazują `envelope_id`, nigdy
`wiki:...`.
Ten podział jest tani do wymuszenia (jeden parametr) i eliminuje ryzyko przy
źródle, zamiast polegać wyłącznie na lincie żeby je złapać po fakcie.
Rekomendacja: dopisać to jako **inwariant 7** przy zatwierdzaniu planu, nie
jako implementacyjny szczegół — to jest decyzja architektoniczna tej samej
wagi co inwarianty 16 z faza3 §8.1.
---
## 8. Punkt 7 — Zależności i kolizje
### Multiagent recon / dyspozytor
Sprawdzone bezpośrednio w `kb/subsystems/recon-multiagent.md` i potwierdzone
przez `kb/audits/mail-sync-2026-08-06.md` §5 (który zrobił dokładnie to samo
ćwiczenie dla przyrostówki): **dyspozytor subsystemu B nie istnieje jeszcze w
kodzie** — jest zdefiniowany jako *osobny projekt* w
`kb/decisions/architektura-2026-07-28.md` (*„B — do-the-work: dyspozytor zadań
(agent)… Osobny wysiłek, osobny projekt"*), planowany na PIHA. `kb-query`
występuje w reconie multiagentowym wyłącznie jako wiersz inwentarza, poza
zakresem control-plane/Telegram.
**Wniosek: zero zależności blokujących w żadną stronę**, z tym samym
zastrzeżeniem, jakie mail-sync-recon już zanotował dla siebie: nowy automat
(tu: ewentualny periodyczny lint albo — gdyby kiedyś powstał — pipeline
eskalacji z §4) **dokłada się do „shadow set"** bytów poza GitOps-ową detekcją
dryfu (`kb-ingest`, `stability-agent`, teraz `kb-mail-sync`, potencjalnie
`kb-wiki`-lint). Nie blokuje nic dzisiaj; warto, żeby trafiło świadomie do
otwartego pytania nr 5 tamtego reconu, a nie przez przeoczenie — dokładnie tak
samo jak zanotował to mail-sync-recon dla siebie.
**Telegram/approval flow** (`CLAUDE.md` „Action approval flow") dotyczy
wyłącznie akcji `container_restart`/`redeploy`/`disk_cleanup`/`alert_only` z
control-plane. Kompilacja wiki nie generuje żadnej z tych akcji — nie ma i nie
powinno być punktu styku. Jedyny sensowny alert, gdyby faza 5 dostała
automatyzację (§4 tryb B, poza zakresem tej fazy), byłby analogiczny do
`KbMailSyncStale`: "kompilator/lint nie chodzi", nie coś wymagające approvalu
operatora.
### kb-site publish
Rozstrzygnięte w §2: `kb-wiki` leży poza `kb/**/*.md`, więc `gen_pages.py`/
`publish.sh` **nie widzą go w ogóle** — zero kolizji, bo zero styku. Gdyby w
przyszłości ktoś położył treść `kb-wiki` wewnątrz `kb/` (odwrotnie od
rekomendacji §2), *wtedy* powstałaby realna kolizja: `gen_pages.py --check`
skanuje wygenerowany output pod kątem wycieków (IP, tokeny, ścieżki hosta) —
ale nie skanuje pod kątem **danych osobowych** (PESEL, adresy, numery umów),
bo nie taki był jego cel. To jest dodatkowy, mocny argument za §2 (osobne
repo): trzymanie wiki-encji poza `kb/` eliminuje całą tę klasę ryzyka
strukturalnie, zamiast polegać na tym, żeby nikt nigdy nie oznaczył strony o
PZU jako `visibility: public` przez pomyłkę.
### Follow-upy fazy mailowej — czy brudne chunki psują kompilację
Z `kb/incidents/2026-08-26-mail-sync-20-dni-ciszy.md` §6 (`Nie w zakresie tej
naprawy`, bez zmian od 08-06): **charset/mojibake w co najmniej 12 kopertach**
oraz **NUL w nagłówkach `jsonb`** pozostają otwarte. Oceniam wpływ na
wiki-kompilat:
- **Skala jest znikoma** — 12 kopert na 227 576 (≈0,005%). Statystycznie
prawdopodobieństwo, że pierwsza fala 17 stron z §3 natrafi akurat na jedną
z tych 12, jest bliskie zeru (żadna z zidentyfikowanych encji §3 nie
pochodzi z okresu/nadawcy powiązanego ze znanymi przypadkami mojibake wg
audytu 08-06).
- **Mechanizm szkody, gdyby trafiła:** kompilująca sesja CC dostaje chunk z
zepsutym kodowaniem jako "dowód" — najgorszy scenariusz to nie błędny fakt
(model raczej rozpozna nieczytelny tekst i pominie/zaznaczy niepewność, patrz
§6 wyżej), tylko **marnotrawstwo** — chunk wygląda jak szum, model go
ignoruje, dowód dla realnego faktu ginie z pola widzenia.
- **NUL w `entities` jsonb jest poważniejszy strukturalnie** — jeśli sesja
kompilująca odpytuje `entities` bezpośrednio (nie tylko `document_chunk.text`),
NUL bajt w jsonb może wywrócić parser po stronie klienta (Pythonowy
`json`/`asyncpg` różnie reaguje na `` w zależności od ścieżki). To
**nie zostało zweryfikowane w tym reconie** (nie testowałem odczytu takiego
wiersza) — flaguję jako `[do weryfikacji]`, nie jako potwierdzony problem.
**Rekomendacja: nie blokować pierwszego etapu na R1R3 z §6 incydentu**
(charset/NUL) — skala nie uzasadnia opóźnienia PoC. Warto natomiast, żeby
lint (inwariant 3) przy pierwszym uruchomieniu zrobił **jednorazowy skan**
`document_chunk`/`envelope.entities` pod kątem znaków zastępczych
(`<60>`) i `` w źródłach, które faktycznie zasiliły PoC (17 stron
§3) — tanie zabezpieczenie, żeby nie odkryć problemu dopiero przy spot-checku
tygodnie później.
---
## 9. Punkt 8 — Plan iteracji
Wzorzec: faza mailowa (`kb-m5-faza-mailowa.md` §10 „Plan implementacji" +
§12 kryterium ukończenia per krok) i mail-sync-recon (§6 „Podsumowanie i zakres
pracy" — tabela kroków z rodzajem zmiany). Stosuję ten sam format: kroki z
zależnościami, DoD per krok, pierwszy etap ograniczony do 12 sesji.
### Etap 0 — Zamknięcie decyzji (ten dokument + zatwierdzenie, 0 sesji kodu)
Operator przyklepuje §11 (Decyzje) poniżej. Bez tego nic dalej nie rusza —
identyczny wzorzec jak Decyzje (a)-(g) w mail-sync-recon, które **zatwierdzono
w całości** przed implementacją (`kb-m5-faza-mailowa.md` nagłówek statusu).
### Etap 1 — Szkielet `kb-wiki` + 3 strony proof (**cel: 1 sesja**)
> **Wykonany 2026-08-27 — z zastrzeżeniem: ten audyt nie wykrył, że proof-of-concept
> (5 stron) już istniał od 2026-07-21 na Forgejo (`oskar/kb-wiki`, poza zestawem
> dowodów tego reconu). Pełne rozliczenie i rekoncyliacja: `kb/phases/kb-m5-faza5-wiki.md`.**
Zakres, celowo mniejszy niż faza3 §8.2 (35 stron) i mniejszy niż lista z §3
(17 encji) — pierwszy etap ma zweryfikować *mechanizm*, nie pokryć korpus:
| # | Element | DoD |
|---|---|---|
| 1 | Repo `kb-wiki` (git, poza `homelab-codex-ws`), struktura katalogów wg faza3 §8.2, `_meta/conventions.md` (format frontmattera + inline-przypisów z §5, w tym reguła fallback z §5, sekcja „Niepewne/sprzeczne" z §6, rozdział retrievalu z §7 inwariant 7) | Repo istnieje, `conventions.md` kompletny, czytelny bez kontekstu tej sesji |
| 2 | Walidator `kb-wiki/check_okf.py` — wariant lekki, wzorowany na `~/narty-2027/saalbach-kb/check_okf.py` (frontmatter parsowalny + `type` niepuste), rozszerzony o sprawdzenie bloku `sources` (niepusty, każdy `envelope_id` istnieje w bazie — może wymagać `KB_DSN`) | `python3 check_okf.py` PASS na repo z krokiem 3 |
| 3 | **3 strony proof**, kompilowane ręcznie sesją CC z wyników `hybrid_retrieve`/`cascade_retrieve` na encjach #1 (`fll-2025-26`), #5 (`mbank`), #9 (`pawel-cesar-sanjuan-szklarz`) z §3 — świadomie zróżnicowane typy (sprawa/podmiot/osoba-z-aliasami) i źródła (paperless/gmail-długi-ciąg/gmail-multi-adres) | Każda strona: frontmatter zgodny z konwencją, ≥1 przypis inline, `sources:` niepuste, przechodzi walidator |
| 4 | Ręczny spot-check (inwariant 3, wersja manualna — pełny lint to Etap 2) — operator albo agent czyta 3 strony vs źródłowe chunki, potwierdza brak halucynacji | Zapisany w `_meta/lint-reports/2026-08-XX.md` — pierwszy raport, nawet jeśli ręczny |
**Dlaczego te trzy, nie 17:** `pawel-cesar-sanjuan-szklarz` (#9) testuje
entity resolution na aliasach — jeśli to zawiedzie, cała reszta listy z §3
(gdzie kilka pozycji ma podobny problem, np. `cosmose`↔#7/#8) wymaga
przeprojektowania konwencji *zanim* zainwestuje się w kolejne 14 stron.
Trzy strony, świadomie różne, są tańszym testem niż siedemnaście podobnych.
### Etap 2 — Lint automatyczny + rozszerzenie do pełnej listy §3 (**2. sesja**)
Zależy od Etapu 1 (mechanizm zweryfikowany). Zakres:
1. `kb-wiki/lint.py` — implementacja inwariantu 3 w kodzie (nie ręcznie):
linki `[[...]]` do nieistniejących stron, strony-sieroty, spot-check N
losowych przypisów (zapytanie do `document_chunk` po `id`, fallback do
`envelope_id` z §5 gdy `id` martwy — z metryką degradacji), sprzeczności
między stronami (heurystyka: te same encje w `sources:` różnych stron z
rozbieżnymi liczbami przy tym samym typie faktu — pełna detekcja
semantyczna to zadanie dla CC, nie dla skryptu; skrypt robi tylko
pre-filtr kandydatów).
2. Rozszerzenie o pozostałe encje z §3 (do 17), plus jednorazowy skan pod
kątem `<60>`/`` (§8 „follow-upy fazy mailowej") na źródłach, które
faktycznie zasiliły te strony.
3. **DoD etapu**: `lint.py` uruchamiany ręcznie (nie timer — zgodnie z §4,
automatyzacja poza zakresem), raport w `_meta/lint-reports/`, zero
krytycznych znalezisk nierozwiązanych (martwe linki, sieroty) — degradacje
chunk-id i sekcje „niepewne" to oczekiwany, nie błędny stan.
### Poza zakresem pierwszych dwóch etapów (świadomie odłożone)
| Temat | Gdzie wraca |
|---|---|
| Integracja `source='wiki'` w `packages/kb-retrieval` (§7, inwariant 5 + 7) | Etap 3 — wymaga działającego korpusu wiki (≥Etap 2) zanim ma sens dodawać go do retrievalu produkcyjnego |
| Synteza odpowiedzi (`/search` → odpowiedź w naturalnym języku) | Osobna faza, `kb-m5-faza-mailowa.md` §11 już to tak kotwiczy |
| Pipeline eskalacji per-query (tryb B z §4) | Po syntezie odpowiedzi — bez niej nie ma sygnału do eskalacji |
| Automatyzacja lintu (timer) | Po Etapie 2, gdy wiadomo ile realnie kosztuje jeden przebieg |
| Ewentualny hosting/publikacja `kb-wiki` | Nie zaplanowane — patrz §2, dziś brak potrzeby |
| Druga tura encji (#18-20 z §3, `outbox.pl`/dawny pracodawca) | Po ustaleniu kontekstu firmy — osobne mini-śledztwo |
---
## 10. Punkt 9 — Decyzje operatora
> **Status: (a)-(h) ZATWIERDZONE przez operatora w całości 2026-08-27, bez
> zmian** — wzorzec identyczny jak decyzje (a)-(g) w `mail-sync-2026-08-06.md`
> (zatwierdzone 2026-08-06 w całości, patrz `kb-m5-faza-mailowa.md` nagłówek
> statusu).
Wzorzec (a)-(g) z `kb/audits/mail-sync-2026-08-06.md` §4.
### (a) Wersja OKF dla `kb-wiki`: pin v0.1 (jak pilot) vs start od razu na v0.2
**Rekomendacja: pin v0.1, z migracją zaplanowaną, nie natychmiastową.**
- Pilot narty27 **potwierdził w praktyce**, że pinowanie v0.1 działa i że
zmiana jest tania: `docs/sessions/2026-07-31-kb-f4-final-narty27.md`
*„OKF v0.1 działa w praktyce, a pinowanie wersji okazało się słuszne — spec
ewoluuje (v0.2: `timestamp``generated: {by, at}`, provenance
first-class). Przy fazie 5 rozważyć start od razu na v0.2 albo pin v0.1 z
zaplanowaną migracją."* To jest bezpośrednia rekomendacja z pilota, cytowana
w zleceniu tego reconu.
- **Ale**: `kb-wiki` ma silniejszą potrzebę provenance niż narty27 (który jest
planowaniem wyjazdu, nie audytowalnym kompilatem nad wrażliwym korpusem).
Provenance first-class w v0.2 (`generated: {by, at}`) pokrywa się częściowo
z tym, co faza3 §8.2 już projektuje osobno (`compiled_by`/`compiled_at` w
`sources`-owym frontmatterze) — czyli **potrzeba, którą v0.2 adresuje,
została już zaadresowana lokalną konwencją**, niezależnie od wersji OKF.
To osłabia argument "musimy mieć v0.2 od razu", bo pole `compiled_by`
robi tę samą robotę.
- Decydujący argument za v0.1: **`homelab-codex` samo jest dziś na v0.1**
(`scripts/kb/check_okf.py`, `PINNED_OKF = "0.1"`). Gdyby `kb-wiki` (osobne
repo, ale koncepcyjnie ta sama rodzina dokumentów co `kb/`) startował na
v0.2, powstałyby **dwie różne wersje specyfikacji w tym samym ekosystemie
narzędzi** — dwa parsery, dwie reguły walidacji, mylące przy przenoszeniu
wzorców między repo (dokładnie to, co uzasadnia rekomendację pilota "pin +
zaplanowana migracja" zamiast "zawsze najnowsza wersja"). Spójność z resztą
ekosystemu waży więcej niż wyprzedzenie o jedną wersję specyfikacji, którą
i tak trzeba będzie kiedyś zmigrować w `kb/` też.
- **Migracja zaplanowana**: gdy `homelab-codex`/`kb/` migruje na v0.2 (osobna
decyzja, poza zakresem tego reconu), `kb-wiki` migruje w tym samym oknie —
jedna zamiana pola (`timestamp`→`generated: {by,at}`), zgodnie z oceną
pilota że to tania zmiana.
### (b) Repo `kb-wiki` — potwierdzenie D7 z faza3
**Rekomendacja: potwierdzić bez zmian** (§2). Jedyne doprecyzowanie: kompilacja
nie idzie przez `agent.sh`-owy worktree — osobny tryb pracy z dwoma klonami
obok siebie, do zapisania w `kb-wiki/_meta/conventions.md`.
### (c) Tryb kompilatora — potwierdzenie batch/hybryda, pipeline eskalacji poza zakres
**Rekomendacja:** (§4) batch (CC/API, ręczny trigger) dla bootstrapu i
utrzymania w tej fazie; pipeline eskalacji per-query (tryb B) świadomie
odłożony do momentu, gdy istnieje synteza odpowiedzi w `kb-query` — bez niej
nie ma sygnału, który miałby eskalować.
### (d) Fallback chunk-id → envelope_id przy re-chunkingu
**Rekomendacja:** (§5) dwupoziomowy link z degradacją do `envelope_id`, lint
raportuje liczbę zdegradowanych przypisów jako osobną metrykę, re-chunking
traktowany jako trigger do ręcznego przeglądu, nie do automatycznej naprawy.
### (e) Sekcja „Niepewne/sprzeczne" i polityka aktualizacji — dopisać do konwencji
**Rekomendacja:** (§6) sekcja jako konwencja treści (nie pole frontmattera),
domyślna polityka „dopisz, nie nadpisuj" przy sprzecznym nowym dowodzie,
wyjątek dla jawnych aktualizacji tego samego faktu, rozróżnienie robi sesja
kompilująca.
### (f) Inwariant 7 — rozdział retrievalu kompilacji od retrievalu zapytań
**Rekomendacja:** (§7) dopisać do inwariantów faza3 §8.1 jako inwariant 7:
kompilacja strony wiki **nigdy** nie czyta `source='wiki'` jako dowód
(`exclude_sources=('wiki',)`), tylko `/search` (warstwa użytkownika, po
zbudowaniu syntezy odpowiedzi) widzi wiki w kaskadzie. Mitygacja
self-citation/citogenesis przy źródle, nie tylko przez lint po fakcie.
### (g) Zakres i kolejność Etapu 1 — 3 strony proof, nie 17
**Rekomendacja:** (§9) Etap 1 = mechanizm (3 strony celowo zróżnicowane:
sprawa/podmiot/osoba-z-aliasami), Etap 2 = skala (do 17) + lint w kodzie.
Rozszerzenie listy encji (#18-20, `outbox.pl`) odłożone do osobnego
mini-śledztwa kontekstu firmy.
### (h) Follow-upy fazy mailowej (charset/NUL) — nie blokują, jednorazowy skan przy Etapie 2
**Rekomendacja:** (§8) nie opóźniać PoC na R1R3 z incydentu 20-dni-ciszy;
dopisać jednorazowy skan `<60>`/`` do zakresu Etapu 2 (lint), nie jako
osobny, wcześniejszy krok.
---
## 11. Podsumowanie
Korpus mailowy dogonił dziś — blokada z `kb-m5-faza3.md` §8.1 jest zdjęta
(§1). Architektura wiki-kompilatu jest w dużej mierze **już zaprojektowana i
zatwierdzona** (`kb-m5-faza3.md` §8, decyzja 2026-07-15/17) — ten recon
weryfikuje ją na aktualnym stanie repo, dociąga trzy realne luki, których
tamten szkic nie adresował (**chunk-id vs re-chunking** §5, **self-citation
przy retrievalu kompilacyjnym** §7 — nowy inwariant 7, **sekcja
niepewne/sprzeczne** §6), i daje 17-pozycyjną listę encji zalążkowych
wyprowadzoną z realnych SELECT-ów na żywym korpusie zamiast z trzech
przykładów w szkicu (§3). Rekomendowany pierwszy etap to **3 strony proof w
1 sesji** (świadomie mniej niż faza3 §8.2 sugerowała, bo pierwszy test ma
zweryfikować mechanizm entity-resolution na trudnym przypadku — aliasach —
zanim zainwestuje się w skalę), z jawnym odłożeniem pipeline'u eskalacji i
integracji retrievalu produkcyjnego do kolejnych etapów.
Decyzje: (a)-(h) w §10 — **ZATWIERDZONE przez operatora w całości 2026-08-27,
bez zmian.**

View file

@ -3,7 +3,7 @@ okf: "0.1"
type: phase type: phase
visibility: private visibility: private
status: active status: active
updated: 2026-07-17 updated: 2026-08-27
links: [] links: []
--- ---
@ -628,6 +628,17 @@ Adapter i embed są już idempotentne — nowość to wyłącznie orkiestracja i
> półprodukt kompilacji) i PO filtrze śmieciowych chunków. Pełna wiki po fazie mailowej > półprodukt kompilacji) i PO filtrze śmieciowych chunków. Pełna wiki po fazie mailowej
> (przyrostówka) — wcześniej kompilat byłby fotografią przeszłości. > (przyrostówka) — wcześniej kompilat byłby fotografią przeszłości.
**Inwariant 7 (dodany 2026-08-27 — rozszerzenie, nie zmiana inwariantów 16
powyżej, które pozostają NIE podlega zmianie):** decyzja (f),
`kb/audits/wiki-kompilat-recon-2026-08-26.md` §10, zatwierdzona przez operatora
w całości 2026-08-27. Kompilacja strony wiki **nigdy** nie czyta `source='wiki'`
jako dowodu (`exclude_sources=('wiki',)` w `cascade_retrieve`/`hybrid_retrieve`) —
retrieval na potrzeby kompilacji zawsze wyklucza wiki, czyta wyłącznie warstwę
dowodową (mail, paperless). Tylko `/search` (warstwa użytkownika, po zbudowaniu
syntezy odpowiedzi — inwariant 5, faza 5) widzi wiki w kaskadzie. Mitygacja
self-citation/citogenesis przy źródle retrievalu, nie tylko przez lint (inwariant
3) po fakcie — patrz audyt §7 dla pełnego rozumowania.
### 8.2 Rozwinięcie wykonawcze (szczegóły, decyzje architektoniczne bez zmian) ### 8.2 Rozwinięcie wykonawcze (szczegóły, decyzje architektoniczne bez zmian)
**Repozytorium** (rozstrzygnięcie punktu 6 szkicu): osobne repo `kb-wiki` — decyzja 7, **Repozytorium** (rozstrzygnięcie punktu 6 szkicu): osobne repo `kb-wiki` — decyzja 7,

View file

@ -0,0 +1,322 @@
---
okf: "0.1"
type: phase
visibility: private
status: active
updated: 2026-08-27
links:
- ../audits/wiki-kompilat-recon-2026-08-26.md
- kb-m5-faza3.md
- kb-m5-faza-mailowa.md
---
# Moduł 5, faza 5 — wiki-kompilat (Etap 1: bootstrap + proof, i **odkrycie rozjazdu**)
> **Status: Etap 1 wykonany 2026-08-27 — ale w cieniu ważniejszego znaleziska: proof-of-concept
> wiki-kompilatu (repo `kb-wiki`, 5 stron) już istniał od **2026-07-21**, na Forgejo
> (`https://forgejo.kapala.org/oskar/kb-wiki`), i `kb/audits/wiki-kompilat-recon-2026-08-26.md`
> tego nie wykrył. Ta sesja (2026-08-27) zbudowała **niezależny, równoległy** lokalny bootstrap
> (`~/kb-wiki-etap1-sesja-2026-08-27/`, 3 strony) nie wiedząc o repo z lipca — dowiedziała się
> dopiero przy Części C tego zadania, kiedy próba udokumentowania "remote nie istnieje" (cytat z
> polecenia sesji, zgodny z audytem) skłoniła do sprawdzenia Forgejo wprost. **Nic nie zostało
> wypchnięte do prawdziwego zdalnego repo** — ta sesja tylko czytała je (`git clone`, pull-only).
>
> **Rekoncyliacja domknięta 2026-08-27 (operator zdecydował: opcja 1 z §4 —
> scalić).** `kb-wiki` ma teraz **7 stron**: 5 lipcowych + `fll-2025-26`
> scalone (dwie niezależne kompilacje tej samej encji) + `mbank`/
> `pawel-cesar-sanjuan-szklarz` przeniesione bez kolizji. Cztery commity na
> `master` **lokalnie, nie pushnięte** — czekają na review operatora przed
> pushem do Forgejo. Pełne rozliczenie: §6.
---
## 0. Jak doszło do odkrycia (żeby przyszła sesja nie powtórzyła tej samej luki)
`kb/audits/wiki-kompilat-recon-2026-08-26.md` (2026-08-26, decyzje a-h zatwierdzone
2026-08-27) stwierdza wprost w nagłówku: *„Nic nie zostało zaimplementowane,
zdeployowane ani skommitowane poza tym dokumentem."* Dowody audytu: worktree
`homelab-codex-ws` cięty z mastera, pilot `~/narty-2027/saalbach-kb` (lokalny
odczyt), żywa baza `kb` na PIHA (`ssh piha 'docker exec kb-postgres psql...'`),
`systemctl status` na PIHA. **Forgejo nie było w tym zestawie dowodów** — audyt nie
odpytał API/repo Forgejo, mimo że `CLAUDE.md` i `inventory/topology.yaml` jasno
wskazują `git_provider: forgejo` jako miejsce, gdzie `kb-wiki` miało w ogóle
powstać (decyzja D7 faza3 §8.2: *„osobne repo kb-wiki"*, bez wskazania konkretnego
hosta w chwili pisania szkicu — Forgejo to naturalna, ale niejawna implikacja).
Zlecenie tej sesji (2026-08-27, Etap 1) powtórzyło za audytem: *„REMOTE NIE
ISTNIEJE — nie konfiguruj, nie pushuj; operator założy repo na Forgejo później."*
To zdanie okazało się **faktycznie nieprawdziwe** — sprawdzone dopiero przy pisaniu
tego dokumentu (Część C zlecenia), zapytaniem GET do `http://100.108.208.3:3000/
api/v1/repos/search?q=kb-wiki` (read-only, bez uwierzytelnienia, `private: false`
więc odpowiedź jawna) i następnie `git clone` (pull-only, potwierdzone uprawnieniami
API: `"push": false, "pull": true`) do katalogu tymczasowego poza tym repo.
**Wniosek na przyszłość:** *„repo nie istnieje"* w planie/audycie nie jest
wystarczające bez sprawdzenia bezpośrednio w systemie, który je hostuje
(`inventory/topology.yaml` → `git_provider: forgejo` → Forgejo API/`git ls-remote`),
nie tylko w obrębie `homelab-codex-ws` i lokalnych klonów znanych z wcześniejszych
sesji. Ten sam błąd metodologiczny mógłby się powtórzyć przy dowolnym innym „osobnym
repo" planowanym w dokumentacji, ale nigdy nie zweryfikowanym wprost u hosta.
---
## 1. Co faktycznie istnieje w `oskar/kb-wiki` (Forgejo, od 2026-07-21)
Stan wg `git clone https://forgejo.kapala.org/oskar/kb-wiki.git` (read-only,
2026-08-27), 3 commity na `master`:
```
156925c struktura: katalogi, konwencje, README
42c7731 compile: pzu, warta, ubezpieczenie-auto-ga431ja-2026, fll-2025-26, wspolnota-mieszkaniowa-targowa-2a ← kaskada retrievalu kb-postgres@PIHA
c52f867 lint: pierwszy raport ręczny 2026-07-21
```
Struktura: `README.md`, `INDEX.md` (wielka litera — inaczej niż plik zarezerwowany
OKF `index.md`, patrz §3 niżej), `_meta/conventions.md`, `_meta/lint-reports/
lint-report-2026-07-21.md`, 5 stron-encji:
| Strona | Typ | Skrót |
|---|---|---|
| `podmioty/pzu.md` | podmiot | PZU — OWU Auto/OC, karty produktu, oferta T1531938417 |
| `podmioty/warta.md` | podmiot | WARTA — OWU AC Standard/Komfort/Moje Auto |
| `sprawy/ubezpieczenie-auto-ga431ja-2026.md` | sprawa | Porównanie ofert PZU/WARTA/InterRisk, Mazda 6 (GA431JA), 06.2026 |
| `sprawy/fll-2025-26.md` | sprawa | FLL Challenge UNEARTHED, drużyna #1100, Warszawa II |
| `sprawy/wspolnota-mieszkaniowa-targowa-2a.md` | sprawa | Wspólnota Mieszkaniowa Targowa 2A (Pruszków) — zebranie roczne 2026 |
`osoby/`, `umowy/`, `tematy/` istnieją jako katalogi puste (`.gitkeep`) — **zero
stron typu `osoba`** w lipcowym proof-of-concept. To jest jedyna luka pokrycia
typów, którą Etap 1 tej sesji (§2) faktycznie domyka, nie duplikuje.
Jakość: wysoka, metodologicznie zgodna z tym, co audyt 08-26 dopiero rekomendował
(sekcja „Niepewne/sprzeczne" jako wzorzec — tam nazwana wprost inaczej, ale ta sama
funkcja; degradacja przy uszkodzonym OCR zamiast ślepego zaufania streszczeniu;
84/86 wpisów `sources[].chunks` i 73/73 przypisów inline zweryfikowanych wprost w
bazie, udokumentowane w `_meta/lint-reports/lint-report-2026-07-21.md`). Pełna
treść: `_meta/conventions.md` tamtego repo (różni się od konwencji tej sesji w
kilku miejscach nieistotnych merytorycznie — casing `INDEX.md`/`index.md`, typ
`meta` vs `temat` dla `conventions.md` — patrz §3).
**`kb-m5-faza4.md` (linia 17, `docs/sessions/2026-07-21.md`) już to dokumentowały
poprawnie** — ten fakt był w repo `homelab-codex-ws` cały czas, czytelny `git log`/
`grep -r kb-wiki`. Audyt 08-26 po prostu tego nie sprawdził (§0 wyżej).
---
## 2. Co ta sesja (2026-08-27) faktycznie zrobiła — Etap 1 wg zlecenia, **lokalnie, bez wiedzy o §1**
Zbudowany od zera, niezależnie: `~/kb-wiki-etap1-sesja-2026-08-27/` (przemianowany
z `~/kb-wiki/` po odkryciu §1 — żeby nie kolidować ścieżką z przyszłym `git clone`
prawdziwego repo). Git lokalny, **6 commitów, nigdy nie pushowany do żadnego
remote** (żaden remote nie był skonfigurowany — zgodnie z literą pierwotnego
zlecenia, które okazało się oparte na błędnym założeniu z §0).
3 strony proof, celowo zróżnicowane typy (jak żądał audyt §9 Etap 1):
| Strona | Typ | Pokrycie względem §1 |
|---|---|---|
| `sprawy/fll-2025-26.md` | sprawa | **Duplikat tej samej encji** co lipcowe repo — skompilowany niezależnie, z częściowo innym zestawem chunków (uzupełniające, nie identyczne `sources`). Wysoka zgodność wniosków — patrz §4. |
| `podmioty/mbank.md` | podmiot | **Nowe pokrycie** — nie istnieje w lipcowym repo (tam tylko PZU/WARTA, ubezpieczyciele) |
| `osoby/pawel-cesar-sanjuan-szklarz.md` | osoba | **Domyka lukę** — lipcowe repo ma katalog `osoby/` pusty; to pierwsza strona typu `osoba` w całym projekcie, jedyny dotąd test entity-resolution na aliasach |
Pełna treść stron, `_meta/conventions.md` tej sesji (napisane niezależnie od
lipcowego — patrz różnice §3), `check_okf.py` (walidator, **którego lipcowe repo
nie ma jako skomitowanego pliku** — tamta sesja weryfikowała `sources`/przypisy
ad hoc, bez zostawienia narzędzia), i `_meta/lint-reports/2026-08-27.md` — wszystko
w `~/kb-wiki-etap1-sesja-2026-08-27/`, 6 commitów (`git log --oneline`):
struktura+konwencje, walidator, 3× `compile:`, 1× `lint:`.
DoD audytu §9 Etap 1 (tabela, 4 punkty) — spełnione **lokalnie**, w tym repo
równoległym: repo istnieje z konwencjami, walidator działa (zielono, 40 par
`sources` + 47 inline przypisów zweryfikowanych wprost w `KB_DSN`), 3 strony
proof spełniają inwarianty 1-2, raport lintu ręcznego zapisany.
**Weryfikacja lint:**
```
$ python3 check_okf.py # w ~/kb-wiki-etap1-sesja-2026-08-27/
ZGODNE z OKF v0.1 (kb-wiki): [...] wszystkie 40 przypisów zweryfikowane w bazie.
```
**`scripts/kb/check_okf.py` z `homelab-codex-ws` uruchomiony na tym samym repo —
zgodnie z rozjazdem przewidzianym w zleceniu:**
```
$ python3 scripts/kb/check_okf.py ~/kb-wiki-etap1-sesja-2026-08-27
Zakres: kb, docs/sessions
Sprawdzono plików .md: 0
BRAK plików w zakresie — nie ma czego walidować.
```
`SCOPE = ("kb", "docs/sessions")` w tamtym skrypcie jest wpisany na sztywno
względem `root``kb-wiki` (żadna z dwóch kopii, lipcowa ani ta) nie ma
podkatalogu `kb/` ani `docs/sessions/`, więc walidator zawsze zwróci "brak
plików" dla tego layoutu. Nie hackowany — to jest realny, ustrukturalny rozjazd
(nie błąd w `kb-wiki`), odnotowany tu jako follow-up: albo `scripts/kb/
check_okf.py` dostaje parametr `--scope`, albo `kb-wiki` (dowolna wersja) trzyma
własny walidator na stałe (rekomendacja tej sesji — już częściowo zrobione, patrz
`check_okf.py` w repo tej sesji).
---
## 3. Różnice konwencji: lipiec vs ta sesja (nieistotne merytorycznie, warto ujednolicić)
| Aspekt | Lipiec (`oskar/kb-wiki`, prawdziwe repo) | Ta sesja (lokalne) | Rekomendacja |
|---|---|---|---|
| Plik nawigacyjny root | `INDEX.md` (duża litera), osobny od OKF-owego `index.md` (nie istnieje w ogóle w lipcowym repo — brak pliku `index.md`) | `index.md` (mała litera, OKF §11 reserved-file, pełni tę samą rolę) | Do rozstrzygnięcia przez operatora — lipcowe repo **nie ma pliku `index.md`** w ogóle, więc formalnie nie przechodzi `check_okf.py` tej sesji (`brak bloku frontmattera` nie wystąpi, bo pliku nie ma, ale reguła "root index.md wymagany" by to złapała, gdyby uruchomić walidator tej sesji na lipcowym repo — nie testowane, bo nie nasze repo do modyfikacji) |
| `type` dla `_meta/conventions.md` | `meta` (dodatkowa wartość w słowniku typów) | `temat` (nadużycie istniejącego typu, bo `TYPES` tej sesji nie miało `meta`) | Lipcowe podejście czystsze — warto przyjąć `meta` jako 6. typ przy rekoncyliacji |
| Frontmatter `sources` obowiązkowe niepuste | Nie wymuszone narzędziem (ręczna dyscyplina) | Wymuszone w `check_okf.py` (poza `_meta/`) | Zachować regułę tej sesji przy rekoncyliacji — tańsze niż poleganie na dyscyplinie |
| Walidator jako plik w repo | Brak (weryfikacja ad hoc, nieskomitowana) | `check_okf.py`, skomitowany, wielokrotnego użytku | Przenieść `check_okf.py` tej sesji do prawdziwego repo przy rekoncyliacji |
| Remote skonfigurowany | Tak (Forgejo, `master`) | Nie (lokalny, celowo) | — |
Żadna z różnic nie jest błędem — obie konwencje są spójne z faza3 §8.2 i audytem
08-26 w rzeczach, które się nakładają (format `[^envelope_id#chunk_id]`, fallback,
sekcja niepewności, CC/API-only, izolacja retrievalu kompilacji od `source='wiki'`).
---
## 4. Rekoncyliacja — do operatora, nie rozstrzygane przez tę sesję
To jest decyzja o realnych, już opublikowanych danych osobowych (finanse,
ubezpieczenia, dziecko na turnieju) w prawdziwym repo — powyżej progu, przy którym
ta sesja podejmuje decyzje sama. Opcje, bez rekomendacji wiążącej:
1. **Scalić nowe strony tej sesji do prawdziwego repo.** `podmioty/mbank.md` i
`osoby/pawel-cesar-sanjuan-szklarz.md` nie kolidują z niczym w lipcowym repo —
czysty dodatek. `sprawy/fll-2025-26.md` **koliduje** (dwie różne kompilacje tej
samej encji) — wymaga porównania treść-po-treści przez operatora albo kolejną
sesję CC, nie automatycznego scalenia.
2. **Porównanie dwóch niezależnych kompilacji `fll-2025-26` jako test jakości
kompilatora.** Obie wersje (lipcowa i ta) zgadzają się merytorycznie w
kluczowych punktach: wynik 195 pkt jako jedyny czytelny wprost z chunka
(`paperless:119#277`/`#278` — obie sesje trafiły w ten sam chunk niezależnie),
pozostałe dwa wyniki (165/230) tylko ze streszczenia, nominacja do Nagrody
Sędziów niepotwierdzona wprost w chunku, imiona z formularzy zgód celowo
pominięte, nazwisko trenera nieczytelne OCR. Rozbieżność: ta sesja znalazła i
zacytowała sprzeczność liczebności drużyny (10 vs 8 osób, z korespondencji
gmail — lipcowa wersja jej nie ma, bo nie sięgnęła po te same koperty gmail).
Lipcowa wersja znalazła niejednoznaczność sezonu SUBMERGED vs UNEARTHED
(`paperless:6`) i konkretne imiona dzieci z nazw plików (metadane, nie treść
dokumentu) — ta sesja pominęła `paperless:6` całkowicie i nie sięgnęła po
`entities[type=filename]`. **Dwie niezależne kompilacje tej samej encji nie są
sprzeczne, są komplementarne** — dobry sygnał, że metoda jest powtarzalna, zły
sygnał, że jedna kompilacja pomija realne dowody, które druga znalazła.
3. **Zostawić oba repo osobno, jawnie zarchiwizować to jako dwa niezależne
przebiegi tej samej fazy.** Najbezpieczniejsze, ale traci szansę na scalenie
uzupełniających się dowodów z punktu 2.
Krok operatora, niezależnie od wyboru z (1)-(3): **`~/kb-wiki-etap1-sesja-2026-08-27/`
istnieje lokalnie, nigdy nie pushowany** — do usunięcia/scalenia/zachowania wg
decyzji, nie automatycznie.
---
## 5. Poza zakresem tej notatki (Etap 2, jeśli operator potwierdzi kontynuację)
Zależnie od wyniku rekoncyliacji z §4:
- Skala do pełnej listy 17 encji z audytu §3 (już częściowo pokryta lipcowym repo
inną listą — PZU/WARTA/wspólnota/FLL nie pokrywają się z 17-pozycyjną listą
audytu poza `fll-2025-26`; wymaga ponownego review, które z 17 są już zrobione
pod innymi nazwami).
- Lint automatyczny w kodzie (`kb-wiki/lint.py`, inwariant 3 pełny) — żadna z
dwóch kopii tego nie ma, obie robiły lint ręcznie.
- Jednorazowy skan `<60>`/`\x00` na źródłach, które faktycznie zasiliły PoC (decyzja
h) — nie wykonany w żadnej z dwóch kompilacji; oba proof-of-concept **napotkały**
uszkodzony OCR organicznie (paperless:119/120 w obu wersjach fll-2025-26,
mbank.md ta sesja) bez systematycznego skanu.
- Integracja `source='wiki'` w `packages/kb-retrieval` (inwariant 5+7) — poza
zakresem obu przebiegów.
- Rozjazd `scripts/kb/check_okf.py` (SCOPE sztywny) — patrz §2, nie naprawiony
celowo (instrukcja zlecenia: nie hackować).
---
## 6. Rekoncyliacja domknięta (2026-08-27, decyzja operatora: opcja 1 z §4)
Operator zdecydował scalić — bez rozstrzygania punktu 3 z §4 (dwa osobne
repo) i bez samego jedynie archiwizowania punktu 2 (test jakości
kompilatora, wykonany przy okazji scalenia `fll-2025-26`, patrz niżej).
Wykonano w klonie roboczym `~/kb-wiki` (repo prawdziwe, `master`), **cztery
commity, lokalnie, świadomie nie pushnięte** — review operatora przed
pushem do Forgejo jest krokiem następnym, nie tej sesji.
### 6.1 `fll-2025-26` — scalenie dwóch niezależnych kompilacji
Wykonane **przed** tą sesją reconciliation (`kb-wiki` commit `c4127b9`,
autor: operator lub wcześniejsza sesja — poza zakresem tego zadania, ta
sesja zastała je już na `master` i pushnięte: *"Push zrobiony"*, cytat
zlecenia). Wynik zgodny z analizą §4 punkt 2: zero sprzecznych twierdzeń,
czysto komplementarne pokrycie (lipiec: 14 kopert paperless; sierpień
dodał 6 kopert gmail + `paperless:183`) — 21 kopert finalnie (15 paperless +
6 gmail), 38/38 par `sources` + 45/45 przypisów inline zweryfikowanych w
bazie przy tamtym scaleniu. Ta sesja **nie modyfikowała** tej strony —
tylko zweryfikowała ją ponownie w ramach pełnego lintu repo (§6.4).
### 6.2 `mbank` i `pawel-cesar-sanjuan-szklarz` — przeniesione bez kolizji
Zgodnie z §4 punkt 1 ("nie kolidują z niczym w lipcowym repo — czysty
dodatek"): skopiowane z `~/kb-wiki-etap1-sesja-2026-08-27/` do `~/kb-wiki`,
linki `[[...]]` przepisane ze składni sesyjnej (`[[../katalog/plik]]`) na
rzeczywistą konwencję repo (`[[plik]]`, bez katalogu). Treść merytoryczna
nietknięta. `INDEX.md` dopisany o oba wpisy. `sprawy/fll-2025-26.md`
przestało być sierotą w grafie `[[...]]` (link przychodzący z obu nowych
stron, sekcja „Powiązane" — decyzja sesyjna sprzed przeniesienia, nie tej
rekoncyliacji: „sprawdzone wprost, zero wspólnego dowodu", link mimo braku
dowodu).
### 6.3 Konwencje: `check_okf.py` przeniesiony, decyzje (d)-(f) scalone
`check_okf.py` (walidator sesyjny) przeniesiony do `~/kb-wiki` — lipcowy
proof-of-concept nie zostawił żadnego skomitowanego narzędzia (§3 tabela,
rekomendacja "Przenieść `check_okf.py` tej sesji do prawdziwego repo przy
rekoncyliacji" — wykonana dosłownie). Zaadaptowany do rzeczywistego layoutu
tego repo: `TYPES` rozszerzone o `meta` (repo miało 6. typ od lipca, sesyjny
walidator znał tylko 5), `README.md`/`INDEX.md`/`_meta/lint-reports/*.md`
wyjęte z wymogu frontmattera stron-konceptów (istniały bez niego od
2026-07-21 — dokumentacja/raporty, nie skompilowane encje).
Decyzje (d)-(f) audytu 08-26, wypracowane w `_meta/conventions.md` sesji
(§5/§7/§8 tamtego pliku), scalone do `_meta/conventions.md` prawdziwego repo
**bez duplikowania** tego, co repo już miało z lipca/wcześniejszej sesji
(6 typów wliczając `meta`, sekcja „Brak danych w KB" — obie już scalone,
commity `156925c`/`3bd2441`, przed tym zadaniem):
- **(d) fallback envelope-only** — `[^envelope_id]` bez `#chunk_id`, gdy
dowód nie ma konkretnego chunka albo chunk zniknął po re-chunkingu.
- **(e) sekcja „Niepewne / sprzeczne"** jako nazwana sekcja końca strony
(już używana de facto na żywych stronach, teraz sformalizowana pisemnie) +
polityka aktualizacji „dopisz, nie nadpisuj" przy nowym sprzecznym dowodzie.
- **(f) inwariant 7** — izolacja retrievalu kompilacji od `source='wiki'`
(mitygacja self-citation/citogenesis), CC/API-only jako wykonawca.
Różnica konwencji z §3 tabeli dotycząca casingu pliku nawigacyjnego
(`INDEX.md` vs OKF-owy `index.md`) **pozostaje nierozstrzygnięta** — zgodnie
z §3, do operatora, poza zakresem tej rekoncyliacji. `check_okf.py`
pragmatycznie traktuje oba pliki root (`README.md`, `INDEX.md`) jako
wyjęte z wymogu frontmattera, bez przesądzania, który plik "wygrywa".
### 6.4 Lint na całości — stan końcowy: 7 stron, zero błędów
`python3 check_okf.py` w `~/kb-wiki`: **ZGODNE**, 74/74 par `sources`
(wszystkie 6 stron-konceptów poza `_meta/`) zweryfikowanych w `KB_DSN`
(SELECT-only). Osobny, pełny przebieg (skrypt jednorazowy, nieskomitowany —
`check_okf.py` nie parsuje treści Markdown) po **wszystkich** przypisach
inline `[^envelope_id#chunk_id]` na **wszystkich** 7 stronach: **150/150**
zweryfikowanych, 0 zdegradowanych do fallback. Zero wiszących linków
`[[...]]`, zero błędów przypisania chunk→envelope. Pełny raport:
`_meta/lint-reports/2026-08-27-rekoncyliacja.md` w `kb-wiki`.
| Metryka | Wynik |
|---|---|
| Stron w repo (po rekoncyliacji) | 7 (5 lipiec + `fll-2025-26` scalone + 2 przeniesione) |
| `sources:` (agregat frontmatter) zweryfikowane w bazie | 74/74 |
| Przypisy inline zweryfikowane w bazie | 150/150 |
| Wiszące linki `[[...]]` | 0 |
| Przypisy zdegradowane (fallback envelope-only) | 0 |
| Commity rekoncyliacji na `kb-wiki:master` | 4, lokalne, **nie pushnięte** |
### 6.5 Co zostało poza zakresem tej rekoncyliacji
- `~/kb-wiki-etap1-sesja-2026-08-27/` — pozostawione nietknięte na żądanie
operatora ("skasuję sam po weryfikacji"), nie usuwane przez tę sesję.
- Push `kb-wiki:master` do Forgejo — czeka na review operatora.
- Wszystko z §5 (skala do 17 encji, lint automatyczny w kodzie, skan
`<60>`/`\x00` na źródłach, integracja `source='wiki'` w retrievalu, rozjazd
`scripts/kb/check_okf.py`) — nadal otwarte, nie ruszone tą rekoncyliacją.

View file

@ -18,6 +18,8 @@ reguły tego repo:
10. Wpisy `contradicts` wyglądające jak ścieżka .md też muszą istnieć; 10. Wpisy `contradicts` wyglądające jak ścieżka .md też muszą istnieć;
pozostałe wpisy to wolny tekst. pozostałe wpisy to wolny tekst.
11. `stub` o ile obecne musi być boolem. 11. `stub` o ile obecne musi być boolem.
12. Brak bajtów kontrolnych poza \t \n \r w całym pliku (łapie wklejki NUL/inne
binarne śmieci wklejone z zewnętrznych źródeł, np. wyników SELECT-ów).
Zakres domyślny: kb/ oraz docs/sessions/, z wyłączeniem README-wskaźników Zakres domyślny: kb/ oraz docs/sessions/, z wyłączeniem README-wskaźników
(POINTER_GLOBS) te nawigacją do kb-doca, nie dokumentami KB, i celowo nie (POINTER_GLOBS) te nawigacją do kb-doca, nie dokumentami KB, i celowo nie
@ -71,6 +73,7 @@ POINTER_GLOBS = (
EXCLUDE_DIRS = ("build",) EXCLUDE_DIRS = ("build",)
DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
CONTROL_CHAR_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
def is_pointer(rel: str) -> bool: def is_pointer(rel: str) -> bool:
@ -232,6 +235,17 @@ def check_file(path: Path, root: Path) -> list[str]:
f"{rel}: `contradicts` wskazuje na nieistniejący plik: {item}" f"{rel}: `contradicts` wskazuje na nieistniejący plik: {item}"
) )
seen_lines: set[int] = set()
for m in CONTROL_CHAR_RE.finditer(text):
line_no = text.count("\n", 0, m.start()) + 1
if line_no in seen_lines:
continue
seen_lines.add(line_no)
errors.append(
f"{rel}: bajt kontrolny {ord(m.group()):#04x} w linii {line_no} "
"(dozwolone tylko \\t \\n \\r)"
)
return errors return errors

View file

@ -0,0 +1,62 @@
"""Tests for check_okf.py — the OKF v0.1 frontmatter validator.
Covers the control-character check added 2026-08-27: a file that otherwise has
valid frontmatter must still fail if its body contains a NUL byte or other
control byte outside \\t \\n \\r (the class of bug found in
kb/audits/wiki-kompilat-recon-2026-08-26.md raw bytes pasted in from a psql
SELECT over the mail corpus).
"""
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import check_okf # noqa: E402
VALID_FRONTMATTER = """---
okf: "0.1"
type: decision
visibility: private
status: active
updated: 2026-08-27
links: []
---
"""
def _write(tmp_path: Path, body: str) -> Path:
p = tmp_path / "doc.md"
p.write_text(VALID_FRONTMATTER + body, encoding="utf-8")
return p
def test_clean_file_has_no_control_char_errors(tmp_path):
path = _write(tmp_path, "# Title\n\nZwykła treść z \t tabulatorem i \r\n końcem linii.\n")
errors = check_okf.check_file(path, tmp_path)
assert errors == []
def test_nul_byte_in_body_is_flagged(tmp_path):
path = _write(tmp_path, "# Title\n\nZawiera bajt NUL: \x00 w tym miejscu.\n")
errors = check_okf.check_file(path, tmp_path)
assert any("bajt kontrolny" in e and "0x0" in e for e in errors)
def test_control_char_error_reports_correct_line(tmp_path):
body = "linia 1\nlinia 2\nzepsuta \x00 linia 3\nlinia 4\n"
path = _write(tmp_path, body)
errors = check_okf.check_file(path, tmp_path)
control_errors = [e for e in errors if "bajt kontrolny" in e]
assert len(control_errors) == 1
# Frontmatter occupies 7 lines before the body starts.
frontmatter_lines = VALID_FRONTMATTER.count("\n")
expected_line = frontmatter_lines + 3
assert f"w linii {expected_line}" in control_errors[0]
def test_multiple_control_chars_same_line_reported_once(tmp_path):
path = _write(tmp_path, "para \x00 z dwoma \x00 bajtami na tej samej linii\n")
errors = check_okf.check_file(path, tmp_path)
control_errors = [e for e in errors if "bajt kontrolny" in e]
assert len(control_errors) == 1

View file

@ -1,17 +1,17 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# health-monitor.sh - Homelab node health monitor and safe disk cleanup # health-monitor.sh - Homelab node health monitor
# #
# Designed to run standalone on the host (cron or direct) or to be called by # Designed to run standalone on the host (cron or direct) or to be called by
# the node-agent Python daemon. All cleanup decisions follow the conservative # the node-agent Python daemon.
# policy agreed in the design review:
# #
# lte_node (chelsty-infra, chelsty-ha) : NO cleanup at all # Docker cleanup (image/container/build-cache prune) does NOT live here.
# sd_card (piha, saturn) : dangling images + stopped containers, # node_agent.py (R1) is the sole owner of that cleanup, with a filtered
# rate-limited to once per 24 h # container prune that respects restart policy and compose ownership —
# ai_node (solaria) : dangling images + stopped containers # see kb/incidents/2026-07-30-ollama-solaria-vanish.md. This script used to
# + build cache (NEVER -a) # carry its own unfiltered `docker container prune -f` (never wired into
# standard (vps) : dangling images + stopped containers # cron/systemd on any node, verified 2026-08-06); it was removed rather than
# + build cache # backporting the R1 filter here too, to avoid two independent copies of
# cleanup logic drifting apart.
# #
# VPS additionally rotates control-plane filesystem artefacts: # VPS additionally rotates control-plane filesystem artefacts:
# actions/completed + failed > 7 days # actions/completed + failed > 7 days
@ -43,15 +43,6 @@ DISK_CRIT_PCT=85
MEM_WARN_PCT=85 MEM_WARN_PCT=85
MEM_CRIT_PCT=95 MEM_CRIT_PCT=95
# Rate-limit file for SD-card nodes (max one Docker cleanup per 24 h)
CLEANUP_LOCK="${STATE_DIR}/last-docker-cleanup"
CLEANUP_INTERVAL=86400 # seconds
# Node classifications
LTE_NODES="chelsty-infra chelsty-ha"
SD_CARD_NODES="piha saturn"
AI_NODES="solaria"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Helpers # Helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -60,20 +51,6 @@ log() { echo "$(date -u +%H:%M:%S) [INFO] $*"; }
warn() { echo "$(date -u +%H:%M:%S) [WARN] $*" >&2; } warn() { echo "$(date -u +%H:%M:%S) [WARN] $*" >&2; }
err() { echo "$(date -u +%H:%M:%S) [ERROR] $*" >&2; } err() { echo "$(date -u +%H:%M:%S) [ERROR] $*" >&2; }
contains() {
local word="$1"; shift
for w in "$@"; do [[ "$w" == "$word" ]] && return 0; done
return 1
}
get_node_type() {
# shellcheck disable=SC2086
if contains "$NODE_NAME" $LTE_NODES; then echo "lte_node"; return; fi
if contains "$NODE_NAME" $SD_CARD_NODES; then echo "sd_card"; return; fi
if contains "$NODE_NAME" $AI_NODES; then echo "ai_node"; return; fi
echo "standard"
}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Event emission # Event emission
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -195,69 +172,6 @@ check_containers() {
--format "{{.Names}}" 2>/dev/null || true) --format "{{.Names}}" 2>/dev/null || true)
} }
# ---------------------------------------------------------------------------
# Safe Docker cleanup (per policy)
# ---------------------------------------------------------------------------
_sd_card_rate_ok() {
if [[ -f "${CLEANUP_LOCK}" ]]; then
local last_ts elapsed
last_ts=$(cat "${CLEANUP_LOCK}" 2>/dev/null || echo 0)
elapsed=$(( TIMESTAMP - last_ts ))
if [[ "${elapsed}" -lt "${CLEANUP_INTERVAL}" ]]; then
log "Docker cleanup skipped: last run ${elapsed}s ago (limit ${CLEANUP_INTERVAL}s)"
return 1
fi
fi
return 0
}
_mark_cleanup_done() {
echo "${TIMESTAMP}" > "${CLEANUP_LOCK}"
}
run_safe_cleanup() {
command -v docker &>/dev/null || return
local node_type
node_type=$(get_node_type)
case "${node_type}" in
lte_node)
# NO cleanup on LTE nodes. Any docker operation risks triggering
# a pull over a metered/intermittent connection.
log "Skipping Docker cleanup: LTE node (${NODE_NAME})"
;;
sd_card)
# Dangling images + stopped containers only.
# Rate-limited to once per 24 hours to protect SD card write endurance.
_sd_card_rate_ok || return
log "Running rate-limited Docker cleanup (SD card node)"
docker image prune -f >/dev/null 2>&1 || true
docker container prune -f >/dev/null 2>&1 || true
_mark_cleanup_done
;;
ai_node)
# Dangling images + stopped containers + build cache.
# NEVER docker image prune -a (would remove Ollama runtime images,
# requiring a multi-hour re-pull of model weights).
log "Running AI-node Docker cleanup (dangling images + containers + build cache)"
docker image prune -f >/dev/null 2>&1 || true
docker container prune -f >/dev/null 2>&1 || true
docker builder prune -f >/dev/null 2>&1 || true
;;
standard)
# VPS and other standard nodes: full safe cleanup.
log "Running standard Docker cleanup"
docker image prune -f >/dev/null 2>&1 || true
docker container prune -f >/dev/null 2>&1 || true
docker builder prune -f >/dev/null 2>&1 || true
;;
esac
}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# VPS-specific: control-plane filesystem rotation # VPS-specific: control-plane filesystem rotation
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -315,15 +229,13 @@ except Exception:
mkdir -p "${EVENTS_DIR}/${NODE_NAME}" "${STATE_DIR}" mkdir -p "${EVENTS_DIR}/${NODE_NAME}" "${STATE_DIR}"
log "Health check starting on ${NODE_NAME} (type=$(get_node_type))" log "Health check starting on ${NODE_NAME}"
disk_pct=$(check_disk || echo 0) disk_pct=$(check_disk || echo 0)
mem_pct=$(check_memory || echo 0) mem_pct=$(check_memory || echo 0)
cpu_pct=$(check_cpu || echo 0) cpu_pct=$(check_cpu || echo 0)
check_containers check_containers
run_safe_cleanup
# VPS: also rotate control-plane filesystem artefacts # VPS: also rotate control-plane filesystem artefacts
if [[ "${NODE_NAME}" == "vps" ]]; then if [[ "${NODE_NAME}" == "vps" ]]; then
cleanup_control_plane_fs cleanup_control_plane_fs

View file

@ -141,6 +141,11 @@ FAILED_EVENTS_DIR = STATE_DIR / "observer_failed_events"
# "operator drops a file, the owning process consumes it" pattern the actions # "operator drops a file, the owning process consumes it" pattern the actions
# pending/approved queue already uses. # pending/approved queue already uses.
RESOLVE_REQUESTS_DIR = WORLD_DIR / "resolve-requests" RESOLVE_REQUESTS_DIR = WORLD_DIR / "resolve-requests"
# mkdir(mode=...) is masked by the process umask, so the SSH operator (group
# aerbot) cannot drop a flag file into a dir created at the default 0o755 —
# same defect/fix as executor.py's INBOX_DIR_MODE (2026-08-06): an explicit,
# idempotent os.chmod after mkdir, applied on the same path the dir is used.
RESOLVE_REQUESTS_DIR_MODE = 0o775
# Time-based fallback for incidents that can never receive the service_healthy # Time-based fallback for incidents that can never receive the service_healthy
# event _resolve_incident() waits for (service removed/renamed/decommissioned # event _resolve_incident() waits for (service removed/renamed/decommissioned
@ -277,6 +282,13 @@ class Observer:
LOGS_DIR.mkdir(parents=True, exist_ok=True) LOGS_DIR.mkdir(parents=True, exist_ok=True)
FAILED_EVENTS_DIR.mkdir(parents=True, exist_ok=True) FAILED_EVENTS_DIR.mkdir(parents=True, exist_ok=True)
RESOLVE_REQUESTS_DIR.mkdir(parents=True, exist_ok=True) RESOLVE_REQUESTS_DIR.mkdir(parents=True, exist_ok=True)
try:
os.chmod(RESOLVE_REQUESTS_DIR, RESOLVE_REQUESTS_DIR_MODE)
except OSError as e:
logger.warning(
f"Could not set mode {oct(RESOLVE_REQUESTS_DIR_MODE)} on "
f"{RESOLVE_REQUESTS_DIR}: {e}"
)
def _quarantine_event_file(self, file_path: str, node_dir: str, exc: Exception) -> None: def _quarantine_event_file(self, file_path: str, node_dir: str, exc: Exception) -> None:
"""Move an unreadable/unprocessable event out of the hot path.""" """Move an unreadable/unprocessable event out of the hot path."""

View file

@ -452,13 +452,15 @@ class Supervisor:
# Choose action type first so we can build the ID. # Choose action type first so we can build the ID.
# #
# container_restart IDs carry a suffix so two DIFFERENT incidents for # Both container_restart and redeploy IDs carry a suffix so two
# the same node+service never collide in cancelled/completed/failed # DIFFERENT incidents for the same node+service never collide in
# (2026-08-26: a generic containers_not_running restart and a later # cancelled/completed/failed (2026-08-26: a generic
# ha-diag-agent shadow-mode restart both used the bare # containers_not_running restart and a later ha-diag-agent
# shadow-mode restart both used the bare
# container-restart-piha-homeassistant id and overwrote each other's # container-restart-piha-homeassistant id and overwrote each other's
# history — worked around manually that session, see # history — worked around manually that session, see
# docs/sessions/2026-08-26.md). # docs/sessions/2026-08-26.md). redeploy carries the same latent risk
# via the same code path and got the same fix on 2026-08-27.
# #
# The suffix is the triggering incident's started_at, NOT time.time() # The suffix is the triggering incident's started_at, NOT time.time()
# at generation time: reconcile() calls _generate_recommendation on # at generation time: reconcile() calls _generate_recommendation on
@ -482,20 +484,22 @@ class Supervisor:
# bare id has no incident to distinguish "same" from "different" # bare id has no incident to distinguish "same" from "different"
# occurrences by, but it is at least stable across calls, which is # occurrences by, but it is at least stable across calls, which is
# what idempotency here actually requires. # what idempotency here actually requires.
if trigger_type in CONTAINER_RESTART_TRIGGERS: #
# Safe to add the suffix to redeploy ids: verified no code anywhere
# else reconstructs `redeploy-{node}-{service}` for an exact-match
# lookup. _cancel_resolved_pending_actions matches on the node/service
# *fields* inside each pending file, not on the id string; executor.py,
# node_agent.py and deploy-runner.sh all treat action_id as an opaque
# string read back from the action's own JSON/marker file, never
# reconstructed from node+service.
action_prefix = "container-restart" if trigger_type in CONTAINER_RESTART_TRIGGERS else "redeploy"
incident_id = self.actual_state["services"].get(drift["svc_key"], {}).get("incident_id") incident_id = self.actual_state["services"].get(drift["svc_key"], {}).get("incident_id")
incident = self.actual_state["incidents"].get(incident_id, {}) if incident_id else {} incident = self.actual_state["incidents"].get(incident_id, {}) if incident_id else {}
started_ts = int(_parse_ts(incident.get("started_at"))) started_ts = int(_parse_ts(incident.get("started_at")))
if started_ts: if started_ts:
action_id = f"container-restart-{node}-{service}-{started_ts}" action_id = f"{action_prefix}-{node}-{service}-{started_ts}"
else: else:
action_id = f"container-restart-{node}-{service}" action_id = f"{action_prefix}-{node}-{service}"
else:
# redeploy IDs stay bare (node-service) — out of scope for this
# fix (see commit message: no observed collision here yet), and
# _cancel_resolved_pending_actions/_ha_action_recently_completed
# do not key off redeploy ids so nothing here depends on it.
action_id = f"redeploy-{node}-{service}"
# Skip if an action for this ID is already live in any active state # Skip if an action for this ID is already live in any active state
# (pending → approved → running). This prevents re-creation after # (pending → approved → running). This prevents re-creation after

View file

@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import json import json
import os
import sys import sys
import time import time
from pathlib import Path from pathlib import Path
@ -915,3 +916,19 @@ def test_resolve_request_flag_for_already_resolved_incident_is_removed(tmp_path)
assert obs.world_state["incidents"][inc_id]["status"] == "resolved" assert obs.world_state["incidents"][inc_id]["status"] == "resolved"
assert obs.world_state["incidents"][inc_id]["resolved_reason"] == "manual_operator" assert obs.world_state["incidents"][inc_id]["resolved_reason"] == "manual_operator"
assert not flag.exists() assert not flag.exists()
def test_resolve_requests_dir_is_group_writable(tmp_path, monkeypatch):
"""world/resolve-requests/ must be group-writable so an SSH operator
(group aerbot, not the observer's own user) can drop a resolve flag file
without docker exec mkdir(mode=...) alone is masked by the process
umask, same defect/fix as executor.py's INBOX_DIR_MODE (2026-08-06)."""
old_umask = os.umask(0o022)
try:
obs = _make_observer_simple(tmp_path)
finally:
os.umask(old_umask)
import observer.observer as obs_mod
mode = obs_mod.RESOLVE_REQUESTS_DIR.stat().st_mode & 0o777
assert mode == 0o775

View file

@ -1,4 +1,5 @@
"""action_id uniqueness for container_restart (2026-08-26 fix). """action_id uniqueness for container_restart (2026-08-26) and redeploy
(2026-08-27, same latent risk, same fix).
Before this fix, _generate_recommendation() built container_restart action Before this fix, _generate_recommendation() built container_restart action
ids as the bare `container-restart-<node>-<service>` no timestamp, no ids as the bare `container-restart-<node>-<service>` no timestamp, no
@ -172,10 +173,51 @@ def test_fallback_bare_id_stable_across_repeated_calls(sup, tmp_path):
assert pending[0].name == "container-restart-piha-paperless.json" assert pending[0].name == "container-restart-piha-paperless.json"
def test_redeploy_action_id_stays_bare(sup, tmp_path): def test_redeploy_action_id_falls_back_to_bare_without_incident(sup, tmp_path):
"""Non-container_restart drift (redeploy path) is out of scope for this """Non-container_restart drift (redeploy path) with no linked incident
fix and keeps its existing bare node-service id.""" record keeps the pre-fix bare node-service id, same fallback as
container_restart."""
drift = _drift("piha", "outline", trigger_type="service_unhealthy") drift = _drift("piha", "outline", trigger_type="service_unhealthy")
sup._generate_recommendation(drift) sup._generate_recommendation(drift)
assert (tmp_path / "actions" / "pending" / "redeploy-piha-outline.json").exists() assert (tmp_path / "actions" / "pending" / "redeploy-piha-outline.json").exists()
def test_redeploy_action_id_carries_incident_started_at_suffix(sup, tmp_path):
"""Domknięcie COMMIT 2 z task/incident-resolve-fix (2026-08-27): redeploy
ids carry the same started_at suffix as container_restart, closing the
same latent node+service collision risk."""
_seed_incident(sup, "piha", "outline", "inc-3000-piha-outline", started_at=3000)
drift = _drift("piha", "outline", trigger_type="service_unhealthy")
sup._generate_recommendation(drift)
pending = _pending(tmp_path)
assert len(pending) == 1
assert pending[0].name == "redeploy-piha-outline-3000.json"
def test_redeploy_new_incident_after_old_completed_gets_different_action_id(sup, tmp_path):
"""Same collision scenario as the container_restart case, for redeploy:
a second, later incident for the same node/service must not collide
with the first incident's already-completed action file."""
_seed_incident(sup, "piha", "outline", "inc-3000-piha-outline", started_at=3000)
drift = _drift("piha", "outline", trigger_type="service_unhealthy")
sup._generate_recommendation(drift)
first_action_path = tmp_path / "actions" / "pending" / "redeploy-piha-outline-3000.json"
assert first_action_path.exists()
completed_dir = tmp_path / "actions" / "completed"
completed_dir.mkdir(parents=True, exist_ok=True)
first_action = json.loads(first_action_path.read_text())
first_action["status"] = "completed"
(completed_dir / first_action_path.name).write_text(json.dumps(first_action))
first_action_path.unlink()
_seed_incident(sup, "piha", "outline", "inc-4000-piha-outline", started_at=4000)
sup._generate_recommendation(drift)
second_action_path = tmp_path / "actions" / "pending" / "redeploy-piha-outline-4000.json"
assert second_action_path.exists()
assert json.loads((completed_dir / "redeploy-piha-outline-3000.json").read_text())["status"] == "completed"