From 142edca40111556577f9f1e98746f26faec8adc2 Mon Sep 17 00:00:00 2001 From: m Date: Fri, 8 May 2026 19:35:39 +0200 Subject: [PATCH] =?UTF-8?q?docs(paliadin):=20t-paliad-161=20inventor=20des?= =?UTF-8?q?ign=20=E2=80=94=20inline=20modal=20+=20agent-suggested=20write?= =?UTF-8?q?=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two intertwined Paliadin upgrades, scoped together because the chat surface is where the write path is triggered and the write path is what makes the chat non-trivial: 1. Inline slide-out modal reachable from every authenticated paliad page, with structured page-context payload (route_name + primary_entity + selection text) and per-route starter prompts. 2. Agent-suggested write path that drafts deadlines/appointments/notes into the existing pending_create lifecycle (t-paliad-160) with new provenance columns on approval_requests (requester_kind + agent_turn_id); approved-from-agent rows render alongside πŸ‘€ with a sparkle ✨. Hard call: keep the existing tmux relay for v1; recommend (but do not commit) the Anthropic API cutover as a prerequisite for opening beyond owner-only. Single Paliadin persona β€” no scope-bouncer pre-design. Inventor parked. DESIGN READY FOR REVIEW. Awaiting m's go/no-go before any coder shift. Refs: m/paliad#20, t-paliad-146, t-paliad-160, t-paliad-138. --- docs/design-paliadin-inline-2026-05-08.md | 943 ++++++++++++++++++++++ 1 file changed, 943 insertions(+) create mode 100644 docs/design-paliadin-inline-2026-05-08.md diff --git a/docs/design-paliadin-inline-2026-05-08.md b/docs/design-paliadin-inline-2026-05-08.md new file mode 100644 index 0000000..cfc791d --- /dev/null +++ b/docs/design-paliadin-inline-2026-05-08.md @@ -0,0 +1,943 @@ +# Inline Paliadin chat modal + agent-suggested-with-approval write path + +**Inventor:** dirac Β· **Task:** t-paliad-161 Β· **Issue:** m/paliad#20 +**Date:** 2026-05-08 Β· **Branch:** `mai/dirac/inventor-inline-paliadin` +**Status:** READY FOR REVIEW β€” awaiting m's go/no-go before any coder shift. + +--- + +## 0 Β· TL;DR + +Two intertwined upgrades, scoped together because the chat surface is where +the write path is triggered and the write path is what makes the chat +non-trivial: + +1. **Inline modal**: a slide-out chat widget reachable from every + authenticated paliad page, replacing the standalone `/paliadin` route's + primacy (the page survives as the dedicated full-screen surface). The + widget is **context-aware** β€” it knows which route the user is on, the + primary entity in view, and any selected text β€” and uses that to + pre-populate page-specific starter prompts. + +2. **Agent-suggested write path**: Paliadin gains a *suggestion verb* that + drafts a deadline / appointment / note / project edit straight into the + existing `pending_create` lifecycle from t-paliad-160. The user reviews + via the same eye-pill πŸ‘€ surface (`/inbox`, list/agenda views) and + approves or rejects. Approved-from-suggestion rows pick up a sparkle ✨ + provenance glyph that lives **next to** πŸ‘€, not in place of it. + +**Hard call**: the inline modal should **keep the existing tmux-relay +backend** for v1. Cutover to the Anthropic Messages API is a separate +substantial piece of work (auth, prompt-caching, tool framework, budget +management); coupling it to the inline-modal ship would extend the design +window past where m needs the modal to land. The design *recommends* the +API cutover as a prerequisite for opening Paliadin beyond owner-only β€” but +the inline modal at owner-only scope works fine on the existing relay. + +**Key locked positions** (all reversible by m before coder shift): + +| # | Decision | Position | +|---|---|---| +| 1 | Modal trigger | Floating button bottom-right + `Cmd/Ctrl-K` shortcut | +| 2 | Surface shape | Right slide-out drawer, 420px desktop, full-screen on mobile | +| 3 | Visibility | Every authenticated page **except** `/paliadin`, `/login`, `/onboarding` | +| 4 | Gate | Same `PaliadinOwnerEmail` gate as today (no scope expansion in this task) | +| 5 | Backend transport | Tmux relay (existing). Anthropic-API cutover deferred. | +| 6 | Multi-turn coherence | Tmux session reuse already handles it; no client-side history hydrate beyond what's there | +| 7 | Context payload | `route_name` + `primary_entity_type` + `primary_entity_id` + `user_selection_text` (optional) + page metadata | +| 8 | Starter-prompt library | Per-route `paliadinStarters` registry, ships with 8 routes + a generic fallback | +| 9 | Agent-suggested attribution | New columns on `paliad.approval_requests` (`requester_kind`, `agent_turn_id`); **not** on entity rows | +| 10 | Visual language | ✨ glyph alongside πŸ‘€ on pending rows; persistent ✨ on approved-from-agent rows in audit log | +| 11 | Persona separation | Single Paliadin SKILL.md unchanged. No pre-design for split personas. | +| 12 | Concurrency | One in-flight turn per user enforced server-side (existing `turnMu`); request-side cancel via context | + +--- + +## 1 Β· Premises verified live + +Read the live system before designing on top β€” every claim below was +checked against the running paliad.de + DB on 2026-05-08, not against +CLAUDE.md or memory. + +- **paliad.de**: live; root 200, `/paliadin` 302 (login redirect for + anon). Production runs `RemotePaliadinService` against mRiver (CLAUDE.md + flags `tmux + claude` as missing in the Dokploy container β€” confirmed + the prod path actually goes through `paliadin-shim` over SSH). +- **Migration tracker**: `paliad.paliad_schema_migrations.version=69`. Next + free migration is **070**. +- **`paliad.approval_requests`** existing columns: `id, project_id, + entity_type, entity_id, lifecycle_event, pre_image, payload, + requested_by, requested_at, required_role, status, decided_by, + decided_at, decision_kind, decision_note, created_at, updated_at`. **No + `agent_*` columns yet** β€” migration 070 adds them. +- **`paliad.paliadin_turns`**: already has a `page_origin TEXT` column + populated from `req.PageOrigin` on every turn. Today the frontend only + ever sets `window.location.pathname` on the standalone page; the inline + widget will widen this from a single string into a structured payload. +- **`paliad.deadlines` + `paliad.appointments`**: already carry + `approval_status text NOT NULL DEFAULT 'approved'` + `pending_request_id + uuid` from migration 054. The πŸ‘€ eye-pill renders on pending rows in + `events.ts:521` and `agenda.ts:289` via `.approval-pill--icon`. +- **Sidebar** (`frontend/src/components/Sidebar.tsx:123`): already has a + `/paliadin` entry hidden by default, revealed by `client/sidebar.ts` + after `/api/me` confirms the caller is the Paliadin owner. The same + reveal hook drives the inline modal's visibility. +- **`PaliadinOwnerEmail`** (`internal/services/paliadin.go:51`): + `matthias.siebels@hoganlovells.com`. Hard-coded gate. **No scope + expansion in this task.** +- **youpc.org reference files** all readable at + `/home/m/dev/web/youpc.org/`: `frontend/templates/ai/sidebar-widget.html`, + `frontend/js/utils/ai-chat-client.js`, `frontend/js/components/ai/sidebar.js`, + `youpc-go/internal/services/youpc_ai_relay.go`, `scripts/youpc-ai-shim`. + Klaus's brief in #20 maps to these directly. + +**One CLAUDE.md correction**: the project's `CLAUDE.md` currently calls +`ANTHROPIC_API_KEY` "reserved-but-unused for the eventual production-v1 +Paliadin". That language stays correct β€” this design *recommends but does +not commit* the API cutover. No CLAUDE.md edit in the implementation PR. + +--- + +## 2 Β· Why the inline modal matters + +m's framing (#20 Β§1) is "Paliadin should be reachable from anywhere". The +real differentiation argument is sharper: the *value of the assistant +collapses to "open a chat tab" if you can't get to it without leaving the +page you're already working on.* For a patent-practice tool, the most +common questions are page-anchored: + +- On `/projects/` β†’ "Was steht fΓΌr diese Akte diese Woche an?" +- On `/deadlines/` β†’ "ErklΓ€re mir die Klageerwiderungsfrist nach UPC RoP 23.1." +- On `/agenda` with selection β†’ "Schreibe einen Nachtrag zu diesem + Termin: …" + +The standalone `/paliadin` page solves none of these because asking the +question requires the user to (a) leave the page, (b) re-explain context +the page already had, (c) navigate back. The inline modal solves (a) by +construction; (b) is solved by the **context payload** (Β§4); (c) is moot. + +The widget is therefore the **default surface** going forward; the +`/paliadin` standalone page survives as the dedicated full-screen mode +(useful for long sessions where the slide-out is too narrow). Both speak +the same backend. + +--- + +## 3 Β· Modal β€” shape, trigger, injection + +### 3.1 Visual shape (recommendation) + +**Right-edge slide-out drawer** β€” same pattern as youpc.org's +`ai-sidebar-widget.html` because it solves the right problems: + +- Doesn't crowd the page content (drawer slides in *over* a translucent + scrim, page underneath stays visible at ~70% opacity so the user can + reference what they were looking at). +- Mobile-responsive for free: at `<640px` the drawer goes full-screen and + the floating button hides while open. +- Doesn't fight with paliad's existing left sidebar (`Sidebar.tsx`) β€” the + drawer claims the right edge, the sidebar keeps the left. + +**Considered and rejected:** + +- *Always-visible secondary sidebar* (left or right rail). Wastes ~280px + of horizontal real-estate on every page; collides with the sidebar on + mobile. +- *Popover anchored to the floating button*. Too small for multi-turn + conversations; mobile would need a separate full-screen mode anyway. +- *Fullscreen takeover overlay*. Defeats the purpose β€” if it covers the + page you can't reference what you were looking at. + +### 3.2 Trigger + +Two entry points: + +1. **Floating action button** at bottom-right (`position: fixed; bottom: + 20px; right: 20px;`). Lime accent (`var(--color-accent)`), ✨ glyph. + Same auth-reveal hook as the sidebar `/paliadin` link β€” `display:none` + until `client/sidebar.ts` confirms `/api/me.email === + PaliadinOwnerEmail`. + +2. **Keyboard shortcut**: `Cmd-K` (macOS) / `Ctrl-K` (other). Standard + command-palette muscle memory. Doesn't collide with browser shortcuts. + Paliad has no other Cmd-K binding today (verified via grep on + `keydown` handlers). + +The shortcut also dismisses the drawer when it's open. `Esc` dismisses +unconditionally. + +### 3.3 Drawer content + +Layout (top to bottom): + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β” +β”‚ ✨ Paliadin ↻ β†— βœ•β”‚ β”‚ Header: name, reset-session, open-fullscreen, close +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€ +β”‚ [Auf dieser Seite] β”‚ +β”‚ Akte: Acme v. MΓΌller β”‚ β”‚ Context chip β€” collapsible, shows what Paliadin +β”‚ 19 Fristen Β· 4 Termine β”‚ β”‚ knows about the current page (read from payload) +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€ +β”‚ [empty-state starter prompts] β”‚ +β”‚ β€’ "Was steht hier an?" β”‚ +β”‚ β€’ "ErklΓ€re die offene…" β”‚ +β”‚ β€’ "Lege eine Frist an" β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€ +β”‚ β”‚ β”‚ Scrollable, user-right / paliadin-left +β”‚ > User bubble β”‚ +β”‚ < Paliadin bubble + ✨ chip β”‚ β”‚ ✨ chip = "I drafted this β€” it's awaiting your approval" +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€ +β”‚ [textarea + send + abort] β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”˜ +``` + +The `β†—` button is the escape hatch to the standalone `/paliadin` for +users who want a full-screen session with full message history visible. + +### 3.4 Injection mechanism + +**One file edits the universe**: `frontend/src/components/PaliadinWidget.tsx` +emits an inline `` +that page-template files include alongside `` and ``. + +The mechanical edit pass: every authenticated TSX page (~30 files) gets a +`` near ``. This mirrors the existing +`` mechanical pass from t-paliad-042 and is the cleanest way to +guarantee the widget reaches every page without HTMX or runtime injection. + +**Alternative considered**: server-side template fragment injected by Go's +HTML response writer (cleaner: no per-page edit). Rejected because paliad +uses bun-built static HTML files, not templated server responses β€” there's +no place to inject server-side. The mechanical pass is fine; the +boilerplate it adds is one component. + +**Visibility predicate** (in `client/paliadin-widget.ts`): + +- **Hide** on `/paliadin` (the standalone page IS Paliadin, the widget + would be redundant). +- **Hide** on `/login`, `/onboarding` (no auth context). +- **Hide** until `/api/me` resolves to `email === PaliadinOwnerEmail`. + Same fail-closed pattern as the sidebar link. +- **Show** on every other authenticated page. + +### 3.5 What about the BottomNav (mobile)? + +`BottomNav.tsx` has 5 slots (Dashboard / Projects / Add / Agenda / Menu) +β€” full. Adding a Paliadin slot would require evicting one. **Don't.** +The floating button is fine on mobile (it sits in the bottom-right corner +*above* the bottom nav, with `z-index` arbitration). At full-screen-drawer +size on mobile, the floating button hides while the drawer is open. + +--- + +## 4 Β· Context payload β€” what flows from frontend to backend + +### 4.1 Schema + +The current `TurnRequest.PageOrigin` is a single string (the URL path). +The inline modal needs more. Define a structured payload: + +```ts +interface PaliadinContext { + // Stable route key β€” independent of URL params. e.g. "projects.detail" + // not "/projects/61e3.../tab=team". The frontend computes this from + // `window.location.pathname` via a route-table lookup. + route_name: string; + + // Path including query string (cosmetic; for audit + display only). + page_origin: string; + + // The "primary entity" of the current page, if any. Examples: + // /projects/ β†’ ("project", "") + // /deadlines/ β†’ ("deadline", "") + // /appointments/ β†’ ("appointment", "") + // /events?type=deadline β†’ null + // /tools/fristenrechner β†’ null + primary_entity_type?: "project" | "deadline" | "appointment"; + primary_entity_id?: string; // uuid + + // User's text selection at the moment they opened the widget (or sent + // the turn). Capped at 1000 chars. Empty string = no selection. + // Source: window.getSelection().toString() at send-time. + user_selection_text?: string; + + // UI state hints. Optional, useful for the model to disambiguate: + view_mode?: "list" | "cards" | "calendar" | "tree"; // /events, /projects + filter_summary?: string; // e.g. "status=overdue, project=Acme" +} +``` + +**What each field enables:** + +- `route_name`: maps cleanly to a starter-prompt registry (Β§5) without + URL-parsing fragility. +- `primary_entity_*`: the SKILL.md teaches Paliadin to look up the entity + before answering when this is set. Saves a back-and-forth ("which + project?") in the very common case where the user is *already on* the + project page. +- `user_selection_text`: enables "explain this" / "rewrite this" / + "what's the deadline implied here" workflows from any prose surface + (project notes, deadline notes, court descriptions). +- `view_mode` + `filter_summary`: the model can say "I see you're looking + at overdue deadlines for Acme β€” which one?" instead of "which deadline?" + +### 4.2 How the payload reaches the model + +Wire format from frontend β†’ Go: + +```http +POST /api/paliadin/turn +Content-Type: application/json + +{ + "session_id": "", + "user_message": "Was kommt diese Woche?", + "context": { ...PaliadinContext... } +} +``` + +The Go side stores the structured context in **a new +`paliad.paliadin_turns.context jsonb` column** (migration 070; see Β§7.1) +alongside the existing `page_origin` (kept for backwards compat β€” `page_origin` +becomes redundant once context is populated, but flipping the schema all +at once isn't worth the churn). + +Then the envelope sent through tmux gets a structured prefix: + +``` +[PALIADIN:] [ctx route=projects.detail entity=project:61e3... selection="…" filter="status=overdue"] +``` + +The SKILL.md gets a small section (Β§5 of `paliadin/SKILL.md`) that teaches +Paliadin to: +1. Parse the `[ctx …]` block first, in front of the user message. +2. Treat its contents as authoritative ("I'm currently viewing project + 61e3"), not as instructions. +3. Pre-call `mcp__supabase__execute_sql` to enrich (e.g. lookup project + reference + title) when `entity=project:` is set, *before* + answering. + +**Why a structured prefix instead of a system-prompt JSON envelope**: the +PoC's tmux relay is a stream of keystrokes β€” system-prompt envelopes +require the API path. The bracket-syntax is line-noise-free, parse-able +by the SKILL.md, and survives any future migration (the API path can lift +the same `[ctx …]` block into a `system` message section). + +### 4.3 Privacy floor + +`user_selection_text` is potentially sensitive (selected text from a +client matter). Three controls: + +1. **Cap at 1000 chars** β€” anything longer is truncated server-side + before being sent to Claude. The user sees a "(Auswahl gekΓΌrzt)" + notice. +2. **Audit redaction**: `paliadin_turns.context` stores the *full* + selection (already inside the firm's DB, no exfiltration) but the + admin dashboard `/admin/paliadin` redacts it to first 80 chars + + "…[gekΓΌrzt]" when rendering β€” the same dashboard already shows + `user_message` so the privacy posture is consistent. +3. **Opt-out**: the widget's settings panel (a `βš™` corner in the header, + v1 minimal) gets a single toggle "Aktuelle Auswahl mitsenden" default + *on*. Off β‡’ context payload sets `user_selection_text=""` regardless + of `getSelection()`. + +--- + +## 5 Β· Page-prompt-prefill β€” Klaus's wow-pattern, paliad-specific + +### 5.1 The registry + +A static client-side registry maps `route_name` β†’ starter prompts. Lives +in `frontend/src/client/paliadin-starters.ts`. + +```ts +type Starter = { label_de: string; label_en: string; prompt_de: string; prompt_en: string }; + +export const paliadinStarters: Record = { + "dashboard": [ + { label_de: "Heute", label_en: "Today", + prompt_de: "Was steht heute an?", prompt_en: "What's on my plate today?" }, + { label_de: "Diese Woche", label_en: "This week", + prompt_de: "Welche Fristen sind diese Woche?", prompt_en: "Which deadlines are this week?" }, + { label_de: "NΓ€chste Schritte", label_en: "Next steps", + prompt_de: "Was sollte ich als nΓ€chstes erledigen?", prompt_en: "What should I tackle next?" }, + ], + "projects.detail": [ + { label_de: "Status der Akte", label_en: "Project status", + prompt_de: "Was ist der aktuelle Status dieser Akte?", prompt_en: "What's the status of this project?" }, + { label_de: "Diese Woche", label_en: "This week", + prompt_de: "Was steht fΓΌr diese Akte diese Woche an?", prompt_en: "What's on for this project this week?" }, + { label_de: "Frist anlegen", label_en: "Add a deadline", + prompt_de: "Lege eine Frist fΓΌr diese Akte an: ", prompt_en: "Add a deadline for this project: " }, + ], + "deadlines.detail": [ + { label_de: "ErklΓ€re die Frist", label_en: "Explain this deadline", + prompt_de: "ErklΓ€re mir die Frist auf dieser Seite.", prompt_en: "Explain this deadline." }, + { label_de: "Rechtsgrundlage", label_en: "Legal basis", + prompt_de: "Welche Norm ist hier einschlΓ€gig?", prompt_en: "What's the relevant rule?" }, + ], + "agenda": [ /* … */ ], + "events": [ /* … */ ], + "inbox": [ /* … */ ], + "tools.fristenrechner": [ /* … */ ], + "glossary": [ /* … */ ], + // Generic fallback for unmapped routes. + "_default": [ + { label_de: "Was kann ich fΓΌr dich tun?", label_en: "What can I help with?", + prompt_de: "", prompt_en: "" }, + ], +}; +``` + +The widget's empty state renders the matching starter list. Click β†’ the +prompt populates the textarea (or sends immediately if `prompt_de` is +empty β€” letting the user type their own). Picking up "Lege eine Frist an: " +seeds the input *partially* so the user finishes the sentence β€” a +deliberate friction-reducer for the common "draft and approve" workflow. + +### 5.2 Why per-route registry, not LLM-generated suggestions? + +Considered: dynamically ask Paliadin to suggest 3 starters based on +context. Rejected because: + +1. **Latency**: every drawer-open would burn a full turn before the user + even types. The PoC's tmux turn is ~2-5 seconds cold; that's an + unusable empty state. +2. **Determinism**: m's audience (PA team) needs predictable affordances. + "What does this thing know how to do?" answered the same way each + visit beats "what does this thing know how to do *today*?" +3. **Translatability**: hand-crafted bilingual starters live next to the + rest of the i18n. LLM-generated would be one language at a time. + +The registry is small (~10 routes Γ— 3 starters Γ— 2 langs = ~60 strings) +and lives next to `i18n.ts` patterns m's team already understands. + +--- + +## 6 Β· Backend transport β€” tmux relay vs Anthropic API + +### 6.1 Recommendation: keep tmux relay for v1 of the inline modal + +Two reasons: + +1. **Scope discipline**: the inline modal's user-visible payoff is + independent of which backend serves it. Cutover to the API is a 4-6 + commit piece of substantial work (auth headers, prompt-cache + management, tool-definition framework, streaming format conversion, + budget controls, audit reshape, plus the existing tmux path needs to + remain as fallback during rollout). Bundling it with the inline modal + doubles the design's blast radius for no inline-modal-side benefit. +2. **Owner-only scope**: paliad's user base today is `PaliadinOwnerEmail = + m`. One user. The tmux relay's serialised one-turn-at-a-time, ~2-5s + cold start, ~1-3s warm response holds up fine for one user clicking + through the day. + +### 6.2 What the API cutover *would* fix (recommend as Phase 2) + +When scope expands beyond owner-only β€” even just to "m + 2 PA colleagues +for piloting" β€” the tmux relay starts to bend: + +- **Concurrency**: serialised turn lock means PA-A waits while PA-B + thinks. Per-user tmux sessions help but mRiver still has finite + resources. +- **Latency**: ~2s cold tmux start is ok for one user; bad for "I just + opened the widget, ask a quick question, close" rhythm at scale. +- **Cost vs subscription**: m's Claude Code subscription covers his + personal turns. Multi-user would either need m's account to absorb the + load (dubious) or the firm's enterprise key (the actual prod path). +- **Streaming**: tmux streaming today is the youpc.org-style "tail the + response file as it grows" stopgap. Real token streaming (TTFB <1s) + needs the API. + +The API cutover should therefore be **a prerequisite for opening Paliadin +beyond owner-only**. The inline modal's design assumes API-cutover-ready +boundaries (the relay interface in Β§6.4) so when m flips the switch, the +inline-modal frontend doesn't change. + +### 6.3 Why not cutover now anyway? + +It's tempting because: + +- The CLAUDE.md note about `ANTHROPIC_API_KEY` reserved-but-unused has + been there since 2026-04-16 and would benefit from being un-deferred. +- The inline modal is the natural moment to revisit infrastructure. +- Klaus's youpc.org has built a relay-interface abstraction + (`youpcAIRelay` interface in `youpc_ai_relay.go`) that paliad could + borrow for the swap point. + +**Counter-arguments that win:** + +- Today's tmux relay shipped only 2-3 days ago (`paliadin_remote.go` + reference t-paliad-151). It's not a legacy substrate to escape β€” it's + fresh code that hasn't earned a rewrite yet. +- The compliance question for the API path (HLC-key vs personal-key, + audit retention requirements, prompt-logging policy) hasn't been + resolved with HLC IT. m flagged this as the **biggest open question** in + the t-paliad-146 design and it's still open. +- Inline modal can ship entirely on the existing relay; if the API + cutover comes later, the modal doesn't have to re-ship. + +**Therefore**: design a small interface seam (Β§6.4) so v1 doesn't paint us +into a tmux-only corner, but don't pay the cutover cost in this PR. + +### 6.4 Relay-interface seam (small, optional, recommended) + +Mirror youpc.org's pattern (`youpc_ai_relay.go`) but smaller β€” paliad +has one role, no streaming variant yet: + +```go +// internal/services/paliadin_relay.go (new) +type PaliadinRelay interface { + RunTurn(ctx context.Context, session string, turnID uuid.UUID, + envelope string) ([]byte, error) + Reset(ctx context.Context, session string) error + HealthGate(ctx context.Context, session string) error +} +``` + +`LocalPaliadinService` and `RemotePaliadinService` keep their current +shapes; the audit-row writes (`paliadinDB`) stay shared. `RunTurn` becomes +a thin wrapper that builds the envelope (with the new `[ctx …]` block from +Β§4.2) and delegates to the relay. A future `httpAPIRelay` slots in beside +the SSH one without touching the audit/turn-row code. + +**Don't extract the interface unless the inline modal's PR organically +needs it.** If the modal can ship without restructuring the existing +relay, the abstraction-cost is negative. + +--- + +## 7 Β· Agent-suggested write path β€” schema + flow + +### 7.1 Schema decision: extend `approval_requests`, not entity rows + +The brief listed three candidate locations: + +| Option | Where the marker lives | Verdict | +|---|---|---| +| A | `boolean agent_suggested` on `paliad.deadlines` / `paliad.appointments` | **Reject**: pollutes domain tables; survives past approval (the entity is no longer "agent-suggested" once it's been live for six months); doesn't carry which agent / which turn | +| B | `text suggested_by_agent` on entity rows (multi-agent provenance) | Same problems as A; "agent name" never used because we have one agent | +| C | New columns on `paliad.approval_requests` linking back to the suggesting turn | **Recommended** | + +The `approval_request` row IS the audit-chain entry; the entity row is +just current state. Provenance information belongs on the audit-chain row +where it can persist forever without polluting the entity schema. + +**Migration 070 (proposed):** + +```sql +ALTER TABLE paliad.approval_requests + -- 'user' = direct user create; 'agent' = drafted by Paliadin from a chat turn. + ADD COLUMN requester_kind text NOT NULL DEFAULT 'user' + CHECK (requester_kind IN ('user', 'agent')), + -- When requester_kind='agent', the chat turn the suggestion came from. + -- NULL otherwise. ON DELETE SET NULL β€” the audit record survives even + -- if the turn row is purged (paliadin_turns has no retention policy + -- today, but design for it). + ADD COLUMN agent_turn_id uuid + REFERENCES paliad.paliadin_turns(turn_id) ON DELETE SET NULL, + ADD CONSTRAINT approval_requests_agent_xor + CHECK ( + (requester_kind = 'agent' AND agent_turn_id IS NOT NULL) + OR (requester_kind = 'user' AND agent_turn_id IS NULL) + ); + +CREATE INDEX approval_requests_agent_turn_idx + ON paliad.approval_requests (agent_turn_id) + WHERE agent_turn_id IS NOT NULL; + +-- paliadin_turns also gets the structured context column. +ALTER TABLE paliad.paliadin_turns + ADD COLUMN context jsonb; +``` + +`requested_by` continues to be the user uuid β€” even for agent suggestions +the user is the *initiator* (Paliadin acts on their behalf, never +autonomously). `requester_kind` distinguishes "the user typed Speichern" +from "the user typed `/lege eine Frist an: …` to Paliadin and Paliadin +drafted it; the user has not yet approved". + +### 7.2 The flow + +1. **User asks Paliadin**: "Lege eine Frist fΓΌr diese Akte an: 16.05. + Klageerwiderung Acme". + +2. **Paliadin's SKILL.md gets a new section**: "Agent-suggested writes" + that teaches it to call a new MCP tool `paliad__suggest_deadline` (and + siblings for appointment / project_note / project_attach). The tool's + server-side handler: + + - Validates the user has visibility on the project (existing + `can_see_project`). + - Calls `DeadlineService.Create` *with the new + `IsAgentSuggestion=true` flag* and `agent_turn_id=`. + - Inside the create-tx, after the entity insert, the existing approval + hookup runs: `ApprovalService.SubmitCreate(...)`. **Critical + change**: when `IsAgentSuggestion` is set, the submit unconditionally + creates an approval request *even if no policy applies* β€” the agent + path is approval-gated by construction, not by partner-unit policy. + +3. **Eye-pill πŸ‘€ + sparkle ✨** render on the resulting row in `/inbox`, + `/deadlines`, `/agenda`. Click β†’ standard approve/reject UI. Approve + flips status to `approved`, sets `decision_kind='peer'` (or + admin_override if global_admin), the entity becomes live. + +4. **Audit chain on the project's Verlauf**: + + - `deadline_approval_requested` event with + `metadata.requester_kind='agent'` + `metadata.agent_turn_id=`. + Verlauf renderer picks this up and labels the event "Paliadin hat + eine Frist vorgeschlagen ✨". + - `deadline_approval_approved` with the user as `decided_by` + the + existing `decision_kind` ladder. Verlauf renders "Anna hat + Paliadin's Vorschlag genehmigt ✨". + +### 7.3 Why agent-suggested unconditionally goes through approval + +Two reasons: + +1. **Trust gradient**: even if a partner has direct create authority on + their own projects (no policy = no approval needed today), an agent + suggesting on their behalf is qualitatively different. Visible review + keeps the user in the loop. +2. **Single audit shape**: today the partner-unit policy decides which + creates need approval; bypassing that for agent suggestions creates a + second code path. Forcing agent suggestions into the approval pipeline + means there's exactly one "agent created an entity" audit shape (the + approval_request row). + +A user who finds the per-suggestion review tedious can request `/genehmige +einfach alles was Paliadin vorschlΓ€gt` β€” but that's a Phase 2 setting +("auto-approve agent suggestions on projects where I'm lead"), explicitly +out-of-scope for v1 (and m says so in #20: "Multi-turn agent loops … +Every creation gets the user's eye."). + +### 7.4 What entities can Paliadin suggest in v1? + +The brief mentions "deadlines, appointments, notes, project-tree edits". +Recommend ordering by reversibility + audit complexity: + +| Entity | v1? | Why | +|---|---|---| +| Deadline create | **Yes** | Highest-value (Klaus would rate this top), well-supported by existing `pending_create` lifecycle | +| Appointment create | **Yes** | Same lifecycle substrate; symmetric tool | +| Project note (`project_events.note`) | **Yes** | Read-only audit event, no approval gate today β€” but for agent-authored notes route through approval anyway (consistency) | +| Project-tree edit (move, rename) | **No, defer** | Approval lifecycle for project moves doesn't exist; designing it is its own task. | +| Deadline / appointment **edit** | **No, defer** | Edits today only need approval when date-fields change (t-paliad-138 Β§Q4). Agent edits would need their own design pass for "what changes does the user see in the diff?" | +| Deadline **complete** | **No, defer** | Same reason β€” complete already has approval lifecycle, but the agent path is qualitatively different (a deadline being marked done is high-stakes; design it after a v1 lands and we see how often agent-creates need editing) | + +**v1 = create only**. Edits/completes are a Phase 2 expansion. + +--- + +## 8 Β· Visual language β€” ✨ alongside πŸ‘€, not in place of + +### 8.1 Design + +`.approval-pill--agent` is a new modifier that sits **next to** the +existing `.approval-pill--icon` (the πŸ‘€ glyph), not replacing it. + +| Row state | Pill rendering | +|---|---| +| `approval_status='pending'` AND `requester_kind='user'` | πŸ‘€ | +| `approval_status='pending'` AND `requester_kind='agent'` | πŸ‘€ ✨ | +| `approval_status='approved'` AND `requester_kind='user'` | (no pill) | +| `approval_status='approved'` AND `requester_kind='agent'` | ✨ (subtle, in the row's *secondary* badge slot β€” not a pill) | + +The πŸ‘€ + ✨ pairing communicates: "this is awaiting approval *and* came +from Paliadin". Hover (`title` attr) on ✨ reads: +"Paliadin hat das vorgeschlagen β€” angeklickt klΓ€rt". + +**Why both glyphs, not a fused single glyph?** The two questions ("is +this awaiting approval?" / "did a human or Paliadin originate this?") are +orthogonal β€” a future autopilot mode might let some agent suggestions +auto-approve, in which case πŸ‘€ disappears but ✨ stays. Keeping them +separate keeps the visual taxonomy decomposable. + +### 8.2 Where ✨ renders + +Three surfaces: + +1. **Eye-pill row** (`/inbox`, `/deadlines`, `/agenda`, project detail, + /events): πŸ‘€ ✨ side-by-side when applicable. Same `.approval-pill` + shape, separate elements. +2. **Audit log** (`/admin/audit-log` + project Verlauf): the row's + "approved by" line gets a trailing ✨ when the underlying request had + `requester_kind='agent'`. Reads "Anna ✨ Schmidt" β†’ tooltip "Über + Paliadin vorgeschlagen, von Anna genehmigt". +3. **Approval request inbox card**: the requester's name in the inbox + card gets a subtle "✨ Paliadin (fΓΌr Anna)" badge instead of just + "Anna" when `requester_kind='agent'`. + +### 8.3 The "+p" annotation question + +m's #20 said: "we say USER + p or with a star or something". The "+p" +text annotation reads in audit logs but doesn't scan in a pill row (✨ is +recognisable; "+p" is not without learning). **Recommend**: ✨ as the +universal glyph. Reserve a textual fallback for compliance-export +contexts where emojis don't render β€” there the audit string becomes +"Anna [agent: Paliadin]" rather than "Anna ✨". + +--- + +## 9 Β· Persona separation + +m's brief asked whether to lean on klaus's "scope-bouncer in SKILL.md" +pattern (Hugo refuses legal questions, points at Lexie; Lexie refuses +"how do I subscribe?", points at Hugo) for paliad β€” i.e. pre-design +multi-persona infrastructure. + +**Recommendation: don't.** Paliad has one Paliadin (Patentpraxis assistant +at HLC's Patent team). The youpc.org split exists because *youpc.org has +fundamentally different audiences* β€” public visitors (Hugo handles "how +does this site work?") and premium-beta lawyers (Lexie does case-law +research). Their refusal scopes are different because their users are +different. + +Paliad's audience is one cohesive group: HLC PA team. They want one +assistant that does "everything PA-relevant" β€” Aktenmanagement, Fristen, +Begriffe, Gerichte, UPC-Recht. There's no audience pair that requires +distinct refusal scopes. + +**If Phase 2 wants to add a case-law research persona** (e.g. cross-link +to youpc.org's Lexie) β€” *that's a separate skill alongside Paliadin*, not +a persona-split inside Paliadin. The infrastructure for that already +exists in Claude Code's skill router (multiple skills, each its own +description/persona). + +**No SKILL.md changes for persona separation in this design**. The skill +gets Β§4.2's `[ctx …]` parser added, plus Β§7.2's `paliad__suggest_*` tool +guidance, but the persona stays "der Paliad-Patentpraxis-Assistent". + +--- + +## 10 Β· Phasing & implementation surface + +### 10.1 Suggested phasing (single PR is feasible; split optional) + +**Slice A β€” schema + relay seam** (~1 commit) +- Migration 070: `approval_requests.requester_kind` + + `agent_turn_id` + xor-check + index; `paliadin_turns.context jsonb`. +- Optional `PaliadinRelay` interface extraction (skip if it makes the PR + bigger without removing duplication). + +**Slice B β€” context payload + SKILL.md update** (~1 commit) +- Wire structured `PaliadinContext` from frontend β†’ Go β†’ tmux envelope. +- SKILL.md `[ctx …]` parsing + behaviour. +- `client/paliadin-context.ts` route-table + entity extraction (one file). +- `/api/paliadin/turn` accepts the new body shape (backwards-compatible: + old `page_origin` still honoured if `context` is absent). + +**Slice C β€” inline widget** (~1 commit, biggest) +- `frontend/src/components/PaliadinWidget.tsx`. +- `client/paliadin-widget.ts` (drawer state, sending, history, hide-on-route). +- `client/paliadin-starters.ts` registry (8 routes + default). +- Mechanical pass: every authenticated TSX adds ``. +- CSS: `.paliadin-widget`, `.paliadin-drawer`, `.paliadin-trigger`, + `.paliadin-context-chip`, ~150 lines of `global.css`. +- ~30 i18n keys. + +**Slice D β€” agent-suggested write path** (~1 commit) +- `paliad__suggest_deadline` + `paliad__suggest_appointment` MCP tools + (or HTTP tool, depending on how the MCP scope already wires β€” + `internal/handlers/paliadin_tools.go` if new file warranted). +- `DeadlineService.Create` / `AppointmentService.Create` accept a + `IsAgentSuggestion bool` + `AgentTurnID *uuid.UUID` plumbed into + `ApprovalService.SubmitCreate` (which gets a sibling + `SubmitAgentCreate` that always creates a request even without policy). +- SKILL.md adds the Β§7.2 "Agent-suggested writes" instruction block. + +**Slice E β€” visual language** (~1 commit) +- `.approval-pill--agent` CSS. +- `events.ts`, `agenda.ts`, `inbox.ts` render ✨ when + `requester_kind='agent'`. +- Audit-log + Verlauf renderer extends to surface ✨ on approved-from-agent + events. +- ~10 i18n keys for the badges + tooltips. + +**Recommended PR shape**: single PR with five commits in this order. Slice +A's migration is independent (can deploy without the rest); Slice D needs +B + C; Slice E builds on D. If sliced into multiple PRs, A and B-C can +ship independently of D-E (modal works as read-only chat without the +write path; that's already an upgrade). + +### 10.2 Files of note for the implementer + +**New files:** +- `internal/db/migrations/070_paliadin_inline.{up,down}.sql` +- `internal/handlers/paliadin_tools.go` (suggest verbs) +- `internal/services/paliadin_relay.go` (optional interface) +- `frontend/src/components/PaliadinWidget.tsx` +- `frontend/src/client/paliadin-widget.ts` +- `frontend/src/client/paliadin-starters.ts` +- `frontend/src/client/paliadin-context.ts` + +**Edits:** +- `internal/services/paliadin.go` (TurnRequest gains structured Context; + insertTurnRow stores it) +- `internal/services/approval_service.go` (SubmitCreate accepts + agent-flag; SubmitAgentCreate variant) +- `internal/services/deadline_service.go`, + `internal/services/appointment_service.go` (Create accepts + IsAgentSuggestion + AgentTurnID; threads to ApprovalService) +- `internal/handlers/paliadin.go` (turnRequest body schema) +- `frontend/src/client/events.ts`, `agenda.ts`, `inbox.ts` (✨ render) +- `frontend/src/styles/global.css` (drawer + ✨ pill CSS) +- `frontend/src/client/i18n.ts` (~40 new keys Γ— 2 langs) +- `frontend/src/components/Sidebar.tsx` β€” no edit (the existing sidebar + link logic already gates on owner; no new entries) +- ~30 page TSX files: mechanical `` add (~1 line each) +- `~/.claude/skills/paliadin/SKILL.md` (via `scripts/install-paliadin-skill`): + add Β§4.2 ctx-parser block + Β§7.2 suggest-tools block + +**Total estimated surface**: comparable to t-paliad-146 (the original +Paliadin design β€” ~3500-4500 LoC) plus the agent-suggest write path +(~1000 LoC). Single PR is feasible if the implementer is pattern-fluent; +split is fine. + +--- + +## 11 Β· Open questions for m + +These are the calls m has to make before any coder shift starts. + +### Q1 β€” Scope gate: still owner-only? +The inline modal's design assumes `PaliadinOwnerEmail` stays as the only +gate (m only). When does scope expand? +- (a) **Stays owner-only for v1** of inline modal β€” recommended; matches + brief. ← **inventor's pick** +- (b) Extend to a beta-features whitelist (firm-wide email domain + flag). +- (c) Expand to all of `hoganlovells.com` immediately. Requires API + cutover (Phase 2 prerequisite). + +### Q2 β€” Backend: tmux relay or Anthropic API for the inline modal? +- (a) **Keep tmux relay** for v1 β€” recommended; ships fastest. ← **inventor's pick** +- (b) Cutover to Anthropic API now β€” slower ship; better long-term. +- (c) Both: ship tmux v1, design the API path as a parallel deferred PR. + +### Q3 β€” Agent-suggested entities in v1: where to draw the line? +- (a) **Create-only**: deadline, appointment, note. Defer edits/completes/project-tree. ← **inventor's pick** +- (b) Create + edit (deadline + appointment). +- (c) Create + edit + complete + project-tree. + +### Q4 β€” Visual language for agent provenance? +- (a) **✨ glyph alongside πŸ‘€** β€” recommended; orthogonal to lifecycle. ← **inventor's pick** +- (b) "+p" text annotation in audit lines only; no glyph in pills. +- (c) Replace πŸ‘€ with ✨ for agent-pending rows (single glyph, more compact). + +### Q5 β€” Selection text in context payload β€” default on or off? +- (a) **Default on**, opt-out via widget settings β€” recommended. ← **inventor's pick** +- (b) Default off, opt-in via widget settings. +- (c) Always on, no toggle. + +### Q6 β€” Widget visibility scope: everywhere except `/paliadin`, or finer? +- (a) **Everywhere except `/paliadin`, `/login`, `/onboarding`** β€” + recommended; lowest cognitive load. ← **inventor's pick** +- (b) Only on data-bearing pages (dashboard, projects, deadlines, agenda, + events, inbox); hide on tool pages (fristenrechner etc.). +- (c) User-configurable per page. + +### Q7 β€” Modal vs dialog: drawer + scrim, or non-modal floating panel? +- (a) **Modal slide-out drawer with scrim** (focus-traps) β€” recommended. ← **inventor's pick** +- (b) Non-modal floating panel (page stays interactive while widget is open). + +### Q8 β€” Keyboard shortcut for opening: Cmd-K? +- (a) **Cmd-K / Ctrl-K** β€” recommended. ← **inventor's pick** +- (b) Different shortcut (m to specify). +- (c) No shortcut, button-only. + +### Q9 β€” Context payload truncation cap (selection text)? +- (a) **1000 chars** β€” recommended; balances usefulness vs prompt-bloat. ← **inventor's pick** +- (b) Higher cap (5000 chars). +- (c) Lower cap (300 chars). + +### Q10 β€” Persona separation pre-design? +- (a) **Single Paliadin, no scope-bouncer pattern** β€” recommended; YAGNI. ← **inventor's pick** +- (b) Add scope-bouncer pattern now (Paliadin refuses non-paliad questions, points at... where?). +- (c) Pre-design split with a second skill (Phase 2 case-law researcher). + +### Q11 β€” Auto-approve some agent suggestions? +- (a) **No, every agent suggestion needs the user's eye** β€” recommended; matches m's #20 verbatim. ← **inventor's pick** +- (b) Auto-approve agent suggestions on projects where the user is lead. +- (c) Auto-approve when the suggestion was a direct response to "Lege … an" (user opted in by phrasing). + +### Q12 β€” Recommended implementer? +Same substrate as t-paliad-146 + t-paliad-160 + t-paliad-138 (paliadin, +approval pipeline, eye-pill UI). Pattern-fluent Sonnet work. +- (a) **Any pattern-fluent Sonnet coder** β€” recommended. ← **inventor's pick** +- (b) The same coder who shipped t-paliad-160 (deepest context on the + approval pipeline). +- (c) Two coders: one on Slices A-C (modal + context), one on Slices D-E + (agent-suggest + visual language). + +--- + +## 12 Β· Out of scope (for now) β€” preserved + +Per m's brief: + +- Direct Paliadin write permission (no RLS bypass, no agent service-role + identity). The approval gate stays the only path agents take into prod + data. +- Multi-turn agent loops β€” no chained writes without per-step user + approval. +- Production-v1 Anthropic API cutover for the existing standalone + `/paliadin` route (recommended in Β§6 as a *prerequisite* for opening + beyond owner-only, but not committed in this task). +- Edits / completes / project-tree as agent-suggestible entities (Β§7.4 + defers to Phase 2). +- Persona separation infrastructure (Β§9 defers indefinitely). + +--- + +## 13 Β· Trade-offs flagged + +| Trade-off | What we accept | Mitigation | +|---|---|---| +| Tmux-relay v1 caps concurrency at one turn per user | Owner-only v1 makes this fine | Spec the relay-interface seam (Β§6.4) so API cutover is non-disruptive | +| Mechanical `` pass touches ~30 files | Same pattern as t-paliad-042 PWAHead, low risk | One commit per slice keeps blame surface tight | +| Agent suggestions unconditionally route through approval | Some users may find it tedious | Phase 2 auto-approve setting (m wants Q11 = no, so this isn't urgent) | +| Two glyphs (πŸ‘€ + ✨) might confuse first-time approvers | Slight onboarding cost | Tooltip on hover; admin/onboarding doc one-liner | +| Selection-text in context payload risks accidental info leakage | Low (data already in DB) | Cap + redaction in admin dashboard (Β§4.3) | +| Per-route starter registry needs maintenance as routes evolve | Yes; cost is real | Default fallback ensures no route is silent; route renames are caught by build (registry imports route names as a const map) | + +--- + +## 14 Β· Implementation hygiene + +- **No bare CSS tokens.** New `.paliadin-widget*` + `.approval-pill--agent` + CSS uses existing `--color-*` / `--accent-*` / `--bg-soft` tokens. The + reminder from t-paliad-150 (third occurrence of bare-token leaks) holds. +- **No RAISE EXCEPTION in migration 070** β€” Maria's build constraint. +- **No `2>&1` on diagnostic** β€” global rule. +- **i18n must compile** β€” every new label gets a key in `client/i18n.ts` + + DE/EN values; `bun run build` regenerates `i18n-keys.ts`. +- **Build + vet + test gate** β€” `go build ./...` + `go vet ./...` + + `go test ./...` + `cd frontend && bun run build` all clean before push. +- **Don't self-merge** β€” push branch, comment on Gitea #20, await m's + merge gate. +- **Don't close issue #20** β€” m closes issues. Set `done` label on + approval. + +--- + +## 15 Β· End-of-shift checklist (this design) + +- [x] Read m/paliad#20 + Klaus's reply (msg #1563 / comment). +- [x] Read existing `paliadin.go` + `paliadin_remote.go` + `approval_service.go` + `paliadin-shim` + `install-paliadin-skill` + `~/.claude/skills/paliadin/SKILL.md`. +- [x] Read youpc.org reference: `sidebar-widget.html` + `sidebar.js` + `ai-chat-client.js` + `youpc_ai_relay.go`. +- [x] Verify live state: paliad.de up, migration tracker at 69, schema columns matched expectations, eye-pill πŸ‘€ already wired. +- [x] Take a position on every decision in the brief (see Β§0 table; Β§11 for the open questions). +- [x] No hour estimates anywhere in the doc. +- [x] Recommend implementer + phasing. +- [ ] Commit this doc on `mai/dirac/inventor-inline-paliadin`. +- [ ] Push branch. +- [ ] Comment on Gitea #20 with summary + doc link. +- [ ] File mBrian synthesis node under `topic-paliadin` (or equivalent). +- [ ] `mai report completed "DESIGN READY FOR REVIEW: …"` and **stop**. Do not auto-flip to coder. + +--- + +*Inventor parked after this commit. The head will surface to m for the +go/no-go gate before any coder shift begins. Skipping that gate has +burned commits before (m/mAi#142); the gate is non-negotiable.*