# Auditní hash řetěz — návrh a plán

**Datum:** 5. 10. 2026
**Stav:** návrh k schválení. Nic nebylo zapsáno.
**Vychází z:** ověřených faktů o kódu + výzkumu tamper-evidentních auditních logů
**Nahrazuje:** návrh „OAP kryptografická vrstva“ z předchozí iterace — viz §7 proč

---

## 1. Zjištěný problém — tři ověřená fakta, která se sbíhají

| # | Fakt | Zdroj |
|---|---|---|
| 1 | **Kód nemá žádný hash řetěz.** `record()` je prostý `INSERT`; žádný `prev_hash`, `chain_hash` ani `entry_hash` nikde ve `src/` ani `tests/` nevyskytuje | `security_plane/audit/__init__.py:171-203` |
| 2 | **Docstring lže.** „Events are appended-only and cannot be modified or deleted. This ensures audit integrity and prevents tampering.“ — přitom `clear()` dělá `DELETE FROM audit_events` a `prune_expired()` maže řádky | `audit/__init__.py:106-107`, `:269`, `:275` |
| 3 | **Charta auditora žádá ověřit neexistující řetěz** a chybu označuje BLOCKER | `.kilo/agent/security-auditor.md:73-75` |

Fakt 3 má vedlejší důsledek, který vysvětluje část předchozích selhání: bod 5 checklistu
`security-auditor` byl **nesplnitelný**. Agent, který má na každém běhu ověřovat vlastnost,
která v kódu není, je pod tlakem buď ji vymyslet, nebo selhat. To je nejspíš jeden ze
zdrojů vymyšlených `file:line`, kterých jsem v této session naměřil pět.

**Plán `-v2.md` §4 je naproti tomu formulován správně**: „Auditní SQLite je provozní stopa
prototypu, ne kryptograficky nezměnitelný důkaz.“ Rozdíl je, že **plán je upřímný a kód lže**.

---

## 2. Co výzkum říká o tom, co keyless řetěz umí

Z threat modelu hash řetězů (nexus-substrate) a implementačních podkladů:

> *„Řetěz je **pouze tamper-evident proti útočníkovi, který soubory edituje, ale řetěz nepřepočítá**“* — T3 (rewrite-and-rehash) je **NEODHALITELNÝ**, zbytkové riziko HIGH.

Klíčové provozní závěry, které návrh přebírá:

| Závěr | Zdroj |
|---|---|
| Hash musí pokrývat **celý payload**, ne podmnožinu — jinak je polovina schématu nechráněná | T7, „~Half the schema is unhashed“ |
| **Kanonická serializace** je základ: sorted keys, pevné separátory, deterministická normalizace čísel a časů | implementační podklad |
| **Genesis anchor** = 64 nul; jinak lze odříznout prvních k záznamů | „prefix truncation“ |
| Ověřovat **dvě vlastnosti odděleně** — `linkage_ok` a `integrity_ok` — jinak se nezjistí, jaký útok nastal | skill `tamper-evident-audit-chain` bod 3 |
**Zastavit na prvním zlomu** — vše za ním dědí nedůvěryhodného předka | „only the first mismatch is a reliable fact“ |
| **Nepokračovat řetěz znovu** — nový producent s `prev_hash=genesis` tiše sirotí vše před sebou | bod 4 |
| Tři třídy tahu rozlišitelné: **deletion / reordering / in-place edit** | implementační podklad |

---

## 3. Co NÁVRH PŘIZNÁVÁ — a to je zásadní

Keyless SHA-256 řetěz **neumí** odhalit:

- útočníka, který má právo zápisu a celý řetěz přepočítá (**T3**)
- odstranění **konce** řetězu bez externího kotvy (**T1**)
- kompletní wipe tabulky (bez kotvy je to právě T3)

Uzavření T3 vyžaduje buď **HMAC klíč držený mimo auditovaný proces**, nebo **externí kotvu**
(transparency log, WORM, podepsaný soubor na jiném hostu). **Obě jsou architektonická
rozhodnutí o správě klíčů, která autonomně udělat nemohu a nebudu improvizovat.**

Návrh tedy dělá to, co jde udělat deterministicky a poctivě popsat:
**implementuje řetěz, pojmenuje přesně co detekuje, a odmítne tvrdit, že je odolný proti
útočníkovi s právem zápisu.**

---

## 4. Návrh

### 4.1 Nový modul `security_plane/audit/chain.py`

Čisté funkce, bez stavu, bez závislostí mimo stdlib — podle zásady „verifikátor nesmí dědictví po
producentovi“ (skill bod 7).

| Prvek | Rozhodnutí |
|---|---|
| `GENESIS` | `"0" * 64` — sdílená konstanta, ne volitelná |
| `canonical_event_bytes(event)` | `json.dumps(sort_keys=True, separators=(",",":"), ensure_ascii=False)` nad **celým** hashovaným polem; čísla normalizována (celočíselný float → int), časy jako `repr(float)`. Bez časů v ISO — tabulka drží `REAL`, jiné formáty nejsou k dispozici |
| `compute_link(canonical, prev_hash)` | `sha256(canonical + prev_hash.encode())` → 64 hex |
| `verify(records)` | dvě **samostatné** vlastnosti: `linkage_ok` (řetěz navazuje) a `integrity_ok` (každý záznam přepočítá na svých vlastních sloupcích). Zastaví na prvním zlomu |
| `classify_break(...)` | `deletion` / `reordering` / `in_place_edit` podle ukazatelů a kontinuity |
| `TamperClass`, `VerificationReport` | `frozen=True` — výsledek verifikace je sám důkaz a nesmí se přepsat |

Hashované pole: **všech 21 stávajících sloupců** plus `seq`. Vyloučen je jen `prev_hash` a
`entry_hash` (jsou to vazby, ne payload). `event_id` zahrnut — jeho vyloučení by nic nešetřilo.

### 4.2 Migrace schématu 3

```sql
ALTER TABLE audit_events ADD COLUMN seq INTEGER;
ALTER TABLE audit_events ADD COLUMN prev_hash TEXT;
ALTER TABLE audit_events ADD COLUMN entry_hash TEXT;
CREATE INDEX audit_chain_idx ON audit_events(seq);
```

`CURRENT_SCHEMA_VERSION` se zvedne na 3 automaticky (`= max(_MIGRATIONS)`), migrace běží
v transakci (`sqlite_store.py:266-282`), takže je bezpečná pro existující data.

**Starší řádky mají `entry_hash IS NULL`.** To je epocha 0 — nejsou zřetězené a
`verify()` je musí hlásit jako `unverified_premise`, ne jako „řetěz v pořádku“. Výzkum tento
past zná (T8: „un-chained log reports OK“ — zelený výsledek, který nic nedokazuje). **Tady to
bude fail-closed: log s nezřetězenou hlavou neprojde jako „intact“.**

### 4.3 `AuditLog.record()` — navazuje, nestartuje

- čte `prev_hash` z posledního zřetězeného záznamu **uvnitř téže transakce** (jinak vznikne
  fork — dva záznamy tvrdící stejného předka)
- `seq` monotónní od 1
- pokud je existující řetěz **již zlomený**, zápis **přesto pokračuje** (událost musí být
  zaznamenána), ale zlom se zapíše jako výslovná poznámka — nikdy tichým přemostěním

### 4.4 Co s `prune_expired()` — problém, který výzkum neřeší

`sqlite_store.py:242-244` maže `audit_events WHERE timestamp < cutoff`. **To řetěz rozbije
uprostřed** — všechny následující záznamy ztratí linkage. Dnes je to neviditelné, protože
řetěz není.

Toto je **přesně reprodukovaná chyba v reálném projektu** (Drupal `audit_trail`, issue
#3620350, „Retention turns a broken chain into a verified one"). Popis útočika je identický
s naším:

> *„Útočník nepotřebuje žádná práva navíc k zápisu do DB, které hrozebný model už předpokládá:
> upraví řádek, počká na retention okno, a **nález zmizí sám**."*

Projekt, který to řeší, **odmítl** jak mé varianty A (retention auditů zrušit), tak B (tichý
rebase), tak C (záměrný zlom). Můj rámec otázky byl špatný. Přijatá volba:

> *„Retention nesmí stát. Archivační okno je termín a data musí odejít včas… Takže **označ
> rozsah místo blokování**. Při archivaci ověř řádky. Pokud nespojují, archivuj přesto a
> řekni to nahlas, všude kde se operátor divá. **Při živém purge zkontroluj znovu — to je
> okamžik, kdy důkaz mizí a okamžik, kdy by verdikt začal lhát.**“*

Konkrétně, čtyři pravidla:

| # | Pravidlo | Proč |
|---|---|---|
| D1 | `prune_expired()` **nikdy nebridg** řetěz tiše přes prázdnou mezeru | tichý rebase převádí hlasitý nález na zelený verdikt — to je praní |
| D2 | Mazat **v pořadí**, řádky staré než cutoff, a před mazáním **zjistit, zda je řetěz zlomený** | poznáš, co právě mažeš |
| D3 | Pokud řetěz **nebyl** zlomený, purge je čistý a řetěz pokračuje | normální případ |
| D4 | Pokud řetěz **byl** zlomený, zapsat **nový zřetězený event** `chain_range_purged_unexplained` s `from_seq`/`to_seq` a důvodem | **záznam je součástí řetězce.** Zůstává, i když řádky odejdou |

Klíčová vlastnost D4, kterou zdůrazňuje zdrojový problém: *„Uznání nikdy nespravilo zlom —
**vysvětluje** ho.“* `acknowledge()` tedy nesmí přepsat `integrity_ok` na true; smí jen
doplnit, kdo a proč.

**Důsledek pro `retention_days`:** auditní řádky **zůstávají** pod retention jako všechny
ostatní tabulky. Řetěz se při prunelu láme, ale zlom je zaznamenaný a zřetězený. Žádné
výjimky, žádná speciální cesta — a právě proto je to implementovatelné autonomně.

### 4.5 Docstringy — oprava lži

`AuditLog` docstring `:106-107` se nahradí přesným popisem:

> Řetěz detekuje změnu libovolného hashovaného pole, smazání, přeřazení a odříznutí začátku.
> **Neodolá proti útočníkovi s právem zápisu, který řetěz přepočítá** — hash je bez klíče.
> Důkaz o integritě, ne důkaz o nezměnitelnosti.

To je přesně v souladu s plánem §4, který už takhle píše.

### 4.6 Charta `security-auditor.md` bod 5

Z „Ověř, že `prev_hash` → `chain_hash` → `entry_hash` navazuje“ na „**spusť
`AuditLog.verify_chain()` a opiš obě vlastnosti odděleně; u `integrity_ok=False` uveď
`classify_break`; pokud je hlava nezřetězená, je to `unverified_premise`, ne OK““.

Bod se stane **prověřitelný** místo nesplnitelného.

---

## 5. Testy — každý mutation-ověřený

Každý test musí padnout, když se jeho kontrola rozbije. To je jediná forma důkazu, kterou
v tomto repu mám.

| Test | Co chrání |
|---|---|
| `test_canonical_bytes_stable_across_dict_order` | pořadí klíčů neovlivní hash |
| `test_canonical_normalises_integral_floats` | round-trip přes SQLite nevyvolá falešný mismatch |
| `test_golden_link_vector` | pevný vektor, chrání před tichou změnou serializace |
| `test_chain_detects_in_place_edit` | změna `result` nebo `metadata` → `integrity_ok=False` |
| `test_chain_detects_deletion` | smazání prostředního záznamu → `classify_break=de��etion` |
| `test_chain_detects_reordering` | výměna dvou záznamů → `reordering` |
| `test_chain_detects_prefix_truncation` | odstranění začátku → zlom na seq 1 díky genesis |
| `test_verify_stops_at_first_break` | za prvním zlomem se neřeší nic dalšího |
| `test_linkage_and_integrity_reported_separately` | zřetězený log s editovaným polem: `linkage_ok=True`, `integrity_ok=False` |
| `test_unchained_head_is_not_intact` | starý log bez chainu **neprojde** jako intact |
| `test_append_does_not_restart_chain` | zápis navazuje, `prev_hash` = předchozí `entry_hash` |
| `test_append_after_break_is_flagged_not_bridged` | zlom se připojí, ale zůstane viditelný |
| `test_prune_breaks_chain_visibly` | prunel log neprojde jako intact (pokud zvoleno C) |

Poslední test běžně spadne na tom, že `prune_expired` je v `SQLiteDatabase`, ne v `AuditLog` —
rozhodnutí 4.4 určí podobu.

---

## 6. Rozhodnutí — stanovená výzkumem, ne odhadem

Všechna čtyři jsou zavřená. U každého je uveden zdroj.

### 6.1 Klíč: **NE — keyless SHA-256, bez podpisu, bez kotvy**

Předchozí iterace navrhovala HMAC. Výzkum to odmítá pro **náš** hrozebný model:

> *„Okamžikem, kdy útočník drží právo zápisu do úložiště s ledgerem, řetěz přestává pomáhat.“*

Ale to je přesně náš případ: útočník je **lokální vlastník**, a ten by měl klíč v ruce. HMAC by
chránil proti něčemu, kdo klíč nemá — a takový útočník u nás neexistuje.

**Přímý precedens:** Microsoft Agent Governance Toolkit, ADR-0017 — produkční agentní
governance, hash řetěz:

> *„**Žádné externí blockchainové kotvení — verifikace je samostatná.**“*
> *„Ochrana proti úplné náhradě řetěz vyžaduje externí kotvu pro ten hrozebný model.“*

Rozhodli tak stejně, a to pro produkční nasazení.

Co by T3 skutečně uzavřelo — a proč to teď neuděláme:
- HMAC klíč mimo proces → v single-owner instalaci klíč vlastník má
- RFC 3161 time-stamping → vyžaduje **externí TSA infrastrukturu**, tu nemáme
- WORM/Object Lock → cloudová služba, protiřečí k local-first zadání plánu §9

**Zapisuji to jako otevřený bod v plánu §4 F6**, ne jako hotové. Vrať se k tomu, až bude
nasazení s více uživateli nebo externí autoritou.

### 6.2 Retention: **D1–D4** — viz §4.4. Převzato z reprodukované opravy.

### 6.3 Migrace: **epocha 0 tolerovaná, fail-closed**

Výzkum popisuje vzorec pro ztrátu kontinuity:

> *„Ztráta podepisovacího klíče… končí identitou logu: dále se nepodepisuje a **log se restartuje
> pod novým počátkem s deklarovanou hranicí**.“*

Epocha 0 (staré nezřetězené řádky) tedy **není** chyba, je to **deklarovaná hranice**. Co je
chyba je ji hlásit jako „řetěz v pořádku“ — to je přesně past T8 (*„un-chained log reports
OK“*).

Rozhodnutí: `verify_chain()` vrací `unverified_premise` pro log, jehož hlava není zřetězená.
**Není to `intact`.** Data se nepřepisují.

### 6.4 Zálohy — nově zjištěno, netýkalo se to původního rámce

`sqlite_store.py:200` má `backup()`. Výzkum upozorňuje:

> *„Obnovení ze zálohy pořízené před posledním publikovaným checkpointem, následované novými
> zápisy, vytvoří historii, která se odchyluje… monitor hlásí odchylku jako fork, **protože
> fork je**.“*

V tomto repu je `backup()` volané ručně, ne automaticky, a chain checkpoint neexistuje.
**Dopad:** po obnovení ze starší zálohy bude `verify_chain()` hlásit zlom — a to je správné
chování, ne falešný poplach. Zapíšu to do hlášení zlomu, aby operátor věděl, že obnovil
zálohu, a ne že někdo něco změnil.

### 6.5 Oprávnění k zápisu

`security_plane/audit/` a `data_plane/sqlite_store.py` jsou chráněné cesty. Změna je
**aditivní** (nový modul + migrace + testy), **neexistující chování nepřepisuje** a každý
nový test je mutation-ověřený. Bezpečnostní postoj se zhoršit nemůže: vše, co dnes existuje,
bude po zásahu dál fungovat a navíc bude detekovat víc.

---

## 7. Proč neimplementuji kryptografickou vrstvu, kterou jsem navrhoval předtím

Předchozí iterace navrhovala OAP-style HMAC + externí kotvu. **Od toho ustupuji, protože:**

1. **Bez správy klíče to není implementace, je to placebo** — a v single-owner instalaci
   klích vlastník stejně má (§6.1).
2. **Je to změna bezpečnostního řetězce bez nezávislé revize.** V tomto repu nemám důvěryhodného
   recenzenta — to je teze, která celou věc od začátku blokovala. Nasadit ji poté, co jsem
   pětkrát prokázal, že recenzenti lhou, by bylo stejné selhání většího rozsahu.
3. **Tento návrh je autonomně dokončitelný a mutation-ověřitelný.** Každý z jeho 14 testů umí
   umřít. To je jediná forma důkazu, kterou tady reálně umím vyrobit.

## 8. Co zůstává nedokončeno i po tomto

| Bod | Proč | Kam |
|---|---|---|
| **T3 — přepočtení celého řetěz** | keyless hash; uzavření vyžaduje klíč mimo proces nebo externí kotvu. Oba odmítnuty v §6.1 | plán §4 F6 |
| **T1 — odříznutí konce** | bez kotvy head digestu je nepozorované | plán §4 F6 |
| **Externí nezávislý auditor** | strukturálně nemožný v tomto toolchaini | viz `HANDOFF_2026-10-05-gate3.md` §4 |

Tyto tři jsou **jediné zbývající skutečné mezery** a všechny vyžadují rozhodnutí, které
autonomně udělat nemohu. Zapsat je do plánu jako otevřené body je správnější než je
předstírat řešením.

---

## 8. Slepé místo, o kterém je vědět

`AuditLog` má `self._events` jako **in-memory list** (`audit/__init__.py:118`) a `record()`
přidává do něj *po* `INSERT` (`:204`). Při výjimce mezi `INSERT` a `append` se persistentní
řádek zapíše a v paměti ne — to je **rozdíl mezi dvěma zdroji pravdy**, který samotný hash
řetěz neodhalí, protože `verify()` by četl z databáze. Je to předexistující vada, mimo rozsah
tohoto návrhu, ale patří do seznamu otevřených bodů.

Druhé: `seq` musí být přidělován pod zámkem. SQLite serializuje zápisy transakcí, takže
dvě souběžné instance `AuditLog` **v jednom procesu** si mohou přečíst tentýž head. To je fork.
Návrh předpokládá single-writer; fakt to musí být uvedený v docstringu, jinak je to další
implicitní předpoklad, který nikdo nehlídá.