Etap 29 — Offline hardening (pełny zakres, Fazy 0→5)¶
Cel
Doprowadzić kiosk do pełnej pracy offline dla wszystkich funkcji (odbiór, nadanie, wydanie, uzupełnianie, inwentaryzacja) — z zachowaniem integralności stanu i limitów, oraz załatać dziury już istniejące w obecnym offline. Bazuje na audycie → Audyt offline. Zasada nadrzędna: backend = źródło prawdy; offline = lokalna aproksymacja + reconcyliacja przez chokepoint. Zapis stanu offline jest dopuszczalny tylko gdy jest relatywny + idempotentny + re-walidowany serwerowo lub ograniczony twardym budżetem + audytowany.
Status 2026-06-17 — WSZYSTKIE FAZY 0→5 WDROŻONE + rozszerzenie (over-limit/flagi)
Kolejność realizacji: 0 → 1 → 4 → 2 → 5.1 → 5.2 → 3 (najgroźniejsze — wydanie z budżetem — na końcu). Wdrożone na produkcji: backend przebudowany (2 migracje zaaplikowane), web przebudowany, kiosk 1.5.69 opublikowany jako MachineRelease STABLE → ota.auto-rollout rozdał na wszystkie 5 maszyn (instalują się na cyklu agenta). Doszło rozszerzenie (sekcja niżej): instancyjne tryby przekroczenia limitu (RESET/CARRY_OVER) + flagi offline na wszystkich zdarzeniach (transakcje/inwentaryzacje/paczki) z odznakami w panelu. Lokalna SQLite kiosku = v5. Pozostaje: test offline na żywej maszynie (wyjmij sieć → każdy flow [odbiór/nadanie/wydanie/uzup./inwent.] działa → wepnij → replay → stan zgodny + flagi/konflikty w panelu).
Prymitywy wspólne (raz, używane we wszystkich fazach)¶
P1. Koperta zdarzenia (outbox) — ujednolicenie¶
Każdy wiersz outboxa = { eventId: uuid(klient), kind, occurredAt, actorUserId?, payload }.
- Kiosk: local_db.dart — kolumna eventId w outbox (migracja lokalnej SQLite, addytywna). offline_repo.dart — helper enqueue(kind, payload, {actorUserId}) generujący eventId raz przy zapisie (NIE przy wysyłce → stabilny klucz mimo retry).
- Backend: machine-sync.dto.ts — ReplayEventDto zyskuje eventId (uuid, wymagany) + actorUserId?. Walidacja occurredAt w rozsądnym zakresie (nie przyszłość > 5 min, nie starsze niż 30 dni) → poza zakresem = REJECT z reasonem.
P2. Serwerowy rejestr dedup ReplayedEvent (klucz, nie tylko status)¶
schema.prisma: nowy modelReplayedEvent { eventId String @id, machineId, kind, outcome, appliedAt }. Migracja addytywna.machine-sync.service.ts replay(): na starcie obsługi eventuINSERT ReplayedEvent(eventId); kolizja (już jest) → zwróć poprzednioutcomejako idempotentny no-op, nie aplikuj ponownie. Zastępuje kruche poleganie naWHERE status=....
P3. Rozróżnienie łączności w kiosku (A-conflation)¶
api_client.dart: typApiException { int? statusCode; bool isNetwork; }. Timeout (domyślnie 8 s) na wszystkich wywołaniach (dziś tylko login).isNetwork=truetylko dla timeout/socket; 4xx/5xx →isNetwork=false+statusCode.sync_service.dart: flip naOFFLINEtylko gdyisNetwork; 401 → osobny stan „klucz odrzucony" (nie offline → nie podstawiaj cicho stale-auth); 5xx → „błąd serwera" (nie offline).
P4. Chokepoint pozostaje jedyną drogą zapisu stanu¶
Każdy replay piszący stan (dispense.confirmed, replenish.confirmed, inventory.saved) MUSI iść przez InventoryService.createMovement / applyStockTake z klientowym idempotencyKey (uuid generowany w kiosku, zapisany w payloadzie przy enqueue). Żadnych bespoke ścieżek omijających walidację stanu.
Faza 0 — Załatać istniejące dziury (LOW ryzyko, robić i tak)¶
Zaimplementowane 2026-06-17 (kiosk 1.5.63) — BEZ migracji
Faza 0 weszła migration-free: pickup/deposit/pickup-failed są status-guarded (idempotentne przez przejście stanu), więc tabela ReplayedEvent (klucz-dedup) przesunięta do Fazy 3 (wydanie, gdzie ruchy nie są status-guarded). Koperta eventId/actorUserId jedzie tylko jako pole (autoryzacja + tracing). Konflikty offline (0.5) logowane do istniejącego MachineApiLog (kind:'offline-replay') → widoczne od ręki w panelowym podglądzie logów /v1 (chipy kinds budowane dynamicznie). Commity: 4bec0ed (0.1), 375e954 (backend 0.4/0.3), c1fb7cf (kiosk 0.2/0.3/0.4).
29-0.1 — Łączność + timeouty (P3)¶
- Kiosk:
api_client.dart(ApiException+timeouty),sync_service.dart(flip tylko na network), banner statusu czyta realny sygnał. - Invariant: 500/401 NIE wygląda już jak offline → koniec cichego stale-auth na zrotowanym kluczu.
- Bez backendu. Bump kiosku.
29-0.2 — Nadanie nie ginie (A2) — deposit.completed na żywo¶
- Kiosk:
offline_repo.dart+markDepositedLocally(flip cached_shipments → DEPOSITED + enqueuedeposit.completed{shipmentId}).deposit_size_screen.dart:160/deposit_lookup_screen.dart:318—POST /shipments/:id/depositedzmienione na local-first (próbuj online; przyisNetwork→ buforuj). - Backend:
replayDepositCompleted(już istnieje, ASSIGNED→DEPOSITED) + dedup P2. Dotyczy paczek rozpoczętych online (ASSIGNED), którym zginął mark — pełne offline-nadanie to Faza 2. - Invariant: paczka fizycznie w skrytce ⇒ shipment zawsze dojdzie do DEPOSITED (nie osierocona).
29-0.3 — Odbiór offline utwardzony (A3/A4/A5)¶
- Kiosk
offline_repo.dart shipmentsForUser: odfiltrujexpiresAt < now; dołączcached_lockers.status→ ustawlockerBrokenz cache (nie otwieraj BROKEN offline).pickup_screen.dart:report-failed→ outboxpickup.failed(nowy kind) zamiast gubić. - Backend: handler replay
pickup.failed(shipment→PICKUP_FAILED + locker BROKEN), dedup P2. - Invariant: offline nie oferuje przeterminowanych/na zepsutej skrytce; nieudany odbiór nie ginie.
29-0.4 — Autoryzacja replay + dedup + zegar (A1/A6/A7)¶
- Backend: P1 (eventId+occurredAt walidacja) + P2 (ReplayedEvent). Autoryzacja:
replayPickupCompletedwalidujeshipment.recipientId == event.actorUserId(inaczej REJECT) → kiosk nie może oznaczyć cudzej paczki odebraną dla dowolnego usera.actorUserId= user, który uwierzytelnił się offline (kiosk dokłada do koperty). - Kiosk: dołącz
actorUserIddopickup.completed/pickup.failed. - Invariant: replay związany z faktycznym odbiorcą; sam klucz maszyny nie wystarcza do dowolnej zmiany.
29-0.5 — Panel: konflikty offline (A3)¶
- Backend: zapisuj outcome CONFLICT/REJECT replay do
MachineApiLog(lub małyMachineSyncConflict) z {shipmentId, kind, reason, at}. - Web: w detalu maszyny / raportach sekcja „Konflikty offline" (ghost-pickup, utknięte nadania).
- Invariant: rozjazdy offline są widoczne dla operatora, nie znikają cicho.
Deploy Fazy 0: backend+web razem (migracja ReplayedEvent + ewent. konflikt-log), OTA kiosku. Migracje addytywne.
Faza 1 — Fundament łączności¶
Zaimplementowane 2026-06-17 (kiosk 1.5.64, commit 3319726)
ConnectivityService wpięty w app.dart obok SyncService. Persisted „offline since" jako pole (in-memory, nie przez reboot — wystarczające dla sygnału). Konsument: sync.flush() na reconnect (reszta — Fazy 2–5).
29-1.1 — ConnectivityService (kiosk)¶
- Lekki health-ping (
GET /health, istnieje) co ~15 s +lastSuccessAt; strumieńonline. Flow konsultują go świadomie zamiast przypadkowego try/catch. Persisted „offline since". Banner = realny sygnał. - Invariant: deterministyczna bramka online/offline dla Faz 2–5.
- Bez backendu (poza istniejącym /health). Bump kiosku.
Faza 2 — Nadanie offline (deposit) — MED¶
Zaimplementowane 2026-06-17 (kiosk 1.5.67) — BEZ leasingu (migration-free)
Zamiast tabeli LockerLease poszliśmy optymistycznie: kiosk bierze wolną PARCEL skrytkę rozmiaru z cache (cached_lockers, lokalnie RESERVED), mintuje finalny trackingId OFF-<hex> i buforuje deposit.offline. Replay (replayDepositOffline) tworzy Shipment DEPOSITED idempotentnie po unikalnym trackingId i waliduje, że skrytka wciąż FREE — jeśli online ją zajął, CONFLICT do operatora (paczka fizycznie w skrytce). Recipient search offline z cached_users. Commit 2f974fc (kiosk), 4cfebea (backend).
29-2.1 — Backend: leasing skrytek + replay nadania¶
schema.prisma:LockerLease { id, machineId, lockerId, size, function, leasedUntil, consumedEventId? }— pula wstępnie zarezerwowanych FREE skrytek wydzielona maszynie online (cron/endpoint odnawia). Migracja addytywna.machines.controller/service:GET /machines/me/locker-leases(kiosk pobiera pulę online),replayDepositOffline— tworzy Shipment (DEPOSITED) wiązany z recipient+locker z leasingu; waliduje że lease ważny/niespożyty; kolizja → CONFLICT (parcel fizycznie w skrytce → flaga do operatora).- Tracking: kiosk mintuje provisional uuid; backend przy replay nadaje realny tracking lub akceptuje provisional (deterministyczne mapowanie po eventId).
29-2.2 — Kiosk: offline branch nadania¶
- Cache: leasing w nowej tabeli
cached_leases.deposit_recipient_screen— szukaj odbiorcy zcached_usersoffline.deposit_size_screen— wybór skrytki z leasingu, otwarcie lokalnym agentem, enqueuedeposit.offline{provisionalTracking, recipientId, lockerId, actorUserId}. - Invariant: offline nadaje tylko w skrytkę z ważnego leasingu; replay waliduje serwerowo; konflikt = operator.
- Bump kiosku. Deploy backend+web (panel: widok leasingu/konfliktów).
Faza 3 — Wydanie offline z twardym budżetem — HIGH¶
Zaimplementowane 2026-06-17 (kiosk 1.5.69, backend migracja 20260617_etap29_movement_offline_flags)
Snapshot zamiast osobnego endpointu: kiosk po każdym udanym katalogu ONLINE (/dispense/me/catalog) zapisuje per-user snapshot do cached_user_dispense (remaining/decyzja/requiresAcceptance z silnika limitów). Reuse cached_items/cached_stock z Fazy 4.1 — żaden nowy endpoint nie był potrzebny. Offline dispenseCatalogOffline liczy efektywny budżet = cached.remaining − Σ offline_usage_ledger (trwały, keyed po eventId; przeżywa reboot). Fail-closed: brak snapshotu / decyzja≠ALLOW / requiresAcceptance → produkt nie pojawia się offline (baner informuje). Po zamknięciu drzwi dispense.confirmed{idempotencyKey} → ledger + outbox; net-blip PO zamknięciu też buforuje. Bez ReplayedEvent — createMovement jest już idempotentny po idempotencyKey, więc tabela-dedup z P2 okazała się zbędna; ledger kasowany na drenażu eventu (świeży katalog nie odejmuje 2×). Backend replayDispenseConfirmed: re-ewaluacja limitu (log:false) → WITHDRAWAL przez chokepoint; nadwyżka (stale snapshot/wyścig) → Movement.flaggedOverLimit=true (+ offlineReplayedAt) zamiast utraty rekordu; konflikt stanu → CONFLICT w logu offline-replay. Commity: c0baf01 (backend 3.1), 8e61b96 (kiosk 3.2).
29-3.1 — Backend: catalog/limit snapshot dla kiosku¶
- Nowy
GET /machines/me/dispense-snapshot?userId=(machine-auth): zwraca katalog (itemy+ceny+dokumenty+flagi), stan per-slot,defaultDeny, oraz snapshot limitów zalogowanego usera (remaining/limitQty/resetAtper item/grupa) policzony silnikiem. Per-user przy logowaniu online (nie wszyscy naraz). - Nowy handler
replayDispenseConfirmed: przezcreateMovement(reject stanu + idempotencja) i re-runlimit.evaluate; over-limit → ruch zapisany ale oflagowany (Movement.flaggedOverLimitlubLimitDecisionLogBLOCK po fakcie) — bo fizyczne nieodwracalne, więc audyt zamiast utraty rekordu. schema.prisma:Movement.flaggedOverLimit Boolean @default(false)(+ ewent.offlineReplayedAt). Migracja addytywna.
29-3.2 — Kiosk: cache + budżet + offline branch wydania¶
- Nowe tabele SQLite:
cached_items,cached_stock,cached_dispense_policy(defaultDeny),cached_user_limits(userId,itemKey,remaining,limitQty,resetAt),offline_usage_ledger(userId,itemId,qty,window,eventId). - Logika: offline
remaining_efektywne = cached.remaining − Σ offline_usage. Wydaj tylko doremaining_efektywneicached_stock. Fail-closed: brak snapshotu limitu dla usera/itemu idefaultDeny(cache) → BLOK.dispense_screenoffline branch: czyta cache, egzekwuje budżet, otwiera lokalnie, dekrementujeoffline_usage_ledger(przeżywa reboot), enqueuedispense.confirmed{idempotencyKey, userId, itemId, qty, slot, actorUserId}. - Dokumenty: cache
cached_documents+cached_acceptances; bramka offline; akceptacja → outbox. - Invariant: offline NIGDY nie wyda ponad
remainingz ostatniego synca (twardy budżet, trwały licznik); brak polityki = fail-closed; nadwyżki (rozjazd stanu) oflagowane przy replay. Ryzyko resztkowe: cofnięte uprawnienie ograniczone świeżością synca (akceptowane, jak revoked-user przy odbiorze dziś). - Bump kiosku. Deploy backend+web (panel: flaga over-limit w raportach).
Faza 4 — Uzupełnianie offline — LOW-MED¶
Zaimplementowane 2026-06-17 (kiosk 1.5.66)
Fundament cache (4.1, commit 63eba02): /machines/me/sync zwraca items + per-slot stock; local_db v4 (cached_items/cached_stock, full-mirror + tombstone). 4.2 (kiosk e687415, backend f906287): lista do uzupełnienia z cache offline, otwarcie lokalnym agentem, replenish.confirmed{idempotencyKey="restock-<job>-<slot>"} → createMovement RESTOCK (relatywny +delta, clamp do headroom, idempotentny). MachinesModule→DispensingModule. Optymistyczny bump cached_stock.
29-4.1 — Kiosk + backend: replenish offline¶
- Cache: reuse
cached_items/cached_stock(z Fazy 3).replenish_screen/replenish_run_screenoffline branch: lista z cache, otwarcie lokalne, enqueuereplenish.confirmed{idempotencyKey ZAPISANY przy enqueue, slotId, qty, actorUserId}. - Backend
replayReplenishConfirmed→createMovementRESTOCK (relatywny +delta, capacity re-clamp, idempotencja). - Invariant: RESTOCK relatywny + idempotentny + re-clamp serwerowy → komponuje się bezpiecznie z przeplotem; klucz stabilny (nie regenerowany).
- Bump kiosku. Deploy backend.
Faza 5 — Inwentaryzacja offline (z guardem OCC) — MED-HIGH¶
Zaimplementowane 2026-06-17 (kiosk 1.5.68, backend 831524f + replay 5.2)
5.1 (commit 831524f): applyStockTake przyjmuje baselineQty per linia (= expectedQty ze snapshotu); gdy slot.quantity != baselineQty → linia pomijana jako DRIFT (+ licznik drift), bez forsowania absolutu — naprawia minę C1 także dla online replay-after-delay. 5.2 (kiosk): lista produktów/skrytek z cache offline, otwarcie lokalne, inventory.saved{clientDocumentId(UUID v4), lines:[…,expectedQty]}; replay → kioskDispense.inventorySave idempotentnie po clientDocumentId (ten sam dokument = brak podwójnego zapisu) → applyStockTake z OCC.
29-5.1 — Backend: OCC guard w applyStockTake (fix C1, korzysta też online)¶
inventory.service.ts applyStockTake: przyjmujebaselineQtyper linia (=expectedQtyze snapshotu operatora). Przy aplikacji: jeślislot.quantity != baselineQty→ NIE forsuj absolutu; oznacz linię DRIFT/CONFLICT (skip + raport do operatora). Dokument inwent. zapisuje baseline+delta+drift per linia.kiosk-dispense.service.ts inventorySave+ DTO: przenosibaselineQtyper linia.- Invariant: spis nie nadpisuje świeższego stanu — dryf między snapshotem a replay jest wykrywany, nie clobberowany. (Naprawia minę nawet dla online replay-after-delay.)
29-5.2 — Kiosk: inwentaryzacja offline¶
- Cache produktów/lockerów inwent. (reuse cached_items/cached_stock +
expectedQtyjako baseline).inventory_screen/inventory_column_screenoffline branch: otwarcie lokalne, enqueueinventory.saved{documentId, lines:[{slot, baselineQty, foundQty}], actorUserId}. - Backend
replayInventorySaved→ InventoryDocument +applyStockTakez OCC. - Invariant: buforowanie inwent. bezpieczne dopiero PO 29-5.1; linie z dryfem → konflikt do operatora, nie ciche nadpisanie.
- Bump kiosku. Deploy backend+web (panel: dryf/konflikty inwent.).
Rozszerzenie po fazach (2026-06-17): tryby przekroczenia limitu + flagi offline wszędzie¶
Zaimplementowane + WDROŻONE 2026-06-17 (migracje 20260617_etap29_movement_offline_flags + 20260617_etap29_overlimit_modes)
Po domknięciu Faz 0→5 doszły dwie rzeczy żądane przez operatora: (1) konfigurowalna instancyjnie polityka traktowania przekroczeń limitu powstałych offline, w dwóch trybach; (2) jednolita flaga „offline" na KAŻDYM rekordzie zmaterializowanym przez replay (transakcje, inwentaryzacje, paczki).
A. Przekroczenie limitu — 2 tryby (konfiguracja całej instancji)¶
Przekroczenie limitu może powstać wyłącznie offline — kiosk online pilnuje twardego budżetu (Faza 3); nadwyżka wpada tylko gdy snapshot uprawnień ze synca był nieaktualny albo był wyścig między urządzeniami. Polityka jest instancyjna: DispenseSetting.overLimitMode ∈ {RESET, CARRY_OVER}, przełączana w panelu Wydawanie → Limity (pod „Domyślnie zabronione").
| Tryb | Zachowanie | Kara w następnym okresie |
|---|---|---|
| RESET (domyślny) | Nadużycie wygasa naturalnie wraz z oknem kroczącym silnika. | Brak — tylko flaga audytowa Movement.flaggedOverLimit. |
| CARRY_OVER | Nadwyżka zapisana jako dług (LimitOverage) odejmowany od przysługującej ilości. |
Tak — jednorazowo, potem dług auto-wygasa. |
Dlaczego dług ma okno [at+okres, at+2·okres) — bez podwójnego karania
Silnik liczy zużycie w oknie kroczącym (usedQty = suma WITHDRAWAL z ostatnich periodSeconds). Ruch nadużycia sam blokuje usera przez periodSeconds (naturalnie „przenosi" przekroczenie w bieżącym oknie). Gdyby dług działał OD RAZU, user byłby ukarany podwójnie (ruchy + dług). Dlatego dług staje się aktywny dopiero gdy ruchy nadużycia wypadną z okna (appliesFrom = at + periodSeconds) i trwa jeden okres (appliesUntil = at + 2·periodSeconds), po czym auto-wygasa → kara jednorazowa, bez kumulacji w nieskończoność.
Ścieżka: replayDispenseConfirmed re-ewaluuje limit (log:false), zapisuje WITHDRAWAL przez chokepoint createMovement z flaggedOverLimit, a w trybie CARRY_OVER woła limits.recordOverLimit() → LimitOverage per przekroczona reguła. evaluate() odejmuje sumę aktywnych długów per reguła od limitQty (clamp do 0). MatchedRuleResult niesie periodSeconds. setPolicy({defaultDeny?, overLimitMode?}) aplikuje tylko przysłane pole (toggle nie rusza drugiego).
B. Flagi „offline" na każdym zreplayowanym rekordzie¶
Każdy rekord sfinalizowany przez replay offline dostaje audytowy offlineReplayedAt (null = online), nakładany przez chokepoint, nie ad-hoc:
| Rekord | Pole | Kind(y) replay | Chokepoint |
|---|---|---|---|
Movement (wydanie + uzupełnianie) |
offlineReplayedAt (+ flaggedOverLimit dla wydania) |
dispense.confirmed, replenish.confirmed |
createMovement (DTO) |
InventoryDocument (inwentaryzacja) |
offlineReplayedAt |
inventory.saved |
inventorySave |
Shipment (paczki) |
offlineReplayedAt |
pickup.completed, pickup.failed, deposit.completed, deposit.offline |
replay tx |
Panel — wspólne odznaki OfflineBadge (bursztyn „offline") + OverLimitBadge (różowy „ponad limit", i18n common.offlineFlags) w: tabeli ruchów (Raporty), liście + detalu przesyłek, liście inwentaryzacji. Listy zwracają te skalary bez zmian backendu (zapytania używają include/braku select, więc skalary jadą same).
Macierz outbox kinds (po etapie 29)¶
| kind | faza | replay → | invariant |
|---|---|---|---|
pickup.completed |
istnieje/0.4 | flip PICKED_UP, recipient==actor | status-guard + actor + dedup |
pickup.failed |
0.3 | PICKUP_FAILED + locker BROKEN | dedup |
deposit.completed |
0.2 | ASSIGNED→DEPOSITED | status-guard + dedup |
deposit.offline |
2 | utwórz Shipment z leasingu | lease-valid + dedup |
dispense.confirmed |
3 | createMovement WITHDRAWAL + re-limit (flag) | budżet kiosk + chokepoint + flaga |
replenish.confirmed |
4 | createMovement RESTOCK | relatywny + re-clamp + idempotent |
inventory.saved |
5 | applyStockTake (OCC) | baseline-guard + dedup |
Każdy kind oznacza rekord jako offline
Po rozszerzeniu z 2026-06-17 każdy replay piszący stan ustawia offlineReplayedAt na docelowym rekordzie (Shipment / Movement / InventoryDocument), a dispense.confirmed dodatkowo Movement.flaggedOverLimit przy przekroczeniu. Patrz sekcja „Rozszerzenie po fazach" wyżej.
Migracje DB — STAN FAKTYCZNY (as-built, wszystkie addytywne)¶
Spec zakładał kilka tabel, których NIE zbudowano (prostsze, sprawdzone wzorce wystarczyły):
- ❌
ReplayedEvent(dedup po kluczu) — niepotrzebny: pickup/deposit są status-guarded (idempotentne przez przejście stanu), a dispense/replenish dedupują się poMovement.idempotencyKey(chokepointcreateMovementjest idempotentny). Inventory — poclientDocumentId. - ❌
LockerLease(pula rezerwacji pod nadanie offline) — zastąpione optymistycznym pickiem z cache + walidacją „skrytka wciąż FREE" przy replayu (replayDepositOffline). - ❌
MachineSyncConflict— konflikty/odrzucenia replay logowane do istniejącegoMachineApiLog(kind:'offline-replay'), widoczne w panelowym podglądzie logów/v1.
Realnie wdrożone migracje serwera (2):
20260617_etap29_movement_offline_flags—Movement.flaggedOverLimit+Movement.offlineReplayedAt(Faza 3).20260617_etap29_overlimit_modes—enum OverLimitMode,DispenseSetting.overLimitMode,Shipment.offlineReplayedAt,InventoryDocument.offlineReplayedAt, tabelaLimitOverage(rozszerzenie po fazach).
Fazy 0/1/2/4/5 weszły migration-free. Lokalna SQLite kiosku doszła do v5 (bez migracji serwera).
Ryzyko i kolejność¶
0 (LOW, robić i tak) → 1 (LOW) → 2 (MED) → 4 (LOW-MED) → 3 (HIGH) → 5 (MED-HIGH, gated na 5.1). Sugerowana kolejność realizacji: 0 → 1 → 4 → 2 → 5.1 → 5.2 → 3 (najgroźniejsze, wydanie, na końcu, gdy fundament i wzorce replay są sprawdzone). Każdy chunk: tsc/analyze → commit etap-29-X.Y: → deploy backend+web / OTA kiosk → weryfikacja (w tym test offline: wyjmij sieć → flow działa → wepnij → replay → stan zgodny).