Czterdzieści minut i jeden bucket
Dwudziesta trzecia czterdzieści, sprzątanie po eksperymencie w homelabie. Kasuję przestrzeń nazw w klastrze i idę zrobić herbatę. Wracam - Terminating. Wracam po dziesięciu minutach - Terminating. Po czterdziestu minutach dłubię w blokadach usunięcia i okazuje się, że czeka na operatora, którego wyłączyłem kwadrans wcześniej, bo „już niepotrzebny”.
Tydzień później, inna historia, ten sam wieczór tygodnia. terraform destroy przechodzi czysto, zielono, bez ostrzeżeń. Po ośmiu dniach dostaję rachunek za bucket, którego w stanie nigdy nie było, bo powstał wtedy, gdy „na chwilę” klikałem w konsoli.
Długo trzymałem te dwie wpadki w osobnych szufladkach. Pierwsza to „coś z Kubernetesem”, druga to „trzeba pilnować stanu”. Dopiero kiedy zacząłem opisywać obie tym samym językiem, zobaczyłem, że to jedna wpadka opowiedziana dwa razy. W obu przypadkach zawiódł graf, tylko za pierwszym razem zabrakło w nim krawędzi mówiącej, kto na kim stoi, a za drugim krawędzi mówiącej, jak coś cofnąć.
Ten artykuł jest o tym grafie. Nie jest o żadnym konkretnym narzędziu - jest o modelu, który pozwala opisać Terraforma, Argo CD, wtyczkę, przepływ w n8n i agenta jednym zestawem pojęć, i zadać im wszystkim ten sam zestaw pytań.
Każdy system, który da się włączać i wyłączać po kawałku, jest grafem typowanych obiektów. Biorąc pod uwagę wyłącznie relacje zależności, widzimy architekturę taką, jaka jest w tej chwili - to rzut przestrzenny. Biorąc pod uwagę relacje cofnięcia, zastąpienia i pochodzenia, widzimy historię i to, co da się jeszcze odwrócić - to rzut czasowy. To nie są dwa grafy, tylko jeden, oglądany z dwóch stron, a podejście „wszystko jako kod” jest metodą serializacji go do repozytorium. Wszystko, czego w grafie nie ma, jest poza zasięgiem automatyzacji: nie da się tego ani cofnąć, ani wyjaśnić.
Cała argumentacja jest w słowach. Zapis formalny siedzi w wydzielonych ramkach „zapis formalny” - można je pominąć i nic z głównej myśli nie stracić.
Dwa pytania, które zawsze zadajemy osobno
Weźmy dowolny fragment infrastruktury i zadajmy mu dwa pytania.
Pytanie o przestrzeń: na czym to stoi w tej chwili? Który komponent dostarcza bazę, który kolejkę, który sekrety. Odpowiedź jest grafem skierowanym: strzałki idą od tego, kto potrzebuje, do tego, kto dostarcza.
Pytanie o czas: co się stanie, jeśli to wyłączę? Odpowiedź też jest grafem: każda wykonana zmiana ma zapisane swoje cofnięcie, każdy zapisany fakt ma poprzednią wersję i źródło.
Zwykle trzymamy te dwie odpowiedzi w zupełnie różnych miejscach. Układ zależności siedzi w manifestach, w depends_on, w falach synchronizacji. Historia siedzi w pliku stanu, w logach wdrożeń, w historii repozytorium. Nikt tego nie łączy, więc nikt nie umie odpowiedzieć na pytanie złożone: czy mogę bezpiecznie wyłączyć ten komponent, biorąc pod uwagę, kto z niego korzysta i co po nim zostanie. A to jest jedyne pytanie, które w praktyce zadajemy.
Cztery klasy obiektów
Trzymam się czterech, bo mniej nie wystarcza, a więcej robi się ontologią, której nikt nie utrzyma.
Jednostka
Wszystko, co można włączyć i wyłączyć jako całość: moduł Terraforma, wydanie Helma, aplikacja w Argo CD, wtyczka, przepływ w n8n, agent z przypisaną rolą, paczka umiejętności wpięta do projektu, własny kontroler.
Tożsamość to identyfikator nadawany raz i nigdy nierecyklingowany. To nie jest kosmetyka i wrócę do tego przy podmianie bazy.
| Atrybut | Znaczenie |
|---|---|
wymaga | zbiór nazw zależności, bez których jednostka nie ruszy |
dostarcza | zbiór nazw, które jednostka wystawia innym; poza nimi nie wolno jej niczego zapisać |
rodzic | jednostka, pod którą ta została uruchomiona |
wyłączona | znacznik administracyjny: nie uruchamiaj, niezależnie od zależności |
widok zatwierdzony | przyporządkowanie każdej zadeklarowanej nazwy do tożsamości dostawcy, wobec którego jednostka faktycznie wystartowała |
zbiorcze cofnięcie | złożenie operacji zwalniających wszystkie zajęte zasoby, gotowe do uruchomienia |
Jednostka jest automatem o czterech stanach, a przejścia wyzwala jedno porównanie: widok docelowy (do czego powinna być podłączona przy obecnej zawartości grafu) kontra widok zatwierdzony (do czego jest podłączona faktycznie).
| Stan | Co znaczy | Wyjście |
|---|---|---|
nieaktywna | nie zajmuje niczego, nie dostarcza niczego | do uruchamiana, gdy widok docelowy przestaje być pusty |
uruchamiana | zajmuje zasoby krok po kroku, jeszcze nie dostarcza | do aktywna po ostatnim kroku, do wyłączana przy zmianie widoku albo błędzie |
aktywna | dostarcza swoje nazwy, jest widoczna dla innych | do wyłączana, gdy widok docelowy przestaje zgadzać się z zatwierdzonym |
wyłączana | już nie dostarcza, jeszcze nie zwolniła zasobów | do nieaktywna po zwolnieniu, pod warunkiem wstrzymującym |
Zasób
Wszystko, co jednostka zajmuje i co trzeba potem oddać: bucket, połączenie do bazy, zarejestrowana obsługa zdarzenia, wpis w tablicy tras, dzierżawa, otwarta sesja, zarezerwowany budżet tokenów, uruchomiony proces potomny.
Cecha definicyjna: istnieje operacja, która ten zasób oddaje. Zasób bez takiej operacji to zadeklarowany wyciek i musi być w modelu widoczny jako jawny brak, a nie jako brak wpisu.
Operacja cofa przypisywana jest w chwili zajęcia, a nie z góry, i ma warunek lokalny: ma przywracać stan w tym miejscu, w którym zasób został zajęty, a nie w każdym możliwym stanie świata. Warunek lokalny da się spełnić i przetestować, globalny zwykle nie.
Fakt
Wszystko, co system ustalił i co ma przetrwać: ocena sygnału w radarze, decyzja o publikacji, wynik testu, wykryta podatność, wersja dokumentu.
Cecha definicyjna, odwrotna do zasobu: faktu nie oddaje się, tylko zastępuje nowszym. Stara wersja zostaje.
Zapis jest wyłącznie dopisujący; wartość bieżąca to ta, której nikt nie zastępuje; wyjaśnienie faktu to przejście po relacji pochodzenia. Niezmiennik jest jeden i twardy: każdy fakt ma niepuste pochodzenie. Fakt bez pochodzenia jest opinią, a po trzech miesiącach nikt nie odróżni jednego od drugiego.
Przebieg
Zapis wykonania: kto, kiedy, na jakich wejściach, z jakim wynikiem, jakimi wersjami narzędzi i konfiguracji.
Bez skrótu wejścia i wersji konfiguracji nie da się rozstrzygnąć czy wynik zmienił się dlatego, że zmienił się świat, czy dlatego, że zmieniliśmy narzędzie. To jest najczęstsze pytanie zadawane systemom, które cokolwiek oceniają, i najczęściej nie ma na nie odpowiedzi.
Pięć relacji i dwa rzuty
| Relacja | Od czego do czego | Znaczenie | Rzut |
|---|---|---|---|
wymaga | Jednostka → nazwa | nie ruszy, dopóki tego nie ma | przestrzenny |
dostarcza | Jednostka → nazwa | to wystawia innym | przestrzenny |
cofa | Zasób → operacja | tak się ten zasób oddaje | czasowy |
zastępuje | Fakt → Fakt | to jest nowsza wersja tamtego | czasowy |
pochodzi z | Fakt → Przebieg, Źródło | stąd się to wzięło | czasowy |
Dwie pierwsze relacje nie łączą jednostek bezpośrednio, tylko przez nazwę. Jednostka nie deklaruje „potrzebuję instancji postgres-prod”, tylko „potrzebuję czegoś, co dostarcza baza-danych”. Dzięki temu dostawcę można podmienić bez dotykania korzystających, a rozstrzygnięcie, kto na kim stoi, jest wyliczane, a nie zapisane.
Rzuty muszą pochodzić z jednego grafu, bo najważniejsze pytanie dotyczy obu naraz. Czy mogę wyłączyć tę jednostkę teraz? Odpowiedź wymaga dwóch warunków jednocześnie: żadna aktywna jednostka nie trzyma już relacji wymaga wskazującej na to, co ta jednostka dostarcza (warunek przestrzenny), i każdy zajęty przez nią zasób ma operację cofa, którą da się uruchomić (warunek czasowy). Pominięcie pierwszego daje bazę wyrwaną spod działającej aplikacji. Pominięcie drugiego daje mój bucket.
Zapis formalny (opcjonalny)
Model to graf typowany
gdzie wymaga i dostarcza, co daje rzut przestrzenny, a cofa, zastępuje i pochodzi z, co daje rzut czasowy. Oba operują na tym samym
Zależności trzymamy jako częściową funkcję zależnego typu
co jest funkcją dobrze określoną przy
Włączanie i wyłączanie
Włączenie nie jest jedną operacją, tylko sekwencją kroków, z których każdy dokłada do grafu zasób wraz z operacją zwalniającą. Trzy własności tej sekwencji decydują o tym, czy model daje się zaimplementować.
Kroki są przerywalne. Między dwoma krokami jest granica, na której można się zatrzymać i wycofać to, co już zrobione. Ziarnistość przerwania równa się ziarnistości kroku: jeśli włączenie jest jednym wielkim krokiem, to albo wykonasz całość, albo nic.
Krok rozpoczęty musi się skończyć. Nie da się anulować wysłanego żądania tak, żeby świat o nim zapomniał. Zmiana decyzji w trakcie nie przerywa kroku w locie - krok ląduje, a dopiero potem jednostka idzie do wyłączania. To ta sama własność, którą znamy jako okres karencji przy zatrzymywaniu kontenera.
Krok może się nie udać. Wtedy jednostka wycofuje wszystko, co zdążyła zająć, i ląduje jako nieaktywna z zapisanym błędem. Trzy szczegóły warte wdrożenia: błąd zostaje na jednostce, więc rodzeństwo działa dalej; z błędu nie wraca się automatycznie, bo powtarzanie tej samej operacji wobec niezmienionego otoczenia to szybsze palenie budżetu; awaria idzie tą samą drogą co zwykłe wyłączenie, więc nie ma osobnej ścieżki kodu na sprzątanie po błędzie.
Wyłączanie musi mieć dwie fazy
Naiwne wyłączenie robi dwie rzeczy naraz: zabiera to, co jednostka dostarczała, i zwalnia jej zasoby. To nie działa, a powód jest prozaiczny: jednostka wyłączana też ma swoje sprzątanie, a do sprzątania potrzebuje często dokładnie tego, co właśnie znika. Zamykając pulę połączeń, trzeba te połączenia komuś oddać.
Wygląda to na przepis na zakleszczenie i warto rozumieć, dlaczego nim nie jest. Po fazie pierwszej jednostka przestała być dostawcą, więc każdy, kto się do niej podpiął, ma teraz niespełnione wymaganie i sam idzie do wyłączenia. Kolejka rozładowuje się od strony liści, a nie od korzenia.
W Kubernetesie ten mechanizm ma swoją nazwę: to blokada usunięcia. Moje czterdzieści minut z pierwszego akapitu to dokładnie sytuacja, w której warunek nigdy się nie zwolni, bo czeka na coś, co już nie nadejdzie.
Zapis formalny (opcjonalny)
Jednostka
Faza druga wyłączania wymaga
a
Kiedy wolno zwalniać w innej kolejności
Domyślnie zwalnia się w kolejności odwrotnej do zajmowania i to działa zawsze. Problem w tym, że w działającym systemie prawie nigdy tak nie zwalniamy - chcemy wyjąć jedną jednostkę, podczas gdy zasoby zajęte później przez inne wciąż stoją.
Ratuje to przemienność: dwie operacje są przemienne, jeżeli wykonane w dowolnej kolejności dają ten sam wynik.
Stąd wniosek, który uważam za najbardziej użyteczny projektowo w całym tym modelu:
Przemienność jest własnością interfejsu, który publikujesz, a nie własnością implementacji.
Jeżeli punkt rozszerzeń jest zbiorem, do którego się dokłada i z którego się zabiera, będzie się składał dobrze i pozwoli wyjmować elementy w dowolnej kolejności. Jeżeli jest listą uporządkowaną, w której pozycja ma znaczenie, każde wyjęcie ze środka będzie bolało. Ten wybór robimy, projektując interfejs, i to jest moment, w którym decydujemy o wszystkim, co będzie potem.
Zapis formalny (opcjonalny)
Zmianę modelujemy jako parę
co jest formalnym zapisem reguły „ostatnie zajęte, pierwsze zwolnione”. Zbiorcze cofnięcie po ciągu zmian to
Cofnięcie zwracane jest w miejscu użycia, więc właściwy typ operacji to
Niech
i gdy przekształcenia jednej nie zmieniają cofnięcia zwracanego przez drugą. Przy parami niezależnych operacjach cofnięcia można uruchomić w dowolnej permutacji i wrócić do stanu wyjściowego. Wystarczy sprawdzić generatory: jeśli komutują generatory, komutują generowane monoidy.
Wszystkie równości stanów czyta się z dokładnością do nieodróżnialności
Granica grafu
Nie wszystko da się do grafu wciągnąć. Wewnątrz jest to, co system może zmienić na wyłączność i przywrócić do stanu sprzed zmiany. Na zewnątrz wszystko inne, a operacje na tym nie zostawiają w modelu śladu.
| Etap | Co to jest | Przykłady | Gdzie leży |
|---|---|---|---|
| Zajęcie | uzyskanie dostępu i zapisanie śladu | otwarcie pliku, utworzenie bucketu, nawiązanie połączenia, uruchomienie procesu, założenie konta | wewnątrz, odwracalne |
| Wysłanie | przepchnięcie danych przez ten kanał | zapisane bajty, wysłany pakiet, mail, opublikowany wpis, przelew | na zewnątrz, nieodwracalne |
Na wysłanie są tylko dwa lekarstwa i oba trzeba zaprojektować świadomie. Wstrzymanie: nie wysyłaj, dopóki nie masz pewności, że stan, który to spowodował, jest trwały. Kompensacja: dołóż osobną operację, która przywraca sytuację z grubsza - skasuj utworzony plik, zwróć opłatę, wyślij sprostowanie.
Kompensacja nie jest cofnięciem i nie należy jej tak nazywać. Cofnięcie przywraca stan nieodróżnialny od poprzedniego. Kompensacja zostawia ślad: wysłany mail i mail ze sprostowaniem to dwa maile, a nie zero.
Zasoby cofa się inaczej niż fakty
Podział na klasę Zasób i klasę Fakt nie jest taksonomiczny, tylko operacyjny: te dwie klasy odwraca się dwoma różnymi mechanizmami, a pomylenie ich jest najkosztowniejszym błędem projektowym w tym obszarze.
| Zasób | Fakt | |
|---|---|---|
| Mechanizm odwracania | uruchomienie operacji cofa | dopisanie wersji z relacją zastępuje |
| Los starej wartości | znika | zostaje, oznaczona jako zastąpiona |
| Zysk | mały stan, brak narastania | pełna historia, możliwość wytłumaczenia zmiany |
| Koszt | historia przepada | stan rośnie, trzeba zarządzać jakością |
| Właściwe dla | połączeń, uchwytów, rejestracji, obiektów w chmurze | ocen, decyzji, wyników, wersji dokumentów |
Użycie mechanizmu faktów do zasobów daje wyciek: połączenie „oznaczone jako zamknięte”, które faktycznie stoi otwarte, i bucket „oznaczony jako usunięty”, za który przychodzi rachunek.
Użycie mechanizmu zasobów do faktów daje amnezję: system po restarcie nie pamięta, czego już próbował, więc próbuje tego znowu, i nikt nie umie wytłumaczyć, dlaczego wczoraj wynik był inny.
W dobrze zaprojektowanym systemie te dwie klasy leżą w dwóch różnych magazynach. To decyzja, którą trzeba podjąć na początku, bo później jest bardzo droga.
Cztery niezmienniki, które da się sprawdzać w potoku
Rzut przestrzenny jest acykliczny. Cykl w relacjach wymaga i dostarcza oznacza, że wszystkie jednostki w cyklu są martwe. Sprawdzenie to zwykłe sortowanie topologiczne po deklaracjach, przed jakimkolwiek uruchomieniem - i to jest przewaga nad zakleszczeniem w systemach współbieżnych, którego z definicji nie da się przewidzieć.
Przy prawdziwym cyklu rozbijamy jednostki na mniejsze. Klasyczny przypadek to serwer i moduł kontroli dostępu: serwer wystawia punkt do zmiany reguł, a kontrola dostępu filtruje żądania przychodzące do serwera. Rozbicie na serwer-rdzeń, kontrola-rdzeń, filtrowanie-żądań i zarządzanie-regułami cykl usuwa, bo żaden rdzeń nie potrzebuje drugiego. Koszt jest realny - liczba jednostek rośnie, w gorszym przypadku kwadratowo - więc nie rozbijamy niczego zapobiegawczo.
Każdy zasób ma operację zwalniającą. Zasób bez niej to zadeklarowany wyciek. Czasem świadomie coś przyjmujemy jako niecofalne, ale zawsze musi to być widoczne. Lista zasobów bez cofnięcia jest równocześnie odpowiedzią na pytanie „co zostanie, jak to wszystko wyłączymy”, i warto trzymać ją w repozytorium jako plik, który się przegląda.
Czego mechanizm nie sprawdzi: że operacja zwalniająca faktycznie zwalnia. To zostaje obowiązkiem autora i nie ma na to obejścia w samym modelu. Poprzeczkę podnosi się testem: uruchom, wyłącz, porównaj stan.
Każdy fakt ma pochodzenie. Zapis bez identyfikatora przebiegu jest odrzucany, a nie przyjmowany z pustym polem. Reguła musi działać od pierwszego dnia, bo uzupełnienie pochodzenia wstecz jest niewykonalne.
Stan spoczynku zależy od konfiguracji, nie od historii. To reguła znana z podejścia GitOps, tylko rzadko wypowiadana wprost: cokolwiek system przeszedł, po ustaniu ruchu powinien wylądować tam, gdzie wylądowałby, gdyby tę samą końcową konfigurację zbudować raz, od zera, w kolejności wynikającej z zależności.
Zapis formalny (opcjonalny)
Niech
Twierdzenie mówi o stanie, a nie o tym, co system wyemitował po drodze, i przestaje obowiązywać przy awariach: to, czy krok się wywróci, zależy od stanu, w którym został uruchomiony, więc jedna kolejność zdarzeń może zepsuć jednostkę tam, gdzie inna ją dokończy. Stąd praktyczny warunek: jednostka, która padła, nie może zostawiać po sobie połowy stanu. Wtedy dwie różne historie różnią się co najwyżej listą jednostek, które akurat padły, a nie zawartością systemu.
Podejście EIAC to serializacja tego grafu
Hasło „wszystko jako kod” brzmi jak postulat higieny: trzymajmy konfigurację w plikach, bo tak wygodniej. W ujęciu grafowym znaczy jednak coś mocniejszego.
Z tego wynikają trzy rzeczy, których samo hasło nie mówi.
Repozytorium jest serializacją rzutu przestrzennego. To, co leży w plikach, opisuje zamierzony układ zależności, a uzgadnianie doprowadza do niego rzeczywistość.
Historia repozytorium jest tylko częścią rzutu czasowego. Mówi, jak zmieniała się intencja. Nie mówi, co faktycznie zostało zajęte w świecie, bo to siedzi w rejestrze stanu. Obie rzeczy trzeba trzymać razem, żeby graf był kompletny.
Wszystko, czego nie ma w grafie, jest poza zasięgiem. Bucket utworzony klikaniem w konsoli nie ma obiektu, więc nie ma operacji zwalniającej, więc żadne usuwanie go nie ruszy. Decyzja podjęta na spotkaniu i niezapisana jako fakt nie ma pochodzenia, więc za pół roku nikt nie odtworzy, dlaczego zrobiliśmy tak, a nie inaczej. To ten sam problem, raz o zasobach, raz o faktach.
Warto zauważyć, gdzie w tym modelu leżą polityki. Polityka nie jest obiektem, tylko atrybutem relacji - nie zmienia tego, czy zależność jest spełniona, tylko jak wolno z niej korzystać. Konsekwencja jest bardzo konkretna i wraca w przedostatnim przykładzie.
Stare zasady w precyzyjnym zapisie
Wszystko, co opisałem wyżej, brzmi jak nowa terminologia na stare rzeczy. I bardzo dobrze, bo tak właśnie jest. KISS, SOLID i reguła zależności z clean architecture to zasady, które znamy z pisania kodu od dwudziestu lat, tylko zwykle przekazujemy je jako dobre rady - „rób małe klasy”, „programuj do interfejsu” - a rada jest tym, o co można się spierać w nieskończoność.
W modelu grafowym one przestają być radami. Każda z nich zamienia się we własność grafu, którą da się sprawdzić skryptem i której złamanie ma policzalną cenę. Ta sekcja pokazuje kurs wymiany, osobno dla kodu i osobno dla infrastruktury z kodu, bo w obu przypadkach cena jest inna.
KISS: mierz wielkość grafu, nie liczbę linii
W kodzie KISS zwykle rozumiemy jako „mniej linii, mniej warstw”. W tym modelu miara jest inna i ostrzejsza: liczy się liczba obiektów i relacji, a nie objętość implementacji. Jednostka, która ma sto linii i jedną krawędź, jest prostsza od jednostki, która ma dziesięć linii i cztery krawędzie, bo ta druga uczestniczy w czterech możliwych przeładowaniach.
Stąd konkretny test, który stosuję, zanim wydzielę cokolwiek:
Czy wydzielenie tej jednostki zmniejsza liczbę krawędzi w grafie? Jeśli nie, dokładam węzeł i nic nie upraszczam.
Warstwa, która tylko przekazuje dalej to, co dostała, nie usuwa żadnej relacji - dokłada jeden węzeł i jedną krawędź na każdą przepuszczaną zależność. W kodzie nazywamy to przekładańcem i przeważnie widać to od razu. W infrastrukturze widać rzadziej, bo przekładaniec ma tam postać modułu opakowującego inny moduł i wyglądającego bardzo porządnie.
W infrastrukturze z kodu ten sam test brzmi: czy mój moduł opakowujący dostawcę robi coś więcej niż przepisanie zmiennych? Jeśli nie, to nie jest abstrakcja, tylko dodatkowy węzeł, który trzeba wersjonować, testować i podnosić przy każdej zmianie dostawcy. Prywatna reguła: abstrakcję nad narzędziem buduję dopiero przy trzecim przypadku użycia, bo przy pierwszym nie wiem jeszcze, co jest zmienne, a przy drugim mam dwa punkty i mogę przez nie przeprowadzić dowolną prostą.
KISS jest tu również przeciwwagą dla czegoś, co model wymusza. Rozbijanie prawdziwych cykli na jednostki integrujące podnosi ich liczbę i w gorszym przypadku robi to kwadratowo. Poprawności to nie psuje, ale psuje możliwość ogarnięcia całości przez człowieka, a graf, którego nikt nie rozumie, jest niewiele lepszy od braku grafu. Rozbijamy cykl, który istnieje. Nie rozbijamy zapobiegawczo.
SOLID litera po literze
| Zasada | Czym jest w grafie | Cena złamania |
|---|---|---|
| Pojedyncza odpowiedzialność | rozmiar zbioru dostarcza jednostki | promień rażenia przy wyłączeniu |
| Otwarte-zamknięte | zbiór kontra lista uporządkowana | modyfikacja zamiast dołożenia |
| Podstawialność | nieodróżnialność dwóch dostawców tej samej nazwy | ciche pęknięcie po podmianie |
| Segregacja interfejsów | szerokość publikowanej nazwy | rozmiar zbioru przeładowań |
| Odwrócenie zależności | wiązanie po nazwie zamiast po dostawcy | brak możliwości podmiany |
Pojedyncza odpowiedzialność: liczba nazw, które dostarczasz
Klasyczne sformułowanie mówi o jednym powodzie do zmiany. W grafie ma ono bezpośrednią miarę: ile nazw jednostka dostarcza. Jednostka dostarczająca jedną nazwę ma jedną grupę odbiorców i jeden powód, żeby ktokolwiek na nią zareagował. Jednostka dostarczająca pięć nazw łączy pięć niepowiązanych grup odbiorców we wspólny los, bo wszystkie znikną razem z nią.
To nie jest estetyka, tylko promień rażenia. Wyłączenie jednostki zabiera wszystkie nazwy, które dostarczała, więc do wyłączenia idzie suma konsumentów wszystkich tych nazw naraz - także tych, którzy nigdy nie interesowali się tą częścią, która faktycznie się zmieniła.
W infrastrukturze z kodu ta zasada ma bardzo namacalną postać: jeden moduł to jeden rejestr stanu i jeden promień rażenia. Moduł, który zakłada sieć i bazę danych naraz, wymusza wspólne wyłączanie sieci i bazy, mimo że baza zmienia się co tydzień, a sieć raz na rok. Rozdzielenie ich to nie porządkowanie katalogów, tylko rozdzielenie dwóch bardzo różnych częstotliwości zmian.
Otwarte-zamknięte: zbiór albo łańcuch
To jest dokładnie różnica, którą pokazywałem przy przemienności. Punkt rozszerzeń będący zbiorem - tablica tras, rejestr obsług zdarzeń, lista dostawców w brokerze - jest otwarty na rozszerzenie i zamknięty na modyfikację: dokładasz wpis i nie ruszasz żadnego istniejącego. Punkt rozszerzeń będący listą uporządkowaną jest odwrotnością tej zasady: wstawienie elementu w środek zmienia to, co widzą elementy po nim, więc rozszerzenie jest modyfikacją.
W infrastrukturze z kodu przekłada się to na jedną decyzję: czy nowe środowisko dokładam jako nakładkę, czy dopisuję warunek w module bazowym. Baza plus nakładki to zbiór - dołożenie regionu nie dotyka niczego, co już działa. Moduł z rozgałęzieniem na środowisko to lista uporządkowana przebrana za konfigurację: każde nowe środowisko modyfikuje kod, z którego korzystają wszystkie pozostałe, więc każde niesie ryzyko dla wszystkich.
Podstawialność: to jest nieodróżnialność dostawców
Tutaj model daje coś, czego klasyczne sformułowanie nie ma. Zasada podstawialności mówi, że podtyp musi dać się użyć wszędzie tam, gdzie typ bazowy, i zwykle kończy się na przykładzie z kwadratem i prostokątem. W grafie brzmi to konkretniej:
Dwaj dostawcy tej samej nazwy muszą być nieodróżnialni przez operacje, które ta nazwa publikuje. Jeśli nie są, nazwa jest kłamstwem.
To jest ta sama nieodróżnialność, którą opisywałem przy cofaniu zmian, tylko przyłożona do dwóch dostawców zamiast do dwóch stanów. I to jest dokładnie ten błąd, który opisywałem przy paczkach: nazwa się zgadza, a kontrakt nie. Wymaganie jest formalnie spełnione, jednostka startuje, a pęka dopiero na pierwszym wywołaniu, którego nowy dostawca nie obsługuje tak samo.
Jedynym praktycznym zabezpieczeniem jest wersjonowanie kontraktu nazwy, a nie implementacji - stąd sens wersjonowania semantycznego i konwencjonalnych commitów w rolach dostawców.
W infrastrukturze z kodu: moduł dostarczający nazwę baza-danych raz przez usługę zarządzaną, raz przez Postgresa w klastrze, jest podstawialny tylko wtedy, gdy oba warianty zwracają ten sam komplet wyjść o tym samym znaczeniu. Jeśli jeden zwraca adres, a drugi adres i port osobno, to nie są to dwa warianty jednej nazwy, tylko dwie różne nazwy udające jedną.
Segregacja interfejsów: szerokość nazwy wprost przekłada się na przeładowania
To jest litera, która w tym modelu zyskuje najwięcej, bo dostaje miarę.
Przy podmianie dostawcy przeładowują się dokładnie ci, którzy tę nazwę deklarują - ani jeden więcej. Wynika z tego rzecz, której z samego SOLID nie widać: szerokość publikowanej nazwy jest tym, co decyduje o rozmiarze przeładowania. Jedna gruba nazwa platforma, pod którą siedzi kolejka, baza i sekrety, oznacza, że wymiana sekretów przeładowuje też wszystkich, którzy potrzebowali wyłącznie kolejki.
Jest jeszcze drugi, mniej oczywisty zysk z wąskich nazw, i wraca on do rozdziału o przemienności. Im mniej publikujesz, tym więcej operacji jest przemiennych, bo przemienność jest własnością tego, co interfejs pozwala porównać. Nazwa, która wystawia mniej, daje więcej swobody w kolejności zwalniania - i to jest ta sama zasada widziana od strony czasu, a nie przestrzeni.
W infrastrukturze z kodu: moduł z dwudziestoma wyjściami to gruba nazwa. Każdy, kto bierze z niego cokolwiek, jest sprzężony ze wszystkim, co ten moduł robi. Podział na trzy moduły po pięć wyjść nie jest kosmetyką, tylko trzykrotnym zmniejszeniem obszaru, który trzeba przeliczyć przy zmianie.
Odwrócenie zależności: to nie jest zalecenie, to jest konstrukcja
W kodzie mówimy: zależ od abstrakcji, nie od konkretu. W tym modelu nie ma innej możliwości, bo relacja wymaga z definicji wskazuje na nazwę, a nie na dostawcę. Gdyby wskazywała na dostawcę, cały mechanizm z tego artykułu przestałby działać: nie dałoby się podmienić bazy bez edycji wszystkich korzystających, a widok zatwierdzony nie miałby czego porównywać.
Warto zauważyć, że to jest jedyna litera SOLID, która w tym modelu jest założeniem, a nie własnością do sprawdzenia. Pozostałe cztery można złamać i graf dalej będzie grafem. Tej złamać się nie da, bo bez niej nie ma modelu.
W infrastrukturze z kodu łamiemy ją najczęściej w jeden sposób: moduł aplikacyjny odwołuje się wprost do typu zasobu konkretnego dostawcy chmury zamiast do nazwy, którą ktoś dostarcza. Wtedy zmiana dostawcy nie jest podmianą jednej jednostki, tylko przepisaniem wszystkich konsumentów - a to jest dokładnie ta sytuacja, której zasada miała zapobiec.
Clean architecture: reguła zależności to acykliczność plus decyzja, co jest na dole
Reguła zależności mówi, że zależności mają wskazywać do wewnątrz, ku regułom, które zmieniają się najrzadziej, a szczegóły - baza, framework, dostawca chmury - mają leżeć na zewnątrz i nikt nie ma od nich zależeć.
W tym modelu ta reguła ma dokładnie dwa składniki i warto je rozdzielić, bo mylenie ich jest źródłem większości sporów o architekturę.
Składnik pierwszy jest twierdzeniem. Powiedzieć „istnieje sensowny podział na warstwy” to dokładnie to samo, co powiedzieć „rzut przestrzenny jest acykliczny”. Jedno wynika z drugiego w obie strony: jeśli nie ma cyklu, da się ponumerować jednostki tak, żeby każda zależność szła w dół, a jeśli takie ponumerowanie istnieje, cyklu być nie może. Reguła zależności nie jest więc dodatkowym wymaganiem ponad pierwszy niezmiennik z tego artykułu - jest tym samym niezmiennikiem, powiedzianym innymi słowami.
Składnik drugi jest decyzją projektową i to on jest właściwą treścią clean architecture. Model mówi tylko, że ponumerowanie istnieje. Nie mówi, co zasługuje na to, żeby być na dole. To wybieramy my, i wybieramy według jednego kryterium: na dole leży to, co zmienia się najrzadziej.
Dwie rzeczy warto tu powiedzieć wprost, bo w rozmowach o clean architecture prawie zawsze się mylą.
To, co leży na dole, zależy od tego, co budujemy. W aplikacji na dole leżą reguły biznesowe, a baza danych jest szczegółem na zewnątrz, bo za dziesięć lat reguły będą podobne, a baza pewnie inna. W infrastrukturze na dole leży tożsamość, sieć i rejestr stanu, bo to one zmieniają się raz na lata, a usługi nad nimi co tydzień. To nie jest sprzeczność, tylko to samo kryterium przyłożone do dwóch różnych światów.
Porty i adaptery to nazwa i dostawca. Port to nazwa w rzucie przestrzennym, adapter to jednostka, która ją dostarcza. Nic więcej za tym nie stoi, a mechanizmem, który to spina, jest odwrócenie zależności opisane akapit wyżej. Dlatego „wymiana adaptera” i „podmiana dostawcy bazy” z przykładu w dalszej części tekstu to jedna i ta sama operacja.
I trzecia rzecz, tym razem o granicy. Clean architecture każe trzymać szczegóły na zewnątrz, żeby dało się je wymienić. Model dokłada do tego twardsze rozróżnienie: to, co leży poza granicą grafu, nie jest szczegółem, który da się wymienić, tylko czymś, czego w ogóle nie da się cofnąć. Wysłany mail nie jest adapterem. Warstwy porządkują to, co jest wewnątrz; granica mówi, dokąd sięga sam porządek.
Zapis formalny (opcjonalny)
Reguła zależności. Warstwowanie to funkcja
gdzie
Segregacja interfejsów. Zbiór jednostek przeładowywanych przy podmianie dostawcy nazwy
Rozbicie nazwy
Pojedyncza odpowiedzialność. Promień rażenia jednostki
Podstawialność. Dwaj dostawcy
Jedna tabela do powieszenia nad biurkiem
| Zasada | Sprawdzenie w grafie | Co robić w kodzie | Co robić w infrastrukturze z kodu |
|---|---|---|---|
| KISS | czy wydzielenie zmniejsza liczbę krawędzi | nie wydzielaj warstwy, która tylko przekazuje dalej | abstrakcja nad narzędziem dopiero przy trzecim przypadku |
| Pojedyncza odpowiedzialność | rozmiar dostarcza | jedna klasa, jeden powód zmiany | jeden moduł, jeden rejestr stanu, jedna częstotliwość zmian |
| Otwarte-zamknięte | zbiór czy lista uporządkowana | rejestr wtyczek zamiast łańcucha z pozycjami | nakładki zamiast rozgałęzień w module bazowym |
| Podstawialność | nieodróżnialność dostawców nazwy | testuj kontrakt, nie implementację | ten sam komplet wyjść o tym samym znaczeniu |
| Segregacja interfejsów | rozmiar zbioru przeładowań | wąskie interfejsy zamiast jednego szerokiego | trzy moduły po pięć wyjść zamiast jednego z dwudziestoma |
| Odwrócenie zależności | czy wymaga wskazuje na nazwę | programuj do nazwy, nie do klasy | zależność od nazwy, nie od typu zasobu dostawcy |
| Reguła zależności | acykliczność plus wybór dolnej warstwy | reguły na dole, framework na zewnątrz | tożsamość i sieć na dole, usługi na górze |
Wszystkie te sprawdzenia da się zrobić na deklaracjach, przed uruchomieniem czegokolwiek. To jest cała różnica między zasadą, o którą można się spierać na przeglądzie kodu, a niezmiennikiem, który wywala potok.
Siedem sytuacji z podwórka
Podmiana bazy pod działającą aplikacją
Aplikacja stoi na Postgresie wystawionym przez jedną jednostkę, chcemy przenieść ją na nową instancję bez przestoju.
Jednostka{ id: api-zamowien, wymaga: [baza-danych, kolejka], dostarcza: [api-publiczne] }
Jednostka{ id: raport-dzienny, wymaga: [baza-danych], dostarcza: [] }
Jednostka{ id: postgres-stary, wymaga: [], dostarcza: [baza-danych] }
Jednostka{ id: postgres-nowy, wymaga: [], dostarcza: [baza-danych] }
Relacja wymaga wskazuje na nazwę, a nie na postgres-stary, więc podmiana polega na tym, że nowa jednostka zaczyna dostarczać tę samą nazwę, a stara przestaje. Żadna z jednostek korzystających nie jest tknięta w plikach.
Niuans decydujący o poprawności siedzi w atrybucie widok zatwierdzony: jednostka zapamiętuje tożsamość dostawcy, a nie wartość. Gdyby porównywała wartości, podmiana na inną instancję pod tym samym adresem byłaby niewidoczna i nikt nie zostałby przeładowany, mimo że po drugiej stronie jest już co innego.
Przebieg wygląda tak: uruchamiamy postgres-nowy; postgres-stary wchodzi w fazę pierwszą i przestaje dostarczać, więc obie jednostki korzystające tracą spełnienie i idą do wyłączenia, a potem od razu do ponownego uruchomienia wobec nowego dostawcy; postgres-stary czeka na fazę drugą, aż nikt na niego nie wskazuje, i przez cały ten czas obie wciąż widzą stare połączenie, więc mogą je porządnie zamknąć.
Efekt: przeładowane zostały wyłącznie te jednostki, których zależność faktycznie się zmieniła. To różnica między „przeładuj wszystko na wszelki wypadek” a modelem, w którym wiadomo, kto na czym stoi.
Przestrzeń nazw, która wisi w usuwaniu
Historia z pierwszego akapitu. To faza druga zablokowana na warunku wstrzymującym: coś trzyma relację wskazującą na obiekt idący do usunięcia i ta relacja nie znika.
Warunek jest bezpieczny pod jednym założeniem: że każdy, kto trzyma relację, sam jest już w drodze do wyłączenia. Wiszenie oznacza złamanie tego założenia, a przyczyny są zwykle trzy: blokadę trzyma operator, który już nie działa; blokada czeka na zasób w zewnętrznym systemie, który nie odpowiada; blokada odwołuje się do czegoś usuniętego inną drogą.
Diagnostycznie: wypisz, kto trzyma relację, i sprawdź, czy ten ktoś w ogóle żyje. Projektowo: w każdym własnym operatorze zakładającym blokadę dołóż limit czasu i ścieżkę awaryjną, bo warunek wstrzymujący bez limitu jest bezpieczny wyłącznie w modelu, a nie w świecie, w którym procesy giną.
Czego nie cofnie usunięcie infrastruktury
Rejestr stanu jest listą zasobów razem z operacjami zwalniającymi. Usuwanie przechodzi po tej liście, a wszystko, czego na liście nie ma, jest poza grafem.
| Co zostaje | Dlaczego nie ma tego w grafie |
|---|---|
| dane pobrane z bucketu przed usunięciem | to było wysłanie, przekroczyło granicę |
| zasoby utworzone „na chwilę” w konsoli | nigdy nie powstał obiekt, więc nie ma operacji zwalniającej |
| zasoby po module usuniętym przy ręcznie poprawionym rejestrze | relacja została zerwana ręcznie |
Poprawianie rejestru stanu z ręki nie jest obejściem problemu, tylko usunięciem relacji z grafu przy pozostawieniu obiektu w świecie. To jest definicja wycieku i to jest mój rachunek za bucket.
Wnioski są trzy: traktować rejestr stanu jak część grafu (zdalnie, z blokadą, z historią); prowadzić jawną listę rzeczy świadomie trzymanych poza grafem; przy każdym wysłaniu o znaczeniu biznesowym projektować kompensację razem z operacją, a nie dopiero wtedy, gdy trzeba się wycofać.
Radar trendów jako graf faktów
Radar zbiera sygnały, ocenia je modelem językowym i wypycha wyniki dalej. Po miesiącu pada pytanie: dlaczego ten sygnał dostał wtedy wysoką ocenę, a teraz niską.
To czysty przypadek klasy Fakt.
Fakt{ id: ocena-771-2026-08-24, wartość: 4,
zastępuje: ocena-771-2026-07-11,
pochodzi z: przebieg-1183 }
Przebieg{ id: przebieg-1183, jednostka: scoring-llm,
wejście: sygnał-771, skrót wejścia: 9f2c...,
wersja promptu: v4, model: nazwa-i-wersja }
Nadpisanie oceny w miejscu niszczy obie relacje naraz: znika historia i znika pochodzenie. Wtedy na pytanie „dlaczego się zmieniło” nie ma odpowiedzi, bo w bazie jest wyłącznie stan bieżący.
Korzyść ujawnia się po kilku miesiącach: mając w przebiegu wersję promptu i skrót wejścia, da się rozstrzygnąć, czy oceny zmieniły się dlatego, że zmienił się świat, czy dlatego, że zmieniliśmy prompt. Bez tych dwóch pól pytanie jest nierozstrzygalne, a to jest różnica między systemem oceniającym a generatorem opinii.
Zmiana polityki bez przeładowania
Chcemy zawęzić uprawnienia jednej jednostki do bazy do samego odczytu, nie restartując niczego. Wszystko zależy od tego, gdzie umieściliśmy politykę.
Wariant zły, polityka jako obiekt:
Jednostka{ id: komponent, wymaga: [silnik-polityk, baza-danych] }
Zmiana reguł zmienia to, co dostarcza silnik-polityk, więc dla komponent zmienia się zależność, więc komponent przechodzi pełny cykl wyłączenia i uruchomienia. Zmiana jednej linijki w regule kosztuje restart.
Wariant dobry, polityka jako atrybut relacji:
Jednostka{ id: komponent, wymaga: [baza-danych],
polityki: { baza-danych: { tryb: tylko-odczyt } } }
Reguła jest etykietą na relacji, konsultowaną w momencie skorzystania z zależności. Nie zmienia tego, czy zależność jest spełniona - graf pozostaje ten sam - tylko jak wolno z niej korzystać. Dzięki temu można ją zakładać, zmieniać i zdejmować bez przeładowania czegokolwiek i bez ruszania układu zależności.
Jest jeszcze jedna zaleta, mniej oczywista: skoro etykieta siedzi na relacji, a nie w kodzie żadnej ze stron, to zespół platformowy może zawęzić uprawnienia jednostki bez modyfikowania ani jej, ani dostawcy. Dokładnie tego oczekujemy od reguł dopuszczenia w klastrze.
Przy projektowaniu każdej reguły warto więc zadać jedno pytanie: czy to zmienia, co jednostka dostaje, czy jak wolno jej z tego korzystać. Pierwsze jest obiektem, drugie atrybutem relacji, a domyślnie należy celować w drugie.
Paczka umiejętności jako podgraf
Instalacja paczki to dołożenie podgrafu: kilku jednostek, kilku zasobów, kilku relacji dostarcza. Wypięcie to przejście po operacjach zwalniających tego podgrafu.
Pytanie, które przy paczkach zwykle w ogóle nie pada: jaka jest odwrotność instalacji tej paczki? Nie „czy da się odinstalować”, tylko: czy odinstalowanie przywraca projekt do stanu sprzed instalacji, i czy to jest zapisane, czy zależy od pamięci autora.
Drugi problem jest subtelniejszy. Wiązanie idzie po nazwie, więc może się zdarzyć, że nazwa się zgadza, a kontrakt nie (dostawca zmienił zachowanie między wersjami, wymaganie jest formalnie spełnione, a wartość nie odpowiada oczekiwaniom) albo że nazwa zgadza się przypadkiem (dwie niezależne paczki użyły tej samej nazwy na różne rzeczy). Lekarstwa: przestrzeniowanie nazw, test „zainstaluj, odinstaluj, porównaj drzewo” i traktowanie niezgodności wersji jako stanu nieaktywnego, a nie błędu.
Agent podmieniający własną jednostkę
Przepływ z agentem, który potrafi wygenerować i wpiąć nową wersję jednego ze swoich narzędzi w trakcie pracy. Nieudana podmiana nie może zabić całości.
To złożenie wszystkiego powyżej. Podmiana narzędzia jest podmianą dostawcy nazwy, więc przeładowani zostaną wyłącznie agenci mający relację wymaga do tej nazwy. Nieudana podmiana jest awarią: nowa jednostka wycofuje to, co zdążyła zająć, ląduje jako nieaktywna z zapisanym błędem, a błąd zostaje na niej. Agenci, którzy z niej korzystali, siedzą nieaktywni i czekają, zamiast się wywracać.
Najważniejszy wniosek tego przykładu dotyczy jednak czegoś innego. Trzeba rozdzielić dwie rzeczy, które w typowym przepływie leżą razem:
| Co | Klasa | Co się z tym dzieje przy podmianie |
|---|---|---|
| otwarte połączenia, zajęte sesje, zarezerwowany budżet, zarejestrowane obsługi zdarzeń | Zasób | znika razem z jednostką |
| zebrane fakty, wyniki cząstkowe, decyzje, to, czego już próbowano | Fakt | musi przeżyć jednostkę |
Stąd reguła, którą warto powiesić nad każdym przepływem z agentem:
Pamięć agenta nie może mieszkać w agencie.
Jeżeli wyniki pracy leżą w kontekście agenta albo w jego stanie wewnętrznym, to każda podmiana i każdy restart zaczyna od zera - a przy jednostkach podmienianych automatycznie oznacza to system, który nigdy niczego nie kończy. Reszta wynika z tego wprost: dwa jawnie rozdzielone magazyny, uprawnienia jako deklaracja zatwierdzana przy wpięciu zamiast pytania w locie (skoro wymaga jest statyczne, pełen zestaw jest znany przed uruchomieniem), limit kroków i limit czasu na każdą podmianę, oraz rozdzielenie uprawnień odczytu od zapisu - bo odczyt jest wewnątrz granicy i jest odwracalny, a zapis bywa wysłaniem. Szerzej o tym w tekście o czterech poziomach agentowego wytwarzania.
Dwanaście pytań, które warto zadać każdemu narzędziu
Zestaw, który daje odpowiedź merytoryczną zamiast marketingowej. Cztery grupy po trzy pytania, ułożone według klas modelu.
Klasa Zasób: czy da się cofnąć
- Czy każda operacja, którą narzędzie wykonuje, ma operację zwalniającą, czy tylko część?
- Gdzie leży rejestr tych operacji i co się dzieje, gdy go stracisz?
- Czy cofnięcie jest wymuszone strukturą, czy jest obowiązkiem autora, o którym można zapomnieć?
Rzut przestrzenny: czy da się połączyć
- Czy zależności są deklarowane, czy wyszukiwane doraźnie w globalnym rejestrze?
- Co się dzieje, gdy dostawca zniknie w trakcie działania: błąd, cicha pustka, czy uporządkowane wyłączenie?
- Czy da się podmienić dostawcę bez restartu korzystającego?
Klasa Fakt: czy pamięta
- Co przeżywa restart, a co znika razem z jednostką, i czy te dwie rzeczy są trzymane osobno?
- Czy zapisane wyniki mają pochodzenie i wersję, czy są nadpisywane w miejscu?
- Czy da się odtworzyć, dlaczego wynik jest taki, a nie inny?
Cykl życia: czy nadaje się do systemu zmieniającego się w trakcie
- Czy da się przerwać długą operację w połowie i z jaką ziarnistością?
- Czy zbiór uprawnień jest znany i przejrzalny przed uruchomieniem?
- Czy narzędzie odróżnia politykę jako atrybut relacji od zależności jako obiektu?
Od czego zacząć u siebie
Zasada nadrzędna, ważniejsza niż kolejność: dokładaj kolejną warstwę wyłącznie wtedy, gdy rozumiesz tryb awarii obecnej i nowa warstwa adresuje dokładnie ten tryb awarii. Jeśli tryb awarii jest niejasny, najpierw zmierz.
| Krok | Co zrobić | Jaki problem to rozwiązuje |
|---|---|---|
| Inwentaryzacja | wypisać jednostki oraz ich wymaga i dostarcza, sprawdzić acykliczność | nikt nie wie, co na czym stoi |
| Bilans zasobów | wypisać zasoby bez operacji zwalniającej | ciche wycieki |
| Rozdzielenie magazynów | oddzielić magazyn zasobów od magazynu faktów | amnezja po restarcie albo wyciek |
| Pochodzenie | dołożyć pochodzenie i wersjonowanie do zapisu faktów | nie da się wyjaśnić zmiany wyniku |
| Polityki na relacje | przenieść reguły z obiektów na atrybuty relacji | zmiana polityki kosztuje restart |
| Wyłączanie dwufazowe | rozdzielić wyłączanie na dwie fazy, z warunkiem i limitem czasu | zależności wyrywane spod działających jednostek |
Rozdzielenie magazynów daje najwięcej i najtrudniej dołożyć je później, więc jeśli miałbym wybrać jeden krok, wybrałbym ten.
Graf nie wymaga bazy grafowej - na początek wystarczą pliki obok kodu:
# rzut przestrzenny
jednostka: api-zamowien
wymaga: [baza-danych, kolejka-zdarzen]
dostarcza: [api-publiczne]
polityki:
baza-danych: { tryb: odczyt-zapis }
kolejka-zdarzen: { tryb: tylko-publikacja }
# rzut czasowy, rzeczy znikające
zasoby:
- id: bucket-raportow
zajety_przez: raport-dzienny
cofa: usun-bucket
- id: webhook-do-crm
zajety_przez: api-zamowien
cofa: null # świadomie poza grafem, wymaga przeglądu
I trzy sprawdzenia do wpięcia w potok pierwszego dnia: sortowanie topologiczne po wymaga i dostarcza (czy nie ma cyklu), wypisanie zasobów z cofa: null (czy lista się nie wydłużyła względem poprzedniego zatwierdzenia), odrzucenie zapisu faktu bez identyfikatora przebiegu (czy każdy fakt ma pochodzenie). Kilkadziesiąt linii kodu i graf zaczyna pilnować się sam.
Test gotowości
System jest grafem czterech klas obiektów. Relacje wymaga i dostarcza mówią, jak jest zbudowany teraz; relacje cofa, zastępuje i pochodzi z mówią, jak do tego doszedł i jak może się wycofać. Zasób odwraca się przez uruchomienie operacji zwalniającej, fakt przez dopisanie nowszej wersji, a mylenie tych dwóch mechanizmów jest najkosztowniejszym błędem w tym obszarze. Wysłanie poza granicę nie odwraca się wcale, więc trzeba je albo wstrzymać, albo skompensować - i wiedzieć, że to nie to samo.
Test jest jeden i nadaje się do powieszenia nad biurkiem:
Każdy istotny wynik da się powiązać z jednostką, która go wytworzyła, z zasobami, które przy tym zajęła, ze źródłem, z którego pochodzi, i z przebiegiem, w którym powstał.
Kiedy to zdanie jest prawdziwe, automatyzacja jest bezpieczna, bo każdą zmianę da się cofnąć albo wytłumaczyć. Kiedy jest fałszywe, każda automatyzacja tylko przyspiesza tempo, w jakim tracimy kontrolę. A po czterdziestu minutach gapienia się w Terminating i jednym rachunku za bucket, którego nie było w stanie, mogę powiedzieć, że wolę wersję pierwszą.