# Architektura czasoprzestrzenna: system jako jeden graf

> Przestrzeń nazw wisiała w Terminating czterdzieści minut, a `terraform destroy` zostawił bucket z danymi. To nie są dwie różne wpadki, tylko dwa rzuty tego samego grafu: jeden mówi, kto na czym stoi, drugi - co da się jeszcze cofnąć. Model obiektowy, sześć schematów i siedem przykładów z podwórka.

URL: https://eiac.dev/blog/architektura-czasoprzestrzenna
Filar: App-as-Code
Data: 2026-08-24
Tagi: architektura, graf, gitops, odwracalnosc, zaleznosci, agenci, platform-engineering, solid, kiss, clean-architecture

---

## 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ń.

<div class="callout">
<strong>Teza</strong>
<p>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 <em>przestrzenny</em>. Biorąc pod uwagę relacje cofnięcia, zastąpienia i pochodzenia, widzimy historię i to, co da się jeszcze odwrócić - to rzut <em>czasowy</em>. 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ć.</p>
</div>

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.

<figure>
<svg viewBox="0 0 640 296" role="img" aria-label="Jeden graf obiektów i dwa jego rzuty. Rzut przestrzenny pokazuje jednostkę A wymagającą czegoś od jednostki B i odpowiada na pytanie, kto na czym stoi teraz. Rzut czasowy pokazuje wersję drugą zastępującą wersję pierwszą i odpowiada na pytanie, jak system doszedł do obecnego stanu i jak może się wycofać.">
  <g font-family="'Poppins', system-ui, sans-serif" text-anchor="middle">
    <rect x="200" y="12" width="240" height="54" rx="6" fill="none" stroke="var(--color-rust)" stroke-width="1.5"/>
    <text x="320" y="36" fill="var(--color-rust)" font-size="14">jeden graf obiektów</text>
    <text x="320" y="55" fill="var(--color-muted)" font-size="10" font-family="'JetBrains Mono Variable', monospace">4 klasy &#183; 5 relacji</text>
    <line x1="270" y1="70" x2="176" y2="112" stroke="currentColor" stroke-width="1.3"/>
    <path d="M172 114 l7 -1 l-3 -6 z" fill="currentColor"/>
    <line x1="370" y1="70" x2="464" y2="112" stroke="currentColor" stroke-width="1.3"/>
    <path d="M468 114 l-7 -1 l3 -6 z" fill="currentColor"/>
    <text x="176" y="88" fill="var(--color-muted)" font-size="11">rzut przestrzenny</text>
    <text x="470" y="88" fill="var(--color-muted)" font-size="11">rzut czasowy</text>
    <rect x="12" y="122" width="296" height="160" rx="8" fill="none" stroke="var(--color-muted)" stroke-width="1" stroke-dasharray="4 4"/>
    <rect x="34" y="146" width="104" height="34" rx="5" fill="none" stroke="currentColor" stroke-width="1.4"/>
    <text x="86" y="168" fill="currentColor" font-size="12">jednostka A</text>
    <rect x="182" y="146" width="104" height="34" rx="5" fill="none" stroke="currentColor" stroke-width="1.4"/>
    <text x="234" y="168" fill="currentColor" font-size="12">jednostka B</text>
    <line x1="138" y1="163" x2="174" y2="163" stroke="var(--color-rust)" stroke-width="1.5"/>
    <path d="M178 163 l-7 -4 v8 z" fill="var(--color-rust)"/>
    <text x="158" y="155" fill="var(--color-rust)" font-size="9" font-family="'JetBrains Mono Variable', monospace">wymaga</text>
    <text x="160" y="216" fill="currentColor" font-size="12">kto na czym stoi teraz</text>
    <text x="160" y="238" fill="var(--color-muted)" font-size="11">musi być acykliczny</text>
    <text x="160" y="262" fill="var(--color-muted)" font-size="10" font-family="'JetBrains Mono Variable', monospace">wymaga &#183; dostarcza</text>
    <rect x="332" y="122" width="296" height="160" rx="8" fill="none" stroke="var(--color-muted)" stroke-width="1" stroke-dasharray="4 4"/>
    <rect x="356" y="146" width="94" height="34" rx="5" fill="none" stroke="currentColor" stroke-width="1.4"/>
    <text x="403" y="168" fill="currentColor" font-size="12">wersja 1</text>
    <rect x="502" y="146" width="94" height="34" rx="5" fill="none" stroke="currentColor" stroke-width="1.4"/>
    <text x="549" y="168" fill="currentColor" font-size="12">wersja 2</text>
    <line x1="498" y1="163" x2="458" y2="163" stroke="var(--color-rust)" stroke-width="1.5"/>
    <path d="M454 163 l7 -4 v8 z" fill="var(--color-rust)"/>
    <text x="470" y="155" fill="var(--color-rust)" font-size="9" font-family="'JetBrains Mono Variable', monospace">zastępuje</text>
    <text x="480" y="216" fill="currentColor" font-size="12">jak system do tego doszedł</text>
    <text x="480" y="238" fill="var(--color-muted)" font-size="11">i co da się jeszcze cofnąć</text>
    <text x="480" y="262" fill="var(--color-muted)" font-size="10" font-family="'JetBrains Mono Variable', monospace">cofa &#183; zastępuje &#183; pochodzi z</text>
  </g>
</svg>
<figcaption>Ten sam graf obiektów można obejrzeć na dwa sposoby. Zawężenie do relacji zależności pokazuje architekturę taką, jaka jest w tej chwili. Zawężenie do relacji cofnięcia, zastąpienia i pochodzenia pokazuje historię systemu i to, co jeszcze da się odwrócić.</figcaption>
</figure>

## 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.

<div class="callout">

**Zapis formalny (opcjonalny)**

Model to graf typowany $G = (V, E, \tau, \lambda)$, gdzie $\tau$ przypisuje węzłowi klasę, a $\lambda$ krawędzi typ relacji. Węzły rozpadają się na rozłączne klasy $V = J \uplus Z \uplus F \uplus P$. Rzut to zawężenie krawędzi do wybranych typów:

$$ G|_L = \bigl(V,\ \{\, e \in E : \lambda(e) \in L \,\}\bigr) $$

gdzie $L_s$ to zbiór relacji `wymaga` i `dostarcza`, co daje rzut przestrzenny, a $L_t$ to zbiór relacji `cofa`, `zastępuje` i `pochodzi z`, co daje rzut czasowy. Oba operują na tym samym $V$, dlatego pytania mieszane są wyrażalne bez łączenia dwóch osobnych struktur.

Zależności trzymamy jako częściową funkcję zależnego typu $\Sigma = (k : K) \rightharpoonup V_k$. Jednostka $n$ deklaruje $d_n \subseteq K$ oraz $p_n \subseteq K$. Stan wspólny nie jest przechowywany, tylko wyliczany:

$$ \sigma \;=\; \bigcup \{\, \sigma_m : m \text{ w stanie aktywna} \,\} $$

co jest funkcją dobrze określoną przy $m \neq m' \Rightarrow p_m \cap p_{m'} = \varnothing$ oraz $\operatorname{dom}(\sigma_m) \subseteq p_m$. Stąd każda nazwa ma co najwyżej jednego dostawcę.

</div>

## 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ć.

<figure>
<svg viewBox="0 0 640 268" role="img" aria-label="Wyłączanie w dwóch fazach. W pierwszej fazie dostawca przechodzi ze stanu aktywna do stanu wyłączana i przestaje dostarczać, ale niczego nie zwalnia. Jego odbiorca traci spełnienie i zaczyna sprzątać, przez cały czas widząc zależność. Dopiero gdy odbiorca jest nieaktywny, zwalnia się warunek wstrzymujący i dostawca wchodzi w fazę drugą, w której zwalnia zasoby.">
  <g font-family="'Poppins', system-ui, sans-serif" text-anchor="middle">
    <text x="42" y="34" fill="var(--color-muted)" font-size="11" text-anchor="start">dostawca</text>
    <rect x="112" y="14" width="116" height="34" rx="5" fill="none" stroke="currentColor" stroke-width="1.4"/>
    <text x="170" y="36" fill="currentColor" font-size="11">aktywna</text>
    <rect x="256" y="14" width="164" height="34" rx="5" fill="none" stroke="var(--color-rust)" stroke-width="1.8"/>
    <text x="338" y="36" fill="var(--color-rust)" font-size="11">wyłączana, nie dostarcza</text>
    <rect x="470" y="14" width="116" height="34" rx="5" fill="none" stroke="currentColor" stroke-width="1.4"/>
    <text x="528" y="36" fill="currentColor" font-size="11">nieaktywna</text>
    <line x1="228" y1="31" x2="248" y2="31" stroke="currentColor" stroke-width="1.4"/>
    <path d="M252 31 l-7 -4 v8 z" fill="currentColor"/>
    <line x1="420" y1="31" x2="462" y2="31" stroke="currentColor" stroke-width="1.4"/>
    <path d="M466 31 l-7 -4 v8 z" fill="currentColor"/>
    <text x="242" y="64" fill="var(--color-muted)" font-size="10" font-family="'JetBrains Mono Variable', monospace">faza 1</text>
    <text x="470" y="64" fill="var(--color-muted)" font-size="10" font-family="'JetBrains Mono Variable', monospace">faza 2</text>
    <text x="42" y="134" fill="var(--color-muted)" font-size="11" text-anchor="start">odbiorca</text>
    <rect x="112" y="114" width="116" height="34" rx="5" fill="none" stroke="currentColor" stroke-width="1.4"/>
    <text x="170" y="136" fill="currentColor" font-size="11">aktywny</text>
    <rect x="256" y="114" width="164" height="34" rx="5" fill="none" stroke="currentColor" stroke-width="1.4"/>
    <text x="338" y="136" fill="currentColor" font-size="11">sprząta, wciąż widzi</text>
    <rect x="470" y="114" width="116" height="34" rx="5" fill="none" stroke="currentColor" stroke-width="1.4"/>
    <text x="528" y="136" fill="currentColor" font-size="11">nieaktywny</text>
    <line x1="228" y1="131" x2="248" y2="131" stroke="currentColor" stroke-width="1.4"/>
    <path d="M252 131 l-7 -4 v8 z" fill="currentColor"/>
    <line x1="420" y1="131" x2="462" y2="131" stroke="currentColor" stroke-width="1.4"/>
    <path d="M466 131 l-7 -4 v8 z" fill="currentColor"/>
    <path d="M528 152 L528 172 L444 172 L444 58" fill="none" stroke="var(--color-rust)" stroke-width="1.4" stroke-dasharray="5 3"/>
    <path d="M444 54 l-4 7 h8 z" fill="var(--color-rust)"/>
    <text x="320" y="196" fill="var(--color-rust)" font-size="11">warunek wstrzymujący: faza druga czeka, aż nikt już nie wskazuje na dostawcę</text>
    <rect x="112" y="214" width="474" height="38" rx="6" fill="none" stroke="var(--color-muted)" stroke-width="1" stroke-dasharray="4 4"/>
    <text x="349" y="238" fill="currentColor" font-size="11">przez cały ten czas odbiorca wciąż czyta zależność, którą oddaje</text>
  </g>
</svg>
<figcaption>Dostawca najpierw wyłącznie odnotowuje decyzję o wyjściu i przestaje być widoczny dla nowych chętnych, ale niczego jeszcze nie zwalnia. Jego dotychczasowi odbiorcy tracą przez to spełnienie i sami zaczynają sprzątać, cały czas korzystając z zależności, która za chwilę zniknie. Dopiero gdy żaden z nich już na niego nie wskazuje, dostawca zwalnia swoje zasoby.</figcaption>
</figure>

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](https://kubernetes.io/docs/concepts/overview/working-with-objects/finalizers/). 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.

<div class="callout">

**Zapis formalny (opcjonalny)**

Jednostka $n$ może wystartować w stanie $\sigma$ wtedy i tylko wtedy, gdy $\sigma \models d_n$, czyli $\forall k \in d_n.\; k \in \operatorname{dom}(\sigma)$. Warunek jest rozstrzygalny, bo dziedzina jest skończona. Każdą zmianę $\sigma \to \sigma'$ klasyfikujemy wobec $d$ jako aktywującą, gdy $\sigma \not\models d \wedge \sigma' \models d$; dezaktywującą, gdy $\sigma \models d \wedge \sigma' \not\models d$; obojętną w pozostałych przypadkach. Ponieważ każda mutacja przechodzi przez operację zapisu, *każda zmiana jest z definicji obserwowana* i nie trzeba osobnego mechanizmu powiadamiania ani odpytywania w pętli.

Faza druga wyłączania wymaga $\neg\,\mathrm{polega}(n)$, gdzie

$$ \mathrm{polega}(n) \iff \exists\, m \neq n,\ k \in d_m .\; m \text{ zainstalowana} \wedge \omega_m(k) = n $$

a $\omega_m$ to widok zatwierdzony jednostki $m$. Warunek zwalnia się, bo po fazie pierwszej $n$ nie należy już do sumy definiującej $\sigma$, więc żaden nowy widok docelowy nie może na nią wskazać, a każde istniejące $\omega_m(k) = n$ należy do jednostki, która właśnie utraciła spełnienie. Przy acyklicznej relacji poprzedzania $n \prec m \iff p_n \cap d_m \neq \varnothing$ indukcja po niej daje osiągnięcie stanu spoczynku. Dowód zakłada, że każda jednostka trzymająca widok faktycznie działa - w rzeczywistości procesy giną, więc implementacja potrzebuje limitu czasu, którego model nie wymaga.

</div>

## 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.

<figure>
<svg viewBox="0 0 640 228" role="img" aria-label="Porównanie zbioru i listy uporządkowanej. W zbiorze trzy wpisy są podpięte niezależnie do wspólnej tablicy, więc wyjęcie jednego nie rusza pozostałych. W liście uporządkowanej trzy etapy tworzą łańcuch, więc wyjęcie środkowego zmienia to, co widzi ostatni.">
  <g font-family="'Poppins', system-ui, sans-serif" text-anchor="middle">
    <text x="12" y="20" fill="currentColor" font-size="13" text-anchor="start">zbiór: przemienny</text>
    <rect x="12" y="32" width="296" height="150" rx="8" fill="none" stroke="var(--color-muted)" stroke-width="1" stroke-dasharray="4 4"/>
    <rect x="112" y="52" width="96" height="32" rx="5" fill="none" stroke="currentColor" stroke-width="1.4"/>
    <text x="160" y="73" fill="currentColor" font-size="11">tablica</text>
    <rect x="30" y="130" width="72" height="30" rx="5" fill="none" stroke="currentColor" stroke-width="1.4"/>
    <text x="66" y="150" fill="currentColor" font-size="11">wpis A</text>
    <rect x="124" y="130" width="72" height="30" rx="5" fill="none" stroke="currentColor" stroke-width="1.4"/>
    <text x="160" y="150" fill="currentColor" font-size="11">wpis B</text>
    <rect x="218" y="130" width="72" height="30" rx="5" fill="none" stroke="var(--color-rust)" stroke-width="1.8" stroke-dasharray="4 3"/>
    <text x="254" y="150" fill="var(--color-rust)" font-size="11">wpis C</text>
    <line x1="66" y1="128" x2="132" y2="88" stroke="currentColor" stroke-width="1.1"/>
    <line x1="160" y1="128" x2="160" y2="88" stroke="currentColor" stroke-width="1.1"/>
    <line x1="254" y1="128" x2="188" y2="88" stroke="currentColor" stroke-width="1.1"/>
    <text x="160" y="176" fill="var(--color-muted)" font-size="10" font-family="'JetBrains Mono Variable', monospace">wyjęcie C nie rusza A ani B</text>
    <text x="332" y="20" fill="currentColor" font-size="13" text-anchor="start">lista uporządkowana: nieprzemienna</text>
    <rect x="332" y="32" width="296" height="150" rx="8" fill="none" stroke="var(--color-muted)" stroke-width="1" stroke-dasharray="4 4"/>
    <rect x="352" y="90" width="72" height="34" rx="5" fill="none" stroke="currentColor" stroke-width="1.4"/>
    <text x="388" y="112" fill="currentColor" font-size="11">etap A</text>
    <rect x="444" y="90" width="72" height="34" rx="5" fill="none" stroke="var(--color-rust)" stroke-width="1.8" stroke-dasharray="4 3"/>
    <text x="480" y="112" fill="var(--color-rust)" font-size="11">etap B</text>
    <rect x="536" y="90" width="72" height="34" rx="5" fill="none" stroke="currentColor" stroke-width="1.4"/>
    <text x="572" y="112" fill="currentColor" font-size="11">etap C</text>
    <line x1="424" y1="107" x2="436" y2="107" stroke="currentColor" stroke-width="1.4"/>
    <path d="M440 107 l-7 -4 v8 z" fill="currentColor"/>
    <line x1="516" y1="107" x2="528" y2="107" stroke="currentColor" stroke-width="1.4"/>
    <path d="M532 107 l-7 -4 v8 z" fill="currentColor"/>
    <text x="480" y="176" fill="var(--color-muted)" font-size="10" font-family="'JetBrains Mono Variable', monospace">wyjęcie B zmienia to, co widzi C</text>
  </g>
</svg>
<figcaption>Rejestracja wpisu w tablicy jest przemienna, więc dowolny wpis można wyjąć niezależnie od pozostałych i kolejność zwalniania nie ma znaczenia. Wstawienie etapu do uporządkowanego łańcucha przetwarzania przemienne nie jest, bo etap wstawiony wcześniej zmienia to, co widzą etapy po nim.</figcaption>
</figure>

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.

<div class="callout">

**Zapis formalny (opcjonalny)**

Zmianę modelujemy jako parę $(f, g)$, gdzie $f : \Gamma \to \Gamma$ przekształca kontekst, a $g$ go cofa. Pary tworzą monoid ze splecionym mnożeniem

$$ (f_1, g_1) \circ (f_2, g_2) \;=\; (f_1 \circ f_2,\; g_2 \circ g_1) $$

co jest formalnym zapisem reguły „ostatnie zajęte, pierwsze zwolnione”. Zbiorcze cofnięcie po ciągu zmian to $\varphi = g_1 \circ g_2 \circ \cdots \circ g_n$, a niezmiennik poprawności stanu brzmi $\varphi(\gamma) = \gamma_0$.

Cofnięcie zwracane jest w miejscu użycia, więc właściwy typ operacji to $\mathfrak{E} = \Gamma \to \Gamma \times (\Gamma \to \Gamma)$, a warunek świadka dla $(\delta, g) = e(\gamma)$ brzmi $g(\delta) \simeq \gamma$ - jest *lokalny*, nie żąda $g \circ f = \mathrm{id}$ wszędzie. Warunek ten zachowuje się przy składaniu, więc cofnięcie pisze się raz dla operacji atomowej, a cofnięcie dowolnego złożenia z niego wynika.

Niech $\mathfrak{M}(e)$ będzie monoidem przekształceń generowanym przez przekształcenie w przód i wszystkie zwracane cofnięcia. Operacje $e_1, e_2$ są niezależne, gdy

$$ \forall f \in \mathfrak{M}(e_1),\ g \in \mathfrak{M}(e_2).\quad f \circ g = g \circ f $$

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 $\simeq$, definiowanej przez to, co widać przez operacje udostępniane pod nazwami. Wszystko, czego żadna nazwa nie wiąże, leży poza relacją. Stąd wniosek z akapitu wyżej: *czy dwie operacje są przemienne, zależy od tego, co publikuje interfejs*. Jeśli uchwyty wydawane przez zarządcę zasobów nie są przez żadną operację porównywane, dwa przydziały w dowolnej kolejności dają stany nieodróżnialne; jeśli adresy są wynikiem porównywanym przez równość, żadna sensowna definicja $\simeq$ tego nie uratuje.

</div>

## 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.

<figure>
<svg viewBox="0 0 640 244" role="img" aria-label="Granica modelu. Wewnątrz jednostka zajmuje zasób i ma operację, która go zwalnia, więc zajęcie jest odwracalne. Strzałka wychodząca poza granicę oznacza wysłanie danych do świata i nie ma strzałki powrotnej, bo wysłania nie da się cofnąć.">
  <g font-family="'Poppins', system-ui, sans-serif" text-anchor="middle">
    <rect x="12" y="24" width="342" height="180" rx="10" fill="none" stroke="currentColor" stroke-width="1.8"/>
    <text x="32" y="48" fill="currentColor" font-size="12" text-anchor="start">wnętrze grafu</text>
    <text x="32" y="66" fill="var(--color-muted)" font-size="10" text-anchor="start" font-family="'JetBrains Mono Variable', monospace">śledzone, odwracalne</text>
    <rect x="46" y="92" width="118" height="38" rx="5" fill="none" stroke="currentColor" stroke-width="1.4"/>
    <text x="105" y="116" fill="currentColor" font-size="12">jednostka</text>
    <rect x="212" y="92" width="118" height="38" rx="5" fill="none" stroke="currentColor" stroke-width="1.4"/>
    <text x="271" y="116" fill="currentColor" font-size="12">zasób</text>
    <line x1="164" y1="104" x2="204" y2="104" stroke="currentColor" stroke-width="1.4"/>
    <path d="M208 104 l-7 -4 v8 z" fill="currentColor"/>
    <text x="186" y="97" fill="var(--color-muted)" font-size="9" font-family="'JetBrains Mono Variable', monospace">zajmuje</text>
    <line x1="212" y1="120" x2="172" y2="120" stroke="var(--color-rust)" stroke-width="1.5"/>
    <path d="M168 120 l7 -4 v8 z" fill="var(--color-rust)"/>
    <text x="190" y="140" fill="var(--color-rust)" font-size="9" font-family="'JetBrains Mono Variable', monospace">cofa</text>
    <text x="183" y="180" fill="var(--color-muted)" font-size="10">otwarcie pliku, bucket, połączenie, proces</text>
    <rect x="424" y="24" width="204" height="180" rx="10" fill="none" stroke="var(--color-muted)" stroke-width="1" stroke-dasharray="6 4"/>
    <text x="444" y="48" fill="var(--color-muted)" font-size="12" text-anchor="start">świat</text>
    <text x="444" y="66" fill="var(--color-muted)" font-size="10" text-anchor="start" font-family="'JetBrains Mono Variable', monospace">poza grafem</text>
    <line x1="354" y1="111" x2="412" y2="111" stroke="var(--color-rust)" stroke-width="2"/>
    <path d="M416 111 l-8 -5 v10 z" fill="var(--color-rust)"/>
    <text x="386" y="102" fill="var(--color-rust)" font-size="9" font-family="'JetBrains Mono Variable', monospace">wysyła</text>
    <text x="386" y="130" fill="var(--color-rust)" font-size="10">brak powrotu</text>
    <text x="526" y="170" fill="var(--color-muted)" font-size="10">wysłany mail, przelew,</text>
    <text x="526" y="186" fill="var(--color-muted)" font-size="10">opublikowany wpis, pakiet</text>
  </g>
</svg>
<figcaption>Prawie każda operacja sięgająca na zewnątrz ma dwa etapy leżące po dwóch stronach granicy. Zajęcie zapisuje ślad wewnątrz grafu i dlatego ma operację zwalniającą. Wysłanie granicę przekracza i takiej operacji mieć nie może, bo dane są już u kogoś innego.</figcaption>
</figure>

| 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.**

<figure>
<svg viewBox="0 0 640 232" role="img" aria-label="Dwa mechanizmy odwracania. Zasób przechodzi ze stanu zajęty w nieistnienie przez uruchomienie operacji zwalniającej. Fakt nie znika: kolejne wersje wskazują na poprzednie relacją zastępuje, a wartością bieżącą jest ta wersja, której nikt jeszcze nie zastąpił.">
  <g font-family="'Poppins', system-ui, sans-serif" text-anchor="middle">
    <text x="12" y="20" fill="currentColor" font-size="13" text-anchor="start">Zasób: cofnięcie przez odwrotność</text>
    <rect x="12" y="32" width="296" height="156" rx="8" fill="none" stroke="var(--color-muted)" stroke-width="1" stroke-dasharray="4 4"/>
    <rect x="40" y="76" width="100" height="36" rx="5" fill="none" stroke="currentColor" stroke-width="1.4"/>
    <text x="90" y="99" fill="currentColor" font-size="12">zajęty</text>
    <rect x="188" y="76" width="100" height="36" rx="5" fill="none" stroke="var(--color-muted)" stroke-width="1.4" stroke-dasharray="4 3"/>
    <text x="238" y="99" fill="var(--color-muted)" font-size="12">nie istnieje</text>
    <line x1="140" y1="94" x2="176" y2="94" stroke="var(--color-rust)" stroke-width="1.5"/>
    <path d="M180 94 l-7 -4 v8 z" fill="var(--color-rust)"/>
    <text x="160" y="86" fill="var(--color-rust)" font-size="9" font-family="'JetBrains Mono Variable', monospace">cofa</text>
    <text x="160" y="140" fill="currentColor" font-size="11">stan pozostaje mały</text>
    <text x="160" y="162" fill="var(--color-muted)" font-size="10" font-family="'JetBrains Mono Variable', monospace">historia znika</text>
    <text x="332" y="20" fill="currentColor" font-size="13" text-anchor="start">Fakt: zastąpienie przez nowszą wersję</text>
    <rect x="332" y="32" width="296" height="156" rx="8" fill="none" stroke="var(--color-muted)" stroke-width="1" stroke-dasharray="4 4"/>
    <rect x="350" y="76" width="76" height="36" rx="5" fill="none" stroke="var(--color-muted)" stroke-width="1.4"/>
    <text x="388" y="99" fill="var(--color-muted)" font-size="11">wersja 1</text>
    <rect x="442" y="76" width="76" height="36" rx="5" fill="none" stroke="var(--color-muted)" stroke-width="1.4"/>
    <text x="480" y="99" fill="var(--color-muted)" font-size="11">wersja 2</text>
    <rect x="534" y="76" width="76" height="36" rx="5" fill="none" stroke="var(--color-rust)" stroke-width="1.8"/>
    <text x="572" y="99" fill="var(--color-rust)" font-size="11">wersja 3</text>
    <line x1="442" y1="94" x2="434" y2="94" stroke="currentColor" stroke-width="1.4"/>
    <path d="M430 94 l7 -4 v8 z" fill="currentColor"/>
    <line x1="534" y1="94" x2="526" y2="94" stroke="currentColor" stroke-width="1.4"/>
    <path d="M522 94 l7 -4 v8 z" fill="currentColor"/>
    <text x="480" y="132" fill="currentColor" font-size="9" font-family="'JetBrains Mono Variable', monospace">zastępuje</text>
    <text x="480" y="158" fill="currentColor" font-size="11">bieżąca to ta, której nikt nie zastępuje</text>
    <text x="480" y="178" fill="var(--color-muted)" font-size="10" font-family="'JetBrains Mono Variable', monospace">stan rośnie, historia zostaje</text>
  </g>
</svg>
<figcaption>Zasób odwraca się przez uruchomienie przypisanej mu operacji zwalniającej i po tej operacji przestaje istnieć. Fakt odwraca się przez dopisanie nowszej wersji wskazującej na poprzednią, a wszystkie wersje zostają w modelu, więc bieżącą wartością jest po prostu ta, której nikt jeszcze nie zastąpił.</figcaption>
</figure>

| | **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](https://opengitops.dev/), 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.

<div class="callout">

**Zapis formalny (opcjonalny)**

Niech $A$ będzie zbiorem jednostek wspieranych w końcowej konfiguracji, zdefiniowanym najmniejszym punktem stałym: jednostka jest wspierana, gdy nie jest wyłączona administracyjnie, jednostka, która ją zarejestrowała, jest wspierana, i każda nazwa z jej $d$ jest dostarczana przez jednostkę wspieraną. Przy acyklicznej relacji $\prec$, parami niezależnych operacjach i braku awarii dowolne dwa ciągi kroków wychodzące z tego samego stanu i wykonujące te same operacje sterujące kończą w stanach nieodróżnialnych, równych temu, który powstałby przez jednokrotne uruchomienie jednostek z $A$ w kolejności liniowo porządkującej $\prec$.

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.

</div>

## 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.

<figure>
<svg viewBox="0 0 640 268" role="img" aria-label="Repozytorium jako serializacja grafu. Cztery filary podejścia wszystko jako kod zapisują różne klasy obiektów, uzgadnianie doprowadza działający graf do zapisanego stanu, a obszar oznaczony jako poza grafem pozostaje przez uzgadnianie nietknięty.">
  <g font-family="'Poppins', system-ui, sans-serif" text-anchor="middle">
    <rect x="12" y="24" width="228" height="196" rx="10" fill="none" stroke="currentColor" stroke-width="1.8"/>
    <text x="126" y="50" fill="currentColor" font-size="13">repozytorium</text>
    <rect x="32" y="66" width="188" height="28" rx="4" fill="none" stroke="var(--color-muted)" stroke-width="1.1"/>
    <text x="44" y="85" fill="currentColor" font-size="11" text-anchor="start">infrastruktura</text>
    <text x="210" y="85" fill="var(--color-muted)" font-size="9" text-anchor="end" font-family="'JetBrains Mono Variable', monospace">Zasób</text>
    <rect x="32" y="102" width="188" height="28" rx="4" fill="none" stroke="var(--color-muted)" stroke-width="1.1"/>
    <text x="44" y="121" fill="currentColor" font-size="11" text-anchor="start">aplikacje</text>
    <text x="210" y="121" fill="var(--color-muted)" font-size="9" text-anchor="end" font-family="'JetBrains Mono Variable', monospace">Jednostka</text>
    <rect x="32" y="138" width="188" height="28" rx="4" fill="none" stroke="var(--color-muted)" stroke-width="1.1"/>
    <text x="44" y="157" fill="currentColor" font-size="11" text-anchor="start">design</text>
    <text x="210" y="157" fill="var(--color-muted)" font-size="9" text-anchor="end" font-family="'JetBrains Mono Variable', monospace">dostarcza</text>
    <rect x="32" y="174" width="188" height="28" rx="4" fill="none" stroke="var(--color-muted)" stroke-width="1.1"/>
    <text x="44" y="193" fill="currentColor" font-size="11" text-anchor="start">proces i polityki</text>
    <text x="210" y="193" fill="var(--color-muted)" font-size="9" text-anchor="end" font-family="'JetBrains Mono Variable', monospace">Przebieg</text>
    <line x1="240" y1="122" x2="288" y2="122" stroke="currentColor" stroke-width="1.8"/>
    <path d="M292 122 l-8 -5 v10 z" fill="currentColor"/>
    <text x="266" y="112" fill="var(--color-muted)" font-size="9" font-family="'JetBrains Mono Variable', monospace">uzgadnianie</text>
    <rect x="300" y="24" width="212" height="196" rx="10" fill="none" stroke="currentColor" stroke-width="1.8"/>
    <text x="406" y="50" fill="currentColor" font-size="13">graf w działaniu</text>
    <circle cx="356" cy="106" r="19" fill="none" stroke="currentColor" stroke-width="1.4"/>
    <text x="356" y="110" fill="currentColor" font-size="11">A</text>
    <circle cx="456" cy="106" r="19" fill="none" stroke="currentColor" stroke-width="1.4"/>
    <text x="456" y="110" fill="currentColor" font-size="11">B</text>
    <circle cx="406" cy="176" r="19" fill="none" stroke="currentColor" stroke-width="1.4"/>
    <text x="406" y="180" fill="currentColor" font-size="11">C</text>
    <line x1="375" y1="106" x2="433" y2="106" stroke="currentColor" stroke-width="1.3"/>
    <path d="M437 106 l-7 -4 v8 z" fill="currentColor"/>
    <line x1="397" y1="159" x2="366" y2="124" stroke="currentColor" stroke-width="1.3"/>
    <path d="M363 121 l8 2 l-6 5 z" fill="currentColor"/>
    <rect x="530" y="80" width="98" height="84" rx="8" fill="none" stroke="var(--color-rust)" stroke-width="1.5" stroke-dasharray="5 4"/>
    <text x="579" y="112" fill="var(--color-rust)" font-size="11">poza</text>
    <text x="579" y="128" fill="var(--color-rust)" font-size="11">grafem</text>
    <text x="579" y="148" fill="var(--color-muted)" font-size="9" font-family="'JetBrains Mono Variable', monospace">klik w konsoli</text>
    <text x="320" y="246" fill="var(--color-muted)" font-size="10">czego nie ma w repozytorium, tego uzgadnianie nie dotknie</text>
  </g>
</svg>
<figcaption>Cztery filary podejścia „wszystko jako kod” zapisują w repozytorium różne fragmenty tego samego grafu, a uzgadnianie doprowadza rzeczywistość do zapisanego stanu. Zasób utworzony poza tym zapisem nie ma w grafie żadnego obiektu, więc żadne uzgadnianie ani żadne usuwanie go nie ruszy.</figcaption>
</figure>

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](/blog/wersjonowanie-semver-conventional-commits) 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.

<figure>
<svg viewBox="0 0 640 258" role="img" aria-label="Porównanie jednej grubej nazwy i trzech wąskich. Po lewej czterech odbiorców jest podpiętych do jednej nazwy platforma, więc jej podmiana przeładowuje wszystkich czterech. Po prawej ci sami odbiorcy są podpięci do trzech osobnych nazw, więc podmiana jednej z nich przeładowuje tylko tego odbiorcę, który ją deklaruje.">
  <g font-family="'Poppins', system-ui, sans-serif" text-anchor="middle">
    <text x="12" y="20" fill="currentColor" font-size="13" text-anchor="start">jedna gruba nazwa</text>
    <rect x="12" y="30" width="296" height="196" rx="8" fill="none" stroke="var(--color-muted)" stroke-width="1" stroke-dasharray="4 4"/>
    <rect x="90" y="50" width="140" height="34" rx="5" fill="none" stroke="var(--color-rust)" stroke-width="1.8"/>
    <text x="160" y="72" fill="var(--color-rust)" font-size="12">platforma</text>
    <rect x="26" y="150" width="62" height="30" rx="5" fill="none" stroke="var(--color-rust)" stroke-width="1.6"/>
    <text x="57" y="170" fill="var(--color-rust)" font-size="11">A</text>
    <rect x="96" y="150" width="62" height="30" rx="5" fill="none" stroke="var(--color-rust)" stroke-width="1.6"/>
    <text x="127" y="170" fill="var(--color-rust)" font-size="11">B</text>
    <rect x="166" y="150" width="62" height="30" rx="5" fill="none" stroke="var(--color-rust)" stroke-width="1.6"/>
    <text x="197" y="170" fill="var(--color-rust)" font-size="11">C</text>
    <rect x="236" y="150" width="62" height="30" rx="5" fill="none" stroke="var(--color-rust)" stroke-width="1.6"/>
    <text x="267" y="170" fill="var(--color-rust)" font-size="11">D</text>
    <line x1="57" y1="148" x2="120" y2="88" stroke="var(--color-rust)" stroke-width="1.2"/>
    <line x1="127" y1="148" x2="146" y2="88" stroke="var(--color-rust)" stroke-width="1.2"/>
    <line x1="197" y1="148" x2="176" y2="88" stroke="var(--color-rust)" stroke-width="1.2"/>
    <line x1="267" y1="148" x2="200" y2="88" stroke="var(--color-rust)" stroke-width="1.2"/>
    <text x="160" y="204" fill="var(--color-rust)" font-size="10" font-family="'JetBrains Mono Variable', monospace">podmiana przeładowuje 4 z 4</text>
    <text x="332" y="20" fill="currentColor" font-size="13" text-anchor="start">trzy wąskie nazwy</text>
    <rect x="332" y="30" width="296" height="196" rx="8" fill="none" stroke="var(--color-muted)" stroke-width="1" stroke-dasharray="4 4"/>
    <rect x="344" y="50" width="86" height="34" rx="5" fill="none" stroke="var(--color-rust)" stroke-width="1.8"/>
    <text x="387" y="72" fill="var(--color-rust)" font-size="11">kolejka</text>
    <rect x="440" y="50" width="86" height="34" rx="5" fill="none" stroke="currentColor" stroke-width="1.4"/>
    <text x="483" y="72" fill="currentColor" font-size="11">baza</text>
    <rect x="536" y="50" width="82" height="34" rx="5" fill="none" stroke="currentColor" stroke-width="1.4"/>
    <text x="577" y="72" fill="currentColor" font-size="11">sekrety</text>
    <rect x="346" y="150" width="62" height="30" rx="5" fill="none" stroke="var(--color-rust)" stroke-width="1.6"/>
    <text x="377" y="170" fill="var(--color-rust)" font-size="11">A</text>
    <rect x="416" y="150" width="62" height="30" rx="5" fill="none" stroke="currentColor" stroke-width="1.4"/>
    <text x="447" y="170" fill="currentColor" font-size="11">B</text>
    <rect x="486" y="150" width="62" height="30" rx="5" fill="none" stroke="currentColor" stroke-width="1.4"/>
    <text x="517" y="170" fill="currentColor" font-size="11">C</text>
    <rect x="556" y="150" width="62" height="30" rx="5" fill="none" stroke="currentColor" stroke-width="1.4"/>
    <text x="587" y="170" fill="currentColor" font-size="11">D</text>
    <line x1="377" y1="148" x2="382" y2="88" stroke="var(--color-rust)" stroke-width="1.4"/>
    <line x1="447" y1="148" x2="470" y2="88" stroke="currentColor" stroke-width="1.1"/>
    <line x1="517" y1="148" x2="494" y2="88" stroke="currentColor" stroke-width="1.1"/>
    <line x1="587" y1="148" x2="580" y2="88" stroke="currentColor" stroke-width="1.1"/>
    <text x="480" y="204" fill="var(--color-rust)" font-size="10" font-family="'JetBrains Mono Variable', monospace">podmiana kolejki przeładowuje 1 z 4</text>
  </g>
</svg>
<figcaption>Zbiór przeładowanych jednostek to dokładnie ci, którzy deklarują podmienianą nazwę. Jedna szeroka nazwa łączy niepowiązanych odbiorców we wspólny los, więc wymiana dowolnej jej części dotyka wszystkich. Rozbicie tej samej funkcjonalności na trzy wąskie nazwy sprawia, że podmiana dotyka wyłącznie tych, których ona faktycznie obchodzi.</figcaption>
</figure>

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.

<figure>
<svg viewBox="0 0 640 292" role="img" aria-label="Trzy warstwy infrastruktury ułożone pionowo: aplikacje na górze, platforma w środku, rdzeń na dole. Strzałki zależności prowadzą wyłącznie w dół, od aplikacji do platformy i od platformy do rdzenia. Zaznaczona na czerwono przerywana strzałka prowadząca z rdzenia w górę do aplikacji jest oznaczona jako zabroniona, ponieważ tworzy cykl.">
  <g font-family="'Poppins', system-ui, sans-serif" text-anchor="middle">
    <rect x="96" y="20" width="380" height="56" rx="6" fill="none" stroke="currentColor" stroke-width="1.5"/>
    <text x="286" y="44" fill="currentColor" font-size="13">aplikacje</text>
    <text x="286" y="63" fill="var(--color-muted)" font-size="10" font-family="'JetBrains Mono Variable', monospace">usługi &#183; zadania &#183; agenci</text>
    <rect x="96" y="106" width="380" height="56" rx="6" fill="none" stroke="currentColor" stroke-width="1.5"/>
    <text x="286" y="130" fill="currentColor" font-size="13">platforma</text>
    <text x="286" y="149" fill="var(--color-muted)" font-size="10" font-family="'JetBrains Mono Variable', monospace">klaster &#183; baza &#183; kolejka &#183; magazyn</text>
    <rect x="96" y="192" width="380" height="56" rx="6" fill="none" stroke="currentColor" stroke-width="1.5"/>
    <text x="286" y="216" fill="currentColor" font-size="13">rdzeń</text>
    <text x="286" y="235" fill="var(--color-muted)" font-size="10" font-family="'JetBrains Mono Variable', monospace">tożsamość &#183; sieć &#183; rejestr stanu</text>
    <line x1="286" y1="76" x2="286" y2="98" stroke="currentColor" stroke-width="1.5"/>
    <path d="M286 102 l-4 -7 h8 z" fill="currentColor"/>
    <text x="342" y="94" fill="var(--color-muted)" font-size="9" font-family="'JetBrains Mono Variable', monospace">wymaga</text>
    <line x1="286" y1="162" x2="286" y2="184" stroke="currentColor" stroke-width="1.5"/>
    <path d="M286 188 l-4 -7 h8 z" fill="currentColor"/>
    <text x="342" y="180" fill="var(--color-muted)" font-size="9" font-family="'JetBrains Mono Variable', monospace">wymaga</text>
    <path d="M476 220 L556 220 L556 48 L500 48" fill="none" stroke="var(--color-rust)" stroke-width="1.5" stroke-dasharray="5 3"/>
    <path d="M496 48 l7 -4 v8 z" fill="var(--color-rust)"/>
    <text x="600" y="134" fill="var(--color-rust)" font-size="10" text-anchor="middle">zabroniona</text>
    <text x="600" y="148" fill="var(--color-rust)" font-size="10" text-anchor="middle">tworzy cykl</text>
    <text x="46" y="134" fill="var(--color-muted)" font-size="10" text-anchor="middle">rzadziej</text>
    <text x="46" y="148" fill="var(--color-muted)" font-size="10" text-anchor="middle">się zmienia</text>
    <line x1="46" y1="90" x2="46" y2="60" stroke="var(--color-muted)" stroke-width="1"/>
    <line x1="46" y1="180" x2="46" y2="212" stroke="var(--color-muted)" stroke-width="1"/>
    <path d="M46 216 l-4 -7 h8 z" fill="var(--color-muted)"/>
    <text x="286" y="276" fill="currentColor" font-size="11">to, co zmienia się najrzadziej, leży najniżej i nie wie o niczym powyżej</text>
  </g>
</svg>
<figcaption>Reguła zależności w praktyce infrastrukturalnej. Zależności prowadzą wyłącznie w dół, ku warstwom zmieniającym się najrzadziej, i żadna warstwa nie wie nic o tym, co leży nad nią. Pojedyncza strzałka poprowadzona w górę zamyka cykl, a wtedy obie warstwy, które on obejmuje, przestają dawać się uruchomić.</figcaption>
</figure>

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.

<div class="callout">

**Zapis formalny (opcjonalny)**

*Reguła zależności.* Warstwowanie to funkcja $\ell : J \to \mathbb{N}$ taka, że

$$ \forall\, n \prec m .\quad \ell(n) < \ell(m) $$

gdzie $n \prec m \iff p_n \cap d_m \neq \varnothing$. Taka funkcja istnieje *wtedy i tylko wtedy*, gdy relacja $\prec$ jest acykliczna, bo w jedną stronę daje ją dowolny porządek topologiczny, a w drugą wartości $\ell$ malałyby wzdłuż cyklu. Reguła zależności jest więc równoważna pierwszemu niezmiennikowi; wybór konkretnej $\ell$ spośród wielu możliwych jest decyzją projektową, nie konsekwencją modelu.

*Segregacja interfejsów.* Zbiór jednostek przeładowywanych przy podmianie dostawcy nazwy $k$ to

$$ R(k) \;=\; \{\, m : k \in d_m \,\} $$

Rozbicie nazwy $k$ na rozłączne nazwy $k_1, \ldots, k_j$ o tej samej łącznej zawartości daje $R(k) = \bigcup_i R(k_i)$, a podmiana dotycząca jednego $k_i$ przeładowuje tylko $R(k_i) \subseteq R(k)$. Segregacja interfejsów jest więc dosłownie redukcją rozmiaru zbioru przeładowań.

*Pojedyncza odpowiedzialność.* Promień rażenia jednostki $n$ to $B(n) = \bigcup_{k \in p_n} R(k)$, więc rośnie on wraz z rozmiarem zbioru $p_n$. Jednostka o jednoelementowym $p_n$ ma promień równy zbiorowi konsumentów jednej nazwy.

*Podstawialność.* Dwaj dostawcy $n_1, n_2$ nazwy $k$ są podstawialni, gdy wartości, które publikują, są nieodróżnialne przez operacje tej nazwy: $\sigma_{n_1}(k) \simeq_k \sigma_{n_2}(k)$. To ta sama relacja $\simeq_k$, którą wcześniej stosowaliśmy do dwóch stanów; tutaj przyłożona do dwóch dostawców. Im węższy zbiór operacji publikowanych przez $k$, tym słabszy warunek - co jest formalnym powodem, dla którego wąskie nazwy łatwiej podmieniać.

</div>

### 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.

```yaml
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.

```yaml
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:

```yaml
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:

```yaml
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](/blog/policy-as-code-dla-zespolow).

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](/blog/cztery-poziomy-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:

```yaml
# rzut przestrzenny
jednostka: api-zamowien
wymaga: [baza-danych, kolejka-zdarzen]
dostarcza: [api-publiczne]
polityki:
  baza-danych: { tryb: odczyt-zapis }
  kolejka-zdarzen: { tryb: tylko-publikacja }
```

```yaml
# 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ą.