# Middleware, Agent & Memory — Handoff

Starting point for a fresh session. This document is self-contained; the
previous conversation is not carried over.

**Sprachkonvention:** Prosa auf Deutsch (byte5-Arbeitssprache), Code-
Identifier und Tool/API-Namen auf Englisch. Scope dieser Session:
**Backend**, also `middleware/` + `skills/`. Für `web-ui/` existiert ein
separater Handoff unter [docs/dev-frontend-handoff.md](dev-frontend-handoff.md).

---

## 1. Was byte5 hier baut

Ziel: eine Middleware als **Single Point of Answer** für interne Fragen
zu Odoo-Produktion + Confluence-Playbook + (später) weiteren Systemen.
Langfristperspektive: Unternehmensintelligenz auf Knowledge-Graph-Basis,
nicht nur ein Chatbot.

### Drei-Schichten-Architektur (mental model)

1. **Execution-Layer** — Orchestrator + Domain-Sub-Agents. Tool-Loop gegen
   Anthropic Claude. **Läuft vollständig lokal in-process** (kein Managed
   Agent mehr).
2. **Knowledge-Layer** — Lokaler Knowledge-Graph über Sessions, Turns und
   die Odoo/Confluence-Entities, die sie berührt haben. Wird beim
   Startup aus den Markdown-Transkripten rehydriert.
3. **Retrieval-Layer** — Noch nicht gebaut. Vector-Store + GraphRAG-
   Queries sind auf der Roadmap.

### Entry-Points für User

- **Teams-Bot** — Bot Framework, `/api/messages`. In Prod via Fly.
- **HTTP Chat** — `/api/chat` (blocking) + `/api/chat/stream` (NDJSON).
  Wird von der Dev-UI (`web-ui/`) genutzt.

---

## 2. Verzeichnis-Layout

```
/Users/johndoe/sources/odoo-bot/
├── agent-config-accounting.yaml       # Alte Managed-Agent-Configs,
├── agent-config-confluence.yaml       # werden nicht mehr aktiv genutzt
├── agent-config-hr.yaml               # (Referenz für Skill-Descriptions)
├── agent-config.yaml
├── docs/
│   ├── day-one-learnings-2026-04-17.md
│   ├── dev-frontend-handoff.md        # UI-Handoff (separater Scope)
│   └── middleware-agent-handoff.md    # DIESES Dokument
├── middleware/                        # FOKUS dieser Session
│   ├── package.json
│   ├── tsconfig.json
│   ├── src/
│   ├── test/
│   ├── scripts/
│   ├── seed/memory/                   # Wird beim Startup in /memories/_rules/ kopiert
│   ├── fly.toml
│   ├── Dockerfile
│   └── .env                           # Lokale Credentials (nicht im Repo)
├── skills/
│   ├── odoo-accounting/SKILL.md       # System-Prompts für Sub-Agents
│   ├── odoo-hr/SKILL.md
│   └── confluence-playbook/SKILL.md
├── scripts/                           # Repo-level Scripts (nicht Middleware-scripts!)
└── web-ui/                           # Dev-UI, eigener Scope
```

### `middleware/src/` im Detail

```
src/
├── index.ts                           # Bootstrap, Wiring, Express-Setup
├── config.ts                          # zod-Schema + .env-Loading
├── memory/
│   ├── store.ts                       # MemoryStore-Interface (Port)
│   ├── filesystem.ts                  # FS-Backend mit Path-Traversal-Schutz
│   └── seeder.ts                      # Kopiert Seed-Files beim Startup
├── routes/
│   ├── chat.ts                        # POST /chat + /chat/stream (NDJSON)
│   ├── messages.ts                    # Teams-Bot-Adapter
│   ├── admin.ts                       # Authentifizierte Memory-Mutation
│   ├── devMemory.ts                   # Unauth. Memory-Browser (flag-gated)
│   └── devGraph.ts                    # Unauth. Graph-Inspect (flag-gated)
├── services/
│   ├── orchestrator.ts                # Tool-Loop, chat + chatStream
│   ├── localSubAgent.ts               # Generischer Sub-Agent-Loop
│   ├── odooClient.ts                  # JSON-RPC + UID-Cache + TLS-Bypass
│   ├── odooCore.ts                    # Whitelist + Red-Line-Filter (shared)
│   ├── odooToolkit.ts                 # Baut odoo_execute-Tool pro Scope
│   ├── odooEntityExtractor.ts         # Odoo-Response → EntityRefs
│   ├── confluenceClient.ts            # REST-Wrapper
│   ├── confluenceCore.ts              # Space-Scoping + EntityRef-Publish
│   ├── confluenceToolkit.ts           # 5 Confluence-Tools
│   ├── confluenceEntityExtractor.ts   # Response → EntityRefs
│   ├── skillLoader.ts                 # Parse SKILL.md (Frontmatter + Body)
│   ├── sessionLogger.ts               # Schreibt Markdown + feedet Graph
│   ├── sessionTranscriptParser.ts     # Reverse von SessionLogger (für Backfill)
│   ├── entityRefBus.ts                # Publish/Subscribe mit Turn-Korrelation
│   ├── turnContext.ts                 # AsyncLocalStorage-Wrapper
│   ├── knowledgeGraph.ts              # Interface + ID-Helper (shared)
│   ├── inMemoryKnowledgeGraph.ts      # Aktive Implementierung
│   └── graphBackfill.ts               # Liest alle Transkripte → Graph
├── tools/
│   ├── memoryTool.ts                  # Wrapper um Anthropic memory_20250818
│   ├── domainQueryTool.ts             # DomainTool-Interface (akzeptiert Askable)
│   └── knowledgeGraphTool.ts          # query_knowledge_graph für Orchestrator
└── types/
    └── entityRef.ts                   # Gemeinsamer EntityRef-Type
```

---

## 3. Die Execution-Layer im Detail

### Orchestrator

- **Datei:** `services/orchestrator.ts`
- **Rolle:** Top-Level-Agent im Teams-Bot / HTTP-Chat-Kontext.
- **Model:** `claude-opus-4-7` (konfigurierbar via `ORCHESTRATOR_MODEL`).
- **Tools:**
  - `memory` (Anthropic managed memory_20250818, beta-header
    `context-management-2025-06-27`)
  - `query_knowledge_graph` (unser eigenes Tool, nur wenn Graph vorhanden)
  - `render_diagram` (unser eigenes Tool, nur wenn Kroki+Tigris-Stack
    konfiguriert — erzeugt Mermaid/PlantUML/Graphviz/Vega-Lite-PNG, gibt
    signierte Proxy-URL zurück, Teams-Adapter + Web-Dev-UI hängen Bild
    automatisch an die Card an. Vega-Lite = Chart-Engine für quantitative
    Daten: Balken/Line/Pie/Scatter aus einem JSON-Spec.)
  - Eine DomainTool-Instanz pro Sub-Agent (`query_odoo_accounting`,
    `query_odoo_hr`, `query_confluence_playbook`)
- **Methoden:** `chat()` blockierend, `chatStream()` als Async-Generator
  mit `ChatStreamEvent`-Events. Beide scopen ihren Turn via
  `turnContext.run` bzw. `turnContext.enter` für EntityRef-Korrelation.
- **System-Prompt:** Spricht Deutsch, liest zu Turn-Start `/memories/_rules`,
  nutzt Session-Transkripte nur auf Rückbezug, persistiert Learnings
  früh (im nächsten Tool-Call, nicht am Ende).

### Sub-Agents (lokal, in-process)

- **Datei:** `services/localSubAgent.ts` (`LocalSubAgent`-Klasse).
- **Rolle:** Ein Sub-Agent pro Domain. Interface `Askable` = `.ask(question): Promise<string>`.
- **Tool-Loop:** eigener kleinerer Loop gegen `messages.create`. Nutzt
  dasselbe SDK + Anthropic-Modell. Hat eigene `maxIterations`
  (`SUB_AGENT_MAX_ITERATIONS`, default 16) und eigene Tools.
- **Logging:** jede Tool-Ausführung loggt `[sub-agent <name>] <tool> → ok|ERR (<ms>, <chars>)`.
- **Kein Memory-Tool:** Sub-Agents sollen nicht eigenständig in den
  globalen Memory schreiben — nur der Orchestrator tut das. Das hält den
  Memory scharf fokussiert.

### Wer ruft wen?

```
User → Orchestrator.chatStream
  ├─ memory (orchestrator-eigene Writes)
  ├─ query_knowledge_graph (eigener Lookup)
  └─ query_odoo_hr (DomainTool)
        └─ LocalSubAgent.ask
              └─ messages.create + odoo_execute
                    └─ executeOdoo (services/odooCore.ts)
                          ├─ Whitelist-Check
                          ├─ Red-Line-Check (HR)
                          ├─ OdooClient.execute (JSON-RPC)
                          ├─ Red-Line-Strip (HR)
                          └─ entityRefBus.publish (tagged mit turnId)
```

### Channel → Orchestrator-Dispatch (per-Channel, Omadia UI)

Ein Channel-Turn erreicht den Orchestrator über den **`orchestratorDispatcher`**
(`TurnDispatcher` in `src/channels/coreApi.ts`, verdrahtet in `index.ts`).
`CoreApi.handleTurnStream(turn)` reicht `channelId` durch; der Dispatcher löst
**pro Turn lazy** den Ziel-Service aus der Service-Registry auf:

```
dispatchService = pluginCatalog.get(channelId)?.plugin.channel?.dispatch_service ?? 'chatAgent'
agent           = serviceRegistry.get<ChatAgentBundle>(dispatchService)?.agent
```

`resolveDispatchService` (`src/channels/dispatchService.ts`) kapselt den
Fallback. **Klassische Channels deklarieren kein `channel.dispatch_service`** und
landen unverändert bei `'chatAgent'`. Omadia UI setzt im Channel-Manifest
`dispatch_service: canvasChatAgent` (**bare Key, kein `@N`** — die Registry
strippt keine Versionen) und routet so seine Turns an den
`omadia-ui-orchestrator` (publiziert `canvasChatAgent@1`, runtime-Key
`canvasChatAgent`). Annahme: `IncomingTurn.channelId` == Plugin-Catalog-Id des
Channels; trifft das nicht zu, greift sicher der `'chatAgent'`-Default.
Zusätzliches additives Manifest-Feld: `channel.canvas_protocol_version`
(informativ; die echte Version wird im Boot-Handshake verhandelt).

### Canvas-Sentinels (Omadia UI, PR-7a)

Canvas-aware Tier-3-Tools (und der Canvas-Client für `_pendingMutation`)
emittieren strukturierte Payloads als **In-Band-JSON-Sentinels** im Tool-Result-
String — dasselbe Muster wie `_pendingUserChoice` / `_pendingRoutineList`
(`parseToolEmittedChoice` in `orchestrator.ts`). Neu in
`harness-orchestrator/src/canvasSentinels.ts`: die reinen Parser
`parseToolEmitted{StructuredPayload,CanvasTree,Mutation}` plus der
**`canvas-output`-Gate** (`isCanvasOutputAuthorized`, **deny-by-default**) —
ein Tool-Sentinel wird nur akzeptiert, wenn das Plugin die `canvas-output`-
Capability deklariert. Parser sind tolerant (malformed JSON / Shape-Mismatch →
`undefined`). **Noch nicht** in den Tool-Loop verdrahtet: das Enforcement plus
das beim Boot aus dem `pluginCatalog` berechnete Allow-Set (welche Tools
`canvas-output` führen) kommt mit dem Canvas-Orchestrator (PR-9), zusammen mit
den Tools, die diese Sentinels überhaupt erst erzeugen. Bis dahin emittiert
niemand sie — Wiring jetzt wäre spekulativ.
### Write-Capabilities + `structured?`-Tool-Output (Omadia UI, PR-8)

`plugin-api` additiv: `LocalSubAgentToolResult.structured?`
(`StructuredToolOutput` — die **getypte Alternative** zum
`_pendingStructuredPayload`-Sentinel-im-String) und der `WriteCapability`-
Vertrag (ein Tool deklariert pro `dataClass`/`operation`, was es mutieren darf).
**Wichtig:** `writeCapabilities` ist **kein** Feld auf `NativeToolSpec` — der
Spec geht via `buildToolsList` verbatim in die Anthropic-Tool-Liste, und
Anthropic lehnt unbekannte Felder ab (gleicher Grund, warum `piiFields` am
LocalSubAgentTool-**Wrapper** hängt, nicht am Spec). Der Anbindungspunkt
(Manifest-Annotation / Registration-Metadata, non-model-facing) wird mit dem
ersten Consumer (PR-9) verdrahtet. `deriveMutabilityCapabilities(caps, dataClass)` in
`plugin-api/src/writeCapabilities.ts` leitet daraus **deterministisch** (kein
LLM-Call) `editable` / `canAddItems` / `canRemoveItems` / `canReorder` ab:
`update`→editierbare Felder, `create`→`canAddItems` + Required-Fields,
`delete`→`canRemoveItems`, `reorder`→`canReorder`. **Fehlende Annotation ⇒
read-only** (strenger Default gegen Rollback-Hölle). Noch **nicht** verdrahtet:
Manifest-Loader-Parsing von `writeCapabilities` + System-Prompt-Emission +
das Threading von `structured` durch `localSubAgent.ts` kommen mit dem
Canvas-Orchestrator (PR-9, erster Consumer).

### omadia-ui-orchestrator (Tier-2, Skeleton, PR-9a)

Neues Plugin-Package `packages/omadia-ui-orchestrator/` (`@omadia/ui-orchestrator`,
`kind: extension`) — wird vom `builtInPackageStore` automatisch entdeckt (jeder
`packages/*`-Ordner mit gültiger `manifest.yaml`), **kein Boot-Edit nötig**. Es
publiziert `canvasChatAgent@1` (runtime bare-key `canvasChatAgent`) — den
Service, an den ein Canvas-Channel via `channel.dispatch_service` (PR-6)
dispatcht. **v0 ist ein dünnes Delegations-Skeleton:** `canvasChatAgent` leitet
`chat`/`chatStream` an den Basis-`chatAgent` weiter (pro Call lazy aufgelöst),
schließt damit die End-to-End-Plumbing, synthetisiert aber **noch keine
Canvas-Surface**. `requires: chatAgent@^1` (nur Ordering; ohne Basis hält das
Plugin zurück und degradiert sauber). CCM ist **kein** hartes `requires`. Die
echte Canvas-Arbeit — UI Skill (Kompositions-Idiom-Bibliothek), `surface_*`-
Synthese, per-`canvasSessionId`-Mutex, Cache — plus die aufgeschobenen Wirings
(PR-7b Sentinel-Gating, `writeCapabilities`-Anbindung, `structured`-Threading)
sind Folge-Slices. **Surface-Synthese aufgelöst durch PR-9b-1 (unten).**

### Tier-2 Surface-Synthese (Omadia UI, PR-9b-1)

Macht `canvasChatAgent` zum echten Stream-Transformer — **ohne den geteilten
Base-Orchestrator-Tool-Loop anzufassen**. Für einen **Canvas-Turn** (einen, der
`input.canvasSessionId` trägt) wickelt `canvasChatAgent` den `base.chatStream`-
Event-Stream in `synthesizeSurfaceEvents` (`packages/omadia-ui-orchestrator/src/
surfaceSynthesis.ts`): pro `tool_result` eines **autorisierten** Tools wird die
Ausgabe mit `parseToolEmittedCanvasTree` (#169) gescannt; bei einem
`_pendingCanvasTree`-Sentinel wird ein `surface_snapshot` synthetisiert und in
den Stream injiziert (per-Stream monotone `surfaceSeq` + Revision, gestempelt mit
`canvasSessionId`). Alle anderen Events passieren unverändert; Nicht-Canvas-Turns
und der `chat()`-Pfad gehen byte-genau durch.

- **Gate (deny-by-default):** nur Tools in `authorizedToolNames` werden gescannt.
  Das Set ist **heute leer** — die boot-berechnete canvas-output-Allow-Liste wird
  mit dem ersten Producer-Tool (PR-9b-2) verdrahtet; bis dahin ist der
  Synthesizer in Produktion korrekt inert (secure-by-construction). Der
  Gate-Mechanismus selbst ist live + getestet.
- **`canvasSessionId`-Threading** (2 geteilte Dateien, additiv): `ChatTurnInput`
  bekommt ein optionales `canvasSessionId`; der `orchestratorDispatcher` liest es
  aus den Turn-Metadaten (vom Canvas-Channel gesetzt, PR-10b) und reicht es in
  `chatStream` durch. Klassische Channels setzen es nie → unverändert.
- **Dependency:** `@omadia/orchestrator` als peerDep des ui-orchestrator (für die
  #169-Parser).

**Noch offen (spätere 9b-Slices):** der **Producer** (canvas-output-Tool / UI
Skill, das `_pendingCanvasTree` tatsächlich emittiert — bis dahin läuft die
Synthese nur in Tests) + das Allow-Set-Boot-Wiring (9b-2);
`_pendingStructuredPayload` → `surface_data_ref_created` (braucht DataRef-HMAC);
per-`canvasSessionId`-Mutex + cross-turn-`surfaceSeq`-Kontinuität + Cache (9b-3).

Test: `test/uiOrchestratorSurface.test.ts` treibt `synthesizeSurfaceEvents` mit
einem Fake-Base-Stream (autorisierter Sentinel → `surface_snapshot`; leeres
Allow-Set → nichts; kein Sentinel → nichts; Nicht-Tool-Events unverändert;
monotone `surfaceSeq`/Revision).

### Tier-2 Haiku-Komposition (Omadia UI, PR-9b-2)

Macht den `canvasChatAgent` zum echten Tier-2-Composer. Für einen Canvas-Turn
(`input.canvasSessionId` gesetzt) laufen drei Schritte; Nicht-Canvas-Turns und
`chat()` bleiben Byte-für-Byte-Passthrough (testbelegt, null LLM-Calls):

1. **Skeleton-first** (`src/composition.ts`): ein Fast-Tier-Call
   (`ctx.llm.complete`, Modell aus Setup-Feld `ui_orchestrator_model`, Default
   `claude-haiku-4-5`; Manifest-Gate `permissions.llm`) erzeugt
   `{ tree, dataRequirements }`. Der Tree wird **server-seitig gegen die
   Protokoll-Schemas validiert** (`src/treeValidator.ts`, Ajv 2020 über die
   nach `packages/omadia-ui-orchestrator/schema/` **vendorte** Kopie der
   omadia-ui-Spec 1.0); ein begrenzter Repair-Retry trägt die Validator-Fehler
   in den Prompt; danach deterministischer Fallback (`FALLBACK_SKELETON`) —
   die Komposition blockiert den Turn **nie** (auch ohne `ctx.llm`). Das
   Skeleton geht als `surface_snapshot` (Revision `"0"`) raus, **bevor** der
   langsame Hauptturn startet (~500ms-Ziel, implementation-plan Risiko #1;
   Spike-Gate: <95% First-Attempt-Validität → Modell auf Sonnet pinnen).
2. **Requirement-Handoff**: der delegierte Hauptturn bekommt die
   `dataRequirements` als `[canvas-context]`-Block an die `userMessage`
   angehängt (containerIds + exakte fieldKeys + Instruktion) — Tier-3
   Sub-Agents liefern ihre `_pendingStructuredPayload`s damit genau passend
   zu dem, was das Skeleton versprochen hat.
3. **Synthese-Fortsetzung** (`src/surfaceSynthesis.ts`, erweitert):
   `startSurfaceSeq`/`baseRevision`/`baseTree` setzen die Zähler **nach** dem
   Skeleton fort. Neu: `_pendingStructuredPayload` (autorisiertes Tool) wird
   **deterministisch, ohne LLM-Call** auf das Skeleton gemappt
   (`src/patchComposition.ts`): Rows gegen die `columns[].fieldKey`s der
   Skeleton-Tabelle, `surface_patch` in der in omadia-ui
   `docs/protocol/1.0.md` §5.1 gepinnten RFC-6902-Subset-Grammatik
   (`replace loading` + `add rows/-`), Post-Patch-Tree validiert.
   **Unmappbare Payloads werden übersprungen** (Daten kommen weiter als Prose
   an) — bewusste Slice-Entscheidung statt LLM-Rekompositions-Snapshot
   mid-stream.

**Allow-Set (Interim):** Setup-Feld `canvas_output_tools` (kommagetrennte
Tool-Namen) füllt das deny-by-default-Gate, bis das boot-berechnete
canvas-output-Capability-Wiring (PR-7b) landet. Leer → keine Synthese.

**Weiter offen (9b-3):** per-`canvasSessionId`-Mutex, cross-turn-`surfaceSeq`/
State-Persistenz (Re-Handshake-Snapshot-Replay), `surface_data_ref_created` +
DataRef-HMAC, `writeCapabilities`-Ableitung.

Test: `test/uiOrchestratorComposition.test.ts` — `composeSkeleton`
(Model-Pfad, Repair-Retry mit Validator-Fehlern, Fallback bei Non-JSON /
Invalid-Twice / fehlendem LLM), `composeStructuredPayloadPatch` (Mapping +
gepinnte Patch-Grammatik, `null` bei unmappbar), Plugin-Ebene (Skeleton ist
**erstes** Event, `[canvas-context]` am Hauptturn, Payload → `surface_patch`
`basedOnRevision "0"`, Nicht-Canvas-Turn → null LLM-Calls).

### omadia-ui-channel (Tier-1 Server, Skeleton, PR-10a)

Neues Channel-Plugin `packages/omadia-ui-channel/` (`@omadia/ui-channel`,
`kind: channel`). Das Manifest deklariert die Canvas-Surface —
`channel.capabilities: [text, canvas]` + `channel.dispatch_service:
canvasChatAgent` —, sodass ein Turn an den `omadia-ui-orchestrator` routet
(#168 + #171). **v0** registriert via `core.registerRoute` einen
Discovery-Endpoint (`GET /omadia-ui/info`), der Protokoll-/Catalog-Versionen +
Capabilities annonciert (was ein Client vor dem Connect liest).

**Wichtiger Befund / aufgeschoben:** Der eigentliche **bidirektionale
WebSocket-Transport** (Handshake `offer→select→ack`, `IncomingTurn`-Bildung,
`surface_*`-Event-Fan-out) fehlt — die **`CoreApi` bietet nur Express-Route/
Router-Registrierung, keinen WebSocket-Upgrade**. Um den Canvas-WebSocket zu
hosten, braucht es eine **CoreApi-SDK-Erweiterung** (WS-/`upgrade`-Registrierung
für Channel-Plugins), die in der Concept-„SDK changes"-Liste **fehlt** — als
Plan-Feed-back vermerkt, eigene Folge-PR. Die Dispatch-Verdrahtung
(`dispatch_service` → `canvasChatAgent`) ist bereits durch #168 validiert.
**Aufgelöst durch PR-11** (unten).

### Canvas WebSocket-Transport (Omadia UI, PR-11)

Schließt die in PR-10a benannte CoreApi-Lücke: additive WebSocket-Registrierung
für Channel-Plugins — das Gegenstück zu `registerRoute`, eine Ebene höher.

- **SDK** (`@omadia/channel-sdk`, additiv): `ChannelSocket` (transport-agnostisch,
  Text-Frames; **kein `ws`-Import im SDK**), `ChannelSocketHandler`,
  `ChannelSessionClaims` und die optionale Methode
  `CoreApi.registerWebSocket?(channelId, path, handler)`. Optional (`?`), damit
  bestehende Channels und Nicht-WS-`createCoreApi`-Wirings unberührt bleiben —
  Channels feature-detecten (`typeof core.registerWebSocket === 'function'`).
- **Kernel** `src/channels/webSocketRegistry.ts`: spiegelt `ExpressRouteRegistry`
  (per-Channel-`active`-Flag; `deactivateChannel` lehnt neue Upgrades ab **und**
  schließt Live-Sockets). Ein einzelner `ws.Server` im `noServer`-Modus;
  `attach(server)` hängt sich an `server.on('upgrade')`. Pfad-/Active-Match →
  Auth → `handleUpgrade`. **Single-Owner-Invariante:** die Registry ist der
  einzige `upgrade`-Consumer des Prozesses (heute kein anderer); unbekannter
  Pfad → `404` + `destroy`. Ein künftiger zweiter WS-Consumer müsste das zu
  einer Delegations-Kette machen statt unmatched Sockets zu zerstören.
- **Auth — vor dem Upgrade, nicht danach.** Der `upgrade`-Request trägt das
  Session-Cookie (`omadia_session`) in `req.headers.cookie`. Die Registry parst
  es selbst (beim rohen `upgrade` läuft **kein** `cookie-parser` davor) und ruft
  `verifySession(token, sessionSigningKey)` — **derselbe Key wie `requireAuth`**.
  Fehlt/ungültig → rohes `401` + `socket.destroy()` **vor** dem `101`; für einen
  unauthentifizierten Peer wird kein WebSocket allokiert. Nur authentifizierte
  Upgrades werden zu `ChannelSocket`s; die verifizierten `ChannelSessionClaims`
  (`subject`/`email`/`displayName`/`provider`/`omadiaUserId?`) gehen an den
  Handler. Zusätzlich der **gleiche Entra-Whitelist-Gate** wie `requireAuth`:
  eine OIDC-(`entra`)-Session mit nicht (mehr) whitelisteter E-Mail → `403`
  (Auth-Parität zu den HTTP-Routes; der `EmailWhitelist` wird mitinjiziert).
  (Hinweis: `CoreApi.resolveIdentity` ist channel-natives User-Mapping,
  **nicht** Session-Auth — daher direkt `verifySession`.)
- **Wiring** (`index.ts`): `new WebSocketRegistry({ signingKey: sessionSigningKey })`
  vor `createCoreApi({ … webSockets })` (≈2505), zusätzlich an die
  `DefaultChannelRegistry` gereicht (Lifecycle-Spiegel zu `routes`), und
  `attach(server)` nach `const server = app.listen(PORT, '::')` (≈2592) —
  dasselbe `http.Server`, der Dual-Stack-`::`-Bind serviert WS mit.
- **Dependency:** `ws` + `@types/ws` nur im Kernel, nicht im SDK.

Test: `test/webSocketRegistry.test.ts` fährt einen echten `http.Server` + echten
`ws`-Client (authentifizierter Upgrade → Claims + Echo-Frame; ohne Cookie →
`401`; unbekannter Pfad → `404`; deaktivierter Channel → `503`). Damit ist der
Transport bereit für **PR-10b** (echter Canvas-Channel: Handshake-`offer→select→
ack`, `IncomingTurn`-Bildung, `surface_*`-Fan-out).

### Canvas WebSocket-Channel (Omadia UI, PR-10b)

Macht aus dem `omadia-ui-channel`-Skeleton (PR-10a) den echten Transport, auf
PR-11s `CoreApi.registerWebSocket` aufsetzend. Drei neue Module im Package
`packages/omadia-ui-channel/src/`:

- **`protocol.ts`** — die Wire-Nachrichten des Channels:
  Server→Client `handshake_offer`/`handshake_error`/`handshake_ack` +
  die Turn-Lifecycle-Envelopes `agent_text_delta`/`turn_complete`/`turn_error`;
  Client→Server `handshake_select` + `turn`. Die `surface_*`-Events selbst
  werden **nicht** re-deklariert — sie sind `SurfaceStreamEvent` aus dem SDK und
  werden 1:1 weitergereicht. Plus ein toleranter `parseClientMessage`
  (Nicht-JSON / unbekannter `type` → verworfen).
- **`canvasConnection.ts`** — `handleCanvasSocket(socket, session, deps)`, die
  per-Verbindungs-State-Machine (DI-freundlich, ohne echten Socket testbar):
  1. **Handshake:** server-initiiertes `handshake_offer` beim Connect; auf
     `handshake_select` Versions-Match (Protokoll **und** Ops-Catalog) → mintet/
     übernimmt `canvasSessionId` und schickt `handshake_ack`; Mismatch →
     `handshake_error` (eine Downgrade-Chance, zweiter Mismatch → `close`).
  2. **Turn-Bildung:** je `turn`-Nachricht ein `IncomingTurn` —
     `channelId`, `userRef` (`kind: 'custom'`, `id = session.subject`), `text`,
     optional `target`/`viewState`/`viewStateTruncated`, `tenantId` aus
     `ctx.services.get('graphTenantId')` (sonst Core-Default `'default'`).
     **`conversationId = `${session.subject}::${canvasSessionId}``** — der
     Core-Scope ist `${channelId}::${conversationId}` und **nicht** user-gescopet,
     darum wird die client-gelieferte `canvasSessionId` unter dem
     authentifizierten Subject genamespacet (sonst Cross-User-Canvas-Zugriff,
     Codex-Blocker). Der rohe `canvasSessionId` bleibt in `metadata` + im Ack.
     `target`/`viewState` werden vor Dispatch leichtgewichtig shape-geprüft
     (Objekt + `target.kind` String) — Vollvalidierung der 10 TargetRef-Varianten
     ist Tier-2-Whitelist; malformed → `turn_error`, kein Dispatch.
  3. **Fan-out:** iteriert `core.handleTurnStream(turn)` und reicht `surface_*`
     **1:1** an den Client (gegen ein explizites Typ-Set, nicht per
     `surface_`-Prefix), faltet `text_delta` → `agent_text_delta`; ein `error`
     **terminiert** den Turn (`turn_error` statt, nicht zusätzlich zu,
     `turn_complete`). Orchestrator-Telemetrie (`iteration_start`, `tool_*`,
     `verifier`, …) wird **verworfen**. Turns sind **pro Verbindung
     serialisiert** (Promise-Chain), damit Surface-Frames nicht interleaven.
- **`plugin.ts`** — `activate` registriert zusätzlich zur Discovery-Route
  (`GET /omadia-ui/info`, jetzt `websocket: /omadia-ui/canvas`) den WS-Endpoint
  via `core.registerWebSocket` — **feature-detected**: fehlt die Methode (kein
  WS-Registry verdrahtet), degradiert der Channel auf Discovery-only, inert. Die
  Auth macht der Kernel **vor** dem Handler (PR-11); `session` ist verifiziert.
  Teardown: Routes + WS-Registrierungen + Live-Sockets räumt der Kernel pro
  `channelId` beim Deactivate ab.

**Client-Context-Passthrough** (Folge-Slice): die im `handshake_select`
deklarierten **`localOperations`** (das Ops-Catalog-Subset des Clients — die
Tier-2-Routing-Wahrheit für Class-B-Aktionen) werden pro Verbindung gehalten und
auf jedem Turn als `IncomingTurn.metadata.localOperations` mitgegeben (Key fehlt,
wenn der Client nichts deklariert). Außerdem kann ein `turn` eine strukturierte
**`action`** tragen (Button-/Row-Click; Objekt mit String-`type`, sonst
`turn_error` ohne Dispatch) → `IncomingTurn.metadata.action`. Beides additiv via
`metadata`, bis das SDK typed Fields bekommt (Protokoll-Feedback omadia-ui
`docs/protocol/1.0.md` §5.1).

Test: `test/uiChannelWebSocket.test.ts` treibt `handleCanvasSocket` mit
Mock-`ChannelSocket` + Mock-`handleTurnStream` (offer; matching select → ack mit
client-`canvasSessionId`; Versions-Mismatch → error, zweiter → close; Turn →
korrekt geformter `IncomingTurn` + `surface_*`/`agent_text_delta`/`turn_complete`
Fan-out; Turn vor Handshake wird verworfen; `localOperations`/`action` landen in
`metadata`, malformed `action` → `turn_error`). Real-Socket-Pfad ist durch PR-11s
`webSocketRegistry.test.ts` abgedeckt. **Damit kann der Agent live UI über den
Canvas synthetisieren, sobald Tier 2 (`omadia-ui-orchestrator`) `surface_*`
emittiert** — der Transport ist vollständig.

---

## 4. Migration Managed Agents → Lokal

### Warum migriert

Managed Agents sind Anthropic-gehostete Beta-Feature. Wir nutzen sie nur
als Skill-Wrapper (unser Memory liegt eh lokal, Session-State haben wir
selbst). Für Produktions-kritische Unternehmensintelligenz ist der
Vendor-Lock-in + Beta-Risiko nicht tragbar. Lokal läuft außerdem
`messages.create` direkt, d.h. 1:1 im eigenen Process, voll loggbar, voll
testbar.

### Was dabei wegfiel

- `services/odooAgent.ts` (Managed-Agent-Client) — gelöscht
- `routes/odooProxy.ts` (HTTP-Proxy für Managed Agents) — gelöscht
- `routes/internal.ts` (Confluence-HTTP-Proxy) — gelöscht
- `routes/internalShared.ts` (Agent-Token-Auth-Middleware) — gelöscht
- Env: `AGENT_PROXY_TOKEN`, `CLAUDE_*_AGENT_ID`, `CLAUDE_*_ENVIRONMENT_ID`
  (entfernt aus config.ts und .env.example)

### Was extrahiert wurde

Die Kernlogik (Whitelists, Red-Lines, Space-Scoping, EntityRef-Publish)
ist in `odooCore.ts` und `confluenceCore.ts` gewandert. Das sind
**Single-Source-of-Truth**-Module: sowohl die früheren HTTP-Proxy-Routes
als auch die heutigen Toolkits hängen dran. Falls HTTP-Proxies mal wieder
gebraucht werden (externe Consumer), reimplementierbar als dünne Wrapper.

### Skill-Integration

Die Skills (`skills/<name>/SKILL.md`) waren für die Managed-Agent-Runtime
geschrieben (Bash/curl/$env-Variablen). Statt alle drei zu rewriten, wird
der **Runtime-Override** beim Sub-Agent-Bootstrapping (in `index.ts`,
Funktion `buildSubAgentSystemPrompt`) vorangestellt: der Sub-Agent wird
explizit instruiert, die HTTP/curl-Abschnitte zu ignorieren und direkt die
Tools zu nutzen. Funktionierte on first try.

---

## 5. Memory-System

### Zwei Memory-Typen

1. **Orchestrator-Memory** — das Anthropic-eigene `memory_20250818`-Tool.
   Der Orchestrator nutzt ein **virtuelles `/memories`-Verzeichnis**,
   dessen Inhalt physisch auf der Middleware liegt (nicht bei Anthropic).
2. **Session-Transkript** — vom `SessionLogger` geschrieben, *nicht* vom
   Modell. Jeder abgeschlossene Turn wird an eine tagesweise `.md`-Datei
   unter `/memories/sessions/<scope>/YYYY-MM-DD.md` angehängt.

### MemoryStore als Port

```ts
interface MemoryStore {
  list / fileExists / directoryExists / readFile /
  createFile / writeFile / delete / rename
}
```

Heute: `FilesystemMemoryStore` (Pfad-Traversal-Schutz, Null-Byte-Schutz,
URL-encoded-`..`-Schutz). Austauschbar gegen Postgres/S3 ohne
Call-Site-Änderung. Diese Abstraktion kostet fast nichts und macht
spätere Migrationen trivial.

### Namespace-Konventionen (im Orchestrator-System-Prompt festgeschrieben)

- `/memories/_rules/` — **Gepflegte Regeln aus dem Repo.** Wird beim
  Startup aus `middleware/seed/memory/_rules/` kopiert. Modus `missing`
  bedeutet: neue Files werden angelegt, existierende nicht überschrieben
  (Runtime-Edits bleiben). Modus `overwrite` würde pinning erzwingen.
  Der Orchestrator-Prompt sagt: nur mit expliziter User-Bestätigung
  ändern.
- `/memories/customers/<name>.md` — stabile Fakten pro Kunde.
- `/memories/observations/YYYY-QX.md` — Zeitstempelbezogen.
- `/memories/sessions/<scope>/YYYY-MM-DD.md` — Transkripte, *geschrieben
  von der Middleware*, nicht vom Modell. Modell soll bei Rückbezug
  reinlesen, nicht standardmäßig.

### Session-Transkript-Format

Jeder Turn-Block:
```md
### HH:MM:SS.mmmZ

**User:**

<user-message>

**Assistant:**

<assistant-answer-as-markdown>

*Telemetrie: tools=N, iterations=N*

<!-- entities: [{"s":"odoo","m":"hr.employee","id":42,"n":"Müller"}, …] -->

---
```

Die Millisekunden-Präzision im Heading ist **kritisch** — ohne sie
kollidieren back-to-back-Turn-IDs bei der Graph-Ingestion.
`sessionTranscriptParser.ts` nutzt dieses Format rückwärts. Jede Änderung
am Renderer in `sessionLogger.ts` muss im Parser gespiegelt werden, sonst
verschluckt der Backfill stumm.

---

## 6. EntityRef-System (Turn-Korrelation)

### Problem, das gelöst wird

Wenn der Sub-Agent `odoo_execute` auf `hr.employee` mit `search_read`
aufruft, liefert Odoo Records mit IDs. Diese IDs gehen normalerweise
verloren, sobald der Agent eine Prose-Zusammenfassung zurückgibt. Für
den Knowledge-Graph brauchen wir die strukturierten IDs aber permanent.

### Pipeline

1. **Publish:** In `odooCore.executeOdoo` (bzw. `confluenceCore.*`) wird
   nach erfolgreichem Call `extractOdooEntityRefs(...)` gelaufen, und
   jede gefundene Ref wird auf `entityRefBus.publish(ref)` gesetzt.
2. **Tagging:** `bus.publish` liest `turnContext.current()` und emittiert
   `{ ref, turnId }`.
3. **Collect:** Der Orchestrator ruft `bus.beginCollection(turnId)` am
   Turn-Start — der resultierende Listener filtert hart auf genau dieses
   Turn-Id.
4. **Drain:** Am Turn-Ende (oder im `finally`) `collection.drain()`, und
   die Refs fließen in `sessionLogger.log({ ..., entityRefs })`.
5. **Persistieren:** Der SessionLogger hängt die Refs als HTML-Kommentar
   ans Markdown **und** feedet sie in `knowledgeGraph.ingestTurn`.

### TurnContext via AsyncLocalStorage

- **Datei:** `services/turnContext.ts`
- **Warum ALS:** die Alternative wäre, turnId durch alle Funktions-
  signaturen zu schleifen — unzumutbar bei 4–5 Hops.
- **`run(turnId, fn)`** — für `orchestrator.chat()` (normale async fn).
- **`enter(turnId)`** — für `orchestrator.chatStream()`. ALS.run ist
  inkompatibel mit Async-Generators (kann nicht um `yield` herum), daher
  `enterWith`. Scope endet mit dem HTTP-Request-Resource-Lifecycle.
- **Filter per turnId** schützt gegen Cross-Contamination bei parallelen
  Teams-Konversationen.

### Entity-Extraktoren

- **Odoo** (`odooEntityExtractor.ts`):
  - `search_read` / `read` → Record-Array mit `{id, name, display_name?}`
  - `search` → ID-Array
  - `search_count` / `read_group` / `fields_get` → []
- **Confluence** (`confluenceEntityExtractor.ts`):
  - `getPage` / `getPageByTitle` → single page with `{id, title}`
  - `search` / `getChildren` → `{ results: [...] }` mit optionalem
    `content`-Wrapper pro Eintrag

---

## 7. Knowledge Graph

### Aktueller Stand

`InMemoryKnowledgeGraph` in `services/inMemoryKnowledgeGraph.ts`. Lebt
im Prozess, verlorenbei Restart. **Disk bleibt Source-of-Truth** — der
Backfill beim Startup restored den Graph aus `/memories/sessions/**.md`.

### Schema

- **Node-Typen:** `Session`, `Turn`, `OdooEntity`, `ConfluencePage`.
- **Edge-Typen:** `IN_SESSION` (Turn → Session), `NEXT_TURN`
  (chronologische Chain pro Session), `CAPTURED` (Turn → Entity).
- **Node-IDs (stabil, deterministisch):**
  - `session:${scope}`
  - `turn:${scope}:${isoTimestamp}` — Millisekunden-Präzision nötig
  - `${system}:${model}:${externalId}` für Entities

### Ingest-Pfad

`SessionLogger.log()` schreibt zuerst Markdown, dann ruft
`graph.ingestTurn(...)`. Fehler beim Graph-Ingest sind geswallowed, damit
das Transkript auf Disk immer konsistent bleibt. Fehler beim Markdown-
Write unterdrücken den Graph-Ingest (keine halb-konsistenten Zustände).

### Backfill

`graphBackfill.ts`: walkt alle `<scope>/*.md`, parst jeden Turn-Block mit
`sessionTranscriptParser.ts`, ruft `graph.ingestTurn()` pro Turn. Wird in
`index.ts` direkt nach Graph-Erzeugung aufgerufen, bevor der HTTP-Server
startet. Logged `scopes=N files=N turns=N skipped=N`.

### Dev-Query-API (nur lokal)

- `GET /api/dev/graph/stats`
- `GET /api/dev/graph/sessions`
- `GET /api/dev/graph/session/:scope`
- `GET /api/dev/graph/neighbors?nodeId=...`

Alle hinter `DEV_ENDPOINTS_ENABLED=true`. Admin-geschützte Variante für
Prod wäre machbar, aktuell nicht nötig.

### Agent-Query-Tool

`query_knowledge_graph` (in `tools/knowledgeGraphTool.ts`). Query-Typen:
- `stats`
- `list_sessions` (most-recent first, `limit` param)
- `find_entity` (`name_contains`, `model`, `limit`)
- `session_summary` (`scope`)

Wird vom Orchestrator aufgerufen, wenn der User auf prior art verweist.
End-to-End verifiziert: der Orchestrator nutzt das Tool von selbst, ohne
dass man ihn zwingt.

---

## 8. Skills

### Was ein Skill ist

Ein Ordner `skills/<name>/` mit einer `SKILL.md`. Frontmatter enthält
`name` + `description`. Body ist Prose, wird als System-Prompt des
Sub-Agents geladen (mit Runtime-Override-Preamble davor).

### Aktuelle Skills

- `odoo-accounting/SKILL.md` — Rechnungen, Zahlungen, offene Posten,
  Kontenplan. Allowed Models: `account.move`, `account.move.line`,
  `account.payment`, `res.partner`, `account.account`, `account.journal`,
  `res.currency`.
- `odoo-hr/SKILL.md` — Mitarbeiter, Abteilungen, Verträge, Urlaub,
  Anwesenheit, Bewerbungen. Hard Red Lines (server-side enforced) in
  `odooCore.HR_RED_LINE_FIELDS` + `HR_CONTRACT_BLOCKED_ALWAYS`: wages,
  tax IDs, bank accounts, private addresses, private contact data,
  emergency contacts.
- `confluence-playbook/SKILL.md` — Lesezugriff auf Space HOME, CQL-
  basierte Suche, Seiten-Lookup. Kein Odoo-Overlap.

### Wichtig

Die Skills wurden **nicht umgeschrieben** nach der Managed→Lokal-
Migration. Statt dessen überschreibt der Preamble in
`index.ts:buildSubAgentSystemPrompt()` die HTTP/curl-Anweisungen:

> Ignoriere alle Abschnitte des Skills, die `curl`, `$odoo_proxy_*`-Env-
> Variablen oder Bash-Snippets referenzieren — diese beschreiben die
> alte Managed-Agent-Laufzeit.

Funktioniert in der Praxis. Falls ein Sub-Agent dennoch curl-Muster
produziert, Skill selbst anpassen.

---

## 9. Tests (63 Stück, alle grün)

### Infrastruktur

Node's eingebauter Test-Runner + `tsx` als TS-Loader. Kein Vitest, kein
Jest.

```bash
npm test         # alles
npm run smoke:entity-refs   # E2E-Smoke ohne externe Creds
```

### Test-Dateien unter `middleware/test/`

- `odooEntityExtractor.test.ts` — Record/Array-Varianten, Edge-Cases
- `confluenceEntityExtractor.test.ts` — Single + search-list + malformed
- `turnContext.test.ts` — ALS-Propagation, Concurrent-Isolation
- `entityRefBus.test.ts` — Turn-Filter, Isolation, Drain-Idempotence
- `odooCore.test.ts` — Whitelist, Red-Line-Blocks, Red-Line-Strip
- `skillLoader.test.ts` — Frontmatter-Parsing
- `inMemoryKnowledgeGraph.test.ts` — Ingest, Chain, Upsert, Neighbors
- `sessionLoggerGraph.test.ts` — Integration zwischen Logger und Graph
- `sessionTranscriptParser.test.ts` — Round-Trip mit Renderer
- `graphBackfill.test.ts` — End-to-End: Live-Log → Markdown → Rebuild
- `devGraphRouter.test.ts` — Express-Integration mit Fetch
- `knowledgeGraphTool.test.ts` — Tool-Queries

### Was **nicht** abgedeckt ist

- `LocalSubAgent.ask` (Tool-Loop) — würde Anthropic-Mock brauchen
- `Orchestrator.chat/chatStream` — selbes Thema
- `OdooClient` (JSON-RPC + Auth) — würde HTTP-Mock oder Vitest brauchen
- `ConfluenceClient` — selbes
- Teams-Route — botbuilder-Mock

Diese fehlen bewusst; Mocks für die SDKs wären der Aufwand-Peak. Sobald
echte Regressions-Bugs auftauchen, gezielt nachrüsten.

---

## 10. Konfiguration

### `middleware/config.ts` — alle Env-Variablen mit zod-Schema

```
# Required
ANTHROPIC_API_KEY
# Core
ORCHESTRATOR_MODEL=claude-opus-4-7
ORCHESTRATOR_MAX_TOKENS=4096
MAX_TOOL_ITERATIONS=12
# Sub-agents
SUB_AGENT_MODEL=claude-opus-4-7     # kann auf haiku/sonnet runter
SUB_AGENT_MAX_TOKENS=4096
SUB_AGENT_MAX_ITERATIONS=16
SKILLS_DIR=../skills                # relativ zum middleware root
# Memory
MEMORY_DIR=./.memory
MEMORY_SEED_DIR=./seed/memory
MEMORY_SEED_MODE=missing            # missing | overwrite | skip
# Odoo
ODOO_URL, ODOO_DB, ODOO_LOGIN, ODOO_API_KEY
ODOO_PROXY_MAX_BYTES=500000
ODOO_INSECURE_TLS=false             # true nur lokal bei Private-CA
# Confluence
CONFLUENCE_EMAIL, CONFLUENCE_API_TOKEN, CONFLUENCE_BASE_URL
CONFLUENCE_SPACE_KEY=HOME
CONFLUENCE_PROXY_MAX_BYTES=200000
# Optional endpoints
ADMIN_TOKEN                         # mount /api/admin (mutating memory)
DEV_ENDPOINTS_ENABLED=false         # mount /api/dev/* (unauth!)# Teams
MICROSOFT_APP_ID, MICROSOFT_APP_PASSWORD, MICROSOFT_APP_TYPE=MultiTenant,
MICROSOFT_APP_TENANT_ID
# Diagram rendering (alle 7 müssen gesetzt sein, sonst wird Feature deaktiviert)
KROKI_BASE_URL=http://localhost:8765       # Kroki-Gateway (lokal aus compose.yml)
DIAGRAM_URL_SECRET                         # openssl rand -hex 32 — pro Env frisch
DIAGRAM_PUBLIC_BASE_URL=http://localhost:3979  # Base-URL für signierte URLs
DIAGRAM_SIGNED_URL_TTL_SEC=900             # 15 min
DIAGRAM_MAX_SOURCE_BYTES=64000             # Quellcode-Cap
DIAGRAM_MAX_PNG_BYTES=900000               # <1 MB Teams-Limit
# Object-storage (Tigris auf Fly, MinIO lokal — auto-provisioniert via `fly storage create`)
BUCKET_NAME, AWS_ENDPOINT_URL_S3, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY
# Tenant-Scope (auch für Diagramm-Cache-Keys genutzt)
GRAPH_TENANT_ID=byte5
# Runtime
PORT=3979
```

`.env.example` ist gepflegt. Leere Strings parsed zod als `""`, nicht
`undefined` — daher muss der Fallback `||` sein, nicht `??`.

### Wichtige Gotchas

1. **`??` vs `||`** bei Env-Fallbacks — haben wir einmal gefangen, steht
   als Kommentar im Code. Zod macht leere `.env`-Werte zu leeren
   Strings, nicht zu `undefined`.
2. **tsx statt ts-node** für `npm run dev`. ts-node + ESM + NodeNext ist
   kaputt in aktueller Node-Version.
3. **`req.on('close')` feuert zu früh** in Express 5 (nach Body-Read,
   nicht nach Socket-Close). Auf `res.on('close')` mit
   `writableEnded`-Check wechseln. Siehe `routes/chat.ts`.
4. **`ODOO_INSECURE_TLS` ist scoped**: nur der `OdooClient` nutzt einen
   undici-Agent mit `rejectUnauthorized: false`. Global
   `NODE_TLS_REJECT_UNAUTHORIZED=0` **nicht** setzen — kompromittiert
   auch Anthropic-Verbindung.

---

## 11. Stream-Protokoll (`POST /api/chat/stream`)

NDJSON — eine vollständige JSON-Zeile pro Event. Event-Typen:

```ts
type ChatStreamEvent =
  | { type: 'iteration_start'; iteration: number }
  | { type: 'text_delta'; text: string }
  | { type: 'tool_use'; id: string; name: string; input: unknown }
  | { type: 'tool_result'; id: string; output: string; durationMs: number; isError?: boolean }
  | { type: 'done'; answer: string; toolCalls: number; iterations: number }
  | { type: 'error'; message: string }
```

Genau ein `done` oder `error` schließt den Stream. Header:
`Content-Type: application/x-ndjson; charset=utf-8`, `X-Accel-Buffering: no`
(nginx-buffer-off).

`orchestrator.chatStream` ist ein Async-Generator. Text-Deltas stammen
aus `anthropic.messages.stream` (nicht `.create`). Tool-Use-Deltas werden
nicht weitergeleitet — stattdessen emittiert das `tool_use`-Event einmal
den vollen Input, sobald der Content-Block schließt.

### 11.1 Omadia UI — Canvas-Surface-Events (additiv)

Für die Omadia-UI-Canvas-Fläche (Spec: `byte5ai/omadia-ui` `CONCEPT.md` v0.15 /
`docs/implementation-plan.md`) wurde die SDK-Typ-Fläche **rein additiv**
erweitert. Bestehende Channels sind unberührt — sie deklarieren die neue
`'canvas'`-Capability nie und ignorieren die `surface_*`-Arme per Default
(kein exhaustiver `assertNever`-Consumer in der middleware). Konkret:

- **`ChatStreamEvent`** (`harness-channel-sdk/src/chatAgent.ts`) bekommt die
  `surface_*`-Familie via `| SurfaceStreamEvent` (`surface.ts`):
  `surface_snapshot`, `surface_patch`, `surface_data_ref_created`,
  `surface_data_ref_invalidated`, `surface_action_result`,
  `surface_local_action`, `surface_error`, `surface_mutation_resolved`. Jedes
  trägt `{ canvasSessionId, surfaceSeq }`; Revisions sind ein **opakes,
  branded `RevisionId`** (nur Gleichheit, keine Arithmetik); Bulk-Daten via
  `DataRef`.
- **`IncomingTurn`** (`incoming.ts`): additive `tenantId?` /
  `target?: TargetRef` / `viewState?: CanvasViewState` / `viewStateTruncated?`.
- **`SemanticAnswer.surface?: OutgoingSurface`** (`outgoing.ts`) +
  `ChatTurnResult.surface?` (`chatAgent.ts`), durchgereicht in
  `toSemanticAnswer`.
- **`TargetRef`** (neuer Shared-Typ in `@omadia/plugin-api`, `targetRef.ts`) —
  die kanonische Ziel-Adressierung (10 Varianten, Stable-IDs statt Positionen).
- Channel-Manifest-Enum **`ChannelCapability`** (`admin-v1.ts` +
  `manifestLoader.ts` `CHANNEL_CAPABILITIES`) bekommt `'canvas'`.

Noch **nicht** in diesem Schritt: Boot-Dispatch (`channel.dispatchService`),
die Canvas-Sentinel-Extractoren (`_pendingCanvasTree` etc.), das
`structured?`/`writeCapabilities`-Tool-Manifest und die zwei neuen Plugins
(`omadia-ui-orchestrator`, `omadia-ui-channel`) — separate Folge-PRs.

---

## 12. Red-Line-Enforcement (HR)

**Defense in depth** — Skill sagt es, Core erzwingt es.

- `odooCore.HR_RED_LINE_FIELDS` — globaler Blacklist (auch wage, ssnid,
  bank_account_id, private_*, emergency_*, …).
- `odooCore.HR_CONTRACT_BLOCKED_ALWAYS` — zusätzlich für `hr.contract`
  (wage, hourly_wage, struct_id).
- **Request-Check:** `findRedLineFieldViolation` in kwargs.fields, inkl.
  dotted sub-selectors (`contract_id.wage`).
- **Response-Strip:** `stripRedLineFields` rekursiv — selbst wenn ein
  Feld nicht angefordert wurde (Odoo returned manchmal Defaults), geht
  es nicht raus.

Ein Request mit Red-Line-Feld wird server-side mit 403-equivalent
abgelehnt (Sub-Agent kriegt `Error: hr_red_line_field — field \`wage\``
— lesbar für das LLM, damit es alternative Strategien finden kann).

---

## 13. Offene Roadmap

### Phase 5 — Business-Entity-Sync (nächster sinnvoller Task)

Aktuell landen Entities nur im Graph, wenn sie in einem Turn auftauchen.
Für proaktive Cross-Domain-Queries fehlen stabile Stammdaten.

**Scope:**
- `services/odooSync.ts` — periodischer Scan (setInterval mit Jitter)
  für `hr.employee` (ohne Red-Lines), `hr.department`, `res.partner`,
  `account.journal`, ggf. `project.project`
- `services/confluenceSync.ts` — Space-Crawl der Top-Level-Seiten +
  Ancestors-Graph
- Neue Edge-Typen: `BELONGS_TO` (employee → department), `RELATED_TO`
  (page → page via parent)
- `knowledgeGraphTool` erweitern um `traverse` / `path` Queries

### Phase 7 — Graph-Persistenz (optional, wenn Restarts oft)

In-Memory funktioniert solange Backfill aus Disk schnell bleibt (aktuell
<1s für 15 Turns). Ab ~10k Turns wird das nerven. Optionen:

- **Kùzu embedded** — Single-File Graph-DB, Node-Binding, Cypher-ähnlich,
  passt zu Fly-Volumes. Kein Sidecar.
- **FalkorDB** — Redis-basiert, separater Fly-Container. Wenn Graphiti
  irgendwann kommt.
- **Graphiti-Sidecar (Python)** — wenn LLM-basierte Entity-Extraction
  gewollt. Temporal-Graph-Modell, aber zusätzlicher Service.

Interface (`KnowledgeGraph`) ist bereits so geschnitten, dass ein
Swap-Out trivial ist. `InMemoryKnowledgeGraph` implementiert es, jede
Alternative muss dieselben Methoden erfüllen.

### Phase 8 — Eval-Harness

Ziel: Regression-Schutz für Agent-Qualität (nicht nur Code). Fixe
Test-Prompts, Golden-Antworten pro Domain, Diff-Report. Könnte als
`scripts/eval.ts` starten. Ohne das kann man kein Skill-Tuning
verteidigen.

### Phase 9 — Proper Auth für Dev-Endpoints

Aktuell sind `/api/dev/*` unauth'd hinter einer Flag. Sobald die
Middleware außerhalb localhost gehostet wird, muss mindestens ein
`DEV_TOKEN` ran. Der Memory-Admin-Router hat schon Constant-Time-Compare,
Pattern vorhanden.

### Phase 10 — Ollama-basierte Entity-Extraction aus Prose

Aktuell erfassen wir nur IDs aus Tool-Responses. "Wie geht's Müller?"
ohne Tool-Call hat keine ID → keine Graph-Verknüpfung. Lösung: nach
jedem Turn ein lokales LLM (Ollama) den Assistant-Answer parsen lassen
auf Entity-Mentions und gegen den bestehenden Graph matchen. Low-
confidence-Kanten mit Flag speichern, UI zeigt sie anders an.

### Phase 11 — Diagramm-Rendering auf Fly deployen

Feature ist lokal fertig (2026-04-19, siehe CHANGELOG für Architektur-Zusammenfassung). Offen:

1. Zwei Fly-Apps `odoo-bot-kroki` + `odoo-bot-kroki-mermaid` mit flycast-only Services (keine öffentlichen IPs). Dockerfile/fly-toml vorbereiten, z.B. unter `kroki/`.
2. Tigris-Bucket über `fly storage create -a odoo-bot-middleware`, dann einmalig `PutBucketLifecycleConfigurationCommand` mit 90-Tage-Expiration.
3. Fly-Secrets setzen: `DIAGRAM_URL_SECRET`, `KROKI_BASE_URL=http://odoo-bot-kroki.flycast:8000`, `DIAGRAM_PUBLIC_BASE_URL=https://odoo-bot-middleware.fly.dev`.
4. Smoke-Probe in Teams: "Flow A→B→C als Mermaid" → Card mit PNG.

Lokale Reproduktion jederzeit via `docker compose up -d` + `npm run smoke:diagrams`.

### Phase 12 — tenantId im TurnContext

Diagramm-Cache-Keys nutzen aktuell `config.GRAPH_TENANT_ID` (statisch `byte5`).
Sobald wir mehrere Teams-Tenants bedienen, muss `tenantId` aus der Teams-Activity
in `TurnContextValue` fließen — analog `turnId`. `DiagramService` liest dann
`turnContext.currentTenantId()` statt `config.GRAPH_TENANT_ID`.

### Phase 13 — Cross-Channel Conversation Memory

Durable, user-scoped Conversation-Memory über Channel-Grenzen hinweg.
Treiber: omadia-ui-Orchestrator deklariert `requires:
crossChannelConversationMemory@1`. Use-Case ist die "S-Bahn → Büro"-
Continuity (Telegram unterwegs → Desktop-App im Büro nahtlos weiter).

Spec liegt als RFC unter [docs/cross-channel-memory.md](cross-channel-memory.md).
Kurzfassung:

- Zwei neue Capabilities: `platformIdentity@1` (ChannelUserRef → stabile
  userId) und `crossChannelConversationMemory@1` (append-only durable
  Conversation-Log pro userId, channel-agnostisch). Service-Registry-Keys
  sind bare names (`platformIdentity`, `crossChannelConversationMemory`),
  Capability-Refs mit `@major` nur im Manifest.
- Vier Plugins als Provider, je Neon + Inmemory-Sibling pro Capability;
  Mutual-Exclusion pro Capability — Operator wählt Neon für Prod,
  Inmemory für CI / Smoke / Local-Dev. Pattern analog
  `harness-knowledge-graph-neon` / `-inmemory`. Inmemory-Sibling ist
  explizit nur Single-Process; Multi-Pod-Setups erfordern Neon.
- Identity-Modell v1: Auto-Merge bei E-Mail-Gleichheit ist **opt-in pro
  Tenant** (`pi_auto_merge_on_email`, default `false`) und greift nur
  bei `email_verified=true`. Race-Sicherheit per `UNIQUE`-Index +
  `INSERT ... ON CONFLICT`. Shared-Mailbox, Recycled-Email und
  Email-Rename sind als Edge-Cases im RFC dokumentiert.
- Backward-compat: `ConversationHistoryStore` aus `harness-channel-sdk`
  bleibt unverändert. Neuer `DurableConversationHistoryStore`-Adapter
  fungiert als Bridge zur Capability; Channel-Plugins opten pro PR ein.
  Fehlt die Capability (CI / Dev), fällt der Adapter auf das bisherige
  `InMemoryConversationHistoryStore`-Verhalten zurück. Type-Bridge
  zwischen den zwei `ConversationTurn`-Shapes im SDK ist in §7.2 des
  RFC spezifiziert.
- `TurnContextValue` bekommt drei additive optionale Felder
  (`tenantId?`, `originatorUserRef?`, `originatorUserId?`) — landet in
  PR 4 zusammen mit dem Adapter und absorbiert die `tenantId`-Arbeit
  aus Phase 12.
- Persistenz raw, Egress-Redaction unverändert; optionaler
  Pre-Persist-Redaction-Hook pro Tenant (`ccm_redact_on_persist`).
  Lese-Default schließt Rows mit `redaction_state='pending'` aus.
  Privileged-Reads (`includeRaw=true`) sind admin-only und werden in
  einer eigenen `ccm_audit_events`-Tabelle persistiert — nicht über
  `ctx.notifications` (Cross-Channel-User-Fan-out, falsche Surface).
- Capacity: TTL 90 Tage (konfigurierbar), Per-User-Count-Cap 10000
  Turns, zusätzlich Per-User-Byte-Cap (~50 MB), `ccm-gc`-Cron mit drei
  Passes (TTL → count → bytes). Outbox-Tabelle plus separater
  `ccm-outbox`-Job für Late-Delivery bei transienten Schreibfehlern.
  Kein Score-Decay / Tier-Rotation — chronologisch, v2-Pfad offen.
- Observability: PluginContext hat heute kein Metrics-API; CCM betreibt
  einen plugin-internen Counter-Registry und exponiert `/ccm/metrics`.
  Wenn `ctx.metrics` kommt, migriert die Surface.

PR-Sequenz (additiv gegen `main`, **source-mergeable**; Deployment
gekettet, weil `requires` beim Boot enforced wird): docs-RFC (diese PR)
→ `platformIdentity@1`-Provider → `ccm@1`-Provider → Adapter im SDK
(plus `TurnContextValue`-Extension) → vier Per-Channel-Opt-in-PRs →
omadia-ui-Orchestrator-Consumer. Details + per-PR-Doc-Pflichten in §15
des RFC.

---

## 14. Commands (vom `middleware/`-Dir aus)

```bash
npm install                   # einmalig
npm run dev                   # tsx watch src/index.ts
npm run typecheck             # tsc --noEmit
npm run lint                  # eslint src/
npm run lint:fix              # eslint --fix
npm run format                # prettier --write
npm test                      # Node --test mit tsx-Loader, 63 Tests
npm run smoke:entity-refs     # E2E-Smoke für EntityRef-Capture-Pfad
```

**Nicht aufrufen:** `npm run build` — Repo-Konvention sagt
"dev-only, typecheck + lint reichen".

### Aktuell laufende Background-Tasks

- `bmp0cq4cz` — Middleware-Dev (`tsx watch src/index.ts`)
- `b837rubug` — Next.js-Dev-UI

Beide überleben den Session-Clear nicht automatisch. Bei neuem Chat ggf.
neu starten.

---

## 15. Git-Status (Repo ist **keine** Git-Repo!)

`/Users/johndoe/sources/odoo-bot/` ist laut CLAUDE-Env-Info **kein
Git-Repository**. Keine Commits, kein Branch-Management nötig. Änderungen
werden direkt auf Files gemacht. Das ist bewusst und für die gesamte
Session so — nicht versuchen zu committen.

---

## 16. Fly-Deployment (aktuell nicht primär)

Middleware liegt als `fly.toml` und `Dockerfile` vor. Eine Fly-App
`odoo-bot-middleware` existiert in Prod und läuft mit leicht anderer
Config (Managed Agents nutzend — veraltet, sollte irgendwann auf lokale
Sub-Agents umgestellt werden). Lokaler Stand ist der **neuere**. Ein
Sync auf Fly würde:

- `ODOO_INSECURE_TLS=false` setzen (Fly-CA-Store kennt das Cert)
- `DEV_ENDPOINTS_ENABLED=false` lassen (Prod-Schutz)
- `SKILLS_DIR` auf Container-Pfad setzen
- Ggf. `SUB_AGENT_MODEL=claude-sonnet-4-6` für Kosten

Solange du primär lokal entwickelst, Fly nicht anfassen.

---

## 17. Für den ersten Prompt im neuen Chat

Gute Einstiegs-Prompts, geordnet nach erwartetem Gewinn:

**Klein & konkret:**
- "Schau dir `services/localSubAgent.ts` an und schreib einen Mock-SDK-
  Test, der den Tool-Loop auf Happy-Path + Error-Path abdeckt."
- "Füge dem `knowledgeGraphTool` einen Query-Typ `entity_neighbors` hinzu,
  der alle Turns + benachbarten Entities eines Entity-IDs zurückgibt."
- "Das HR-Skill hat im 'Query Pattern'-Abschnitt noch bash/curl-Beispiele.
  Schreib den Abschnitt so um, dass er das `odoo_execute`-Tool direkt
  referenziert statt HTTP."

**Mittel:**
- "Beginn Phase 5: implementier `services/odooSync.ts`, das alle 30min
  `hr.employee` und `hr.department` in den Graph syncet. Füge einen
  Test dazu und verdrahte in `index.ts`."
- "Baue ein `scripts/eval.ts`, das eine Liste fixer Prompts gegen die
  laufende Middleware fährt und Antworten + Tool-Counts + Dauer
  protokolliert. Erstmal nur aufzeichnen, kein Diff noch."

**Groß:**
- "Evaluiere, ob wir `InMemoryKnowledgeGraph` auf Kùzu-embedded migrieren
  sollten. Schau die aktuelle Interface-Surface in `knowledgeGraph.ts`
  an, recherchier den Kùzu-Node-Client, schreib einen Prototyp-Adapter.
  Tests sollen identisch grün bleiben."

### Grundsätzliche Arbeitsweise

- User ist Senior-Dev, architektur-first, knappe Tech-Sprache bevorzugt.
- Tools vor Prosa: `lint:fix` + `typecheck` + `test` nach jeder Änderung.
- Keine Auto-Deploys, keine Fly-Kommandos ohne explizite Ansage.
- Memory-System ist Projekt-kritisch — Änderungen am Session-Transkript-
  Format brauchen Parser-Update im selben Commit.
- Red-Lines sind heilig — nie abschwächen, nur durch explizite User-
  Entscheidung.

### Wenn du unsicher bist

- `.env.example` ist aktuell, liest sich wie Spec.
- `package.json`-Scripts zeigen alles was supported ist.
- Alle 63 Tests in einem Lauf geben eine schnelle "funktioniert noch
  alles"-Antwort.
- Die Dev-UI unter `http://localhost:3000` ist der schnellste Weg, Agent-
  Verhalten interaktiv zu testen — sie streamt Tool-Calls live.
