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