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.
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}.
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.
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.
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
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
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
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
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.
Three remaining surfaces from the locked design (Q9 + Q13):
/inbox empty-state admin nudge (Q9):
- New conditional block (.inbox-admin-nudge) revealed only when:
* /api/me reports global_role='global_admin'
* the inbox tab returned zero rows
* /api/admin/approval-policies/seeded reports any=false (no policies firm-wide)
- Card links to /admin/approval-policies. Hidden in every other case so the
ordinary post-rollout state (admins with active policies) sees nothing.
Form-time 4-eye hint on /projects/{id}/deadlines/new + /appointments/new (Q13):
- New .approval-hint container above the Speichern button on each form;
hidden by default.
- Client TS fires GET /api/projects/{id}/approval-policies/effective on
page load + on project change, reveals the hint when required_role is
non-null and not 'none'. Renders role label + source attribution
('· Standard: Munich Lit') so the user knows where the rule comes from.
- Hides in every 'no policy applies' case (no candidates / 'none' suppression
/ project change to a project with no policy / fetch error).
i18n: 6 new keys × 2 langs (3 inbox-nudge keys + 2 form-hint keys + the
inbox-nudge title/body/cta wired in inbox.tsx). Total i18n keys: 1929.
Dynamic-key call sites use tDyn (admin-approval-policies.ts +
deadlines-new.ts + appointments-new.ts) so the typed t() barrier stays
intact for static keys.
Build: bun run build clean, go build + vet + test clean (no DB tests
require TEST_DATABASE_URL — those run in CI).
New TSX page shell + client orchestration + admin-index card + CSS for
the matrix + i18n keys (DE+EN).
Page structure:
- Section 1 'Partner-Unit-Standards': accordion list, each <details>
block expandable into the 8-cell matrix for that partner unit.
- Section 2 'Projekt-spezifisch': search-driven project picker → matrix
showing the EFFECTIVE policy per cell with attribution chips
(Projekt / Geerbt / Standard) per source.
- Bulk-apply modal: 'Auf Unterprojekte anwenden' button per project; lists
affected descendants; POST to /api/admin/approval-policies/apply-to-descendants.
Cell semantics:
- Select per cell with options: '— keine Regel —' (= DELETE), partner /
of_counsel / associate / senior_pa / pa / 'Keine Genehmigung' (= 'none'
sentinel, project-row only).
- Change → PUT for any value, DELETE for empty. Re-fetch the affected
scope so attribution chips reflect the new state.
CSS: matrix grid on desktop (≥700px); two stacked sections (Fristen /
Termine) below 700px via media query — both rendered in DOM, CSS toggles.
All tokens are existing --color-* / --status-* / --hlc-*-rgb (no bare
--surface / --text-muted / --bg-subtle).
i18n: 42 new keys × 2 languages = 84 entries. Total i18n keys: 1924.
Build: bun run build clean (i18n codegen updated, IIFE wrapping enforced).
8 new endpoints under /api/admin/* (admin-gated) and /api/projects (gated
on per-user authentication for the form-time hint):
Admin APIs (gated by adminGate):
- GET /admin/approval-policies — page shell
- GET /api/admin/partner-units/{unit_id}/approval-policies — list unit defaults
- PUT /api/admin/partner-units/{unit_id}/approval-policies/{entity}/{lifecycle} — upsert unit default
- DELETE /api/admin/partner-units/{unit_id}/approval-policies/{entity}/{lifecycle} — clear unit default
- GET /api/admin/approval-policies/seeded — exists check (gates inbox nudge)
- GET /api/admin/approval-policies/matrix?project_id=... — 8 effective rows w/ attribution
- POST /api/admin/approval-policies/apply-to-descendants — bulk fanout
Form-time hint (NOT admin-gated — every user authoring a deadline /
appointment needs to know whether their save will trigger 4-eye):
- GET /api/projects/{id}/approval-policies/effective?entity_type=&lifecycle=
AuditService extension:
- New AuditSourcePolicyAuditLog source string.
- Fifth UNION ALL branch in auditUnionSQL queries paliad.policy_audit_log,
packs description as 'entity/lifecycle: old → new'. project_id forwarded
for project-scoped rows so /admin/audit-log filters work — but
policy_audit_log is NOT a /verlauf source (the verlauf SELECT in
ProjectService.ListProjectEvents reads project_events directly), so
Q8's no-leak constraint is preserved.
Build + go vet clean. The new handler functions register with the existing
adminGate / gateOnboarded patterns; no new middleware.
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
Service-layer changes implementing the locked design (Q5/Q6/Q8):
LookupPolicy (existing, called by SubmitCreate/Update/Complete/Delete)
delegates to paliad.approval_policy_effective() resolver. Returns nil
for the 'none' sentinel — explicit project-level suppression of inherited
defaults. Synthesizes a *models.ApprovalPolicy carrying the actual
project_id so the existing submit chain branches don't change.
Policy CRUD split into project + unit scope methods:
- ListProjectPolicies / ListUnitPolicies — read-only per scope.
- UpsertProjectPolicy / DeleteProjectPolicy — project-scoped writes,
audit-emitting (writes paliad.policy_audit_log inside the same tx).
- UpsertUnitPolicy / DeleteUnitPolicy — unit-default writes, same shape.
- All four use validatePolicyTuple for entity_type/lifecycle/required_role
ranges. IsValidPolicyRole accepts the 'none' sentinel; the existing
IsValidRequiredRole keeps rejecting 'none' (gate-only contract).
Effective-policy reads:
- GetEffectivePolicyOne(projectID, entity, lifecycle) — single-cell,
used by the form-time hint endpoint above /projects/{id}/deadlines/new.
- GetEffectivePoliciesMatrix(projectID) — 8 cells in stable display order
(Fristen/Termine × create/update/complete/delete), each w/ attribution.
- lookupSourceName resolves source_id to projects.title or partner_units.name.
ApplyMatrixToDescendants — bulk-apply (Q10): copies source project's
effective matrix down to listed descendants as project-specific rows,
inside one tx. Validates targetIDs are actual descendants via path-prefix
NOT LIKE check. Idempotent fanout: deletes target's project rows first
then writes the source's effective values. Self-target skipped. Audit
row per affected target.
PoliciesExist() — bool, used by /inbox empty-state nudge.
Models:
- ApprovalPolicy.ProjectID is now *uuid.UUID (was uuid.UUID); new
*uuid.UUID PartnerUnitID. Existing handler code only reads RequiredRole
so no upstream breakage.
- New EffectivePolicy struct (resolved cell + source attribution).
- New PolicyAuditEntry struct (paliad.policy_audit_log row).
Handlers:
- handleListApprovalPolicies → ListProjectPolicies (renamed).
- handlePutApprovalPolicy → UpsertProjectPolicy (caller-id reordering).
- handleDeleteApprovalPolicy → DeleteProjectPolicy (now needs uid for
audit; took the existing requireUser path).
Tests:
- Existing TestApprovalService_PolicyCRUD updated for new method names
+ post-148 enum (partner, not lead) + new 'none' sentinel acceptance.
- New TestIsValidPolicyRole pins the helper that gates writes.
- TestIsValidRequiredRole extended with 'none' rejection (gate-only).
Build + go vet + role-tests clean.
Q8: audit emission writes to paliad.policy_audit_log only — never to
project_events — so /admin/audit-log surfaces the change while /verlauf
stays focused on entity-level lifecycle.
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
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
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
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
Q8 of locked design: policy CRUD audits to /admin/audit-log only, NOT
to per-project /verlauf. The 4 existing audit sources (project_events,
caldav_sync_log, reminder_log, partner_unit_events) don't fit cleanly:
project_events would surface on /verlauf (rejected by Q8); partner_unit_events
constrains event_type and requires unit_name + a non-null partner_unit_id
which doesn't fit project-scoped policy changes.
Added paliad.policy_audit_log as a fifth audit source — admin-only, scoped
either to a project or a partner unit, snapshots scope_name so post-cascade
rows still render. RLS: select for any authenticated user (route gate is
the actual control); write for global_admin only.
AuditService.ListEntries will union this source in commit 2 of this PR.
Validated insert/select live in BEGIN ... ROLLBACK.
Schema:
- ALTER paliad.approval_policies: project_id nullable, ADD partner_unit_id
uuid REFERENCES paliad.partner_units(id) ON DELETE CASCADE.
- XOR check: exactly one of (project_id, partner_unit_id) is set.
- Replace UNIQUE composite with two partial unique indexes (one per scope).
- Extend required_role CHECK with 'none' sentinel.
- approval_role_level('none') already returns 0 via existing ELSE branch
in 059_profession_vs_responsibility.up.sql:218 — no function update.
Resolver paliad.approval_policy_effective(project, entity_type, lifecycle):
- Step 1: project-specific row wins outright (any value, including 'none').
- Step 2: MAX(approval_role_level) across ancestor rows on project's path
+ unit-default rows for partner units attached to project. Tied levels
break alphabetically ('ancestor' beats 'unit_default') for stable
attribution.
- Step 3: zero rows (no candidates) — caller treats as 'no policy applies'.
Returns (required_role, source, source_id) — source ∈ {project, ancestor,
unit_default}; source_id is project_id or partner_unit_id depending.
Seed:
- 8 rows × every existing partner_unit (currently 11): deadline+appointment
× create/update/delete = associate; complete = none.
- ON CONFLICT (partner_unit_id, entity_type, lifecycle_event)
WHERE partner_unit_id IS NOT NULL DO NOTHING — idempotent on re-run
(verified live: 11 units → 88 seed rows, second run is no-op).
- Safe on a DB with 0 partner_units (SELECT returns no rows).
Down migration: reverse-order. Coerces 'none' rows to 'associate' before
restoring CHECK so rollback works without data loss. Drops seeded unit
rows; preserves project rows that pre-date 062.
Validated end-to-end against the live DB inside BEGIN ... ROLLBACK; the
existing project policy (deadline:create=partner) is preserved by the
DO NOTHING clause and the partial-index scope.
Design: docs/design-approval-policy-ui-2026-05-07.md §3.1.
No RAISE EXCEPTION. No bare CSS tokens (no CSS in this commit).
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
Inventor pass for m/paliad#13. Surfaces the dormant t-138 4-eye system
(zero policies in DB → silent bypass) by adding /admin/approval-policies
with project-picker → 8-cell matrix + partner-unit-defaults section.
12 design questions surfaced sequentially via AskUserQuestion (per dogma)
and locked in §2 of the doc:
1. Surface: /admin/approval-policies only (admin page card on /admin index)
2. Defaults concept: per-partner-unit defaults
3. Multi-unit conflict: most-restrictive wins
4. Tree inheritance: yes (ancestors contribute candidates)
5. Cross-source precedence: most-restrictive across project+ancestor+unit;
project row overrides outright
6. Suppression sentinel: 'none' value in required_role enum
7. Soft-disable: no, delete-only
8. Audit emission: /admin/audit-log only, not project verlauf
9. Empty-state: admin-only nudge card on /inbox when zero pending+policies
10. Bulk-apply: per-project "Auf Unterprojekte anwenden" button
11. Seed defaults: yes — conservative associate baseline for all partner units
12. Mobile shape: stacked sections per entity_type
13. Form hint: yes, above Speichern button on deadline/appointment new+edit
Migration 062 adds partner_unit_id (XOR with project_id),
'none' to required_role enum, paliad.approval_policy_effective() resolver,
and seeds 8 rows × N partner_units. ApprovalService.LookupPolicy delegates
to the resolver while preserving its calling contract (existing submit/
decide chain unchanged). New admin endpoints for unit-defaults, matrix
view, bulk-apply, and form-time effective lookup. ~3500-4500 LoC, single
PR, 5 commits.
Inventor parked. NOT cronus per memory directive. Awaiting m go/no-go.
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
Substrate marshals deadline.due_date as time.Date(...,0,0,0,0,UTC), so the
JSON arrives as "YYYY-MM-DDT00:00:00Z" — UTC midnight, no real time. Feeding
that into new Date() + toLocaleTimeString() produced "02:00" in CEST,
"01:00" in CET, "20:00 the day before" in EST, etc.
Pattern A: don't render time for date-only fields.
- Centralised the date/time formatters used by the views shapes into
frontend/src/client/views/format.ts. parseDateOnly recognises both
"YYYY-MM-DD" and the substrate's "YYYY-MM-DDT00:00:00Z" form; formatDate
formats those in UTC so the day matches the source day in every timezone.
- shape-cards.ts: per-row time slot is empty for deadlines when the day is
already in the heading (groupBy=day). Falls back to formatDate when
groupBy=week|none. Bucketing now anchors date-only inputs to UTC so a
deadline can't slip into the previous day in negative-offset zones.
- shape-list.ts: formatRelative is kind-aware — deadlines reduce to
day-precision ("morgen" / "in 3 Tagen") instead of leaking hour math
("in 2h") off the UTC midnight.
- Appointments and other timestamped sources are untouched.
- format.test.ts: regression coverage in CEST / PST / UTC. 14 tests pass.
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
The frontend toggle on /projects/{id} Fristen + Termine emitted
`&direct_only=true`, but `handleListEvents` and `handleEventsSummary`
never read the param, so EventListFilter / EventSummaryFilter went out
without DirectOnly and the backend always returned the subtree-aggregated
default (per t-paliad-139). The toggle has been silently dead since the
Fristen/Termine surfaces migrated to /api/events in t-paliad-139.
Backend-only fix, symmetric across endpoints:
- ListFilter (deadlines), AppointmentListFilter, EventListFilter,
EventSummaryFilter all gain DirectOnly bool.
- When ProjectID != nil && DirectOnly, the SQL predicate swaps from
projectDescendantPredicate("p") to a direct `<alias>.project_id = :project_id`
scope on each rail (deadline list, appointment list, deadline+appointment
bucket counts).
- Handlers parse `direct_only` via the existing parseDirectOnly helper.
- Test extends project_filter_descendants_test.go with three DirectOnly=true
assertions (events, deadlines, appointments) — each must collapse to the
one direct seed row.
DirectOnly is a no-op when ProjectID is nil or PersonalOnly is set —
PersonalOnly already nullifies ProjectID.
Verlauf is untouched: it still uses /api/projects/{id}/events, which
already wired direct_only via projects.go:512.
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
Adds the Cards view-mode to /projects (third option in the segment-control
between Tree and Liste).
frontend/src/projects.tsx:
- View-mode segment gains "Karten" button
- Two new toolbars (initially display:none, surfaced by Cards mode):
- .projects-cards-toolbar: layout dropdown + [Bearbeiten] + [Neue Ansicht]
+ "Alle Ebenen anzeigen" toggle
- .projects-cards-edit-toolbar: density radio + grid select + rename /
delete / set-default / discard / save buttons
- New container: .projects-cards-wrap > #projects-cards-grid
frontend/src/client/projects-cards.ts (NEW, ~640 LoC):
- Layout management: GET /api/user-card-layouts on first mount; auto-seeds
Standard layout if empty (POST). Layout dropdown switches active layout
in-place; show_all_levels toggle persists immediately.
- Edit mode: clones the active layout into editDraft; renders per-card
fact list with drag handles + visibility checkboxes + count steppers
(1..5) for next-events / recent-verlauf. HTML5 drag-and-drop reorders
facts; title-row is forced to the first position so the server-side
validator's invariant holds.
- New layout: prompts for a name, seeds with the current draft (or active
layout's facts), POSTs, enters edit mode.
- Set-default / rename / delete: each maps to PATCH or DELETE; default
cannot be deleted (server returns 409 + UI alerts).
- Card render: title row (icon + link + pin star), type/status chips,
client-matter, parent-path-as-reference (parent breadcrumb deferred —
needs an extra fetch per card), deadline-counts (subtree-aggregated
when available), next-events from /api/projects/cards-preview, recent-
verlauf, team-chips initials with overflow count.
- Pin click on a card star does optimistic toggle + POST/DELETE pin
endpoint and updates treeCache in place.
- Cards sort: pinned first, then last_activity_at DESC, then title ASC.
- "Alle Ebenen anzeigen" toggle decides whether Mandanten + Litigations
appear as their own cards (off by default — leaf-ish projects only:
Cases, Patents, Verfahren, Projekte).
frontend/src/client/projects.ts (orchestrator):
- ViewMode type expands to "tree" | "cards" | "flat"
- View segment-control wires through to Cards mode
- render() dispatches to renderCardsView / teardownCardsView based on
active mode
frontend/src/client/i18n.ts: 53 new keys DE+EN under projects.cards.* —
section titles, empty-states, layout picker labels (label/new/edit/save/
discard/set_default/delete/rename/is_default/new.prompt/delete.confirm/
delete.default_blocked), per-fact labels (title-row/type-chip/status-chip/
client-matter/parent-path/deadline-counts/next-events/recent-verlauf/
team-chips/reference/last-activity-at), density values (compact/roomy),
grid values (auto/2/3/4), event-kind labels (deadline/appointment/
project_event), edit toggles (toggle.hide/show/move_up/move_down/count).
frontend/src/styles/global.css: ~290 LoC appended for cards toolbar +
grid + card layout (title row / row / section / event row / team chips)
+ edit-mode chrome (drag handles, drop targets, count steppers) + dark-
themed dashed border on edit cards. Mobile media query forces single-
column grid.
i18n codegen: 1830 → 1882 keys (+52). bun run build clean. tsc on new
files clean (pre-existing JSX-IntrinsicElements noise unrelated).
go build/vet/test still clean.
Migration 061 (paliad.user_card_layouts): per-user named card layouts.
- Partial unique index on (user_id) WHERE is_default=true keeps "at most
one default per user" honest at the DB level.
- UNIQUE (user_id, name) so the layout dropdown can use names as stable
labels.
- RLS owner-only (mirrors paliad.user_views from t-144).
LayoutSpec (internal/services/layout_spec.go): structured JSON validator
with KnownFactKeys registry (11 fact keys: title-row, type-chip, status-
chip, client-matter, parent-path, deadline-counts, next-events, recent-
verlauf, team-chips, reference, last-activity-at). Validator enforces:
- title-row must be the first VISIBLE fact (always-on, structural)
- no duplicate keys
- count ∈ [1, 5] only on next-events / recent-verlauf
- density ∈ {compact, roomy} (CardDensity, distinct from t-144's
ListDensity which only ranges over comfortable/compact)
- grid_columns ∈ {auto, 2, 3, 4}
DefaultLayoutSpec returns the m-locked rich content set per design §5b.4
(9 facts, roomy density, auto grid, leaf-ish projects only).
CardLayoutService: CRUD with auto-seed (GetDefault creates "Standard"
on first call) + tx-flip-default (setting is_default=true on B clears
A in the same transaction) + ErrUserCardLayoutDefaultGate (deleting
the active default returns 409). isPgUniqueViolation maps the partial
unique index conflict to ErrUserCardLayoutNameTaken.
ProjectService.CardsPreview: per-project event rollups for the Cards view.
4 source SQLs with ROW_NUMBER() OVER PARTITION BY project_id (top 3 each
for upcoming deadlines, upcoming appointments, recent project_events) +
team-chips JOIN. Single round-trip per source, visibility-gated. Returns
map[uuid.UUID]*ProjectCardPreview with last_activity_at computed across
all sources for the orchestrator's card-grid sort.
Handlers: 5 /api/user-card-layouts/* endpoints (GET list, POST create,
PATCH update, DELETE, POST set-default) + GET /api/projects/cards-preview
(narrowable via ?ids=<csv>).
Wired in handlers.go (Services struct + dbServices struct) and
cmd/server/main.go. ErrUserCardLayoutNameTaken / NotFound / DefaultGate
mapped to 409 / 404 / 409 respectively.
Tests:
- layout_spec_test.go (8 cases, pure-Go): valid default, empty rejection,
title-row-first invariant, hidden leading allowed, dup-key rejection,
unknown-key rejection, count-bounds + count-on-wrong-key, density/grid
enum, ParseLayoutSpec round-trip.
- card_layout_service_test.go (6 cases, live-DB): GetDefault auto-seeds
+ idempotent, first Create auto-becomes default, SetDefault clears
prior, Delete refuses active default, Delete non-default works,
duplicate name rejected, Update round-trips layout JSON.
go build / vet / test (short) clean.
Design: docs/design-projects-page-2026-05-07.md §5b.3, §5b.5, §8.2.
frontend/src/projects.tsx — strip the legacy 3-select toolbar; replace with
search input + view-mode segment-control (Tree | Liste) + chip filter row
(Alle / Nur meine / Angepinnt / Status / Typ / Mit aktiven Fristen). Tree
container is the default visible mount; flat-table hidden until view mode
toggles.
frontend/src/client/projects.ts — orchestrator. Owns chip + search + view-
mode state. Last-viewed restore from sessionStorage (Q1 lock-in), URL params
override on load, syncURL on every state change. Debounced search (250ms).
Multi-select panels via <details> for status/type. Delegates rendering to
project-tree.ts (tree mode) or projects-flat.ts (flat mode).
frontend/src/client/projects-flat.ts (NEW) — extracted table render from the
old projects.ts so the orchestrator can mount/unmount cleanly.
frontend/src/client/project-tree.ts — extends ProjectTreeNode shape with
pinned, inherited_visibility, match_kind, *_subtree fields. Renders pin
star button (always-visible per design §4.6 — touch-friendly), greyed-
ancestor opacity for InheritedVisibility=true, lime backdrop on
match_kind=self. Pin click does optimistic toggle + POST/DELETE
/api/projects/{id}/pin then invalidates the tree cache.
frontend/src/styles/global.css — toolbar + chips + pin star + greyed-
ancestor + match highlighting. ~200 LoC appended.
frontend/src/client/i18n.ts — 29 new keys DE+EN under projects.toolbar.*,
projects.chip.*, projects.tree.deadlines.*, projects.tree.pin/unpin,
projects.search.match.*, projects.empty.filtered.action.
internal/services/pin_service_test.go (NEW) — live-DB tests for PinService
(pin/unpin/idempotent/owner-scope/visibility-gate) + 2 BuildTreeWithOptions
cases (PinnedSet surfaces, ScopeMine greys ancestors). Skips without
TEST_DATABASE_URL; pure-Go path runs clean.
Frontend bun build clean. go build / vet / test (short) clean.
Migration 060 (paliad.user_pinned_projects): per-user, RLS owner-only, ON
DELETE CASCADE on both FKs.
PinService (Pin / Unpin / IsPinned / PinnedSet / ListPinned): visibility-
gates pin (can't pin what you can't see) but not unpin (so users can clean
up after losing access). PinnedSet returns a map for O(1) lookups during
tree stitching.
ProjectService.BuildTreeWithOptions extends BuildTree with chip-driven
filtering. New ProjectTreeNode fields are additive (Pinned,
InheritedVisibility, OpenDeadlinesSubtree, OverdueDeadlinesSubtree,
MatchKind) so the old BuildTree(ctx, userID) call still works for legacy
callers. New options:
Scope: All / Mine / Pinned (Mine + Pinned both expand to path-closure
with InheritedVisibility flag on greyed ancestors)
StatusIn / TypeIn: chip-narrowing whitelists
HasOpenDeadlines: per-node or subtree-aggregated, depending on
IncludeSubtreeCounts
SearchTerm: case-fold contains on title/reference/clientmatter, then
prune to {matches ∪ ancestors ∪ descendants} with match_kind tagged
IncludeSubtreeCounts: post-order DFS sums, O(N)
GET /api/projects/tree gains query params: scope, status, type,
has_open_deadlines, q, subtree_counts. Zero query string preserves
legacy behaviour.
POST/DELETE /api/projects/{id}/pin and GET /api/user-pinned-projects
wired. Service registered in cmd/server/main.go and dbServices.
build + vet clean.
Design: docs/design-projects-page-2026-05-07.md §4.7, §8.1, §8.3.
Three visual bugs from the t-146 PoC ship.
1. Bubble alignment robustness — keep `align-self: flex-end/-start`
but also pin with `margin-left/right: auto`. align-self was already
correct in CSS, but layered margin-auto makes the alignment
bulletproof against any future cross-axis override.
2. Dark-mode contrast — paliadin CSS used three undefined tokens
(`--color-accent-tint`, `--color-status-red`, `--color-status-red-tint`,
`--color-surface-hover`) whose hardcoded fallbacks (`#e8fbb2`, `#fee`,
etc.) always fired. In dark mode the user bubble rendered light cream
text on light-lime background, the error bubble light cream on light
pink — both unreadable. Repointed to the project's actual tokens:
`--color-bg-lime-tint` (defined in both modes), `--status-red-fg/bg/border`
(defined in both modes), `--color-surface-2` for the starter hover.
Added explicit `color: var(--color-text)` to `.paliadin-bubble` and
`color: var(--status-red-fg)` to the error variant. Same root cause as
t-paliad-144's contrast sweeps (cf. memory `paliad: undefined --color-bg-muted token`).
3. Friendly tmux-unavailable error — Dokploy container has no tmux/claude
CLI per CLAUDE.md, so prod hits `event: error` with
`{"code":"tmux_unavailable", ...}`. The client used to dump the raw
JSON into the bubble. Now `friendlyErrorMessage()` parses the payload
and shows a localised "Paliadin läuft nur lokal" notice (DE+EN), with
a `connection_lost` fallback for native EventSource transport errors
(no `data`) or anything we don't recognise. Same code path also
replaces the generic "Fehler beim Senden: …" pre-SSE catch block with
`paliadin.error.upstream` so transport errors don't leak `String(err)`
into the UI either.
058 = paliadin_poc (t-146), 059 = profession_vs_responsibility (t-148), both shipped on main 2026-05-07. Next available is 060.
Per maria's coder-shift instruction.