Phase 2 slice of the universal-filter migration (Phase 1 was
t-paliad-163 → /inbox; remaining /agenda /events /deadlines
/appointments stay queued).
What ships:
- FilterBar gains two non-invasive options that future surfaces will
also need:
customRunner — bypass the substrate POST and hand the effective
spec to a surface-supplied runner. Required by
surfaces whose data path can't move to the substrate
yet (Verlauf still uses /api/projects/{id}/events for
subtree expansion + cursor pagination, both absent
from the substrate's project_event runner).
timePresets — per-surface override of the time chip cluster, so
backward-looking surfaces can show past_*+all without
forcing forward-looking next_* chips on every host.
systemViewSlug becomes optional; the bar enforces "exactly one of
customRunner | systemViewSlug" at construction.
- project_event_kind axis renderer (was a null stub) — chip cluster
over KnownProjectEventKinds, labels reuse the existing
event.title.<kind> i18n table so the chip text matches the Verlauf
row title for the same kind.
- HorizonPast7d added end-to-end (substrate validate +
computeViewSpecBounds; FilterBar TimeOverlay + parseHorizon; views
TimeHorizon mirror) so the chip value is valid in every layer when a
later SystemView reuses it.
- Verlauf tab on /projects/<id> mounts the bar with
axes=["time","project_event_kind"], timePresets=
["past_7d","past_30d","past_90d","any"], showSaveAsView=false. The
customRunner reads predicates.project_event.event_types + time.horizon
off the effective spec, sets a verlaufFilters global, and routes
through the legacy loadEvents/loadMoreEvents pipeline (which now
applies the filter set client-side and tracks raw cursor IDs so
"Mehr laden" still walks the underlying pagination boundary even when
most rows get filtered out of a page).
- Subtree toggle drives loadEvents through verlaufBar.refresh() so the
current filter state survives the toggle.
URL state reuses the bar's existing keys (?time=past_30d, ?pe_kind=…).
Empty filter → identity passthrough → current behaviour preserved.
Out of scope (deferred to t-paliad-169 SmartTimeline):
- Migrating Verlauf to the substrate (needs scope-with-descendants)
- Past/future split, dated/undated split, source-track facet
Refs m/paliad#23.
m's 2026-05-08 22:08 dogfood: rule '§ 276 Abs. 1 S. 2 ZPO — Klageerwiderung'
(DE) auto-filled to 'Klageerwiderung' label but the chosen event_type was
upc_statement_of_defence (UPC). Both render as 'Klageerwiderung' in the
UI, but they are different legal events in different jurisdictions.
Migration 074 adds a jurisdiction column to
paliad.deadline_concept_event_types and swaps the unique-default index
from per-concept to per-(concept, jurisdiction). Backfills jurisdiction
from each event_type's own column, then re-elects DE / DPMA / EPO
defaults where a non-UPC event_type genuinely exists. Idempotent: uses
ADD COLUMN IF NOT EXISTS, ON CONFLICT DO UPDATE, partial unique index.
DeadlineRuleService.hydrateConceptDefaultEventTypes now JOINs
paliad.proceeding_types and matches on (rule.concept, rule.jurisdiction)
with EPA→EPO canonicalisation. Rules whose (concept, jurisdiction) has
no default stay NULL — silent no-op on the form, better than a wrong
jurisdictional default. UPC rules unchanged; DE rules now resolve to
de_klageerwiderung when concept = statement-of-defence, else no autofill.
Live audit confirms: every active rule now resolves to a same-
jurisdiction event_type or no event_type at all. No more cross-
jurisdiction matches in the seed.
m's 2026-05-08 22:08 dogfood, after t-paliad-163 Phase 1: "I like the
new inbox filters but now the inbox somehow does not show nothing no
more..." The new bar opened with the chip defaulted to
"approver_eligible" (the legacy "Zur Genehmigung" tab semantics —
requests EXCLUDING ones the caller authored). For users who only
SUBMIT requests and have nothing to approve themselves (incl. m,
who has 4 own pending submissions and 0 incoming), that's an empty
view.
Flip the default to "any_visible" on both ends:
- internal/services/system_views.go InboxSystemView.Filter — base
spec ViewerRole = "any_visible".
- frontend/src/client/filter-bar/axes.ts approval_viewer_role chip —
default = "any_visible" when the URL doesn't pin one. The two
defaults are intentionally redundant: the server narrows on its
default if the request omits a_role, and the chip highlights the
same option on the empty URL.
The chip still narrows. "Zur Genehmigung" + "Eigene Anfragen" stay
one click away; the bar just doesn't pre-narrow into "Zur Genehmigung"
on first visit anymore.
The "/views/inbox-mine" SystemView (slug + URL stays "self_requested")
keeps its narrower default — that route exists precisely to land on
the requester's view.
Three slices on mai/riemann/inventor-universal:
d5a01e6 Slice 1 — RenderSpec.list.row_action + validator + tests
de4e133 Slice 2 — <FilterBar> scaffolding (axes / url-codec / save-modal)
4670cd6 Slice 3 — /inbox migrates to <FilterBar>; tabs collapse to chips
What ships (Phase 1):
- A new frontend/src/client/filter-bar/ module:
types.ts — Spec + RenderSpec + AxisDeclaration types
axes.ts — registry of supported filter axes
url-codec.ts — URL ↔ FilterSpec serialization (round-tripping)
save-modal.ts — "Speichern als Sicht" dialog
index.ts — <FilterBar> mounts
Plus a url-codec.test.ts golden table.
- /inbox surface migrates to the bar:
Top-level "Zur Genehmigung / Meine Anfragen" tabs collapse into the
bar's `approval_viewer_role` chip cluster (incoming / outgoing /
both). One control, three mutually exclusive options. Stateful via
`?role=` URL param.
Bookmark-friendly: legacy `?tab=mine` + `?tab=pending-mine` redirect
to `?role=outgoing` and `?role=incoming` respectively for one
release.
Sortable column headers on the result list (list-shape only;
cards/calendar shape-modes defer their own ordering to the spec).
- RenderSpec.list gains `row_action` ("navigate" | "expand" | "none")
so list-shape surfaces declare row click behaviour explicitly. The
validator + tests cover the new field.
- system_views.go gains the inbox SystemView definitions so the bar
reads its base spec from the same registry that custom views use.
m's locked positions (commit `1e23745` design doc; m's greenlight
2026-05-08 21:47): all 11 default picks honoured. Q4 = collapse
tabs to chips ✓.
Phase 2 surfaces (port /agenda → bar; port /events → bar; port
/deadlines → bar; port /appointments → bar) follow as separate PRs.
Refs m/paliad#23.
Two slices on mai/noether/collapse-regel-typ-on:
0c12644 feat(deadline-rules): expose concept's canonical event_type per rule
1e97ecc feat(deadlines/new): auto-link Typ to Regel's concept
What ships:
- New junction paliad.deadline_concept_event_types maps every
paliad.deadline_concepts row to its canonical paliad.event_types
row(s). Many-to-many for concepts with multiple legitimate variants
(statement-of-defence ↔ base + with_ccr + no_ccr; opposition across
EPO + DPMA). Exactly one row per concept marked is_default = true
by a partial unique index — that is the row the deadline form
auto-fills with.
- Backend: paliad.deadline_rules_with_concept_event_type view + the
deadline-rules read path now expose the rule's default concept
event_type so the form has the auto-fill target without an extra
round-trip.
- Frontend deadline create / edit form: when the user picks a Regel,
the Typ chip auto-fills with the rule's concept's default event_type.
A small "vorgegeben durch Regel — überschreiben?" hint sits next to
the chip so the auto-fill is visible. The user can override (free-
text or pick a different type); the override is explicit, no
blocking validation.
- Free-text Typ stays available — manual deadlines without a
matching rule (e.g. "Call me" reminders) keep working as today.
Migration housekeeping
======================
noether authored her migration as 072 on her branch but main had
already taken 072 via minkowski's t-paliad-164 (paliad.projects.our_side).
Renumbered to 073 during merge resolution to resolve the same-number
collision. Added IF NOT EXISTS guards on CREATE TABLE / CREATE INDEX
for re-run safety (the seed INSERT already had ON CONFLICT DO NOTHING).
Live tracker bumped 72 → 73 in the same operation: both effects
(our_side column AND deadline_concept_event_types table) were
applied to live during dev (each worker against the same DB), so
the tracker advance reflects schema reality. Next deploy sees
tracker=73 with file 073 present and has nothing to apply.
Refs m/paliad#18.
Three slices on mai/minkowski/project-level-our-side:
188d8ec Slice 1 — paliad.projects.our_side column + service plumbing
5d9c62d Slice 2 — "Wir vertreten" select on the project edit form
3a41ace Slice 3 — Determinator predefines perspective from our_side
What ships:
- Migration 072 adds paliad.projects.our_side text with check constraint
IN ('claimant','defendant','court','both', NULL). Idempotent
(IF NOT EXISTS / DO blocks). NULL stays the default.
- Project model + service plumbing: OurSide *string on models.Project,
threaded into Create / Update / SELECT projections + handlers.
- Project edit form: new "Wir vertreten" select with the four options
+ "unbekannt / nicht gesetzt", DE+EN i18n.
- Fristenrechner Determinator (Slice 3c — perspective chip): when a
project is selected and our_side is set, the chip is predefined to
that value with a "vorgegeben durch Akte" hint above. The user can
still override (chip click); the override is explicit. When
our_side is NULL, the existing free-pick behaviour stays.
m's dogfood (2026-05-08 21:42): "We chose a case of ours where our
side should be predefined - yet I can make a selection for which
side we are." Now resolved end-to-end: edit the project once to set
"Wir vertreten = Klägerseite", and the Determinator perspective chip
auto-locks to that side on every subsequent visit.
/inbox is the first surface to consume the universal FilterBar. The
two-tab UI collapses into the bar's approval_viewer_role chip cluster
(per Q4 lock-in 2026-05-08 21:47); status / entity_type / time chips
are new affordances; density toggle gives the activity-feed look the
brief asked for.
Changes:
- system_views.go: InboxSystemView + InboxRequesterSystemView render
spec gains RowAction=approve so shape-list.ts knows which row
layout to stamp (entity title + diff + approve/reject/revoke).
- shape-list.ts: row_action='approve' branch — stamps the inbox-row
markup the surface owned today; surface attaches click handlers
via data-attrs on .views-approval-action / .views-approval-row.
- inbox.tsx: tab row replaced with <div id='inbox-filter-bar'> +
<div id='inbox-results'>. Heading + admin nudge unchanged.
- client/inbox.ts: shrunk to mountFilterBar with axes [time,
approval_viewer_role, approval_status, approval_entity_type,
density, sort]. Action handlers run via fetch + bar.refresh().
Legacy ?tab=mine -> ?a_role=self_requested redirect on mount so
bookmarks / sidebar bell still land on the right sub-view.
Build clean: bun run build + go build/vet/test all pass.
Add paliad.deadline_concept_event_types junction (mig 072) mapping each
deadline_concept to its canonical paliad.event_types row(s). Hydrate
DeadlineRule.ConceptDefaultEventTypeID via one IN query per List call so
/api/deadline-rules carries the autofill hint for the deadline create
form (t-paliad-165 / m/paliad#18).
Seed mapping covers the active concepts driving existing rules — 29
rows across 26 distinct concepts. Concepts without an obvious event_type
counterpart (decision, filing, grant, the DE-only Begründung family)
stay unmapped; auto-fill silently skips them.
m's 2026-05-08 21:42 dogfood feedback on the Determinator perspective
chip: when an Akte is selected, the chip should be locked to the firm's
known side instead of asking the user to re-pick. paliad didn't track
that anywhere — paliad.parties.role records each party's role but no
flag for "this is the side we represent".
Migration 072 adds paliad.projects.our_side text with a CHECK
constraint (claimant | defendant | court | both | NULL). NULL stays the
default so existing rows are neutral and the Determinator falls back to
free-pick. Idempotent (ADD COLUMN IF NOT EXISTS + DO-block guarded
constraint) so a re-run against a partially-applied state is safe —
paliad has been bitten by collision twice this week.
Project model + ProjectService:
- OurSide *string field on models.Project
- CreateProjectInput / UpdateProjectInput accept our_side
- INSERT and partial UPDATE thread the value through; validateOurSide
rejects unknown enum values with ErrInvalidInput before the DB
constraint would; nullableOurSide turns "" into NULL so the form's
"unset" sentinel can clear the column
- Update logs an our_side_changed audit event with "<from> → <to>"
description (matching status_changed / project_type_changed
shape); both ends use the literal "none" sentinel for NULL so the
frontend renderer can map it to projects.field.our_side.none
i18n: event.title.our_side_changed (DE/EN), dashboard.action.short
verb form, projects.field.our_side.{label,hint,unset,claimant,
defendant,court,both,none} for the upcoming Slice 2 select.
Frontend translateEventDescription gets an our_side_changed branch
that runs translateArrowSlugs over the projects.field.our_side.*
prefix so the Verlauf tab renders localized labels.
Slice 2 wires the form, Slice 3 wires the Determinator.
Schema bump that lets the universal <FilterBar> tell shape-list which
row interaction to wire (navigate / complete_toggle / approve / none).
Defaults to navigate when empty so existing SystemView definitions and
saved user views continue to render rows that route to the per-kind
detail page.
Validator extended; pure-Go test cases over every enum value + reject.
TS mirror updated in client/views/types.ts. No DB migration — the
field is purely additive on the JSON shape.
When a user's tmux session dies (mRiver reboot, OOM, manual kill,
container restart) the next turn used to wake claude with NO prior
context — the persona had to derive everything from the new turn
alone. Now: when the Go side detects a fresh pane, it pulls the last
N exchanges from paliad.paliadin_turns and prepends them as a
[primer …][/primer] block to the next user envelope.
Format SKILL.md parses (single-line, control-chars stripped):
[PALIADIN:<turn_id>] [primer last=N] U: … \n A: … \n … [/primer] [ctx …] <Frage>
Detection paths:
- Local (LocalPaliadinService): ensurePane now returns
(target, isFresh, err). isFresh is true when no prior
@paliadin-scope=chat window existed and we created one. RunTurn
passes that into buildPrimerIfFresh.
- Remote (RemotePaliadinService): can't see across the SSH boundary
to know the pane's true freshness, so we approximate with a
per-(session, Go-process) "primed" cache. First turn after
process-start, ResetSession, or healthGate failure rebuilds the
primer; subsequent turns skip it. ResetSession + healthGate failure
both call clearPrimed(session) explicitly.
paliadinDB.buildPrimerIfFresh assembles the block:
- Reads the last MaxPrimerTurns=5 exchanges from
ListHistoryForSession (Slice F).
- truncateForPrimer normalises each side (drops \r\n, collapses
whitespace, caps at MaxPrimerCharsPerSide=600 with …).
- Returns "" silently when isFresh=false, no SessionID, no prior
history, or DB error — the user's actual question still lands; we
only lose the recap.
SKILL.md (~/.claude/skills/paliadin/SKILL.md, refreshed via
scripts/install-paliadin-skill) gets a new "Crash-recovery primer"
section above the context-envelope block. Five behaviour rules:
1. Don't re-execute prior tool calls (audit log already has them).
2. Use the primer for thread continuity, not as a data source.
Re-call tools for fresh facts.
3. Truncated lines (ending in …) are partial — paraphrase rather
than quote.
4. No primer at all = normal case (existing pane, history is in
tmux memory). Behave as before.
5. Acknowledge sparingly — usually just answer the actual question
with the recap as silent context.
New test TestTruncateForPrimer pins the per-side truncation contract
(no \r\n leaks, repeated spaces collapsed, ellipsis on oversized
input, short input untouched). go test green.
Refs: docs/design-paliadin-inline-2026-05-08.md §6
(deferred Anthropic API cutover prereq).
Two Paliadin chat surfaces shared a user but not their conversation:
the inline drawer (paliadin-widget.ts) maintained `paliadin:widget:session`
+ `paliadin:widget:history:` while the standalone /paliadin page used
`paliadin:session` + `paliadin:history:`. A turn typed in the drawer
never surfaced on /paliadin and vice versa, and a localStorage wipe
tossed everything.
Fix in three coordinated parts:
1. **Shared session id.** The widget now uses the same `paliadin:session`
key the standalone page already uses. One-time migration in
bootSession copies any legacy `paliadin:widget:session` across so
existing users keep their conversation thread, then deletes the legacy
key. The widget's HISTORY_PREFIX also drops the `widget:` namespace
so both surfaces' render-caches address the same bucket.
2. **DB-driven history.** New endpoint:
GET /api/paliadin/history?session=<id>&limit=<N>
Returns the caller's turns for the session, oldest → newest,
gated by PaliadinOwnerEmail (same gate as POST /api/paliadin/turn).
Backed by paliadinDB.ListHistoryForSession, which mirrors the
existing visibility predicate (own rows always; all rows for
global_admin). Default limit 50, capped at 200.
3. **Hydrate-on-mount, hydrate-on-open.**
- paliadin.ts (standalone page): DOMContentLoaded calls
hydrateFromServer() right after renderHistory() seeds from
localStorage. DB rows replace the cache when present.
- paliadin-widget.ts (inline drawer): revealIfOwner kicks
hydrateFromServer in the background after rehydrateHistory paints
the cache. openDrawer() also calls hydrateFromServer so a turn the
user typed on /paliadin since the last drawer-open shows up
without a manual reload.
Reconciliation: DB > localStorage when DB has rows. DB call fails or
returns empty → keep showing whatever's in cache (offline cushion).
This kills the trap klaus warned about (paliad#19): every render
reconciles against the server, no first-paint short-circuits.
Schema: zero migrations. paliad.paliadin_turns already carries
session_id + user_message + response + ts since the t-paliad-146 PoC;
this slice just adds a typed read path.
Backwards compatible: the standalone /paliadin page's session key is
unchanged; only the widget migrates onto it.
Builds + tests green; i18n unchanged.
Refs: m/paliad#19 (localStorage short-circuit), m/paliad#20 (inline modal),
docs/design-paliadin-inline-2026-05-08.md §3.4.
m's dogfood 2026-05-08 21:16: project card for "UPC-CoA Berufung Huawei"
showed "4 offen" but "Nächste Termine — keine bevorstehenden Termine"
even though the four pending deadlines exist with future due dates.
Live container log:
ERROR service: cards preview appointments:
pq: column t.starts_at does not exist at position 13:41 (42703)
The cards-preview appointments query used `t.starts_at`; the actual
column on paliad.appointments is `start_at` (singular). The query
errored, CardsPreview returned (nil, error), the handler returned a
500, and the frontend's `r.ok ? r.json() : []` fell through to an
empty preview map for every project — so deadlines that the deadline
half of the same function had already loaded never reached the card.
"4 offen" stayed visible because that count comes from BuildTreeWith-
Options, a separate query untouched by the bug.
Fix: rename starts_at → start_at in the rowAppointment db tag, the
ORDER BY, the WHERE clause, and the SELECT projection. StartsAt as
the Go field name stays — only the db tag + SQL identifiers change.
Same column name everywhere else in the codebase already used start_at.
m's dogfood 2026-05-08 20:35: a deadline showed an approval-pending
banner on its detail page but did not appear in /inbox under either
"Zur Genehmigung" or "Meine Anfragen". Live container log:
ERROR service: list submitted by user: sql: Scan error on column
index 5, name "pre_image": unsupported Scan, storing driver.Value
type <nil> into type *json.RawMessage
Root cause: paliad.approval_requests.pre_image is NULL whenever the
lifecycle_event is 'create' (no prior row state to capture). The Go
ApprovalRequest struct binds it as json.RawMessage, which is a []byte
typedef that does NOT implement sql.Scanner — sqlx fell back to
*json.RawMessage and choked on the NULL. Same hazard on .payload for
'complete' / 'delete' rows where there's no payload either.
The handler returned the resulting error as a 500, the inbox.ts catch
swallowed it as a network failure, and rendered the empty state. Both
tabs were dark because both list paths hit the same scan.
Fix: introduce models.NullableJSON, a []byte typedef that implements
sql.Scanner / driver.Valuer / json.Marshaler / json.Unmarshaler and
treats NULL ↔ nil cleanly. Inline JSON output is preserved (no base64
cast that bare []byte would have caused). Bind PreImage + Payload to
NullableJSON; existing call-sites (approval_service.go:606) keep
working — both json.RawMessage and NullableJSON are []byte under the
hood, and len() / json.Unmarshal accept either.
Other nullable jsonb columns (User.EmailPreferences, *Metadata) are
all NOT NULL with default '{}' so they don't hit the same path; left
as json.RawMessage.
Verified: live tracker is at v71, no schema change needed; approval
service tests green; /api/inbox/mine query against prod returns the
three expected rows for m once the binary picks this up.
When Claude writes the response file after the 60 s pollForResponse
window expires (e.g. the tmux pane was busy mid-turn when the message
arrived), the SSE stream has already closed with an error and the
file sits unread on disk forever. The chat shows a permanent timeout
even though the answer exists.
Backend:
- LocalPaliadinService.StartJanitor: scans responseDir every 2 s and
patches rows whose response is still NULL when the file lands.
completeTurnLate stamps error_code='late' so the FE can render a
marker. Guarded with WHERE response IS NULL to never overwrite a
real response if RunTurn races.
- Paliadin.GetTurn(callerID, turnID) on the shared paliadinDB. Same
visibility predicate as ListRecentTurns.
- GET /api/paliadin/turns/{id} — owner-gated; lets the chat UI
discover late-arrived responses without a refresh.
Frontend:
- paliadin-late-poll.ts: shared 3 s / 10 min poller.
- paliadin.ts + paliadin-widget.ts: on SSE error, show
"wartet auf späte Antwort", kick off the poller, swap bubble in
place when response arrives + retroactively persist to history.
- i18n: paliadin.late.waiting + paliadin.late.marker (DE/EN).
- CSS: --late-pending opacity tweak, --late neutral background,
italic-grey "verspätet" tag.
m's dogfood 2026-05-08 20:35: "the paliadin hook does not always work — it
does not confirm the claude / terminal command... like lacking an enter
key. Or too fast."
Race between two consecutive tmux send-keys calls: the first writes the
prompt literally with `-l`; the second sends an Enter key event. Claude
Code's TUI debounces keyboard input. When the Enter lands while the
paste is still being absorbed, the carriage-return collapses into the
input buffer as a literal newline character instead of registering as a
"submit" gesture — the prompt sits typed but unsubmitted, and the
backend's pollForResponse then times out on the missing response file.
Fix: sleep 200ms between the literal paste and the Enter. Below the
human-perceptible threshold but well above tmux's pty flush window and
the TUI's input-debounce window. Applied to both code paths:
- scripts/paliadin-shim:send_to_pane (the SSH/RPC production path)
- internal/services/paliadin.go:LocalPaliadinService.sendToPane
(the laptop-only direct-tmux path)
The Go-side variant uses a context-aware sleep so request cancellation
still propagates correctly.
Production shim copy at /home/m/.local/bin/paliadin-shim refreshed
locally on mRiver so the next turn picks up the fix without waiting
for redeploy. (The Dokploy container does not run paliadin — gate on
PaliadinOwnerEmail is owner-only and prod has no claude+tmux anyway —
so no deploy step required for the shim path.)
m's 2026-05-08 18:09 spec — Slice 3c. Adds a Klägerseite / Beklagtenseite
chip strip at the top of the B1 cascade panel; cascade leaves tagged
with a contradictory party get hidden. Klägerseite never files
Klageerwiderung; Beklagtenseite never files Klageschrift.
Migration 071 adds `paliad.event_categories.party text[]` (CHECK on
{claimant, defendant, both, court}) plus a partial GIN index. Backfill
is conservative — only the obvious leaves get tagged on this pass:
- claimant ich-moechte-einreichen.klage.* (9 leaves)
ich-moechte-einreichen.spaetere-schriftsaetze.replik-*
- defendant ich-moechte-einreichen.widerklage.*
ich-moechte-einreichen.spaetere-schriftsaetze.duplik-*
cms-eingang.* (incoming) and frist-verpasst.* (anyone misses a
deadline) stay NULL because the user can be on either side and still
receive the same court communication. Cross-appeal / Anschluss-
berufung / Reply-to-cross-appeal also stay NULL — the role flips
depending on who appealed first; the cascade doesn't have that
context yet. Tag in a follow-up once dogfood validates the chip.
Backend: EventCategoryNode JSON gains optional `party` array;
EventCategoryService.Tree SELECT picks it up via pq.StringArray.
Frontend: new Perspective type + URL state (?role=claimant|defendant)
+ perspective chip strip styled identically to the inbox-channel chip
strip. perspectiveAllowsParty(party) gates each cascade child;
"both"/"court" tagged nodes always pass; neutral nodes always pass.
Persistence is URL-only — dogfood will tell us whether to add a saved
default later.
Migration applied to live Supabase; tracker at v71.
Refs t-paliad-157 / m/paliad#15.
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 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.
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 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 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).
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).
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.
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.
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.
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.
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.
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.
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.
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).
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.
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.