#java #architecture #spi #jvm

Co wiedziałem i co zbudowałem

Dwa seamy w Exeris Kernel dorobione po fakcie — body codec i identity provider — i dlaczego obie luki widać dopiero od strony konsumenta, nie kontraktu.

To ósmy artykuł z serii o Exeris Kernel. Pierwszych siedem dotyczyło decyzji, których jestem pewien: propagacja kontekstu, gdzie polimorfizm runtime przestaje się opłacać, gdzie StructuredTaskScope naprawdę się sprawdza, off-heap TLS, silnik Flow, poprawność kompensacji i wzorzec LoanedBuffer. Ten jest inny. Jest o dwóch kontraktach, które musiałem dorobić po tym, jak początkowy projekt został zamknięty — i o jednej rzeczy, która je łączy, a której nie widziałem, dopóki obie się nie wydarzyły.


Miałem jedną architektoniczną wizję Exeris Kernel i dwa przeoczenia w jej realizacji. Z zewnątrz wyglądają na niepowiązane — jedno jest w kliencie HTTP, drugie w podsystemie bezpieczeństwa — ale mają ten sam kształt. W obu przypadkach zbudowałem pojedynczą, konkretną instancję czegoś, czego architektura potrzebowała jako pluggable seam. I w obu przypadkach luka pozostawała niewidoczna, dopóki nie przeczytałem konsumującego kodu od początku do końca.

Ta ostatnia rzecz jest sednem całego artykułu, więc powiem ją od razu: nie złapałem żadnego z tych przeoczeń na design review. Złapałem je, będąc konsumentem własnego kernela. Dlaczego to ważne — i dlaczego to nie jest tylko spowiedź — bo większość ludzi, którzy projektują kontrakt, nigdy nie zajmuje pozycji, z której widać jego luki. Wydają kontrakt, ktoś inny konsumuje go dwie warstwy niżej, a luka wychodzi jako incydent na produkcji. Ja złapałem swoje wcześnie i sam tylko dlatego, że nie pracuję wyłącznie nad kernelem. Buduję też tooling, który generuje kod pod niego, i produkt, który na tym działa. Siedzę po obu stronach granicy, którą zaprojektowałem. I „wcześnie” jest dosłowne: lukę w codeku znalazłem w lokalnych testach, czytając zarówno generator w exeris-tooling, jak i kod, który on emituje. Tooling, który ten kod emituje, nie ma jeszcze taga release’owego — wygenerowany output regeneruje się na żądanie, nie ma nic opublikowanego, do czego aplikacja downstream mogłaby się przypiąć. Więc ta wersja generatora, która hardcodowała konkretny codec, nigdy nie miała konsumenta zależnego od siebie; zanim rewrite konsumujący SPI wszedł, kontrakt, który konsumuje, był już w wydanym kernelu. To nie szczęście. Stałem na krawędzi konsumenta, gdy kontrakt miał jeszcze zero zależności — najtańszy możliwy moment na znalezienie takiej luki.

Jest jeszcze trzecia, mniejsza wersja tego samego błędu — zagnieżdżona w naprawie pierwszego. Dojdę do niej; to część, którą uważam za najbardziej użyteczną, bo pokazuje, że ten ślepy punkt nie jest jednorazową wpadką, którą da się rozwiązać większą ostrożnością. On się powtarza — nawet wtedy, gdy aktywnie naprawiasz jego instancję.


HttpClient, który zbudowałem i nie mogłem użyć

Najpierw słowo o numerach wersji, bo są dwa tory i łatwo je pomylić. Numerowane wersje w tym artykule to release’y kernela — 0.5.0 i 0.8.0 niżej, 0.8.1 obecnie, wszystkie otagowane na GitHubie. Tooling, który konsumuje te seamy, to osobny tor: zaimplementowany przez 0.4, na 0.5.0-SNAPSHOT, celowo jeszcze bez taga release’owego. Więc kiedy mówię, że naprawa „weszła”, mam na myśli wydany kernel. Kiedy mówię, że rewrite generatora jest „sekwencjonowany”, mam na myśli tor toolingu, który celowo nie otagował release’u — więcej o tym „dlaczego” na końcu.

Punkt zapalny był konkretny. Budowałem warstwę SDK/tooling — generator kodu w exeris-tooling, który emituje typowane, per-encja klienty REST (WidgetClient.findById(id), WidgetClient.create(widget)) pod dowolny artefakt kernela, który wybierze POM aplikacji. Całe zadanie generatora to być tier-neutralnym: emituje kod, który kompiluje się pod tier Community (HTTP/1.1 + HTTP/2) albo Enterprise (HTTP/3 + Panama JSON, gdy wejdzie), nie wiedząc i nie dbając o to, który.

Nie mógł. Fasada klienta HTTP, którą wydałem — wtedy nazwana CommunityWebClient — hardcodowała Jackson 3 jako codec JSON. Marshalling ciała żył wewnątrz fasady: mapper.writeValueAsBytes na wyjściu, mapper.readValue na wejściu. Konstruktor brał ObjectMapper wprost. Wynikły z tego dwie konsekwencje, z których żadnej nie uświadomiłem sobie, dopóki generator nie wepchnął mi ich przed oczy.

Pierwsza: codec nie był wymienny. Gdyby konsument chciał Protobuf, CBOR albo przyszły Panama-native binding JSON, nie było seamu, w który można by się wpiąć — format był wtopiony w fasadę. To była świadoma decyzja, gdy ją podejmowałem. Jackson był już codekiem, który linkowała reszta kernela; wystawienie ObjectMapper na konstruktorze było ścieżką najmniejszego oporu, gdy budowałem fasadę (ADR-026), i wmówiłem sobie, że kernel nie ma jeszcze konkretnej potrzeby drugiego codeka. Lokalnie racjonalne. Problem w tym, że „nie ma jeszcze konkretnej potrzeby” było zdaniem o kernelu w izolacji — a konsument, który potrzebował seamu, czyli tier-neutralny generator, jeszcze nie istniał, gdy podejmowałem tę decyzję.

Druga konsekwencja była gorsza, bo nie była nawet decyzją, którą pamiętam, że podejmowałem. Fasada nazywała się CommunityWebClient, a generator emitował tę nazwę do każdego pliku klienta, który produkował:

public final class WidgetClient {
    private final CommunityWebClient client;          // tier identity, in user code
    public WidgetClient(CommunityWebClient client) { ... }
    public Optional<Widget> findById(UUID id) {
        try { return Optional.ofNullable(client.get(...)); }
        catch (CommunityWebClient.WebClientException ex) { ... }  // tier identity again
    }
}

Nazwa tier wyciekała do każdej klasy aplikacji, którą tooling wygenerował. To jest build-time’owy odpowiednik reguły, którą już spisałem i egzekwowałem wszędzie indziej w kernelu — The Wall (ADR-006): granica kernela widoczna dla aplikacji musi być implementation-blind. Egzekwowałem ją rygorystycznie w runtime. Nie zauważyłem, że stosuje się też w build-time — do symboli, które pojawiają się w generowanym źródle.

Wzorzec, który już zbudowałem — raz

Tu jest część, która zabolała. Strona serwerowa już miała SPI, którego brakowało mi po stronie klienta. HttpResponseBodyEncoder był tier-neutralnym seamem SPI od 0.5.0 (era ADR-009): kontrakt enkodera, rekord kontekstu enkodowania, registry z fabryką empty(), reużywalny off-heap nośnik HttpEncodedBody i Community’owy driver Jacksona, który go implementował. Zaprojektowałem dokładnie właściwy kształt — encoder + context + registry, z konkretnym codekiem jako wymiennym driverem — a potem zbudowałem go w dokładnie jednym miejscu i nie zauważyłem, że ten sam kształt jest potrzebny w trzech innych.

Przestrzeń projektowa codeków ciała to macierz 2×2: {request, response} × {encode, decode}. Jedna ćwiartka miała porządne SPI od początku — server-side response-encode, wydane w 0.5.0 (ADR-009). Pozostałe trzy były hardcodowane na Jacksona, wtapiane w różnych momentach, w miarę jak budowała się każda powierzchnia: client-side request-encode i response-decode weszły, gdy później budowałem fasadę klienta (CommunityWebClient, ADR-026), a server-side request-decode siedział w generowanych handlerach. Nie brakowało mi koncepcji SPI codeka. Sam ją zaprojektowałem — zanim fasada, która jej potrzebowała, w ogóle istniała — i potem nie użyłem jej ponownie, gdy tę fasadę budowałem, bo wtedy ta jedna ćwiartka, którą wypełniłem, była jedyną z konsumentem patrzącym jej w oczy.

encodedecode
requestclient-side — ADR-034 (nowe)server-side — ADR-036 (przeoczone)
responseserver-side — ADR-009 (0.5.0)client-side — ADR-034 (nowe)

Naprawa (ADR-034)

ADR-034 zamknął połowę po stronie klienta. Sześć nowych typów SPI w eu.exeris.kernel.spi.http, celowo lustrzanych względem istniejącego serwerowego trypletu enkodera dla grep-symetrii: HttpRequestBodyEncoder, HttpRequestEncodingContext, HttpRequestBodyEncoderRegistry po stronie encode; HttpResponseBodyDecoder, HttpResponseDecodingContext, HttpResponseBodyDecoderRegistry po stronie decode. Binding Jacksona stał się dwoma Community’owymi driverami (CommunityJsonRequestBodyEncoder, CommunityJsonResponseBodyDecoder) za tymi kontraktami. Nośnik HttpEncodedBody z 0.5.0 został reużyty, a nie zduplikowany.

Koszt jest realny i warto go nazwać: każde ciało przechodzi teraz przez rozwiązanie w registry i indirekcję drivera, co w oryginalnej fasadzie było bezpośrednim wywołaniem mapper.writeValueAsBytes. Dla przypadku jednego codeka to narzut, który płacę, żeby utrzymać seam otwarty — świadomy trade, nie darmowa wygrana.

Sama fasada wyszła z tieru Community do Core i straciła nazwę tier: CommunityWebClient stał się KernelWebClient w eu.exeris.kernel.core.http.client. Jej konstruktor stracił parametr ObjectMapper — Jackson zszedł do drivera codeka, a Core przestał mieć jakiekolwiek zdanie o JSON-ie. Generator emituje teraz KernelWebClient do kodu aplikacji; żadna tożsamość tier nie wypływa. ADR-034 zastąpił ADR-026, który był wcześniejszym domem fasady i traktował wyciek nazwy tier jako problem podmiany stringa do naprawienia w lockstepie — zamiast jako strukturalny symptom, którym faktycznie był.

Chcę być precyzyjny co do tego, co było decyzją, a co przeoczeniem — bo to nie to samo, a mylenie ich byłoby zbyt dla mnie łaskawe. Hardcodowanie Jacksona w fasadzie klienta było decyzją, której granice doszacowałem za nisko — do obrony w momencie podjęcia, błędną w momencie, gdy pojawił się konsument. Wyciek nazwy tier nie był decyzją w ogóle. To było coś, co złapałbym natychmiast, gdybym przeczytał output generatora jako kod aplikacji, a nie jako swój własny kod. Różnica między tymi dwoma to różnica między źle skalibrowanym osądem a niepatrzeniem — i tylko jedno z nich da się naprawić, myśląc mocniej na etapie projektu.

Przeoczenie wewnątrz naprawy (ADR-036)

To część, którą uważam za najbardziej użyteczną — i to dla niej w ogóle piszę ten artykuł.

ADR-034 dokończył trzy z czterech ćwiartek macierzy: server-response-encode (już było od 0.5.0), client-request-encode (nowe), client-response-decode (nowe). Zostawił czwartą — server-side decode ciała żądania, czyli ciało z wire, które staje się typowanym argumentem handlera, jak Widget dla POST /widgets. Ta ćwiartka nie miała seamu SPI w ogóle. Był tam hardcodowany Jackson wewnątrz generowanego kodu: toolingowy KernelHandlerGenerator emitował statyczne pole Jacksona MAPPER i import JacksonException do każdego kontrolera, który produkował. Dokładnie to samo build-time’owe naruszenie The Wall, które właśnie naprawiłem po stronie klienta — siedzące nietknięte po stronie serwera, w innym generatorze.

Wyszło dokładnie tak samo jak pierwotny problem — przez czytanie konsumującego kodu od początku do końca. Pierwszy zestaw luk wyszedł, gdy ponownie przeczytałem KernelClientGenerator. Ta wyszła, gdy ponownie przeczytałem KernelHandlerGenerator. Ta sama czynność, inny plik, ta sama klasa znaleziska.

Chcę to czysto oddzielić od decyzji, którą podjąłem świadomie w ADR-034, bo uczciwa wersja tej historii zależy od tego rozróżnienia. ADR-034 świadomie odłożył jedną rzecz: unifikację serwerowej ścieżki encode — refaktor działającego JsonBodyEncoder z 0.5.0, żeby subskrybował content-type tak, jak robi to nowy decoder klienta. To był wybór, zapisany jako sprzątanie na v1.0, bo to refaktor seamu, który już działa. Serwerowa ćwiartka request-decode była czymś zupełnie innym: nie seamem, który postanowiłem zostawić w spokoju, lecz seamem, który nigdy nie istniał — ukrytym w generowanym kodzie, na który nie patrzyłem. Jedno było odłożone. Drugie przeoczone. ADR-036 zamknął to przeoczone, lustrując triplet response-decodera słowo w słowo w HttpRequestBodyDecoder / HttpRequestDecodingContext / HttpRequestBodyDecoderRegistry. Oba ADR-y weszły w kernelu 0.8.0 — czteroćwiartkowe SPI codeka jest w wydanym kernelu dziś. Toolingowy generator, który konsumuje nowy seam server-decode, został od tamtej pory przepisany, żeby rozwiązywać przez registry zamiast wtapiać konkretny symbol codeka; co to oznacza dla release’u toolingu — wracam do tego na końcu.

To jest część, która ma znaczenie: pracowałem aktywnie dokładnie nad tym problemem — seamy codeków, build-time’owa higiena The Wall, macierz — i wciąż zostawiłem jedną ćwiartkę hardcodowaną, bo ta ćwiartka żyła w konsumencie, którego jeszcze nie przeczytałem ponownie. To nie nieostrożność. Patrzyłem na trzy z czterech miejsc, w których żył wzorzec; czwarte było gdzieś, gdzie nie skierowałem uwagi. Sekcja zamykająca jest o tym, dlaczego to strukturalne, a nie porażka dyscypliny.


Walidator, który zbudowałem, i seam, którego nie

Drugie przeoczenie jest w podsystemie bezpieczeństwa i to ono nauczyło mnie najwięcej — bo gdy pierwszy raz opisałem je sobie, opisałem je źle. Moje początkowe ujęcie brzmiało: „zapomniałem zbudować wsparcie dla identity providerów”. Nie to się stało, a prawdziwa wersja jest bardziej użyteczna.

Nie zapomniałem o tożsamości. Kernel ma walidację tokenów od 0.5.0. SecurityProvider.authenticate(LoanedBuffer rawToken) zwraca AuthenticationResult niosący PrincipalContext (principal id, tenant id, role, scope’y) i StorageContext (decyzję o routingu multi-tenant). Ten seam jest dziś konsumowany na krawędzi HTTP. Za nim siedzi CommunityJwksValidator — pipeline Nimbus JOSE, który robi prawdziwą robotę: kid → key → signature → issuer → audience → expiry, fail-closed na każdym kroku zgodnie z ADR-012. Walidacja tożsamości nie jest nieobecna. Działa i działała.

To, co zbudowałem, to pojedynczy, statyczny walidator tylko-RSA wtopiony w jedną implementację SecurityProvider. Zestaw kluczy to niemutowalna Map<kid, RSAPublicKey> wstrzyknięta przy konstrukcji — bez rotacji, bez pobierania JWKS, bez EC, i co kluczowe, bez sposobu, by ugościć drugiego identity providera bez dziedziczenia albo duplikowania cross-cutting logiki, która otacza walidację. Pipeline walidacji to już jakieś osiemdziesiąt procent walidatora resource-server OIDC. Po prostu wtopiłem te osiemdziesiąt procent w jedną konkretną klasę, zamiast wystawić je jako kontrakt.

Nie zauważyłem, że to ten sam błąd co codec w HttpClient, dopóki nie zapisałem walidatora w ten sam sposób. Inny podsystem, identyczny kształt: tam zbudowałem jedną ćwiartkę codeka i zostawiłem resztę konkretną; tu zbudowałem jeden walidator i nie zostawiłem seamu na drugi. W obu przypadkach to, co wydałem, działa idealnie dla jednego przypadku, który obsługuje. W obu przypadkach architektura potrzebowała pluggable kontraktu, a ja dałem jej konkretną instancję.

Gdzie to wyszło

Luka w HttpClient wyszła, gdy czytałem generatory toolingu. Ta wyszła, gdy spróbowałem postawić prawdziwy produkt na kernelu.

Deployment B2B SaaS nie uwierzytelnia się przeciw jednemu identity providerowi. Pracowników wystawia przez jednego (powiedzmy Okta), użytkowników B2C przez drugiego (Auth0), a ruch service-to-service przez trzeciego (coś wewnętrznego). Dispatch dzieje się przez inspekcję issuera tokena przed walidacją. Moja architektura z jednym SecurityProvider nie miała na to miejsca. Jedynym sposobem doczepienia drugiego providera był composite na poziomie aplikacji, który musiałby reimplementować fail-closed, deterministic-deny inwarianty, które ADR-012 celowo trzyma wewnątrz kernela. Innymi słowy: żeby użyć kernela do tego, do czego zbudowałem kernel, aplikacja musiałaby sięgnąć dookoła kernela i reimplementować jego najbardziej safety-critical kontrakt. To jest ta luka. To nie brak walidacji. To brakujący seam — a ten seam to dokładnie ta część, której prawdziwy konsument potrzebuje najpierw.

Konsumentem, który to ujawnił, był BudgetHQ — produkt, który buduję na kernelu. BudgetHQ jest B2C i B2B naraz: indywidualni użytkownicy z jednej strony, biznesowe workspace’y z drugiej. Ścieżka B2C uwierzytelnia się bez problemu przeciw jednemu providerowi — i dokładnie dlatego luka pozostawała ukryta: walidator jednego providera obsługuje typowy przypadek bez narzekania. Wyszła w momencie, gdy zacząłem planować multi-provider authentication dla biznesowych workspace’ów: workspace federujący własnych pracowników przez własnego identity providera, podczas gdy użytkownicy B2C dalej uwierzytelniają się przez domyślnego. Ścieżka od Authorization: Bearer … do wypełnionego PrincipalContext była — dla tego przypadku multi-provider — po prostu nieobecna jako ścieżka wspierana przez kernel. Roadmapa nazywa to bez owijania: największy pojedynczy blocker „wydania B2B SaaS” dla ekosystemu. Wiem to tylko dlatego, że jestem też tym, kto próbuje wydać ten biznesowy workspace.

Decyzja (RFC, jeszcze nie kod)

Będę szczery co do statusu tego — bo różni się od przypadku HttpClient, a różnica ma znaczenie. Przeoczenie HttpClient jest naprawione i wydane — ADR-034 i ADR-036 weszły w kernelu 0.8.0, a seam jest w kernelu działającym dziś. Toolingowy generator, który go konsumuje, został od tamtej pory przepisany, żeby pasować; jedyny element wciąż w locie to release toolingu niosący ten rewrite, na torze, który nie jest jeszcze otagowany. Przeoczenie IDP jest na zupełnie innym etapie: zdiagnozowane i zdecydowane, ale jeszcze nie zbudowane. Decyzja przeszła przez RFC, a nie prosto do ADR — bo w odróżnieniu od naprawy codeka niosła naprawdę otwarte wybory strategiczne, i chcę pokazać to rozróżnienie, a nie je zamaskować.

Decyzja: wprowadzić dedykowane SPI IdentityProvider, do którego SecurityProvider deleguje. SecurityProvider zostaje punktem wejścia, ale staje się cienkim dispatcherem — wybiera jednego IdentityProvider tanim peekiem na issuer/format, deleguje walidację, a potem stosuje cross-cutting troski z ADR-012 (isolation-claim → StorageContext, semantyka fail-closed) jednolicie do dowolnego dispatchowanego providera. Walidacja staje się per-provider; orkiestracja zostaje centralna. Pierwszym referencyjnym driverem jest OIDC + JWKS, bo istniejący CommunityJwksValidator jest już w większości na miejscu — ekstrakcja to głównie refaktor, czyli najmniej ryzykowny sposób wydania pierwszego drivera. Registry czyta się tak samo jak registry codeków z ADR-034 (of(List<…>), uporządkowane priorytetem) — celowo, żeby identity provider, który nie wspierałby pluggable wariantów, był wyjątkiem, a nie normą.

Ten dispatch też nie jest darmowy: każde żądanie płaci teraz peek przed walidacją, żeby wybrać providera, zanim ruszy jakakolwiek kryptografia. Tanie obok weryfikacji podpisu, ale to drugi krok na krawędzi auth, którego projekt z jednym walidatorem nie miał.

W tym projekcie czai się ostry failure mode — ten typ rzeczy, który zamienia security seam w security hole. Gdyby dispatcher ponawiał próbę na następnym providerze po nieudanej walidacji wybranego providera, token pasujący do issuera A, ale oblewający podpis A, mógłby zostać „uratowany” przez providera B — federacyjny fail-open. Kontrakt zamyka to normatywnie: selekcja poprzedza walidację i jest od niej oddzielona; gdy provider zostanie wybrany, jego porażka jest terminalna, bez fall-through. Ten inwariant to obowiązkowa asercja TCK, nie komentarz w kodzie. Seam, którego za pierwszym razem nie zbudowałem, okazuje się mieć własność poprawnościową, której w ogóle bym nie wyspecyfikował, gdyby nie zmuszono mnie do zaprojektowania wersji pluggable.

To właśnie kupuje złapanie luki na poziomie seamu zamiast na produkcji: produkcyjna wersja tej lekcji to incydent fail-open, odkryty przez kogoś, kto nie jest tobą, w systemie, którego nie da się już tanio zmienić. Wersja z czasu projektowania to asercja TCK w RFC. Ta sama lekcja, dramatycznie inny koszt.

Propagacja tożsamości na zewnątrz — jak sparsowana tożsamość podróżuje do następnego serwisu w łańcuchu wywołań — to powiązane pytanie z własnym seamem (HttpClientRequestEnricher z ADR-032, który propaguje sparsowane nagłówki X-Tenant-Id / X-Principal-Id, zamiast przekazywać surowe tokeny bearer). Ta część już istnieje i jest celowo trzymana wąsko: kernel trzyma sparsowaną tożsamość, nie surowy credential, więc nie może wyciec tokena, którego nigdy nie zatrzymał. Seam wejściowy — SPI IdentityProvider — to ten, który RFC rozstrzyga, a v0.10 implementuje.


Dwa przeoczenia, jeden ślepy punkt

Z zewnątrz te dwa wyglądają jak różne kategorie błędu. Jedno to codec wtopiony w fasadę; drugie to walidator wtopiony w jednego providera. Inne podsystemy, inne konsekwencje, inne naprawy. Ale mechanicznie to ten sam błąd — i oblały tak samo.

Błąd jest ten sam: zbudowałem konkretną instancję tam, gdzie architektura potrzebowała pluggable seamu — Jackson wtopiony w klienta, OIDC wtopiony w jeden SecurityProvider. Obie działały, dla dokładnie tego jednego przypadku, który miał konsumenta patrzącego mu w oczy, gdy pisałem kod; kształt, którego potrzebowałbym dla drugiego przypadku, był już albo gdzieś zaprojektowany (serwerowe SPI enkodera), albo w osiemdziesięciu procentach obecny (walidator JWKS) — więc to nigdy nie była niewiedza o właściwym projekcie. Failure mode też jest ten sam: obie luki były niewidoczne od wewnątrz kernela i oczywiste od strony konsumenta. Defekt nigdy nie był w kodzie, który czytałem — był w relacji między tym kodem a konsumentem o warstwę czy dwie dalej, a relacji nie zobaczysz, gapiąc się mocniej w jeden jej koniec.

To, co faktycznie złapało obie luki, to zajęcie pozycji konsumenta przeze mnie samego. Lukę w codeku znalazłem, bo piszę tooling, który generuje kod pod kernel. Lukę w walidatorze znalazłem, bo buduję produkt, który działa na kernelu. W obu przypadkach przeszedłem ze strony autora granicy na stronę konsumenta, przeczytałem kontrakt stamtąd — i luka była natychmiast oczywista. Nie dlatego, że zmądrzałem w trakcie przejścia — dlatego, że luka jest widoczna tylko z tamtej strony.

To uogólnia się poza mną — i nie w pochlebnym kierunku. W większości zespołów osoba, która projektuje kontrakt, jest strukturalnie odcięta od zajęcia pozycji jego konsumenta. Wydaje kontrakt; inny zespół konsumuje go dwie warstwy niżej; luka wychodzi miesiące później jako incydent na produkcji, którego nikt nie naprawi tanio, bo kontrakt już stwardniał i zebrał zależności. Gaszenie pożaru nie jest oznaką, że ktoś był nieostrożny. To domyślny wynik podziału pracy, w którym projektant nigdy nie stoi tam, gdzie luka jest widoczna. Złapałem swoje na seamie projektowym zamiast na produkcji z jednego strukturalnego powodu, który nie ma nic wspólnego z dyscypliną: tak się składa, że siedzę po obu stronach granicy, którą zaprojektowałem.

Nie sądzę, żeby wniosek brzmiał „zawsze bądź własnym konsumentem” — to często niemożliwe, a postawione tak szeroko jest tym rodzajem rady, która jest prawdziwa i bezużyteczna. Wąska, wykonalna wersja brzmi tak: luki kontraktu żyją na jego krawędzi konsumenta, więc ktoś musi przeczytać kontrakt od strony konsumenta, zanim stwardnieje. Jeśli nie może to być projektant, musi to być prawdziwy konsument wciągnięty wcześnie — nie recenzent oceniający kontrakt na jego własnych warunkach, lecz ktoś zmuszony faktycznie budować przeciw niemu. Najtańszy moment na znalezienie tych luk jest przed tym, jak kontrakt ma zależności. Gdy już je ma, ta sama naprawa kosztuje skoordynowaną zmianę SPI plus migrację każdego konsumenta, który utwardził się względem starego kształtu — czyli stan, w którym większość zespołów odkrywa lukę.

Rekursja z sekcji o HttpClient jest dowodem, że to nie problem siły woli. Zostawiłem czwartą ćwiartkę codeka hardcodowaną podczas aktywnego naprawiania pozostałych trzech. Byłem tak wyczulony na ten dokładnie failure mode, jak tylko będę kiedykolwiek — i wciąż mi umknął, bo czwarta ćwiartka żyła w konsumencie, którego jeszcze nie przeczytałem ponownie. Nie pokonasz tego, próbując mocniej. Pokonasz to, organizując sobie przeczytanie każdego konsumenta od początku do końca, zanim kontrakt wyjdzie — albo godząc się, że te, których nie przeczytasz, są tam, gdzie czeka następna luka.


Czego to nie rozwiązuje

Ta heurystyka — przeczytaj kod konsumenta, zanim kontrakt stwardnieje — ma granicę, co do której chcę być szczery.

Zakłada, że istnieje konsument do przeczytania. Gdy projektujesz kontrakt naprawdę przed jakimkolwiek konsumentem, nie ma jeszcze nic do przeczytania z drugiej strony, a „bądź własnym konsumentem” degeneruje się w zgadywanie. Metoda też nie jest darmowa: wciągnięcie prawdziwego konsumenta wcześnie, zanim kontrakt się ustabilizuje, spowalnia projekt i sprzęga dwa ruchome cele. Ja wchłonąłem ten koszt tanio, bo konsument i kontrakt są oba moje. Zespół, który płaci go świadomie, robi prawdziwy trade, nie zbiera darmowej wygranej.

I nie domyka historii tożsamości. Seam IdentityProvider jest tylko wejściowy — token do PrincipalContext. Wyjściowa tożsamość service-to-service poza sparsowanymi nagłówkami (token exchange, on-behalf-of, client-credentials) jest zarezerwowana w RFC jako przyszły seam OutboundCredentialProvider, nie zbudowana. Mesh zero-trust, który potrzebuje, żeby kernel wybił downstreamowy credential, to dokładnie przypadek, który ten projekt nazywa, a potem odkłada.


Co dalej

Konkretnie, po stronie kernela:

SPI codeka HttpClient jest wydane. ADR-034 (client-side request encode + response decode, fasada KernelWebClient) i ADR-036 (server-side request decode, domykające macierz 2×2) weszły oba w kernelu 0.8.0; czteroćwiartkowy seam jest w kernelu działającym dziś, 0.8.1. Naprawa nie była sprzątaniem — była warunkiem wstępnym. Tier-neutralnego generatora nie dało się napisać, póki codec był wtopiony w fasadę, więc seamy, które kernel wydał w 0.8.0, to dokładnie to, co odblokowało generator, który ich potrzebował. Naprawa kontraktu w wydanym kernelu oczyściła następną warstwę w górę. Nie naprawiłem codeka w próżni — naprawiłem go, bo wymagała tego rzecz, którą budowałem dalej.

I następna warstwa poszła za tym. Generatory, które konsumują te seamy — KernelHandlerGenerator, KernelClientGenerator — zostały od tamtej pory przepisane, żeby ich używać: handler rozwiązuje ciało żądania przez registry decoderów zamiast przez wstawione wywołanie Jacksona, a klient emituje tier-neutralny KernelWebClient, co domyka też oryginalny wyciek nazwy tier, którym ten artykuł się otworzył. Ten rewrite jest zmergowany na głównej linii toolingu. To, co wciąż jest pending, jest węższe niż rewrite — to sam release toolingu: tooling siedzi na 0.5.0-SNAPSHOT bez wyciętego taga, celowy hold, którego następny tag czeka na milestone Capabilities/SKU — tak samo, jak kernel otagował 0.8.0 dopiero, gdy jego seamy były gotowe. Pętla jest domknięta w źródle — seam kernela wydany, konsumujące generatory przepisane — a jedyne, co wciąż jest sekwencjonowane, to release, który to wysyła.

SPI IdentityProvider jest zdecydowane i zarezerwowane — kierunek jest zablokowany przez RFC (dedykowane SPI, SecurityProvider jako dispatcher, OIDC-first jako driver referencyjny, fail-closed terminalna selekcja). ADR, który blokuje detal, i implementacja — oba wchodzą w v0.10. Najpierw wchodzi zależność nośna: rotacja kluczy JWKS z oknem nakładania, w v0.9, którą driver OIDC potem konsumuje.

Żadna z tych napraw nie jest interesującą częścią. Interesującą częścią jest pytanie, które przepuszczam teraz przez każdy kontrakt, zanim nazwę jego projekt zamkniętym: kto to konsumuje — i czy przeczytałem jego kod, nie swój, od początku do końca? To słabe pytanie. Nie brzmi jak architektura. Ale łapie tę konkretną klasę luki, na którą mocne myślenie architektoniczne jest strukturalnie ślepe — i po dwóch instancjach tego samego ślepego punktu ufam słabemu pytaniu bardziej niż własnej pewności, że projekt jest kompletny.


Omawiane tu SPI codeka żyje w exeris-kernel-spi pod eu.exeris.kernel.spi.http; fasada KernelWebClient jest w exeris-kernel-core. Seam bezpieczeństwa (SecurityProvider, PrincipalContext, StorageContext) jest w exeris-kernel-spi pod eu.exeris.kernel.spi.security. Zapisy decyzji — ADR-034, ADR-036, ADR-032 i RFC o IdentityProvider — są w drzewie docs kernela: 🔗 exeris-systems/exeris-kernel