Compare commits

...

30 Commits

Author SHA1 Message Date
m
7be8511833 docs(t-paliad-158): deadline data model — proceedings-as-DAG analysis
Consultant analysis of paliad's deadline data model per m's framing
(court system → proceeding → ordered event types → conditional
trigger edges). Maps current 5-table fragmentation, identifies gaps
G1–G7, locks 5 structural decisions via AskUserQuestion, proposes
target shape with mermaid example, sketches 4-phase additive→cutover
migration. Pure design — no code or schema changes in this branch.

Locked decisions (verbatim):
- Q1: Reuse courts.court_type as court-system identity
- Q2: Project IS proceeding instance (sub-projects when needed)
- Q3: Separate proceeding_event_edges table (multi-parent natural)
- Q4: Typed if_flags/unless_flags/requires_event_id columns
- Q5: Subsume deadline_concepts into event_types.concept_slug
2026-05-08 16:27:04 +02:00
m
7daa70aaad feat(paliadin/projects-cards): markdown rendering + grid compactness + overdue-pending in NextEvents
Three issues from m's dogfood (2026-05-08 15:02–15:14):

## A. /projects-cards on desktop overflowed the right column

.projects-cards-grid.is-grid-2 used grid-template-columns: repeat(2, 1fr)
which is shorthand for repeat(2, minmax(auto, 1fr)). 'auto' resolves to
max-content so any card with content wider than the track expands the
track and pushes the right column past the parent's right edge.

Switched is-grid-2/3/4 to repeat(N, minmax(0, 1fr)) which clamps the
floor to zero — overflow now wraps/clips inside the card instead of
blowing out the layout. Bonus: the auto-fill default also got the
min(320px, 100%) treatment so narrow viewports collapse the floor and
spare us horizontal scroll on mobile (mirrors t-paliad-155's earlier
views-cards fix).

## B. "Nächste Termine" empty while "5 offen" showed

CardsPreview's deadline source filtered WHERE f.status = 'pending'
AND f.due_date >= today::date. m's 5 pending deadlines are all in the
past — overdue — so they were excluded from NextEvents while still
counted in the "X offen" badge.

Dropped the >= today predicate. Now any pending deadline lands in
NextEvents, sorted ASC by due_date, so most-overdue surfaces first
(which matches m's mental model: an overdue Frist is more urgent than
tomorrow's, not less). Appointments keep the >= now filter (past
appointments are history, not next). Cleaned up the args[] threading
since deadlines no longer needs the temporal bound.

## C. Chat bubbles ignored Markdown formatting (## h2, **bold**, lists)

renderResponseHTML only handled chip markers + the new (today)
markdown-link / bare-URL passes; everything else fell through as raw
text. "## Projekte" rendered with the literal hashes visible.

Added renderBlocks() — a small block-level parser that turns:

-  → <h2>H</h2>
-  → <h3>H</h3>
-  lines → <ul class=paliadin-list><li>...</li></ul>
-  → <hr>
- blank-line-separated runs → <p>...<br>...</p>

and inline emphasis passes that wrap **bold** in <strong> and *italic*
in <em>. Block-level runs before the link passes so the regexes only
operate inside a block; emphasis runs after links so a bold link works.
Pipeline is still: escape → chip-stage → blocks → md-links → bare-urls
→ emphasis → unstage chips.

## D. (carrying over from earlier in this commit) /admin/paliadin monitor — show user + response preview + page origin + per-tool row counts
2026-05-08 15:09:24 +02:00
m
05d14d5e5a feat(admin/paliadin): show user + response preview + page origin + per-tool row counts on the monitor
m's ask (2026-05-08 15:02): the Paliadin monitor should show which user
made each turn, and ideally log more than just timing/classifier.

Backend:
- PaliadinTurn gains UserEmail + UserDisplayName fields (json:omitempty
  so user-facing API paths don't leak unrelated identity info; only
  populated by the admin LIST query).
- ListRecentTurns LEFT JOINs paliad.users to surface email +
  display_name on each row. The existing global_admin OR caller-owns
  visibility predicate on the WHERE clause stays unchanged.

Frontend (admin-paliadin):
- Recent-turns table grows from 5 → 8 columns:
  Zeit · Nutzer · Art · Anfrage · Antwort · Tools · Seite · Dauer
- Nutzer cell shows display_name (fallback email, fallback first 8 of
  user_id), with the full email in the title attribute on hover.
- Antwort cell renders the first 80 chars of the response with the full
  cleanBody available on hover. Useful for spot-checking what Paliadin
  actually wrote without clicking through every turn.
- Tools cell now pairs each tool name with its rows_seen count
  ("list_my_projects (11), search_my_deadlines (18)") so the data
  density is legible at a glance.
- Seite cell exposes page_origin (where in Paliad m kicked off the
  turn) — was already audited but never surfaced.
- DE/EN i18n keys added for the four new column headers.
2026-05-08 15:05:24 +02:00
m
925a377c8b feat(paliadin): markdown links + auto-link bare URLs in chat responses
m's ask (2026-05-08 14:18): chat should render arbitrary links, not
just internal navigation chips.

Extends renderResponseHTML with two link passes after the existing chip
substitution:

1. Markdown link syntax — [label](url) becomes <a class=paliadin-link>.
   Internal /paths stay same-tab; external http(s) URLs open in a new tab
   with rel=noopener,noreferrer.

2. Auto-linkify bare URLs — any free-standing https?:// becomes a link.
   The leading-character class on the regex avoids re-matching URLs that
   are already inside an href attribute (like the chip URLs from stage 1
   or the markdown-link URLs from stage 2).

Pipeline order: HTML-escape → chip markers replaced with SOH-bounded
sentinels → markdown links → bare URLs → sentinels swapped back. Done in
that order so chip URLs never go through the link passes (which would
double-anchor them) and the SOH boundary characters can't collide with
user text.

fix(views/cards): collapse min-width floor on mobile to prevent overflow

m's report (2026-05-08): on mobile-portrait the views-cards layout
forced horizontal scrolling because grid-template-columns had a 280px
floor on every column. Replaced minmax(280px, 1fr) with
minmax(min(280px, 100%), 1fr) so on viewports narrower than 280px the
floor collapses to the available width — cards span 100% of the stream
on mobile, return to the 280px-min auto-fill once there's room.
2026-05-08 14:22:18 +02:00
m
7935fee7bf fix(paliadin): preserve chip/link markers when saving chat history
When a Paliadin response contains chip markers like [#deadline-OPEN:c47bd2-1]
they get rendered to anchor tags by renderResponseHTML in finishBubble. The
'end' handler then saved the bubble to localStorage history via getBubbleText,
which returns textContent — i.e. the anchor text *only*, with the original
[#deadline-OPEN:...] markers gone.

On a page reload, history.forEach replays each entry: appendBubble puts h.text
back into textContent, then finishBubble runs again and tries to re-render via
renderResponseHTML — but the markers are already gone, so the links don't come
back (m, 2026-05-08 14:11 — links disappeared on second load).

Fix: save placeholder.dataset.fullText (the raw Markdown body cached at
content-event time) instead of the post-render textContent. On reload the raw
markers survive, finishBubble re-runs renderResponseHTML, and the chips/links
reappear identically to the first render.

Refs t-paliad-155, m/paliad#12.
2026-05-08 14:12:42 +02:00
m
be2150c17d fix(paliadin): used_tools NOT NULL violation + frontend response truncation
Two bugs surfaced in m's dogfood of t-paliad-155 (2026-05-08 13:55).

## A. used_tools NOT NULL constraint violation on casual turns

paliad.paliadin_turns.used_tools is text[] NOT NULL DEFAULT '{}'. parseTrailer
leaves trailerMeta.UsedTools as nil when Claude omits the trailer ("Heyhey!")
or sends an empty list. completeTurn passed pq.StringArray(nil) which the pq
driver writes as NULL — UPDATE failed with constraint 23502 on every casual
chat turn, leaving the row half-finalized.

Fix: coerce UsedTools to a non-nil empty pq.StringArray before the UPDATE,
mirroring the existing rowsSeen pattern in the same function.

## B. Frontend rendered "## Proje" instead of the full 1408-byte response

m saw the first 8 characters of his Markdown response in the chat bubble,
plus the full meta row underneath. The DB row had the complete cleanBody
in 'response'. Truncation lived entirely in the browser.

Root cause: finishBubble read textNode.textContent at the moment of the
'end' event — but typewriter() animates the text 8 chars at a time, so
textContent was "## Proje" (one tick into 1408 bytes) when finishBubble
fired. renderResponseHTML(raw) baked in the partial state, then the
typewriter's next tick saw streaming='false' and ran 'node.textContent =
text' which overwrote the rendered HTML with the raw string — except in
this case the second tick never ran in time, leaving the partial render.

Fix:
1. Cache the full SSE-delivered text on placeholder.dataset.fullText at
   content-event time. finishBubble prefers that over textContent.
2. Typewriter's abort branch no longer overwrites the node — finishBubble
   already owns the final rendered HTML, so a delayed tick should just
   return rather than blow away the rendered Markdown.

Both fixes verified locally: go build clean, bun build clean.

Refs t-paliad-155, m/paliad#12.
2026-05-08 14:00:03 +02:00
m
5893c45e5e Merge: t-paliad-155 — Paliadin skill-as-skill + per-user tmux session + project-MCP cwd + 120s timeout (skill at ~/.claude/skills/paliadin/SKILL.md replaces paliadin_prompt.go's keystroke-bootstrap; per-user session keying paliad-paliadin-<user_id_short>; shim spawns claude in /home/m/dev/paliad so project MCPs incl. supabase load; PALIADIN_TIMEOUT_S default 60→120s for cold-start safety; SKILL.md bans psql/curl fallbacks; install-paliadin-skill script for repo-as-source-of-truth; paliadin_prompt.go deprecated) 2026-05-08 13:43:23 +02:00
m
3e1f4eee4b fix(t-paliad-155): cold-start timeout headroom + ban DB fallbacks in skill
Shim's run-turn hard timeout: 60s → 120s (PALIADIN_TIMEOUT_S default).
First turn after a fresh tmux session stacks claude boot + skill load
+ MCP discovery + first reasoning, which can blow past 60s before the
response file lands.

Aligned the surrounding timeouts so 120s is actually reachable:
- callShim ctx (paliadin_remote.go): 70s → 130s (shim 120 + 10 SSH).
- runPaliadinTurnAsync handler ctx: 120s → 150s (shim 120 + 10 SSH +
  20 paliad-side overhead).

SKILL.md hard rule #6 added: never fall back to psql / curl PostgREST /
nix-shell — mcp__supabase__execute_sql is the only DB tool. If it's
unavailable, write a short 'DB nicht erreichbar — bitte paliad neu
deployen oder PALIADIN_REMOTE_CWD prüfen' response immediately with
classifier_tag=meta. Saves the 60s-fallback-dance failure mode m hit
on the cwd-misconfig turn.
2026-05-08 13:19:27 +02:00
m
e75a71fb34 fix(t-paliad-155): spawn claude pane in paliad repo root for project MCPs
claude in the shim's tmux pane was being launched from $HOME, so it
loaded only global MCPs (mai, mai-memory, mgeo) and missed the
project-scoped Supabase MCP at /home/m/dev/paliad/.mcp.json. SKILL.md's
SQL recipes therefore had no DB tool — m saw 'no DB access' on every
real Paliadin turn.

Fix: tmux new-window -c $CLAUDE_CWD when spawning the pane. New env
var PALIADIN_REMOTE_CWD (default /home/m/dev/paliad) lets a host
override the path if the repo lives elsewhere; shim fast-fails with
exit 3 if the directory doesn't exist.

CLAUDE.md updated. Verified by spawning a fresh session via the shim
and inspecting #{pane_current_path}.
2026-05-08 13:03:50 +02:00
m
9579032f94 feat(t-paliad-155): re-author paliadin skill via /write-a-skill conventions
Splits the 250-line hand-rolled SKILL.md into a 96-line SKILL.md
(under the 100-line soft cap from agentskills-extras) plus
references/sql-recipes.md (134 lines). Description rewritten in
imperative voice with explicit pushy triggers — including the short-
message case ('Hey', 'wer bin ich?') so Claude doesn't second-guess
when the prefix [PALIADIN:<uuid>] is present but the body looks like
normal chat.

SKILL.md keeps: persona, response-file format, classifier table,
action chips, hard rules, full example, first-turn rule. Out: 8 SQL
recipes, moved to references/sql-recipes.md with a concrete pointer
trigger ('Read before any project / deadline / appointment / court /
glossary / deadline-rule / UPC-judgment lookup').

install-paliadin-skill now mirrors the entire skill tree (SKILL.md +
references/) and clears stale aux files on each run. Manual one-shot
— m's call to skip a post-merge auto-refresh hook for now.
2026-05-08 12:48:00 +02:00
m
97a412498d feat(t-paliad-155): real Claude SKILL.md + per-user tmux session
Move Paliadin's persona + response protocol from a tmux-keystroke-injected
system prompt into a real Claude skill at ~/.claude/skills/paliadin/SKILL.md
(repo source: scripts/skills/paliadin/SKILL.md, install script:
scripts/install-paliadin-skill). Claude's skill router auto-matches the
[PALIADIN:<uuid>] envelope on every turn, so the protocol contract
survives /clear, fresh sessions, and pane restarts — root-cause fix for
the post-/clear stuck-spinner that triggered this task.

Per-user tmux session keying: each Paliad user gets a session named
<prefix>-<userid8> (first 8 hex chars of UUID). One persistent session
per user, conversation history accumulates per visit, ResetSession kills
the session entirely. Health-check cache becomes per-session.

Service-side simplifications:
- paliadin_prompt.go (paliadinSystemPrompt) deleted; trailer parser stays
  in paliadin.go.
- paliadin_remote.go: ensureBootstrapped removed; healthGate takes a
  session arg + caches per-key; ResetSession derives session from UserID
  and shells out to 'reset <session>'.
- paliadin.go (LocalPaliadinService): per-user pane cache, ensurePane
  takes UserID, no more in-process system-prompt send.
- Paliadin interface: ResetSession now takes UserID.

Shim refactor (scripts/paliadin-shim):
- All verbs accept the tmux session as their first positional arg.
- 'bootstrap' verb removed (skill replaces it).
- 'reset' kills the named session via tmux kill-session.
- Session name validated against [A-Za-z0-9_.-]{1,64}.

Env var rename: PALIADIN_TMUX_SESSION -> PALIADIN_SESSION_PREFIX (semantic
shift from literal session name to per-user prefix); CLAUDE.md updated.

Tests cover per-session health caching, session-name derivation,
ResetSession kill-session shape, and health-cache eviction on reset.
2026-05-08 12:42:57 +02:00
m
319221ff83 Merge: t-paliad-151 fix — base64-decode PALIADIN_SSH_PRIVATE_KEY env var
Dokploy's .env mechanism truncates multi-line env vars to first line.
Empirically: the multi-line PEM arrived as just `-----BEGIN OPENSSH
PRIVATE KEY-----\n` (36 bytes) inside the container, ssh -i failed
with `Load key: error in libcrypto`.

Go now decodes the env value as either raw PEM (multi-line) or
base64-encoded PEM. Whitespace inside base64 stripped before decode.
Dokploy secret already updated to the base64 form alongside this
merge.

Refs m/paliad#12
2026-05-08 11:28:53 +02:00
m
4c47819da8 fix(t-paliad-151): base64-decode PALIADIN_SSH_PRIVATE_KEY env var
Dokploy stores compose env vars in a single-line `.env` file, which
silently truncates multi-line values to their first line. Empirically
verified inside the running paliad container: a multi-line PEM
arrived as just `-----BEGIN OPENSSH PRIVATE KEY-----\n` (36 bytes)
and `ssh -i …` failed with `Load key: error in libcrypto`.

decodePaliadinPrivateKey now accepts either:
  - raw PEM (multi-line, starts with `-----` and contains a newline) —
    used as-is for local-dev convenience
  - base64-encoded PEM — decoded into raw PEM. Survives the .env
    one-line-per-key round-trip.

Whitespace (spaces / line breaks) inside the base64 blob is stripped
before decoding so an OpenSSH-keygen-helper-style 64-char-wrap is
also accepted.

After deploy, m needs to update the Dokploy PALIADIN_SSH_PRIVATE_KEY
secret to the base64-encoded form:
  base64 -w0 < ~/.paliad-staging/paliad-prod-key
…and redeploy. Then sshd's libcrypto loads the key correctly and the
shim's command= path runs.

Refs m/paliad#12
2026-05-08 11:28:02 +02:00
m
db3514c4db Merge: t-paliad-151 Phase A.5 — env-var passthrough for Paliadin remote-routing
Drops the original network_mode: host approach (incompatible with
Dokploy's compose-network injection) in favour of a far simpler
discovery: docker bridge + mLake's host-side tailscale0 + Docker NAT
already routes container outbound to mRiver:22022. Source IP NAT'd to
mLake's tailnet IP, matches the from=100.99.98.201 clause on mRiver's
authorized_keys.

Compose change is therefore JUST the 5 PALIADIN_* env entries pulled
through from already-registered Dokploy secrets. No traefik conflict.

Phase A.5 verified empirically before this merge (2026-05-08 11:23):
plain alpine container on Dokploy's default bridge SSHs to mriver:22022
via the paliadin-shim and gets "ok" in ~3s.

Refs m/paliad#12
2026-05-08 11:25:13 +02:00
m
a0d1e77ef2 feat(t-paliad-151) Phase A.5: compose env-var passthrough for Paliadin remote routing
Adds the 5 PALIADIN_* env entries to docker-compose.yml so paliad's
container picks them up from Dokploy secrets. With PALIADIN_REMOTE_HOST
set, paliad's main.go switches to RemotePaliadinService (already in
main from B5/0c8a2f1) and shells out to ssh m@mriver paliadin-shim.

**Phase A.5 finding (overrides design §4.2/§4.5 + decision 1):**

The original design assumed `network_mode: host` was needed so paliad
inherited mLake's tailscale0. The first attempt at that (a80652a,
reverted in 82faa3d) failed Dokploy's compose validation:

  service web declares mutually exclusive `network_mode` and `networks`:
  invalid compose project

Dokploy auto-injects `networks: [dokploy-network, default]` on the
primary service for traefik routing — irreconcilable with `network_mode:
host`. So design decision 1 (host mode) is fundamentally incompatible
with this Dokploy app's compose lifecycle.

But: empirically, paliad does NOT need host mode at all. Verified
(2026-05-08 11:23) by running a plain alpine container on Dokploy's
default bridge:

  $ docker run --rm -v /tmp/paliad-prod-key:/tmp/k:ro \
                  -v /tmp/paliad-known_hosts:/tmp/kh:ro alpine:3.21 \
      sh -c 'apk add openssh-client && \
             ssh -p 22022 -i /tmp/k -o UserKnownHostsFile=/tmp/kh \
                 -o IdentitiesOnly=yes m@100.99.98.203 health'
  → ok

Why this works: Docker's outbound NAT masquerades the container's
bridge IP onto mLake's host IPs, including tailscale0
(100.99.98.201). Linux routing on mLake sends 100.99.98.0/24 to
tailscale0. mRiver's sshd sees the connection coming from
100.99.98.201, which matches the from="100.99.98.201" clause on the
paliad-prod authorized_keys entry. No tailscale-in-container, no
sidecar, no host networking — the kernel does it for free.

Resulting compose change is therefore minimal: 5 env entries pulled
through from Dokploy secrets. expose: ["8080"] preserved (no host-mode
side-effects). traefik routing untouched (no network_mode collision).

The amended commit message clarifies what changed; the design doc
needs an A.5 amendment in a follow-up — design §4 (host-mode shape)
is empirically wrong and §7 Phase A.5 needs an "M3: kernel does the
masquerade for you" entry.

Refs m/paliad#12
2026-05-08 11:25:02 +02:00
m
d519363c8d fix(admin/approval-policies): preserve <details> open state across re-renders
Changing any required_role cell saves the policy and re-renders the units
list to refresh the attribution chips, but the re-render rebuilt every
<details> closed — collapsing the accordion the admin was actively
editing (m, 2026-05-08 11:19).

Capture the set of open data-unit-ids before innerHTML overwrites them,
then re-apply the open attribute on the rendered nodes for those ids.
Adds data-unit-id to the <details> as the stable identity. No behavior
change for first render or for units the admin hadn't expanded.
2026-05-08 11:20:39 +02:00
m
82faa3d8bd Revert "Merge: t-paliad-151 Phase A.5 — compose network_mode: host + Paliadin env-var plumbing. Lifts the DO-NOT-MERGE-before-A.5 gate from da971a7. Dokploy secrets PALIADIN_SSH_PRIVATE_KEY + PALIADIN_KNOWN_HOSTS already registered on mlake (validated SSH key roundtrip via ssh-keygen -y); single-line vars PALIADIN_REMOTE_HOST=100.99.98.203 / PORT=22022 / USER=m also staged. Next deploy is the M1-vs-M2 traefik gate (design §4.2): if paliad.de returns 200/3xx after redeploy, traefik routes under host mode (M2) and the route ships; if 502, revert this merge and revisit decision 1."
This reverts commit a80652a085, reversing
changes made to f820aa8316.
2026-05-08 02:39:36 +02:00
m
a80652a085 Merge: t-paliad-151 Phase A.5 — compose network_mode: host + Paliadin env-var plumbing. Lifts the DO-NOT-MERGE-before-A.5 gate from da971a7. Dokploy secrets PALIADIN_SSH_PRIVATE_KEY + PALIADIN_KNOWN_HOSTS already registered on mlake (validated SSH key roundtrip via ssh-keygen -y); single-line vars PALIADIN_REMOTE_HOST=100.99.98.203 / PORT=22022 / USER=m also staged. Next deploy is the M1-vs-M2 traefik gate (design §4.2): if paliad.de returns 200/3xx after redeploy, traefik routes under host mode (M2) and the route ships; if 502, revert this merge and revisit decision 1. 2026-05-08 02:37:32 +02:00
m
f820aa8316 Merge: t-paliad-154 — approval-policy authoring UI (migration 062 paliad.approval_policies unit-defaults + 'none' sentinel + tree-walking resolver + 88 unit-default seed rows + paliad.policy_audit_log; ApprovalService rewire with resolver delegation + scope-split CRUD + audit emission; HTTP handlers admin APIs + form-hint endpoint + audit-log union; /admin/approval-policies admin page + admin-index card + form-time hints on deadline/appointment new pages + inbox empty-state nudge for admins; 13 m-locked design decisions honoured verbatim per docs/design-approval-policy-ui-2026-05-07.md §2) 2026-05-08 02:33:25 +02:00
m
1d7c7d7246 Merge: t-paliad-151 Phase B code (env-var-gated, compose flip held for A.5) — Paliadin remote-routing via Tailscale SSH to mRiver. Includes Phase A.0 design doc + scripts/paliadin-shim from earlier shift. Production behavior unchanged: without PALIADIN_REMOTE_HOST in env, paliad never invokes ssh and uses local-tmux PoC path byte-identically. Refactor: Paliadin interface + LocalPaliadinService + RemotePaliadinService + DisabledPaliadinService stub. main.go env-var switch (remote/local/disabled). Dockerfile +openssh-client. 14 unit tests via callShimHook. Frontend friendlyErrorMessage for mriver_unreachable/shim_auth_failed/shim_error/bootstrap_failed/timeout (DE+EN). NOT included: docker-compose network_mode: host flip — held on branch as da971a7 pending Phase A.5 traefik test by m. NOT cronus. 2026-05-08 02:23:38 +02:00
m
da971a7466 DO NOT MERGE before Phase A.5 — compose: network_mode: host + Paliadin env vars
Stages the docker-compose.yml change so m can flip it together with
the Phase A.5 traefik validation (design §7). Three deltas:

1. network_mode: host on the web service. paliad inherits mLake's
   tailnet interface so the Go RemotePaliadinService can reach
   mRiver:22022 over Tailscale.

2. Removed the now-meaningless `expose: ["8080"]` block (host-mode
   binds the port on the host directly).

3. Five new env entries plumbing the Paliadin remote-routing knobs:
   PALIADIN_REMOTE_HOST=100.99.98.203
   PALIADIN_REMOTE_PORT=22022
   PALIADIN_REMOTE_USER=m
   PALIADIN_SSH_PRIVATE_KEY=...   (multi-line; register as Dokploy secret)
   PALIADIN_KNOWN_HOSTS=...       (one-line; register as Dokploy secret)

   The two secret values are staged at ~/.paliad-staging/ on mRiver
   from Phase A.0 — see issue #12 issuecomment-6886.

**This commit must NOT merge to main until Phase A.5 confirms traefik
still routes paliad.de under host mode.** Per the design's §4.2
honest trade-off acknowledgement: if the test surfaces M1 (traefik
can't discover via Docker DNS → 502), revert this commit and revisit
decision 1 (sidecar variant) in a follow-up issue. Per maria's
non-negotiable head rule, m drives the merge.

A.5 procedure (m's hands):
1. Branch this commit (or cherry-pick onto a temp branch off main)
2. Push to trigger Dokploy redeploy
3. curl --connect-timeout 5 -sSI https://paliad.de/
4. PASS (200/3xx): keep the merge; register Dokploy secrets; redeploy
5. FAIL (502): git revert HEAD && git push; file follow-up issue

Refs m/paliad#12
2026-05-08 02:20:39 +02:00
m
e4110cf2db feat(t-paliad-151) frontend: friendly errors for remote-Paliadin codes
Extends the SSE error switch in frontend/src/client/paliadin.ts'
friendlyErrorMessage to map four new error codes from RemotePaliadin
Service into localised messages:

- mriver_unreachable: mRiver is offline / paliadin-shim 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.")
- shim_auth_failed: SSH key / authorized_keys mismatch (Permission
  denied)
- shim_error / bootstrap_failed: generic remote-shim failure
- timeout: Claude didn't write the response file in 60 s

Adds the matching i18n keys (DE + EN) plus the type-union entries in
i18n-keys.ts so the t() typecheck stays sound. The old codes
(tmux_unavailable, connection_lost, upstream) are unchanged — local-PoC
deployments keep their existing UX.

Frontend `bun run build` clean: 1886 keys (unchanged sync).

Refs m/paliad#12
2026-05-08 02:19:48 +02:00
m
68c56ea920 test(t-paliad-151): paliadin_remote_test.go — RemotePaliadinService unit tests
14 tests covering:
- NewRemotePaliadinService default values (SSHPort=22022, SSHUser="m")
- NewRemotePaliadinService honours overrides
- classifySSHError mapping (nil / explicit + wrapped ErrMRiverUnreachable
  / context.DeadlineExceeded / shim exit-124 timeout / Connection
  refused/timed out / Permission denied / unknown fallback)
- healthGate caches OK results for 10 s
- healthGate does NOT cache failures (every call re-probes)
- healthGate rejects unexpected shim replies (returns wrap of
  ErrMRiverUnreachable)
- healthGate cache expires after 10 s wall clock
- ensureBootstrapped runs exactly once on success (idempotent)
- ensureBootstrapped retries after failure, then caches the success
- DisabledPaliadinService returns ErrPaliadinDisabled from RunTurn +
  ResetSession
- compile-time Paliadin interface conformance for all three impls
- callShim forwards args verbatim through the test hook
- callShim error-wrapping path preserves stderr (so classifySSHError
  can pattern-match Permission denied / Connection refused etc.)

All tests bypass exec via the callShimHook field — no real ssh, no
real DB. RunTurn audit-row tests are out of scope (paliad has no
sqlx mock; existing paliadin_test.go also stays on pure functions).

Refs m/paliad#12
2026-05-08 02:18:08 +02:00
m
0c8a2f1a95 feat(t-paliad-151) RemotePaliadinService + main.go env-var routing
Phase B step 2: lands the Paliadin backend that talks to mRiver via
ssh + paliadin-shim. Local backend untouched — selection happens in
cmd/server/main.go based on PALIADIN_REMOTE_HOST.

Files:
- internal/services/paliadin_remote.go (new) — RemotePaliadinService
  + RemotePaliadinConfig, with five SSH knobs (Host/Port/User/KeyPath/
  KnownHostsPath). RunTurn does insertTurnRow → healthGate → bootstrap
  → callShim run-turn → splitTrailer → completeTurn, mirroring the
  local path's audit-row contract. ResetSession sends shim 'reset'.
  callShim runs `ssh -F /dev/null -i <key> -p <port> -o … host -- verb
  args`; ControlMaster intentionally not enabled (design §6.8).
- internal/services/paliadin_remote.go also adds DisabledPaliadinService
  (returns ErrPaliadinDisabled from RunTurn/ResetSession; DB methods
  inherited from paliadinDB still work) so cmd/server/main.go can wire
  a non-nil Paliadin even when neither local tmux nor remote SSH is
  available.
- ErrMRiverUnreachable sentinel for the friendly error code.
- classifySSHError translates ssh exit 124 / Permission denied /
  network errors into the audit-row error_code field.
- Compile-time conformance: var _ Paliadin = (*Local|*Remote|*Disabled)
  PaliadinService(nil).

cmd/server/main.go switch:
  PALIADIN_REMOTE_HOST set → NewRemotePaliadinService
  else: tmux on PATH → NewLocalPaliadinService
  else: NewDisabledPaliadinService

buildPaliadinRemoteConfig materialises PALIADIN_SSH_PRIVATE_KEY +
PALIADIN_KNOWN_HOSTS (multi-line Dokploy secrets) into chmod-600/644
tmpfiles at boot. Defaults: SSHUser=m, SSHPort=22022 (bypasses
Tailscale SSH on :22, see design §4.5). Fails fast on a configured
remote-host without the matching key/known_hosts secrets.

Local-tmux mode now requires `tmux` actually be on PATH at boot
(exec.LookPath gate); previously the constructor unconditionally
returned a service whose RunTurn would fail at runtime with
ErrTmuxUnavailable. The handler-level "friendly error" UX is
unchanged: DisabledPaliadinService surfaces ErrPaliadinDisabled which
the frontend renders the same way.

Build green; existing paliadin_test.go still passes (it tests
package-level helpers, untouched). Remote-specific tests land in B4.

Refs m/paliad#12
2026-05-08 02:16:50 +02:00
m
56a3dc961e refactor(t-paliad-151): extract Paliadin interface; rename PaliadinService → LocalPaliadinService
Phase B step 1 of the Tailscale-SSH route to mRiver. Splits the existing
local-tmux PoC into a Paliadin interface with two implementations; the
remote-SSH backend lands in a follow-up commit (paliadin_remote.go).

Surface:
- Paliadin interface — RunTurn, ResetSession, ListRecentTurns, Stats,
  IsOwner. The handler at internal/handlers/paliadin.go now talks to
  this instead of the concrete struct.
- paliadinDB — embedded base type carrying the audit-table I/O
  (insertTurnRow, completeTurn, markTurnError, markTurnAbandonedOrError)
  plus the read-side queries (IsOwner, ListRecentTurns, Stats). Both
  Local and Remote impls inherit these by embedding paliadinDB so the
  remote path doesn't have to duplicate any DB code.
- LocalPaliadinService — the renamed PoC backend. Identical behaviour
  to the previous PaliadinService; only the type name and method
  receivers change. Method receivers split: tmux-specific operations
  (RunTurn, ResetSession, ensurePane, sendToPane, pollForResponse, etc.)
  stay on *LocalPaliadinService; DB-only operations promote to
  *paliadinDB.

Wiring:
- internal/handlers/handlers.go — Paliadin field becomes the interface
  type; Register() unchanged.
- cmd/server/main.go — calls NewLocalPaliadinService instead of
  NewPaliadinService. The remote-vs-local switch on PALIADIN_REMOTE_HOST
  lands in B5.

Tests in paliadin_test.go all green — they test package-level functions
(splitTrailer, countChips, approxTokenCount, sanitiseForTmux,
PaliadinOwnerEmail) and don't touch the renamed struct. No behaviour
change on the local-tmux path.

Refs m/paliad#12
2026-05-08 02:14:12 +02:00
m
f62bf9f8fb feat(t-paliad-151) Dockerfile: openssh-client for remote Paliadin
paliad's RemotePaliadinService shells out to `ssh m@mriver paliadin-shim`
to deliver Paliadin turns from prod (paliad.de Dokploy container) to
mRiver where the long-lived tmux+claude pane lives. The alpine final
stage didn't ship an SSH client; add openssh-client (~1.1MB compressed).

The Go service wires this up in a follow-up commit (Paliadin interface
split). When PALIADIN_REMOTE_HOST is unset, the binary still picks up
the local-tmux PoC path and never invokes ssh, so this change is safe
on its own.

Refs m/paliad#12
2026-05-08 02:10:40 +02:00
m
dd139a3536 Merge remote-tracking branch 'origin/main' into mai/noether/inventor-paliadin 2026-05-08 02:08:12 +02:00
m
f952fb85c3 design(t-paliad-151) amend: port 22022 bypass + Phase A.0 results
Phase A.0 revealed Tailscale SSH on mRiver intercepts :22 from tailnet
peers and bypasses OpenSSH's authorized_keys entirely (banner
"SSH-2.0-Tailscale", auth method "none", command= restriction never
fires). The fix is port 22022 via a systemd ssh.socket drop-in:
Tailscale SSH only intercepts :22, so :22022 hits real OpenSSH where
the design's command=/from= shim restriction works as specified.

Updated:
- §3 locked decisions: row 5 added (port 22022, m's call 23:35)
- §4.5 new subsection: Tailscale SSH bypass via socket drop-in
  + records the "Address already in use" first-attempt failure as a
  "don't retry without cleaning sshd_config Port directives first"
  lesson
- §5.2/5.3: ssh-keyscan now uses -p 22022; known_hosts is host:port
  keyed for non-22 ports
- §6.1/6.2/6.3: SSHPort field on RemotePaliadinService config, -p
  flag in callShim, PALIADIN_REMOTE_PORT env (default 22022)
- §7 phasing: A.0 completion checked off step-by-step with concrete
  fingerprints; A.5/A.6/A.7 split out as m-driven
- §8 security: Tailscale-SSH-on-:22 risk explicitly tabled with
  port-22022 mitigation
- §10 deliverables: mRiver host-setup artifacts noted
- §12 new Phase A.0 completion summary with the three secrets m
  needs to register in Dokploy

Phase A.0 verified end-to-end:
- ssh -p 22022 paliad-prod-key m@mriver health → ok
- run-turn UUID base64msg → 3.4 s including a real Claude response
- from="100.99.98.201" correctly rejects connections from mRiver
  itself

mRiver host state in place (not in repo): authorized_keys with
restrictions, /home/m/.local/bin/paliadin-shim, ssh.socket drop-in.
Three secrets staged at ~/.paliad-staging/ on mRiver for m to copy
into Dokploy: paliad-prod-key (PALIADIN_SSH_PRIVATE_KEY),
known_hosts (PALIADIN_KNOWN_HOSTS), and the three plain env vars.

Refs m/paliad#12
2026-05-07 23:37:26 +02:00
m
024841129f feat(t-paliad-151) shim: scripts/paliadin-shim
Server-side RPC for paliad's remote-tmux turns. Invoked via mRiver's
~/.ssh/authorized_keys command= restriction; dispatches on the verb in
$SSH_ORIGINAL_COMMAND. Four verbs: health, bootstrap, run-turn, reset.

Per the design (§5.4), this is the single SSH entry point for paliad-prod
on mLake. The Go service in cmd/server/main.go later constructs
RemotePaliadinService with this script as the only command the
authorized_keys entry permits.

Multi-character payloads (system prompt, user message) are base64-encoded
by the caller so they never have to be quoted through ssh's argv. The
shim validates UUID turn_ids, base64 decodes inputs, and never evals
$SSH_ORIGINAL_COMMAND.

Smoke-tested on mRiver:
- empty / unknown verb → exit 2 with clear stderr
- bootstrap with bad base64 → exit 2 BEFORE creating any pane
- health → "ok" on a clean tmux session

Refs m/paliad#12
2026-05-07 23:02:52 +02:00
m
befa41c00e design(t-paliad-151): Paliadin Tailscale SSH route to mRiver
Inventor design for routing Paliadin from paliad.de's Dokploy container
on mLake to mRiver via Tailscale + SSH, preserving m's Claude Code
subscription instead of paying Anthropic API tokens.

Three sub-designs covering m's four locked decisions (2026-05-07 22:35):
- network_mode: host on paliad (m overrode the sidecar recommendation;
  Phase A explicitly tests traefik compatibility under host mode)
- server-side paliadin-shim with one RPC per turn (run-turn / reset /
  health / bootstrap), authorized_keys command= restriction, from=mlake
- env-var routing trigger (PALIADIN_REMOTE_HOST) + Paliadin interface
  split: LocalPaliadinService keeps the laptop PoC, RemotePaliadinService
  shells out to ssh m@mriver paliadin-shim
- ed25519 keypair via Dokploy secret PALIADIN_SSH_PRIVATE_KEY, written
  to a chmod 600 tmpfile at startup; pinned host key via
  PALIADIN_KNOWN_HOSTS

Verified live before designing: mRiver tmux+claude present, mLake
Tailscale active and sees mRiver, paliad Dockerfile is alpine-minimal,
no authorized_keys on mRiver yet. No assumptions left from CLAUDE.md.

Includes: friendly error code mriver_unreachable extending t-paliad-150,
single-flight rate limit, security review (defence-in-depth via
command=/from= restrictions), three-phase rollout (manual proof →
Dockerfile bake → polish), file-level deliverables for the coder shift.

Inventor stops here — no code shipped. Awaiting m's go/no-go.

Refs m/paliad#12
2026-05-07 22:47:30 +02:00
24 changed files with 3354 additions and 430 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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
}

View File

@@ -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

View 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:1316: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/041046 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 (G1G6, deferring G7 explicitly), it lands on locked decisions throughout (Q1Q5 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 23 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.*

View 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 ~1020 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 ~3050 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.

View File

@@ -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 &hellip;</td></tr>
<tr><td colspan={8} data-i18n="admin.paliadin.loading">Lade &hellip;</td></tr>
</tbody>
</table>
</div>

View File

@@ -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>

View File

@@ -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, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/\n/g, " ");
}
function setText(id: string, val: string): void {
const el = document.getElementById(id);
if (el) el.textContent = val;

View File

@@ -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…",

View File

@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
// 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 {

View File

@@ -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"

View File

@@ -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;

View File

@@ -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) {

View File

@@ -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(),
})

View File

@@ -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

View File

@@ -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.
`)
}

View 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)
)

View 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))
}
}

View File

@@ -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
View 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
View 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

View 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 13 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.

View 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`.