Invariants Registry
Pravidla, která nesmí porušit ani full rewrite. Každý invariant má ID, tvrzení (1 věta, testovatelné), proč existuje, test status, a důsledek porušení.
Hierarchie:
- Critical → musí mít test (tested:<path>). CI blokuje merge, pokud test padá.
- Standard → text-only, hlídá doc-curator.
Konvence:
- ID: INV-<DOMAIN>-NN (např. INV-AUTH-01).
- Při refactoru zachovat ID. Při zastarání: status Deprecated + odkaz na nový invariant.
Auth & Role gating
INV-AUTH-01 — Role gating je v middleware, ne v UI [Critical]
Tvrzení: Žádná chráněná routa nesmí spoléhat výhradně na UI check role. Autorita rolí je v web/src/utils/supabase/middleware.ts (anonymní → /login, pending/parent → /pending, staff → app).
Proč: UI checky lze obejít přímým HTTP voláním nebo úpravou client state. Middleware je jediný bod, který volá Supabase pro skutečnou roli z profiles tabulky.
Test: web/tests/invariants/role-gating.spec.ts (3× anonymous-redirect na /, /plan, /zaci). Positive-path test (staff dorazí do appky) žije v e2e suite, ne tady — invariant gate musí být reproducibilní bez seedované DB.
Důsledek porušení: Neoprávněný uživatel vidí cizí data — GDPR incident.
INV-AUTH-02 — RLS je primární vrstva ochrany dat [Critical]
Tvrzení: Každá tabulka s personal data (profiles, students, attendance_records, classbook_entries, slot_topics, student_slot_topics, atd.) má RLS enabled a má aspoň jednu policy pro select rozhodnutou rolí, ne auth.uid() = id. Middleware je první obrana, RLS je druhá — i kdyby middleware selhal, DB nepustí cizí data.
Proč: Defense in depth. Bug v middleware (nebo budoucí přechod na API routes) nesmí znamenat ztrátu dat. RLS založené na roli (ne na user.id hardkódu) přežije refactor relací.
Test: web/tests/invariants/rls-coverage.spec.ts (planned — Fáze 2)
Důsledek porušení: SQL injection nebo middleware bypass exfiltruje data celé školy.
INV-AUTH-03 — Nový účet startuje vždy v pending [Standard]
Tvrzení: Trigger handle_new_user v supabase/migrations/20260422000001_init.sql:309-311 vkládá nový profil s role = 'pending'. Žádná cesta v aplikaci nesmí měnit roli z UI bez explicitního admin path (zatím manuálně v Supabase Dashboard).
Proč: Zero-trust onboarding. Self-signup nesmí vést k automatickému přístupu k datům. Tomáš schvaluje.
Test: text-only (lze přidat SQL test, ale low priority dokud není self-signup UI).
Důsledek porušení: Kdokoli s Google účtem se dostane do systému.
INV-AUTH-04 — Auth callback validuje session přes Supabase, ne přímo z query parametrů [Critical]
Tvrzení: web/src/app/auth/callback/route.ts použije supabase.auth.exchangeCodeForSession(code) (případně verifyOtp pro magic-link token) — nikdy nečte email, role ani jiné údaje přímo z URL.
Proč: URL parametry jsou plně klientem kontrolované. Supabase Auth validuje JWT issuer a podpis při exchange / verify. Kdyby callback přečetl email z URL a vytvořil session, útočník by se vydával za kohokoli.
Test: pokrytí typovým systémem (callback nemá jinou cestu než exchangeCodeForSession), code review.
Důsledek porušení: Account takeover.
INV-AUTH-05 — V produkci je magic link jediný login flow [Critical]
Tvrzení: V produkčním buildu (NODE_ENV=production, KOSMO_DEMO_MODE ≠ "true") je jediná cesta k získání session přes Supabase signInWithOtp (magic link na e-mail). Žádný signInWithPassword v server actions, žádný OAuth provider zapnutý v Supabase Auth config, žádné quick-login tlačítko v UI. Dev quick-login (devQuickLogin) musí na začátku ověřit process.env.KOSMO_DEMO_MODE === "true" a jinak zamítnout.
Proč: Provider rozhodnutí (M365 ↔ Google) je dnes pohyblivé — magic link je jediný provider-agnostic flow. Žádné heslo = žádný password attack surface, žádný password reset flow. Dev quick-login je nutný pro lokální vývoj, ale gating ho drží mimo produkci. Viz ADR 2026-05-14-auth-magic-link-only.md.
Test: web/tests/invariants/login-magic-link-only.spec.ts — bez KOSMO_DEMO_MODE se na /login nezobrazuje quick-login panel ani Google tlačítko ani password input.
Důsledek porušení: Resurrekce password flow, který nikdo aktivně nepoužívá → zbytečný attack surface; nebo nechtěné zveřejnění quick-login tlačítek v prod = bypass middleware role gate.
Data integrity
INV-DATA-01 — Docházka je idempotentní upsert [Critical]
Tvrzení: Zápis docházky používá upsert s onConflict: "classbook_entry_id,student_id". Opakované volání stejného payloadu nesmí vytvářet duplicity ani měnit stav, pokud se hodnoty nezměnily.
Proč: Mobilní síť padá, učitel tapne tlačítko dvakrát. Bez idempotence: duplicate row error (Postgres unique constraint) → UI selže. S idempotencí: tichá konvergence.
Test: web/tests/invariants/attendance-idempotence.spec.ts (planned — backfill při příští úpravě)
Důsledek porušení: Učitel ztratí důvěru („proč mi to selhalo?"), nebo se duplicitní řádek vplete do reportingu.
INV-DATA-02 — classbook_entry se zakládá lazy [Standard]
Tvrzení: Slot bez interakce učitele (žádná docházka, žádné téma) NEMÁ řádek v classbook_entries. Záznam vzniká až při první mutaci přes ensureClassbookEntry().
Proč: Bez lazy zakládání by každý den všechny sloty všech tříd dostaly ghost záznamy → garbage, falešné metriky („99% pokrytí" = jen že existuje entry).
Test: text-only.
Důsledek porušení: Reporty „kolik hodin proběhlo" lžou.
INV-DATA-03 — classbook_entries.taught_by autoritou substituce [Standard]
Tvrzení: schedule_slots.default_teacher_id je výchozí učitel; skutečný učitel hodiny je classbook_entries.taught_by. UI a budoucí reporting čte vždy taught_by (s fallbackem na default_teacher_id pouze v prázdném slotu).
Proč: Substituce se musí dohledat. Bez taught_by autority by report ukazoval, že hodinu učil ten, kdo má rozvrh — bez ohledu na realitu.
Test: text-only.
Důsledek porušení: Pracovní výkaz / fakturace lže (až bude implementováno).
INV-DATA-04 — Po uzavření docházky se odeberou topic přiřazení absentům [Critical]
Tvrzení: setAttendanceDone(slot, true) provede v jedné transakci: (a) flagne classbook_entries.attendance_done_at, (b) smaže řádky student_slot_topics pro studenty se statusem absent v tomto slotu.
Proč: Fair credit. Absent žák nedostal téma → nesmí se mu počítat do statistik zvládnutí. Pokud učitel zapomene, výsledky lžou.
Test: web/tests/invariants/attendance-topic-cleanup.spec.ts (planned)
Důsledek porušení: Falešně pozitivní statistiky zvládnutí, problém v dialogu rodič ↔ Sofie.
Architektonické disciplíny (cross-cutting)
INV-ARCH-01 — Server actions a route handlers validují input přes Zod [Critical]
Tvrzení: Každá server action a route handler validuje vstup pomocí Zod schématu z web/src/lib/schemas/. Žádné formData.get() nebo request.json() se nedostane do business logiky bez Schema.parse().
Proč: Typová jistota + future MCP/OpenAPI generace ze schémat. Bez Zod = týden retrofitu kontraktů při zavádění agenta.
Test: text-only + code review (lint rule by mohl být doplněn).
Důsledek porušení: Runtime crash z neplatného vstupu, nebo tichá data corruption.
INV-ARCH-02 — Auth client podporuje cookie i Bearer token [Standard]
Tvrzení: web/src/utils/supabase/middleware.ts (a server client factory) přijme jak cookie session, tak Authorization: Bearer <jwt>. Bearer path je dnes dead code, ale musí zůstat funkční (otestovaná).
Proč: MCP server, cron joby, externí integrace dostanou service tokeny. Pokud teď postavíme jen cookie auth, přepíšeme celou auth vrstvu při prvním externím konzumentovi.
Test: text-only (lze doplnit invariant test).
Důsledek porušení: Velký refactor auth vrstvy při zavádění agenta.
INV-ARCH-03 — Žádné JSON bloby pro core domain data [Standard]
Tvrzení: Hlavní entity (docházka, témata, žáci, sloty, override dny) mají relační sloupce. M:N přes join tabulky. JSONB povolen jen pro skutečně volná data (AI response blob, event metadata).
Proč: Universal Knowledge Graph (vize ve docs/ideas/knowledge-graph.md) bude později mirror z relačních tabulek. S JSONB = migrační peklo.
Test: text-only (manuální review při změnách schématu).
Důsledek porušení: Knowledge graph nelze postavit bez kompletní data migrace.
INV-ARCH-04 — RLS policies přes vztahy, ne přes hardcoded auth.uid() [Critical]
Tvrzení: RLS policies používají JOIN na role tabulky (class_staff, student_guardians, profiles.role). Žádná policy nepřičítá konkrétní auth.uid() = NEJAKE_ID. Viz vzor v supabase/migrations/20260422000001_init.sql (policies přes class_staff).
Proč: Až MCP server dostane JWT, RLS musí aplikovat stejná pravidla jako UI bez přepisování. Hardcoded auth.uid() policies se rozpadnou při změně rolí.
Test: text-only + manuální audit při změně schématu.
Důsledek porušení: Přepis RLS při každém novém integračním scénáři.
PII & Data minimization
INV-PII-01 — Zakázané kategorie údajů v DB schématu [Critical]
Tvrzení: Žádná tabulka v public schématu nesmí obsahovat sloupce, jejichž jméno matchuje blocklist: birth_number, rodne_cislo, street, address, health, medical, allergy, svp, ivp, biometric, birth_date, date_of_birth, photo, face (case-insensitive, substring match). Žák má pouze birth_year SMALLINT, ne plné datum. Adresa, rodné číslo, zdravotní a SEN informace mimo schéma.
Proč: Rodné číslo má v ČR samostatný zákonný režim (zákon 133/2000). Zdravotní data, SVP/IVP a biometrie spadají pod čl. 9 GDPR (zvláštní kategorie) — nepřiměřené riziko vůči přínosu ve Fázi 1-2. Minimální schéma drží blast radius úniku v pásmu "nízkorizikový incident" v terminologii ÚOOÚ. Sloupec přidaný omylem (copy-paste z legacy systému, automaticky vygenerovaná migrace) tímto invariantem padne v CI.
Test: web/tests/invariants/pii-forbidden-columns.spec.ts — parsuje SQL migrace v supabase/migrations/*.sql a hledá CREATE TABLE / ADD COLUMN identifikátory proti blocklistu. Offline test, nevyžaduje běžící DB.
Důsledek porušení: GDPR incident s vysokým dopadem; pro rodná čísla i porušení zvláštního zákona. Při skutečné potřebě sloupce: ADR, který tento invariant superseduje pro konkrétní use case.
INV-PII-02 — LLM exporty jsou pseudonymizované by default [Standard]
Tvrzení: Jakmile vznikne helper pro export dat do LLM kontextu (Claude, Gemini), default funkce vrací pseudonymizovaná data (Žák A37 místo Anna Nováková, bez kontaktů rodičů). Plný-identita export musí být oddělená funkce s explicitním názvem (exportWithIdentity() apod.) a komentářem, kdy je legitimní (generování dopisů rodičům, právní dokumenty).
Proč: Default-safe. Většina LLM úloh (rozvrhy, statistiky, skupinkování, generování úloh) jména nepotřebuje. Snižuje povrch při omylu — copy-paste pseudonymizovaného seznamu do veřejného chatu není incident.
Test: text-only. Povýší na Critical, jakmile helper existuje (test na regex Žák [A-Z]\d+ v default exportu).
Důsledek porušení: Zbytečný únik jmen při ad-hoc práci s LLM. Právně OK (máme DPA), ale reputačně nepříjemné.
INV-PII-03 — Birth year, ne full date [Standard]
Tvrzení: students.birth_year SMALLINT jako jediný údaj o věku. Plné datum narození přidat až ve Fázi 2 s konkrétním business use casem (právní dokument, oslavenec) přes ADR, který tento invariant superseduje.
Proč: Kombinace jméno + plné datum narození výrazně zvyšuje identifikovatelnost (k-anonymita klesá z desítek na jednotky). Pro běžnou školní logiku (ročník, věk, "kdo má 7 let") stačí rok.
Test: Pokryto INV-PII-01 (birth_date, date_of_birth na blocklistu).
Důsledek porušení: Zvýšené riziko reidentifikace při úniku, vyšší klasifikace incidentu vůči ÚOOÚ.
Konvence (meta)
INV-META-01 — Žádná produkční data dnes [Standard]
Tvrzení: Supabase Cloud DB neobsahuje reálná osobní data žáků/rodičů. Migrace mohou být destruktivní (supabase db reset).
Proč: Vibe coding mode — rychlost přes konzervativnost. Při příchodu reálných dat tento invariant odpadne a aktivuje se striktní migrace gate.
Test: text-only — Tomáš kontroluje.
Důsledek porušení: Ztráta dat školy.