docs(paliadin): t-paliad-161 inventor design — inline modal + agent-suggested write path

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.
This commit is contained in:
m
2026-05-08 19:35:39 +02:00
parent caa76d2925
commit 142edca401

View File

@@ -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/<id>` → "Was steht für diese Akte diese Woche an?"
- On `/deadlines/<id>` → "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" │
├──────────────────────────────┼─┤
│ <messages> │ │ 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 `<div id="paliadin-widget" style="display:none"></div>`
that page-template files include alongside `<PWAHead />` and `<Sidebar />`.
The mechanical edit pass: every authenticated TSX page (~30 files) gets a
`<PaliadinWidget />` near `</body>`. This mirrors the existing
`<PWAHead />` 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/<id> → ("project", "<id>")
// /deadlines/<id> → ("deadline", "<id>")
// /appointments/<id> → ("appointment", "<id>")
// /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": "<uuid>",
"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:<turn_id>] [ctx route=projects.detail entity=project:61e3... selection="…" filter="status=overdue"] <user_message>
```
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:<id>` 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<string, Starter[]> = {
"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=<current turn>`.
- 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=<uuid>`.
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 `<PaliadinWidget />`.
- 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 `<PaliadinWidget />` 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 `<PaliadinWidget />` 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.*