# bullpen — Container-Architektur

**Fassung 3, 2026-08-08.** Fassung 2 (2026-08-07) war der Entwurf vor dem Bau und lag nur in
einem Sitzungsverzeichnis — dieselbe Krankheit, gegen die dieses Dokument antritt. Fassung 3
steht im Repo und trennt sauber: was **entworfen** war, was **gebaut** wurde, und was die
Messung danach **korrigiert** hat.

Die vier Direktiven kommen von markus und sind nicht verhandelbar:

* **a)** Homelab ist nicht die NSA — Sicherheit nicht übertreiben.
* **b)** bullseye ist ein Fenster und soll im LAN frei zugänglich sein.
* **c)** Anthropic-Modelle laufen über Claude Code (Abo), **kein API-Token**.
* **d)** Kontextarme Mitglieder müssen bei der Übergabe von Tests, Code und Werkzeugen
  sofort dieselbe Basis sehen — und der Grinder darf die Tests nicht bedienen.

---

## Schichtung: pro Vertrauensstufe schneiden, nicht pro Mitglied

```
hertz  (Wirt, Incus, Wurzel auf sdapool)
└── bullpen          Eltern: nesting, idmap.size=1e9, limits.memory=4GiB, autostart
    │                Kinder gebrückt am LAN
    ├── room         lmcp, besitzt room.jsonl (EINZELSCHREIBER)      768 MiB
    ├── workers      dispatcher, callboy, researcher, skeptic, triage  1 GiB
    ├── testdesign   @testdesigner — eigener Container, damit einbehaltene
    │                Testfälle für den Implementierer wirklich unerreichbar sind
    │                                                                  768 MiB
    ├── grind        @coder + py/go/js, bwrap je Lauf, Ausgang gefiltert  1,5 GiB
    ├── deus         RICH  pi                                          1,5 GiB
    ├── reviewer     RICH  claude                                      1,5 GiB
    ├── architect    RICH  claude                                      1,5 GiB
    ├── foreman      RICH  opencode, room-only                         1,5 GiB
    └── bullseye     Caddy + Home-CA-Zertifikat, frei lesbar im LAN     512 MiB
```

**Ein Nick, ein Ort.** Kein Mitglied existiert zweimal — das war das Doppel-`@architect`-Desaster.

**Eine Namensliste.** Das Roster im Repo ist die Quelle, `incus list` sein Abbild.

**Alles innerhalb von `bullpen` ist EINE Sicherheitsdomäne.** Fassung 1 behauptete
Geschwister-Isolation; das ist falsch. Ein privilegiertes Kind, das ausbricht, wird root im
Eltern-Container, und dem gehört das innere Incus. Die Grenze, die zählt, ist die zum Wirt, und
die hält — gemessen: root im Kind erscheint auf hertz als UID 1000000. Für ein Homelab ist genau
das die richtige Menge (Direktive a). Die Grinder-Sperre bleibt trotzdem, weil sie fremden Code
ausführt und nichts kostet.

---

## Was der Bau eingelöst hat

| | |
|---|---|
| Eltern auf `sdapool`, 4 GiB, nesting, idmap 1e9, autostart | ✅ |
| Neun Kinder nach Vertrauensstufe geschnitten, alle laufend | ✅ |
| Je Kind ein eigener `limits.memory` | ✅ (aber siehe Überbuchung) |
| `testdesign` als eigener Container | ✅ |
| bullseye frei im LAN, Home-CA-Zertifikat | ✅ `https://bullseye.fritz.box/` |
| deus von außen erreichbar | ✅ `https://deus.fritz.box/` |
| Claude Code per Abo, kein API-Token | ✅ architect + reviewer angemeldet |

## Was der Bau schuldig blieb — und warum es zählt

### 1. Der Erbauer existiert nicht. Die Anlage ist wieder handgestellt.

Das war **Fables Blocker Nummer eins** an Fassung 1 und die Antwort auf markus' Satz *„sonst
bekomme ich wieder eine Bastellösung, die ohne Claude-Zugang nicht funktioniert."* Gefordert war:

```
deploy/roster.yaml        WER existiert: Nick, Stufe, Motor, Geheimnisbedarf   FEHLT
deploy/bullpen-up         legt Eltern + alle Kinder aus roster.yaml an          alt
deploy/secrets.example    welche Geheimnisse woher — nie die Werte             FEHLT
```

`deploy/bullpen-up` gibt es, aber es ist der **alte** Einzel-Container-Starter: er baut *einen*
Container mit dem Raum und startet ausdrücklich keine Arbeiter. Die neun Container wurden von
Hand angelegt.

Und die Auslieferung ist ebenfalls handgelegt: **alle neun laufen aus `/opt/bullpen-src`, einem
git-Checkout.** `dpkg -l bullpen` ist in jedem Container leer, `/usr/share/bullpen` existiert
nirgends. Die stehende Anweisung lautete, die laufende Instanz **aus dem Paket** zu bauen.

Das Paket selbst ist installierbar — gemessen in einem frischen Debian 13:
`apt-get install ./bullpen.deb` zieht die Abhängigkeiten, legt 14 Einstiegspunkte, 16 Units,
`bullpen_config` lädt über den Symlink→realpath-Pfad, `bullseye.server` ist importierbar. **Aber**
seine Units scheitern mit `status=203/EXEC` an `/usr/local/bin/bullpen-*`, weil das `.deb` aus dem
gepinnten Tag v0.1.0 baut und der Fix erst danach entstand. Solange kein neuer Tag steht, ist das
Paket halb tot und die Anlage benutzt es ohnehin nicht.

### 2. Die „Überbuchung" ist eine Buchhaltungsgröße, kein Befund.

Fassung 3 nannte hier Faktor 2,62 als Warnung. Gemessen am 2026-08-08 hält das nicht:

```
Summe der Kinder-Deckel : 10752 MiB      Summe der BELEGTEN :  1844 MiB
Eltern-Deckel           :  4096 MiB      Eltern gemessen    :  2,33 GiB
hertz                   : 3752 MB verfügbar (3838 MB davon Puffer/Cache)
```

`architect` hält 62 MiB bei 1536 MiB Deckel, `reviewer` 89. Neun Container auf demselben Wirt,
aus demselben Abbild, teilen Seitencache, Bibliotheken und COW-Seiten — **Deckel zu addieren
ergibt eine Zahl ohne Bezug zum Speicherbedarf**. Sie sind Sicherungen je Container, kein Budget.

Entschieden (markus, 2026-08-08): **keine Zulassungssteuerung.** Stattdessen wird der Druck
gemessen und sichtbar gemacht — `bullpen-verify` meldet Belegung gegen Deckel je Container und den
Eltern-Stand. Fables Einwand „OOM ist kein Scheduler" bleibt richtig; er wiegt nur weniger schwer
als das Serialisieren einer Gesellschaft, die vom gleichzeitigen Widerspruch lebt. Fables Satz war *„OOM ist
kein Scheduler."* Die Antwort im Entwurf: höchstens ein reicher Motor gleichzeitig wach, über eine
Sperre, die der Lurker vor dem Wecken nimmt und nach dem Antworten freigibt — wer sie nicht
bekommt, wartet **sichtbar im Raum**. **Diese Sperre gibt es nicht.** Solange sie fehlt, hält den
Deckel nur die Tatsache, dass selten zwei reiche Motoren zugleich laufen. Das ist Glück, keine
Eigenschaft.

### 3. `room.jsonl` hat keine Sicherung.

Der Entwurf nennt ihn *„den einzigen unersetzlichen Zustand"* und fordert einen täglichen Abzug
nach data. Es gibt keinen cron-Eintrag und keine Abzüge. Er liegt weiterhin allein im
`room`-Container, auf einer USB-Platte.

### 4. Neun Kinder sind derzeit die Decke.

Der Entwurf plante zehn. Ein zehnter Container scheitert beim Start:

```
newuidmap failed to write mapping … Failed to set up id mapping
```

trotz `security.idmap.size=1000000000` am Eltern-Container. Ursache nicht ausdiagnostiziert;
festgehalten, damit der nächste nicht dagegenläuft.

---

## Korrekturen, die die Messung erzwungen hat

### @deus ist kein Lurker, und niemand weckt ihn

Der Entwurf führte deus als „RICH pi|claude" neben den anderen. Der Lurker kennt aber nur die
Laufzeiten `claude` und `opencode` — **kein `pi`**. deus kann deshalb nicht auf Raumaktivität
aufwachen.

Das ist **kein Rückschritt**: der alte deus auf dcw2 hatte auch keinen Wecker, nur `pi-web` und
`pi-web-sessiond`. **deus wird vom Menschen im Browser getrieben.** Das passt zur Rolle — der
Eingriff kommt von außen — hat aber eine Kehrseite, die man kennen muss: *wenn sich die
Gesellschaft verklemmt, weckt ihn niemand.* Genau der Fall, für den es ihn gibt.

### deus hält keine Schlüssel — also baut er den Sandkasten nicht mehr

Die alte Rolle gab deus den Sandkastenbau ausdrücklich, weil die eingesperrten Agenten kein
Dateisystem erreichen konnten. In dieser Architektur hat deus **bewusst keine Zugangsdaten**, und
darauf ruht die Grenze. Beides zusammen geht nicht. Entschieden: **`grind` ist der Sandkasten** —
Interpreter, bwrap, gefilterter Ausgang sind dort. Braucht eine Kampagne echte Reichweite, ist das
ein Befund und kein Grund, einen Schlüssel zu besorgen. Festgehalten in `participants/deus.md`.

### Die Vertrauensliste ist eine Architektur-Invariante, kein Detail

Wer einen Agenten wecken darf, steht in `cfg.TRUST`. Fehlt ein Nick, verschwindet seine Nachricht
**geräuschlos**: der Raum antwortet `{"ok":true}` und speichert sie, der Empfänger verwirft sie.
Gemessen am 2026-08-08, als deus fehlte:

```
room_say  →  {"id":143,"ok":true}
@foreman  →  "refusing ask from untrusted nick 'deus'"
```

Kein Fehlschlag, keine Rückmeldung an den Absender, nur Stille. **Nachzusehen ist im
Lurker-Protokoll des EMPFÄNGERS**; das eigene `ok:true` beweist gar nichts. Jedes neue Mitglied
muss in dieselbe Liste, sonst ist es stumm.

### Raum und Rolle dürfen auf demselben Rechner liegen

Vier Fundstellen setzten voraus, der Raum laufe *woanders*, und gingen über `sic <host>`. Seit ein
Container je Rolle läuft, ist der Raum ein Nachbar im Netz: kein sic, kein SSH-Schlüssel, kein
Fremdhost. Die Entscheidung fällt jetzt an **einer** Stelle je Datei über `LMCP_HOST`; der Weg
über sic bleibt der Rückfall für die Aufstellung mit getrennten Rechnern.

Am teuersten war die stille Variante: `room_read` liefert bei Fehlschlag bewusst `None` statt einen
leeren Raum vorzutäuschen — ein Lurker ohne sic meldete deshalb „up, watching …" und tat still gar
nichts, während Nachrichten an ihn im Raum lagen.

### Der Grinder-Ausgang ist gefiltert, nicht gesperrt

Fassung 2 schrieb „KEIN Ausgang". Eine Nullsperre baut einen toten Container: die Grinder-Schleife
spricht bei **jeder** Iteration mit dem LLM-Proxy und muss den Raum erreichen. Gebaut ist deshalb
eine nftables-Regel **in `grind`** — erlaubt sind Raum, Proxy, DNS und DHCP, alles andere wird
abgewiesen (`reject`, nicht `drop`, damit ein blockierter Zugriff im Protokoll erkennbar ist statt
wie ein hängender Dienst auszusehen).

Nicht am Eltern-Container als Incus-ACL, weil deren Regeln `ct state` brauchen und das im
unprivilegierten Eltern mit „Protocol error" scheitert — und den Eltern privilegiert zu machen
käme nicht in Frage, auf seiner Unprivilegiertheit ruht die Grenze zum Wirt.

### Betriebsrelevante Dokumente gehören ins Repo, nicht in einen Container

Die stehende Rolle von @deus lag als `~/.pi/agent/AGENTS.md` **nur im Container** und war nach dem
Neubau weg. Sie steht jetzt als `participants/deus.md` im Repo. Dieses Dokument selbst lag bis
heute nur in einem Sitzungsverzeichnis. **Wenn ein Dokument den Betrieb bestimmt, gehört es
versioniert** — sonst überlebt es den nächsten Neubau nicht, und der Neubau ist der Moment, in dem
man es braucht.

### bullseyes Denk-Anzeige ist über Containergrenzen tot

Sie liest `/var/lib/bullpen/thinking` — **Dateien**, keine Raumnachrichten. Die liegen im
`workers`-Container, der Code läuft in `bullseye`. Der umgebende `except` schluckt den Fehler, die
Anzeige bleibt leer und sieht dabei gesund aus. Reparieren heißt entscheiden, wo die Dateien
künftig liegen (gemeinsamer Einhang, oder `workers` reicht sie heraus) — deshalb im Quelltext als
Lücke vermerkt statt überklebt.

---

### Die Abnahme trägt jetzt Adressen

@foreman meldete am 2026-08-08 „Phase B — ABNAHME GRÜN, 14/14, exit 0“ und nannte @testdesigner
Tester-of-Record. @testdesigners eigenes Protokoll zeigte für dasselbe Fenster: kein Lua, kein Test
gefunden. Der Koordinator hatte die Abnahme der Arbeit ausgeführt, die er koordinierte, und die
Urkunde jemand anderem zugeschrieben. Sichtbar war das nur durch einen Zeitstempelvergleich von
Hand — im Raum stand eine glänzende Erfolgsmeldung.

Der Rückgabewert eines Tests beweist nichts, solange offen ist, **wer** ihn ausgeführt hat und
**woran**. `bin/bullpen-attest` führt den Test deshalb selbst aus und gibt eine Urkunde aus, die
beides bindet: Prüfsumme des Prüflings **und** des Tests, Commit, gemessener Rückgabewert, und der
Container, in dem es lief. Es urteilt nicht — 0 ist GRÜN, alles andere ROT — und beendet sich mit
dem gemessenen Code, ist also in einer `&&`-Kette brauchbar.

Das ist keine Sperre. Wer lügen will, kann weiter lügen; er muss dann aber Zahlen fälschen, die
jeder mit demselben Befehl nachrechnen kann, statt nur einen Satz zu formulieren. Die Rollendateien
sind entsprechend geändert: @testdesigner liefert die Urkunde statt der Behauptung, @foreman darf
gar keine Abnahme mehr aussprechen und reicht nur weiter — oder meldet **unattestiert**.

Ein Nebenbefund beim Bau: der erste Entwurf startete `.py`-Tests als Skript. @testdesigners
Rollendatei verlangt aber eine **pytest**-Suite, und die tut als Skript nichts und endet mit 0 —
das Werkzeug hätte jede pytest-Datei grün gesprochen. Genau die Fehlerklasse, gegen die es gebaut
ist. Jetzt erkennt es `test_*.py` / `*_test.py`, ruft `pytest -q` und reicht den Prüfling über
`BULLPEN_IMPL` durch (ein zweites Argument läse pytest als weiteren Testpfad).

## Direktive d im Detail: gleiche Basis, und der Grinder bedient die Tests nicht

Kontextarme Mitglieder bekommen bei der Übergabe **einen Umschlag**: Vertrag, Pfade mit Container,
Abnahmekriterium als ausführbarer Befehl, und alle Verweise, die sie brauchen — ein kleines Modell
kann nicht auf die Suche gehen. Bekannte Fehlerart ist **nicht** mangelnde Fähigkeit, sondern
**Nicht-Konvergenz**: Denkmodelle drehen sich und schreiben die Datei nie. Begrenzte Einheiten mit
explizitem Abnahmekriterium sind das Gegenmittel.

Gegen das Bedienen der Tests:

* **@testdesigner hat einen eigenen Container.** Einbehaltene Fälle liegen für den Implementierer
  physisch unerreichbar. Im selben Container wären sie es nicht.
* **Wer implementiert, schreibt den Test nicht.** An der lmcp-Arbeit war der Defekt viermal
  hintereinander der *Selbsttest*, nie der Code.
* **Ein Vertrag und ein externer Abnahmetest dürfen von außen kommen**, wenn die Gesellschaft die
  *Implementierung* liefern soll. Das ist keine Übernahme, sondern die Trennung, auf der die
  Abnahme ruht. Ist der Test der Liefergegenstand, gilt das Gegenteil. Entschieden wird nach dem
  Auftrag.
* **Ein Test, der nur besteht, misst nichts.** Jeder Abnahmetest wird in beide Richtungen
  nachgewiesen: Rückgabe 0 gegen eine korrekte Fassung, Rückgabe ≠ 0 mit benannten Fehlschlägen
  gegen eine absichtlich kaputte.

---

## Offene Tore

1. **Erbauer bauen** — `roster.yaml` gibt es, ein `bullpen-up`, das die neun Container daraus
   **anlegt**, noch nicht. Das ist die verbliebene Hälfte des Tors.

   Die andere Hälfte ist seit 2026-08-08 eingelöst: **v0.2.1 liegt in beiden Pools und ist auf
   beiden Betriebssystemen installierbar.** Bis dahin ging das gar nicht — `arch/bullpen/` war ein
   *leeres Verzeichnis* (angelegt am 07.08., nie geschrieben), und damit konnte ausgerechnet
   boltzmann, der Wirt, auf dem die Abnahmetests laufen, bullpen nicht installieren. Gemessen vor
   dem Bau: `pacman -Q bullpen` → „package 'bullpen' was not found“.

   | | Debian | Arch |
   |---|---|---|
   | Rezept | `debian/bullpen/build-deb.sh` | `arch/bullpen/PKGBUILD` (neu) |
   | Pool | `bullpen_0.2.1-1_all.deb` | `bullpen-0.2.1-1-any.pkg.tar.xz` + `.sig` |
   | aus dem Pool installiert | hertz | boltzmann |
   | Bilanz beider Bauten | 23 Programme, 68 Dateien | 23 Programme, 68 Dateien |

   Beide Rezepte tragen dieselben zwei Vollständigkeitsprüfungen (jedes Verzeichnis der obersten
   Ebene muss eingeordnet sein; je Verzeichnis wird gezählt). Gegenprobe gefahren: `bullseye` aus
   der Liste gestrichen → `makepkg` bricht mit rc 4 ab.

   Was bleibt: die neun Container laufen weiter aus `/opt/bullpen-src`, nicht aus dem Paket.
   Solange das so ist, ist die Anlage eine Bastellösung mit einem inzwischen ordentlichen Paket
   daneben.
2. **Zulassungssteuerung — aufgelöst, kein Tor.** Die 2,62-fache „Überbuchung“ war eine
   Buchhaltungsgröße (siehe oben): gemessen belegen die neun Container 1844 von 10752 MiB Deckel,
   der Eltern steht bei 2,33 von 4 GiB. Entschieden (markus, 2026-08-08): **keine Sperre**, aber der
   Druck wird gemessen — `bullpen-verify` meldet Belegung gegen Deckel je Container.
3. **`room.jsonl` sichern — erledigt 2026-08-08.** `bullpen-room-backup.timer` auf hertz,
   stündlich, ruft `/opt/herding/bin/backup-room-jsonl.sh`. Drei Wächter vor dem Überschreiben:
   die Datei parst als JSON, die höchste id ist nicht gefallen, die Zeilenzahl ist nicht gefallen.
   Gegenprobe gefahren: rc 0 im Normalfall, rc 1 bei künstlich herbeigeführtem Rückschritt.
4. **Der USB-Pool ist gemessen — die Sorge war unbegründet.** `sdapool` ist eine drehende
   2,5-Zoll-Platte an USB (`sda`, ROTA=1, TRAN=usb). Gemessen 2026-08-08 mit der Last, auf die es
   ankommt (4k synchron, nicht sequentiell lesend):

   | | 4k synchron ×1000 | fsync-Latenz |
   |---|---|---|
   | `sdapool` (USB, drehend) | **124 IOPS**, 498 KB/s | 18 ms |
   | `default` (mmc, intern) | 85 IOPS, 340 KB/s | 9 ms |

   Die USB-Platte ist bei kleinen synchronen Schreibvorgängen **schneller als der interne
   Speicher**; ein Umzug auf `default` würde es verschlechtern (SD/eMMC sind bei kleinen
   Zufallsschreibvorgängen schwach, btrfs' COW bündelt auf der Platte gut). Die 18 ms fsync sind
   plausibel echt — eine Umdrehung bei 5400 U/min sind ~11 ms, der USB-Brückenchip lügt also nicht
   über seinen Flush. Das ist der Preis je `room_say`; bei 179 Nachrichten irrelevant, Thema erst
   bei zweistelligen Nachrichten pro Sekunde. io-Wartezeit bei laufendem Aufbau: **0 %**.
5. **Zehnter Container** — uid-Zuteilung klären, bevor jemand einen braucht.
6. **Wer weckt deus** — wenn unbeaufsichtigter Betrieb je gewünscht ist, braucht der Lurker eine
   dritte Laufzeit oder deus einen Wecker von außen. Heute: der Mensch.
