Přeskočit obsah

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.

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.

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.