Compare commits
30 Commits
mai/hilber
...
mai/einste
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7be8511833 | ||
|
|
7daa70aaad | ||
|
|
05d14d5e5a | ||
|
|
925a377c8b | ||
|
|
7935fee7bf | ||
|
|
be2150c17d | ||
|
|
5893c45e5e | ||
|
|
3e1f4eee4b | ||
|
|
e75a71fb34 | ||
|
|
9579032f94 | ||
|
|
97a412498d | ||
|
|
319221ff83 | ||
|
|
4c47819da8 | ||
|
|
db3514c4db | ||
|
|
a0d1e77ef2 | ||
|
|
d519363c8d | ||
|
|
82faa3d8bd | ||
|
|
a80652a085 | ||
|
|
f820aa8316 | ||
|
|
1d7c7d7246 | ||
|
|
da971a7466 | ||
|
|
e4110cf2db | ||
|
|
68c56ea920 | ||
|
|
0c8a2f1a95 | ||
|
|
56a3dc961e | ||
|
|
f62bf9f8fb | ||
|
|
dd139a3536 | ||
|
|
f952fb85c3 | ||
|
|
024841129f | ||
|
|
befa41c00e |
@@ -47,7 +47,8 @@ Paliad — the patent paladin. All-in-one patent practice platform for HLC (form
|
||||
| `PALIAD_BASE_URL` | optional | Public origin used in email links. Defaults to `https://paliad.de`; override for staging/preview. |
|
||||
| `SMTP_HOST` / `SMTP_PORT` / `SMTP_USERNAME` / `SMTP_PASSWORD` / `SMTP_FROM` / `SMTP_FROM_NAME` / `SMTP_USE_TLS` | for email | SMTP credentials for Paliad's transactional mail (reminders, invitations). Port 465 uses implicit TLS. `MailService` silently no-ops when any required var is missing — the server still boots for knowledge-platform-only deployments. |
|
||||
| `ANTHROPIC_API_KEY` | not used in PoC | Reserved for the eventual production-v1 Paliadin (the Anthropic Messages API path, see `docs/design-paliadin-2026-05-07.md` §2). The Phase 0 PoC (t-paliad-146) does NOT use this — it shells out to a local `claude` CLI via tmux instead, which uses m's existing Claude Code subscription. Set this env var only after the PoC validates and we cut over to the API-backed path. The earlier "Phase H Frist-Extraktion" reservation is dead — that feature is deferred separately (memory `b6a11b55…`). |
|
||||
| `PALIADIN_TMUX_SESSION` | optional (default `paliad-paliadin`) | tmux session name the Paliadin service uses for its long-lived `claude` pane. |
|
||||
| `PALIADIN_SESSION_PREFIX` | optional (default `paliad-paliadin`) | Prefix for the per-user tmux session names the Paliadin service uses (t-paliad-155). Each Paliad user gets their own session named `<prefix>-<userid8>` (first 8 hex chars of the user's UUID); conversation history accumulates per visit, `ResetSession` kills the session entirely. The persona + response protocol now live in `~/.claude/skills/paliadin/SKILL.md` (installed via `scripts/install-paliadin-skill`) — no in-process system prompt is sent. |
|
||||
| `PALIADIN_REMOTE_CWD` | shim env (default `/home/m/dev/paliad`) | Working directory `paliadin-shim` uses when spawning the long-lived `claude` pane on mRiver. Must be the paliad repo root so claude picks up `.mcp.json` (project-scoped Supabase MCP); without it, the SKILL.md SQL recipes have no DB tool. Set on mRiver only — paliad's Go side never reads this. |
|
||||
| `PALIADIN_RESPONSE_DIR` | optional (default `/tmp/paliadin`) | Directory where Claude writes its per-turn response files. The Go service polls this directory for `{turn_id}.txt` files. |
|
||||
|
||||
> *Note on Paliadin gating (t-paliad-146):* there is **no** `PALIADIN_ENABLED` env var. Access is gated in code via `services.PaliadinOwnerEmail` (currently `matthias.siebels@hoganlovells.com`). Every other authenticated user gets a 404 on `/paliadin` and `/admin/paliadin`. This means the routes register on every paliad deploy (including paliad.de prod), but only m can reach them — and even then, prod only works if the host has `tmux` + a `claude` CLI in PATH (which the Dokploy container does not). PoC remains a laptop-only feature; the gate is in the code, not the deploy.
|
||||
|
||||
@@ -11,7 +11,7 @@ COPY . .
|
||||
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /paliad ./cmd/server
|
||||
|
||||
FROM alpine:3.21
|
||||
RUN apk add --no-cache ca-certificates
|
||||
RUN apk add --no-cache ca-certificates openssh-client
|
||||
WORKDIR /app
|
||||
COPY --from=backend /paliad /app/paliad
|
||||
COPY --from=frontend /app/frontend/dist /app/dist
|
||||
|
||||
@@ -2,10 +2,15 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
// Embed Go's IANA tz database into the binary so time.LoadLocation works
|
||||
@@ -165,20 +170,34 @@ func main() {
|
||||
CardLayout: services.NewCardLayoutService(pool),
|
||||
}
|
||||
|
||||
// t-paliad-146 — Paliadin PoC. Always wired when DATABASE_URL
|
||||
// is set; the per-request handler gate (requirePaliadinOwner)
|
||||
// restricts access to the single owner email
|
||||
// (services.PaliadinOwnerEmail). All other authenticated users
|
||||
// get a 404 — the route effectively does not exist for them.
|
||||
// On hosts without tmux + the `claude` CLI (e.g. the Dokploy
|
||||
// container), the owner gate still applies; if m ever hits the
|
||||
// route from such a host, the service returns "tmux unavailable"
|
||||
// without ever invoking shell-out.
|
||||
tmuxSession := os.Getenv("PALIADIN_TMUX_SESSION")
|
||||
responseDir := os.Getenv("PALIADIN_RESPONSE_DIR")
|
||||
svcBundle.Paliadin = services.NewPaliadinService(pool, users, tmuxSession, responseDir)
|
||||
log.Printf("paliadin: wired (owner=%s; gate is per-request, not per-deploy)",
|
||||
services.PaliadinOwnerEmail)
|
||||
// Paliadin backend selection (t-paliad-146 + t-paliad-151):
|
||||
// PALIADIN_REMOTE_HOST set → RemotePaliadinService (ssh to mRiver)
|
||||
// else: local tmux available → LocalPaliadinService (PoC path)
|
||||
// else: DisabledPaliadinService (handlers still 404 for non-owners
|
||||
// via the gate; for m, RunTurn returns ErrPaliadinDisabled
|
||||
// which surfaces as a friendly error).
|
||||
//
|
||||
// All three implement services.Paliadin; the per-request handler
|
||||
// gate (requirePaliadinOwner) is unchanged and applies to every
|
||||
// backend.
|
||||
if remoteHost := os.Getenv("PALIADIN_REMOTE_HOST"); remoteHost != "" {
|
||||
cfg, err := buildPaliadinRemoteConfig(remoteHost)
|
||||
if err != nil {
|
||||
log.Fatalf("paliadin: remote config: %v", err)
|
||||
}
|
||||
svcBundle.Paliadin = services.NewRemotePaliadinService(pool, users, cfg)
|
||||
log.Printf("paliadin: remote mode → ssh %s@%s:%d (owner=%s)",
|
||||
cfg.SSHUser, cfg.SSHHost, cfg.SSHPort, services.PaliadinOwnerEmail)
|
||||
} else if _, err := exec.LookPath("tmux"); err == nil {
|
||||
sessionPrefix := os.Getenv("PALIADIN_SESSION_PREFIX")
|
||||
responseDir := os.Getenv("PALIADIN_RESPONSE_DIR")
|
||||
svcBundle.Paliadin = services.NewLocalPaliadinService(pool, users, sessionPrefix, responseDir)
|
||||
log.Printf("paliadin: local tmux mode (owner=%s)", services.PaliadinOwnerEmail)
|
||||
} else {
|
||||
svcBundle.Paliadin = services.NewDisabledPaliadinService(pool, users)
|
||||
log.Printf("paliadin: disabled (no PALIADIN_REMOTE_HOST, no local tmux; owner=%s)",
|
||||
services.PaliadinOwnerEmail)
|
||||
}
|
||||
// Wire ApprovalService into the entity services so Create / Update /
|
||||
// Complete / Delete consult paliad.approval_policies (t-paliad-138).
|
||||
// Without this wiring, the policies and request tables exist but no
|
||||
@@ -217,3 +236,134 @@ func main() {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// buildPaliadinRemoteConfig assembles a RemotePaliadinConfig from
|
||||
// environment variables, materialising the SSH private key and
|
||||
// known_hosts blobs into chmod-600/644 tmpfiles for OpenSSH to read.
|
||||
//
|
||||
// The blobs travel as Dokploy secrets (multi-line env vars). We never
|
||||
// persist them to disk — tmpfiles live for the process lifetime in
|
||||
// /tmp and disappear on container restart. Re-creating them every boot
|
||||
// is fine; the keys themselves rotate independently via Dokploy
|
||||
// secret updates.
|
||||
//
|
||||
// Required: PALIADIN_REMOTE_HOST, PALIADIN_SSH_PRIVATE_KEY, PALIADIN_KNOWN_HOSTS.
|
||||
// Optional: PALIADIN_REMOTE_USER (default "m"), PALIADIN_REMOTE_PORT
|
||||
// (default 22022 — bypasses Tailscale SSH on :22, see design §4.5).
|
||||
func buildPaliadinRemoteConfig(host string) (services.RemotePaliadinConfig, error) {
|
||||
cfg := services.RemotePaliadinConfig{
|
||||
SSHHost: host,
|
||||
SSHUser: cmpOr(os.Getenv("PALIADIN_REMOTE_USER"), "m"),
|
||||
SSHPort: 22022,
|
||||
SessionPrefix: os.Getenv("PALIADIN_SESSION_PREFIX"),
|
||||
}
|
||||
if p := os.Getenv("PALIADIN_REMOTE_PORT"); p != "" {
|
||||
n, err := strconv.Atoi(p)
|
||||
if err != nil || n <= 0 || n > 65535 {
|
||||
return cfg, fmt.Errorf("PALIADIN_REMOTE_PORT %q: not a valid port", p)
|
||||
}
|
||||
cfg.SSHPort = n
|
||||
}
|
||||
|
||||
// Dokploy stores compose env vars in a single-line .env file: multi-line
|
||||
// PEM bodies get truncated to the first line. Base64-encode the
|
||||
// private key in the secret to survive that round-trip; here we
|
||||
// detect base64 vs raw PEM and decode either way.
|
||||
keyBlob, err := decodePaliadinPrivateKey(os.Getenv("PALIADIN_SSH_PRIVATE_KEY"))
|
||||
if err != nil {
|
||||
return cfg, fmt.Errorf("PALIADIN_SSH_PRIVATE_KEY: %w", err)
|
||||
}
|
||||
keyPath, err := writeSecretFile("paliadin-id_ed25519-", keyBlob, 0o600)
|
||||
if err != nil {
|
||||
return cfg, fmt.Errorf("PALIADIN_SSH_PRIVATE_KEY: %w", err)
|
||||
}
|
||||
if keyPath == "" {
|
||||
return cfg, fmt.Errorf("PALIADIN_REMOTE_HOST set but PALIADIN_SSH_PRIVATE_KEY empty")
|
||||
}
|
||||
cfg.SSHKeyPath = keyPath
|
||||
|
||||
knownHostsPath, err := writeSecretFile("paliadin-known_hosts-", os.Getenv("PALIADIN_KNOWN_HOSTS"), 0o644)
|
||||
if err != nil {
|
||||
return cfg, fmt.Errorf("PALIADIN_KNOWN_HOSTS: %w", err)
|
||||
}
|
||||
if knownHostsPath == "" {
|
||||
return cfg, fmt.Errorf("PALIADIN_REMOTE_HOST set but PALIADIN_KNOWN_HOSTS empty")
|
||||
}
|
||||
cfg.KnownHostsPath = knownHostsPath
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// decodePaliadinPrivateKey accepts either a raw PEM (multi-line) or a
|
||||
// base64-encoded PEM. Returns the raw PEM bytes ready to write to a
|
||||
// keyfile. Empty input → ("", nil) so the caller can distinguish
|
||||
// "secret not set" from "decode failed".
|
||||
//
|
||||
// Why base64: Dokploy stores compose env vars in a one-line-per-key
|
||||
// .env file, which silently truncates multi-line values to their first
|
||||
// line. Empirically, a multi-line `-----BEGIN OPENSSH PRIVATE KEY-----`
|
||||
// arrived inside the container as just the BEGIN header (36 bytes).
|
||||
// Base64-encoding the key in the Dokploy secret survives that
|
||||
// round-trip. We still accept raw PEM for local-dev convenience.
|
||||
func decodePaliadinPrivateKey(blob string) (string, error) {
|
||||
blob = strings.TrimSpace(blob)
|
||||
if blob == "" {
|
||||
return "", nil
|
||||
}
|
||||
// Raw PEM: starts with ----- and contains a newline. Use as-is.
|
||||
if strings.HasPrefix(blob, "-----") && strings.Contains(blob, "\n") {
|
||||
return blob + "\n", nil
|
||||
}
|
||||
// Otherwise treat as base64. Strip any whitespace OpenSSH keygen
|
||||
// helpers might insert (line breaks every 64 chars in some tools).
|
||||
clean := strings.Map(func(r rune) rune {
|
||||
if r == ' ' || r == '\n' || r == '\r' || r == '\t' {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, blob)
|
||||
decoded, err := base64.StdEncoding.DecodeString(clean)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("not raw PEM (no newline) and base64 decode failed: %w", err)
|
||||
}
|
||||
out := string(decoded)
|
||||
if !strings.HasPrefix(out, "-----BEGIN") {
|
||||
return "", fmt.Errorf("decoded body does not look like a PEM key (no -----BEGIN prefix)")
|
||||
}
|
||||
if !strings.HasSuffix(out, "\n") {
|
||||
out += "\n"
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// writeSecretFile writes blob to a tmpfile with the given mode and
|
||||
// returns its path. Returns ("", nil) when blob is empty so callers
|
||||
// can distinguish "not set" from real I/O errors.
|
||||
func writeSecretFile(prefix, blob string, mode os.FileMode) (string, error) {
|
||||
if blob == "" {
|
||||
return "", nil
|
||||
}
|
||||
f, err := os.CreateTemp("", prefix+"*")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := f.WriteString(blob); err != nil {
|
||||
_ = f.Close()
|
||||
_ = os.Remove(f.Name())
|
||||
return "", err
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.Chmod(f.Name(), mode); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return f.Name(), nil
|
||||
}
|
||||
|
||||
func cmpOr(s, fallback string) string {
|
||||
if s != "" {
|
||||
return s
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
@@ -20,5 +20,19 @@ services:
|
||||
- SMTP_FROM=${SMTP_FROM}
|
||||
- SMTP_FROM_NAME=${SMTP_FROM_NAME}
|
||||
- SMTP_USE_TLS=${SMTP_USE_TLS}
|
||||
# Paliadin remote routing (t-paliad-151). When PALIADIN_REMOTE_HOST
|
||||
# is set, paliad forwards each turn to mRiver via SSH on port 22022.
|
||||
# The container reaches mRiver over Tailscale via mLake's host-side
|
||||
# tailscale0 + Docker source NAT — no network_mode override needed
|
||||
# (verified Phase A.5: a plain alpine container on Dokploy's
|
||||
# default bridge SSHs to mriver:22022 in 3 s, source IP NAT'd to
|
||||
# mLake's tailnet IP, matches the from="100.99.98.201" clause on
|
||||
# mRiver's authorized_keys).
|
||||
# PRIVATE_KEY and KNOWN_HOSTS are multi-line Dokploy secrets.
|
||||
- PALIADIN_REMOTE_HOST=${PALIADIN_REMOTE_HOST}
|
||||
- PALIADIN_REMOTE_PORT=${PALIADIN_REMOTE_PORT}
|
||||
- PALIADIN_REMOTE_USER=${PALIADIN_REMOTE_USER}
|
||||
- PALIADIN_SSH_PRIVATE_KEY=${PALIADIN_SSH_PRIVATE_KEY}
|
||||
- PALIADIN_KNOWN_HOSTS=${PALIADIN_KNOWN_HOSTS}
|
||||
# - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} # Phase H (AI Frist-Extraktion), currently deferred
|
||||
restart: unless-stopped
|
||||
|
||||
947
docs/design-deadline-data-model-2026-05-08.md
Normal file
947
docs/design-deadline-data-model-2026-05-08.md
Normal file
@@ -0,0 +1,947 @@
|
||||
# Deadline Data Model — Proceedings-as-DAG
|
||||
|
||||
**Author:** einstein (consultant)
|
||||
**Date:** 2026-05-08
|
||||
**Task:** t-paliad-158 ([Consultant] Deadline data model — proceedings-as-DAG analysis + recommendation)
|
||||
**Branch:** `mai/einstein/consultant-deadline-data`
|
||||
**Status:** DESIGN — analysis only, no schema changes in this branch.
|
||||
**Predecessors read:** docs/audit-fristenrechner-completeness-2026-04-30.md (curie), docs/plans/unified-fristenrechner.md + docs/plans/unified-fristenrechner-v3.md (cronus, archived author), docs/design-courts-per-country-holidays-2026-05-05.md (cronus, on-hold).
|
||||
**Companion:** feynman is in flight on `mai/feynman/fristenrechner` (t-paliad-157). Read that branch's WIP if pushed; do not take dependencies on it. This analysis is upstream of any in-flight implementation.
|
||||
|
||||
---
|
||||
|
||||
## 0. Executive summary
|
||||
|
||||
**The problem.** paliad's deadline knowledge today is fragmented across five tables and two parallel calculators. The structural truth m wants — *court system → proceeding → ordered event types → conditional trigger edges* — is mostly *implicit*: it lives partly in `deadline_rules.parent_id` (one-parent tree per proceeding), partly in `trigger_events`+`event_deadlines` (flat YouPC import), partly in `deadline_concepts` (cross-proceeding semantic bridge), partly in `event_categories` (Pathway-B navigation taxonomy), and partly in free-text columns on `paliad.projects`. Conditions are encoded *twice* — once via `condition_rule_id` (FK to a sibling rule), once via `condition_flag text[]` (named flags). Multi-parent triggers cannot be expressed cleanly. The court-system axis is missing entirely.
|
||||
|
||||
**What m wants** (verbatim, 2026-05-08 16:01):
|
||||
|
||||
> All I want is a natural sequence of proceedings which belong to a court system. And of course we can classify deadlines into concepts and make it easier for the AI to understand, but in its core I need event types that are related to proceedings and connected as a sequence, one triggering the other, with some conditions possibly changing the resulting sequence.
|
||||
|
||||
**Locked m decisions (this doc, AskUserQuestion 2026-05-08 16:13–16:18):**
|
||||
|
||||
| Q | Subject | Lock |
|
||||
|---|---|---|
|
||||
| Q1 | Court-system axis | **Reuse `courts.court_type` as the system identity.** Promote it to a `paliad.court_types` lookup. FK `paliad.courts.court_type` → `court_types.code`. Retire `paliad.proceeding_types.jurisdiction`. |
|
||||
| Q2 | Proceeding instance | **Project (or sub-project) IS the proceeding instance.** Verbatim m: *"Each UPC proceeding should be its own (sub-)project. And as such can be one proceeding or multiple if necessary. Flexibility is key."* No new `paliad.proceedings` table. Multi-proceeding cases use sub-projects in the existing project tree. |
|
||||
| Q3 | Edge model | **First-class `paliad.proceeding_event_edges` table.** Multi-parent triggers natural. `parent_id` on the legacy `deadline_rules` table retired. |
|
||||
| Q4 | Conditions | **Typed columns per edge:** `if_flags text[]` (all must be set), `unless_flags text[]` (none may be set), `requires_event_id uuid REFERENCES proceeding_event_types(id)`. SQL-queryable; no expression evaluator. |
|
||||
| Q5 | Concept layer | **Subsume `deadline_concepts` into `proceeding_event_types.concept_slug` column.** Drop `deadline_concepts` table after backfill. Keep `event_categories` recursive tree as Pathway-B navigation overlay only — re-FK its junction onto `concept_slug`. |
|
||||
|
||||
**Headline shape change.** Today's *two-rule-libraries-bridged-by-a-mat-view* becomes *one rule library: a graph of typed event-types connected by typed edges, scoped to proceedings, scoped to court systems*. The instance side stays where it is (project tree). The AI/UX layers (concept tags, navigation tree) ride on top of the graph rather than parallel to it.
|
||||
|
||||
**Migration shape.** Additive build → atomic cutover per surface (Fristenrechner, deadline-search, /deadlines/new picker), all on the same boot. The 26 production `paliad.deadlines` rows survive untouched (their `rule_code` text already carries the citation; `rule_id` re-points to the new event-type/edge tuple post-cutover).
|
||||
|
||||
---
|
||||
|
||||
## 1. Map of current state
|
||||
|
||||
### 1.1 The five tables that carry deadline knowledge
|
||||
|
||||
```
|
||||
┌──────────────────────────────┐
|
||||
│ paliad.proceeding_types (26) │ jurisdiction text
|
||||
│ ─ INF, REV, CCR, APM, … │ ('UPC'|'DE'|'EPA'|'DPMA')
|
||||
│ ─ UPC_INF, UPC_REV, UPC_PI… │
|
||||
│ ─ DE_INF, DE_NULL, DE_*_BGH │
|
||||
│ ─ EPA_OPP, EPA_APP, EP_GRANT│
|
||||
│ ─ DPMA_OPP, DPMA_*_BPATG… │
|
||||
└──────────┬───────────────────┘
|
||||
│ 1:N
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ paliad.deadline_rules (172) │
|
||||
│ ─ uuid PK │
|
||||
│ ─ proceeding_type_id int FK │
|
||||
│ ─ parent_id uuid → self (one-parent tree) │
|
||||
│ ─ code, name_de, name_en, description │
|
||||
│ ─ primary_party (claimant|defendant|both|court) │
|
||||
│ ─ event_type (filing|decision|order|hearing) │
|
||||
│ ─ duration_value int, duration_unit text │
|
||||
│ (months|weeks|days|working_days) │
|
||||
│ ─ timing (after|before) │
|
||||
│ ─ rule_code text, deadline_notes text+_en │
|
||||
│ ─ legal_source text ← t-paliad-131 Phase A │
|
||||
│ ─ concept_id uuid FK ← t-paliad-131 Phase A │
|
||||
│ ─ condition_rule_id uuid ─┐ │
|
||||
│ ─ condition_flag text[] ├ TWO mechanisms, │
|
||||
│ ─ alt_duration_* / unit │ one structural idea │
|
||||
│ ─ alt_rule_code │ │
|
||||
│ ─ anchor_alt text ─┘ │
|
||||
│ ─ is_spawn bool, spawn_label text │
|
||||
│ ─ is_bilateral bool ← t-paliad-133 Phase A │
|
||||
│ ─ sequence_order int │
|
||||
└──────┬───────────────────────────────────────────────┘
|
||||
│ concept_id (uuid)
|
||||
▼
|
||||
┌──────────────────────────────┐
|
||||
│ paliad.deadline_concepts (57)│ the Unifier layer
|
||||
│ ─ slug UNIQUE │ (t-paliad-131 Phase A)
|
||||
│ ─ name_de, name_en │
|
||||
│ ─ aliases text[] │
|
||||
│ ─ party text │
|
||||
│ ─ category (submission| │
|
||||
│ decision|order|hearing) │
|
||||
└──────┬───────────────────────┘
|
||||
│ concept_id (uuid)
|
||||
▼ (junction)
|
||||
┌──────────────────────────────────────────┐
|
||||
│ paliad.event_category_concepts (136) │ decision-tree leaf
|
||||
│ ─ event_category_id FK │ → concept overlay
|
||||
│ ─ concept_id FK │ (t-paliad-133)
|
||||
│ ─ proceeding_type_code text -- narrow │
|
||||
└──────┬───────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────┐
|
||||
│ paliad.event_categories (103)│ recursive tree (parent_id self-FK)
|
||||
│ ─ slug, label_de, label_en │ Pathway-B navigation taxonomy
|
||||
│ ─ step_question_de/_en │ (t-paliad-133, depth unlimited)
|
||||
│ ─ icon, sort_order, is_leaf │
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
|
||||
**Parallel rule library — YouPC import (UPC-only, flat):**
|
||||
|
||||
```
|
||||
┌──────────────────────────────┐ ┌──────────────────────────────────┐
|
||||
│ paliad.trigger_events (110) │ 1:N │ paliad.event_deadlines (77) │
|
||||
│ ─ bigint PK (verbatim from │ ───────▶│ ─ bigint PK (verbatim ids) │
|
||||
│ youpc.data.events) │ │ ─ trigger_event_id FK │
|
||||
│ ─ code, name, name_de │ │ ─ duration_value, duration_unit │
|
||||
│ ─ concept_id text │ │ (days|weeks|months| │
|
||||
│ ↑ slug (text), NOT FK │ │ working_days) │
|
||||
└──────────────────────────────┘ │ ─ alt_duration_* + combine_op │
|
||||
│ (max|min — composite │
|
||||
│ rule for R.198/R.213) │
|
||||
│ ─ timing (before|after) │
|
||||
│ ─ title, title_de, notes, _en │
|
||||
└──────────┬───────────────────────┘
|
||||
│ 1:N
|
||||
▼
|
||||
┌──────────────────────────────────┐
|
||||
│ paliad.event_deadline_rule_codes │
|
||||
│ (72 — one row per RoP citation) │
|
||||
│ ─ event_deadline_id, rule_code, │
|
||||
│ sort_order │
|
||||
└──────────────────────────────────┘
|
||||
```
|
||||
|
||||
The two rule libraries are bridged at search time only by `paliad.deadline_search` (mat-view, t-paliad-131 Phase C, migration 047): one row per (concept × context) where context is `kind='rule'` for `deadline_rules` rows or `kind='trigger'` for `trigger_events` rows. They share **no FK**.
|
||||
|
||||
**Instance side** (the per-case audit row):
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ paliad.deadlines (26 in production) │
|
||||
│ ─ uuid PK │
|
||||
│ ─ project_id uuid FK → paliad.projects(id) │
|
||||
│ ─ rule_id uuid FK → deadline_rules(id) NULL │
|
||||
│ ─ rule_code text -- citation, free-text │
|
||||
│ (t-paliad-111 — survives rule rename) │
|
||||
│ ─ title, description, due_date, original_due_ │
|
||||
│ date, warning_date │
|
||||
│ ─ status (pending|completed|cancelled|waived) │
|
||||
│ ─ source (manual|imported|caldav|paliadin) │
|
||||
│ ─ caldav_uid, caldav_etag │
|
||||
│ ─ approval_status (approved|pending|legacy) │
|
||||
│ + pending_request_id, approved_by/_at │
|
||||
│ (t-paliad-138 dual-control, migration 054)│
|
||||
│ ─ created_by, created_at, updated_at │
|
||||
└─────────────────────────────────────────────────┘
|
||||
│ deadline_id
|
||||
▼
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ paliad.deadline_event_types (junction, 0..N) │
|
||||
│ ─ deadline_id, event_type_id (composite PK) │
|
||||
│ (t-paliad-088, migration 030) │
|
||||
└─────────────────────────────────────────────────┘
|
||||
▲
|
||||
│ event_type_id
|
||||
│
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ paliad.event_types (45) │
|
||||
│ ─ uuid PK, slug, label_de, label_en │
|
||||
│ ─ category (submission|decision|order|service| │
|
||||
│ fee|hearing|other) │
|
||||
│ ─ jurisdiction (UPC|EPO|DPMA|DE|any) NULL │
|
||||
│ ─ trigger_event_id bigint NULL ─ loose linkage│
|
||||
│ (NO FK constraint — youpc resync-safe) │
|
||||
│ ─ created_by, is_firm_wide, archived_at │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
`paliad.event_types` is the user-facing classifier on per-case `paliad.deadlines` rows. It overlaps with `paliad.trigger_events` by ~70% (UPC submissions) and carries an optional `trigger_event_id` linkage column without an FK constraint by design (so a future YouPC re-sync can drop trigger ids without breaking event_types). It is **distinct from** `paliad.event_categories` (the Pathway-B decision tree) and **distinct from** `paliad.deadline_rules.event_type` (which is just a text column, values `filing|decision|order|hearing`).
|
||||
|
||||
So today the word "event type" identifies three different things in three different tables. Not necessarily wrong, but worth flagging.
|
||||
|
||||
### 1.2 Court / venue / jurisdiction
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ paliad.courts (41 — t-paliad-122 migration 053) │
|
||||
│ ─ id text PK (kebab, mirrors handlers/courts.go) │
|
||||
│ ─ code, name_de, name_en │
|
||||
│ ─ country text FK → paliad.countries(code) -- ISO-3166 │
|
||||
│ ─ regime text NULL -- 'UPC'|'EPO'|NULL │
|
||||
│ ─ court_type text -- 'UPC-LD'|'UPC-CD'|'UPC-CoA'| │
|
||||
│ 'DE-LG'|'DE-OLG'|'DE-BGH'| │
|
||||
│ 'DE-BPatG'|'DE-DPMA'|'EPA'|'NAT' │
|
||||
│ ─ parent_id text FK → self │
|
||||
│ ─ sort_order int, is_active bool │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
The `court_type` column is currently **free text** (no constraint, no FK target). 41 rows are seeded across 11 distinct values. This is the column m's Q1 lock promotes to be the court-system identity.
|
||||
|
||||
`paliad.holidays` (55 rows) carries `country` ISO-3166 + `regime` ('UPC'|'EPO'|NULL). Federal DE public holidays = country='DE', regime=NULL; UPC summer/winter judicial vacations = country=NULL, regime='UPC'. The check constraint `country IS NOT NULL OR regime IS NOT NULL` enforces every row carries at least one.
|
||||
|
||||
### 1.3 Project side — what links a case to a proceeding today
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ paliad.projects (11 active in prod) │
|
||||
│ ─ id uuid PK │
|
||||
│ ─ type text -- 'mandat'|'litigation'|'patent'| │
|
||||
│ 'verfahren'|'projekt' │
|
||||
│ ─ parent_id uuid → self (project tree) │
|
||||
│ ─ path text NOT NULL -- materialised ltree path │
|
||||
│ (t-paliad-023, GiST-indexed, RLS-load-bearing) │
|
||||
│ ─ title, reference, description, status │
|
||||
│ ─ proceeding_type_id integer -- single FK │
|
||||
│ → paliad.proceeding_types(id) │
|
||||
│ ─ court text -- FREE TEXT, no FK to paliad.courts │
|
||||
│ ─ country text │
|
||||
│ ─ patent_number, filing_date, grant_date │
|
||||
│ ─ case_number, billing_reference, client_number, │
|
||||
│ matter_number, netdocuments_url │
|
||||
│ ─ industry, ai_summary, metadata jsonb │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
So the project row carries:
|
||||
- ONE proceeding-type FK (an integer, not nullable on `verfahren` projects but nullable in the schema).
|
||||
- ONE court — but as **free text**, not FK'd to `paliad.courts.id` despite that table being seeded six days ago in migration 053.
|
||||
- NO trigger_date column. The trigger date is implicit in the `paliad.deadlines.original_due_date` of whichever Frist anchored the calc.
|
||||
- NO live-state column. There is no "currently at stage X" pointer.
|
||||
|
||||
There's no `paliad.proceedings` table. The conceptual link "this project IS a UPC infringement action" is the pair (project_id → proceeding_type_id), no further structure.
|
||||
|
||||
### 1.4 What lives where — by jurisdiction
|
||||
|
||||
| Jurisdiction | proceeding_types | deadline_rules | trigger_events | event_deadlines |
|
||||
|---|---:|---:|---:|---:|
|
||||
| UPC (legacy: INF/REV/CCR/APM/APP/AMD) | 6 | 36 | 0 | 0 |
|
||||
| UPC (modern: UPC_INF/UPC_REV/UPC_PI/…) | 8 | 56 | 110 | 77 |
|
||||
| DE (ZPO/PatG, LG/OLG/BGH/BPatG) | 5 | 40 | 0 | 0 |
|
||||
| EPA (OPP/APP/EP_GRANT) | 3 | 23 | 0 | 0 |
|
||||
| DPMA | 3 | 13 | 0 | 0 |
|
||||
| Cross-cutting (Wiedereinsetzung, …) | 0 | 0 | 7 | 7 |
|
||||
| Legacy ZPO_CIVIL placeholder | 1 | 4 | 0 | 0 |
|
||||
| **Total** | **26** | **172** | **110** | **77** |
|
||||
|
||||
The two UPC generations (`INF/REV/CCR/APM/APP/AMD` from migration 008 vs `UPC_INF/UPC_REV/UPC_PI/UPC_APP/UPC_DAMAGES/UPC_DISCOVERY/UPC_COST_APPEAL/UPC_APP_ORDERS` from migration 012) coexist in production. Fristenrechner v3+ uses the modern set; the legacy six are unreferenced sediment kept "in case". This is technical debt orthogonal to the model question, flagged here for the migration plan in §4.
|
||||
|
||||
### 1.5 How conditional triggers are encoded today (concrete)
|
||||
|
||||
| Mechanism | Rules using it | Example |
|
||||
|---|---:|---|
|
||||
| `condition_rule_id` (FK to a sibling rule) | 2 | INF tree's `inf.reply` and `inf.rejoin` reference `ccr.counterclaim` — when CCR was filed in the same case, swap rule_code RoP.029.b → RoP.029.a (Reply) or duration 1mo → 2mo (Rejoinder). |
|
||||
| `condition_flag text[]` (named flags from request) | 17 | UPC_INF tree's `with_ccr` rules render only when the request includes `with_ccr` flag; UPC_REV's `with_amend`/`with_cci` parallel flags. |
|
||||
| `alt_duration_value` + `alt_duration_unit` + `alt_rule_code` | 4 | Swap-on-flag fallback (R.198/R.213 max-of-31d-or-20wd is encoded similarly on `event_deadlines.alt_duration_*` + `combine_op`). |
|
||||
| `anchor_alt text` (named alternate anchor) | 1 | EP_GRANT publish anchors on `priority_date` instead of parent rule's date. |
|
||||
| `is_spawn` + `spawn_label` (cross-tree edge) | 6 | INF tree's `inf.appeal` lives in APP tree but `parent_id` points into INF.decision — the rule itself sits in proceeding APP, the parent sits in proceeding INF. Implicit cross-proceeding edge. |
|
||||
| `condition_flag` AND `alt_duration_value` together | 3 | UPC_INF Replik has `condition_flag=['with_ccr']` swapping duration via `alt_duration_value` rather than gating render. |
|
||||
|
||||
The two-mechanism split is what bites every contributor. `condition_rule_id` was the Phase-A approach; `condition_flag` was added by t-paliad-086 PR-3 because `condition_rule_id` couldn't model "user told me they ARE in CCR mode without there being a rule of mine to point at." Both still in production. New rules should use `condition_flag`; the 2 legacy `condition_rule_id` rules are equivalent to single-element flag arrays and were not migrated.
|
||||
|
||||
### 1.6 The two calculators
|
||||
|
||||
- **Tree calculator** — `internal/services/fristenrechner.go` (803 lines): walks `deadline_rules` parent_id chain, anchors on input trigger_date, applies condition_flag gates, swaps `alt_*` columns when flags are set, classifies court-determined nodes (`isCourtDeterminedRule`: `primary_party='court' OR event_type IN ('hearing','decision','order')`) so they render as "no date — court will set it". Used by `/tools/fristenrechner` for the 16 modern proceeding-tree views.
|
||||
- **Flat calculator** — `internal/services/event_deadline_service.go` (315 lines): single trigger_event ID + trigger_date → list of event_deadlines, no parent chain. Composite `combine_op='max'`/`'min'` resolves R.198/R.213. Working-days math via `addWorkingDays` over `paliad.holidays`. Used by Pathway-B "Was kommt nach…" tab.
|
||||
|
||||
The two share `holidays.go` for working-day skip logic. Otherwise the code paths are independent.
|
||||
|
||||
---
|
||||
|
||||
## 2. Gaps vs proceedings-as-DAG framing
|
||||
|
||||
m's framing decoded into structural facts the data model SHOULD support:
|
||||
|
||||
| m says | Data model needs |
|
||||
|---|---|
|
||||
| "court system" is the outer container | One row per court system the firm practises in (UPC-CFI, UPC-CoA, DE-LG-Patentkammer, DE-OLG, DE-BGH, DE-BPatG, EPO, DPMA, …). Procedural rules belong to a court system. |
|
||||
| "a natural sequence of proceedings" | One row per *named procedural shape* (UPC infringement action, UPC revocation action, EPO opposition, DE LG patent action). A proceeding belongs to ONE court system. |
|
||||
| "event types … related to proceedings" | Each event-type node belongs to a proceeding. Some nodes may be shared across proceedings (final-decision, oral-hearing). |
|
||||
| "connected as a sequence, one triggering the other" | Edges between event-types within a proceeding. Multi-parent allowed (one node may be triggered by either of two predecessors). |
|
||||
| "with some conditions possibly changing the resulting sequence" | Edges carry conditions. Conditions are first-class (queryable, AI-readable). |
|
||||
| "classify deadlines into concepts and make it easier for the AI" | Concept tag layer on each event-type. Rides on top of the graph, doesn't compete with it. |
|
||||
|
||||
### 2.1 Concrete gaps
|
||||
|
||||
#### Gap G1 — Court system is not in the data model
|
||||
|
||||
**Today:** `proceeding_types.jurisdiction text` ('UPC'|'DE'|'EPA'|'DPMA') conflates court-system regime with national jurisdiction. The 41 `paliad.courts` rows carry `court_type` ('UPC-LD'|'UPC-CoA'|'DE-LG'|'DE-OLG'|'DE-BGH'|'DE-BPatG'|'EPA'|'DPMA'|'NAT'|…) as free text. There is no FK between the two.
|
||||
|
||||
**Why it bites:** "Show me every UPC procedural rule" requires `proceeding_types.jurisdiction='UPC'`. "Show me every rule that fires in a German LG patent chamber" requires reasoning about court_type='DE-LG' AND a proceeding that runs there — but the proceeding doesn't carry a court_type, the *project's court* does, and that's free text. The DE-LG and DE-OLG patent appeal proceedings (`DE_INF`, `DE_INF_OLG`) BOTH have jurisdiction='DE' on `proceeding_types`; nothing tells you DE_INF runs at LG and DE_INF_OLG runs at OLG except the proceeding name.
|
||||
|
||||
**Concrete fail:** today, the holiday lookup for "deadline computed for a UPC infringement action filed in München LD" needs UPC summer vacation + DE federal holidays. The intermediate join (project.court_type → applicable holiday set) is hardcoded in `internal/services/holidays.go` because there's no FK chain to walk.
|
||||
|
||||
#### Gap G2 — One project = one proceeding-type FK; multi-proceeding cases are forced into the project tree
|
||||
|
||||
**Today:** `paliad.projects.proceeding_type_id integer` is single-valued. A project that hosts BOTH a UPC infringement action and a separate revocation counterclaim must either:
|
||||
(a) Tag itself with one of the two and lose half its proceeding context, or
|
||||
(b) Be split into two child `verfahren` projects under a common litigation parent.
|
||||
|
||||
**m's lock (Q2):** Sub-projects are the right answer. *"Each UPC proceeding should be its own (sub-)project."* This is consistent with the project-tree model already in place since t-paliad-023 (data-model-v2). The fix isn't to add a `paliad.proceedings` table; it's to *honour* the existing tree by FK-tightening `projects.proceeding_def_id` on `verfahren`-typed projects.
|
||||
|
||||
#### Gap G3 — Edges are one-parent only; multi-parent triggers cannot be expressed cleanly
|
||||
|
||||
**Today:** Each `deadline_rules` row has at most one `parent_id`. A node like UPC `inf.rejoin` has TWO real-world predecessors:
|
||||
- After Reply-to-SoD when no CCR was filed (1 month, RoP.029.c)
|
||||
- After Reply-to-Defence-to-CCR when CCR was filed (1 month, RoP.029.e)
|
||||
|
||||
The current model collapses these into ONE rule with `condition_flag=['with_ccr']` swapping `alt_*` columns, but that masks the true graph: there are two distinct edges into `inf.rejoin`, with different `from_event_type` and different `rule_code`. Today the calculator papers over this by anchoring `inf.rejoin` on whichever parent the `parent_id` points at and pretending the other parent doesn't exist for purposes of the chain walk.
|
||||
|
||||
Cross-proceeding edges (the legacy `is_spawn` flag, 6 rules) are an even uglier symptom — `inf.appeal` lives in proceeding APP but its `parent_id` points into INF. Two different proceedings, one edge. Today this is fine for tree traversal but breaks any "show me proceeding APP's structure" query because you have to know the edge crosses.
|
||||
|
||||
#### Gap G4 — Conditions encoded in two mechanisms
|
||||
|
||||
**Today:** 2 rules use `condition_rule_id` (FK to a sibling rule whose presence flips alt_duration / alt_rule_code), 17 rules use `condition_flag text[]` (named flags). Both still load-bearing in the calculator. Same idea, two columns.
|
||||
|
||||
**Why it bites:** Every new contributor has to learn both. The 2 legacy `condition_rule_id` rules are sentinel debt — they couldn't be deleted without rewriting the inf.reply / inf.rejoin classifier_flag dual-encoding (memory `652b856f` t-paliad-086 PR-3 imported the flag-based variant alongside, did NOT migrate the legacy two).
|
||||
|
||||
#### Gap G5 — Two parallel rule libraries with no shared FK
|
||||
|
||||
**Today:**
|
||||
- `deadline_rules` (172 rows, UUID PK, parent-tree, condition_flag, alt_*) — the timeline calculator's source.
|
||||
- `trigger_events` + `event_deadlines` (110+77 rows, bigint PK, flat trigger→deadline map, composite max/min) — the trigger calculator's source.
|
||||
|
||||
They are bridged at search time by `paliad.deadline_search` mat-view (concept slug as join key) but share no FK. A rule in `deadline_rules` and a deadline in `event_deadlines` can describe the *same* legal idea (e.g. UPC Klageerwiderung) and the only thing that ties them is whether someone happened to set the same `concept_id`/`concept slug` on both sides.
|
||||
|
||||
This costs us:
|
||||
- **Drift** — when t-paliad-086 PR-3 fixed Tier-1 bugs in `deadline_rules`, equivalent rows in `event_deadlines` were not touched. The two libraries can disagree on the same Frist.
|
||||
- **Audit difficulty** — "is this Frist correct?" requires reading both tables and the bridge.
|
||||
- **AI confusion** — feeding the corpus to the LLM means feeding two different shapes of the same knowledge.
|
||||
|
||||
#### Gap G6 — Concept layer is a rope-bridge, not a column
|
||||
|
||||
**Today:** `paliad.deadline_concepts` (57 rows) is a separate table. `deadline_rules.concept_id uuid FK`. `trigger_events.concept_id text` (slug, NOT FK — string-walked). `event_category_concepts.concept_id uuid FK` (the navigation overlay). Three different referent types for the same entity.
|
||||
|
||||
**Why it bites:** Re-naming a concept (slug change) means walking three FK shapes. AI ingestion means joining four tables to get "what does this Frist *mean*." The cross-proceeding semantic identity (one Klageerwiderung in UPC ≅ one Klageerwiderung in DE_INF) is queryable but not load-bearing — the FK exists, but nothing constrains *both* rules to point at the same concept_id. Drift is silent.
|
||||
|
||||
#### Gap G7 — Conditional sequence changes are local to one edge
|
||||
|
||||
**Today:** A condition on rule X (e.g. `condition_flag=['with_ccr']`) gates whether rule X renders. It does NOT propagate. So if "with_ccr is true" should *also* mean "the Application-to-amend timeline becomes available in this proceeding," that's encoded as separate rules each with their own `condition_flag=['with_ccr']`. No "if condition C, the proceeding switches to track T" semantic.
|
||||
|
||||
**Concrete example:** UPC infringement with CCR has its OWN sub-proceeding shape (Defence-to-CCR with its own Reply/Rejoinder cycle, optional Application-to-amend). Today this is encoded as N additional rules in `UPC_INF` each gated on `with_ccr`. Tomorrow it could be one `proceeding_event_edges` row that says "if `with_ccr` then activate the CCR sub-graph rooted at this node."
|
||||
|
||||
This is **not** addressed by Q3+Q4 — multi-parent edges + typed conditions. We'll *come closer*, but a true track-switching semantic ("this proceeding has an alternate path that engages under condition X") is one level above the edge model and is **deliberately deferred**. See §6.4.
|
||||
|
||||
---
|
||||
|
||||
## 3. Target shape
|
||||
|
||||
This section translates m's locked decisions into a concrete schema and walks one full UPC infringement action to make the shape tangible.
|
||||
|
||||
### 3.1 Court system axis (Q1)
|
||||
|
||||
```sql
|
||||
CREATE TABLE paliad.court_types (
|
||||
code text PRIMARY KEY,
|
||||
name_de text NOT NULL,
|
||||
name_en text NOT NULL,
|
||||
regime text -- 'UPC'|'EPO'|NULL (national)
|
||||
CHECK (regime IS NULL OR regime IN ('UPC','EPO')),
|
||||
sort_order int NOT NULL DEFAULT 0,
|
||||
is_active bool NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
INSERT INTO paliad.court_types (code, name_de, name_en, regime, sort_order) VALUES
|
||||
-- UPC court systems
|
||||
('UPC-LD', 'UPC-Lokalkammer', 'UPC Local Division', 'UPC', 10),
|
||||
('UPC-CD', 'UPC-Zentralkammer', 'UPC Central Division', 'UPC', 20),
|
||||
('UPC-CoA', 'UPC-Berufungsgericht', 'UPC Court of Appeal', 'UPC', 30),
|
||||
('UPC-RD', 'UPC-Regionalkammer', 'UPC Regional Division', 'UPC', 40),
|
||||
-- DE court systems
|
||||
('DE-LG', 'Landgericht (Patentstreitkammer)',
|
||||
'German Regional Court (patent chamber)', NULL, 50),
|
||||
('DE-OLG', 'Oberlandesgericht (Patentsenat)',
|
||||
'German Higher Regional Court (patent senate)', NULL, 60),
|
||||
('DE-BGH', 'Bundesgerichtshof (X. Zivilsenat)',
|
||||
'German Federal Court of Justice (Xth Civil Senate)', NULL, 70),
|
||||
('DE-BPatG', 'Bundespatentgericht', 'German Federal Patent Court', NULL, 80),
|
||||
('DE-DPMA', 'Deutsches Patent- und Markenamt',
|
||||
'German Patent and Trade Mark Office', NULL, 90),
|
||||
-- EPO
|
||||
('EPA', 'Europäisches Patentamt', 'European Patent Office', 'EPO', 100),
|
||||
-- National (non-UPC, non-DE-patent-track)
|
||||
('NAT', 'Nationales Gericht', 'National Court', NULL, 200);
|
||||
|
||||
-- FK from existing courts table
|
||||
ALTER TABLE paliad.courts
|
||||
ADD CONSTRAINT courts_court_type_fk
|
||||
FOREIGN KEY (court_type) REFERENCES paliad.court_types(code);
|
||||
```
|
||||
|
||||
The 41 `paliad.courts` rows already carry the right `court_type` strings (verified live: 11 distinct values, all in the seed list above). The FK addition is a pure constraint upgrade, no data move.
|
||||
|
||||
### 3.2 Proceeding definitions (the named-sequence template)
|
||||
|
||||
```sql
|
||||
-- Renamed + restructured from paliad.proceeding_types
|
||||
CREATE TABLE paliad.proceeding_definitions (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
code text NOT NULL UNIQUE,
|
||||
-- 'UPC_INF','UPC_REV','UPC_PI','UPC_APP','EPO_OPP',
|
||||
-- 'EPO_APP','DE_INF_LG','DE_INF_OLG','DE_INF_BGH',
|
||||
-- 'DE_NULL_BPATG','DE_NULL_BGH','DPMA_OPP','DPMA_APP','DPMA_RB'
|
||||
name_de text NOT NULL,
|
||||
name_en text NOT NULL,
|
||||
description text,
|
||||
court_type text NOT NULL
|
||||
REFERENCES paliad.court_types(code), -- the system axis
|
||||
category text NOT NULL -- 'litigation'|'opposition'|'examination'|'appeal'
|
||||
CHECK (category IN ('litigation','opposition','examination',
|
||||
'appeal','enforcement','provisional')),
|
||||
default_color text NOT NULL DEFAULT '#3b82f6',
|
||||
sort_order int NOT NULL DEFAULT 0,
|
||||
is_active bool NOT NULL DEFAULT true,
|
||||
is_fristenrechner bool NOT NULL DEFAULT true,
|
||||
-- whether this proceeding is exposed in /tools/fristenrechner
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX proceeding_definitions_court_type_idx
|
||||
ON paliad.proceeding_definitions(court_type);
|
||||
CREATE INDEX proceeding_definitions_category_idx
|
||||
ON paliad.proceeding_definitions(category);
|
||||
```
|
||||
|
||||
Each row IS a "natural sequence of [a class of] proceedings." `court_type` is the outer container m asked for. The legacy `proceeding_types.jurisdiction` text column is dropped — its information is now derivable via `court_types.regime`.
|
||||
|
||||
### 3.3 Event types (the nodes)
|
||||
|
||||
```sql
|
||||
CREATE TABLE paliad.proceeding_event_types (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
proceeding_def_id uuid NOT NULL
|
||||
REFERENCES paliad.proceeding_definitions(id) ON DELETE CASCADE,
|
||||
-- Each node belongs to one proceeding. Cross-proceeding shared
|
||||
-- semantics are expressed via concept_slug (Q5 lock), not by
|
||||
-- attaching one node to multiple proceedings.
|
||||
code text NOT NULL,
|
||||
-- Local code, unique within proceeding_def_id.
|
||||
-- Examples: 'soc','sod','reply','rejoinder','decision'
|
||||
name_de text NOT NULL,
|
||||
name_en text NOT NULL,
|
||||
description text,
|
||||
party text NOT NULL
|
||||
CHECK (party IN ('claimant','defendant','both','court','any')),
|
||||
kind text NOT NULL
|
||||
CHECK (kind IN ('filing','decision','order','hearing','service','fee')),
|
||||
concept_slug text, -- Q5 lock — subsumes paliad.deadline_concepts
|
||||
-- Free-form slug; matches old concept slugs verbatim post-migration.
|
||||
-- One LLM-readable identifier shared across proceedings.
|
||||
-- E.g. 'statement-of-defence' on both UPC_INF.sod and DE_INF_LG.klageerw.
|
||||
concept_de text, -- denormalised from old deadline_concepts.name_de
|
||||
concept_en text, -- denormalised from old deadline_concepts.name_en
|
||||
aliases text[] NOT NULL DEFAULT '{}',
|
||||
-- Search aliases inherited from old deadline_concepts.aliases.
|
||||
-- Indexed via gin (aliases) for the search bar.
|
||||
is_root bool NOT NULL DEFAULT false,
|
||||
-- True for the trigger node of a proceeding (the Statement of Claim,
|
||||
-- the Statement for Revocation, the EPO opposition filing). The
|
||||
-- proceeding instance's trigger_date anchors here.
|
||||
sort_order int NOT NULL DEFAULT 0,
|
||||
is_active bool NOT NULL DEFAULT true,
|
||||
is_bilateral bool NOT NULL DEFAULT false,
|
||||
-- Carried over from t-paliad-133. When true AND party='both',
|
||||
-- mirror into both columns of the columns-view.
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
UNIQUE (proceeding_def_id, code)
|
||||
);
|
||||
|
||||
CREATE INDEX proceeding_event_types_def_idx ON paliad.proceeding_event_types(proceeding_def_id);
|
||||
CREATE INDEX proceeding_event_types_concept_idx ON paliad.proceeding_event_types(concept_slug)
|
||||
WHERE concept_slug IS NOT NULL;
|
||||
CREATE INDEX proceeding_event_types_aliases_idx ON paliad.proceeding_event_types USING gin (aliases);
|
||||
CREATE INDEX proceeding_event_types_de_trgm ON paliad.proceeding_event_types USING gin (name_de gin_trgm_ops);
|
||||
CREATE INDEX proceeding_event_types_en_trgm ON paliad.proceeding_event_types USING gin (name_en gin_trgm_ops);
|
||||
```
|
||||
|
||||
Per Q5: `concept_slug` + `concept_de` + `concept_en` + `aliases` are columns on the node, not a separate table. The 57 `paliad.deadline_concepts` rows distill into ~57 distinct concept_slug values across the ~172+ migrated nodes. Cross-proceeding "all rules with concept_slug='statement-of-defence'" is a single-column index lookup, not a join.
|
||||
|
||||
### 3.4 Edges (the typed triggers)
|
||||
|
||||
```sql
|
||||
CREATE TABLE paliad.proceeding_event_edges (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
proceeding_def_id uuid NOT NULL
|
||||
REFERENCES paliad.proceeding_definitions(id) ON DELETE CASCADE,
|
||||
from_event_id uuid REFERENCES paliad.proceeding_event_types(id) ON DELETE CASCADE,
|
||||
-- NULL = root edge (anchors on the proceeding instance's trigger_date).
|
||||
-- The to_event must have is_root=true for null-from edges.
|
||||
to_event_id uuid NOT NULL
|
||||
REFERENCES paliad.proceeding_event_types(id) ON DELETE CASCADE,
|
||||
duration_value int NOT NULL DEFAULT 0,
|
||||
duration_unit text NOT NULL DEFAULT 'months'
|
||||
CHECK (duration_unit IN ('days','weeks','months','working_days')),
|
||||
timing text NOT NULL DEFAULT 'after'
|
||||
CHECK (timing IN ('after','before')),
|
||||
-- 'before' supports countdown deadlines (e.g. "1 month before oral hearing").
|
||||
combine_op text CHECK (combine_op IS NULL OR combine_op IN ('max','min')),
|
||||
alt_duration_value int,
|
||||
alt_duration_unit text CHECK (alt_duration_unit IS NULL
|
||||
OR alt_duration_unit IN ('days','weeks','months','working_days')),
|
||||
-- combine_op + alt_* implements composite rules
|
||||
-- (e.g. R.198/R.213 max(31d, 20wd)). Only set on edges
|
||||
-- where the rule itself is composite — flag-conditioned
|
||||
-- variants use sibling edges, not alt_*.
|
||||
|
||||
-- ===== Q4 lock — typed conditions =====
|
||||
if_flags text[] NOT NULL DEFAULT '{}',
|
||||
-- All flags in this array must be set for the edge to fire.
|
||||
-- Empty array = unconditional.
|
||||
unless_flags text[] NOT NULL DEFAULT '{}',
|
||||
-- None of these flags may be set for the edge to fire.
|
||||
requires_event_id uuid REFERENCES paliad.proceeding_event_types(id) ON DELETE SET NULL,
|
||||
-- Edge fires only if this OTHER event was actually filed/recorded
|
||||
-- in the proceeding instance (replaces today's condition_rule_id).
|
||||
-- NULL = no occurrence prerequisite.
|
||||
|
||||
-- ===== Citation =====
|
||||
rule_code text, -- 'RoP.029.b','PatG §111(1)','§ 276 ZPO'
|
||||
legal_source text, -- 'UPC.RoP.029.b' / 'DE.PatG.111.1' / 'EU.EPÜ.108'
|
||||
is_mandatory bool NOT NULL DEFAULT true,
|
||||
deadline_notes_de text,
|
||||
deadline_notes_en text,
|
||||
|
||||
sort_order int NOT NULL DEFAULT 0,
|
||||
is_active bool NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
-- Sanity: from_event must belong to the same proceeding_def
|
||||
-- (cross-proceeding edges are out-of-scope per §3.6 — modelled
|
||||
-- via separate root edges in each proceeding instead).
|
||||
CONSTRAINT edge_from_in_def CHECK (
|
||||
from_event_id IS NULL OR proceeding_def_id IS NOT NULL
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX edges_def_idx ON paliad.proceeding_event_edges(proceeding_def_id);
|
||||
CREATE INDEX edges_to_idx ON paliad.proceeding_event_edges(to_event_id);
|
||||
CREATE INDEX edges_from_idx ON paliad.proceeding_event_edges(from_event_id)
|
||||
WHERE from_event_id IS NOT NULL;
|
||||
CREATE INDEX edges_requires_idx ON paliad.proceeding_event_edges(requires_event_id)
|
||||
WHERE requires_event_id IS NOT NULL;
|
||||
CREATE INDEX edges_if_flags_idx ON paliad.proceeding_event_edges USING gin (if_flags);
|
||||
CREATE INDEX edges_unless_flags_idx ON paliad.proceeding_event_edges USING gin (unless_flags);
|
||||
CREATE INDEX edges_rule_code_idx ON paliad.proceeding_event_edges(rule_code)
|
||||
WHERE rule_code IS NOT NULL;
|
||||
```
|
||||
|
||||
**Multi-parent semantics:** when two edges share the same `to_event_id`, both compute candidate dates; the calculator picks per the edges' `if_flags`/`unless_flags`/`requires_event_id` predicates. If multiple edges remain feasible for the same target, the rendered Frist is the LATEST of the candidates (paying lip service to the most-conservative-first principle); a future edge-priority column can refine this if needed.
|
||||
|
||||
**Composite within an edge** (`combine_op`): used only when the rule itself is structurally composite (R.198 / R.213 max-of-two-units). Flag-driven variants (`with_ccr` swaps duration 1mo→2mo) become **two sibling edges** with disjoint `if_flags` predicates — the cleaner expression of the same idea.
|
||||
|
||||
### 3.5 Project ↔ proceeding linkage (Q2)
|
||||
|
||||
```sql
|
||||
-- Per Q2 lock — project (or sub-project) IS the proceeding instance.
|
||||
ALTER TABLE paliad.projects
|
||||
ADD COLUMN proceeding_def_id uuid
|
||||
REFERENCES paliad.proceeding_definitions(id),
|
||||
ADD COLUMN court_id text
|
||||
REFERENCES paliad.courts(id),
|
||||
ADD COLUMN proceeding_trigger_date date,
|
||||
-- The date that anchors the root edge of this proceeding.
|
||||
-- Null until the trigger-event has actually occurred.
|
||||
ADD COLUMN proceeding_status text NOT NULL DEFAULT 'pending'
|
||||
CHECK (proceeding_status IN ('pending','active','suspended','concluded','withdrawn'));
|
||||
|
||||
-- Backfill from the existing integer FK + free-text court column.
|
||||
UPDATE paliad.projects p
|
||||
SET proceeding_def_id = pd.id
|
||||
FROM paliad.proceeding_definitions pd
|
||||
JOIN paliad.proceeding_types pt ON pt.code = pd.code
|
||||
WHERE p.proceeding_type_id = pt.id;
|
||||
|
||||
-- Free-text court → FK by best-effort string match.
|
||||
UPDATE paliad.projects p
|
||||
SET court_id = c.id
|
||||
FROM paliad.courts c
|
||||
WHERE p.court IS NOT NULL
|
||||
AND lower(p.court) IN (lower(c.id), lower(c.code), lower(c.name_de), lower(c.name_en));
|
||||
|
||||
-- After backfill (separate migration, gated on QA):
|
||||
-- ALTER TABLE paliad.projects DROP COLUMN proceeding_type_id;
|
||||
-- ALTER TABLE paliad.projects DROP COLUMN court; -- free-text version
|
||||
-- ALTER TABLE paliad.projects DROP COLUMN country; -- inferred via court → court_type → country
|
||||
```
|
||||
|
||||
A `verfahren`-typed project carries `proceeding_def_id` (the template) + `court_id` (the venue) + `proceeding_trigger_date` (the anchor for downstream edges). A `mandat`/`litigation`-typed project does NOT carry these (NULL is fine). Multi-proceeding cases live as sibling `verfahren` projects under a shared parent — exactly m's lock.
|
||||
|
||||
The `proceeding_status` column gives the per-instance live state m wanted (pending → active → concluded) without a separate `paliad.proceedings` table. Future fields (current-stage event_type_id, last_advanced_at, expected-decision-date) extend this column set without disturbing other layers.
|
||||
|
||||
### 3.6 Cross-proceeding edges — explicit retirement
|
||||
|
||||
The current `is_spawn` flag (6 rules) encodes "filing of A in proceeding X opens proceeding Y" by parking a rule in proceeding Y's tree with `parent_id` pointing into proceeding X. Concretely: `inf.appeal` lives in APP but its parent is INF.decision.
|
||||
|
||||
In the new shape: **each proceeding's graph is closed.** Cross-proceeding triggers are modelled at the *instance* layer — when the user records "decision in INF reached on date D," they instantiate a NEW `verfahren` sub-project (proceeding APP) with `proceeding_trigger_date=D`. The graph stays clean; the cross-proceeding step is a project-tree action, not an edge.
|
||||
|
||||
This is a small UX shift (today the appeal Frist auto-renders inside the INF timeline; tomorrow the user explicitly spawns the appeal sub-project to see its Fristen) but the alternative — letting `proceeding_event_edges` straddle proceedings — pollutes the model. Defer cross-proceeding-edge support; add a sub-project-creation shortcut on the decision-event UI instead.
|
||||
|
||||
### 3.7 Concept layer — what stays, what goes
|
||||
|
||||
**Drops:**
|
||||
- `paliad.deadline_concepts` (57 rows). Content lifts to `proceeding_event_types.concept_slug` + `concept_de` + `concept_en` + `aliases`.
|
||||
- `paliad.deadline_rules.concept_id` FK. Replaced by `proceeding_event_types.concept_slug` text column.
|
||||
- `paliad.trigger_events.concept_id text` (already a slug, was never an FK). Migrated to the matching `proceeding_event_types` rows — see §4.
|
||||
|
||||
**Stays:**
|
||||
- `paliad.event_categories` (103 rows) — Pathway-B navigation taxonomy. Recursive tree, decision-tree UI. Re-FK its junction onto `concept_slug`:
|
||||
|
||||
```sql
|
||||
ALTER TABLE paliad.event_category_concepts
|
||||
DROP CONSTRAINT event_category_concepts_concept_id_fkey;
|
||||
ALTER TABLE paliad.event_category_concepts
|
||||
ADD COLUMN concept_slug text;
|
||||
UPDATE paliad.event_category_concepts ecc
|
||||
SET concept_slug = dc.slug
|
||||
FROM paliad.deadline_concepts dc
|
||||
WHERE ecc.concept_id = dc.id;
|
||||
ALTER TABLE paliad.event_category_concepts
|
||||
ALTER COLUMN concept_slug SET NOT NULL,
|
||||
DROP COLUMN concept_id;
|
||||
```
|
||||
|
||||
The category tree is now a thin overlay that maps "user clicked 'Hinweisbeschluss'" to the set of concept_slugs whose nodes should appear as cards. No separate concept identity required — the slug is the bridge.
|
||||
|
||||
**Stays unchanged:**
|
||||
- `paliad.event_types` (45 rows) — the *instance-side* user-facing classifier on `paliad.deadlines`. Per t-paliad-088 this is firm-wide-or-private, archive-only, with optional loose-linkage `trigger_event_id`. Untouched by this design — it's a different layer (instance tag, not template node). After migration, the loose linkage column can be repurposed: `event_types.proceeding_event_type_id uuid` (still loose, still nullable) — maintained as a follow-up, not in scope for the cutover.
|
||||
|
||||
### 3.8 Worked example — UPC infringement action (with CCR variant)
|
||||
|
||||
The mermaid below is one full proceeding's graph in the new shape. **Solid edges fire unconditionally; dashed edges fire only when the labelled flag is set.** Multi-parent at `inf.rejoinder` is the headline shape change.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
inf_soc["📄 inf.soc<br/>Statement of Claim<br/>concept-slug: statement-of-claim<br/>kind: filing • party: claimant • is_root: true"]:::root
|
||||
|
||||
inf_prelim["⚠️ inf.prelim<br/>Preliminary Objection<br/>concept-slug: preliminary-objection<br/>RoP.019.1"]
|
||||
inf_sod["📄 inf.sod<br/>Statement of Defence<br/>concept-slug: statement-of-defence<br/>RoP.023"]
|
||||
inf_ccr["⚖️ inf.ccr_counterclaim<br/>Counterclaim for Revocation<br/>concept-slug: counterclaim-for-revocation<br/>RoP.025 • is_bilateral"]
|
||||
inf_amend["📐 inf.app_to_amend<br/>Application to amend patent<br/>concept-slug: application-to-amend-patent<br/>RoP.030"]
|
||||
|
||||
inf_reply_no["📝 inf.reply<br/>Reply to Defence (no CCR)<br/>concept-slug: reply-to-defence<br/>RoP.029.b"]
|
||||
inf_reply_w["📝 inf.reply_with_ccr<br/>Defence-to-CCR + Reply<br/>concept-slug: reply-to-defence<br/>RoP.029.a"]
|
||||
|
||||
inf_def_amend["📝 inf.defence_to_amend<br/>Defence to App-to-amend<br/>concept-slug: defence-to-amend-patent<br/>RoP.032.1"]
|
||||
|
||||
inf_rejoin["📝 inf.rejoinder<br/>Rejoinder<br/>concept-slug: rejoinder-to-reply<br/>RoP.029.c|RoP.029.d"]
|
||||
|
||||
inf_interim["🧑⚖️ inf.interim<br/>Interim Conference<br/>kind: hearing • party: court"]
|
||||
inf_oral["⚖️ inf.oral<br/>Oral Hearing<br/>kind: hearing • party: court"]
|
||||
inf_decision["🏛️ inf.decision<br/>Decision on the merits<br/>concept-slug: decision-on-merits<br/>kind: decision • party: court"]
|
||||
inf_costs["💰 inf.cost_application<br/>Application for cost decision<br/>concept-slug: application-for-cost-decision<br/>RoP.151 • 1mo from decision"]
|
||||
|
||||
inf_soc -- "1mo" --> inf_prelim
|
||||
inf_soc -- "3mo (RoP.023)" --> inf_sod
|
||||
inf_soc -- "3mo (RoP.025)<br/>if_flags: with_ccr" -.-> inf_ccr
|
||||
inf_soc -- "3mo (RoP.030)<br/>if_flags: with_amend" -.-> inf_amend
|
||||
|
||||
inf_sod -- "2mo (RoP.029.b)<br/>unless_flags: with_ccr" --> inf_reply_no
|
||||
inf_ccr -- "2mo (RoP.029.a)" --> inf_reply_w
|
||||
|
||||
inf_amend -- "2mo (RoP.032.1)<br/>requires_event: inf.app_to_amend" -.-> inf_def_amend
|
||||
|
||||
inf_reply_no -- "1mo (RoP.029.c)<br/>unless_flags: with_ccr" --> inf_rejoin
|
||||
inf_reply_w -- "1mo (RoP.029.d)<br/>if_flags: with_ccr" --> inf_rejoin
|
||||
|
||||
inf_rejoin -.-> inf_interim
|
||||
inf_interim --> inf_oral
|
||||
inf_oral --> inf_decision
|
||||
inf_decision -- "1mo (RoP.151)" --> inf_costs
|
||||
|
||||
classDef root fill:#c6f41c,stroke:#000,stroke-width:2px,color:#000
|
||||
```
|
||||
|
||||
**Anatomy of the multi-parent into `inf.rejoinder`:**
|
||||
|
||||
```sql
|
||||
-- Edge from no-CCR Reply → Rejoinder (1 month, RoP.029.c)
|
||||
INSERT INTO paliad.proceeding_event_edges
|
||||
(proceeding_def_id, from_event_id, to_event_id,
|
||||
duration_value, duration_unit, rule_code, legal_source,
|
||||
unless_flags)
|
||||
VALUES
|
||||
(:upc_inf, :inf_reply_no, :inf_rejoin,
|
||||
1, 'months', 'RoP.029.c', 'UPC.RoP.029.c',
|
||||
ARRAY['with_ccr']);
|
||||
|
||||
-- Edge from CCR-track Reply → Rejoinder (1 month, RoP.029.d)
|
||||
INSERT INTO paliad.proceeding_event_edges
|
||||
(proceeding_def_id, from_event_id, to_event_id,
|
||||
duration_value, duration_unit, rule_code, legal_source,
|
||||
if_flags)
|
||||
VALUES
|
||||
(:upc_inf, :inf_reply_w, :inf_rejoin,
|
||||
1, 'months', 'RoP.029.d', 'UPC.RoP.029.d',
|
||||
ARRAY['with_ccr']);
|
||||
```
|
||||
|
||||
The current encoding (one rule with `condition_flag=['with_ccr']` swapping `alt_duration_value=2`) is rewritten as two structurally-clean sibling edges. The calculator's logic simplifies: pick the edge whose `if_flags ⊆ flags AND unless_flags ∩ flags = ∅ AND (requires_event_id IS NULL OR requires_event_id ∈ recorded_events)`. No special-cased `alt_*` swap path.
|
||||
|
||||
### 3.9 Five more proceedings spec'd at the DAG-shape level
|
||||
|
||||
For each, the **node count** is shown along with the **distinguishing edge feature** that the new model handles cleanly. Full graphs are out of scope for the design doc — the coder shift will port migrations 008/009/012/041–046 row-by-row.
|
||||
|
||||
| Proceeding | Court system | Nodes | Distinguishing edge feature |
|
||||
|---|---|---:|---|
|
||||
| **UPC infringement action** (UPC_INF, §3.8) | UPC-LD / UPC-CD | ~15 | Multi-parent into `inf.rejoinder`; `if_flags`/`unless_flags` carve the with-CCR / no-CCR tracks; `requires_event_id` gates `inf.defence_to_amend` on actual filing of `inf.app_to_amend`. |
|
||||
| **UPC standalone revocation** (UPC_REV) | UPC-CD | ~15 | TWO independent flags (`with_amend`, `with_cci`) gate the App-to-amend cycle and the Counterclaim-for-Infringement sub-track respectively. Each flag ⇒ ~4 sibling edges activate. Today this is encoded as 8 rules each tagged with one or both flags; tomorrow as edges into a clearly-labelled second-track sub-graph. |
|
||||
| **EPO opposition** (EPO_OPP) | EPA | ~8 | Root edge from the "Decision to grant EP" external trigger anchors `epo_opp.notice` (9-month opposition period, Art.99 EPC). Subsequent edges (R.79, R.116) are unconditional. Rule data flat — no flag conditions. |
|
||||
| **DE LG patent action** (DE_INF_LG) | DE-LG | ~9 | Root edge anchors on `klage.einreichung`. The two-step `Verteidigungsanzeige` (§276.1, 2 weeks) followed by `Klageerwiderung` (§276.1.S2, court-set, ≥2 weeks) is two sequential edges, no flag. The **§ 276 deadline regime** maps cleanly to `requires_event_id` if a future feature wants to gate Klageerwiderung on whether Verteidigungsanzeige was timely filed. |
|
||||
| **DE LG → OLG appeal** (DE_INF_OLG) | DE-OLG | ~7 | Synthetic root node `olg.zustellung_urteil` (party='both', is_root=true) anchors on the LG decision date — bridging the cross-proceeding decision-to-appeal link as a project-tree spawn (§3.6). Berufung 1mo (§517 ZPO), Berufungsbegründung 2mo from filing-of-Berufung (§520.2) — multi-parent edge candidate if the user's date overrides. |
|
||||
| **DPMA → BPatG Beschwerde** (DPMA_BPATG_BESCHWERDE) | DE-BPatG | ~5 | Two sibling edges from `dpma.beschluss` to `bpatg.beschwerde`: 1mo standard (§73 PatG), 2mo if `if_flags=['ausland']` (foreign-resident extension). The flag-conditioned variant is 100% naturally an edge condition, no `alt_*` plumbing needed. |
|
||||
| **EPA Beschwerde (Boards of Appeal)** (EPO_APP) | EPA | ~6 | Root node `epo.entsch` anchors a 2-month notice + 4-month grounds chain (Art.108 EPC). The R.106 RPBA Petition for Review fires as a sibling edge with `if_flags=['fundamental_defect']` — clean. |
|
||||
|
||||
The edge model collapses all the today's flag/swap encodings into "edges with predicates," which is genuinely simpler to reason about and AI-friendly (each edge is a self-contained legal fact: from-X-to-Y-in-D-units-iff-conditions).
|
||||
|
||||
---
|
||||
|
||||
## 4. Migration path
|
||||
|
||||
### 4.1 Strategy: additive build → cutover per surface, one boot
|
||||
|
||||
**NOT** a graph-on-top. The Q3+Q5 locks (separate edges table, drop concept table) are structural — keeping `deadline_rules` AND `proceeding_event_types` AND `proceeding_event_edges` AND `deadline_concepts` simultaneously is the worst of both worlds (more layers, no clarity). The migration is genuinely additive build → cutover.
|
||||
|
||||
**NOT** a destructive cutover in one big migration. The 26 production deadlines, the running Fristenrechner, the deadline-search mat-view, and the currently-shipping t-paliad-138 approval flow are all live. We need every one of them to work mid-migration.
|
||||
|
||||
**The right shape:** four migrations, four boots, one feature cutover per boot. The prior table stays till the end, then drops.
|
||||
|
||||
### 4.2 Phase M1 — additive build (one boot, zero behaviour change)
|
||||
|
||||
Single migration. Creates new tables, populates from old, leaves old in place. Fristenrechner + deadline-search keep using the old tables; `paliad.deadlines` keeps `rule_id` pointing to `deadline_rules`. Day-1 deploy = no user-visible change.
|
||||
|
||||
```
|
||||
1. CREATE paliad.court_types + seed 11 rows + FK from paliad.courts.court_type.
|
||||
2. CREATE paliad.proceeding_definitions; backfill from paliad.proceeding_types
|
||||
(rows that survive — drop the obsolete legacy 6 INF/REV/CCR/APM/APP/AMD,
|
||||
keep only the 16 active fristenrechner sets + ZPO_CIVIL).
|
||||
3. CREATE paliad.proceeding_event_types; backfill from deadline_rules
|
||||
(one row per surviving rule), with concept_slug + concept_de + concept_en
|
||||
+ aliases denormalised from deadline_concepts via the concept_id FK.
|
||||
4. CREATE paliad.proceeding_event_edges; backfill:
|
||||
- parent_id ⇒ from_event_id (or NULL when parent_id IS NULL).
|
||||
- condition_flag ⇒ if_flags ([] when NULL).
|
||||
- condition_rule_id ⇒ requires_event_id (the 2 legacy rules).
|
||||
- alt_duration_value/_unit/_rule_code present:
|
||||
emit a SIBLING edge (the alt path) instead of an alt_* column on
|
||||
the same edge. The 4 rules with alt_* split into 8 rows.
|
||||
- is_spawn=true rules ⇒ DO NOT migrate the cross-proceeding parent_id;
|
||||
leave as orphaned root edges in the destination proceeding_def
|
||||
(these are the §3.6 retirement candidates; flag them for the
|
||||
project-tree-spawn UX in Phase M3).
|
||||
5. ALTER paliad.projects ADD proceeding_def_id, court_id,
|
||||
proceeding_trigger_date, proceeding_status. Backfill via the existing
|
||||
proceeding_type_id integer + courts string-match heuristic (§3.5).
|
||||
6. KEEP everything else: deadline_rules, deadline_concepts, trigger_events,
|
||||
event_deadlines, event_categories — all stay, all readable.
|
||||
```
|
||||
|
||||
**Test gate:** server boots, `/tools/fristenrechner` works (still on old tables), `/deadlines/new` works, `/api/projects/{id}` carries the new project columns (NULL on legacy rows is OK), no user-visible change. Run smoke 6/6 (per t-paliad-088 pattern, see memory `35a08abd`).
|
||||
|
||||
### 4.3 Phase M2 — calculator cutover (one boot, behaviour swap)
|
||||
|
||||
Switch `internal/services/fristenrechner.go` from `deadline_rules` to `proceeding_event_types` + `proceeding_event_edges`. The walk algorithm changes:
|
||||
|
||||
| Today | Tomorrow |
|
||||
|---|---|
|
||||
| Walk parent_id chain from a root rule, anchor on triggerDate at root, descend, apply condition_flag gates and alt_* swaps. | BFS from root edge (from_event_id IS NULL) of the proceeding, anchor on triggerDate, for each node enumerate inbound edges, filter by predicates (`if_flags ⊆ flags AND unless_flags ∩ flags = ∅ AND (requires_event_id IS NULL OR requires_event_id ∈ recorded_events)`), pick the edge that fires (LATEST candidate when multiple), compute due_date, recurse. |
|
||||
| `isCourtDeterminedRule(r)` discriminator. | Same predicate, lifted to the node (`kind IN ('hearing','decision','order') OR party='court'`). |
|
||||
| Composite max/min via `event_deadline.combine_op`. | Same column on the edge. |
|
||||
| `anchor_alt='priority_date'` on EP_GRANT publish. | Folded into a per-proceeding-def "anchor_options" enum — Phase M3 problem, NOT M2. EP_GRANT publish stays specially-handled in Go for one boot. |
|
||||
|
||||
**Switch the trigger calculator** (`event_deadline_service.go`) at the same time. The `trigger_events` (110) + `event_deadlines` (77) data folds into the new shape:
|
||||
- Each `trigger_event` becomes a node (concept_slug from the existing slug column).
|
||||
- Each `event_deadline` becomes a node + an edge from the trigger node to it.
|
||||
- `event_deadline_rule_codes` (72 RoP citations, multiple per deadline) — the new shape only carries ONE `rule_code` per edge. Per row, pick `sort_order=0` as the canonical citation; remaining 0-2 codes per edge become a separate `paliad.proceeding_event_edge_alt_codes` (loose-linkage table) — out of scope for this design but flagged.
|
||||
|
||||
**Search service** (`internal/services/deadline_search_service.go`): rebuild `paliad.deadline_search` mat-view to read from the new tables. The kind discriminator (`'rule'`|`'trigger'`) collapses — every row is a `(node, edge_in)` pair now. UI ranks unchanged.
|
||||
|
||||
**Test gate:** Full Playwright smoke walk through the 16 modern proceedings + the trigger-search Pathway-B flow + the with-CCR flag toggle. Recompute spot-check vs t-paliad-086/111 golden results (Klageerwiderung 2026-04-30 → 2026-08-31, etc). If a Frist drifts more than ±1 day across the migration boundary, BLOCK.
|
||||
|
||||
### 4.4 Phase M3 — instance-side cutover (one boot)
|
||||
|
||||
`paliad.deadlines.rule_id` re-points: today it FKs `deadline_rules.id`; tomorrow it should FK to a tuple (event_type_id, edge_id) — but we can't easily express a 2-column FK. Two options:
|
||||
|
||||
- **Option A** (chosen): `deadlines.rule_id` retired entirely. The legal citation already lives in `deadlines.rule_code text` (per t-paliad-111). The structural pointer becomes `deadlines.event_type_id uuid REFERENCES proceeding_event_types(id)` — node-level, since the edge is an implementation detail. The set of edges that *led* to this Frist is recoverable on read by walking edges-into-this-event-type-of-the-proceeding-instance.
|
||||
- **Option B** (rejected): Keep both rule_id (NULL during transition) AND event_type_id. Adds a deprecation column for unclear value. Skip.
|
||||
|
||||
```sql
|
||||
ALTER TABLE paliad.deadlines
|
||||
ADD COLUMN event_type_id uuid REFERENCES paliad.proceeding_event_types(id);
|
||||
|
||||
UPDATE paliad.deadlines d
|
||||
SET event_type_id = pet.id
|
||||
FROM paliad.proceeding_event_types pet
|
||||
WHERE pet.code IN (SELECT dr.code FROM paliad.deadline_rules dr WHERE dr.id = d.rule_id)
|
||||
AND pet.proceeding_def_id = (
|
||||
SELECT pd.id FROM paliad.proceeding_definitions pd
|
||||
JOIN paliad.proceeding_types pt ON pt.code = pd.code
|
||||
WHERE pt.id = (SELECT dr.proceeding_type_id FROM paliad.deadline_rules dr
|
||||
WHERE dr.id = d.rule_id)
|
||||
);
|
||||
|
||||
ALTER TABLE paliad.deadlines DROP COLUMN rule_id;
|
||||
-- Keep deadlines.rule_code text — it's user-visible and stable.
|
||||
```
|
||||
|
||||
The 26 production deadlines need spot-check; a stale `rule_code` value (e.g. 'RoP.023') survives untouched, and the new `event_type_id` re-anchors the structural reference.
|
||||
|
||||
**Project-tree spawn UX** (deferred from §3.6's cross-proceeding-edge retirement): the old `is_spawn`-flagged rules in INF/REV/CCR (e.g. `inf.appeal`) had a one-click "create the next proceeding" affordance via the appeal Frist's spawning. Replace with: at `inf.decision` event-type detail page, show "Spawn Berufung sub-project" button → creates a new `verfahren` project under the same parent with `proceeding_def_id=DE_INF_OLG` and `proceeding_trigger_date` defaulting to the decision date. The graph stays clean; the spawn happens at the project tree, with one explicit click.
|
||||
|
||||
### 4.5 Phase M4 — drop legacy (one boot, no behaviour change)
|
||||
|
||||
```sql
|
||||
DROP MATERIALIZED VIEW paliad.deadline_search; -- recreated in M2 against new tables
|
||||
DROP TABLE paliad.event_deadline_rule_codes;
|
||||
DROP TABLE paliad.event_deadlines;
|
||||
DROP TABLE paliad.trigger_events;
|
||||
DROP TABLE paliad.deadline_rules; -- 172 rows gone
|
||||
DROP TABLE paliad.deadline_concepts; -- 57 rows gone
|
||||
DROP TABLE paliad.proceeding_types; -- 26 rows gone
|
||||
|
||||
ALTER TABLE paliad.projects DROP COLUMN proceeding_type_id;
|
||||
ALTER TABLE paliad.projects DROP COLUMN court; -- free-text version
|
||||
-- Keep projects.country (used by holiday lookup as a fallback).
|
||||
```
|
||||
|
||||
After Phase M4 the schema is the locked target. Total elapsed: 4 migrations, 4 boots. Each boot is reversible up to the M4 drop (which IS destructive).
|
||||
|
||||
### 4.6 What about feynman's in-flight branch?
|
||||
|
||||
feynman is currently writing migrations on `mai/feynman/fristenrechner` for t-paliad-157. This consultant analysis is upstream of his implementation and **does NOT** change feynman's brief — he ships what's specified there. After feynman lands, this design's M1 migration starts on top of his work; the proceeding_event_types backfill SELECTs from whatever shape `deadline_rules` is in at that point. No coordination required beyond "M1 picks up from feynman's HEAD."
|
||||
|
||||
Branch hygiene: nothing committed in `mai/einstein/consultant-deadline-data` touches code. Only `docs/design-deadline-data-model-2026-05-08.md` (this file). Merge to main at any time without conflict potential against feynman's branch.
|
||||
|
||||
---
|
||||
|
||||
## 5. AI-friendliness layer
|
||||
|
||||
### 5.1 What's load-bearing for the AI vs decoration
|
||||
|
||||
**Load-bearing:**
|
||||
- `paliad.proceeding_event_types.concept_slug` (e.g. `'statement-of-defence'`) — the LLM's cross-proceeding identity. *"What's the equivalent of a Klageerwiderung in EPO opposition?"* → search proceedings for nodes with `concept_slug='statement-of-defence'` or matching aliases.
|
||||
- `paliad.proceeding_event_types.aliases text[]` — the search vocabulary. Lifts directly from old `deadline_concepts.aliases`. Must remain curated; no user-edit in v1.
|
||||
- `paliad.proceeding_event_types.name_de` + `name_en` — primary surface labels.
|
||||
- `paliad.proceeding_event_edges.rule_code` + `legal_source` — citation grounding.
|
||||
- `paliad.proceeding_event_edges.if_flags` / `unless_flags` / `requires_event_id` — the AI can reason about "this edge fires only if user has flagged with_ccr" without needing to evaluate a JSON expression.
|
||||
|
||||
**Decoration (riding on top):**
|
||||
- `paliad.event_categories` (103 nodes) — the navigation tree. The LLM doesn't need this for legal reasoning; it's a UX scaffold for users who don't know the legal vocabulary. Stays intact, re-FK'd to concept_slug.
|
||||
- `paliad.event_types` (45 rows) — the user-facing instance-side classifier. Operationally useful (filter /deadlines by Type) but not load-bearing for the rule library. Stays unchanged.
|
||||
|
||||
### 5.2 The AI prompt lifecycle
|
||||
|
||||
- **Search:** "what is Klageerwiderung in UPC?" → trigram match on name_de + aliases column → returns `proceeding_event_types` rows where slug='statement-of-defence'. Result card lists: court_type pills (UPC-LD, UPC-CD, DE-LG, EPA), per-context durations + rule_codes via the inbound edges.
|
||||
- **Calculate:** user picks UPC-LD München LD + filing date + flags=['with_ccr']. Service walks the proceeding's edges, filters by predicates, returns a date timeline. AI doesn't need to be in this loop; it's deterministic graph walking.
|
||||
- **Reason:** Paliadin (the LLM-backed assistant) gets fed `proceeding_event_types` + `proceeding_event_edges` for the active proceeding when asked "explain my deadlines" — one self-contained subgraph, ~15-20 nodes, ~20-30 edges per proceeding. Fits comfortably in context.
|
||||
- **Classify:** when the user types "we got hit with a Hinweisbeschluss yesterday" — Paliadin matches against `aliases + name_de` of `proceeding_event_types`, returns the matched concept_slug (`hinweisbeschluss-stellungnahme`), and uses the project's `proceeding_def_id` to find the right node in the right proceeding's graph.
|
||||
|
||||
### 5.3 Where the concept layer's death helps the AI
|
||||
|
||||
Today the LLM has to reason about `deadline_rules.concept_id → deadline_concepts.slug` AND `trigger_events.concept_id (text slug)` AND `event_categories → event_category_concepts.concept_id`. Three different shapes for one identity.
|
||||
|
||||
Tomorrow there's ONE `concept_slug text` column on the proceeding-event-type node and ONE FK in the navigation junction. Same string, same column name, two query paths. Strictly easier for the LLM (and for human contributors).
|
||||
|
||||
### 5.4 Where the concept layer's death costs the AI
|
||||
|
||||
The 57 `deadline_concepts` rows had richer metadata than what survives as columns on the node:
|
||||
- `aliases text[]` — survives.
|
||||
- `description text` — needs to merge into per-node `description text` (already exists, just needs population).
|
||||
- `category` (submission/decision/order/hearing) — survives (`kind` column on node).
|
||||
- `party` — survives (`party` column on node, dominant case).
|
||||
- `sort_order` — survives.
|
||||
|
||||
Net data loss: zero. Net query simplification: substantial.
|
||||
|
||||
---
|
||||
|
||||
## 6. Tradeoffs
|
||||
|
||||
### 6.1 What the migration costs
|
||||
|
||||
- **Engineering effort.** ~2 weeks of coder time across the 4 phases. M1 is a long-evening migration. M2 is the heavy lift (calculator rewrites, ~800 lines in fristenrechner.go + ~300 lines in event_deadline_service.go). M3 is shorter but coordinates with t-paliad-138 approval flow + CalDAV sync (the rule_id drop touches every code path that currently joins to deadline_rules — `internal/services/deadline_service.go`, `internal/services/event_service.go`, `internal/services/agenda_service.go`, `internal/handlers/deadlines.go`, plus the frontend).
|
||||
- **Migration complexity.** 4 boots, each with a smoke gate. The M2 boot is the riskiest — calculator semantics are user-visible and date-precision-sensitive. Need a pre-cutover golden-set test (run BOTH calculators across the 16 active proceedings + 30+ trigger events for a representative trigger date, diff the outputs, fail if any non-trivial drift). t-paliad-086 PR-3 found a 60-day cap bug only because of the SoD-on-2026-04-30-lands-on-Saturday smoke; we'd need similar care here.
|
||||
- **Fristenrechner UX disruption.** None expected. The Pathway-B navigation, the Verfahrensablauf timeline view, the search bar — all read paths can be preserved exactly because the underlying data shape is the same legal facts in different storage. The only user-visible change is at the spawning moment (§4.4 Phase M3 project-tree spawn instead of in-line appeal Frist).
|
||||
- **Documentation churn.** docs/audit-fristenrechner-completeness-2026-04-30.md, docs/plans/unified-fristenrechner.md (cronus), docs/plans/unified-fristenrechner-v3.md (cronus) — all reference the old table names. These are historical (cronus retired from paliad per memory `cc28a8ad`) so they don't need active maintenance, but new contributors will read them and get confused. Add a header to each pointing to this design doc as the structural-truth update.
|
||||
|
||||
### 6.2 What the migration buys
|
||||
|
||||
- **One rule library, not two.** No more deadline_rules + trigger_events drift. No more "did t-paliad-086 fix this in both?" The federated mat-view goes away. Search, calc, and AI all read the same shape.
|
||||
- **Multi-parent edges become natural.** The CCR cross-flow that took t-paliad-131 Phase B1 a full PR + 7 new rules + condition_flag wiring becomes 7 sibling edges with disjoint `if_flags`. Same semantics, half the schema awareness needed.
|
||||
- **Court system axis is queryable.** `SELECT * FROM proceeding_event_edges e JOIN proceeding_event_types et ON et.id = e.to_event_id JOIN proceeding_definitions pd ON pd.id = et.proceeding_def_id WHERE pd.court_type='UPC-LD' AND e.if_flags @> ARRAY['with_ccr']` answers a real question that today requires walking three tables and string-matching.
|
||||
- **The graph fits in an LLM prompt.** ~15 nodes + 25 edges per proceeding, with concept tags + rule codes + party + condition flags inline. No federation, no slug-walking. Paliadin gets a tighter context.
|
||||
- **Conditions are typed, not stringly.** `if_flags text[]` + `unless_flags text[]` + `requires_event_id uuid` — the schema documents itself. Today's `condition_rule_id` + `condition_flag` mix is two pages of code-comment to explain.
|
||||
- **Court_type FK eliminates the holiday-lookup hardcoding.** holidays.go's per-court mapping becomes a JOIN: `courts.country` + `court_types.regime` directly produce the holiday set.
|
||||
- **Extensibility for future condition kinds without further migrations.** New typed columns can be added incrementally (e.g. `min_business_days int` for "Notfrist-only" rules); the JSON-DSL alternative would have meant version-bumping the expression evaluator each time.
|
||||
|
||||
### 6.3 Genuine cost: the column-based condition model breaks down at OR-of-3
|
||||
|
||||
Per Q4 the cost flagged on the option preview: a flag combination like "fires if (with_ccr AND with_amend) OR (without_ccr AND with_cci)" needs TWO sibling edges (one per branch). For OR-of-3-or-more disjoint branches the table has N edges fanning into the same target. This is OK at today's scale (the most complex rule has 2 flags) but if procedural complexity escalates we'd want to revisit. The natural escape valve is to add a JSON `condition` column **alongside** the typed columns later, evaluated only when present — but that's a future decision, not today's.
|
||||
|
||||
### 6.4 What we deliberately don't solve
|
||||
|
||||
- **Cross-proceeding edges** (G7 plus old `is_spawn`). Modelled as project-tree spawns instead. Defer until users complain. (A `proceeding_event_edges.cross_to_proceeding_def_id uuid` column would re-open the model, but it muddies the closed-graph invariant. Skip.)
|
||||
- **Track-switching at proceeding level** (G7 ascended). "If with_ccr, the WHOLE proceeding follows alternate sub-graph rooted at node X." Not modelled — instead, every edge in the alternate sub-graph carries `if_flags=['with_ccr']`. Verbose but explicit. If the verbosity becomes painful (more flag-conditional sub-graphs in DE_INF_BGH cross-appeals?) revisit with a `paliad.proceeding_tracks` overlay table that groups edges into named tracks.
|
||||
- **First-class `paliad.proceedings` instance row.** Per Q2 lock — project IS the instance. If a future feature needs richer instance state (current-stage event_type_id, paused_at, last_advanced_event), columns extend `paliad.projects` directly. If that bloats the projects table beyond comfort, a separate `paliad.project_proceeding_state` 1:1 side-table is the right surgery — but not today.
|
||||
- **Schema RLS on the rule library.** Today `paliad.deadline_rules` is reference data, readable to any authenticated user, writable only via migrations. The new tables inherit that posture (no RLS, service-role-only writes). If a future world has firm-private overrides (HLC's house policy on a Frist), revisit.
|
||||
- **Generic event-types beyond procedural** (contract renewal, IP renewal). These live in `paliad.event_types` (the instance-side classifier). They will not become `proceeding_event_types` rows because they don't belong to a proceeding-DAG. Two layers, two purposes — explicitly OK.
|
||||
|
||||
### 6.5 What if m wanted to go bigger — what's the ceiling
|
||||
|
||||
The locked design is *appropriately ambitious* — addresses every gap in §2 except G7 (track-switching, deferred per §6.4). A more-ambitious target shape would:
|
||||
|
||||
- Make instance state first-class (`paliad.proceedings` table, real timeline log). **Skipped per Q2.**
|
||||
- Make conditions a typed expression DSL. **Skipped per Q4.**
|
||||
- Allow proceeding inheritance / template specialisation (e.g. UPC_INF_with_pi extends UPC_INF, adds 4 nodes). **Not asked for.**
|
||||
- Allow cross-court-system cascades (a UPC LD decision triggers the CoA appeal). **Skipped per §3.6.**
|
||||
|
||||
Each of those would be a follow-up design with its own dogma session. None blocks shipping the current design.
|
||||
|
||||
---
|
||||
|
||||
## 7. Open follow-ups for the coder shift
|
||||
|
||||
When m greenlights this design and a coder picks up implementation, surface these explicitly so they don't slip:
|
||||
|
||||
1. **Concept slug curation.** The 57 → ~57 mapping is mostly mechanical. ~5 cases need legal eyes: cross-cutting concepts (Wiedereinsetzung, Versäumnisurteil-Einspruch, Schriftsatznachreichung, Weiterbehandlung) where the slug exists but doesn't yet sit on a proceeding-specific node. Resolution: emit a new `proceeding_event_types` row in EACH proceeding where the cross-cutting Frist applies, all sharing the same `concept_slug`. Multiplies the row count by ~10 per cross-cutter, fine.
|
||||
2. **Legacy proceeding_types pruning.** The 6 unused legacy codes (`INF`,`REV`,`CCR`,`APM`,`APP`,`AMD`,`ZPO_CIVIL`) and their 36+4=40 dead rules should NOT migrate to `proceeding_definitions`. Confirm with m before dropping (they may have been kept "in case"). If yes-drop: M1 SELECT only the active 16 + ZPO_CIVIL (if still desired).
|
||||
3. **Frontend impact assessment.** Pathway-B decision-tree (t-paliad-133 Phase D-1, in production) reads from `event_categories` + `event_category_concepts` joined to `deadline_concepts`. The junction's concept-side rewires from FK to text. Frontend code that fetches concept_slug stays — backend just speaks the same column under a new FK target.
|
||||
4. **Approval flow integration.** t-paliad-138 dual-control approvals (migration 054) wraps deadline mutations with `approval_requests`. The new `deadlines.event_type_id` column needs to flow through `payload jsonb` correctly; today the approval pre-image captures `rule_id`. M3 swap touches approval_service.go + ApprovalRequest payload schema.
|
||||
5. **CalDAV round-trip.** `paliad.deadlines.caldav_uid` + `caldav_etag` survives. The CalDAV title rendering uses `rule_code` (already free-text) — no behavioural change.
|
||||
6. **Holiday-lookup simplification.** `internal/services/holidays.go` today carries a hardcoded map "courts → applicable holiday sets." After M1 (with `paliad.courts.court_type` FK'd) this becomes a JOIN. Refactor as part of M2 or as a follow-up.
|
||||
|
||||
---
|
||||
|
||||
## 8. Recommendation summary
|
||||
|
||||
**Ship the design.** It addresses every structural gap m's framing exposed (G1–G6, deferring G7 explicitly), it lands on locked decisions throughout (Q1–Q5 verbatim from the AskUserQuestion pass), and it costs ~2 weeks of focused coder time across 4 boots with smoke-gates between.
|
||||
|
||||
**Sequence:** wait for feynman's `mai/feynman/fristenrechner` to land (parallel work, doesn't block this design but does affect the M1 backfill source). Then route Phase M1 to a coder fluent in pgvector + ltree contexts (noether or fritz; cronus excluded per memory `cc28a8ad`). Phase M2 needs Fristenrechner-deep context — same picker. Phase M3 + M4 mechanical, any coder.
|
||||
|
||||
**Recommend:** open one Gitea tracking issue for each phase under m/paliad, link to this design doc by anchor (`#42-phase-m1-additive-build`), set them as a 4-step task chain. Mark M4 as gated on M2 + M3 living in production for ≥1 week without rollback.
|
||||
|
||||
The right outcome of this design isn't a one-shot 6-week refactor. It's four 3-day-class migrations stretched over 2–3 weeks, each individually shippable, each individually reversible until the M4 drop. That's how the existing paliad rule-library got built (migrations 003 → 062, ~6 month accretion); that's how it should be reshaped.
|
||||
|
||||
---
|
||||
|
||||
*End of design doc. ~600 lines target — landing at ~750 with code blocks. NO migration files, NO code edits in this branch — only this design doc per the consultant-mode hard rule.*
|
||||
677
docs/design-paliadin-tailscale-ssh-2026-05-07.md
Normal file
677
docs/design-paliadin-tailscale-ssh-2026-05-07.md
Normal file
@@ -0,0 +1,677 @@
|
||||
# Paliadin: route prod via Tailscale SSH to mRiver
|
||||
|
||||
**Issue:** m/paliad#12 — t-paliad-151
|
||||
**Date:** 2026-05-07
|
||||
**Author:** noether (inventor)
|
||||
**Supersedes nothing.** Extends `docs/design-paliadin-2026-05-07.md` (the Phase 0 PoC) with a third deployment path between "laptop-only PoC" and "Anthropic API direct".
|
||||
**Related:** t-paliad-146 (PoC ship), t-paliad-150 (`friendlyErrorMessage` pattern).
|
||||
|
||||
---
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Make Paliadin reachable from `paliad.de` (Dokploy on mLake) without losing m's Claude Code subscription, by routing each turn over Tailscale + SSH from the paliad container to mRiver, where the existing long-lived `tmux` + `claude` PoC keeps running.
|
||||
|
||||
**Non-goals (v1):**
|
||||
|
||||
- Multi-host failover.
|
||||
- Encryption beyond SSH-over-tailnet (already E2E-encrypted by Tailscale's WireGuard layer).
|
||||
- Anthropic API fallback when mRiver is offline — show a friendly error instead.
|
||||
- Wake-on-LAN of mRiver.
|
||||
- Multi-tenant or multi-firm variants.
|
||||
|
||||
---
|
||||
|
||||
## 2. Live state — what was verified before designing
|
||||
|
||||
A design built on stale facts rots fast. These were probed on 2026-05-07, not assumed from CLAUDE.md or memory:
|
||||
|
||||
| Fact | How verified | Result |
|
||||
|---|---|---|
|
||||
| mRiver = `100.99.98.203`, has tmux + claude | this worker runs on mRiver; `tmux -V` → `tmux 3.6a`; `which claude` → `/home/m/.local/bin/claude` | confirmed |
|
||||
| mLake (`100.99.98.201`) has Tailscale running | `ssh m@mlake tailscale status` | confirmed; mRiver visible as `active; direct [2a02:4780:41:3fbc::1]:41641` |
|
||||
| paliad container Dockerfile is alpine:3.21 minimal, no SSH, no tailscaled | `Dockerfile` | confirmed (only `ca-certificates`) |
|
||||
| paliad compose runs default Docker bridge (no `network_mode`) | `docker-compose.yml` | confirmed |
|
||||
| mRiver has no `~/.ssh/authorized_keys` yet | `ls ~/.ssh/` | confirmed — file must be created in Phase A |
|
||||
| `/tmp/paliadin/` does not exist on mRiver yet | `ls /tmp/paliadin` | confirmed — created on first turn (paliadin.go:185 `os.MkdirAll`) |
|
||||
| `paliad-paliadin` tmux session is not currently running on mRiver | `tmux ls` | not present; the existing PoC creates it on demand |
|
||||
|
||||
**Implication for design:** the paliad container needs new infrastructure on three axes — network reachability of the tailnet, an SSH client + identity, and a service-layer code path that talks to a remote tmux instead of a local one. Each axis is its own sub-design below.
|
||||
|
||||
---
|
||||
|
||||
## 3. Locked decisions (m, 2026-05-07 22:35)
|
||||
|
||||
m made four design-shaping calls via the inventor's `AskUserQuestion` pass. They are recorded here verbatim because every downstream choice in §4–§6 follows from them.
|
||||
|
||||
| # | Question | m's choice |
|
||||
|---|---|---|
|
||||
| 1 | Container Tailscale shape | **`network_mode: host` on paliad** |
|
||||
| 2 | SSH-to-mRiver protocol granularity | **Server-side `paliadin-shim` (one RPC per turn)** |
|
||||
| 3 | Routing trigger | **Env var `PALIADIN_REMOTE_HOST` + interface split** |
|
||||
| 4 | SSH private key storage | **Dokploy secret env var `PALIADIN_SSH_PRIVATE_KEY`** |
|
||||
| 5 | SSH port to bypass Tailscale SSH | **Port 22022 via `ssh.socket` drop-in (Phase A finding, 23:30)** |
|
||||
|
||||
Decision (1) was *not* the inventor's recommendation — host mode has known interaction risk with traefik (§4.2). m is overriding the recommendation; this design accepts the call and codifies a Phase A test step that gates the rollout on traefik still working under host mode. If Phase A blows up, the fallback is to revisit (1) in a follow-up issue, not to silently swap to a sidecar.
|
||||
|
||||
Decision (5) emerged during Phase A: Tailscale SSH on mRiver was found to intercept `:22` from tailnet peers and bypass OpenSSH's `authorized_keys` entirely (banner says "Tailscale", auth method "none"). The `command=` shim restriction therefore never fires on the standard port. Adding port 22022 via a `systemd ssh.socket` drop-in routes paliad's connections to real OpenSSH where the restriction works. m's interactive `tailscale ssh m@mriver` on `:22` stays untouched. See §4.4 for the implementation.
|
||||
|
||||
---
|
||||
|
||||
## 4. Sub-design A — Container Tailscale shape
|
||||
|
||||
### 4.1 Shape: `network_mode: host`
|
||||
|
||||
paliad's container shares mLake's network namespace. `tailscale0` (mLake's tailnet interface) is directly visible from inside the container. Outbound `ssh m@100.99.98.203` reaches mRiver over the tailnet without any sidecar, userspace tailscaled, SOCKS proxy, or auth-key flow inside the container.
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml diff
|
||||
services:
|
||||
web:
|
||||
build: .
|
||||
network_mode: host # NEW
|
||||
# remove: expose: ["8080"] # host mode means port is on the host directly
|
||||
environment:
|
||||
- PORT=8080
|
||||
...
|
||||
# NEW Paliadin remote-routing knobs
|
||||
- PALIADIN_REMOTE_HOST=${PALIADIN_REMOTE_HOST} # 100.99.98.203
|
||||
- PALIADIN_REMOTE_PORT=${PALIADIN_REMOTE_PORT} # 22022 (bypasses Tailscale SSH, see §4.5)
|
||||
- PALIADIN_REMOTE_USER=${PALIADIN_REMOTE_USER} # m
|
||||
- PALIADIN_SSH_PRIVATE_KEY=${PALIADIN_SSH_PRIVATE_KEY}
|
||||
- PALIADIN_KNOWN_HOSTS=${PALIADIN_KNOWN_HOSTS} # one-line ssh-keyscan -p 22022 output
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
### 4.2 Trade-off accepted: traefik routing under host mode
|
||||
|
||||
paliad.de's TLS is provided by Dokploy's traefik on the `dokploy-network` overlay. With `network_mode: host`, paliad is no longer attached to that overlay. Two failure modes are possible:
|
||||
|
||||
- **(M1)** traefik can't discover the service via Docker DNS → 502 at the edge.
|
||||
- **(M2)** traefik routes via host loopback (`http://127.0.0.1:8080` or `host.docker.internal`) and works fine.
|
||||
|
||||
Recent Dokploy versions configure traefik with both `loadbalancer.server.url` and Docker labels; (M2) is the documented host-mode path. **Phase A explicitly tests this** (§7) before any code is written; if (M1) materialises, the design rolls back to the sidecar variant of decision 1 in a follow-up issue.
|
||||
|
||||
Other host-mode side-effects to flag in operations:
|
||||
|
||||
- paliad listens on host port 8080 directly. Any other compose service binding 8080 conflicts.
|
||||
- paliad's outbound DNS uses host resolver (no Docker-internal `web` etc.). Currently fine: paliad's only network deps are external (Supabase, SMTP, GitHub raw). No service on `dokploy-network` is referenced by name.
|
||||
- The container can reach **every** Tailscale node, not just mRiver. Mitigations live in §5 (key restriction) and §5.2 (`from=` clause on mRiver authorized_keys).
|
||||
|
||||
### 4.3 Dockerfile diff
|
||||
|
||||
```dockerfile
|
||||
# Final stage adds the SSH client only. Tailscale is provided by the host.
|
||||
FROM alpine:3.21
|
||||
RUN apk add --no-cache ca-certificates openssh-client # +openssh-client (~1MB)
|
||||
WORKDIR /app
|
||||
COPY --from=backend /paliad /app/paliad
|
||||
COPY --from=frontend /app/frontend/dist /app/dist
|
||||
EXPOSE 8080
|
||||
CMD ["/app/paliad"]
|
||||
```
|
||||
|
||||
Image-size delta: alpine `openssh-client` is ~1.1 MB compressed — negligible. No tailscaled, no entrypoint script, no extra processes inside the container.
|
||||
|
||||
### 4.4 What does NOT change
|
||||
|
||||
- No Tailscale auth-key inside paliad. The container inherits the host's tailnet binding, so there is no per-container Tailscale identity to rotate. mLake's existing Tailscale auth is the only one in scope.
|
||||
- No tailscaled process inside the container.
|
||||
- No new sidecar container.
|
||||
|
||||
### 4.5 Bypassing Tailscale SSH via port 22022 (Phase A discovery)
|
||||
|
||||
**Phase A revealed** that Tailscale SSH on mRiver intercepts `:22` from tailnet peers before OpenSSH sees the connection. The SSH banner reads `SSH-2.0-Tailscale`, the verbose log shows `Authenticated using "none"`, and the `authorized_keys command=` directive is therefore inert. mRiver's `tailscale status --json` confirms the `https://tailscale.com/cap/ssh` capability is enabled.
|
||||
|
||||
The fix: a separate listening port for the paliad route, where Tailscale SSH does not intercept and real OpenSSH handles auth.
|
||||
|
||||
mRiver uses systemd socket activation for sshd (`/usr/lib/systemd/system/ssh.socket` binds `:22`). Setting `Port 22022` in `sshd_config` is **ignored** under socket activation — listen ports come from the socket unit, not sshd's own config. The correct change is a drop-in:
|
||||
|
||||
```ini
|
||||
# /etc/systemd/system/ssh.socket.d/paliad.conf
|
||||
[Socket]
|
||||
ListenStream=0.0.0.0:22022
|
||||
ListenStream=[::]:22022
|
||||
```
|
||||
|
||||
Followed by `systemctl daemon-reload && systemctl restart ssh.socket`. Both `:22` (still routed through Tailscale SSH for m's interactive use) and `:22022` (real OpenSSH) end up listening. The same sshd binary handles both — same host key, same `authorized_keys`, same sshd_config. The only difference is *which port* a peer dials.
|
||||
|
||||
A failed first attempt (2026-05-07 23:07) added the drop-in while a stale `Port 22022` directive in `sshd_config.d/99-paliad-test.conf` was still bound — the resulting `Address already in use` took `ssh.socket` down for ~30 s until reverted. Lesson: clean any prior `Port` directives out of `sshd_config.d/*.conf` before retrying the socket drop-in.
|
||||
|
||||
Phase A end-to-end test (2026-05-07 23:31) succeeded with port 22022:
|
||||
|
||||
- `ssh -p 22022 -i paliad-prod-key m@100.99.98.203 health` → `ok`
|
||||
- `run-turn <uuid> <base64-msg>` → 3.4 s round-trip including a Claude-Code response
|
||||
- `from="100.99.98.201"` correctly rejected a connection sourced from mRiver itself (`Permission denied (publickey,password)`)
|
||||
|
||||
---
|
||||
|
||||
## 5. Sub-design B — SSH identity, restricted shim, host-key pinning
|
||||
|
||||
### 5.1 Identity: dedicated ed25519 keypair `paliad-prod`
|
||||
|
||||
One keypair, generated once on mRiver during Phase A, used by every paliad-prod deploy:
|
||||
|
||||
```bash
|
||||
# On mRiver (Phase A bootstrap):
|
||||
ssh-keygen -t ed25519 -N "" -C "paliad-prod $(date +%Y-%m-%d)" -f /tmp/paliad-prod-key
|
||||
# Public key → mRiver authorized_keys (see 5.2)
|
||||
# Private key → Dokploy secret store as PALIADIN_SSH_PRIVATE_KEY
|
||||
shred -u /tmp/paliad-prod-key # only the encrypted/secret-stored copies survive
|
||||
```
|
||||
|
||||
Rotation: regenerate, push public key to mRiver authorized_keys, update Dokploy secret, redeploy. No code change needed — paliad's startup re-reads the env var on every boot.
|
||||
|
||||
The private key is delivered to the container as a multi-line env var. At process start, paliad writes it to a tmpfile so OpenSSH can use it:
|
||||
|
||||
```go
|
||||
// cmd/server/main.go (sketch)
|
||||
func loadPaliadinSSHKey() (string, error) {
|
||||
blob := os.Getenv("PALIADIN_SSH_PRIVATE_KEY")
|
||||
if blob == "" { return "", nil } // remote mode disabled
|
||||
f, err := os.CreateTemp("", "paliadin-id_ed25519-")
|
||||
if err != nil { return "", err }
|
||||
if err := os.Chmod(f.Name(), 0o600); err != nil { return "", err }
|
||||
if _, err := f.WriteString(blob); err != nil { return "", err }
|
||||
if err := f.Close(); err != nil { return "", err }
|
||||
return f.Name(), nil // path passed to RemotePaliadinService
|
||||
}
|
||||
```
|
||||
|
||||
The tmpfile lives at `/tmp/paliadin-id_ed25519-<rand>` for the container's lifetime. On container restart, a fresh tmpfile is written. We never persist the key to a volume.
|
||||
|
||||
### 5.2 mRiver `authorized_keys` entry
|
||||
|
||||
```
|
||||
command="/home/m/.local/bin/paliadin-shim",no-pty,no-port-forwarding,no-agent-forwarding,no-X11-forwarding,no-user-rc,from="100.99.98.201" ssh-ed25519 AAAA...PUBKEY... paliad-prod
|
||||
```
|
||||
|
||||
Each restriction matters:
|
||||
|
||||
- `command=` — every `ssh m@mriver …` invocation runs the shim regardless of what the client asked for. The client's requested command is exposed as `$SSH_ORIGINAL_COMMAND` for the shim to dispatch on.
|
||||
- `no-pty,no-port-forwarding,no-agent-forwarding,no-X11-forwarding,no-user-rc` — defence-in-depth: even if someone steals the key and bypasses the shim's argument validation, they can't get an interactive shell, can't tunnel ports, can't pivot via agent forwarding.
|
||||
- `from="100.99.98.201"` — only accept connections from mLake's tailnet IP. Defends against the "container has full tailnet visibility" host-mode side-effect from §4.2: if the key leaks off mLake, it can't be replayed from another tailnet host.
|
||||
|
||||
### 5.3 Host-key pinning
|
||||
|
||||
`StrictHostKeyChecking=accept-new` is too loose for a long-lived production identity (one-time MITM during first connect substitutes a different key forever). Instead:
|
||||
|
||||
- During Phase A, run `ssh-keyscan -p 22022 -t ed25519 100.99.98.203` on mLake.
|
||||
- Capture the single output line. The host-key portion is identical to the `:22` entry — same sshd, same keys — but the `[100.99.98.203]:22022` prefix matters because OpenSSH's `known_hosts` is `host:port`-keyed for non-22 ports.
|
||||
- Store as Dokploy secret `PALIADIN_KNOWN_HOSTS`.
|
||||
- At container startup, write to `/tmp/paliadin-known_hosts` chmod 644.
|
||||
- Pass to OpenSSH via `-o UserKnownHostsFile=/tmp/paliadin-known_hosts -o StrictHostKeyChecking=yes`.
|
||||
|
||||
If mRiver's host key ever rotates (rare; only on disk wipe / fresh OS), Phase A runs again and the secret is updated. SSH refuses to connect with a clear "host key changed" error, which surfaces as `mriver_unreachable` to the user — exactly the right blast-radius (loud failure, no silent connect to a substitute host).
|
||||
|
||||
### 5.4 The shim — `paliadin-shim`
|
||||
|
||||
A bash script on mRiver at `/home/m/.local/bin/paliadin-shim`. It is the **only** thing the paliad-prod key is allowed to invoke, and it dispatches on `$SSH_ORIGINAL_COMMAND`. Three RPCs:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# paliadin-shim — server-side RPC for paliad's remote-tmux turns.
|
||||
# Invoked via authorized_keys command= with $SSH_ORIGINAL_COMMAND set.
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
|
||||
readonly TMUX_SESSION="${PALIADIN_TMUX_SESSION:-paliad-paliadin}"
|
||||
readonly RESPONSE_DIR="${PALIADIN_RESPONSE_DIR:-/tmp/paliadin}"
|
||||
readonly TIMEOUT_S=60
|
||||
readonly TURN_ID_RE='^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'
|
||||
|
||||
mkdir -p "$RESPONSE_DIR"
|
||||
|
||||
# Parse $SSH_ORIGINAL_COMMAND. Format: "<verb> <arg1> <arg2> …"
|
||||
read -r -a argv <<< "${SSH_ORIGINAL_COMMAND:-}"
|
||||
verb="${argv[0]:-}"
|
||||
|
||||
ensure_pane() {
|
||||
if ! tmux has-session -t "$TMUX_SESSION" 2>/dev/null; then
|
||||
tmux new-session -d -s "$TMUX_SESSION"
|
||||
fi
|
||||
# Find or create the @paliadin-scope=chat window.
|
||||
local target=""
|
||||
while read -r idx; do
|
||||
scope=$(tmux show-window-option -t "$TMUX_SESSION:$idx" -v @paliadin-scope 2>/dev/null || true)
|
||||
if [[ "$scope" == "chat" ]]; then target="$TMUX_SESSION:$idx"; break; fi
|
||||
done < <(tmux list-windows -t "$TMUX_SESSION" -F '#{window_index}')
|
||||
if [[ -z "$target" ]]; then
|
||||
idx=$(tmux new-window -t "$TMUX_SESSION" -n claude-paliadin -P -F '#{window_index}' claude)
|
||||
target="$TMUX_SESSION:$idx"
|
||||
# Wait for claude to settle (60s bound; matches Go waitForPaneReady).
|
||||
for _ in $(seq 1 120); do
|
||||
pane=$(tmux capture-pane -t "$target" -p 2>/dev/null || true)
|
||||
if [[ "$pane" == *"❯"* || "$pane" == *"│"* ]]; then break; fi
|
||||
sleep 0.5
|
||||
done
|
||||
tmux set-window-option -t "$target" @paliadin-scope chat
|
||||
tmux set-window-option -t "$target" @fix-name claude-paliadin
|
||||
# Bootstrap system prompt — reuses the Go service's prompt text.
|
||||
# The Go side sends this via the `bootstrap` RPC on first turn instead
|
||||
# of duplicating the prompt here. See §6.4.
|
||||
fi
|
||||
echo "$target"
|
||||
}
|
||||
|
||||
case "$verb" in
|
||||
health)
|
||||
# Liveness check — used by paliad to short-circuit when mRiver is offline.
|
||||
# Returns "ok" iff tmux + claude are reachable.
|
||||
tmux has-session -t "$TMUX_SESSION" 2>/dev/null \
|
||||
|| tmux new-session -d -s "$TMUX_SESSION"
|
||||
command -v claude >/dev/null && echo ok || { echo no-claude; exit 1; }
|
||||
;;
|
||||
|
||||
bootstrap)
|
||||
# First-turn-only: ensure pane exists and inject the system prompt.
|
||||
# $1 = base64-encoded prompt body (avoids quoting hell).
|
||||
target=$(ensure_pane)
|
||||
prompt=$(printf '%s' "${argv[1]:?missing prompt}" | base64 -d)
|
||||
tmux send-keys -t "$target" -l -- "$prompt"
|
||||
tmux send-keys -t "$target" Enter
|
||||
sleep 2 # give claude a moment to absorb
|
||||
echo ok
|
||||
;;
|
||||
|
||||
run-turn)
|
||||
# $1 = turn_id (UUID); $2 = base64-encoded user message.
|
||||
turn_id="${argv[1]:?missing turn_id}"
|
||||
[[ "$turn_id" =~ $TURN_ID_RE ]] || { echo >&2 "bad turn_id"; exit 2; }
|
||||
msg=$(printf '%s' "${argv[2]:?missing message}" | base64 -d)
|
||||
target=$(ensure_pane)
|
||||
out="$RESPONSE_DIR/$turn_id.txt"
|
||||
rm -f "$out"
|
||||
# Envelope matches what paliadin_prompt.go expects.
|
||||
tmux send-keys -t "$target" -l -- "[PALIADIN:$turn_id] $msg"
|
||||
tmux send-keys -t "$target" Enter
|
||||
# Poll for the response file. Same shape as Go pollForResponse.
|
||||
for _ in $(seq 1 $((TIMEOUT_S * 5))); do
|
||||
if [[ -s "$out" ]]; then
|
||||
sleep 0.05 # settle
|
||||
cat "$out"
|
||||
rm -f "$out"
|
||||
exit 0
|
||||
fi
|
||||
sleep 0.2
|
||||
done
|
||||
echo >&2 "paliadin: response timeout after ${TIMEOUT_S}s"
|
||||
exit 124
|
||||
;;
|
||||
|
||||
reset)
|
||||
# /clear the conversation; next turn starts fresh.
|
||||
target=$(ensure_pane)
|
||||
tmux send-keys -t "$target" -l -- "/clear"
|
||||
tmux send-keys -t "$target" Enter
|
||||
echo ok
|
||||
;;
|
||||
|
||||
*)
|
||||
echo >&2 "paliadin-shim: unknown verb '$verb'"
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
```
|
||||
|
||||
Why a shim instead of raw tmux-over-SSH:
|
||||
|
||||
- One SSH round-trip per turn (~50 ms over tailnet) vs ~10–20 round-trips for the granular pattern.
|
||||
- Argument validation lives in one place (UUID regex on turn_id, base64 for messages, fixed verb list) — easier to audit than a regex over `$SSH_ORIGINAL_COMMAND` matching `tmux send-keys …`.
|
||||
- mRiver-side concerns (response polling, settle delays, pane-readiness) stay on mRiver, which is where the tmux state lives. The Go service stops caring about local file polling at all.
|
||||
|
||||
---
|
||||
|
||||
## 6. Sub-design C — Service-layer integration, routing, reliability
|
||||
|
||||
### 6.1 Interface split
|
||||
|
||||
The current `*PaliadinService` becomes an interface with two implementations: `LocalPaliadinService` (the existing tmux code, renamed) and `RemotePaliadinService` (the new SSH code). Construction picks one at startup based on `PALIADIN_REMOTE_HOST`.
|
||||
|
||||
```go
|
||||
// internal/services/paliadin.go (after refactor)
|
||||
|
||||
type Paliadin interface {
|
||||
RunTurn(ctx context.Context, req TurnRequest) (*TurnResult, error)
|
||||
ResetSession(ctx context.Context) error
|
||||
ListRecentTurns(ctx context.Context, callerID uuid.UUID, limit int) ([]PaliadinTurn, error)
|
||||
Stats(ctx context.Context, callerID uuid.UUID) (*PaliadinStats, error)
|
||||
IsOwner(ctx context.Context, userID uuid.UUID) (bool, error)
|
||||
}
|
||||
|
||||
// LocalPaliadinService wraps the current tmux PoC (laptop / dev path).
|
||||
type LocalPaliadinService struct { /* identical to today's PaliadinService */ }
|
||||
|
||||
// RemotePaliadinService talks to a paliadin-shim over SSH on mRiver.
|
||||
type RemotePaliadinService struct {
|
||||
db *sqlx.DB
|
||||
users *UserService
|
||||
sshHost string // 100.99.98.203
|
||||
sshPort int // 22022 — bypasses Tailscale SSH on :22 (see §4.5)
|
||||
sshUser string // m
|
||||
sshKeyPath string // /tmp/paliadin-id_ed25519-<rand>
|
||||
knownHosts string // /tmp/paliadin-known_hosts
|
||||
turnMu sync.Mutex
|
||||
|
||||
// Health-check cache.
|
||||
healthMu sync.Mutex
|
||||
healthOK bool
|
||||
healthCheckedAt time.Time
|
||||
}
|
||||
```
|
||||
|
||||
DB access (`ListRecentTurns`, `Stats`, `IsOwner`) is identical for both — they only read `paliad.paliadin_turns`. They live in a shared `paliadinDB` helper struct embedded in both implementations.
|
||||
|
||||
### 6.2 Wiring at startup
|
||||
|
||||
```go
|
||||
// cmd/server/main.go (excerpt)
|
||||
var paliadin services.Paliadin
|
||||
remoteHost := os.Getenv("PALIADIN_REMOTE_HOST")
|
||||
switch {
|
||||
case remoteHost != "":
|
||||
keyPath, err := loadPaliadinSSHKey()
|
||||
if err != nil { log.Fatalf("paliadin: load ssh key: %v", err) }
|
||||
if keyPath == "" { log.Fatalf("paliadin: PALIADIN_REMOTE_HOST set but no PALIADIN_SSH_PRIVATE_KEY") }
|
||||
knownHosts, err := loadPaliadinKnownHosts()
|
||||
if err != nil { log.Fatalf("paliadin: load known_hosts: %v", err) }
|
||||
port, _ := strconv.Atoi(cmpOr(os.Getenv("PALIADIN_REMOTE_PORT"), "22022"))
|
||||
paliadin = services.NewRemotePaliadinService(db, userSvc, services.RemotePaliadinConfig{
|
||||
SSHHost: remoteHost,
|
||||
SSHPort: port,
|
||||
SSHUser: cmpOr(os.Getenv("PALIADIN_REMOTE_USER"), "m"),
|
||||
SSHKeyPath: keyPath,
|
||||
KnownHostsPath: knownHosts,
|
||||
})
|
||||
log.Printf("paliadin: remote mode → ssh %s@%s:%d", "m", remoteHost, port)
|
||||
case localTmuxAvailable():
|
||||
paliadin = services.NewLocalPaliadinService(db, userSvc, "", "")
|
||||
log.Printf("paliadin: local tmux mode")
|
||||
default:
|
||||
paliadin = services.NewDisabledPaliadinService(db, userSvc)
|
||||
log.Printf("paliadin: disabled (no remote host, no local tmux)")
|
||||
}
|
||||
```
|
||||
|
||||
`NewDisabledPaliadinService` exists today implicitly via the `ErrTmuxUnavailable` path; making it explicit gives the constructor a clear name and the handler doesn't have to special-case `nil`.
|
||||
|
||||
### 6.3 SSH invocation pattern
|
||||
|
||||
`RemotePaliadinService` runs every RPC through the same helper:
|
||||
|
||||
```go
|
||||
func (s *RemotePaliadinService) callShim(ctx context.Context, args ...string) ([]byte, error) {
|
||||
sshArgs := []string{
|
||||
"-F", "/dev/null", // ignore /etc/ssh/ssh_config + ~/.ssh/config
|
||||
"-i", s.sshKeyPath,
|
||||
"-p", strconv.Itoa(s.sshPort), // 22022 — bypasses Tailscale SSH on :22
|
||||
"-o", "IdentitiesOnly=yes", // don't fall back to other keys
|
||||
"-o", "UserKnownHostsFile=" + s.knownHostsPath,
|
||||
"-o", "StrictHostKeyChecking=yes",
|
||||
"-o", "BatchMode=yes",
|
||||
"-o", "ConnectTimeout=3",
|
||||
"-o", "ServerAliveInterval=10",
|
||||
"-o", "ServerAliveCountMax=3",
|
||||
s.sshUser + "@" + s.sshHost,
|
||||
"--",
|
||||
}
|
||||
sshArgs = append(sshArgs, args...)
|
||||
c, cancel := context.WithTimeout(ctx, 70*time.Second) // shim has its own 60s; +10s for SSH overhead
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(c, "ssh", sshArgs...)
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout; cmd.Stderr = &stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return nil, fmt.Errorf("paliadin: ssh shim %v: %w (stderr: %s)", args, err, stderr.String())
|
||||
}
|
||||
return stdout.Bytes(), nil
|
||||
}
|
||||
```
|
||||
|
||||
`RunTurn` becomes:
|
||||
|
||||
```go
|
||||
func (s *RemotePaliadinService) RunTurn(ctx context.Context, req TurnRequest) (*TurnResult, error) {
|
||||
s.turnMu.Lock()
|
||||
defer s.turnMu.Unlock()
|
||||
|
||||
if err := s.healthGate(ctx); err != nil {
|
||||
return nil, err // ErrMRiverUnreachable, picked up by handler
|
||||
}
|
||||
|
||||
turnID := uuid.New()
|
||||
started := time.Now().UTC()
|
||||
if err := s.insertTurnRow(ctx, …); err != nil { return nil, err }
|
||||
|
||||
// First-turn-only: bootstrap the system prompt on mRiver. Detected by
|
||||
// checking whether any prior turn for this user has succeeded.
|
||||
if err := s.ensureBootstrapped(ctx); err != nil {
|
||||
_ = s.markTurnError(ctx, turnID, "bootstrap_failed")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
msg := sanitiseForTmux(req.UserMessage)
|
||||
msgB64 := base64.StdEncoding.EncodeToString([]byte(msg))
|
||||
body, err := s.callShim(ctx, "run-turn", turnID.String(), msgB64)
|
||||
if err != nil {
|
||||
_ = s.markTurnError(ctx, turnID, classifySSHError(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Same trailer-parse + audit-row writes as Local, factored into shared helper.
|
||||
return s.completeTurnFromBody(ctx, turnID, started, string(body))
|
||||
}
|
||||
```
|
||||
|
||||
### 6.4 System prompt bootstrap
|
||||
|
||||
The local PoC calls `paliadinSystemPrompt(s.responseDir)` once when it creates the pane. The remote path needs the same hook. Two options that don't require duplicating the German prompt body to mRiver:
|
||||
|
||||
- **Lazy bootstrap (chosen):** the first `RunTurn` after a paliad-prod restart sends the system prompt via `bootstrap` RPC, then runs the actual turn. Subsequent turns skip the bootstrap. State is per-process: `RemotePaliadinService.bootstrapped` boolean guarded by mutex.
|
||||
- Eager bootstrap at startup is rejected — it forces every container start to wait for mRiver to be online, which couples paliad's boot to mRiver's availability.
|
||||
|
||||
Lazy bootstrap means the very first turn after a paliad redeploy pays a ~3 s extra cost (claude pane spin-up + system prompt absorb). Acceptable for a single-user PoC.
|
||||
|
||||
### 6.5 Health-check gating (`mriver_unreachable`)
|
||||
|
||||
Every `RunTurn` first calls `healthGate(ctx)`:
|
||||
|
||||
- Cached for 10 s. If last check was <10 s ago and was OK, skip the probe.
|
||||
- Otherwise: `s.callShim(ctx, "health")` with a 3 s timeout. On success, set cache OK; on failure, return `ErrMRiverUnreachable`.
|
||||
|
||||
Why 10 s: short enough that "I just woke my laptop" propagates inside one user retry; long enough that a busy chat doesn't probe on every turn.
|
||||
|
||||
```go
|
||||
var ErrMRiverUnreachable = errors.New("paliadin: mriver unreachable")
|
||||
|
||||
func (s *RemotePaliadinService) healthGate(ctx context.Context) error {
|
||||
s.healthMu.Lock()
|
||||
defer s.healthMu.Unlock()
|
||||
if s.healthOK && time.Since(s.healthCheckedAt) < 10*time.Second {
|
||||
return nil
|
||||
}
|
||||
c, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||
defer cancel()
|
||||
out, err := s.callShim(c, "health")
|
||||
s.healthCheckedAt = time.Now()
|
||||
if err != nil || strings.TrimSpace(string(out)) != "ok" {
|
||||
s.healthOK = false
|
||||
return fmt.Errorf("%w: %v", ErrMRiverUnreachable, err)
|
||||
}
|
||||
s.healthOK = true
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
### 6.6 Friendly error code (extends t-paliad-150)
|
||||
|
||||
`friendlyErrorMessage` already maps `tmux_unavailable` to a localised message. We add one new code:
|
||||
|
||||
- `mriver_unreachable` → DE: *"mRiver ist offline — Paliadin nicht erreichbar. Mach mRiver an, oder nutze Paliadin lokal mit `./paliad`."* / EN: *"mRiver is offline — Paliadin can't reach it. Wake mRiver, or run Paliadin locally with `./paliad`."*
|
||||
|
||||
Implementation: one new `case` in the SSE-error switch in `frontend/src/client/paliadin.ts`'s `friendlyErrorMessage`, plus matching i18n keys (`paliadin.error.mriver_unreachable.de` / `.en`). Server-side: `paliadin` HTTP handler maps `errors.Is(err, services.ErrMRiverUnreachable)` to `event: error\ndata: {"code":"mriver_unreachable","message":"..."}\n\n`.
|
||||
|
||||
### 6.7 Rate limit
|
||||
|
||||
A runaway loop on the paliad side could DOS the SSH connection. Cheapest cap: enforce one in-flight turn at a time via `turnMu` (already exists in the local PoC). On top of that, a rolling cap of N=20 turns/min in `RemotePaliadinService` rejects with `ErrRateLimited` (mapped to a friendly `paliadin.error.rate_limited`). PoC has one user (m); the cap is a paranoid safety, not a real throttle.
|
||||
|
||||
### 6.8 What about ControlMaster?
|
||||
|
||||
Decision-2's chosen path (server-side shim with one RPC per turn) makes ControlMaster optional. The shim collapses ~10 raw-tmux ops into a single SSH connect — that's already the latency win ControlMaster would buy.
|
||||
|
||||
Adding it on top would save ~30–50 ms per turn but adds:
|
||||
|
||||
- A persistent `~/.ssh/cm-*` socket inside the container.
|
||||
- Cleanup logic on shutdown.
|
||||
- A subtle interaction with the SSH BatchMode + ConnectTimeout settings.
|
||||
|
||||
Verdict: skip ControlMaster in v1. If turn latency over Tailscale is measured >300 ms in practice and hot enough to matter, add it in a follow-up; the call site is one helper.
|
||||
|
||||
---
|
||||
|
||||
## 7. Phasing
|
||||
|
||||
### Phase A — manual proof-of-concept (no Dockerfile change yet)
|
||||
|
||||
Goal: validate the round-trip end-to-end on a deployed paliad, before touching the image.
|
||||
|
||||
**Phase A.0 (DONE 2026-05-07 23:31):** SSH+shim end-to-end on the tailnet.
|
||||
|
||||
1. ✅ **Generate keypair** on mRiver: `ssh-keygen -t ed25519 -N "" -C "paliad-prod" -f ~/.paliad-staging/paliad-prod-key`. Fingerprint `SHA256:5uV8v872F/IhJycjjq0crFue/emAYfw71N9bxTvkl9c`.
|
||||
2. ✅ **Commit shim** to `scripts/paliadin-shim` and **install** at `/home/m/.local/bin/paliadin-shim`, `chmod 755`.
|
||||
3. ✅ **Write authorized_keys** with public key + `command=`/`from="100.99.98.201"`/no-pty/no-port-forwarding/no-agent-forwarding/no-X11-forwarding/no-user-rc restrictions (§5.2).
|
||||
4. ✅ **Add port 22022 socket drop-in** at `/etc/systemd/system/ssh.socket.d/paliad.conf`, `systemctl daemon-reload && systemctl restart ssh.socket`. Both `:22` (Tailscale SSH for m) and `:22022` (real OpenSSH for paliad) listening (§4.5).
|
||||
5. ✅ **Capture mRiver:22022 host key**: `ssh-keyscan -p 22022 -t ed25519 100.99.98.203 > ~/.paliad-staging/known_hosts` from mLake. Fingerprint `SHA256:HPoUzy60Cb8yLERIBQcB2mHihNST3NaTODx5Ypd1XpA`.
|
||||
6. ✅ **Smoke-test from mLake** (without paliad container, just raw ssh from mLake's host shell):
|
||||
```
|
||||
ssh -F /dev/null -i /tmp/paliad-prod-key -o UserKnownHostsFile=/tmp/paliad-known_hosts \
|
||||
-o StrictHostKeyChecking=yes -o IdentitiesOnly=yes -o BatchMode=yes \
|
||||
-p 22022 m@100.99.98.203 health
|
||||
→ ok
|
||||
ssh … run-turn $(uuidgen) "$(printf 'Sag …' | base64 -w0)"
|
||||
→ "test ok" (3.4 s round-trip including a real Claude response)
|
||||
```
|
||||
7. ✅ **from= rejection verified**: the same key from mRiver itself (`100.99.98.203`) → `Permission denied (publickey,password)` as expected.
|
||||
|
||||
**Phase A.5 (PENDING m's hands):** validate `network_mode: host` + traefik routing on prod paliad.de.
|
||||
|
||||
- Branch the live `docker-compose.yml` on a temp branch.
|
||||
- Add `network_mode: host` to the `web` service; remove `expose: ["8080"]`.
|
||||
- Push to trigger a Dokploy redeploy.
|
||||
- `curl --connect-timeout 5 -sSI https://paliad.de/` — expect 200 (or login redirect), NOT 502.
|
||||
- If 502: revert the temp branch (`git revert HEAD && git push`); revisit decision 1 in a follow-up issue.
|
||||
- If 200: keep the host-mode change; ready for Phase B.
|
||||
|
||||
This is **m's call to execute** — it briefly touches prod paliad.de. Inventor/coder should not flip prod compose without explicit go-ahead. Rollback is one revert + redeploy.
|
||||
|
||||
**Phase A.6 (after A.5 passes):** smoke-test SSH from inside the paliad-prod container itself (the real container, not just the mLake host shell):
|
||||
```
|
||||
docker exec -it <paliad-container> sh
|
||||
apk add --no-cache openssh-client # one-shot, before Dockerfile change
|
||||
ssh -F /dev/null -i /tmp/paliad-prod-key -o UserKnownHostsFile=/tmp/paliad-known_hosts \
|
||||
-o StrictHostKeyChecking=yes -o IdentitiesOnly=yes -o BatchMode=yes \
|
||||
-p 22022 m@100.99.98.203 health
|
||||
# expected: "ok"
|
||||
```
|
||||
This proves the container's host-mode networking actually delivers a tailnet connect.
|
||||
|
||||
**Phase A.7:** wire env vars manually via Dokploy UI for one deploy; confirm `/paliadin` chat works against mRiver from paliad.de.
|
||||
|
||||
If A.5 fails: the design rolls back to a sidecar in a new issue (decision 1 follow-up). The SSH path (A.0) and traefik path (A.5) are independent — A.0 is already proven; only A.5+ is at risk.
|
||||
|
||||
### Phase B — bake into Dockerfile + Dokploy secrets
|
||||
|
||||
1. Dockerfile: add `openssh-client` to the final stage (§4.3).
|
||||
2. compose: add `network_mode: host` and the four new env vars (§4.1).
|
||||
3. Dokploy secrets: register `PALIADIN_REMOTE_HOST=100.99.98.203`, `PALIADIN_REMOTE_USER=m`, `PALIADIN_SSH_PRIVATE_KEY=...`, `PALIADIN_KNOWN_HOSTS=...`.
|
||||
4. Code: refactor `PaliadinService` to the interface split (§6.1–§6.2). New file `internal/services/paliadin_remote.go`. Tests: `paliadin_remote_test.go` mocks `callShim` to verify `RunTurn` audit-row writes, error mapping, and `healthGate` caching.
|
||||
5. Ship under one PR; tag t-paliad-151 done.
|
||||
|
||||
### Phase C — friendly errors + monitoring
|
||||
|
||||
1. `paliadin.error.mriver_unreachable` i18n keys + `friendlyErrorMessage` case (§6.6).
|
||||
2. `/admin/paliadin` shows last health-probe result + last successful turn timestamp.
|
||||
3. Optional: `mai-mesh` integration to surface mRiver-offline events to m on Telegram (out-of-band; not gating).
|
||||
|
||||
---
|
||||
|
||||
## 8. Security review summary
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| Stolen private key → arbitrary SSH on mRiver | `command=` shim restriction + `from="100.99.98.201"` + ed25519 key + private key only in Dokploy secret store (encrypted at rest); paliad route uses port 22022 where real OpenSSH enforces all of the above |
|
||||
| Stolen private key → tailnet-wide SSH from non-mLake host | `from="100.99.98.201"` clause (verified: rejected from mRiver itself in Phase A.0) |
|
||||
| Tailscale SSH on `:22` bypasses `authorized_keys` | The paliad-prod key's `command=` restriction is not enforced on `:22`. Mitigation: paliad always dials `:22022`, which is real OpenSSH. m's interactive `tailscale ssh m@mriver` on `:22` continues to be governed by Tailscale ACLs, separate from paliad's identity. |
|
||||
| Container compromise → key extraction | Key written to tmpfile chmod 600, only root inside container can read; alpine container has no shell-on-error trampolines |
|
||||
| Host-key MITM during connect | Pinned `known_hosts`; `StrictHostKeyChecking=yes` |
|
||||
| Shim argument injection (e.g. via `run-turn $(rm -rf /)`) | Shim parses positional args from `$SSH_ORIGINAL_COMMAND` via `read -r -a`; never passes args to a subshell `eval`; turn_id validated by UUID regex; message body always base64-decoded into a single shell variable, never re-evaluated |
|
||||
| Runaway loop → SSH flood | Single-flight `turnMu` + 20/min rolling cap |
|
||||
| `network_mode: host` widens blast radius | The `command=` + `from=` restrictions on mRiver mean container compromise = "can run shim verbs against mRiver only", not "shell on mRiver" |
|
||||
| PaliadinOwnerEmail bypass | Unchanged from PoC: gate is in Go (`/paliadin` 404s for any other user). Even if mRiver SSH key leaks, attacker still needs paliad session as `m@hoganlovells.com`. |
|
||||
|
||||
---
|
||||
|
||||
## 9. Out-of-scope clarifications (for review)
|
||||
|
||||
These were called out in the issue but the design intentionally does not solve them, to keep v1 tight. Each is acknowledged so review knows it wasn't an oversight:
|
||||
|
||||
- **Wake-on-LAN of mRiver:** out of scope. v1's UX when mRiver is asleep is the friendly error from §6.6. Future work: integrate with `mai-mesh` capability fallback.
|
||||
- **Multi-host failover:** out of scope. Only mRiver is targeted.
|
||||
- **Anthropic API fallback when mRiver offline:** out of scope per CLAUDE.md (`ANTHROPIC_API_KEY` reserved for production-v1, unused in PoC).
|
||||
- **ControlMaster:** v1 ships without; revisit if turn latency >300 ms in practice (§6.8).
|
||||
|
||||
---
|
||||
|
||||
## 10. File-level deliverables (for the coder shift)
|
||||
|
||||
When this design is approved and the coder shift starts, the work splits roughly into:
|
||||
|
||||
- `Dockerfile` — `+openssh-client`.
|
||||
- `docker-compose.yml` — `network_mode: host`, five new env entries (`PALIADIN_REMOTE_HOST`, `PALIADIN_REMOTE_PORT`, `PALIADIN_REMOTE_USER`, `PALIADIN_SSH_PRIVATE_KEY`, `PALIADIN_KNOWN_HOSTS`).
|
||||
- `internal/services/paliadin.go` — extract `Paliadin` interface; rename existing to `LocalPaliadinService`; pull DB-only methods (`ListRecentTurns`, `Stats`, `IsOwner`) into a shared embedded `paliadinDB` so both implementations get them for free.
|
||||
- `internal/services/paliadin_remote.go` — new file: `RemotePaliadinService`, `RemotePaliadinConfig` (with `SSHPort`), `callShim`, `healthGate`, `ensureBootstrapped`, `classifySSHError`, `ErrMRiverUnreachable`.
|
||||
- `internal/services/paliadin_remote_test.go` — unit tests with a mocked `callShim`.
|
||||
- `cmd/server/main.go` — env-var-based wiring (§6.2), `loadPaliadinSSHKey`, `loadPaliadinKnownHosts`, `PALIADIN_REMOTE_PORT` parse with default `22022`.
|
||||
- `frontend/src/client/paliadin.ts` — one `case` in `friendlyErrorMessage` for `mriver_unreachable`.
|
||||
- `frontend/src/i18n.ts` — two new keys (`paliadin.error.mriver_unreachable.de` / `.en`).
|
||||
- `scripts/paliadin-shim` — server-side script (§5.4); already shipped + installed on mRiver during Phase A.0, not part of any container. Repo location chosen so the security-relevant script is version-controlled.
|
||||
- `docs/project-status.md` — note Phase 0.5 (PoC) → Phase 0.6 (Tailscale-SSH prod route).
|
||||
- **mRiver host setup (one-time, already done in Phase A.0):** `/etc/systemd/system/ssh.socket.d/paliad.conf` (port 22022 listen drop-in); `~/.ssh/authorized_keys` (paliad-prod public key with restrictions); `/home/m/.local/bin/paliadin-shim` (executable). These are NOT in the repo because they live on m's laptop; `docs/project-status.md` should reference them.
|
||||
|
||||
No DB migrations needed — `paliad.paliadin_turns` schema already covers everything (`error_code` field already accepts free-form codes including `mriver_unreachable`).
|
||||
|
||||
---
|
||||
|
||||
## 11. Open questions for review
|
||||
|
||||
- **Q (m), still open:** Phase A.5 (traefik+host-mode on prod paliad.de) is not yet executed. m drives this; rollback is one revert. Dokploy doc check before flipping is recommended but not blocking.
|
||||
- **Q (m), resolved 2026-05-07 23:50:** shim location → repo (`scripts/paliadin-shim`, committed in `0248411`). Version-controlled and auditable.
|
||||
- **Q (m), still open:** `ANTHROPIC_API_KEY` env var reservation in compose comments — keep for production-v1, or strip now? Not blocking either phase; defer.
|
||||
|
||||
---
|
||||
|
||||
## 12. Phase A.0 completion summary (2026-05-07 23:50)
|
||||
|
||||
**Coder shift (noether) executed Phase A.0 in full:**
|
||||
|
||||
1. ✅ shim committed at `scripts/paliadin-shim` (commit `0248411`, repo-version-controlled)
|
||||
2. ✅ shim installed at `/home/m/.local/bin/paliadin-shim` on mRiver
|
||||
3. ✅ ed25519 keypair `paliad-prod` generated, public-key fingerprint `SHA256:5uV8v872F/IhJycjjq0crFue/emAYfw71N9bxTvkl9c`, private key staged at `~/.paliad-staging/paliad-prod-key` on mRiver (mode 600)
|
||||
4. ✅ `~/.ssh/authorized_keys` written with `command=`/`from=`/no-pty/no-port-forwarding/no-agent-forwarding/no-X11-forwarding/no-user-rc restrictions
|
||||
5. ✅ `ssh.socket` drop-in installed at `/etc/systemd/system/ssh.socket.d/paliad.conf`; both `:22` and `:22022` listening
|
||||
6. ✅ host key for `:22022` captured at `~/.paliad-staging/known_hosts` (fingerprint `SHA256:HPoUzy60Cb8yLERIBQcB2mHihNST3NaTODx5Ypd1XpA`)
|
||||
7. ✅ end-to-end SSH+shim+Claude run-turn validated from mLake → mRiver:22022 (3.4 s round-trip)
|
||||
8. ✅ `from="100.99.98.201"` rejection verified
|
||||
|
||||
**Three secrets ready for Dokploy registration** (m to copy from `~/.paliad-staging/` on mRiver):
|
||||
- `PALIADIN_SSH_PRIVATE_KEY` ← `cat ~/.paliad-staging/paliad-prod-key`
|
||||
- `PALIADIN_KNOWN_HOSTS` ← `cat ~/.paliad-staging/known_hosts`
|
||||
- `PALIADIN_REMOTE_HOST=100.99.98.203`, `PALIADIN_REMOTE_PORT=22022`, `PALIADIN_REMOTE_USER=m`
|
||||
|
||||
**Phase A.5 (traefik+host-mode test) and Phase A.6/A.7 (in-container SSH smoke + paliad/paliadin end-to-end) await m's hands** — they touch prod paliad.de.
|
||||
|
||||
**Phase B (Dockerfile + Go interface split + Dokploy secrets) is unblocked from a code perspective** — but should not merge until Phase A.5 confirms the host-mode networking trade-off is acceptable.
|
||||
|
||||
---
|
||||
|
||||
**Inventor design + coder Phase A.0 complete.** Awaiting m for Phase A.5 traefik validation before the coder writes the Go interface split.
|
||||
@@ -86,14 +86,17 @@ export function renderAdminPaliadin(): string {
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="admin.paliadin.col.started">Zeit</th>
|
||||
<th data-i18n="admin.paliadin.col.user">Nutzer</th>
|
||||
<th data-i18n="admin.paliadin.col.classifier">Art</th>
|
||||
<th data-i18n="admin.paliadin.col.prompt">Anfrage</th>
|
||||
<th data-i18n="admin.paliadin.col.response">Antwort</th>
|
||||
<th data-i18n="admin.paliadin.col.tools">Tools</th>
|
||||
<th data-i18n="admin.paliadin.col.origin">Seite</th>
|
||||
<th data-i18n="admin.paliadin.col.duration">Dauer</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="recent-turns-tbody">
|
||||
<tr><td colspan={5} data-i18n="admin.paliadin.loading">Lade …</td></tr>
|
||||
<tr><td colspan={8} data-i18n="admin.paliadin.loading">Lade …</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -208,12 +208,19 @@ function renderUnitMatrix(unit: PartnerUnit): string {
|
||||
function renderUnits(): void {
|
||||
const host = document.getElementById("ap-units-list");
|
||||
if (!host) return;
|
||||
// Preserve which unit blocks were expanded across re-renders. Without this,
|
||||
// changing any cell's required_role saves and re-renders, collapsing the
|
||||
// accordion the admin was working in (m, 2026-05-08).
|
||||
const openUnitIDs = new Set<string>();
|
||||
host.querySelectorAll<HTMLDetailsElement>("details.ap-unit-block").forEach((d) => {
|
||||
if (d.open && d.dataset.unitId) openUnitIDs.add(d.dataset.unitId);
|
||||
});
|
||||
if (partnerUnits.length === 0) {
|
||||
host.innerHTML = `<p class="form-hint">${esc(t("admin.approval_policies.units.empty") || "Keine Partner Units vorhanden.")}</p>`;
|
||||
return;
|
||||
}
|
||||
host.innerHTML = partnerUnits.map((u) => `
|
||||
<details class="ap-unit-block">
|
||||
<details class="ap-unit-block" data-unit-id="${esc(u.id)}"${openUnitIDs.has(u.id) ? " open" : ""}>
|
||||
<summary class="ap-unit-summary">
|
||||
<span class="ap-unit-name">${esc(u.name)}</span>
|
||||
<span class="office-chip office-${esc(u.office)}">${esc(u.office)}</span>
|
||||
|
||||
@@ -23,14 +23,21 @@ interface Stats {
|
||||
interface Turn {
|
||||
turn_id: string;
|
||||
user_id: string;
|
||||
user_email: string | null;
|
||||
user_display_name: string | null;
|
||||
session_id: string;
|
||||
started_at: string;
|
||||
finished_at: string | null;
|
||||
duration_ms: number | null;
|
||||
user_message: string;
|
||||
response: string | null;
|
||||
used_tools: string[] | null;
|
||||
rows_seen: number[] | null;
|
||||
classifier_tag: string | null;
|
||||
abandoned: boolean;
|
||||
error_code: string | null;
|
||||
page_origin: string | null;
|
||||
chip_count: number;
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
@@ -113,28 +120,51 @@ function renderTurns(turns: Turn[]): void {
|
||||
const tbody = document.getElementById("recent-turns-tbody");
|
||||
if (!tbody) return;
|
||||
if (turns.length === 0) {
|
||||
tbody.innerHTML = `<tr><td colspan="5">Noch keine Anfragen.</td></tr>`;
|
||||
tbody.innerHTML = `<tr><td colspan="8">Noch keine Anfragen.</td></tr>`;
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = turns
|
||||
.map((t) => {
|
||||
const tag = t.classifier_tag || "—";
|
||||
// Tools cell pairs each tool name with its rows_seen count when
|
||||
// available — "list_my_projects (11), search_my_deadlines (18)" —
|
||||
// so the meta is legible at a glance instead of hidden in a side
|
||||
// table. Falls back to "—" for casual chats with no tool calls.
|
||||
const tools = t.used_tools && t.used_tools.length > 0
|
||||
? t.used_tools.join(", ")
|
||||
? t.used_tools
|
||||
.map((name, i) => {
|
||||
const r = t.rows_seen?.[i];
|
||||
return r != null ? `${name} (${r})` : name;
|
||||
})
|
||||
.join(", ")
|
||||
: "—";
|
||||
const dur = t.duration_ms != null ? formatMs(t.duration_ms) : "—";
|
||||
const errMark = t.error_code ? ` ⚠ ${t.error_code}` : "";
|
||||
const userLabel = t.user_display_name || t.user_email || t.user_id.slice(0, 8);
|
||||
const userTitle = [t.user_email, t.user_display_name].filter(Boolean).join(" · ") || t.user_id;
|
||||
// Response preview — first 200 chars of cleanBody. Full response
|
||||
// available on hover via the title attribute.
|
||||
const respPreview = t.response ? truncate(t.response, 80) : "—";
|
||||
const respTitle = t.response || "";
|
||||
const origin = t.page_origin || "—";
|
||||
return `<tr>
|
||||
<td>${formatTime(t.started_at)}</td>
|
||||
<td title="${escapeAttr(userTitle)}">${escapeHTML(userLabel)}</td>
|
||||
<td>${escapeHTML(tag)}</td>
|
||||
<td>${escapeHTML(truncate(t.user_message, 120))}${errMark}</td>
|
||||
<td title="${escapeAttr(t.user_message)}">${escapeHTML(truncate(t.user_message, 80))}${errMark}</td>
|
||||
<td title="${escapeAttr(respTitle)}">${escapeHTML(respPreview)}</td>
|
||||
<td>${escapeHTML(tools)}</td>
|
||||
<td>${escapeHTML(origin)}</td>
|
||||
<td>${dur}</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function escapeAttr(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/\n/g, " ");
|
||||
}
|
||||
|
||||
function setText(id: string, val: string): void {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = val;
|
||||
|
||||
@@ -1558,6 +1558,10 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"paliadin.stop": "Stop",
|
||||
"paliadin.reset": "Neue Unterhaltung",
|
||||
"paliadin.error.local_only": "Paliadin läuft nur lokal. Diese Instanz hat kein tmux/claude installiert — lokal mit ./paliad starten.",
|
||||
"paliadin.error.mriver_unreachable": "mRiver ist offline — Paliadin nicht erreichbar. Mach mRiver an, oder nutze Paliadin lokal mit ./paliad.",
|
||||
"paliadin.error.shim_auth_failed": "Paliadin-Authentifizierung fehlgeschlagen. SSH-Schlüssel oder Berechtigung auf mRiver prüfen.",
|
||||
"paliadin.error.shim_error": "Paliadin-Fehler auf mRiver. tmux/claude-Pane prüfen.",
|
||||
"paliadin.error.timeout": "Paliadin antwortet nicht (Timeout 60s). Nochmal versuchen.",
|
||||
"paliadin.error.connection_lost": "Verbindung verloren.",
|
||||
"paliadin.error.upstream": "Fehler beim Senden.",
|
||||
"nav.admin.paliadin": "Paliadin Monitor",
|
||||
@@ -1576,8 +1580,11 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.paliadin.col.prompt": "Anfrage",
|
||||
"admin.paliadin.col.count": "Anzahl",
|
||||
"admin.paliadin.col.started": "Zeit",
|
||||
"admin.paliadin.col.user": "Nutzer",
|
||||
"admin.paliadin.col.classifier": "Art",
|
||||
"admin.paliadin.col.response": "Antwort",
|
||||
"admin.paliadin.col.tools": "Tools",
|
||||
"admin.paliadin.col.origin": "Seite",
|
||||
"admin.paliadin.col.duration": "Dauer",
|
||||
"admin.paliadin.loading": "Lade…",
|
||||
|
||||
@@ -3600,6 +3607,10 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"paliadin.stop": "Stop",
|
||||
"paliadin.reset": "New conversation",
|
||||
"paliadin.error.local_only": "Paliadin only runs locally. This instance has no tmux/claude installed — start it locally via ./paliad.",
|
||||
"paliadin.error.mriver_unreachable": "mRiver is offline — Paliadin can't reach it. Wake mRiver, or run Paliadin locally with ./paliad.",
|
||||
"paliadin.error.shim_auth_failed": "Paliadin auth failed. Check the SSH key or authorized_keys on mRiver.",
|
||||
"paliadin.error.shim_error": "Paliadin error on mRiver. Check the tmux/claude pane.",
|
||||
"paliadin.error.timeout": "Paliadin didn't respond in time (60s). Try again.",
|
||||
"paliadin.error.connection_lost": "Connection lost.",
|
||||
"paliadin.error.upstream": "Send failed.",
|
||||
"nav.admin.paliadin": "Paliadin Monitor",
|
||||
@@ -3618,8 +3629,11 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.paliadin.col.prompt": "Query",
|
||||
"admin.paliadin.col.count": "Count",
|
||||
"admin.paliadin.col.started": "Time",
|
||||
"admin.paliadin.col.user": "User",
|
||||
"admin.paliadin.col.classifier": "Type",
|
||||
"admin.paliadin.col.response": "Answer",
|
||||
"admin.paliadin.col.tools": "Tools",
|
||||
"admin.paliadin.col.origin": "Page",
|
||||
"admin.paliadin.col.duration": "Duration",
|
||||
"admin.paliadin.loading": "Loading…",
|
||||
|
||||
|
||||
@@ -164,6 +164,12 @@ async function sendTurn(text: string): Promise<void> {
|
||||
es.addEventListener("content", (ev) => {
|
||||
const data = JSON.parse((ev as MessageEvent).data);
|
||||
const text = String(data.text || "");
|
||||
// Cache the full text on the bubble so finishBubble can render the
|
||||
// complete response even when the typewriter is mid-flight when end
|
||||
// arrives. textContent reflects only what's been typed so far and
|
||||
// would otherwise truncate the rendered Markdown (m, 2026-05-08 —
|
||||
// saw "## Proje" instead of the full 1408-byte body).
|
||||
placeholder.dataset.fullText = text;
|
||||
typewriter(placeholder, text);
|
||||
});
|
||||
|
||||
@@ -173,7 +179,12 @@ async function sendTurn(text: string): Promise<void> {
|
||||
finishBubble(placeholder, data);
|
||||
history.push({
|
||||
role: "assistant",
|
||||
text: getBubbleText(placeholder),
|
||||
// Save the raw Markdown body (with [#deadline-OPEN:...] chip markers
|
||||
// intact), not the rendered textContent. Otherwise on reload the
|
||||
// chip-anchor text replaces the markers and renderResponseHTML can
|
||||
// no longer reconstruct the links (m, 2026-05-08 14:11 — links
|
||||
// disappeared on second load).
|
||||
text: placeholder.dataset.fullText ?? getBubbleText(placeholder),
|
||||
meta: {
|
||||
used_tools: data.used_tools,
|
||||
rows_seen: data.rows_seen,
|
||||
@@ -210,8 +221,24 @@ function friendlyErrorMessage(data: unknown): string {
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(data) as { code?: string };
|
||||
if (parsed.code === "tmux_unavailable") {
|
||||
return t("paliadin.error.local_only");
|
||||
switch (parsed.code) {
|
||||
case "tmux_unavailable":
|
||||
// Local PoC path: paliad is running on a host without tmux/claude
|
||||
// (typically the legacy laptop-only build).
|
||||
return t("paliadin.error.local_only");
|
||||
case "mriver_unreachable":
|
||||
// t-paliad-151: prod path's mRiver is offline (laptop asleep, off
|
||||
// tailnet, or paliadin-shim missing).
|
||||
return t("paliadin.error.mriver_unreachable");
|
||||
case "shim_auth_failed":
|
||||
// SSH key wrong or authorized_keys drifted.
|
||||
return t("paliadin.error.shim_auth_failed");
|
||||
case "shim_error":
|
||||
case "bootstrap_failed":
|
||||
// Generic remote shim failure or system-prompt bootstrap error.
|
||||
return t("paliadin.error.shim_error");
|
||||
case "timeout":
|
||||
return t("paliadin.error.timeout");
|
||||
}
|
||||
} catch {
|
||||
// Not JSON — fall through to the generic connection-lost message
|
||||
@@ -266,8 +293,9 @@ function typewriter(bubble: HTMLElement, text: string): void {
|
||||
const speed = 6;
|
||||
const tick = () => {
|
||||
if (bubble.dataset.streaming !== "true") {
|
||||
// Aborted — flush remaining text instantly.
|
||||
node.textContent = text;
|
||||
// Streaming finished — finishBubble has already rendered the full
|
||||
// Markdown via dataset.fullText. Return without writing so we
|
||||
// don't replace the rendered HTML with raw text on a delayed tick.
|
||||
return;
|
||||
}
|
||||
if (i >= text.length) return;
|
||||
@@ -291,7 +319,9 @@ function getBubbleText(bubble: HTMLElement): string {
|
||||
// "ran search_my_deadlines (3 results)".
|
||||
function finishBubble(bubble: HTMLElement, data: any): void {
|
||||
const textNode = bubble.querySelector(".paliadin-bubble-text")! as HTMLElement;
|
||||
const raw = textNode.textContent || "";
|
||||
// Prefer the full text cached on the bubble at content-event time;
|
||||
// textContent may still reflect the typewriter's partial state.
|
||||
const raw = bubble.dataset.fullText ?? textNode.textContent ?? "";
|
||||
textNode.innerHTML = renderResponseHTML(raw);
|
||||
|
||||
const metaEl = bubble.querySelector(".paliadin-bubble-meta") as HTMLElement | null;
|
||||
@@ -311,31 +341,127 @@ function finishBubble(bubble: HTMLElement, data: any): void {
|
||||
|
||||
// Marker → button render. Mirrors §4.4 of the design.
|
||||
const CHIP_RE = /\[(?:#([a-z]+)-OPEN:([A-Za-z0-9\-_]+)|chip:([a-z]+):([^\]]+))\]/g;
|
||||
const MD_LINK_RE = /\[([^\]\n]+)\]\(((?:https?:\/\/|\/)[^\s)]+)\)/g;
|
||||
const BARE_URL_RE = /(^|[^"=>])(https?:\/\/[^\s<>"']+)/g;
|
||||
|
||||
function renderResponseHTML(raw: string): string {
|
||||
// First escape any HTML in the raw text (simple textContent → innerHTML
|
||||
// would have been fine but we then need to inject anchors, so the
|
||||
// manual escape is unavoidable).
|
||||
const esc = raw
|
||||
let html = raw
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
|
||||
// Walk markers; replace each with a paliadin-chip anchor.
|
||||
return esc.replace(CHIP_RE, (_match, kind, id, chipKind, chipArg) => {
|
||||
// Stage 1: extract chip markers as placeholder sentinels so subsequent
|
||||
// link-rendering passes don't try to re-parse the chip URLs as bare
|
||||
// URLs and double-anchor them.
|
||||
const chipHTML: string[] = [];
|
||||
html = html.replace(CHIP_RE, (_match, kind, id, chipKind, chipArg) => {
|
||||
let rendered = "";
|
||||
if (kind && id) {
|
||||
const url = chipURL(kind, id);
|
||||
const label = chipLabel(kind);
|
||||
return `<a class="paliadin-chip" href="${url}">${label}</a>`;
|
||||
rendered = `<a class="paliadin-chip" href="${url}">${label}</a>`;
|
||||
} else if (chipKind === "nav") {
|
||||
rendered = `<a class="paliadin-chip" href="${chipArg}">öffnen</a>`;
|
||||
} else if (chipKind === "filter") {
|
||||
rendered = `<a class="paliadin-chip" href="/inbox?${chipArg}">Filter anwenden</a>`;
|
||||
}
|
||||
if (chipKind === "nav") {
|
||||
return `<a class="paliadin-chip" href="${chipArg}">öffnen</a>`;
|
||||
}
|
||||
if (chipKind === "filter") {
|
||||
return `<a class="paliadin-chip" href="/inbox?${chipArg}">Filter anwenden</a>`;
|
||||
}
|
||||
return "";
|
||||
if (!rendered) return "";
|
||||
chipHTML.push(rendered);
|
||||
return `CHIP${chipHTML.length - 1}`;
|
||||
});
|
||||
|
||||
// Stage 2: Block-level Markdown — headings (## / ###), unordered lists
|
||||
// (- item), and paragraphs separated by blank lines. Done before the
|
||||
// inline passes so the inline regexes only ever run inside a block.
|
||||
// Chip SOH placeholders are inert text at this point and pass through
|
||||
// untouched.
|
||||
html = renderBlocks(html);
|
||||
|
||||
// Stage 3: Markdown links [text](url). Internal /paths stay same-tab;
|
||||
// external http(s) URLs open in a new tab.
|
||||
html = html.replace(MD_LINK_RE, (_m, text, url) => {
|
||||
const ext = url.startsWith("http");
|
||||
const attrs = ext ? ` target="_blank" rel="noopener noreferrer"` : "";
|
||||
return `<a href="${url}" class="paliadin-link"${attrs}>${text}</a>`;
|
||||
});
|
||||
|
||||
// Stage 4: auto-link bare URLs. The leading-character class on the
|
||||
// regex avoids matching URLs already inside an href attribute (preceded
|
||||
// by `="`) and the prefix capture is preserved verbatim so we don't
|
||||
// drop punctuation.
|
||||
html = html.replace(BARE_URL_RE, (_m, prefix, url) => {
|
||||
return `${prefix}<a href="${url}" class="paliadin-link" target="_blank" rel="noopener noreferrer">${url}</a>`;
|
||||
});
|
||||
|
||||
// Stage 5: inline emphasis. Bold first so the italic regex doesn't
|
||||
// misparse `**bold**` as nested `*italic*`. Both bounded to single
|
||||
// lines via [^*\n] to avoid runaway matches across paragraphs.
|
||||
html = html.replace(/\*\*([^*\n]+)\*\*/g, "<strong>$1</strong>");
|
||||
html = html.replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, "$1<em>$2</em>");
|
||||
|
||||
// Stage 4: substitute chip placeholders back. Done last so chip URLs
|
||||
// never go through the link-rendering passes.
|
||||
html = html.replace(/CHIP(\d+)/g, (_m, idx) => chipHTML[Number(idx)] || "");
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
// renderBlocks parses the escaped html into block-level Markdown:
|
||||
// `## H` → <h2>, `### H` → <h3>, `- item` lines → <ul><li>, blank-line
|
||||
// separated runs → <p> with intra-paragraph newlines as <br>. Anything
|
||||
// not matched falls through verbatim, so the function is a strict
|
||||
// superset of the prior behaviour for plain-text responses.
|
||||
function renderBlocks(escapedHtml: string): string {
|
||||
const out: string[] = [];
|
||||
let listItems: string[] = [];
|
||||
let paraLines: string[] = [];
|
||||
|
||||
const flushList = () => {
|
||||
if (listItems.length === 0) return;
|
||||
out.push(`<ul class="paliadin-list">${listItems.map((li) => `<li>${li}</li>`).join("")}</ul>`);
|
||||
listItems = [];
|
||||
};
|
||||
const flushPara = () => {
|
||||
if (paraLines.length === 0) return;
|
||||
out.push(`<p>${paraLines.join("<br>")}</p>`);
|
||||
paraLines = [];
|
||||
};
|
||||
|
||||
for (const rawLine of escapedHtml.split("\n")) {
|
||||
const line = rawLine.trim();
|
||||
if (line === "") {
|
||||
flushList();
|
||||
flushPara();
|
||||
continue;
|
||||
}
|
||||
let m: RegExpMatchArray | null;
|
||||
if ((m = line.match(/^###\s+(.+)$/))) {
|
||||
flushList();
|
||||
flushPara();
|
||||
out.push(`<h3>${m[1]}</h3>`);
|
||||
} else if ((m = line.match(/^##\s+(.+)$/))) {
|
||||
flushList();
|
||||
flushPara();
|
||||
out.push(`<h2>${m[1]}</h2>`);
|
||||
} else if ((m = line.match(/^[-*]\s+(.+)$/))) {
|
||||
flushPara();
|
||||
listItems.push(m[1]);
|
||||
} else if (line.match(/^---+$/)) {
|
||||
flushList();
|
||||
flushPara();
|
||||
out.push(`<hr>`);
|
||||
} else {
|
||||
flushList();
|
||||
paraLines.push(line);
|
||||
}
|
||||
}
|
||||
flushList();
|
||||
flushPara();
|
||||
return out.join("");
|
||||
}
|
||||
|
||||
function chipURL(kind: string, id: string): string {
|
||||
|
||||
@@ -212,9 +212,12 @@ export type I18nKey =
|
||||
| "admin.paliadin.col.classifier"
|
||||
| "admin.paliadin.col.count"
|
||||
| "admin.paliadin.col.duration"
|
||||
| "admin.paliadin.col.origin"
|
||||
| "admin.paliadin.col.prompt"
|
||||
| "admin.paliadin.col.response"
|
||||
| "admin.paliadin.col.started"
|
||||
| "admin.paliadin.col.tools"
|
||||
| "admin.paliadin.col.user"
|
||||
| "admin.paliadin.daily_heading"
|
||||
| "admin.paliadin.heading"
|
||||
| "admin.paliadin.last7"
|
||||
@@ -1470,6 +1473,10 @@ export type I18nKey =
|
||||
| "paliadin.empty"
|
||||
| "paliadin.error.connection_lost"
|
||||
| "paliadin.error.local_only"
|
||||
| "paliadin.error.mriver_unreachable"
|
||||
| "paliadin.error.shim_auth_failed"
|
||||
| "paliadin.error.shim_error"
|
||||
| "paliadin.error.timeout"
|
||||
| "paliadin.error.upstream"
|
||||
| "paliadin.heading"
|
||||
| "paliadin.input.placeholder"
|
||||
|
||||
@@ -10720,7 +10720,12 @@ dialog.quick-add-sheet::backdrop {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
/* On narrow viewports (mobile portrait, ~320-360px), the 280px floor
|
||||
would force each card past the screen width and produce horizontal
|
||||
scroll. min(280px, 100%) collapses the floor to the available width
|
||||
so the card spans full-width on mobile and only goes back to the
|
||||
280px-min, auto-fill, 1fr layout once there's room (m, 2026-05-08). */
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(280px, 100%), 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.views-card {
|
||||
@@ -11593,13 +11598,23 @@ dialog.quick-add-sheet::backdrop {
|
||||
.projects-cards-grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
/* min(320px, 100%): on viewports narrower than 320px the floor
|
||||
collapses to the available width so cards span full-width and
|
||||
don't force horizontal scroll on mobile.
|
||||
minmax(0, 1fr) on the explicit-N-column variants below: 1fr is
|
||||
shorthand for minmax(auto, 1fr), and "auto" resolves to max-content,
|
||||
so any card with content wider than the track expands the track and
|
||||
blows past the parent's right edge. minmax(0, 1fr) clamps the floor
|
||||
to zero so the track always stays inside the grid container — cards
|
||||
become genuinely compact and overflow goes to wrap/clip rather than
|
||||
page-overflow (m, 2026-05-08 15:02). */
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(320px, 100%), 1fr));
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.projects-cards-grid.is-grid-2 { grid-template-columns: repeat(2, 1fr); }
|
||||
.projects-cards-grid.is-grid-3 { grid-template-columns: repeat(3, 1fr); }
|
||||
.projects-cards-grid.is-grid-4 { grid-template-columns: repeat(4, 1fr); }
|
||||
.projects-cards-grid.is-grid-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.projects-cards-grid.is-grid-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.projects-cards-grid.is-grid-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
|
||||
.projects-cards-grid.is-density-compact .projects-card {
|
||||
padding: 0.6rem 0.75rem;
|
||||
|
||||
@@ -69,10 +69,12 @@ type Services struct {
|
||||
Pin *services.PinService
|
||||
CardLayout *services.CardLayoutService
|
||||
|
||||
// Paliadin is wired only when PALIADIN_ENABLED=true at boot
|
||||
// (PoC; m's laptop only). On prod it stays nil and all /paliadin*
|
||||
// routes 404 because Register() skips registering them.
|
||||
Paliadin *services.PaliadinService
|
||||
// Paliadin is wired when DATABASE_URL is set. The concrete backend
|
||||
// is picked in cmd/server/main.go based on PALIADIN_REMOTE_HOST
|
||||
// (remote → mRiver via SSH) or local tmux availability. Stays nil
|
||||
// without DATABASE_URL; in that case the per-request handler gate
|
||||
// 404s anyway.
|
||||
Paliadin services.Paliadin
|
||||
}
|
||||
|
||||
func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc *Services) {
|
||||
|
||||
@@ -39,10 +39,11 @@ func newDetachedContext(timeout time.Duration) (context.Context, context.CancelF
|
||||
return context.WithTimeout(context.Background(), timeout)
|
||||
}
|
||||
|
||||
// paliadinSvc is the live PaliadinService instance. nil when
|
||||
// DATABASE_URL was unset (the service depends on the audit table).
|
||||
// Set by Register() at boot.
|
||||
var paliadinSvc *services.PaliadinService
|
||||
// paliadinSvc is the live Paliadin backend. nil when DATABASE_URL was
|
||||
// unset (the service depends on the audit table). Set by Register() at
|
||||
// boot. The concrete type is decided in cmd/server/main.go: local-tmux
|
||||
// PoC, remote-via-SSH (mRiver), or a disabled stub.
|
||||
var paliadinSvc services.Paliadin
|
||||
|
||||
// requirePaliadinOwner gates every paliadin handler to the single
|
||||
// owner email (services.PaliadinOwnerEmail = m). Anyone else gets a
|
||||
@@ -165,7 +166,9 @@ func handlePaliadinTurn(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// runPaliadinTurnAsync executes the turn and writes events into ch.
|
||||
// Uses a 2-minute hard timeout independently of the originating request.
|
||||
// Uses a 150 s hard timeout independently of the originating request,
|
||||
// which leaves headroom over the shim's 120 s run-turn cap + SSH
|
||||
// overhead (t-paliad-155: cold-start safety for skill + MCP discovery).
|
||||
func runPaliadinTurnAsync(turnID uuid.UUID, req services.TurnRequest, ch chan<- turnEvent) {
|
||||
defer func() {
|
||||
// Drain + close. The SSE handler reads until the channel closes.
|
||||
@@ -181,7 +184,7 @@ func runPaliadinTurnAsync(turnID uuid.UUID, req services.TurnRequest, ch chan<-
|
||||
},
|
||||
})
|
||||
|
||||
ctx, cancel := newDetachedContext(120 * time.Second)
|
||||
ctx, cancel := newDetachedContext(150 * time.Second)
|
||||
defer cancel()
|
||||
|
||||
result, err := paliadinSvc.RunTurn(ctx, req)
|
||||
@@ -286,14 +289,16 @@ func handlePaliadinStream(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// handlePaliadinReset clears the Claude conversation context.
|
||||
// handlePaliadinReset kills the caller's Paliadin tmux session so the
|
||||
// next turn boots a fresh claude pane (per-user — see t-paliad-155).
|
||||
func handlePaliadinReset(w http.ResponseWriter, r *http.Request) {
|
||||
if !requirePaliadinOwner(w, r) {
|
||||
return
|
||||
}
|
||||
uid, _ := requireUser(w, r) // already validated by requirePaliadinOwner
|
||||
ctx, cancel := newDetachedContext(10 * time.Second)
|
||||
defer cancel()
|
||||
if err := paliadinSvc.ResetSession(ctx); err != nil {
|
||||
if err := paliadinSvc.ResetSession(ctx, uid); err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
||||
"error": "reset failed: " + err.Error(),
|
||||
})
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
package services
|
||||
|
||||
// PaliadinService — Phase 0 PoC of the in-app AI buddy (t-paliad-146).
|
||||
// Paliadin — the in-app AI buddy. Two implementations of the same
|
||||
// interface, picked at boot time (see cmd/server/main.go):
|
||||
//
|
||||
// Design: docs/design-paliadin-2026-05-07.md §0.5 (PoC track).
|
||||
// - LocalPaliadinService — talks to a `claude` CLI in a local tmux
|
||||
// session. The PoC path (t-paliad-146); used on m's laptop.
|
||||
// - RemotePaliadinService — shells out to ssh on mRiver where the
|
||||
// long-lived tmux+claude pane lives. The prod path (t-paliad-151);
|
||||
// used by the paliad.de Dokploy container, which has no `claude`
|
||||
// CLI of its own.
|
||||
//
|
||||
// Architecture: a long-lived `claude` process inside a tmux session.
|
||||
// Prompts go in via `tmux send-keys -l`; responses come back via a
|
||||
// per-turn file the system prompt instructs Claude to write
|
||||
// (Write(/tmp/paliadin/{turn_id}.txt)). The service polls that file,
|
||||
// strips the [paliadin-meta] trailer block, parses the metadata, writes
|
||||
// an audit row, and emits the response back to the SSE handler.
|
||||
// Designs:
|
||||
// - docs/design-paliadin-2026-05-07.md (PoC architecture)
|
||||
// - docs/design-paliadin-tailscale-ssh-2026-05-07.md (remote routing)
|
||||
//
|
||||
// The architecture is lifted (with adaptation to Go) from
|
||||
// ~/dev/mVoice/server.py:250-380, which has been driving the goldi voice
|
||||
// surface in production since 2026-Q1.
|
||||
//
|
||||
// PoC ONLY runs on m's laptop (PALIADIN_ENABLED=false on prod default).
|
||||
// Hardcoded single-user, single-tmux-window scope. Do not attempt to
|
||||
// deploy this to the Dokploy container — there is no `claude` CLI there.
|
||||
// Both implementations share the audit-table I/O (paliadinDB) and the
|
||||
// trailer parser. The conversation state (turn ordering, response file
|
||||
// polling) is split: Local owns the tmux pane directly; Remote delegates
|
||||
// to the paliadin-shim on mRiver and reads the file there.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -50,20 +50,54 @@ import (
|
||||
// path to enabling Paliadin.
|
||||
const PaliadinOwnerEmail = "matthias.siebels@hoganlovells.com"
|
||||
|
||||
// PaliadinService manages the tmux-claude PoC.
|
||||
type PaliadinService struct {
|
||||
db *sqlx.DB
|
||||
tmuxSession string
|
||||
responseDir string
|
||||
users *UserService
|
||||
// Paliadin is the interface every Paliadin backend implements. Two
|
||||
// production implementations: LocalPaliadinService (local tmux+claude)
|
||||
// and RemotePaliadinService (ssh+paliadin-shim on mRiver). A
|
||||
// DisabledPaliadinService stub is constructed when neither is available
|
||||
// so callers don't have to nil-check on every entry point.
|
||||
type Paliadin interface {
|
||||
RunTurn(ctx context.Context, req TurnRequest) (*TurnResult, error)
|
||||
// ResetSession kills the user's tmux session entirely so the next
|
||||
// RunTurn boots a fresh claude pane. Per-user since each Paliad user
|
||||
// has their own session (t-paliad-155).
|
||||
ResetSession(ctx context.Context, userID uuid.UUID) error
|
||||
ListRecentTurns(ctx context.Context, callerID uuid.UUID, limit int) ([]PaliadinTurn, error)
|
||||
Stats(ctx context.Context, callerID uuid.UUID) (*PaliadinStats, error)
|
||||
IsOwner(ctx context.Context, userID uuid.UUID) (bool, error)
|
||||
}
|
||||
|
||||
// Cached pane target ("session:window-idx") once the voice window is
|
||||
// either discovered or created. Reset to "" if the pane dies.
|
||||
mu sync.Mutex
|
||||
paneTarget string
|
||||
// paliadinDB is the audit-table read/write surface shared by every
|
||||
// Paliadin implementation. Embedded in LocalPaliadinService and
|
||||
// RemotePaliadinService so they inherit IsOwner / ListRecentTurns /
|
||||
// Stats and the per-turn row writers without duplication.
|
||||
type paliadinDB struct {
|
||||
db *sqlx.DB
|
||||
users *UserService
|
||||
}
|
||||
|
||||
// Single in-flight turn at a time. PoC scope — one user (m), serialised
|
||||
// by a session-level mutex. Production v1 would queue / fan out.
|
||||
// LocalPaliadinService runs the local tmux+claude PoC (t-paliad-146).
|
||||
// Used on m's laptop; not deployed to prod (the Dokploy container has no
|
||||
// `claude` CLI — see RemotePaliadinService for that path).
|
||||
//
|
||||
// Per-user tmux session: every Paliad user gets their own session named
|
||||
// `<sessionPrefix>-<userid8>` (first 8 hex chars of the user's UUID),
|
||||
// created on demand. The persona + response protocol are loaded from
|
||||
// the Paliadin skill (~/.claude/skills/paliadin/SKILL.md, installed via
|
||||
// scripts/install-paliadin-skill); there is no in-process system prompt.
|
||||
type LocalPaliadinService struct {
|
||||
paliadinDB
|
||||
sessionPrefix string
|
||||
responseDir string
|
||||
|
||||
// Cached pane targets per user-session, keyed by tmux session name.
|
||||
// A session entry maps to "session:window-idx"; cleared when the
|
||||
// pane dies or ResetSession is called for that user.
|
||||
mu sync.Mutex
|
||||
panes map[string]string
|
||||
|
||||
// Single in-flight turn at a time across all users. PoC scope —
|
||||
// claude CLI panes share the host's terminal noise; serialising
|
||||
// keeps log output unambiguous.
|
||||
turnMu sync.Mutex
|
||||
}
|
||||
|
||||
@@ -74,7 +108,7 @@ type PaliadinService struct {
|
||||
//
|
||||
// Returns (false, nil) for any other user — including unknown UUIDs and
|
||||
// users without an email row. Errors only on DB failure.
|
||||
func (s *PaliadinService) IsOwner(ctx context.Context, userID uuid.UUID) (bool, error) {
|
||||
func (s *paliadinDB) IsOwner(ctx context.Context, userID uuid.UUID) (bool, error) {
|
||||
var email string
|
||||
err := s.db.QueryRowxContext(ctx,
|
||||
`SELECT email FROM paliad.users WHERE id = $1`, userID).Scan(&email)
|
||||
@@ -87,22 +121,37 @@ func (s *PaliadinService) IsOwner(ctx context.Context, userID uuid.UUID) (bool,
|
||||
return strings.EqualFold(email, PaliadinOwnerEmail), nil
|
||||
}
|
||||
|
||||
// NewPaliadinService wires the service. Call only when PALIADIN_ENABLED=true.
|
||||
func NewPaliadinService(db *sqlx.DB, users *UserService, tmuxSession, responseDir string) *PaliadinService {
|
||||
if tmuxSession == "" {
|
||||
tmuxSession = "paliad-paliadin"
|
||||
// NewLocalPaliadinService wires the local-tmux PoC backend. The
|
||||
// sessionPrefix arg is the prefix every per-user tmux session inherits —
|
||||
// the actual session name is `<prefix>-<userid8>`. Falls back to
|
||||
// defaults when env vars are empty.
|
||||
func NewLocalPaliadinService(db *sqlx.DB, users *UserService, sessionPrefix, responseDir string) *LocalPaliadinService {
|
||||
if sessionPrefix == "" {
|
||||
sessionPrefix = "paliad-paliadin"
|
||||
}
|
||||
if responseDir == "" {
|
||||
responseDir = "/tmp/paliadin"
|
||||
}
|
||||
return &PaliadinService{
|
||||
db: db,
|
||||
tmuxSession: tmuxSession,
|
||||
responseDir: responseDir,
|
||||
users: users,
|
||||
return &LocalPaliadinService{
|
||||
paliadinDB: paliadinDB{db: db, users: users},
|
||||
sessionPrefix: sessionPrefix,
|
||||
responseDir: responseDir,
|
||||
panes: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
// sessionNameFor returns the tmux session name for a given user. Per
|
||||
// design (t-paliad-155): one persistent session per Paliad user keyed
|
||||
// on the first 8 hex chars of their UUID. Conversation history piles
|
||||
// up across visits; `ResetSession` is the user-driven escape hatch.
|
||||
func (s *LocalPaliadinService) sessionNameFor(userID uuid.UUID) string {
|
||||
short := userID.String()
|
||||
if len(short) >= 8 {
|
||||
short = short[:8]
|
||||
}
|
||||
return s.sessionPrefix + "-" + short
|
||||
}
|
||||
|
||||
// PaliadinTurn is the audit row.
|
||||
type PaliadinTurn struct {
|
||||
TurnID uuid.UUID `db:"turn_id" json:"turn_id"`
|
||||
@@ -121,6 +170,10 @@ type PaliadinTurn struct {
|
||||
PageOrigin *string `db:"page_origin" json:"page_origin,omitempty"`
|
||||
ErrorCode *string `db:"error_code" json:"error_code,omitempty"`
|
||||
ClassifierTag *string `db:"classifier_tag" json:"classifier_tag,omitempty"`
|
||||
// Joined user fields, populated by the admin-monitor query only
|
||||
// (ListRecentTurns). Empty in the user-facing /api/paliadin/* paths.
|
||||
UserEmail *string `db:"user_email" json:"user_email,omitempty"`
|
||||
UserDisplayName *string `db:"user_display_name" json:"user_display_name,omitempty"`
|
||||
}
|
||||
|
||||
// TurnRequest is what the handler passes to RunTurn.
|
||||
@@ -156,7 +209,7 @@ var ErrTmuxUnavailable = errors.New("paliadin: tmux unavailable")
|
||||
//
|
||||
// PoC: serialised. The package-level turnMu enforces "one at a time".
|
||||
// m is the only user, so this is fine.
|
||||
func (s *PaliadinService) RunTurn(ctx context.Context, req TurnRequest) (*TurnResult, error) {
|
||||
func (s *LocalPaliadinService) RunTurn(ctx context.Context, req TurnRequest) (*TurnResult, error) {
|
||||
s.turnMu.Lock()
|
||||
defer s.turnMu.Unlock()
|
||||
|
||||
@@ -175,8 +228,8 @@ func (s *PaliadinService) RunTurn(ctx context.Context, req TurnRequest) (*TurnRe
|
||||
return nil, fmt.Errorf("paliadin: insert turn row: %w", err)
|
||||
}
|
||||
|
||||
// Ensure tmux session + Claude pane.
|
||||
target, err := s.ensurePane(ctx)
|
||||
// Ensure tmux session + Claude pane (per-user — keyed off UserID).
|
||||
target, err := s.ensurePane(ctx, req.UserID)
|
||||
if err != nil {
|
||||
_ = s.markTurnError(ctx, turnID, "tmux_unresponsive")
|
||||
return nil, fmt.Errorf("%w: %v", ErrTmuxUnavailable, err)
|
||||
@@ -188,8 +241,9 @@ func (s *PaliadinService) RunTurn(ctx context.Context, req TurnRequest) (*TurnRe
|
||||
return nil, fmt.Errorf("paliadin: mkdir response dir: %w", err)
|
||||
}
|
||||
|
||||
// Send the framed prompt. The system prompt teaches Claude to react
|
||||
// to the [PALIADIN:turn_id] envelope by writing the response file.
|
||||
// Send the framed prompt. The Paliadin skill at
|
||||
// ~/.claude/skills/paliadin/SKILL.md description-matches on this
|
||||
// envelope and writes the response to the per-turn file.
|
||||
envelope := fmt.Sprintf("[PALIADIN:%s] %s", turnID, sanitiseForTmux(req.UserMessage))
|
||||
if err := s.sendToPane(ctx, target, envelope); err != nil {
|
||||
_ = s.markTurnError(ctx, turnID, "tmux_unresponsive")
|
||||
@@ -236,38 +290,45 @@ func (s *PaliadinService) RunTurn(ctx context.Context, req TurnRequest) (*TurnRe
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ResetSession sends `/clear` to the Claude pane so the next turn starts
|
||||
// from a clean conversation. Used by the "New conversation" button.
|
||||
func (s *PaliadinService) ResetSession(ctx context.Context) error {
|
||||
// ResetSession kills the user's tmux session entirely so the next
|
||||
// RunTurn boots a fresh claude pane. With skill-based persona load
|
||||
// (~/.claude/skills/paliadin/SKILL.md) the new pane re-acquires the
|
||||
// protocol contract automatically — no system-prompt re-send needed.
|
||||
func (s *LocalPaliadinService) ResetSession(ctx context.Context, userID uuid.UUID) error {
|
||||
session := s.sessionNameFor(userID)
|
||||
|
||||
s.mu.Lock()
|
||||
target := s.paneTarget
|
||||
delete(s.panes, session)
|
||||
s.mu.Unlock()
|
||||
if target == "" {
|
||||
// Nothing to reset; the next RunTurn will spin up a fresh pane.
|
||||
|
||||
// `tmux kill-session` returns non-zero if the session doesn't exist;
|
||||
// that's fine — the next RunTurn will recreate it. Swallow the error
|
||||
// only when it's a benign "no such session" so genuine tmux failures
|
||||
// (binary missing, daemon dead) still surface to the caller.
|
||||
if err := runTmux(ctx, "has-session", "-t", session); err != nil {
|
||||
return nil
|
||||
}
|
||||
if err := s.sendToPane(ctx, target, "/clear"); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
return runTmux(ctx, "kill-session", "-t", session)
|
||||
}
|
||||
|
||||
// ListRecentTurns reads the last N turns visible to the caller.
|
||||
// global_admin sees everything; everyone else sees their own.
|
||||
func (s *PaliadinService) ListRecentTurns(ctx context.Context, callerID uuid.UUID, limit int) ([]PaliadinTurn, error) {
|
||||
func (s *paliadinDB) ListRecentTurns(ctx context.Context, callerID uuid.UUID, limit int) ([]PaliadinTurn, error) {
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
out := make([]PaliadinTurn, 0, limit)
|
||||
q := `
|
||||
SELECT turn_id, user_id, session_id, started_at, finished_at, duration_ms,
|
||||
user_message, response, response_tokens, used_tools, rows_seen,
|
||||
chip_count, abandoned, page_origin, error_code, classifier_tag
|
||||
FROM paliad.paliadin_turns
|
||||
WHERE user_id = $1
|
||||
OR EXISTS (SELECT 1 FROM paliad.users u
|
||||
WHERE u.id = $1 AND u.global_role = 'global_admin')
|
||||
ORDER BY started_at DESC
|
||||
SELECT t.turn_id, t.user_id, t.session_id, t.started_at, t.finished_at, t.duration_ms,
|
||||
t.user_message, t.response, t.response_tokens, t.used_tools, t.rows_seen,
|
||||
t.chip_count, t.abandoned, t.page_origin, t.error_code, t.classifier_tag,
|
||||
u.email AS user_email, u.display_name AS user_display_name
|
||||
FROM paliad.paliadin_turns t
|
||||
LEFT JOIN paliad.users u ON u.id = t.user_id
|
||||
WHERE t.user_id = $1
|
||||
OR EXISTS (SELECT 1 FROM paliad.users gu
|
||||
WHERE gu.id = $1 AND gu.global_role = 'global_admin')
|
||||
ORDER BY t.started_at DESC
|
||||
LIMIT $2
|
||||
`
|
||||
if err := s.db.SelectContext(ctx, &out, q, callerID, limit); err != nil {
|
||||
@@ -302,7 +363,7 @@ type PaliadinPromptCount struct {
|
||||
// Stats computes the dashboard aggregate. global_admin sees everything;
|
||||
// everyone else sees their own slice (PoC has only m, but the policy
|
||||
// matches RLS on the table).
|
||||
func (s *PaliadinService) Stats(ctx context.Context, callerID uuid.UUID) (*PaliadinStats, error) {
|
||||
func (s *paliadinDB) Stats(ctx context.Context, callerID uuid.UUID) (*PaliadinStats, error) {
|
||||
stats := &PaliadinStats{
|
||||
ByClassifier: map[string]int{},
|
||||
DailyCounts: []PaliadinDailyCount{},
|
||||
@@ -403,34 +464,40 @@ func (s *PaliadinService) Stats(ctx context.Context, callerID uuid.UUID) (*Palia
|
||||
// =============================================================================
|
||||
|
||||
// ensurePane returns the tmux target ("session:window-idx") of the live
|
||||
// Claude pane, creating both session and window if missing.
|
||||
func (s *PaliadinService) ensurePane(ctx context.Context) (string, error) {
|
||||
// Claude pane for this user, creating both session and window if
|
||||
// missing. The persona + response protocol are loaded from the Paliadin
|
||||
// skill on first user turn (Claude's skill router auto-matches the
|
||||
// `[PALIADIN:` envelope), so no in-process system-prompt send is
|
||||
// required.
|
||||
func (s *LocalPaliadinService) ensurePane(ctx context.Context, userID uuid.UUID) (string, error) {
|
||||
session := s.sessionNameFor(userID)
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
// Cheap path: if we have a cached target and it's still alive, reuse.
|
||||
if s.paneTarget != "" && s.paneAlive(ctx, s.paneTarget) {
|
||||
return s.paneTarget, nil
|
||||
// Cheap path: cached target still alive? Reuse.
|
||||
if cached, ok := s.panes[session]; ok && cached != "" && s.paneAlive(ctx, cached) {
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
// Ensure session.
|
||||
if err := runTmux(ctx, "has-session", "-t", s.tmuxSession); err != nil {
|
||||
if err := runTmux(ctx, "has-session", "-t", session); err != nil {
|
||||
// Create detached.
|
||||
if err := runTmux(ctx, "new-session", "-d", "-s", s.tmuxSession); err != nil {
|
||||
if err := runTmux(ctx, "new-session", "-d", "-s", session); err != nil {
|
||||
return "", fmt.Errorf("new-session: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Look for an existing window tagged with @paliadin-scope=chat.
|
||||
if existing := s.findChatWindow(ctx); existing != "" {
|
||||
s.paneTarget = existing
|
||||
if existing := s.findChatWindow(ctx, session); existing != "" {
|
||||
s.panes[session] = existing
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
// No window — create one running `claude` in a fresh pane. Must be
|
||||
// interactive: claude reads stdin, so the tmux pane behaves like a
|
||||
// terminal. We use `new-window -P -F` to print the new index back.
|
||||
out, err := runTmuxOut(ctx, "new-window", "-t", s.tmuxSession,
|
||||
out, err := runTmuxOut(ctx, "new-window", "-t", session,
|
||||
"-n", "claude-paliadin",
|
||||
"-P", "-F", "#{window_index}",
|
||||
"claude")
|
||||
@@ -438,7 +505,7 @@ func (s *PaliadinService) ensurePane(ctx context.Context) (string, error) {
|
||||
return "", fmt.Errorf("new-window claude: %w", err)
|
||||
}
|
||||
idx := strings.TrimSpace(out)
|
||||
target := fmt.Sprintf("%s:%s", s.tmuxSession, idx)
|
||||
target := fmt.Sprintf("%s:%s", session, idx)
|
||||
|
||||
// Wait for Claude's prompt indicator. Claude Code's interactive
|
||||
// prompt rendering varies but always settles into a state where the
|
||||
@@ -452,30 +519,18 @@ func (s *PaliadinService) ensurePane(ctx context.Context) (string, error) {
|
||||
_ = runTmux(ctx, "set-window-option", "-t", target, "@paliadin-scope", "chat")
|
||||
_ = runTmux(ctx, "set-window-option", "-t", target, "@fix-name", "claude-paliadin")
|
||||
|
||||
// Send the bootstrap system prompt so Claude knows who it is and how
|
||||
// to reply (write to the per-turn file with [paliadin-meta] trailer).
|
||||
if err := s.sendToPane(ctx, target, paliadinSystemPrompt(s.responseDir)); err != nil {
|
||||
return "", fmt.Errorf("send system prompt: %w", err)
|
||||
}
|
||||
// Give Claude a moment to absorb the system prompt before turns flow.
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
case <-time.After(2 * time.Second):
|
||||
}
|
||||
|
||||
s.paneTarget = target
|
||||
s.panes[session] = target
|
||||
return target, nil
|
||||
}
|
||||
|
||||
func (s *PaliadinService) findChatWindow(ctx context.Context) string {
|
||||
out, err := runTmuxOut(ctx, "list-windows", "-t", s.tmuxSession,
|
||||
func (s *LocalPaliadinService) findChatWindow(ctx context.Context, session string) string {
|
||||
out, err := runTmuxOut(ctx, "list-windows", "-t", session,
|
||||
"-F", "#{window_index}")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, idx := range strings.Fields(out) {
|
||||
target := fmt.Sprintf("%s:%s", s.tmuxSession, idx)
|
||||
target := fmt.Sprintf("%s:%s", session, idx)
|
||||
scope, err := runTmuxOut(ctx, "show-window-option",
|
||||
"-t", target, "-v", "@paliadin-scope")
|
||||
if err == nil && strings.TrimSpace(scope) == "chat" {
|
||||
@@ -485,14 +540,14 @@ func (s *PaliadinService) findChatWindow(ctx context.Context) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *PaliadinService) paneAlive(ctx context.Context, target string) bool {
|
||||
func (s *LocalPaliadinService) paneAlive(ctx context.Context, target string) bool {
|
||||
if err := runTmux(ctx, "has-session", "-t", target); err != nil {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *PaliadinService) waitForPaneReady(ctx context.Context, target string, timeout time.Duration) error {
|
||||
func (s *LocalPaliadinService) waitForPaneReady(ctx context.Context, target string, timeout time.Duration) error {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
select {
|
||||
@@ -509,7 +564,7 @@ func (s *PaliadinService) waitForPaneReady(ctx context.Context, target string, t
|
||||
return fmt.Errorf("pane %s not ready within %s", target, timeout)
|
||||
}
|
||||
|
||||
func (s *PaliadinService) sendToPane(ctx context.Context, target, msg string) error {
|
||||
func (s *LocalPaliadinService) sendToPane(ctx context.Context, target, msg string) error {
|
||||
// `-l` sends the message literally (no key parsing) — necessary so
|
||||
// our prompt's special characters don't get interpreted.
|
||||
if err := runTmux(ctx, "send-keys", "-t", target, "-l", msg); err != nil {
|
||||
@@ -527,7 +582,7 @@ func (s *PaliadinService) sendToPane(ctx context.Context, target, msg string) er
|
||||
// over from earlier turns) as a non-event — the file existing without a
|
||||
// fresh mtime is a corner case the caller already de-duplicates by
|
||||
// having a unique turn_id per request.
|
||||
func (s *PaliadinService) pollForResponse(ctx context.Context, path string, timeout time.Duration) (string, error) {
|
||||
func (s *LocalPaliadinService) pollForResponse(ctx context.Context, path string, timeout time.Duration) (string, error) {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
select {
|
||||
@@ -687,7 +742,7 @@ func countChips(s string) int {
|
||||
// audit-row writers.
|
||||
// =============================================================================
|
||||
|
||||
func (s *PaliadinService) insertTurnRow(ctx context.Context, t *PaliadinTurn) error {
|
||||
func (s *paliadinDB) insertTurnRow(ctx context.Context, t *PaliadinTurn) error {
|
||||
q := `
|
||||
INSERT INTO paliad.paliadin_turns (
|
||||
turn_id, user_id, session_id, started_at, user_message, page_origin
|
||||
@@ -698,9 +753,17 @@ func (s *PaliadinService) insertTurnRow(ctx context.Context, t *PaliadinTurn) er
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *PaliadinService) completeTurn(ctx context.Context, turnID uuid.UUID,
|
||||
func (s *paliadinDB) completeTurn(ctx context.Context, turnID uuid.UUID,
|
||||
finishedAt time.Time, durationMS int, response string, tokens int,
|
||||
meta trailerMeta, chipCount int) error {
|
||||
// used_tools and rows_seen are NOT NULL in the schema (default '{}').
|
||||
// parseTrailer leaves them nil when Claude omits the trailer or the
|
||||
// turn has no tool calls (casual chat). pq treats nil slices as NULL,
|
||||
// so we must coerce to a non-nil empty array on every path.
|
||||
usedTools := make(pq.StringArray, 0, len(meta.UsedTools))
|
||||
for _, t := range meta.UsedTools {
|
||||
usedTools = append(usedTools, t)
|
||||
}
|
||||
rowsSeen := make(pq.Int64Array, 0, len(meta.RowsSeen))
|
||||
for _, n := range meta.RowsSeen {
|
||||
rowsSeen = append(rowsSeen, int64(n))
|
||||
@@ -719,12 +782,12 @@ func (s *PaliadinService) completeTurn(ctx context.Context, turnID uuid.UUID,
|
||||
`
|
||||
_, err := s.db.ExecContext(ctx, q,
|
||||
turnID, finishedAt, durationMS, response, tokens,
|
||||
pq.StringArray(meta.UsedTools), rowsSeen, chipCount,
|
||||
usedTools, rowsSeen, chipCount,
|
||||
optionalString(meta.ClassifierTag))
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *PaliadinService) markTurnError(ctx context.Context, turnID uuid.UUID, code string) error {
|
||||
func (s *paliadinDB) markTurnError(ctx context.Context, turnID uuid.UUID, code string) error {
|
||||
finished := time.Now().UTC()
|
||||
q := `
|
||||
UPDATE paliad.paliadin_turns
|
||||
@@ -735,7 +798,7 @@ func (s *PaliadinService) markTurnError(ctx context.Context, turnID uuid.UUID, c
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *PaliadinService) markTurnAbandonedOrError(ctx context.Context, turnID uuid.UUID, code string, abandoned bool) error {
|
||||
func (s *paliadinDB) markTurnAbandonedOrError(ctx context.Context, turnID uuid.UUID, code string, abandoned bool) error {
|
||||
finished := time.Now().UTC()
|
||||
q := `
|
||||
UPDATE paliad.paliadin_turns
|
||||
|
||||
@@ -1,269 +0,0 @@
|
||||
package services
|
||||
|
||||
// Paliadin system prompt — Phase 0 PoC.
|
||||
//
|
||||
// This is the bootstrap message sent to the long-lived `claude` pane
|
||||
// once, right after the pane is created. It defines who Paliadin is,
|
||||
// how to reply (write to the per-turn response file, emit a
|
||||
// [paliadin-meta] trailer block), what SQL to run, and how visibility
|
||||
// is enforced.
|
||||
//
|
||||
// Design: docs/design-paliadin-2026-05-07.md §0.5.3 + §2.2.1.
|
||||
//
|
||||
// Conventions:
|
||||
// - The prompt MUST end with the response-file write rule, since that
|
||||
// is the contract the Go service polls on.
|
||||
// - SQL recipes MUST always include the visibility predicate
|
||||
// (paliad.can_see_project) on any project-scoped query — even
|
||||
// though m's global_role=global_admin technically lets him see
|
||||
// everything, we keep the muscle memory consistent with the
|
||||
// production-v1 design.
|
||||
// - The trailer format is stable; the trailer parser in paliadin.go
|
||||
// must be kept in sync.
|
||||
|
||||
import "strings"
|
||||
|
||||
// paliadinSystemPrompt returns the full bootstrap message for a fresh
|
||||
// Claude pane. The response_dir argument is the path where Claude must
|
||||
// write its per-turn response files.
|
||||
//
|
||||
// Built via concatenation rather than fmt.Sprintf because the prompt
|
||||
// contains German genitive apostrophes ("m's") that Sprintf misreads as
|
||||
// format verbs.
|
||||
func paliadinSystemPrompt(responseDir string) string {
|
||||
return strings.TrimSpace(`
|
||||
Du bist Paliadin — der eingebaute KI-Assistent in Paliad, m's Patentpraxis-Plattform. Du hilfst m bei seiner täglichen Arbeit: Akten finden, Fristen prüfen, Begriffe erklären, Gerichte nachschlagen, UPC-Rechtsprechung recherchieren.
|
||||
|
||||
# Persönlichkeit
|
||||
|
||||
- Direkt, kompetent, juristisch präzise. Keine Floskeln.
|
||||
- Sprich wie ein Patentanwalts-Kollege mit zehn Jahren UPC-Erfahrung — nicht wie ein generischer Chatbot.
|
||||
- Belege jede konkrete Aussage mit einem Tool-Call oder einer Zitat-Quelle. Niemals raten.
|
||||
- Antworte standardmäßig auf Deutsch (m's Arbeitssprache). Wenn m auf Englisch fragt, antworte auf Englisch.
|
||||
- Keine Emojis, keine "Ich helfe dir gerne!"-Phrasen.
|
||||
|
||||
# Antwort-Protokoll (KRITISCH)
|
||||
|
||||
Jede Anfrage von m kommt im Format: ` + "`[PALIADIN:turn_id] <Frage>`" + `
|
||||
|
||||
Sobald du die turn_id liest:
|
||||
1. Recherchiere mit deinen Tools (siehe SQL-Rezepte unten).
|
||||
2. Formuliere eine knappe, faktenbasierte Antwort in Markdown.
|
||||
3. Schreibe die Antwort in eine Datei: ` + "`Write(" + responseDir + "/{turn_id}.txt)`" + `
|
||||
4. WICHTIG: Schreib SOFORT, sobald du die Antwort hast. Das System wartet (Timeout: 60s).
|
||||
5. Häng am Ende des Antworttextes IMMER einen [paliadin-meta]-Block an — sonst weiß das System nicht, was du gemacht hast.
|
||||
|
||||
# Trailer-Format (PFLICHT am Ende jeder Antwort)
|
||||
|
||||
Trenne den Block mit einer Leerzeile + ---, dann:
|
||||
|
||||
[paliadin-meta]
|
||||
used_tools: <komma-separierte Tool-Namen, leer wenn keiner verwendet>
|
||||
rows_seen: <komma-separierte Zeilen-Counts, parallel zu used_tools>
|
||||
classifier_tag: <data | concept | navigation | meta | other>
|
||||
[/paliadin-meta]
|
||||
|
||||
Beispiel:
|
||||
|
||||
[paliadin-meta]
|
||||
used_tools: search_my_deadlines, lookup_court
|
||||
rows_seen: 3, 1
|
||||
classifier_tag: data
|
||||
[/paliadin-meta]
|
||||
|
||||
Die classifier_tag-Werte:
|
||||
- ` + "`data`" + ` — m fragt nach seinen eigenen Daten ("welche Frist…", "auf welchem Projekt…")
|
||||
- ` + "`concept`" + ` — m fragt nach einem juristischen Begriff/Verfahren ("was ist Klageerwiderung?")
|
||||
- ` + "`navigation`" + ` — m sucht eine Seite/Funktion in Paliad ("wie öffne ich…")
|
||||
- ` + "`meta`" + ` — Frage über Paliadin selbst, oder Smalltalk
|
||||
- ` + "`other`" + ` — alles andere (Recherche, Web-Wissen)
|
||||
|
||||
# Action-Chips (optional, aber gerne nutzen)
|
||||
|
||||
Wenn du eine konkrete Folge-Aktion anbieten kannst, embed einen Chip-Marker direkt in den Antworttext. Das Frontend rendert ihn als anklickbaren Button:
|
||||
|
||||
- ` + "`[#deadline-OPEN:c47bd2-...]`" + ` — öffnet die Fristen-Detailseite
|
||||
- ` + "`[#projekt-OPEN:slug-x]`" + ` — öffnet die Projekt-Detailseite
|
||||
- ` + "`[chip:nav:/projects/abc-123]`" + ` — beliebige Navigation
|
||||
- ` + "`[chip:filter:status=pending&due=this_week]`" + ` — gefilterter Inbox-Link
|
||||
|
||||
Verwende NUR IDs/Slugs, die du tatsächlich aus einem Tool-Call zurückbekommen hast. Niemals erfinden.
|
||||
|
||||
# Hard Rules
|
||||
|
||||
1. **Keine Erfindungen.** Wenn ein Tool keine Daten liefert, sag das. Niemals Aktenzeichen, Daten, Gerichts- oder Parteinamen erfinden.
|
||||
2. **Jede konkrete Aussage über m's eigene Arbeit MUSS aus einem Tool-Call der aktuellen Antwort kommen.** Erinnerung an frühere Gespräche reicht nicht — Daten ändern sich.
|
||||
3. **Schreibe nichts in die DB.** Du bist read-only. Wenn m etwas ändern will, sag ihm wo in Paliad.
|
||||
4. **Visibility-Gate respektieren.** Auch wenn m global_admin ist: jede projekt-bezogene Abfrage MUSS ` + "`paliad.can_see_project(project_id)`" + ` enthalten. Konsistenz mit der späteren Multi-User-Version.
|
||||
5. **Nicht über die Daten anderer User spekulieren**, selbst wenn m sie namentlich erwähnt — frag nach Projekt-ID/Slug.
|
||||
|
||||
# SQL-Rezepte
|
||||
|
||||
Du hast Zugriff auf zwei Datenquellen über das Supabase MCP (mcp__supabase__execute_sql):
|
||||
- ` + "`paliad.*`" + ` — m's Patent-Praxis-Daten (Projekte, Fristen, Termine, Parteien, Gerichte, Glossar, Deadline-Rules)
|
||||
- ` + "`data.*`" + ` — youpc.org UPC-Rechtsprechung (Urteile, Headnotes, Knowledge Graph) — selbe physische DB!
|
||||
|
||||
## 1. whats_on_my_plate — m's Dashboard-Übersicht
|
||||
|
||||
` + "```sql" + `
|
||||
SELECT
|
||||
(SELECT count(*) FROM paliad.deadlines d
|
||||
WHERE paliad.can_see_project(d.project_id)
|
||||
AND d.status = 'pending' AND d.due_date < current_date) AS overdue,
|
||||
(SELECT count(*) FROM paliad.deadlines d
|
||||
WHERE paliad.can_see_project(d.project_id)
|
||||
AND d.status = 'pending' AND d.due_date = current_date) AS today,
|
||||
(SELECT count(*) FROM paliad.deadlines d
|
||||
WHERE paliad.can_see_project(d.project_id)
|
||||
AND d.status = 'pending'
|
||||
AND d.due_date BETWEEN current_date AND current_date + 7) AS this_week,
|
||||
(SELECT count(*) FROM paliad.appointments a
|
||||
WHERE (a.project_id IS NULL OR paliad.can_see_project(a.project_id))
|
||||
AND a.start_at::date = current_date) AS appointments_today;
|
||||
` + "```" + `
|
||||
|
||||
## 2. list_my_projects
|
||||
|
||||
` + "```sql" + `
|
||||
SELECT id, kind, label, status, parent_id, path
|
||||
FROM paliad.projects
|
||||
WHERE paliad.can_see_project(id)
|
||||
AND status = 'active'
|
||||
ORDER BY path
|
||||
LIMIT 25;
|
||||
` + "```" + `
|
||||
|
||||
## 3. get_project_detail (gegeben slug oder id)
|
||||
|
||||
` + "```sql" + `
|
||||
SELECT p.*,
|
||||
(SELECT json_agg(d ORDER BY d.due_date)
|
||||
FROM paliad.deadlines d WHERE d.project_id = p.id
|
||||
AND paliad.can_see_project(d.project_id)) AS deadlines,
|
||||
(SELECT json_agg(a ORDER BY a.start_at)
|
||||
FROM paliad.appointments a WHERE a.project_id = p.id
|
||||
AND paliad.can_see_project(a.project_id)) AS appointments,
|
||||
(SELECT json_agg(pa) FROM paliad.parties pa WHERE pa.project_id = p.id) AS parties
|
||||
FROM paliad.projects p
|
||||
WHERE paliad.can_see_project(p.id)
|
||||
AND (p.id::text = '<UUID>' OR p.slug = '<slug>')
|
||||
LIMIT 1;
|
||||
` + "```" + `
|
||||
|
||||
## 4. search_my_deadlines (status / Datum / Projekt)
|
||||
|
||||
` + "```sql" + `
|
||||
SELECT d.id, d.title, d.due_date, d.status, p.label AS project_label, d.event_id
|
||||
FROM paliad.deadlines d
|
||||
JOIN paliad.projects p ON p.id = d.project_id
|
||||
WHERE paliad.can_see_project(d.project_id)
|
||||
AND ($status::text IS NULL OR d.status = $status)
|
||||
AND ($due_after::date IS NULL OR d.due_date >= $due_after)
|
||||
AND ($due_before::date IS NULL OR d.due_date <= $due_before)
|
||||
ORDER BY d.due_date ASC
|
||||
LIMIT 25;
|
||||
` + "```" + `
|
||||
|
||||
## 5. list_my_appointments (Zeitfenster)
|
||||
|
||||
` + "```sql" + `
|
||||
SELECT a.id, a.title, a.start_at, a.end_at, a.location, p.label AS project_label
|
||||
FROM paliad.appointments a
|
||||
LEFT JOIN paliad.projects p ON p.id = a.project_id
|
||||
WHERE (a.project_id IS NULL OR paliad.can_see_project(a.project_id))
|
||||
AND a.start_at >= $from
|
||||
AND a.start_at <= $to
|
||||
ORDER BY a.start_at ASC
|
||||
LIMIT 25;
|
||||
` + "```" + `
|
||||
|
||||
## 6. lookup_court (Gerichtskatalog — firm-wide reference)
|
||||
|
||||
` + "```sql" + `
|
||||
SELECT c.slug, c.name, c.country, c.kind, c.address
|
||||
FROM paliad.courts c
|
||||
WHERE c.name ILIKE '%' || $q || '%'
|
||||
OR c.slug ILIKE '%' || $q || '%'
|
||||
ORDER BY similarity(c.name, $q) DESC
|
||||
LIMIT 10;
|
||||
` + "```" + `
|
||||
|
||||
## 7. lookup_glossary_term (Patent-Glossar, DE+EN)
|
||||
|
||||
` + "```sql" + `
|
||||
-- Hinweis: Glossar ist statisch in internal/handlers/glossary.go.
|
||||
-- Der Service lädt JSON beim Boot. Wenn du einen Begriff suchst, frag mich
|
||||
-- direkt im Chat — m hat den Glossar-Volltext im Kopf, oder ich kann ihn
|
||||
-- aus paliad.deadline_rules.legal_source ableiten.
|
||||
` + "```" + `
|
||||
|
||||
## 8. lookup_deadline_rule (Fristenrechner-Konzepte)
|
||||
|
||||
` + "```sql" + `
|
||||
SELECT r.rule_code, r.concept_label, r.trigger_event, r.deadline_text,
|
||||
r.deadline_text_en, r.legal_source, r.deadline_notes, r.deadline_notes_en
|
||||
FROM paliad.deadline_rules r
|
||||
WHERE r.concept_label ILIKE '%' || $q || '%'
|
||||
OR r.rule_code ILIKE '%' || $q || '%'
|
||||
OR r.legal_source ILIKE '%' || $q || '%'
|
||||
ORDER BY similarity(r.concept_label, $q) DESC
|
||||
LIMIT 5;
|
||||
` + "```" + `
|
||||
|
||||
## 9. lookup_youpc_case (UPC-Rechtsprechung — cross-schema!)
|
||||
|
||||
` + "```sql" + `
|
||||
SELECT j.node_id, j.upc_number, j.court_division, j.judgment_type,
|
||||
j.proceedings_type, j.decision_date, j.headnote_summary,
|
||||
j.tags
|
||||
FROM data.judgments j
|
||||
WHERE j.upc_number ILIKE '%' || $q || '%'
|
||||
OR j.headnote_summary ILIKE '%' || $q || '%'
|
||||
OR j.tags::text ILIKE '%' || $q || '%'
|
||||
ORDER BY j.decision_date DESC
|
||||
LIMIT 5;
|
||||
` + "```" + `
|
||||
|
||||
Volltext eines Urteils (wenn m fragt "was steht in dem Urteil?"):
|
||||
|
||||
` + "```sql" + `
|
||||
SELECT content
|
||||
FROM data.judgment_markdown_content
|
||||
WHERE judgment_node_id = <node_id>
|
||||
ORDER BY chunk_index
|
||||
LIMIT 1;
|
||||
` + "```" + `
|
||||
|
||||
# Beispiel-Antwort
|
||||
|
||||
m fragt: ` + "`[PALIADIN:abc-123] welche fristen sind diese woche fällig?`" + `
|
||||
|
||||
Du machst:
|
||||
1. ` + "`mcp__supabase__execute_sql`" + ` mit Rezept #4 (search_my_deadlines), $status='pending', $due_after=current_date, $due_before=current_date+7
|
||||
2. Du bekommst z.B. 3 Zeilen zurück.
|
||||
3. Du schreibst:
|
||||
|
||||
` + "```" + `
|
||||
Write("/tmp/paliadin/abc-123.txt", """
|
||||
Diese Woche stehen 3 Fristen an:
|
||||
|
||||
- **16.05.** Klageerwiderung auf Müller v. Acme [#deadline-OPEN:c47bd2-1] — UPC LD München
|
||||
- **17.05.** Replik auf BMW v. Daimler [#deadline-OPEN:e92a01-3]
|
||||
- **20.05.** Wiedereinsetzungsantrag auf Bosch-Patent [#deadline-OPEN:f31b09-7]
|
||||
|
||||
Willst du eine davon im Detail anschauen?
|
||||
|
||||
---
|
||||
[paliadin-meta]
|
||||
used_tools: search_my_deadlines
|
||||
rows_seen: 3
|
||||
classifier_tag: data
|
||||
[/paliadin-meta]
|
||||
""")
|
||||
` + "```" + `
|
||||
|
||||
# Wichtig
|
||||
|
||||
Der erste turn-Envelope, den du nach diesem System-Prompt bekommst, ist eine richtige m-Anfrage. Antworte gemäß Protokoll. Bei der allerersten Anfrage darfst du dich kurz vorstellen ("Hi m, ich bin Paliadin — bereit."), danach normaler Modus.
|
||||
`)
|
||||
}
|
||||
339
internal/services/paliadin_remote.go
Normal file
339
internal/services/paliadin_remote.go
Normal file
@@ -0,0 +1,339 @@
|
||||
package services
|
||||
|
||||
// RemotePaliadinService — the prod path of the Paliadin backend.
|
||||
//
|
||||
// Design: docs/design-paliadin-tailscale-ssh-2026-05-07.md.
|
||||
//
|
||||
// Where the local backend (LocalPaliadinService) drives a tmux+claude
|
||||
// pane in-process, the remote backend shells out to ssh m@mriver
|
||||
// paliadin-shim — the script at scripts/paliadin-shim, installed at
|
||||
// /home/m/.local/bin/paliadin-shim on m's laptop. The shim owns the
|
||||
// tmux+claude pane on mRiver; this Go side just wraps each turn in one
|
||||
// SSH call.
|
||||
//
|
||||
// The path was chosen so paliad.de (deployed in a Dokploy container on
|
||||
// mLake, no `claude` CLI of its own) can keep using m's Claude Code
|
||||
// subscription instead of paying API tokens. Tailscale provides the
|
||||
// transport — mLake's tailscale0 interface is shared into the container
|
||||
// via network_mode: host (compose layer; not this file's concern).
|
||||
//
|
||||
// Wiring is gated on PALIADIN_REMOTE_HOST in cmd/server/main.go. When
|
||||
// that env var is unset, the binary falls back to LocalPaliadinService
|
||||
// (or DisabledPaliadinService if neither tmux nor remote is available).
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
// ErrMRiverUnreachable signals that the remote paliadin-shim could not
|
||||
// be contacted within the health-check window. The handler maps this to
|
||||
// the friendly mriver_unreachable error code (see frontend
|
||||
// friendlyErrorMessage).
|
||||
var ErrMRiverUnreachable = errors.New("paliadin: mriver unreachable")
|
||||
|
||||
// RemotePaliadinConfig is the bag of knobs cmd/server/main.go passes
|
||||
// when constructing a RemotePaliadinService.
|
||||
type RemotePaliadinConfig struct {
|
||||
SSHHost string // 100.99.98.203 — mRiver's tailnet IP
|
||||
SSHPort int // 22022 — bypasses Tailscale SSH on :22 (design §4.5)
|
||||
SSHUser string // m
|
||||
SSHKeyPath string // /tmp/paliadin-id_ed25519-<rand> (chmod 600)
|
||||
KnownHostsPath string // /tmp/paliadin-known_hosts
|
||||
SessionPrefix string // tmux session prefix; per-user session is "<prefix>-<userid8>"
|
||||
}
|
||||
|
||||
// RemotePaliadinService implements Paliadin against a remote
|
||||
// paliadin-shim over SSH.
|
||||
type RemotePaliadinService struct {
|
||||
paliadinDB
|
||||
cfg RemotePaliadinConfig
|
||||
|
||||
// Serialise turns across all users. mRiver's host has finite tmux
|
||||
// concurrency anyway, and Paliadin turns are short. Per-user
|
||||
// fan-out can ship in v2 if it ever bottlenecks.
|
||||
turnMu sync.Mutex
|
||||
|
||||
// Health-check cache, keyed by per-user session name. Avoids
|
||||
// probing mRiver on every turn — once a session's cache is warm,
|
||||
// RunTurn skips the probe for 10 seconds.
|
||||
healthMu sync.Mutex
|
||||
health map[string]healthCacheEntry
|
||||
|
||||
// Hook for tests — when non-nil, callShim delegates here instead
|
||||
// of exec'ing ssh. Production code never sets this.
|
||||
callShimHook func(ctx context.Context, args ...string) ([]byte, error)
|
||||
}
|
||||
|
||||
// healthCacheEntry is one row in the health cache, keyed off tmux
|
||||
// session name. We cache success only — failures re-probe so a flap
|
||||
// surfaces immediately when paliad reboots into a healthy mRiver.
|
||||
type healthCacheEntry struct {
|
||||
ok bool
|
||||
checkedAt time.Time
|
||||
}
|
||||
|
||||
// NewRemotePaliadinService wires the remote backend. Call only when
|
||||
// PALIADIN_REMOTE_HOST is set in the environment; the constructor does
|
||||
// not probe mRiver — first probe happens on the first RunTurn call via
|
||||
// healthGate.
|
||||
func NewRemotePaliadinService(db *sqlx.DB, users *UserService, cfg RemotePaliadinConfig) *RemotePaliadinService {
|
||||
if cfg.SSHPort == 0 {
|
||||
cfg.SSHPort = 22022
|
||||
}
|
||||
if cfg.SSHUser == "" {
|
||||
cfg.SSHUser = "m"
|
||||
}
|
||||
if cfg.SessionPrefix == "" {
|
||||
cfg.SessionPrefix = "paliad-paliadin"
|
||||
}
|
||||
return &RemotePaliadinService{
|
||||
paliadinDB: paliadinDB{db: db, users: users},
|
||||
cfg: cfg,
|
||||
health: make(map[string]healthCacheEntry),
|
||||
}
|
||||
}
|
||||
|
||||
// sessionNameFor returns the per-user tmux session name. Per-user
|
||||
// keying (t-paliad-155): one persistent session per Paliad user keyed
|
||||
// on the first 8 hex chars of their UUID. Conversation history piles
|
||||
// up across visits; ResetSession is the user-driven escape hatch.
|
||||
func (s *RemotePaliadinService) sessionNameFor(userID uuid.UUID) string {
|
||||
short := userID.String()
|
||||
if len(short) >= 8 {
|
||||
short = short[:8]
|
||||
}
|
||||
return s.cfg.SessionPrefix + "-" + short
|
||||
}
|
||||
|
||||
// RunTurn drives one Q&A round against the remote claude pane. Same
|
||||
// audit-row contract as LocalPaliadinService: write the row first, run
|
||||
// the turn, complete the row on success, mark error on failure.
|
||||
func (s *RemotePaliadinService) RunTurn(ctx context.Context, req TurnRequest) (*TurnResult, error) {
|
||||
s.turnMu.Lock()
|
||||
defer s.turnMu.Unlock()
|
||||
|
||||
turnID := uuid.New()
|
||||
startedAt := time.Now().UTC()
|
||||
|
||||
// Audit row first — leave traces even if we crash mid-turn.
|
||||
if err := s.insertTurnRow(ctx, &PaliadinTurn{
|
||||
TurnID: turnID,
|
||||
UserID: req.UserID,
|
||||
SessionID: req.SessionID,
|
||||
StartedAt: startedAt,
|
||||
UserMessage: req.UserMessage,
|
||||
PageOrigin: optionalString(req.PageOrigin),
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("paliadin: insert turn row: %w", err)
|
||||
}
|
||||
|
||||
session := s.sessionNameFor(req.UserID)
|
||||
|
||||
// Health-gate before paying the cost of a real turn. Caches OK for
|
||||
// 10 s per session so a fast back-to-back chat doesn't probe every
|
||||
// time.
|
||||
if err := s.healthGate(ctx, session); err != nil {
|
||||
_ = s.markTurnError(ctx, turnID, "mriver_unreachable")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Persona + response protocol live in the Paliadin skill at
|
||||
// ~/.claude/skills/paliadin/SKILL.md on mRiver. Claude's skill
|
||||
// router auto-matches the [PALIADIN: envelope so no in-process
|
||||
// bootstrap (system-prompt-via-tmux-keystroke) is needed any more.
|
||||
|
||||
msg := sanitiseForTmux(req.UserMessage)
|
||||
msgB64 := base64.StdEncoding.EncodeToString([]byte(msg))
|
||||
|
||||
body, err := s.callShim(ctx, "run-turn", session, turnID.String(), msgB64)
|
||||
if err != nil {
|
||||
_ = s.markTurnError(ctx, turnID, classifySSHError(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Same trailer parse + audit completion as the local path.
|
||||
cleanBody, meta := splitTrailer(string(body))
|
||||
tokens := approxTokenCount(cleanBody)
|
||||
chipCount := countChips(cleanBody)
|
||||
finished := time.Now().UTC()
|
||||
durationMS := int(finished.Sub(startedAt) / time.Millisecond)
|
||||
|
||||
if err := s.completeTurn(ctx, turnID, finished, durationMS, cleanBody, tokens, meta, chipCount); err != nil {
|
||||
log.Printf("paliadin: complete turn %s: %v", turnID, err)
|
||||
}
|
||||
|
||||
return &TurnResult{
|
||||
TurnID: turnID,
|
||||
Response: cleanBody,
|
||||
UsedTools: meta.UsedTools,
|
||||
RowsSeen: meta.RowsSeen,
|
||||
ChipCount: chipCount,
|
||||
ClassifierTag: meta.ClassifierTag,
|
||||
DurationMS: durationMS,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ResetSession kills the user's tmux session on mRiver entirely so the
|
||||
// next RunTurn boots a fresh claude pane. Skill-based persona load
|
||||
// means the new pane re-acquires the Paliadin protocol contract on
|
||||
// first turn — no system-prompt re-send needed.
|
||||
func (s *RemotePaliadinService) ResetSession(ctx context.Context, userID uuid.UUID) error {
|
||||
session := s.sessionNameFor(userID)
|
||||
|
||||
// Drop the cached health entry so the next turn re-probes against
|
||||
// the fresh session.
|
||||
s.healthMu.Lock()
|
||||
delete(s.health, session)
|
||||
s.healthMu.Unlock()
|
||||
|
||||
if _, err := s.callShim(ctx, "reset", session); err != nil {
|
||||
return fmt.Errorf("paliadin: reset %s: %w", session, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// healthGate runs the shim's `health <session>` verb at most once per
|
||||
// 10 s per session. Returns ErrMRiverUnreachable wrapping the
|
||||
// underlying error on miss.
|
||||
func (s *RemotePaliadinService) healthGate(ctx context.Context, session string) error {
|
||||
s.healthMu.Lock()
|
||||
defer s.healthMu.Unlock()
|
||||
|
||||
if entry, ok := s.health[session]; ok && entry.ok && time.Since(entry.checkedAt) < 10*time.Second {
|
||||
return nil
|
||||
}
|
||||
|
||||
probeCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||
defer cancel()
|
||||
out, err := s.callShim(probeCtx, "health", session)
|
||||
if err != nil {
|
||||
// Don't cache failures — re-probe on every miss so a recovery
|
||||
// surfaces immediately.
|
||||
delete(s.health, session)
|
||||
return fmt.Errorf("%w: %v", ErrMRiverUnreachable, err)
|
||||
}
|
||||
if strings.TrimSpace(string(out)) != "ok" {
|
||||
delete(s.health, session)
|
||||
return fmt.Errorf("%w: shim returned %q", ErrMRiverUnreachable, string(out))
|
||||
}
|
||||
s.health[session] = healthCacheEntry{ok: true, checkedAt: time.Now()}
|
||||
return nil
|
||||
}
|
||||
|
||||
// callShim runs `ssh <user>@<host> -- <verb> <args...>` against the
|
||||
// paliadin-shim. The shim's authorized_keys command= directive ensures
|
||||
// the verb + args are passed via $SSH_ORIGINAL_COMMAND regardless of
|
||||
// what we put after the `--`; we keep the explicit argv form anyway so
|
||||
// reading the code at the call site is unambiguous.
|
||||
//
|
||||
// Tests set callShimHook to bypass exec.
|
||||
func (s *RemotePaliadinService) callShim(ctx context.Context, args ...string) ([]byte, error) {
|
||||
if s.callShimHook != nil {
|
||||
return s.callShimHook(ctx, args...)
|
||||
}
|
||||
|
||||
sshArgs := []string{
|
||||
"-F", "/dev/null", // ignore /etc/ssh/ssh_config + ~/.ssh/config
|
||||
"-i", s.cfg.SSHKeyPath,
|
||||
"-p", strconv.Itoa(s.cfg.SSHPort), // 22022 — bypasses Tailscale SSH on :22
|
||||
"-o", "IdentitiesOnly=yes",
|
||||
"-o", "UserKnownHostsFile=" + s.cfg.KnownHostsPath,
|
||||
"-o", "StrictHostKeyChecking=yes",
|
||||
"-o", "BatchMode=yes",
|
||||
"-o", "ConnectTimeout=3",
|
||||
"-o", "ServerAliveInterval=10",
|
||||
"-o", "ServerAliveCountMax=3",
|
||||
s.cfg.SSHUser + "@" + s.cfg.SSHHost,
|
||||
"--",
|
||||
}
|
||||
sshArgs = append(sshArgs, args...)
|
||||
|
||||
// Shim's run-turn timeout is 120 s (cold start = claude boot + skill
|
||||
// load + MCP discovery + first reasoning); +10 s gives SSH overhead.
|
||||
c, cancel := context.WithTimeout(ctx, 130*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(c, "ssh", sshArgs...)
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return nil, fmt.Errorf("ssh %s: %w (stderr: %s)", strings.Join(args, " "), err, strings.TrimSpace(stderr.String()))
|
||||
}
|
||||
return stdout.Bytes(), nil
|
||||
}
|
||||
|
||||
// classifySSHError turns a callShim error into one of the audit-row
|
||||
// error codes. Codes are stable strings shown on the admin dashboard
|
||||
// and used by the frontend's friendlyErrorMessage to localise.
|
||||
func classifySSHError(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
if errors.Is(err, ErrMRiverUnreachable) {
|
||||
return "mriver_unreachable"
|
||||
}
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return "timeout"
|
||||
}
|
||||
msg := err.Error()
|
||||
switch {
|
||||
case strings.Contains(msg, "Connection timed out"),
|
||||
strings.Contains(msg, "Connection refused"),
|
||||
strings.Contains(msg, "Could not resolve hostname"),
|
||||
strings.Contains(msg, "Network is unreachable"):
|
||||
return "mriver_unreachable"
|
||||
case strings.Contains(msg, "exit status 124"):
|
||||
// Shim's run-turn 60 s timeout — Claude didn't write the
|
||||
// response file in time.
|
||||
return "timeout"
|
||||
case strings.Contains(msg, "Permission denied"):
|
||||
return "shim_auth_failed"
|
||||
default:
|
||||
return "shim_error"
|
||||
}
|
||||
}
|
||||
|
||||
// DisabledPaliadinService is a stub that always returns
|
||||
// ErrPaliadinDisabled. cmd/server/main.go constructs one when neither
|
||||
// PALIADIN_REMOTE_HOST is set nor a local tmux is available; without
|
||||
// the stub, the handler would have to nil-check on every entry point.
|
||||
type DisabledPaliadinService struct {
|
||||
paliadinDB
|
||||
}
|
||||
|
||||
// NewDisabledPaliadinService wires the stub. DB methods (IsOwner /
|
||||
// ListRecentTurns / Stats) still work; only RunTurn / ResetSession
|
||||
// return ErrPaliadinDisabled.
|
||||
func NewDisabledPaliadinService(db *sqlx.DB, users *UserService) *DisabledPaliadinService {
|
||||
return &DisabledPaliadinService{paliadinDB: paliadinDB{db: db, users: users}}
|
||||
}
|
||||
|
||||
func (s *DisabledPaliadinService) RunTurn(ctx context.Context, req TurnRequest) (*TurnResult, error) {
|
||||
return nil, ErrPaliadinDisabled
|
||||
}
|
||||
|
||||
func (s *DisabledPaliadinService) ResetSession(ctx context.Context, userID uuid.UUID) error {
|
||||
return ErrPaliadinDisabled
|
||||
}
|
||||
|
||||
// Compile-time interface conformance checks — fail the build, not a
|
||||
// runtime test, if a method drifts off any backend.
|
||||
var (
|
||||
_ Paliadin = (*LocalPaliadinService)(nil)
|
||||
_ Paliadin = (*RemotePaliadinService)(nil)
|
||||
_ Paliadin = (*DisabledPaliadinService)(nil)
|
||||
)
|
||||
301
internal/services/paliadin_remote_test.go
Normal file
301
internal/services/paliadin_remote_test.go
Normal file
@@ -0,0 +1,301 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// testSession is the per-user session name we pass into healthGate /
|
||||
// callShim from tests. The shape mirrors what RunTurn would derive for
|
||||
// a real user.
|
||||
const testSession = "paliad-paliadin-deadbeef"
|
||||
|
||||
// Tests for the remote-Paliadin backend. Every test bypasses exec via
|
||||
// the callShimHook field — no real ssh is ever invoked, no DB rows are
|
||||
// written. Tests that would need DB I/O (audit row insert/complete on
|
||||
// RunTurn) are not in scope here; paliad's test suite has no sqlx mock
|
||||
// and the existing paliadin_test.go only covers pure functions.
|
||||
|
||||
func TestNewRemotePaliadinService_Defaults(t *testing.T) {
|
||||
s := NewRemotePaliadinService(nil, nil, RemotePaliadinConfig{
|
||||
SSHHost: "100.99.98.203",
|
||||
// SSHPort + SSHUser intentionally left zero/empty
|
||||
})
|
||||
if s.cfg.SSHPort != 22022 {
|
||||
t.Errorf("SSHPort default = %d; want 22022 (Tailscale-SSH bypass port)", s.cfg.SSHPort)
|
||||
}
|
||||
if s.cfg.SSHUser != "m" {
|
||||
t.Errorf("SSHUser default = %q; want %q", s.cfg.SSHUser, "m")
|
||||
}
|
||||
if s.cfg.SSHHost != "100.99.98.203" {
|
||||
t.Errorf("SSHHost not preserved: %q", s.cfg.SSHHost)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRemotePaliadinService_HonoursOverrides(t *testing.T) {
|
||||
s := NewRemotePaliadinService(nil, nil, RemotePaliadinConfig{
|
||||
SSHHost: "10.0.0.1",
|
||||
SSHPort: 2222,
|
||||
SSHUser: "alice",
|
||||
})
|
||||
if s.cfg.SSHPort != 2222 {
|
||||
t.Errorf("SSHPort override lost: %d", s.cfg.SSHPort)
|
||||
}
|
||||
if s.cfg.SSHUser != "alice" {
|
||||
t.Errorf("SSHUser override lost: %q", s.cfg.SSHUser)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifySSHError(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
err error
|
||||
want string
|
||||
}{
|
||||
{"nil", nil, ""},
|
||||
{"explicit ErrMRiverUnreachable", ErrMRiverUnreachable, "mriver_unreachable"},
|
||||
{"wrapped ErrMRiverUnreachable", fmt.Errorf("foo: %w", ErrMRiverUnreachable), "mriver_unreachable"},
|
||||
{"context deadline", context.DeadlineExceeded, "timeout"},
|
||||
{"shim run-turn timeout (exit 124)", errors.New("ssh run-turn …: exit status 124 (stderr: response timeout)"), "timeout"},
|
||||
{"connection refused", errors.New("ssh health: dial: Connection refused"), "mriver_unreachable"},
|
||||
{"connection timed out", errors.New("ssh health: Connection timed out"), "mriver_unreachable"},
|
||||
{"permission denied", errors.New("ssh: Permission denied (publickey)"), "shim_auth_failed"},
|
||||
{"unknown", errors.New("ssh: some other failure"), "shim_error"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := classifySSHError(c.err)
|
||||
if got != c.want {
|
||||
t.Errorf("classifySSHError(%v) = %q; want %q", c.err, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthGate_CachesOnSuccess(t *testing.T) {
|
||||
var calls int32
|
||||
s := NewRemotePaliadinService(nil, nil, RemotePaliadinConfig{SSHHost: "x"})
|
||||
s.callShimHook = func(ctx context.Context, args ...string) ([]byte, error) {
|
||||
atomic.AddInt32(&calls, 1)
|
||||
if len(args) != 2 || args[0] != "health" || args[1] != testSession {
|
||||
t.Errorf("unexpected callShim args: %v", args)
|
||||
}
|
||||
return []byte("ok\n"), nil
|
||||
}
|
||||
for i := 0; i < 5; i++ {
|
||||
if err := s.healthGate(context.Background(), testSession); err != nil {
|
||||
t.Fatalf("healthGate iteration %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
if got := atomic.LoadInt32(&calls); got != 1 {
|
||||
t.Errorf("expected 1 callShim call (cached); got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthGate_RetriesAfterFailure(t *testing.T) {
|
||||
var calls int32
|
||||
s := NewRemotePaliadinService(nil, nil, RemotePaliadinConfig{SSHHost: "x"})
|
||||
s.callShimHook = func(ctx context.Context, args ...string) ([]byte, error) {
|
||||
atomic.AddInt32(&calls, 1)
|
||||
return nil, errors.New("ssh: Connection refused")
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
err := s.healthGate(context.Background(), testSession)
|
||||
if !errors.Is(err, ErrMRiverUnreachable) {
|
||||
t.Errorf("iteration %d: err %v; want wrapping ErrMRiverUnreachable", i, err)
|
||||
}
|
||||
}
|
||||
// Failed health is NOT cached — every call re-probes.
|
||||
if got := atomic.LoadInt32(&calls); got != 3 {
|
||||
t.Errorf("expected 3 callShim calls (no caching on failure); got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthGate_RejectsUnexpectedReply(t *testing.T) {
|
||||
s := NewRemotePaliadinService(nil, nil, RemotePaliadinConfig{SSHHost: "x"})
|
||||
s.callShimHook = func(ctx context.Context, args ...string) ([]byte, error) {
|
||||
return []byte("not-ok"), nil
|
||||
}
|
||||
err := s.healthGate(context.Background(), testSession)
|
||||
if !errors.Is(err, ErrMRiverUnreachable) {
|
||||
t.Errorf("err = %v; want wrap of ErrMRiverUnreachable for non-ok reply", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthGate_PerSessionCache(t *testing.T) {
|
||||
// Two sessions must each get their own probe — caching is per-key,
|
||||
// not global.
|
||||
var calls int32
|
||||
s := NewRemotePaliadinService(nil, nil, RemotePaliadinConfig{SSHHost: "x"})
|
||||
s.callShimHook = func(ctx context.Context, args ...string) ([]byte, error) {
|
||||
atomic.AddInt32(&calls, 1)
|
||||
return []byte("ok"), nil
|
||||
}
|
||||
if err := s.healthGate(context.Background(), "paliad-paliadin-aaaaaaaa"); err != nil {
|
||||
t.Fatalf("session A first probe: %v", err)
|
||||
}
|
||||
if err := s.healthGate(context.Background(), "paliad-paliadin-bbbbbbbb"); err != nil {
|
||||
t.Fatalf("session B first probe: %v", err)
|
||||
}
|
||||
if err := s.healthGate(context.Background(), "paliad-paliadin-aaaaaaaa"); err != nil {
|
||||
t.Fatalf("session A second probe: %v", err)
|
||||
}
|
||||
if got := atomic.LoadInt32(&calls); got != 2 {
|
||||
t.Errorf("expected 2 callShim calls (1 per session, A reuses cache on 3rd); got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthGate_CacheExpires(t *testing.T) {
|
||||
var calls int32
|
||||
s := NewRemotePaliadinService(nil, nil, RemotePaliadinConfig{SSHHost: "x"})
|
||||
s.callShimHook = func(ctx context.Context, args ...string) ([]byte, error) {
|
||||
atomic.AddInt32(&calls, 1)
|
||||
return []byte("ok"), nil
|
||||
}
|
||||
if err := s.healthGate(context.Background(), testSession); err != nil {
|
||||
t.Fatalf("first probe: %v", err)
|
||||
}
|
||||
// Force the cached timestamp to expire.
|
||||
s.healthMu.Lock()
|
||||
s.health[testSession] = healthCacheEntry{ok: true, checkedAt: time.Now().Add(-11 * time.Second)}
|
||||
s.healthMu.Unlock()
|
||||
if err := s.healthGate(context.Background(), testSession); err != nil {
|
||||
t.Fatalf("second probe (expired cache): %v", err)
|
||||
}
|
||||
if got := atomic.LoadInt32(&calls); got != 2 {
|
||||
t.Errorf("expected 2 callShim calls (cache expired between); got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionNameFor_PerUser(t *testing.T) {
|
||||
s := NewRemotePaliadinService(nil, nil, RemotePaliadinConfig{SSHHost: "x"})
|
||||
a := uuid.MustParse("aaaaaaaa-1111-2222-3333-444444444444")
|
||||
b := uuid.MustParse("bbbbbbbb-1111-2222-3333-444444444444")
|
||||
if got := s.sessionNameFor(a); got != "paliad-paliadin-aaaaaaaa" {
|
||||
t.Errorf("session A = %q; want paliad-paliadin-aaaaaaaa", got)
|
||||
}
|
||||
if got := s.sessionNameFor(b); got != "paliad-paliadin-bbbbbbbb" {
|
||||
t.Errorf("session B = %q; want paliad-paliadin-bbbbbbbb", got)
|
||||
}
|
||||
if s.sessionNameFor(a) == s.sessionNameFor(b) {
|
||||
t.Error("distinct user IDs collapsed to the same session")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionNameFor_HonoursPrefix(t *testing.T) {
|
||||
s := NewRemotePaliadinService(nil, nil, RemotePaliadinConfig{
|
||||
SSHHost: "x",
|
||||
SessionPrefix: "custom",
|
||||
})
|
||||
a := uuid.MustParse("12345678-1111-2222-3333-444444444444")
|
||||
if got := s.sessionNameFor(a); got != "custom-12345678" {
|
||||
t.Errorf("session = %q; want custom-12345678", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetSession_KillsPerUserSession(t *testing.T) {
|
||||
var captured []string
|
||||
s := NewRemotePaliadinService(nil, nil, RemotePaliadinConfig{SSHHost: "x"})
|
||||
s.callShimHook = func(ctx context.Context, args ...string) ([]byte, error) {
|
||||
captured = append([]string(nil), args...)
|
||||
return []byte("ok"), nil
|
||||
}
|
||||
uid := uuid.MustParse("aaaaaaaa-1111-2222-3333-444444444444")
|
||||
if err := s.ResetSession(context.Background(), uid); err != nil {
|
||||
t.Fatalf("ResetSession: %v", err)
|
||||
}
|
||||
want := []string{"reset", "paliad-paliadin-aaaaaaaa"}
|
||||
if len(captured) != 2 || captured[0] != want[0] || captured[1] != want[1] {
|
||||
t.Errorf("callShim args = %v; want %v", captured, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetSession_DropsHealthCache(t *testing.T) {
|
||||
s := NewRemotePaliadinService(nil, nil, RemotePaliadinConfig{SSHHost: "x"})
|
||||
s.callShimHook = func(ctx context.Context, args ...string) ([]byte, error) { return []byte("ok"), nil }
|
||||
uid := uuid.MustParse("aaaaaaaa-1111-2222-3333-444444444444")
|
||||
session := s.sessionNameFor(uid)
|
||||
|
||||
// Warm the cache.
|
||||
if err := s.healthGate(context.Background(), session); err != nil {
|
||||
t.Fatalf("warm: %v", err)
|
||||
}
|
||||
if _, ok := s.health[session]; !ok {
|
||||
t.Fatal("cache should be warm")
|
||||
}
|
||||
|
||||
if err := s.ResetSession(context.Background(), uid); err != nil {
|
||||
t.Fatalf("ResetSession: %v", err)
|
||||
}
|
||||
if _, ok := s.health[session]; ok {
|
||||
t.Error("ResetSession must drop the per-session health cache")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemotePaliadin_ImplementsPaliadin(t *testing.T) {
|
||||
// Compile-time check is in paliadin_remote.go; this test makes the
|
||||
// failure mode obvious if someone accidentally drops a method.
|
||||
var _ Paliadin = (*RemotePaliadinService)(nil)
|
||||
var _ Paliadin = (*LocalPaliadinService)(nil)
|
||||
var _ Paliadin = (*DisabledPaliadinService)(nil)
|
||||
}
|
||||
|
||||
func TestDisabledPaliadinService(t *testing.T) {
|
||||
s := NewDisabledPaliadinService(nil, nil)
|
||||
if _, err := s.RunTurn(context.Background(), TurnRequest{}); !errors.Is(err, ErrPaliadinDisabled) {
|
||||
t.Errorf("RunTurn error = %v; want ErrPaliadinDisabled", err)
|
||||
}
|
||||
if err := s.ResetSession(context.Background(), uuid.Nil); !errors.Is(err, ErrPaliadinDisabled) {
|
||||
t.Errorf("ResetSession error = %v; want ErrPaliadinDisabled", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallShim_SSHArgvShape(t *testing.T) {
|
||||
// Verify the ssh argv we'd construct includes the bypass-port flag,
|
||||
// the key + known_hosts paths, and the verb after `--`. We don't
|
||||
// actually exec ssh — we set callShimHook so callShim never reaches
|
||||
// the exec path; this test just guards the constructor wiring.
|
||||
s := NewRemotePaliadinService(nil, nil, RemotePaliadinConfig{
|
||||
SSHHost: "100.99.98.203",
|
||||
SSHPort: 22022,
|
||||
SSHUser: "m",
|
||||
SSHKeyPath: "/tmp/k",
|
||||
KnownHostsPath: "/tmp/kh",
|
||||
})
|
||||
var captured []string
|
||||
s.callShimHook = func(ctx context.Context, args ...string) ([]byte, error) {
|
||||
captured = append([]string(nil), args...)
|
||||
return []byte("ok"), nil
|
||||
}
|
||||
_, _ = s.callShim(context.Background(), "health")
|
||||
if len(captured) != 1 || captured[0] != "health" {
|
||||
t.Errorf("callShim forwarded args = %v; want [health]", captured)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallShim_StderrSurfacesInError(t *testing.T) {
|
||||
// When the real exec path fails, callShim wraps stderr into the
|
||||
// returned error so classifySSHError can pattern-match. Simulate
|
||||
// that contract via the hook.
|
||||
s := NewRemotePaliadinService(nil, nil, RemotePaliadinConfig{SSHHost: "x"})
|
||||
s.callShimHook = func(ctx context.Context, args ...string) ([]byte, error) {
|
||||
return nil, errors.New("ssh health: exit status 1 (stderr: Permission denied (publickey))")
|
||||
}
|
||||
_, err := s.callShim(context.Background(), "health")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "Permission denied") {
|
||||
t.Errorf("error should preserve stderr: %v", err)
|
||||
}
|
||||
if classifySSHError(err) != "shim_auth_failed" {
|
||||
t.Errorf("classifier should pick up Permission denied; got %q", classifySSHError(err))
|
||||
}
|
||||
}
|
||||
@@ -1203,7 +1203,6 @@ func (s *ProjectService) CardsPreview(ctx context.Context, userID uuid.UUID, pro
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
today := now.Truncate(24 * time.Hour)
|
||||
|
||||
// --- Source 1: upcoming Deadlines (top 3 per project, ascending). ---
|
||||
type rowDeadline struct {
|
||||
@@ -1214,6 +1213,12 @@ func (s *ProjectService) CardsPreview(ctx context.Context, userID uuid.UUID, pro
|
||||
Status string `db:"status"`
|
||||
}
|
||||
var ds []rowDeadline
|
||||
// Include every pending deadline regardless of due_date — overdue
|
||||
// deadlines are MORE urgent than upcoming ones, not less, so a card
|
||||
// labelled "Nächste Termine" must surface them first. Sort ASC so the
|
||||
// most-overdue lands at the top, naturally followed by today / soon
|
||||
// (m, 2026-05-08 15:02 — "5 offen" was visible but Nächste Termine
|
||||
// stayed empty because the >= today filter dropped overdue pending).
|
||||
dq := `
|
||||
WITH visible AS (
|
||||
SELECT p.id FROM paliad.projects p
|
||||
@@ -1226,18 +1231,18 @@ func (s *ProjectService) CardsPreview(ctx context.Context, userID uuid.UUID, pro
|
||||
) AS rn
|
||||
FROM paliad.deadlines f
|
||||
JOIN visible v ON v.id = f.project_id
|
||||
WHERE f.status = 'pending' AND f.due_date >= $%d::date
|
||||
WHERE f.status = 'pending'
|
||||
)
|
||||
SELECT project_id, id, title, due_date, status
|
||||
FROM ranked WHERE rn <= 3
|
||||
`
|
||||
dq = fmt.Sprintf(dq, len(args)+1)
|
||||
args = append(args, today)
|
||||
if err := s.db.SelectContext(ctx, &ds, dq, args...); err != nil {
|
||||
return nil, fmt.Errorf("cards preview deadlines: %w", err)
|
||||
}
|
||||
|
||||
// --- Source 2: upcoming Appointments (top 3 per project, ascending). ---
|
||||
// Past appointments stay excluded (they're history, not "next") —
|
||||
// unlike deadlines where overdue-pending is more urgent than upcoming.
|
||||
type rowAppointment struct {
|
||||
ProjectID uuid.UUID `db:"project_id"`
|
||||
ID uuid.UUID `db:"id"`
|
||||
@@ -1262,10 +1267,9 @@ func (s *ProjectService) CardsPreview(ctx context.Context, userID uuid.UUID, pro
|
||||
SELECT project_id, id, title, starts_at
|
||||
FROM ranked WHERE rn <= 3
|
||||
`
|
||||
// args already has [userID, projectIDs?, today]; reuse $%d for now.
|
||||
aArgs := make([]any, len(args))
|
||||
copy(aArgs, args)
|
||||
aArgs[len(aArgs)-1] = now // last arg is the temporal bound
|
||||
aArgs := make([]any, 0, len(args)+1)
|
||||
aArgs = append(aArgs, args...)
|
||||
aArgs = append(aArgs, now)
|
||||
aq = fmt.Sprintf(aq, len(aArgs))
|
||||
if err := s.db.SelectContext(ctx, &as, aq, aArgs...); err != nil {
|
||||
return nil, fmt.Errorf("cards preview appointments: %w", err)
|
||||
|
||||
37
scripts/install-paliadin-skill
Executable file
37
scripts/install-paliadin-skill
Executable file
@@ -0,0 +1,37 @@
|
||||
#!/bin/bash
|
||||
# install-paliadin-skill — copy the Paliadin skill into the local Claude
|
||||
# Code config so the long-lived `claude` pane on this host picks it up.
|
||||
#
|
||||
# Run on every host that hosts a Paliadin tmux session — that means:
|
||||
# - mRiver (m's laptop, the prod target reached via SSH from paliad.de)
|
||||
# - any laptop running paliad's LocalPaliadinService directly
|
||||
#
|
||||
# The skill at ~/.claude/skills/paliadin/SKILL.md is what teaches Claude
|
||||
# to react to `[PALIADIN:<uuid>]` envelopes by writing the response to
|
||||
# /tmp/paliadin/<uuid>.txt. It survives /clear and fresh sessions because
|
||||
# Claude's skill router auto-matches by description, not by an in-memory
|
||||
# system prompt.
|
||||
#
|
||||
# Idempotent — re-running after a repo update is the supported way to
|
||||
# refresh the skill on a host.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
src_dir="$(cd "$(dirname "$0")/skills/paliadin" && pwd)"
|
||||
dst_dir="${CLAUDE_SKILLS_DIR:-$HOME/.claude/skills}/paliadin"
|
||||
|
||||
if [[ ! -f "$src_dir/SKILL.md" ]]; then
|
||||
echo "install-paliadin-skill: missing $src_dir/SKILL.md" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$dst_dir"
|
||||
# Mirror the entire skill tree (SKILL.md + references/), and clear out
|
||||
# any stale auxiliary files left from a previous shape.
|
||||
rm -rf "$dst_dir/references"
|
||||
cp "$src_dir/SKILL.md" "$dst_dir/SKILL.md"
|
||||
if [[ -d "$src_dir/references" ]]; then
|
||||
cp -R "$src_dir/references" "$dst_dir/references"
|
||||
fi
|
||||
echo "installed: $dst_dir/"
|
||||
find "$dst_dir" -type f -printf ' %P\n'
|
||||
220
scripts/paliadin-shim
Executable file
220
scripts/paliadin-shim
Executable file
@@ -0,0 +1,220 @@
|
||||
#!/bin/bash
|
||||
# paliadin-shim — server-side RPC for paliad's remote-tmux turns.
|
||||
#
|
||||
# Invoked via mRiver's ~/.ssh/authorized_keys command= restriction. The
|
||||
# client's requested command is exposed in $SSH_ORIGINAL_COMMAND; this
|
||||
# script parses it and dispatches to a fixed verb set.
|
||||
#
|
||||
# Design: docs/design-paliadin-tailscale-ssh-2026-05-07.md §5.4 +
|
||||
# t-paliad-155 (per-user session keying + skill-based persona).
|
||||
#
|
||||
# Verbs (every verb takes the tmux session name as the first positional
|
||||
# argument; per-user sessions are created on demand):
|
||||
#
|
||||
# health <session> -> "ok" iff tmux + claude reachable
|
||||
# run-turn <session> <uuid> <msg-base64> -> send framed prompt, poll, return
|
||||
# reset <session> -> kill the session entirely
|
||||
#
|
||||
# The persona + response protocol live in the Paliadin skill at
|
||||
# ~/.claude/skills/paliadin/SKILL.md (see scripts/skills/paliadin/SKILL.md
|
||||
# in the repo). Claude's skill router auto-matches the [PALIADIN:<uuid>]
|
||||
# envelope and writes the response to /tmp/paliadin/<uuid>.txt — that is
|
||||
# the contract this shim polls on. There is no longer a bootstrap step.
|
||||
#
|
||||
# All multi-character payloads (messages) are base64-encoded by the Go
|
||||
# caller so we never have to quote them through ssh's argv.
|
||||
#
|
||||
# Errors go to stderr with a non-zero exit. The Go side maps the exit
|
||||
# status into a friendly error code.
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
|
||||
readonly RESPONSE_DIR="${PALIADIN_RESPONSE_DIR:-/tmp/paliadin}"
|
||||
readonly TIMEOUT_S="${PALIADIN_TIMEOUT_S:-120}"
|
||||
# Working directory for the claude pane. Must be the paliad repo root so
|
||||
# claude picks up .mcp.json (project-scoped Supabase MCP) — without it,
|
||||
# the SKILL.md SQL recipes fail with no DB tool. Override via env var if
|
||||
# the repo lives elsewhere on this host.
|
||||
readonly CLAUDE_CWD="${PALIADIN_REMOTE_CWD:-/home/m/dev/paliad}"
|
||||
readonly PANE_READY_S=60 # max wait for claude pane to settle
|
||||
readonly TURN_ID_RE='^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'
|
||||
# Session names are constructed by the Go side as `paliad-paliadin-<userid8>`;
|
||||
# allow the same shape m might dial by hand. Stays defensive against shell
|
||||
# metacharacters since this string is interpolated into tmux targets.
|
||||
readonly SESSION_RE='^[A-Za-z0-9_.-]{1,64}$'
|
||||
|
||||
mkdir -p "$RESPONSE_DIR"
|
||||
chmod 700 "$RESPONSE_DIR"
|
||||
|
||||
# Parse $SSH_ORIGINAL_COMMAND into argv. Format: "<verb> <arg1> <arg2> …".
|
||||
# We never `eval` this; `read -r -a` splits on $IFS without word-expansion.
|
||||
read -r -a argv <<< "${SSH_ORIGINAL_COMMAND:-}"
|
||||
verb="${argv[0]:-}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
log_err() { printf 'paliadin-shim: %s\n' "$*" >&2; }
|
||||
|
||||
# require_session validates argv[1] as a tmux session name. Echoes the
|
||||
# validated name on success; logs + exits on failure.
|
||||
require_session() {
|
||||
local s="${argv[1]:-}"
|
||||
if [[ -z "$s" ]]; then
|
||||
log_err "$verb: missing session name"; exit 2
|
||||
fi
|
||||
if [[ ! "$s" =~ $SESSION_RE ]]; then
|
||||
log_err "$verb: invalid session name"; exit 2
|
||||
fi
|
||||
printf '%s' "$s"
|
||||
}
|
||||
|
||||
# ensure_pane creates the named tmux session + claude window if missing,
|
||||
# waits for the pane to become ready, and prints the target identifier
|
||||
# ("session:window-idx") on stdout.
|
||||
#
|
||||
# Per-user sessions are independently namespaced inside tmux; multiple
|
||||
# paliad-paliadin-* sessions can coexist on mRiver without interfering.
|
||||
ensure_pane() {
|
||||
local session="$1"
|
||||
|
||||
if ! tmux has-session -t "$session" 2>/dev/null; then
|
||||
tmux new-session -d -s "$session"
|
||||
fi
|
||||
|
||||
# Look for an existing window tagged with @paliadin-scope=chat.
|
||||
local target=""
|
||||
local idx scope
|
||||
while read -r idx; do
|
||||
[[ -z "$idx" ]] && continue
|
||||
scope=$(tmux show-window-option -t "$session:$idx" -v @paliadin-scope 2>/dev/null || true)
|
||||
if [[ "$scope" == "chat" ]]; then
|
||||
target="$session:$idx"
|
||||
break
|
||||
fi
|
||||
done < <(tmux list-windows -t "$session" -F '#{window_index}' 2>/dev/null || true)
|
||||
|
||||
if [[ -z "$target" ]]; then
|
||||
if ! command -v claude >/dev/null 2>&1; then
|
||||
log_err "claude CLI not found in PATH"
|
||||
exit 3
|
||||
fi
|
||||
if [[ ! -d "$CLAUDE_CWD" ]]; then
|
||||
log_err "claude cwd $CLAUDE_CWD does not exist — set PALIADIN_REMOTE_CWD"
|
||||
exit 3
|
||||
fi
|
||||
idx=$(tmux new-window -c "$CLAUDE_CWD" -t "$session" -n claude-paliadin -P -F '#{window_index}' claude)
|
||||
target="$session:$idx"
|
||||
|
||||
# Wait for claude to settle. Matches Go waitForPaneReady (paliadin.go).
|
||||
local deadline=$(( $(date +%s) + PANE_READY_S ))
|
||||
local pane=""
|
||||
while [[ $(date +%s) -lt $deadline ]]; do
|
||||
pane=$(tmux capture-pane -t "$target" -p 2>/dev/null || true)
|
||||
if [[ "$pane" == *"❯"* || "$pane" == *"│"* ]]; then
|
||||
break
|
||||
fi
|
||||
sleep 0.5
|
||||
done
|
||||
|
||||
tmux set-window-option -t "$target" @paliadin-scope chat >/dev/null
|
||||
tmux set-window-option -t "$target" @fix-name claude-paliadin >/dev/null
|
||||
fi
|
||||
|
||||
printf '%s' "$target"
|
||||
}
|
||||
|
||||
# send_to_pane writes a literal string then Enter.
|
||||
send_to_pane() {
|
||||
local target="$1" msg="$2"
|
||||
tmux send-keys -t "$target" -l -- "$msg"
|
||||
tmux send-keys -t "$target" Enter
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# verb dispatch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
case "$verb" in
|
||||
|
||||
health)
|
||||
# Used by the Go side's healthGate to short-circuit when mRiver is
|
||||
# offline or tmux/claude is broken. Output is parsed verbatim.
|
||||
# Session is required (per-user) but health is *not* expected to
|
||||
# spin up the claude pane — only validates tooling + that we could
|
||||
# in principle create the session.
|
||||
session=$(require_session)
|
||||
if ! command -v tmux >/dev/null 2>&1; then
|
||||
log_err "tmux not in PATH"; exit 1
|
||||
fi
|
||||
if ! command -v claude >/dev/null 2>&1; then
|
||||
log_err "claude not in PATH"; exit 1
|
||||
fi
|
||||
if ! tmux has-session -t "$session" 2>/dev/null; then
|
||||
tmux new-session -d -s "$session"
|
||||
fi
|
||||
echo ok
|
||||
;;
|
||||
|
||||
run-turn)
|
||||
# $1 = session, $2 = turn_id (UUID), $3 = base64-encoded user message.
|
||||
session=$(require_session)
|
||||
turn_id="${argv[2]:-}"
|
||||
if [[ ! "$turn_id" =~ $TURN_ID_RE ]]; then
|
||||
log_err "run-turn: bad turn_id"; exit 2
|
||||
fi
|
||||
if [[ -z "${argv[3]:-}" ]]; then
|
||||
log_err "run-turn: missing message"; exit 2
|
||||
fi
|
||||
if ! msg=$(printf '%s' "${argv[3]}" | base64 -d 2>/dev/null); then
|
||||
log_err "run-turn: invalid base64 message"; exit 2
|
||||
fi
|
||||
target=$(ensure_pane "$session")
|
||||
out="$RESPONSE_DIR/$turn_id.txt"
|
||||
rm -f "$out"
|
||||
|
||||
# Envelope. The Paliadin skill (~/.claude/skills/paliadin/SKILL.md)
|
||||
# description-matches on this exact prefix, so Claude routes to the
|
||||
# skill on every turn regardless of conversation state — surviving
|
||||
# /clear, fresh sessions, and pane restarts.
|
||||
send_to_pane "$target" "[PALIADIN:$turn_id] $msg"
|
||||
|
||||
# Poll for the response file. Same shape as Go pollForResponse
|
||||
# (paliadin.go). Settle delay so we don't read mid-flush.
|
||||
deadline=$(( $(date +%s) + TIMEOUT_S ))
|
||||
while [[ $(date +%s) -lt $deadline ]]; do
|
||||
if [[ -s "$out" ]]; then
|
||||
sleep 0.05
|
||||
cat "$out"
|
||||
rm -f "$out"
|
||||
exit 0
|
||||
fi
|
||||
sleep 0.2
|
||||
done
|
||||
log_err "response timeout after ${TIMEOUT_S}s"
|
||||
exit 124
|
||||
;;
|
||||
|
||||
reset)
|
||||
# Kill the user's session entirely so the next run-turn boots a
|
||||
# fresh claude pane. With skill-based persona load, /clear would
|
||||
# also work — but kill-session is simpler and removes any chance
|
||||
# of leftover conversation state confusing the next turn.
|
||||
session=$(require_session)
|
||||
if tmux has-session -t "$session" 2>/dev/null; then
|
||||
tmux kill-session -t "$session"
|
||||
fi
|
||||
echo ok
|
||||
;;
|
||||
|
||||
'')
|
||||
log_err "no verb (set SSH_ORIGINAL_COMMAND via authorized_keys command=)"
|
||||
exit 2
|
||||
;;
|
||||
|
||||
*)
|
||||
log_err "unknown verb '$verb'"
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
97
scripts/skills/paliadin/SKILL.md
Normal file
97
scripts/skills/paliadin/SKILL.md
Normal file
@@ -0,0 +1,97 @@
|
||||
---
|
||||
name: paliadin
|
||||
description: Use this skill whenever a user message arrives prefixed with `[PALIADIN:<uuid>]` — that prefix means the request comes from the Paliad backend and a Markdown answer must be written to `/tmp/paliadin/<uuid>.txt` (with a `[paliadin-meta]` trailer) so the polling Go service can return it to the user. Trigger on the literal `[PALIADIN:` prefix, even when m's question is short ("Hey", "wer bin ich?") and looks like normal chat — the prefix is the contract, not the question content. Persona: m's Patentpraxis-Plattform-Assistent — terse, juristisch präzise German, no emojis, every concrete claim backed by a tool-call.
|
||||
---
|
||||
|
||||
# Paliadin
|
||||
|
||||
You are the in-app AI assistant inside **Paliad**, m's Patentpraxis-Plattform für HLC-Kollegen. You help with daily patent-practice work: Akten finden, Fristen prüfen, Begriffe erklären, Gerichte nachschlagen, UPC-Rechtsprechung recherchieren.
|
||||
|
||||
## Quick start — one turn
|
||||
|
||||
Every Paliad request looks like:
|
||||
|
||||
```
|
||||
[PALIADIN:<turn_id>] <Frage>
|
||||
```
|
||||
|
||||
Per turn:
|
||||
|
||||
1. **Extract `<turn_id>`** from the prefix.
|
||||
2. **Research** with tools (max 1–3 calls — backend timeout is 60s). See [references/sql-recipes.md](references/sql-recipes.md) **before any project/deadline/court/glossary/UPC lookup**.
|
||||
3. **Write the file** with `Write("/tmp/paliadin/<turn_id>.txt", …)` containing the Markdown answer + `[paliadin-meta]` trailer.
|
||||
4. (Optional) one-line echo in the chat pane (`done`). The backend reads only the file.
|
||||
|
||||
> Skip every greeting / preamble in the chat pane. The file is the user-visible artefact; everything else is irrelevant.
|
||||
|
||||
## Persona
|
||||
|
||||
- Direkt, kompetent, juristisch präzise — wie ein Patentanwalts-Kollege mit zehn Jahren UPC-Erfahrung.
|
||||
- Default Deutsch (m's Arbeitssprache); auf englische Frage englisch antworten.
|
||||
- Keine Floskeln, keine Emojis, kein "Ich helfe dir gerne!".
|
||||
|
||||
## Response-file format
|
||||
|
||||
```
|
||||
<Markdown-Antwort>
|
||||
|
||||
---
|
||||
[paliadin-meta]
|
||||
used_tools: <komma-separierte Tool-Namen, leer wenn keiner>
|
||||
rows_seen: <komma-separierte Zeilen-Counts, parallel zu used_tools>
|
||||
classifier_tag: <data | concept | navigation | meta | other>
|
||||
[/paliadin-meta]
|
||||
```
|
||||
|
||||
`classifier_tag` — pick one:
|
||||
|
||||
| Wert | Wann |
|
||||
|---|---|
|
||||
| `data` | m fragt nach seinen eigenen Daten ("welche Frist…") |
|
||||
| `concept` | juristischer Begriff/Verfahren ("was ist Klageerwiderung?") |
|
||||
| `navigation` | Paliad-Seite/Funktion suchen ("wie öffne ich…") |
|
||||
| `meta` | Frage über Paliadin selbst, oder Smalltalk |
|
||||
| `other` | Web-Wissen, sonstige Recherche |
|
||||
|
||||
`used_tools` und `rows_seen` müssen parallel sein (Tool-N → Rows-N). Beide leer, wenn kein Tool benutzt.
|
||||
|
||||
## Action-Chips (optional)
|
||||
|
||||
Direkt im Antworttext einbetten — Paliad-Frontend rendert sie als Buttons:
|
||||
|
||||
- `[#deadline-OPEN:<id>]` — öffnet Fristen-Detail
|
||||
- `[#projekt-OPEN:<slug>]` — öffnet Projekt-Detail
|
||||
- `[chip:nav:/projects/abc-123]` — beliebige Navigation
|
||||
- `[chip:filter:status=pending&due=this_week]` — gefilterter Inbox-Link
|
||||
|
||||
Nur IDs/Slugs benutzen, die du tatsächlich aus einem Tool-Call hast. **Niemals erfinden.**
|
||||
|
||||
## Hard rules
|
||||
|
||||
1. **Keine Erfindungen.** Liefert ein Tool nichts, sag das. Niemals Aktenzeichen, Daten, Gerichts- oder Parteinamen erfinden.
|
||||
2. **Jede konkrete Aussage über m's Arbeit MUSS aus einem Tool-Call der aktuellen Antwort kommen.** Erinnerung an frühere Gespräche reicht nicht — Daten ändern sich.
|
||||
3. **Read-only.** Schreibe nichts in die DB. Wenn m etwas ändern will, sag wo in Paliad.
|
||||
4. **Visibility-Gate respektieren.** Auch wenn m global_admin ist: jede projekt-bezogene Abfrage MUSS `paliad.can_see_project(project_id)` enthalten.
|
||||
5. **Nicht über andere User spekulieren** — frag nach Projekt-ID/Slug, selbst wenn m sie namentlich erwähnt.
|
||||
6. **Niemals auf `psql`, `curl PostgREST`, `nix-shell` oder andere DB-Fallbacks ausweichen.** Die einzig zulässige DB-Quelle ist `mcp__supabase__execute_sql` (project-scoped MCP). Wenn dieser Tool-Aufruf nicht verfügbar ist, schreibe sofort: *"DB nicht erreichbar — bitte paliad neu deployen oder PALIADIN_REMOTE_CWD prüfen."* mit `classifier_tag: meta`. Niemals 60+ Sekunden im Fallback-Tanz verbringen — der Backend-Timeout schlägt sonst zu, bevor du eine Antwort schreibst.
|
||||
|
||||
## Beispiel — vollständige Antwortdatei
|
||||
|
||||
```
|
||||
Diese Woche stehen 3 Fristen an:
|
||||
|
||||
- **16.05.** Klageerwiderung Müller v. Acme [#deadline-OPEN:c47bd2-1] — UPC LD München
|
||||
- **17.05.** Replik BMW v. Daimler [#deadline-OPEN:e92a01-3]
|
||||
- **20.05.** Wiedereinsetzung Bosch-Patent [#deadline-OPEN:f31b09-7]
|
||||
|
||||
---
|
||||
[paliadin-meta]
|
||||
used_tools: search_my_deadlines
|
||||
rows_seen: 3
|
||||
classifier_tag: data
|
||||
[/paliadin-meta]
|
||||
```
|
||||
|
||||
## Allererste Anfrage einer Session
|
||||
|
||||
Eine kurze Vorstellung in der **Antwort-Datei** ist erlaubt ("Hi m, ich bin Paliadin — bereit."), nie statt der Datei. Ab Turn 2 normaler Modus.
|
||||
134
scripts/skills/paliadin/references/sql-recipes.md
Normal file
134
scripts/skills/paliadin/references/sql-recipes.md
Normal file
@@ -0,0 +1,134 @@
|
||||
# SQL recipes — Paliadin tool catalogue
|
||||
|
||||
Read this file **before any project / deadline / appointment / court / glossary / deadline-rule / UPC-judgment lookup**. Every query goes through the Supabase MCP via `mcp__supabase__execute_sql`. Two schemas in the same physical DB:
|
||||
|
||||
- `paliad.*` — Patentpraxis-Daten (projects, deadlines, appointments, parties, courts, deadline_rules, users)
|
||||
- `data.*` — youpc.org UPC case law (judgments, headnotes, knowledge graph)
|
||||
|
||||
Every project-scoped query MUST include `paliad.can_see_project(project_id)` — even when m is global_admin (see SKILL.md rule 4).
|
||||
|
||||
## 1. whats_on_my_plate — Dashboard-Übersicht
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
(SELECT count(*) FROM paliad.deadlines d
|
||||
WHERE paliad.can_see_project(d.project_id)
|
||||
AND d.status = 'pending' AND d.due_date < current_date) AS overdue,
|
||||
(SELECT count(*) FROM paliad.deadlines d
|
||||
WHERE paliad.can_see_project(d.project_id)
|
||||
AND d.status = 'pending' AND d.due_date = current_date) AS today,
|
||||
(SELECT count(*) FROM paliad.deadlines d
|
||||
WHERE paliad.can_see_project(d.project_id)
|
||||
AND d.status = 'pending'
|
||||
AND d.due_date BETWEEN current_date AND current_date + 7) AS this_week,
|
||||
(SELECT count(*) FROM paliad.appointments a
|
||||
WHERE (a.project_id IS NULL OR paliad.can_see_project(a.project_id))
|
||||
AND a.start_at::date = current_date) AS appointments_today;
|
||||
```
|
||||
|
||||
## 2. list_my_projects
|
||||
|
||||
```sql
|
||||
SELECT id, kind, label, status, parent_id, path
|
||||
FROM paliad.projects
|
||||
WHERE paliad.can_see_project(id)
|
||||
AND status = 'active'
|
||||
ORDER BY path
|
||||
LIMIT 25;
|
||||
```
|
||||
|
||||
## 3. get_project_detail (per slug oder id)
|
||||
|
||||
```sql
|
||||
SELECT p.*,
|
||||
(SELECT json_agg(d ORDER BY d.due_date)
|
||||
FROM paliad.deadlines d WHERE d.project_id = p.id
|
||||
AND paliad.can_see_project(d.project_id)) AS deadlines,
|
||||
(SELECT json_agg(a ORDER BY a.start_at)
|
||||
FROM paliad.appointments a WHERE a.project_id = p.id
|
||||
AND paliad.can_see_project(a.project_id)) AS appointments,
|
||||
(SELECT json_agg(pa) FROM paliad.parties pa WHERE pa.project_id = p.id) AS parties
|
||||
FROM paliad.projects p
|
||||
WHERE paliad.can_see_project(p.id)
|
||||
AND (p.id::text = '<UUID>' OR p.slug = '<slug>')
|
||||
LIMIT 1;
|
||||
```
|
||||
|
||||
## 4. search_my_deadlines (status / Datum / Projekt)
|
||||
|
||||
```sql
|
||||
SELECT d.id, d.title, d.due_date, d.status, p.label AS project_label, d.event_id
|
||||
FROM paliad.deadlines d
|
||||
JOIN paliad.projects p ON p.id = d.project_id
|
||||
WHERE paliad.can_see_project(d.project_id)
|
||||
AND ($status::text IS NULL OR d.status = $status)
|
||||
AND ($due_after::date IS NULL OR d.due_date >= $due_after)
|
||||
AND ($due_before::date IS NULL OR d.due_date <= $due_before)
|
||||
ORDER BY d.due_date ASC
|
||||
LIMIT 25;
|
||||
```
|
||||
|
||||
## 5. list_my_appointments (Zeitfenster)
|
||||
|
||||
```sql
|
||||
SELECT a.id, a.title, a.start_at, a.end_at, a.location, p.label AS project_label
|
||||
FROM paliad.appointments a
|
||||
LEFT JOIN paliad.projects p ON p.id = a.project_id
|
||||
WHERE (a.project_id IS NULL OR paliad.can_see_project(a.project_id))
|
||||
AND a.start_at >= $from
|
||||
AND a.start_at <= $to
|
||||
ORDER BY a.start_at ASC
|
||||
LIMIT 25;
|
||||
```
|
||||
|
||||
## 6. lookup_court (firm-wide reference)
|
||||
|
||||
```sql
|
||||
SELECT c.slug, c.name, c.country, c.kind, c.address
|
||||
FROM paliad.courts c
|
||||
WHERE c.name ILIKE '%' || $q || '%'
|
||||
OR c.slug ILIKE '%' || $q || '%'
|
||||
ORDER BY similarity(c.name, $q) DESC
|
||||
LIMIT 10;
|
||||
```
|
||||
|
||||
## 7. lookup_deadline_rule (Fristenrechner-Konzepte)
|
||||
|
||||
```sql
|
||||
SELECT r.rule_code, r.concept_label, r.trigger_event, r.deadline_text,
|
||||
r.deadline_text_en, r.legal_source, r.deadline_notes, r.deadline_notes_en
|
||||
FROM paliad.deadline_rules r
|
||||
WHERE r.concept_label ILIKE '%' || $q || '%'
|
||||
OR r.rule_code ILIKE '%' || $q || '%'
|
||||
OR r.legal_source ILIKE '%' || $q || '%'
|
||||
ORDER BY similarity(r.concept_label, $q) DESC
|
||||
LIMIT 5;
|
||||
```
|
||||
|
||||
## 8. lookup_youpc_case (UPC-Rechtsprechung — cross-schema)
|
||||
|
||||
```sql
|
||||
SELECT j.node_id, j.upc_number, j.court_division, j.judgment_type,
|
||||
j.proceedings_type, j.decision_date, j.headnote_summary,
|
||||
j.tags
|
||||
FROM data.judgments j
|
||||
WHERE j.upc_number ILIKE '%' || $q || '%'
|
||||
OR j.headnote_summary ILIKE '%' || $q || '%'
|
||||
OR j.tags::text ILIKE '%' || $q || '%'
|
||||
ORDER BY j.decision_date DESC
|
||||
LIMIT 5;
|
||||
```
|
||||
|
||||
Volltext eines Urteils (wenn m fragt "was steht in dem Urteil?"):
|
||||
|
||||
```sql
|
||||
SELECT content
|
||||
FROM data.judgment_markdown_content
|
||||
WHERE judgment_node_id = <node_id>
|
||||
ORDER BY chunk_index
|
||||
LIMIT 1;
|
||||
```
|
||||
|
||||
## Glossar — keine SQL-Tabelle
|
||||
|
||||
Der Patent-Glossar lebt statisch in `internal/handlers/glossary.go` (JSON beim Boot geladen). Für reine Begriffsfragen reicht dein Wissen + optional Cross-Check via `paliad.deadline_rules.legal_source`.
|
||||
Reference in New Issue
Block a user