When a pending row was drafted by Paliadin (requester_kind='agent' on
its in-flight approval_request), surface a sparkle ✨ next to the
existing eye-pill 👀. The two glyphs are orthogonal: 👀 = "needs
approval", ✨ = "Paliadin drafted this". Either can change without the
other, so the visual taxonomy stays decomposable for any future
autopilot mode where 👀 disappears but ✨ stays.
Read-path:
- DeadlineService.ListVisibleForUser + AppointmentService.ListVisibleForUser
LEFT JOIN paliad.approval_requests on pending_request_id and project
ar.requester_kind into the row. NULL when no request is pending.
- models.DeadlineWithProject + AppointmentWithProject grow
RequesterKind *string. The list-projection helpers
(projectDeadline / projectAppointment in event_service.go) carry it
into EventListItem.
- /api/events response now includes requester_kind on every pending
row; /api/inbox already does (Slice D extended approvalRequestViewColumns).
Render-path:
- frontend/src/client/events.ts — new AGENT_PILL_GLYPH constant ("✨"),
agentPill rendered into the title cell next to the existing
pendingPill when item.approval_status='pending' AND
item.requester_kind='agent'. EventListItem TS shape gains
`requester_kind?: "user" | "agent"`.
- frontend/src/client/agenda.ts — same pattern, agendaItem TS shape
+ agentPill rendered next to pendingPill in the headline span.
- frontend/src/client/inbox.ts — ApprovalRequestView gains
requester_kind + agent_turn_id; the meta line replaces the
requester's plain name with "Anna ✨ Paliadin" when the request was
drafted by the agent.
CSS: new .approval-pill--agent modifier in global.css using only
existing tokens (--color-bg-lime-tint / --color-surface-2 /
--color-text), mirroring the .approval-pill--icon shape so the two
glyphs sit side-by-side at the same baseline.
i18n: 3 new keys × 2 langs (approvals.agent.label /
approvals.agent.byline / approvals.agent.suggestion_pending) — total
1966 → 1969.
Build clean (frontend + go), tests green.
Refs: docs/design-paliadin-inline-2026-05-08.md §8.
Paliadin can now draft deadlines + appointments through two new
owner-gated HTTP endpoints. Drafted entities land in the existing
approval pipeline as approval_status='pending' with
requester_kind='agent' + agent_turn_id linking back to the chat turn
that produced the suggestion. The user reviews via the same eye-pill
👀 surface (with ✨ added in Slice E).
POST /api/paliadin/suggest/deadline
POST /api/paliadin/suggest/appointment
Wiring:
- ApprovalService.SubmitAgentCreate — agent variant of SubmitCreate;
always creates an approval_request (bypassing policy lookup) and
stamps requester_kind='agent' + agent_turn_id. Required-role defaults
to 'associate' so the deadlock check has a non-NULL threshold; m's
lock-in for Q11 (every agent suggestion needs the user's eye) means
bypassing the policy gate is correct here, not a regression.
- The shared `submit` kernel takes an optional agent_turn_id pointer.
All four lifecycle entry points (SubmitCreate / SubmitUpdate /
SubmitComplete / SubmitDelete) pass nil; SubmitAgentCreate passes
the turn id. INSERT to approval_requests now writes both
requester_kind + agent_turn_id atomically (xor-check on the schema
enforces consistency).
- models.ApprovalRequest grows the two columns + their JSON tags so
the inbox view + Verlauf renderer can read provenance without an
extra fetch.
- approvalRequestViewColumns adds ar.requester_kind + ar.agent_turn_id
to the SQL projection; both surfaces (ListPendingForApprover,
ListSubmittedByUser, GetRequest) inherit the new fields free.
- CreateDeadlineInput + CreateAppointmentInput each get an optional
AgentTurnID *uuid.UUID. When non-nil, the create-tx routes through
SubmitAgentCreate instead of the regular SubmitCreate. Default-zero
behaviour is unchanged for every existing caller.
- handlers/paliadin_suggest.go is the new HTTP layer. Owner-gated via
requirePaliadinOwner (same gate /paliadin uses), JSON-bodied,
RFC3339 + ISO-date validation, 409 + a useful message on
ErrNoQualifiedApprover.
- Project-event audit metadata gains requester_kind + agent_turn_id so
the project's Verlauf can render "Paliadin hat eine Frist
vorgeschlagen ✨" without joining approval_requests (Slice E reads
this).
SKILL.md (~/.claude/skills/paliadin/SKILL.md) gains an "Agent-suggested
writes" section with the tool catalog, behaviour rules ("never write
directly", confirmation in the response file, project_id lookup
discipline, RFC3339 dates, no chained tool calls per turn), and the
409 error contract.
go build + go vet + go test all clean. No frontend changes in this
slice — Slice E lights up the ✨ on existing eye-pill surfaces.
Refs: docs/design-paliadin-inline-2026-05-08.md §7.
The inline Paliadin chat surface — reachable from every authenticated
page, replacing the standalone /paliadin route as the primary entry
point. The standalone page survives as the dedicated full-screen mode
(the drawer's "↗ fullscreen" action links to it).
Components:
- frontend/src/components/PaliadinWidget.tsx — emits the floating
trigger button (bottom-right, lime ✨, owner-revealed by JS), a
scrim, and the right-edge slide-out drawer with header (reset /
fullscreen / close), context chip, message stream, empty-state
starter list, and textarea+send form. Loads /assets/paliadin-widget.js.
- frontend/src/client/paliadin-widget.ts — runtime. /api/me probe
reveals the trigger when caller matches PaliadinOwnerEmail (with
optional is_paliadin_owner flag fast-path); Cmd+J / Ctrl+J shortcut
toggles open/close (Cmd+K stays reserved for global search per
client/search.ts). Uses computePaliadinContext() (Slice B) per send
so route + entity + selection flow into every turn. SSE consumer
writes assistant bubbles; localStorage persists per-session history.
- frontend/src/client/paliadin-starters.ts — per-route starter prompt
registry. 14 routes covered (dashboard, projects.*, deadlines.*,
appointments.*, agenda, events, inbox, tools.*, glossary, courts) +
a _default fallback. Bilingual (DE/EN); prompts ending in `: ` seed
the textarea for the user to finish; fully-formed prompts auto-send.
- 39 authenticated TSX pages get a `<PaliadinWidget />` element after
`<Footer />` via a mechanical pass. paliadin.tsx (the standalone)
is intentionally excluded — its dedicated UI is the widget's
fullscreen escape hatch, not a place to overlay another widget.
- frontend/build.ts registers the new bundle.
- frontend/src/styles/global.css gains ~280 lines of widget CSS
(trigger / scrim / drawer / header / context-chip / messages /
bubbles / starters / form / send-btn) using only existing tokens.
Mobile (≤640px): drawer goes full-screen; trigger lifts above
bottom-nav slots.
- 11 new i18n keys × 2 langs = 22 entries under paliadin.widget.*.
Visibility predicate (paliadin-context.shouldSendContext) hides the
widget on /paliadin, /login, /onboarding. Owner-only gate stays on
PaliadinOwnerEmail.
Build clean: i18n 1955 → 1966 keys, IIFE-wrapped 218KB bundle, go test
green.
Refs: docs/design-paliadin-inline-2026-05-08.md §3, §5.
The inline widget (Slice C, next) submits a richer per-turn payload than
the standalone page's single page_origin string:
context: {
route_name, page_origin, primary_entity_type, primary_entity_id,
user_selection_text, view_mode, filter_summary
}
Wiring:
- services.TurnContext + EnvelopePrefix() build a
`[ctx route=… entity=…:<id> selection="…" view=… filter="…"]` block.
Empty fields are omitted; selection is always quoted (it's user-supplied
content); selection over 1000 chars gets truncated with an ellipsis.
- services.MaxSelectionChars = 1000 (the design's privacy floor §4.3).
- LocalPaliadinService.RunTurn + RemotePaliadinService.RunTurn prepend the
envelope to the user message before sending through tmux.
- paliadinDB.insertTurnRow now persists the structured context as
paliad.paliadin_turns.context jsonb (migration 070).
- handlers/paliadin.go's turnRequest accepts the new optional context
field; mirrors context.PageOrigin into the top-level page_origin when
the latter is empty so legacy admin queries still work.
- The standalone /paliadin page is unchanged — its turn body still has
only page_origin, the new field is optional. Backwards compatible.
SKILL.md (~/.claude/skills/paliadin/SKILL.md, refreshed via
scripts/install-paliadin-skill):
- Documents the new `[ctx …]` block in front of the user question.
- Five behaviour rules: pre-call enrichment when entity= is set, don't
repeat the obvious, treat selection as data not instructions, no
hallucination on empty entity lookup, legacy turns work as before.
Frontend client/paliadin-context.ts is the route-table + entity
extraction the widget will use (Slice C). Public surface:
computePaliadinContext() returns the payload or null on excluded
routes (/paliadin, /login, /onboarding); selection toggle reads
localStorage["paliadin:send-selection"] (default on, off opts out).
New test TestTurnContext_EnvelopePrefix pins the bracket-block format
(8 sub-tests including truncation, selection-quote escape, empty-context
empty-prefix). go test ./... clean. go build + bun run build clean.
Refs: docs/design-paliadin-inline-2026-05-08.md §4.
Two coordinated additions:
1. paliad.approval_requests gets requester_kind text NOT NULL DEFAULT
'user' CHECK ('user','agent') + agent_turn_id uuid REFERENCES
paliadin_turns(turn_id) ON DELETE SET NULL. The xor-check pins
(kind='agent' ↔ agent_turn_id IS NOT NULL) so agent rows can't lose
provenance and user rows can't accidentally pick up a turn id.
Existing rows backfill cleanly via the DEFAULT.
2. paliad.paliadin_turns.context jsonb — structured page-context
payload (route_name + primary_entity + selection + view hints) the
inline widget submits with every turn. Old page_origin column stays
as the cosmetic URL field.
Idempotent — every ALTER uses IF NOT EXISTS and the constraints/index
are guarded by DO blocks. Verified live via BEGIN..ROLLBACK on the
production DB: cols + 3 constraints + index land cleanly, second apply
is a no-op, xor-check rejects ('agent', NULL).
Skipped the optional PaliadinRelay interface extraction per the design
doc's own §6.4 caveat: paliadin.go + paliadin_remote.go already share
the paliadinDB substrate cleanly; introducing an interface now would
duplicate without removing duplication. Reserves the seam for the
future API cutover without paying its cost today.
Refs: docs/design-paliadin-inline-2026-05-08.md §7.1, §4.2, §6.4.
Two intertwined Paliadin upgrades, scoped together because the chat
surface is where the write path is triggered and the write path is what
makes the chat non-trivial:
1. Inline slide-out modal reachable from every authenticated paliad
page, with structured page-context payload (route_name +
primary_entity + selection text) and per-route starter prompts.
2. Agent-suggested write path that drafts deadlines/appointments/notes
into the existing pending_create lifecycle (t-paliad-160) with new
provenance columns on approval_requests (requester_kind + agent_turn_id);
approved-from-agent rows render alongside 👀 with a sparkle ✨.
Hard call: keep the existing tmux relay for v1; recommend (but do not
commit) the Anthropic API cutover as a prerequisite for opening beyond
owner-only. Single Paliadin persona — no scope-bouncer pre-design.
Inventor parked. DESIGN READY FOR REVIEW. Awaiting m's go/no-go before
any coder shift.
Refs: m/paliad#20, t-paliad-146, t-paliad-160, t-paliad-138.
fdbbc74 — feat(fristenrechner/cascade): more opponent-side proceeding
types in the B1 cascade. Adds 5 parents (UPC Berufung, UPC einstweilige
Maßnahmen, DE BGH Revision/NZB, DE BGH Berufung Nichtigkeit, DPMA
Rechtsbeschwerde) + 17 leaves under cms-eingang.gegenseite. Forums-
tagged for the inbox-channel chip and proceeding_type_code-narrowed
so result cards land on the right rules. Migration is idempotent
(ON CONFLICT clauses).
Live tracker is already at v69 (feynman applied during dev). Deploy
will see tracker=69 with file 069 present and have nothing to apply.
Role variants (Klägerseite vs Beklagtenseite) deferred to the
Determinator redesign per m's earlier scope decision.
m's 2026-05-08 17:41 batch Item 4: today
`cms-eingang.gegenseite` exposes UPC INF/REV, DE INF/NULL, EPA OPP/APP,
DPMA OPP — but is missing the appellate / interim-measures arms m
named. m: "we need a lot more proceeding types for Opponent submission
— we currently only see Verletzungsverfahren."
Migration 069 adds 5 new parent nodes and 17 leaves under
`cms-eingang.gegenseite`:
- upc-app UPC Berufungsverfahren — 5 leaves: Berufungs-
schrift, -begründung, -erwiderung, Anschluss-
berufung (R.237), Erwiderung dazu (R.238).
Concepts: notice-of-appeal, statement-of-
grounds-of-appeal, response-to-appeal,
cross-appeal, reply-to-cross-appeal.
- upc-pi UPC einstweilige Maßnahmen — 2 leaves: PI-
Antrag, PI-Erwiderung. Concepts: application-
for-provisional-measures, statement-of-defence.
- de-bgh-inf DE Revision / NZB BGH (Verletzung) — 5 leaves:
NZB, NZB-Begründung, Revisionsschrift,
Revisionsbegründung, Revisionserwiderung.
Concepts: nichtzulassungsbeschwerde,
nichtzulassungsbeschwerde-begruendung,
revisionsfrist, revisionsbegruendung,
response-to-appeal.
- de-bgh-null DE Berufung BGH (Nichtigkeit) — 3 leaves:
Berufungsschrift, -begründung, -erwiderung.
Concepts: notice-of-appeal,
statement-of-grounds-of-appeal,
response-to-appeal.
- dpma-bgh DPMA Rechtsbeschwerde BGH — 2 leaves:
Rechtsbeschwerde, RB-Begründung. Concepts:
rechtsbeschwerde, rechtsbeschwerde-begruendung.
Each parent + leaf carries the matching forums tag so the
inbox-channel chip (#15) hides / shows the subtree correctly. The
event_category_concepts junction sets proceeding_type_code per leaf
(UPC_APP / UPC_PI / DE_INF_BGH / DE_NULL_BGH / DPMA_BGH_RB) so the
result card pills only the relevant proceeding's rules.
All INSERTs use ON CONFLICT (slug) DO UPDATE so re-running the
migration after a partial apply is safe. Mig applied to live Supabase;
tracker at v69.
Refs m/paliad#15.
Three commits from mai/feynman/fristenrechner:
- 614f9af fix(approval-pill): two-eyes glyph 👀 instead of single SVG eye
on /deadlines + /appointments + /agenda. m's preference: emoji denotes
"being looked at" closer to "wartet auf Genehmigung" semantics.
- 2d6ea3e feat(deadline-rules/is-optional): conditional rules opt-in via
save modal. Adds paliad.deadline_rules.is_optional. Distinct from
is_mandatory: a rule can be statutorily fixed when it applies AND
conditional on whether it applies (RoP.151 cost-decision request,
appeal-related deadlines). Save-modal pre-unchecks optional rows;
user toggles to opt in. Timeline shows "auf Antrag" pill.
- 097e21c feat(fristenrechner): proceeding-picker collapses to one-line
"Verfahren: X · [Reselect]" pill after pick (saves vertical space).
Column view becomes the default for the timeline (was previously
whichever-default; m wants Column on first render).
Migration housekeeping:
feynman's migration was authored as 066 on his branch but main has
already taken 066/067 via shannon's t-paliad-160 (approval policy
split + drop required_role). Renumbered to 068 during merge to
resolve the same-number collision. Added ADD COLUMN IF NOT EXISTS
to make the up-migration idempotent (defensive for environments
where the column was already applied out-of-band during dev). The
RoP.151 backfill UPDATE is naturally idempotent.
Live tracker bumped from 66 → 68 to reflect schema reality before
this merge: shannon's 066+067 effects and feynman's is_optional
column are all already present in the live youpc Supabase. The
next deploy will see tracker=68 and have nothing to apply.
Refs m/paliad#15, m/paliad#18 (rule-Typ contradiction filed against
Item A scope, not part of this batch).
m's 2026-05-08 18:26 dogfood batch — two pure UX tweaks on the
Verfahrensablauf wizard:
1) Collapse the proceeding-picker once a Verfahren is chosen. Replaces
the four-group block (UPC / DE / EPA / DPMA, ~25 buttons total)
with a one-line "Verfahren: X · [Anderes Verfahren wählen]" pill.
Reselect re-expands without throwing away the rest of the wizard
state (trigger date, flags, calc result stay put until the user
actually picks again). reset() also re-expands.
2) Column view as the default for step 3. The proactive / court /
reactive grid reads more naturally for the HLC team than the
single vertical timeline. URL semantics flipped: ?view=timeline
now opts back into the legacy view; absence of ?view= yields
columns. Share links stay clean.
Files:
- frontend/src/fristenrechner.tsx — new .proceeding-summary
markup; the view-toggle radio order swapped so "Spalten" is the
first / checked option.
- frontend/src/client/fristenrechner.ts — setProceedingPickerCollapsed
helper toggles the four .proceeding-group blocks vs the summary;
selectProceeding collapses, reset() + Reselect re-expand.
procedureView default flipped to "columns"; initViewToggle URL
semantics inverted.
- frontend/src/client/i18n.ts — 2 new keys (DE+EN) for the
summary label + Reselect button.
- frontend/src/styles/global.css — .proceeding-summary +
.proceeding-summary-reselect styles, all bound to the existing
--color-* token palette.
Refs m/paliad#15 dogfood thread (m's 2026-05-08 18:26 batch).
m's 2026-05-08 batch Item 2: some rules don't always apply per-instance.
Antrag auf Kostenentscheidung (RoP.151) only fires when a party files
for it; some appeal-related deadlines depend on specific facts. Today
they render in the timeline as if always applicable; the save-to-
project modal pre-checks them so the user has to remember to uncheck.
New paliad.deadline_rules.is_optional bool flag (default false). Threads
through the Go model, ruleColumns SELECT, UIDeadline JSON, and the
frontend save modal:
- Migration 066 adds the column + comment + a starter UPDATE that
flips RoP.151 to is_optional=true. m can flip more via SQL as he
reviews the rule library — distinct from is_mandatory, which is
about statutory strictness once the rule applies (an "auf Antrag"
rule can be is_mandatory=true once requested).
- Save modal: optional rows pre-uncheck (the user opts in) and a
small "auf Antrag" / "on request" pill renders in the meta line.
Court-determined rows still pre-uncheck via the existing disabled
path; isOptional doesn't override that.
Migration applied to live Supabase; tracker at v66.
Refs m/paliad#15 (m's 2026-05-08 18:21 follow-up batch Item 2).
m's 2026-05-08 18:21 follow-up: "two eyes instead of the one." The
single-eye SVG read as a watching-Eye-of-Sauron glyph; 👀 reads as
"under review" / "being looked at" — closer to "wartet auf
Genehmigung" semantics.
Drops the inline SVG + the .approval-pill--icon svg sizing rule;
replaces with the literal emoji as the pill's text content. CSS
modifier becomes a small auto-width text pill (min-width 28px so
single emoji stays nicely round-ish at higher densities).
Renamed APPROVAL_PILL_EYE_SVG → APPROVAL_PILL_GLYPH in both events.ts
and agenda.ts since the constant is no longer SVG.
m's 2026-05-08 cosmetic ask: the "Wartet auf Genehmigung" badge ate
row width and read as a noisy block of text on every pending row.
Replace with a 22px eye-icon pill; the lifecycle label moves to the
hover tooltip (title attr + aria-label so screen readers still get
the full text).
Three pieces:
- global.css — new .approval-pill--icon modifier sets the pill to
a circular 22×22 hit target with a centered SVG. Base
.approval-pill (text-pill behavior) and --historic (inbox status
pill) stay untouched so the inbox surface keeps rendering the
full status + decider name.
- client/events.ts (the /deadlines + /appointments shell) and
client/agenda.ts each get a tiny APPROVAL_PILL_EYE_SVG constant
+ the new --icon class on the pending pill. Two definitions
(no shared icons module today; no other surfaces need this glyph
yet) — the duplication is two lines, easier to read than yet
another import.
What it looks like: 👁 in a soft amber circle, hovers to "Änderung
wartet auf Genehmigung" / "Erledigung wartet auf Genehmigung" / etc.
The lifecycle-specific label kept (no schema work) — Maria gated this
slice as pure-frontend; the richer "wartet auf Genehmigung von
<role>; angefragt am <date>" tooltip needs a backend join we're not
doing here.
Refs t-paliad-160 §C / m's 2026-05-08 18:15 batch Item B.
m's 2026-05-08 17:50 feedback: 'Antrag auf Kostenentscheidung' (RoP.151)
labels itself "wird vom Gericht bestimmt" but the rule is actually
"1 Monat ab Hauptentscheidung". The court doesn't directly determine
this date — it determines the parent's date (Hauptentscheidung) and
this rule chains off that. Calling it "vom Gericht bestimmt" overstates
the relationship; "unbestimmt" reads correctly: derived from a
not-yet-known anchor.
Two failure modes split:
- Direct court-set rule itself is hearing / decision / order
(or primary_party='court'). Label stays
"wird vom Gericht bestimmt" — strictly correct.
- Indirect court-set rule has a real duration but its anchor is a
court-set parent (RoP.151 case), or it's a
zero-duration rule whose parent is court-set
without a real date. Label flips to
"unbestimmt".
Backend: new `IsCourtSetIndirect bool` on UIDeadline, set on the three
indirect cases inside FristenrechnerService.Calculate. Direct cases
keep IsCourtSetIndirect=false so their label stays unchanged. JSON
omits the field when false, no consumer churn.
Frontend: deadlineCardHtml + the save-modal row both consult
IsCourtSetIndirect to pick between two i18n keys (deadlines.court.set
"vom Gericht bestimmt" and deadlines.court.indirect "unbestimmt"; EN
falls back to "set by court" / "tbd"). The override edit affordance
keeps working unchanged — user types the actual parent date, downstream
re-flows.
Refs m/paliad#15 (m's 2026-05-08 17:50 feedback Item 1).
m's 2026-05-08 feedback: the inbox-channel chip is a Determinator step,
not a page-level prefilter — "Verlauf does not need to see that so it
cant be outside of that."
Changes:
- frontend/src/fristenrechner.tsx — strip the .fristen-inbox-bar
markup from above the pathway fork; mount it instead at the top
of #fristen-b1-panel, before the cascade. The chip is now visible
only when the user enters Pathway B → tree mode.
- frontend/src/client/fristenrechner.ts — drop the .proceeding-group
visibility loop from applyInboxFilter. Pathway A's wizard is no
longer filtered by the chip. The data-forum attributes stay on
the markup as documentation of intent but no longer drive
visibility.
What stays:
- persistence (paliad.users.forum_pref via PATCH /api/me)
- URL ?inbox= override
- B1 cascade narrowing via paliad.event_categories.forums
- B2 fine-bucket activeForums sync (B2 lives inside the
Determinator too)
Refs m/paliad#15 (m's 2026-05-08 17:50 feedback).
- Slice 1+2 (3a41aa9): schema split required_role → requires_approval boolean + min_role text via mig 066 with two-step dual-read; resolver most-strict-wins via approval_role_level(); ErrAlreadyPendingApproval / ErrNoQualifiedApprover / ErrInvalidInput → 409 Conflict with structured body via mapApprovalError helper.
- Slice 2 (073af97): /admin/approval-policies UI flip — 2-control checkbox + role dropdown replacing the 'none' sentinel option; pending-approval badge on deadline + appointment detail pages; 'Genehmigungsanfrage zurückziehen' button wired to existing Revoke endpoint; /approvals 'Meine Anfragen' visibility hardening (filter regression + inbox count badge sync).
- Slice 3 / M2 (aec6cf6): drop required_role column once writers cut over via mig 067.
- 3368aa5: renumber 064/065 → 066/067 to avoid collision with feynman's t-157 migrations 064 (users.forum_pref) + 065 (event_categories.forums) that landed first.
- 9350cd0: merge origin/main into branch to absorb feynman's slices.
Closes m's open dogfood gap from t-138 (cronus) + t-154 (hilbert): the 500 on already-pending now becomes a friendly 409, the entity detail page surfaces the pending state, the user can withdraw their own request explicitly, and /approvals lists their pending-mine entries. m's locked redesign (2026-05-08 16:40) of split + most-strict-wins shipped end-to-end. NOT cronus.)
Cleanup of the t-paliad-160 dual-read shim. After slice 1+2 every writer
hits both `required_role` and the new `(requires_approval, min_role)`
columns, and every reader prefers the new ones. M2 (migration 065) drops
the legacy column from `paliad.approval_policies` and rewrites
`paliad.approval_policy_effective()` to a 4-column return shape.
`paliad.approval_requests.required_role` is intentionally untouched —
that's the in-flight policy snapshot at submission time, a separate
concern from the policy authoring grammar.
Go side:
- models.ApprovalPolicy and models.EffectivePolicy lose RequiredRole.
The MinRole pointer is now the only seniority-threshold surface.
- LookupPolicy / GetEffectivePolicyOne / List* / snapshotProjectRows
drop the required_role SELECT projection.
- UpsertProjectPolicySplit / UpsertUnitPolicySplit /
DeleteProjectPolicy / DeleteUnitPolicy / ApplyMatrixToDescendants
drop the required_role write. The audit-log row still uses the
legacy string format ('partner|...|none'); composed via
legacyFromSplit() from the new columns so the audit table layout
keeps working without a parallel migration.
- submit() reads policy.MinRole directly (LookupPolicy guarantees
non-nil when a non-nil policy is returned).
- nullToPtr helper retired (no remaining callers).
Frontend side:
- admin-approval-policies.ts UnitPolicy / EffectivePolicy lose the
legacy required_role optional. The 2-control UI was already on the
split-grammar path.
- deadlines-new.ts + appointments-new.ts form-time hint readers prefer
requires_approval+min_role. They keep a soft-fall back to the
legacy required_role for one cycle in case any cached pre-M2 server
is still serving the old shape — that path is dead-code post-deploy
and can be dropped later.
Test:
- TestApprovalService_PolicyCRUD asserts MinRole instead of
RequiredRole after re-upsert.
Build: bun build OK, go build ./... OK, go test ./... OK.
Deploy ordering: this slice MUST land after slice 2 is merged so the
pre-deploy code paths that still reference required_role have already
been retired.
A3 — admin/approval-policies 2-control flip:
Each cell becomes [✓] requires_approval checkbox + role select + clear
button. The "none" option in the role dropdown is gone — the checkbox
replaces it. Role select is greyed when the checkbox is off (gate
closed). Clear button explicitly drops the cell back to inheritance.
Project matrix surfaces inherited "no approval" state with its own
attribution chip ("Geerbt · keine Genehmigung") so admins can tell a
silently-inherited off-state from a never-authored cell.
PUT /api/.../approval-policies/{entity}/{lifecycle} accepts the new
shape `{requires_approval: bool, min_role: string|null}` while still
honouring the legacy `{required_role: "..."}` body during the M1
dual-read window (decodePolicyBody routes to UpsertProjectPolicySplit
vs UpsertProjectPolicy accordingly).
C+E — Pending-approval badge + Withdraw button:
deadlines-detail + appointments-detail surface a "Wartet auf
Genehmigung" badge when approval_status='pending'. Hover-tooltip
carries requested_at + required_role + requester_name. Action
controls (Complete, Edit, Delete) freeze while pending — caller
would get a 409 anyway, no point letting them try.
Withdraw button visible only to the requester (me.id ===
pending_request.requested_by). Click → POST /api/approval-requests/
{id}/revoke (existing endpoint, no new server route). On success,
the entity flips back to approval_status='approved' and the page
re-renders with normal controls.
Complete button now handles 409 from the server gracefully:
surfaces the new mapApprovalError body's `message` instead of
silently disabling itself.
D — /inbox "Meine Anfragen" visibility hardening:
Three defence-in-depth fixes for the "tab shows empty" report:
1. handlers force `[]` (not Go-nil → JSON null) on every inbox
endpoint so the frontend never trips on `rows.length` of null.
2. parseInboxFilter validates ?status= against an allowlist
(pending|approved|rejected|revoked|superseded). Anything else
is silently dropped — a stray ?status=foo from a stale
frontend build can no longer shadow rows out of the result.
entity_type filter same treatment (deadline|appointment).
3. Frontend inbox.ts coerces null body → [] so older / cached
builds talking to the new server still don't crash.
Test coverage: TestParseInboxFilter_DropsUnknownStatus +
TestApprovalService_ListSubmittedByUser_PendingVisible (live-DB,
skipped without TEST_DATABASE_URL).
Build clean: bun build OK, go test ./... OK.
Defers: M2 (drop required_role column) — only fires once all
in-tree writers are confirmed off the legacy column path.
m's locked redesign (2026-05-08 16:40): replace `required_role` (with
'none' sentinel) with two columns — `requires_approval boolean` (the
gate) + `min_role text` (the seniority threshold). Cleanly separates
"approval applies at all" from "who's allowed to approve".
M1 phase: additive migration 064 adds the columns, backfills from the
legacy required_role ('none' → false/NULL; else → true/role), and
rewrites paliad.approval_policy_effective() to most-strict-wins:
- requires_approval := bool_or across project + ancestor + unit_default
- min_role := MAX(approval_role_level) among requires_approval=true
The legacy required_role column survives this slice as a dual-read
mirror (resolver returns it too) so any caller that hasn't cut over
keeps working. M2 will drop required_role.
Service layer (approval_service.go): LookupPolicy + GetEffectivePolicyOne
read the new columns; UpsertProjectPolicySplit / UpsertUnitPolicySplit
accept the new shape directly; legacy UpsertProjectPolicy /
UpsertUnitPolicy stay as thin shims that map required_role through
splitFromLegacy(). ApplyMatrixToDescendants writes both columns.
Handler 409 mapping (§B): writeServiceError now consults a shared
mapApprovalError() helper before falling through to the generic 500.
ErrConcurrentPending → HTTP 409 with body
{code: "awaiting_approval", message, request_id?, required_role?}.
PendingApprovalError wraps ErrConcurrentPending with the in-flight
request id + role so the UI knows which request to point a withdraw
button at. ErrNoQualifiedApprover, ErrSelfApproval, ErrNotApprover,
ErrRequestNotPending all mapped consistently. writeApprovalError
now defers to the same helper for shape consistency.
Models: ApprovalPolicy + EffectivePolicy gain RequiresApproval/MinRole
fields. RequiredRole stays as a dual-read mirror until M2.
Tests: TestMapApprovalError_* covers the four 409/403 branches and the
"no match — fall through" case. Existing approval service tests pass
unchanged.
Defers per task spec to follow-up slices:
- A3 (admin UI 2-control flip)
- C+E (badge + withdraw button on detail pages)
- D (/inbox Meine Anfragen visibility fix)
- M2 (drop required_role column)
Completes the #15 vision: the inbox chip now narrows the B1 decision
tree alongside Pathway A's picker and B2's fine-bucket forum filter.
Picking CMS hides DE / EPA / DPMA cascade entries; picking beA /
Posteingang hides UPC / EPA / DPMA entries. Neutral nodes (top-level
branches, Mündliche Verhandlung sub-states, court-generic events like
Ladung / Kostenfestsetzung) stay visible from every inbox setting so
the user can always reach the cross-jurisdictional middle of the tree.
Migration 065 adds paliad.event_categories.forums (text[]) with a
CHECK on {upc, de, epa, dpma}, a partial GIN index, and a two-step
backfill:
1. Regex on slug for nodes that carry the forum token explicitly.
Token-bounded by ^/./- so .dpma doesn't trip the de pattern.
2. Explicit slug list for stragglers (BGH / BPatG / Versäumnisurteil /
Hinweisbeschluss are DE-only; r116-eingaben is EPA-only).
NULL stays neutral. Migration applied to live Supabase; tracker at v65.
Backend: EventCategoryNode JSON gains an optional `forums` array;
EventCategoryService.Tree SELECT includes the column and threads it
through to the response.
Frontend: new module-level currentInboxChannel mirrors the chip state
so renderB1Cascade can ask "which forum is active?" without re-deriving
from the URL on every step. inboxFilterAllowsForums(forums) gates each
child node — neutral arrays (undefined / empty) always pass; tagged
arrays must include the active forum. applyInboxFilter re-renders the
cascade so chip clicks reflow B1 in place. Pathway A picker filter
and B2 fine-bucket sync remain orthogonal — same chip, three filters.
Refs m/paliad#15 (B1 follow-up).
The page-top inbox chip now drives the existing 10-bucket B2 forum
filter so search results inherit the same narrowing as the Pathway A
picker. CMS auto-selects upc_cfi + upc_coa; beA / Posteingang
auto-select de_lg + de_olg + de_bgh + de_bpatg; "Alle" clears both the
inbox and the fine chips.
State-priority discipline:
- User chip click always replaces activeForums with
the inbox-derived set.
- Hydrate (URL ?inbox=, no
?forum=) derives activeForums from inbox.
initForumFilter (which runs first)
has already loaded URL ?forum= when
present, so the explicit forum= wins.
- Hydrate (no URL ?inbox=,
/api/me forum_pref set) same as above: derive when ?forum=
is empty.
- popstate re-applies the same rule so browser
back/forward stays consistent.
The "explicit URL forum= wins" rule means a colleague's CMS-narrowed
share link still works when the recipient has a different saved pref —
they see the shared narrowing, not their own.
Refs m/paliad#15.
Adds a slim chip strip above the pathway fork on /tools/fristenrechner
so the user can pick the inbox channel they typically work in (CMS for
UPC, beA / Posteingang for national-DE). Three behaviours stack:
- URL ?inbox=cms|bea|posteingang per-visit override; lets a colleague
share a CMS-narrowed link without
flipping the recipient's saved pref.
- /api/me forum_pref the user's persisted default,
fetched on hydrate when no URL.
- unset picker shows all four groups.
Click behaviour: write URL → apply filter (hide non-matching
.proceeding-group via the new data-forum attributes) → PATCH /api/me.
The "Alle" chip clears both URL and the saved pref. EPA / DPMA fall
out under cms / bea / posteingang; users still reach those via B2
search or by clearing the chip.
Frontend pieces:
- frontend/src/fristenrechner.tsx — new .fristen-inbox-bar markup
above the pathway fork; data-forum attributes on each
.proceeding-group so the filter knows which to hide.
- frontend/src/client/fristenrechner.ts — initInboxFilter() hydrates
from URL → /api/me, wires chip clicks (write URL, apply filter,
PATCH /api/me opportunistically), restores on popstate.
- frontend/src/client/i18n.ts — 6 new keys (deadlines.inbox.*) DE+EN.
- frontend/src/i18n-keys.ts — codegen picked up the new keys.
- frontend/src/styles/global.css — .fristen-inbox-bar /
.fristen-inbox-chip / --active / --clear styles, all bound to the
existing --color-* / --color-accent token palette.
The chip writes "" to forum_pref to clear (matching the
EscalationContactID convention from the previous slice). The B2 forum
filter (the 10-bucket finer-grained chip set further down the page)
stays untouched and orthogonal — this slice is the page-top coarse
pre-filter only.
Refs m/paliad#15.
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
Adds paliad.users.forum_pref so /tools/fristenrechner can pre-narrow
the proceeding picker to the user's typical inbox channel without
re-asking on every visit. The new column threads through the User
model, the userColumns SELECT, and UpdateProfileInput so the existing
PATCH /api/me handler accepts it without a new endpoint.
Allowed values mirror the channel chips m named in t-paliad-157:
- cms → UPC
- bea → national-DE
- posteingang → national-DE (slower channel, same forums)
NULL means "no preference, picker shows everything"; URL ?inbox=
overrides per-visit (frontend lands in the next commit). The CHECK
constraint enforces the 3-value enum at the DB layer; isValidForumPref
mirrors it in the service so callers see a typed error instead of a
raw pq violation. Empty string in the PATCH body clears the
preference, consistent with the EscalationContactID convention.
Migration 064 applied to the live Supabase pool; tracker bumped to
v64 so the boot-time runner skips re-applying.
Refs m/paliad#15.
8 RoP sections cross-referenced against paliad's deadline_rules library
via the youpc data.laws_contents authoritative text.
Two high-impact duration bugs found:
- rev.defence: 3 months seeded, RoP R.49.1 says 2 months
- rev.rejoin: 2 months seeded, RoP R.52 says 1 month
Both UPC_REV pleadings rules — every active Nichtigkeitsverfahren
tracked in paliad has miscalibrated reminders today. Single-row
UPDATEs fix both.
Plus rule_code drift on UPC_APP (R.220.1 used where R.224.1.a /
R.224.2.a / R.235.2 should be cited), R.51 / R.52 NULLs on REV
chain, and 25 missing rules ordered by frequency (R.19, R.262.2,
R.224.2.b, R.235.1, R.333.2, R.353, registry-correction family,
saisie + PI gaps, R.109 oral-hearing prep, R.245 rehearing, etc).
Plus an anchoring nuance on UPC_APP_ORDERS.app_ord.discretion
(R.220.3) — Pathway A may compute up to 15d too early because
the rule anchors on order, not on leave-refusal event.
Wave 0 (duration bugs) is the recommended first migration.
Wave 1+ orderings, tooling-blocked rules (R.198/R.213/R.245.2),
and m's open questions (proceeding-code naming, R.245 scope,
DNI scope) listed in §6, §7.
The "Frist verpasst" cascade covered DE PatG/ZPO, EPA Art.122 and DPMA
Wiedereinsetzung paths but had no UPC option, even though UPC R.320 RoP
grants re-establishment of rights with the same shape (2 months from
removal of the obstacle, 12-month outer limit).
Migration 063 adds:
- trigger_event id 207 "Wegfall des Hindernisses (UPC R.320)" tied to
the existing wiedereinsetzung concept, so the concept card picks
up a UPC pill alongside DE / EPA / DPMA.
- event_categories leaf frist-verpasst.upc at sort_order 50 so UPC
reads first under "Frist verpasst" (national + EPA siblings stay
at 100/200/300/400).
- event_category_concepts junction linking the new leaf to the
wiedereinsetzung concept; NULL proceeding_type_code mirrors the
sibling pattern (cross-cutting trigger pills bypass the forum
filter by design — per-leaf narrowing is part of the IA-reframe
issue #16).
Migration applied to live Supabase; matview refreshed; tracker bumped
to v63 so the boot-time runner skips re-applying.
Refs m/paliad#14 section C.
Undated events (Urteil, Beschluss, court-set placeholders) were keyed by
the empty string and collapsed into a single trailing row, so Urteil and
Berufungseinlegung ended up adjacent even though Urteil precedes Berufung
in the proceeding's sequence_order. Each undated event now gets its own
row keyed by its index in the backend response (which is already sorted
by sequence_order), and dated/unscheduled keys are sorted into separate
buckets before concatenation so the dateless tail still sits below the
dated rows.
Refs #14 (section D).
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
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.
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.
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.
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.
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